great-cto 3.16.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "3.16.0",
5
+ "version": "3.17.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -0,0 +1,9 @@
1
+ #!/bin/bash
2
+ # Test fixture: a `bd` stand-in that always exits non-zero, for testing the
3
+ # failure path of warmTasksAsync (packages/board/beads-warm-async.test.mjs).
4
+ if [ "$1" = "--version" ]; then
5
+ echo "bd-fake 0.0.0"
6
+ exit 0
7
+ fi
8
+ echo "dolt: database is locked" >&2
9
+ exit 1
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+ # Test fixture: a `bd` stand-in that sleeps before answering, so the
3
+ # boot-responsiveness tests can prove the board stays answerable DURING a
4
+ # slow `bd list` — not just that the code calls the right function. Loaded
5
+ # only via GREAT_CTO_BD_BIN in packages/board/*.test.mjs; never used outside
6
+ # tests.
7
+ if [ "$1" = "--version" ]; then
8
+ echo "bd-fake 0.0.0"
9
+ exit 0
10
+ fi
11
+ sleep "${FAKE_BD_DELAY_SECS:-3}"
12
+ echo "[]"
@@ -12,14 +12,20 @@ import { listProjects, readProjectMd } from './projects.mjs';
12
12
  import { addNotification } from './notifications.mjs';
13
13
  import { getMetrics } from './metrics.mjs';
14
14
  import { getCostHistory, getInbox } from './data-readers.mjs';
15
- // Every sweep below runs over EVERY registered project, and `bd list` is
16
- // spawnSync the event loop is held for the whole of it. At 16 projects and
17
- // 2-6 s each that is ~60 s per sweep, and three of these fire every five
18
- // minutes. Sweeps therefore read at sweep freshness, not interactive freshness:
19
- // see SWEEP_MAX_AGE_MS in beads.mjs for the arithmetic that made the board
20
- // unanswerable while reporting "live · synced just now".
15
+ // Every sweep below runs over EVERY registered project. `sweep: true` opts a
16
+ // COLD project (never read this process) into bdList()'s non-blocking fill
17
+ // path instead of its synchronous one the same escape hatch a WARM-but-
18
+ // stale entry already gets. Without it: a real board's first cron tick
19
+ // (t=+5:10, no request in flight) lagged the event loop 9.8s reading a
20
+ // single project synchronously; at 16 projects and 2-6s each that is ~60s
21
+ // per sweep, three of which fire every five minutes, indefinitely. Sweeps
22
+ // read at sweep freshness, not interactive freshness — see SWEEP_MAX_AGE_MS
23
+ // in beads.mjs for the arithmetic that made the board unanswerable while
24
+ // reporting "live · synced just now". `sweep: true` must NEVER be added to
25
+ // an interactive read's opts: an ordinary request is someone waiting on
26
+ // THIS project's answer, and the fill path answers `[]` instead.
21
27
  import { getTasks, SWEEP_MAX_AGE_MS } from './beads.mjs';
22
- const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS };
28
+ const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS, sweep: true };
23
29
  import { readVerdicts } from './verdicts.mjs';
24
30
  import { isFailure } from './fleet.mjs';
25
31
  import { getShareState, toggleShare } from './share.mjs';
@@ -38,6 +38,15 @@ import { log } from './log.mjs';
38
38
  // because each request arrived just after the entry expired.
39
39
  const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
40
40
 
41
+ /**
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.
47
+ */
48
+ const lastInvalidatedAt = new Map();
49
+
41
50
  /**
42
51
  * Drop a directory's entry so the next read is guaranteed fresh.
43
52
  *
@@ -47,6 +56,7 @@ const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
47
56
  */
48
57
  function bdCacheInvalidate(cwd) {
49
58
  clearSelfTouch(cwd);
59
+ lastInvalidatedAt.set(cwd, Date.now());
50
60
  bdCache.delete(cwd);
51
61
  }
52
62
 
@@ -63,6 +73,7 @@ function bdCacheInvalidate(cwd) {
63
73
  */
64
74
  function bdCacheStale(cwd) {
65
75
  clearSelfTouch(cwd);
76
+ lastInvalidatedAt.set(cwd, Date.now());
66
77
  const cached = bdCache.get(cwd);
67
78
  if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
68
79
  }
@@ -290,13 +301,16 @@ const refreshing = new Set();
290
301
  * measured at 1-10 s because it was queued behind a task read. Warming at boot
291
302
  * moved the first stall out of sight; this removes the rest.
292
303
  *
293
- * Nothing awaits it. It exists to make the NEXT read fast, and a caller that
294
- * needed the new data would have had to block for it anyway.
304
+ * `onDone`, if given, fires exactly once at every exit point (success,
305
+ * failure, or "already refreshing"). Every existing caller omits it and stays
306
+ * fire-and-forget — the ONE caller that wants to know when the fill finished
307
+ * is the boot warm-up (see `warmTasksAsync`), which logs how long it took.
295
308
  */
296
- function bdRefreshAsync(cwd) {
297
- if (refreshing.has(cwd)) return;
309
+ function bdRefreshAsync(cwd, onDone = () => {}) {
310
+ if (refreshing.has(cwd)) { onDone({ ok: false, skipped: true }); return; }
298
311
  refreshing.add(cwd);
299
- lastBdRunAt.set(cwd, Date.now());
312
+ const startedAt = Date.now();
313
+ lastBdRunAt.set(cwd, startedAt);
300
314
  let out = '';
301
315
  try {
302
316
  const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv() });
@@ -304,31 +318,87 @@ function bdRefreshAsync(cwd) {
304
318
  child.on('error', (e) => {
305
319
  refreshing.delete(cwd);
306
320
  bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
321
+ onDone({ ok: false });
307
322
  });
308
323
  child.on('close', (code) => {
309
324
  refreshing.delete(cwd);
310
325
  lastBdRunAt.set(cwd, Date.now());
311
- if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); return; }
326
+ if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); onDone({ ok: false }); return; }
312
327
  try {
313
328
  const parsed = JSON.parse(out || '[]');
314
329
  // Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
315
330
  // and a non-array rendered as no tasks is the silent zero again.
316
331
  if (!Array.isArray(parsed)) {
317
332
  bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
333
+ onDone({ ok: false });
334
+ return;
335
+ }
336
+ // This fetch answers "what was true at `startedAt`?" — if anyone has
337
+ // since told the cache it's wrong (a write's invalidate/stale-mark)
338
+ // or already written something newer (a synchronous cold read that
339
+ // raced us and won), our answer is superseded. Committing it now
340
+ // would silently resurrect data from before a change a caller is
341
+ // already waiting to see reflected — exactly what made
342
+ // `tests/pipeline-e2e.test.mjs`'s gate-approval assertions flake once
343
+ // this function started running from a COLD cache (the boot warm-up)
344
+ // instead of only ever refreshing an already-populated one.
345
+ const supersededByInvalidation = (lastInvalidatedAt.get(cwd) || 0) >= startedAt;
346
+ const supersededByFresherEntry = (bdCache.get(cwd)?.ts || 0) >= startedAt;
347
+ if (supersededByInvalidation || supersededByFresherEntry) {
348
+ onDone({ ok: true, count: parsed.length, discarded: true });
318
349
  return;
319
350
  }
320
351
  bdFailures.delete(cwd);
321
352
  bdCache.set(cwd, { ts: Date.now(), data: parsed });
353
+ onDone({ ok: true, count: parsed.length });
322
354
  } catch (e) {
323
355
  bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
356
+ onDone({ ok: false });
324
357
  }
325
358
  });
326
359
  } catch (e) {
327
360
  refreshing.delete(cwd);
328
361
  bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
362
+ onDone({ ok: false });
329
363
  }
330
364
  }
331
365
 
366
+ /**
367
+ * Warm a directory's task cache at boot, WITHOUT blocking the event loop.
368
+ *
369
+ * The boot warm-up used to call `getTasks(cwd)` directly — `bdList()`'s
370
+ * COLD-cache path, which is `bd()`'s synchronous `spawnSync`, because a cold
371
+ * cache has nothing to serve while a background refresh runs, so it has
372
+ * always been the one path in this file that blocks. That's the right call
373
+ * for a REQUEST (something is waiting and there's no stale data to fall back
374
+ * on); it was the wrong call at boot, because a boot warm-up has nobody
375
+ * waiting on it — its only job is to be ready before someone asks.
376
+ *
377
+ * Measured with an artificially slow `bd` (GREAT_CTO_BD_BIN pointed at a
378
+ * fixture that sleeps N seconds before answering): the board bound its port,
379
+ * then answered `000` on EVERY endpoint — including /api/version, a plain
380
+ * readdirSync with nothing to do with bd — for the fixture's entire delay.
381
+ * The port being open did not matter; the single process serving it was
382
+ * blocked inside `spawnSync` the whole time. `bd()`'s own 8s spawnSync
383
+ * timeout capped that run at ~8s; a real cold Dolt store or lock contention
384
+ * has no such cap, which is consistent with the up-to-2-minute stalls
385
+ * reported in production.
386
+ *
387
+ * This calls `bdRefreshAsync` — the SAME `spawn`-based, non-blocking path
388
+ * already used for warm-cache "stale-while-revalidate" reads — for the cold
389
+ * boot case too. The returned Promise exists only so the boot log can report
390
+ * how long the fill took; nothing waits on it, and the server is accepting
391
+ * and answering connections for the entire duration either way.
392
+ *
393
+ * @returns {Promise<{ok: boolean, count?: number, skipped?: boolean, ms: number}>}
394
+ */
395
+ function warmTasksAsync(cwd) {
396
+ const t0 = Date.now();
397
+ return new Promise((resolve) => {
398
+ bdRefreshAsync(cwd, (result) => resolve({ ...result, ms: Date.now() - t0 }));
399
+ });
400
+ }
401
+
332
402
  function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
333
403
  const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
334
404
  const cached = bdCache.get(cwd);
@@ -338,14 +408,7 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
338
408
  if (cached && Date.now() - cached.ts < ttl) return cached.data;
339
409
 
340
410
  // Stale-while-revalidate. An entry that exists is served immediately, however
341
- // old, and refreshed off the event loop. Only a directory with NO entry at all
342
- // blocks — which after the boot warm-up means a project nobody has opened yet,
343
- // once.
344
- //
345
- // The alternative was making this async and every caller with it: getTasks,
346
- // getInbox, getPipeline, the metrics readers, the SSE broadcast, the alert
347
- // sweeps. That refactor is the correct end state and is not what a board
348
- // hanging today needs.
411
+ // old, and refreshed off the event loop.
349
412
  //
350
413
  // The injected `runner` is how the tests drive this. When one is supplied the
351
414
  // sync path is kept, so a test that stubs `bd` still observes the call it
@@ -354,6 +417,32 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
354
417
  bdRefreshAsync(cwd);
355
418
  return cached.data;
356
419
  }
420
+
421
+ // A directory with NO entry at all — genuinely never read. Left
422
+ // synchronous for an ordinary caller: a request FOR this project is
423
+ // waiting on the answer, and returning `[]` while a background fill
424
+ // catches up would be wrong data handed to a caller who cannot tell it
425
+ // apart from "no tasks" — confirmed the hard way. This branch originally
426
+ // covered every caller, and tests/board-gate.test.mjs,
427
+ // tests/pipeline-e2e.test.mjs and tests/resume-e2e.test.mjs all went red:
428
+ // a gate approved seconds after project creation read back as "no gates",
429
+ // because the read that should have shown it took the async path and
430
+ // answered empty instead.
431
+ //
432
+ // `opts.sweep` is the one caller allowed to skip the wait: the alert cron,
433
+ // which already has its own looser freshness contract (SWEEP_MAX_AGE_MS)
434
+ // and iterates EVERY registered project on a timer nobody is blocking on.
435
+ // Measured live, unprompted: a real board's first cron tick (t=+5:10, no
436
+ // request in flight) lagged the event loop 9.8s doing exactly this
437
+ // synchronous cold read, once per registered project, every five minutes,
438
+ // indefinitely. For that caller specifically, the same non-blocking fill
439
+ // stale-while-revalidate already uses for a WARM entry is safe on a COLD
440
+ // one too — the cron does not need this tick's answer to be correct, only
441
+ // bounded in staleness, which the background fill still gives it.
442
+ if (!cached && runner === bd && opts.sweep) {
443
+ bdRefreshAsync(cwd);
444
+ return [];
445
+ }
357
446
  try {
358
447
  lastBdRunAt.set(cwd, Date.now());
359
448
  const result = runner(['list', '--json', '--all', '--include-gates'], { cwd });
@@ -743,6 +832,7 @@ export {
743
832
  checkBeadsAvailable,
744
833
  bdWriteSerialised,
745
834
  bdList,
835
+ warmTasksAsync,
746
836
  bdFailureFor,
747
837
  parseTasksMd,
748
838
  getReadDegradation,
@@ -109,6 +109,16 @@
109
109
  }
110
110
  /* Light theme — same emerald system, AA-contrast on white. Toggle via data-theme. */
111
111
  [data-theme="light"] {
112
+ /* Four values that had never been measured.
113
+ The contrast audit read `:root` and nothing else, so this theme — 39 token
114
+ overrides, half the board's surface area — was unchecked for months. When it
115
+ was finally measured against `--bg-strong`, the hardest surface here:
116
+ --accent 1.51:1 against a 3:1 floor for UI boundaries. Half.
117
+ --accent-text 4.17, --p1-fg 4.04, --p2-fg 4.09 against a 4.5 floor.
118
+ `--accent` was simply the dark theme's green, which is designed to sit on
119
+ #0a0e0c. Each value below is the same hue darkened until it clears its floor,
120
+ computed rather than eyeballed. */
121
+
112
122
  --bg-page: #eef2f0;
113
123
  --bg-panel: rgba(255, 255, 255, 0.9);
114
124
  --bg-card: #ffffff;
@@ -121,11 +131,11 @@
121
131
  --text2: #56655e;
122
132
  --text3: #55625b;
123
133
  --focus-ring: #047857;
124
- --accent: #00d97e;
134
+ --accent: #009657;
125
135
  --accent-2: #0a7d4d;
126
136
  --accent-soft: rgba(0, 217, 126, 0.12);
127
137
  --accent-strong: rgba(0, 168, 98, 0.20);
128
- --accent-text: #0a7d4d;
138
+ --accent-text: #097648;
129
139
  --status-backlog: #6b7280;
130
140
  --status-todo: #6b7280;
131
141
  --status-progress: #b45309;
@@ -134,8 +144,8 @@
134
144
  --status-blocked: #c43030;
135
145
  --status-gate: #7c3aed;
136
146
  --p0-bg: rgba(196, 48, 48, 0.12); --p0-fg: #b91c1c;
137
- --p1-bg: rgba(180, 83, 9, 0.12); --p1-fg: #b45309;
138
- --p2-bg: rgba(146, 102, 10, 0.12); --p2-fg: #92660a;
147
+ --p1-bg: rgba(180, 83, 9, 0.12); --p1-fg: #a64c08;
148
+ --p2-bg: rgba(146, 102, 10, 0.12); --p2-fg: #885f09;
139
149
  --p3-bg: rgba(0, 0, 0, 0.05); --p3-fg: #5c6b65;
140
150
  --surface-drawer: #ffffff;
141
151
  --surface-translucent: rgba(255, 255, 255, 0.72);
@@ -1389,7 +1399,16 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1389
1399
  background: var(--text3); flex-shrink: 0;
1390
1400
  }
1391
1401
  .pl-stage.idle .pl-name .dot { background: var(--text3); }
1392
- .pl-stage.active .pl-name .dot { background: var(--status-progress); animation: pl-pulse 1.4s infinite; }
1402
+ /* Motion means the data is live.
1403
+
1404
+ A pulsing dot says "this is happening right now". Three stages pulsed on a
1405
+ rail whose newest timestamp was 260 hours old — the strongest liveness signal
1406
+ the design has, spent on work that stopped eleven days ago. The animation is
1407
+ now gated on `.fresh`, which the renderer adds only when the stage's own
1408
+ timestamp is inside the hour. An `active` stage that has not moved keeps its
1409
+ colour and loses its heartbeat. */
1410
+ .pl-stage.active .pl-name .dot { background: var(--status-progress); }
1411
+ .pl-stage.active.fresh .pl-name .dot { animation: pl-pulse 1.4s infinite; }
1393
1412
  .pl-stage.done .pl-name .dot { background: var(--status-review); }
1394
1413
  .pl-stage.failed .pl-name .dot { background: var(--status-blocked); }
1395
1414
  @keyframes pl-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); } 50% { box-shadow: 0 0 0 5px rgba(245, 158, 11, 0); } }
@@ -1471,7 +1490,19 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1471
1490
  border-radius: 8px;
1472
1491
  display: inline-flex; align-items: center; justify-content: center;
1473
1492
  }
1474
- .pl-gate.active .pl-name .gate-glyph { animation: pl-pulse 1.4s infinite; }
1493
+ .pl-gate.active.fresh .pl-name .gate-glyph { animation: pl-pulse 1.4s infinite; }
1494
+
1495
+ /* Reduced motion, declared AFTER the pulse rules on purpose.
1496
+
1497
+ It was first written into the reduced-motion block near the top of this file,
1498
+ where `css-cascade` immediately reported it: same specificity as the two rules
1499
+ above, and the later rule wins, so `animation: none` never applied and a reader
1500
+ who had asked for less movement still got a pulsing dot. The check exists for
1501
+ exactly this, and it caught it within the minute. */
1502
+ @media (prefers-reduced-motion: reduce) {
1503
+ .pl-stage.active.fresh .pl-name .dot,
1504
+ .pl-gate.active.fresh .pl-name .gate-glyph { animation: none; }
1505
+ }
1475
1506
  /* The gate with nothing pending is a node like any other idle stage — the tinted
1476
1507
  card is reserved for the state that actually holds the pipeline, so a clear
1477
1508
  gate stops out-shouting the stages that ran. */
@@ -1657,6 +1688,79 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1657
1688
  }
1658
1689
 
1659
1690
  /* ── Resume card ──────────────────────────────────────────────────────── */
1691
+ /* Project status — one disclosure for everything the operator cannot act on. */
1692
+ /* Focus, visible against every surface it can land on.
1693
+
1694
+ Queue rows became keyboard-reachable and the buttons on them are real buttons,
1695
+ but nothing drew a ring — so a keyboard user could reach a row and not see
1696
+ where they were. `outline-offset: 2px` is not decoration: `.gate-approve` is a
1697
+ filled green button, and a ring drawn ON that fill disappears into it. Offset
1698
+ puts the ring on the surface behind, where it keeps its contrast. */
1699
+ .inbox-row:focus-visible,
1700
+ .gate-btn:focus-visible,
1701
+ .ac-btn:focus-visible,
1702
+ .ac-primary:focus-visible {
1703
+ outline: 2px solid var(--focus-ring);
1704
+ outline-offset: 2px;
1705
+ border-radius: 8px;
1706
+ }
1707
+
1708
+ /* Reason chip — why a row is in the queue, carried by the row itself. */
1709
+ .why {
1710
+ display: inline-block;
1711
+ font-family: var(--mono);
1712
+ font-size: var(--fs-eyebrow);
1713
+ letter-spacing: 0.04em;
1714
+ padding: 1px 7px;
1715
+ margin-right: 8px;
1716
+ border-radius: 5px;
1717
+ border: 1px solid var(--border-strong);
1718
+ color: var(--text2);
1719
+ vertical-align: 1px;
1720
+ }
1721
+ .why-gate { color: var(--status-gate); border-color: var(--status-gate); }
1722
+ .why-p0 { color: var(--status-blocked); border-color: var(--status-blocked); }
1723
+ .why-blocked { color: var(--status-progress); border-color: var(--status-progress); }
1724
+ .why-stale { color: var(--text3); }
1725
+
1726
+ .proj-status {
1727
+ border: 1px solid var(--border);
1728
+ border-radius: 12px;
1729
+ background: var(--bg-card);
1730
+ margin: 10px 0 18px;
1731
+ }
1732
+ .proj-status-summary {
1733
+ cursor: pointer;
1734
+ padding: 11px 16px;
1735
+ font-size: var(--fs-small);
1736
+ color: var(--text2);
1737
+ list-style: none;
1738
+ display: flex; align-items: center; gap: 8px;
1739
+ }
1740
+ .proj-status-summary::-webkit-details-marker { display: none; }
1741
+ .proj-status-summary::before {
1742
+ content: "\25B8";
1743
+ color: var(--text3);
1744
+ transition: transform 0.12s;
1745
+ }
1746
+ .proj-status[open] .proj-status-summary::before { transform: rotate(90deg); }
1747
+ .proj-status-summary:hover { color: var(--text); }
1748
+ .proj-status-summary:focus-visible {
1749
+ outline: 2px solid var(--focus-ring);
1750
+ outline-offset: 2px;
1751
+ border-radius: 8px;
1752
+ }
1753
+ /* A link with no colour rule gets the browser's default, which is on nobody's
1754
+ palette. This one — "stale, listed below" in the empty resume column — was
1755
+ added today without one, and the rendered-layout check found it on both themes
1756
+ within the hour. That is the check working on its author. */
1757
+ .resume-list a, .empty a { color: var(--accent-text); text-decoration: underline; }
1758
+ .resume-list a:hover, .empty a:hover { color: var(--accent); }
1759
+
1760
+ .proj-status-verdict { color: var(--text3); font-family: var(--mono); font-size: var(--fs-eyebrow); }
1761
+ .proj-status-body { padding: 0 16px 14px; }
1762
+ .proj-status-body .inbox-section { margin-top: 6px; }
1763
+
1660
1764
  .resume-card {
1661
1765
  background: linear-gradient(135deg, rgba(0, 217, 126, 0.04), var(--surface-translucent));
1662
1766
  border: 1px solid rgba(0, 217, 126, 0.18);
@@ -2022,7 +2126,11 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2022
2126
  way, the reason is available on hover and to a screen reader. Sized down from
2023
2127
  whatever it sits in, so a missing hero figure does not shout at 52px. */
2024
2128
  .absent {
2025
- font-family: var(--mono); font-size: 0.55em;
2129
+ /* `0.55em` is a size relative to whatever it lands inside. Next to a 52px
2130
+ display numeral that resolves to 28.6px — a step the ramp does not have, and
2131
+ the rendered-layout check flagged it on two screens. The "n/a" beside a
2132
+ figure is a caption; it is sized like one. */
2133
+ font-family: var(--mono); font-size: var(--fs-caption);
2026
2134
  color: var(--text3); letter-spacing: 0.04em;
2027
2135
  cursor: help; border-bottom: 1px dotted var(--border-strong);
2028
2136
  }
@@ -2912,14 +3020,26 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2912
3020
  <div class="panel active" id="panel-inbox">
2913
3021
  <div class="inbox-page">
2914
3022
  <div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
2915
- <div class="inbox-summary" id="inbox-summary"></div>
2916
3023
  <!-- Top rung of the evidence ladder: are the bytes that were reviewed
2917
3024
  still the bytes in the tree? Hidden while they match — and now only
2918
3025
  used for `differs`, the one reading that needs a decision. The other
2919
3026
  two speaking states render into #inbox-receipt-foot at the bottom of
2920
3027
  this page (see refreshReceipt). -->
2921
3028
  <div id="inbox-receipt" style="display:none;margin:6px 0 2px;font-size:var(--fs-small)"></div>
2922
-
3029
+ <!-- Everything below is STATUS, and status is not a decision.
3030
+
3031
+ Six of the seven blocks on this screen were read-only: a pipeline rail,
3032
+ a resume card, two advisory notices, a strip of stat tiles. They sat
3033
+ ABOVE the one item the operator could act on, under a headline promising
3034
+ decisions. The rule the design spec states — a block earns the top of a
3035
+ screen only if the operator can act on it now — puts all of it here,
3036
+ one disclosure, closed by default.
3037
+
3038
+ Not deleted. "How is the project" is a real question; it is simply not
3039
+ the question this screen is named after. -->
3040
+ <details class="proj-status" id="proj-status">
3041
+ <summary class="proj-status-summary">Project status<span class="proj-status-verdict" id="proj-status-verdict"></span></summary>
3042
+ <div class="proj-status-body">
2923
3043
  <!-- Resume — pick up where you left off -->
2924
3044
  <div class="resume-card" id="resume-card" style="display:none">
2925
3045
  <div class="resume-head">
@@ -2944,6 +3064,20 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2944
3064
  </div>
2945
3065
  </div>
2946
3066
  </div>
3067
+ <div id="tool-failure-rate" style="display:none;font-size:var(--fs-small);color:var(--status-blocked,#f87171);background:rgba(248,113,113,0.08);padding:6px 10px;border-radius:6px;margin-bottom:10px;"></div>
3068
+
3069
+ <div class="inbox-section">
3070
+ <div class="inbox-section-head">
3071
+ <span class="dot dot-blue"></span>
3072
+ <h3>Active pipeline</h3>
3073
+ <span class="inbox-count" id="pipeline-status">idle</span>
3074
+ <span class="tier-badge" id="tier-badge" title="change_tier for the current working-tree diff — which gates + judge open (ADR-003/004)"></span>
3075
+ </div>
3076
+ <div class="pipeline-track" id="pipeline-track"></div>
3077
+ </div>
3078
+ </div>
3079
+ </details>
3080
+
2947
3081
 
2948
3082
  <!-- Project archetype / compliance / phase used to be a full-width mono
2949
3083
  row here, between the KPI tiles and the pipeline. On a screen whose
@@ -2956,24 +3090,27 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2956
3090
  Not deleted: `PROJECT.md` is not in the Docs list, so compliance and
2957
3091
  phase would have become unreachable. -->
2958
3092
  <!-- Tool failure rate watchdog warning -->
2959
- <div id="tool-failure-rate" style="display:none;font-size:var(--fs-small);color:var(--status-blocked,#f87171);background:rgba(248,113,113,0.08);padding:6px 10px;border-radius:6px;margin-bottom:10px;"></div>
3093
+ <!-- One queue, not four sections.
2960
3094
 
2961
- <div class="inbox-section">
2962
- <div class="inbox-section-head">
2963
- <span class="dot dot-blue"></span>
2964
- <h3>Active pipeline</h3>
2965
- <span class="inbox-count" id="pipeline-status">idle</span>
2966
- <span class="tier-badge" id="tier-badge" title="change_tier for the current working-tree diff which gates + judge open (ADR-003/004)"></span>
2967
- </div>
2968
- <div class="pipeline-track" id="pipeline-track"></div>
2969
- </div>
2970
- <div class="inbox-section" id="inbox-gates-section">
3095
+ Gates, P0s, blocked and stale were four headed lists, each with its own
3096
+ count, on a screen that shows five to seven rows in total. The headings
3097
+ cost more vertical space than the rows and forced the reader to work out
3098
+ which bucket a thing was in before reading what it was.
3099
+
3100
+ Linear and GitHub both do the opposite: one ranked stream, and the reason
3101
+ an item is there rides ON the row as a chip. The reason is a property of
3102
+ the item, so it survives the item moving between buckets — which is what
3103
+ made an expired gate unrecognisable when it fell out of the gate list.
3104
+
3105
+ Order: gates first (a signature is owed), then oldest first, because the
3106
+ longest-ignored row is the one a triage list is read for. -->
3107
+ <div class="inbox-section" id="inbox-queue-section">
2971
3108
  <div class="inbox-section-head">
2972
3109
  <span class="dot dot-purple"></span>
2973
- <h3>Pending decisions</h3>
2974
- <span class="inbox-count" id="inbox-gates-count">0</span>
3110
+ <h3>Needs you</h3>
3111
+ <span class="inbox-count" id="inbox-queue-count">0</span>
2975
3112
  </div>
2976
- <div id="inbox-gates"></div>
3113
+ <div id="inbox-queue"></div>
2977
3114
  </div>
2978
3115
  <!-- Gates that did NOT wait for you. Placed directly under the decisions
2979
3116
  you still owe, because "what proceeded without me" is the question a
@@ -2986,30 +3123,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2986
3123
  </div>
2987
3124
  <div id="inbox-standdown"></div>
2988
3125
  </div>
2989
- <div class="inbox-section" id="inbox-p0-section">
2990
- <div class="inbox-section-head">
2991
- <span class="dot dot-red"></span>
2992
- <h3>P0 open</h3>
2993
- <span class="inbox-count" id="inbox-p0-count">0</span>
2994
- </div>
2995
- <div id="inbox-p0"></div>
2996
- </div>
2997
- <div class="inbox-section" id="inbox-blocked-section">
2998
- <div class="inbox-section-head">
2999
- <span class="dot dot-orange"></span>
3000
- <h3>Blocked</h3>
3001
- <span class="inbox-count" id="inbox-blocked-count">0</span>
3002
- </div>
3003
- <div id="inbox-blocked"></div>
3004
- </div>
3005
- <div class="inbox-section" id="inbox-stale-section">
3006
- <div class="inbox-section-head">
3007
- <span class="dot dot-amber"></span>
3008
- <h3>Stale (in progress &gt; 48h)</h3>
3009
- <span class="inbox-count" id="inbox-stale-count">0</span>
3010
- </div>
3011
- <div id="inbox-stale"></div>
3012
- </div>
3013
3126
  <!-- Idle focus: when nothing needs a decision, the four sections above collapse and this shows instead. -->
3014
3127
  <div class="inbox-allclear" id="inbox-allclear" style="display:none">
3015
3128
  <div class="ac-mark">✓</div>
@@ -3464,36 +3577,53 @@ function renderInbox(d) {
3464
3577
  Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
3465
3578
  // Same rule as the all-clear card: "nothing urgent" is a claim about data we
3466
3579
  // may not have. When a read failed, say that instead of reassuring the user.
3467
- const greetTail = anyDegraded()
3468
- ? "Some data could not be read — treat the counts below as incomplete."
3469
- : (s.gates + s.p0 ? "Here's what needs your decision." : 'Nothing urgent — back to deep work.');
3470
- document.getElementById('inbox-greet').textContent = `${greetByHour()} ${greetTail}`;
3471
- // Only the tiles that have something in them.
3580
+ // The headline states the SITUATION, not the time of day.
3472
3581
  //
3473
- // Four rendered unconditionally, and on an ordinary morning two of them read 0
3474
- // half the header spending a quarter of the width each to say "nothing here".
3475
- // A zero is worth stating when it is the WHOLE answer, so the all-clear is one
3476
- // line instead of four boxes of noughts.
3477
- const TILES = [
3478
- { n: s.gates, dot: 'purple', lbl: 'Pending decisions' },
3479
- { n: s.p0, dot: 'red', lbl: 'P0 open' },
3480
- { n: s.blocked, dot: 'orange', lbl: 'Blocked' },
3481
- { n: s.stale, dot: 'amber', lbl: 'Stale &gt; 48h' },
3482
- ];
3483
- const liveTiles = TILES.filter((t) => Number(t.n) > 0);
3484
- document.getElementById('inbox-summary').innerHTML = liveTiles.length
3485
- ? liveTiles.map((t) => `<div class="pill-stat"><span class="dot dot-${t.dot}"></span>`
3486
- + `<div><div class="num">${t.n}</div><div class="lbl">${t.lbl}</div></div></div>`).join('')
3487
- : `<div class="pill-stat" style="grid-column:1/-1"><span class="dot dot-green"></span>`
3488
- + `<div><div class="lbl">No gates, no P0s, nothing blocked or stale.</div></div></div>`;
3489
- renderInboxList('inbox-gates', d?.pending_gates || [], 'inbox-gates-count', { showApprove: true });
3490
- // `implies` names the fact the section heading already states, so the rows
3491
- // below it stop repeating that fact on every card. "P0 open" states the
3492
- // priority; "Stale (in progress > 48h)" states the age. "Blocked" states
3493
- // neither, so its rows keep both.
3494
- renderInboxList('inbox-p0', d?.p0_open || [], 'inbox-p0-count', { implies: 'priority' });
3495
- renderInboxList('inbox-blocked', d?.blocked || [], 'inbox-blocked-count', {});
3496
- renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', { implies: 'age' });
3582
+ // "Good morning. Here's what needs your decision." sat above a screen whose
3583
+ // every timestamp was 11 to 30 days old. The most important fact available
3584
+ // that nothing had moved in eleven days was the one thing the page never
3585
+ // said. Borrowed from Datadog's monitor states, where "No Data" is an alert in
3586
+ // its own right rather than the absence of one: silence is a reading.
3587
+ //
3588
+ // Age is taken across everything the screen knows about, so a quiet queue with
3589
+ // a live pipeline reads differently from a project that has stopped.
3590
+ const newestEventAgeH = (() => {
3591
+ const stamps = [
3592
+ ...(d?.pending_gates || []), ...(d?.p0_open || []),
3593
+ ...(d?.blocked || []), ...(d?.stale_in_progress || []),
3594
+ ].map((t) => t?.updated_at).filter(Boolean)
3595
+ .concat((window.__pipelineStages || []).map((st) => st?.ts).filter(Boolean))
3596
+ .map((t) => Date.parse(t)).filter((n) => Number.isFinite(n));
3597
+ if (!stamps.length) return null;
3598
+ return (Date.now() - Math.max(...stamps)) / 3600e3;
3599
+ })();
3600
+
3601
+ const needs = s.gates + s.p0;
3602
+ const STALL_H = 72; // the same 72h the gate-expiry hook uses
3603
+ const greetEl = document.getElementById('inbox-greet');
3604
+ greetEl.setAttribute('role', 'status');
3605
+ greetEl.setAttribute('aria-live', 'polite');
3606
+
3607
+ if (anyDegraded()) {
3608
+ // A read failed. Reassurance would be a claim about data we do not have.
3609
+ greetEl.textContent = 'Some data could not be read — treat the counts below as incomplete.';
3610
+ } else if (newestEventAgeH != null && newestEventAgeH > STALL_H) {
3611
+ const days = Math.floor(newestEventAgeH / 24);
3612
+ greetEl.textContent = needs
3613
+ ? `Stalled — nothing has moved in ${days} days, and ${needs} item${needs > 1 ? 's' : ''} ${needs > 1 ? 'are' : 'is'} waiting on you.`
3614
+ : `Stalled — nothing has moved in ${days} days, and nothing is waiting on you either.`;
3615
+ } else if (needs) {
3616
+ greetEl.textContent = "Here's what needs your decision.";
3617
+ } else {
3618
+ greetEl.textContent = newestEventAgeH == null
3619
+ ? 'Nothing recorded yet for this project.'
3620
+ : `All clear — last activity ${relTime(new Date(Date.now() - newestEventAgeH * 3600e3).toISOString())}.`;
3621
+ }
3622
+ // The stat-tile row is gone: two of its four boxes read 0 on an ordinary
3623
+ // morning, and the two that did not repeated a count the section headings
3624
+ // below already carry. The counts still reach the nav badge.
3625
+ renderQueue(d);
3626
+ renderStatusVerdict(d, newestEventAgeH);
3497
3627
  refreshStandDowns();
3498
3628
  refreshReceipt();
3499
3629
  refreshBudgetLine();
@@ -3506,11 +3636,122 @@ function renderInbox(d) {
3506
3636
  // zero and the card used to headline "Nothing needs your decision" — maximum
3507
3637
  // confidence at exactly the moment we knew least. Absence of findings is not
3508
3638
  // a finding of absence.
3509
- if (ac) ac.style.display = (attention === 0 && !anyDegraded()) ? '' : 'none';
3639
+ //
3640
+ // And "nothing needs you" has TWO readings that deserve different words.
3641
+ //
3642
+ // quiet because the work is done → rest, and the actions offer more work
3643
+ // quiet because nothing is running → that is not rest, it is a stopped
3644
+ // machine, and "back to deep work" is
3645
+ // the wrong thing to say to it
3646
+ //
3647
+ // Today's board is the second, and until now the screen had no way to say so:
3648
+ // an empty queue rendered the same congratulation either way.
3649
+ if (ac) {
3650
+ const show = attention === 0 && !anyDegraded();
3651
+ ac.style.display = show ? '' : 'none';
3652
+ if (show) {
3653
+ const stalled = newestEventAgeH != null && newestEventAgeH > STALL_H;
3654
+ const txt = ac.querySelector('.ac-text');
3655
+ const mark = ac.querySelector('.ac-mark');
3656
+ if (stalled) {
3657
+ const days = Math.floor(newestEventAgeH / 24);
3658
+ if (mark) mark.textContent = '·';
3659
+ if (txt) txt.innerHTML = `<b>Nothing is waiting on you.</b> Nothing has run in `
3660
+ + `${days} days either — this project is idle, not finished.`;
3661
+ } else {
3662
+ if (mark) mark.textContent = '\u2713';
3663
+ if (txt) txt.innerHTML = '<b>All clear.</b> Nothing needs your decision — back to deep work.';
3664
+ }
3665
+ }
3666
+ }
3510
3667
  renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
3511
3668
  'Some project data could not be read — counts below are incomplete.');
3512
3669
  }
3513
3670
 
3671
+ /**
3672
+ * The line a closed disclosure says out loud.
3673
+ *
3674
+ * "Project status" on its own is a label, not an answer: closed, it told the
3675
+ * reader nothing, so the only way to learn anything was to open it — which is
3676
+ * the cost the disclosure was supposed to remove. It now carries the verdict, and
3677
+ * the reader opens it only when the verdict is worth chasing.
3678
+ */
3679
+ function renderStatusVerdict(d, ageH) {
3680
+ const el = document.getElementById('proj-status-verdict');
3681
+ if (!el) return;
3682
+ const parts = [];
3683
+ if (ageH == null) parts.push('no activity recorded');
3684
+ else if (ageH < 1) parts.push(`last activity ${Math.max(1, Math.round(ageH * 60))}m ago`);
3685
+ else if (ageH < 48) parts.push(`last activity ${Math.round(ageH)}h ago`);
3686
+ else parts.push(`idle ${Math.floor(ageH / 24)}d`);
3687
+
3688
+ const stages = window.__pipelineStages || [];
3689
+ const active = stages.filter((x) => x && x.status === 'active').length;
3690
+ if (stages.length) parts.push(active ? `${active} stage${active > 1 ? 's' : ''} active` : 'pipeline idle');
3691
+
3692
+ // A budget that cannot be measured is not a budget that is fine.
3693
+ const budget = document.getElementById('inbox-budget-line');
3694
+ if (budget) parts.push('budgets unmeasured');
3695
+
3696
+ el.textContent = ' · ' + parts.join(' · ');
3697
+ }
3698
+
3699
+ /**
3700
+ * The one queue. Extracted from renderInbox because the receipt fetch finishes
3701
+ * AFTER the first render and has to put its row in — and calling renderInbox from
3702
+ * refreshReceipt, which renderInbox itself calls, is an infinite loop. Rendering
3703
+ * only the list it needs to change is both correct and cheaper.
3704
+ */
3705
+ function renderQueue(d) {
3706
+ // One queue. The reason an item is here travels ON the item, so it survives the
3707
+ // item changing buckets — the failure that made an expired gate unreadable when
3708
+ // it dropped out of `pending_gates`.
3709
+ const ageD = (t) => {
3710
+ const h = ageHours(t?.updated_at);
3711
+ return h == null ? null : Math.floor(h / 24);
3712
+ };
3713
+ const withReason = (list, reason) => (list || []).map((t) => ({
3714
+ ...t,
3715
+ __reason: typeof reason === 'function' ? reason(t) : reason,
3716
+ }));
3717
+ const queue = [
3718
+ // An expired gate is still a gate, and saying so is the whole point: the row
3719
+ // that lost its buttons for 699 hours looked like an ordinary P0.
3720
+ ...withReason(d?.pending_gates, (t) =>
3721
+ (t.raw_status === 'blocked' ? 'expired gate' : 'gate')),
3722
+ ...withReason(d?.p0_open, (t) => (t.is_gate ? 'expired gate' : 'P0')),
3723
+ ...withReason(d?.blocked, 'blocked'),
3724
+ ...withReason(d?.stale_in_progress, (t) => {
3725
+ const dd = ageD(t);
3726
+ return dd == null ? 'stale' : `stale ${dd}d`;
3727
+ }),
3728
+ ];
3729
+ // The receipt-drift row, when there is one. It is not a task, so it arrives as
3730
+ // a synthetic item rather than from any bucket.
3731
+ if (window.__receiptDrift) queue.push(window.__receiptDrift);
3732
+ // Gates first — a signature is owed. Then oldest first, because the
3733
+ // longest-ignored row is what a triage list is read for.
3734
+ queue.sort((a, b) => {
3735
+ const ga = a.is_gate ? 0 : 1, gb = b.is_gate ? 0 : 1;
3736
+ if (ga !== gb) return ga - gb;
3737
+ const ah = ageHours(a.updated_at), bh = ageHours(b.updated_at);
3738
+ if (ah == null) return 1;
3739
+ if (bh == null) return -1;
3740
+ return bh - ah;
3741
+ });
3742
+ renderInboxList('inbox-queue', queue, 'inbox-queue-count', { reasons: true });
3743
+ // The heading claims what the rows can deliver, and no more. "Needs you" over a
3744
+ // list of things you cannot touch is the same overclaim this screen spent the
3745
+ // day removing — it was simply mine.
3746
+ // "Needs you" means a decision only you can make — a gate to sign. Everything
3747
+ // else is work that continues elsewhere, and saying otherwise is the overclaim
3748
+ // that made six rows promise an action five of them did not have.
3749
+ const decidable = queue.some((t) => t.is_gate);
3750
+ const head = document.querySelector('#inbox-queue-section h3');
3751
+ if (head) head.textContent = queue.length === 0 ? 'Needs you'
3752
+ : (decidable ? 'Needs you' : 'Waiting on work elsewhere');
3753
+ }
3754
+
3514
3755
  /**
3515
3756
  * Does the last approval still describe what is in the tree?
3516
3757
  *
@@ -3569,11 +3810,38 @@ async function refreshReceipt() {
3569
3810
  //
3570
3811
  // Moved, not removed: same wording, same detail, same page, still unmissable
3571
3812
  // if you read to the bottom, and it still never renders as "checked and fine".
3813
+ // `differs` now becomes a ROW IN THE QUEUE rather than a line above it.
3814
+ //
3815
+ // The queue exists so that everything asking for the operator is in one place.
3816
+ // A purple line floating above it was a second place — smaller, but a reader
3817
+ // still had to notice it separately and decide whether it counted. It carries
3818
+ // the chip `review drift`, sorts with everything else, and is reachable by
3819
+ // keyboard like any other row.
3820
+ //
3821
+ // `unreadable` and `extended` stay in the footer. "Cannot check" is true and
3822
+ // careful and asks for nothing; putting it in a queue named for what needs you
3823
+ // would be the overclaim this whole screen has been unwinding.
3572
3824
  const actionable = d.state === 'differs';
3573
- const host = actionable ? el : (foot || el);
3574
- const colour = actionable ? 'var(--status-gate)' : 'var(--text3)';
3575
- host.style.display = '';
3576
- host.innerHTML = `<span style="color:${colour}">${body}</span>`;
3825
+ if (actionable) {
3826
+ el.style.display = 'none';
3827
+ window.__receiptDrift = {
3828
+ id: 'receipt',
3829
+ title: `${changedFiles.length} reviewed file(s) changed after approval`,
3830
+ desc: body,
3831
+ updated_at: d.ts || null,
3832
+ __reason: 'review drift',
3833
+ __synthetic: true,
3834
+ __files: changedFiles,
3835
+ };
3836
+ } else {
3837
+ window.__receiptDrift = null;
3838
+ const host = foot || el;
3839
+ host.style.display = '';
3840
+ host.innerHTML = `<span style="color:var(--text3)">${body}</span>`;
3841
+ }
3842
+ // The queue was rendered before this fetch returned, so it has to be rebuilt
3843
+ // with the row in it. Silent if the Inbox is not the open panel.
3844
+ if (window.__inboxData) renderQueue(window.__inboxData);
3577
3845
  }
3578
3846
 
3579
3847
  /**
@@ -3748,7 +4016,8 @@ function renderInboxList(rootId, items, countId, opts = {}) {
3748
4016
  // longest-ignored row at the top, which is the order a triage list is read in.
3749
4017
  // Gates keep their server order: their own list is already time-ordered and
3750
4018
  // their ⏳ stamp is the loud field.
3751
- const rows = opts.showApprove
4019
+ // A queue built with reasons arrives already ranked; re-sorting would undo it.
4020
+ const rows = (opts.showApprove || opts.reasons)
3752
4021
  ? items
3753
4022
  : items.slice().sort((a, b) => {
3754
4023
  const ah = ageHours(a.updated_at), bh = ageHours(b.updated_at);
@@ -3779,7 +4048,7 @@ function renderInboxList(rootId, items, countId, opts = {}) {
3779
4048
  alsoTag,
3780
4049
  // Gates (the showApprove list) get the exact date+time the decision has been
3781
4050
  // waiting since; other rows carry their age in the right-hand column instead.
3782
- opts.showApprove && t.updated_at
4051
+ (opts.showApprove || t.is_gate === true) && t.updated_at
3783
4052
  ? `<span title="${esc(fmtDate(t.updated_at))}" style="color:var(--status-gate);font-weight:600">⏳ ${fmtDT(t.updated_at)}</span>`
3784
4053
  : '',
3785
4054
  ].filter(Boolean).join(' · ');
@@ -3793,7 +4062,43 @@ function renderInboxList(rootId, items, countId, opts = {}) {
3793
4062
  // this change is removing.
3794
4063
  const hrs = ageHours(t.updated_at);
3795
4064
  const aged = !opts.showApprove && opts.implies !== 'age' && hrs != null && hrs >= 48;
3796
- const actions = opts.showApprove
4065
+ // A gate can be approved because it IS a gate — not because of which list it
4066
+ // happened to land in.
4067
+ //
4068
+ // `gate-expiry.mjs` marks a gate `blocked` once it has waited 72 hours.
4069
+ // `getInbox` excludes blocked gates from `pending_gates`, so the item drops
4070
+ // into `p0_open`, which renders without `showApprove` — and loses its
4071
+ // Approve/Reject buttons. The hook built to draw attention to a neglected
4072
+ // gate quietly removed the only way to answer it. Observed on a gate that had
4073
+ // then waited 699 hours with nothing on screen able to end that wait.
4074
+ //
4075
+ // The affordance now follows `is_gate`, which is a fact about the task rather
4076
+ // than about the query that fetched it.
4077
+ const canDecide = opts.showApprove || t.is_gate === true;
4078
+ // A row with no action is a row that says "needs you" and means "look at me".
4079
+ //
4080
+ // Merging four sections under one "Needs you" heading made that promise for
4081
+ // all of them, and five of six rows could not be acted on from this board at
4082
+ // all: the only write action it has is approving a gate. The operator's words
4083
+ // were "но я не могу ничего сделать" — and they were right.
4084
+ //
4085
+ // The board does not dispatch agents; it hands you the line that does. That is
4086
+ // the same copy-not-dispatch the pipeline rail already uses on a failed stage,
4087
+ // and it is the honest affordance for a task that has sat for a month: this
4088
+ // cannot be finished here, here is what finishes it.
4089
+ // The drift row is not a task and has no agent, so it needs its own line: the
4090
+ // one that shows what changed under an approval. Files come from the receipt.
4091
+ const diffBtn = (t.__synthetic && Array.isArray(t.__files) && t.__files.length)
4092
+ ? `<div class="actions" onclick="event.stopPropagation()">`
4093
+ + `<button type="button" class="gate-btn" title="Copy a git diff limited to the reviewed files"`
4094
+ + ` onclick="event.stopPropagation(); copyDiff(${esc(JSON.stringify(JSON.stringify(t.__files)))})">Copy diff</button></div>`
4095
+ : '';
4096
+ const rerun = (!canDecide && !t.__synthetic && t.agent && t.id)
4097
+ ? `<div class="actions" onclick="event.stopPropagation()">`
4098
+ + `<button type="button" class="gate-btn" title="Copy the line that re-runs this agent"`
4099
+ + ` onclick="event.stopPropagation(); copyRerun('${esc(t.agent)}')">Copy re-run</button></div>`
4100
+ : '';
4101
+ const actions = canDecide
3797
4102
  ? `<div class="actions" onclick="event.stopPropagation()">
3798
4103
  <button class="gate-btn gate-approve" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'approve')">Approve</button>
3799
4104
  <button class="gate-btn gate-reject" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'reject')">Reject</button>
@@ -3814,12 +4119,26 @@ function renderInboxList(rootId, items, countId, opts = {}) {
3814
4119
  ? `<span class="ttl-desc"${ctx.wire ? ` title="${esc(ctx.wire)}"` : ''}>${esc(shown.slice(0, 140))}${shown.length > 140 ? '…' : ''}</span>`
3815
4120
  : '';
3816
4121
  // Only gate rows can stand down, so only they carry the note.
3817
- const tierNote = opts.showApprove ? gateTierNote(t) : '';
4122
+ const tierNote = (opts.showApprove || t.is_gate === true) ? gateTierNote(t) : '';
4123
+ // Reachable by keyboard. Every row in this queue was a bare `div` with an
4124
+ // onclick — no tabindex, no role, no key handler — so the whole list of
4125
+ // things needing the operator could be seen and never opened without a
4126
+ // mouse. Enter and Space now do what a click does, and the row announces
4127
+ // itself as a button instead of as anonymous text.
4128
+ // Why this row is here, as a property of the row. Four headed sections used to
4129
+ // carry this fact in their headings, where it was lost the moment an item moved
4130
+ // between them — which is how an expired gate became indistinguishable from an
4131
+ // ordinary P0 for 699 hours.
4132
+ const REASON_TONE = { 'expired gate': 'gate', gate: 'gate', P0: 'p0', blocked: 'blocked' };
4133
+ const tone = REASON_TONE[t.__reason] || (String(t.__reason || '').startsWith('stale') ? 'stale' : '');
4134
+ const reasonChip = (opts.reasons && t.__reason)
4135
+ ? `<span class="why why-${tone}">${esc(t.__reason)}</span>`
4136
+ : '';
3818
4137
  return `
3819
- <div class="inbox-row${aged ? ' row-aged' : ''}" onclick='openSide(${JSON.stringify(t).replace(/'/g, "&apos;")})'>
4138
+ <div class="inbox-row${aged ? ' row-aged' : ''}" tabindex="0" role="button" aria-label="${esc(t.title || t.id)}" onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();this.click();}' onclick='openSide(${JSON.stringify(t).replace(/'/g, "&apos;")})'>
3820
4139
  <span class="id">${esc(t.id || '')}</span>
3821
- <span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
3822
- ${actions}
4140
+ <span class="ttl">${reasonChip}${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
4141
+ ${actions}${rerun}${diffBtn}
3823
4142
  </div>`;
3824
4143
  }).join('');
3825
4144
  }
@@ -4677,7 +4996,7 @@ function renderPipeline(stages) {
4677
4996
  ? `oldest ${s.age_min < 60 ? s.age_min + 'm' : Math.round(s.age_min/60)+'h'}`
4678
4997
  : 'waiting';
4679
4998
  return `
4680
- <div class="pl-stage pl-gate active" title="Human gate — irreversible actions need a signature here. Click to see pending gates." onclick="drillToGates()">
4999
+ <div class="pl-stage pl-gate active${(s.age_min != null && s.age_min < 60) ? ' fresh' : ''}" title="Human gate — irreversible actions need a signature here. Click to see pending gates." onclick="drillToGates()">
4681
5000
  <div class="pl-name"><span class="gate-glyph">✍</span>${label}<span class="gate-badge">${s.pending}</span></div>
4682
5001
  ${s.last_message ? `<div class="pl-msg">${esc(s.last_message)}</div>` : ''}
4683
5002
  <div class="pl-meta">${meta}</div>
@@ -4698,6 +5017,9 @@ function renderPipeline(stages) {
4698
5017
  // age, which is what the reader was looking at anyway.
4699
5018
  const msg = s.last_message ? `<div class="pl-msg">${esc(s.last_message)}</div>` : '';
4700
5019
  const meta = s.age_min != null ? `${s.age_min < 60 ? s.age_min + 'm' : Math.round(s.age_min/60)+'h'} ago` : '';
5020
+ // Within the hour is live; anything older is a record of something that
5021
+ // finished. Only the first earns the heartbeat.
5022
+ const fresh = s.age_min != null && s.age_min < 60 ? ' fresh' : '';
4701
5023
  // A failed stage offered one affordance: filter the Kanban by it. That is
4702
5024
  // the same thing every other stage offers, and it is not what you want when
4703
5025
  // something broke. GitHub Actions puts "Re-run failed jobs" on the failure
@@ -4712,7 +5034,7 @@ function renderPipeline(stages) {
4712
5034
  title="Copy the command that re-runs this stage">re-run</button>
4713
5035
  </div>` : '';
4714
5036
  return `
4715
- <div class="pl-stage ${s.status}" title="Click to filter Kanban by ${esc(s.stage)}" onclick="drillToStage('${esc(s.stage)}')">
5037
+ <div class="pl-stage ${s.status}${fresh}" title="Click to filter Kanban by ${esc(s.stage)}" onclick="drillToStage('${esc(s.stage)}')">
4716
5038
  <div class="pl-name"><span class="dot"></span>${label}</div>
4717
5039
  ${msg}
4718
5040
  <div class="pl-meta">${meta || s.status}</div>
@@ -4747,6 +5069,17 @@ function copyRerun(stage) {
4747
5069
  );
4748
5070
  }
4749
5071
 
5072
+ /** The same copy-not-dispatch: the board shows what changed, it does not open it. */
5073
+ function copyDiff(filesJson) {
5074
+ let files = [];
5075
+ try { files = JSON.parse(filesJson); } catch { /* nothing to scope to */ }
5076
+ const cmd = files.length ? `git diff -- ${files.join(' ')}` : 'git diff';
5077
+ navigator.clipboard.writeText(cmd).then(
5078
+ () => showToast(`Copied <code>${esc(cmd.slice(0, 70))}…</code>`, 'success', 3000),
5079
+ () => showToast(`Run <code>${esc(cmd.slice(0, 70))}</code>`, 'info', 4000),
5080
+ );
5081
+ }
5082
+
4750
5083
  // Click the human-gate node → jump to the gates/inbox view where they get signed.
4751
5084
  function drillToGates() {
4752
5085
  const search = document.getElementById('search-input');
@@ -13,7 +13,7 @@ import fs from 'fs';
13
13
  import path from 'path';
14
14
  import { spawnSync } from 'child_process';
15
15
  import { PORT, PUBLIC, HOST } from './lib/config.mjs';
16
- import { getTasks } from './lib/beads.mjs';
16
+ import { warmTasksAsync } from './lib/beads.mjs';
17
17
  import { originAllowed, isInsideDir } from './lib/util.mjs';
18
18
  import { discoverProjects, resolveProjectInfo } from './lib/projects.mjs';
19
19
  import { startAlertCron } from './lib/alerts.mjs';
@@ -115,27 +115,34 @@ server.listen(PORT, HOST, () => {
115
115
  log.info(` ⚠ bound to ${HOST} — reachable beyond this machine. Operators authenticate via invite`);
116
116
  log.info(` links; put your reverse-proxy auth in front for anything admin-grade.`);
117
117
  }
118
- // Warm the task cache for THIS project before a browser asks.
118
+ // Warm the task cache for THIS project before a browser asks — asynchronously.
119
119
  //
120
- // `bd list` costs seconds and runs through `spawnSync`, which holds the event
121
- // loop for the whole of it. On a cold board the first request pays that, and
122
- // every other request queues behind it: opening the board showed "loading…"
123
- // on Docs for 27 seconds, measured, while the endpoint itself takes 0.4 s
124
- // warm. Nothing was broken everything was waiting.
120
+ // `bd list` costs seconds. This used to call `getTasks(cwd)` directly, which
121
+ // is `bdList()`'s COLD-cache path `spawnSync` because a cache with
122
+ // nothing in it has nothing to fall back on while a refresh runs. That
123
+ // reasoning is right for a REQUEST; it is wrong here, because nobody is
124
+ // waiting on a boot warm-up. The comment this replaced said moving the call
125
+ // here "moves the stall to before anyone is looking" — true of the
126
+ // `.listen()` callback, false of the port: `setImmediate` only defers past
127
+ // that callback, not past `accept()`, and the port is already open. Every
128
+ // connection accepted during the spawnSync — including one with nothing to
129
+ // do with this cwd, like /api/version's plain readdirSync — queued behind
130
+ // it. Measured with an artificially slow `bd` (GREAT_CTO_BD_BIN): every
131
+ // endpoint answered `000` for the fixture's entire delay, whatever it was
132
+ // asked for, because the one process serving them was blocked inside
133
+ // spawnSync the whole time.
125
134
  //
126
- // Doing it here moves the stall to before anyone is looking, where a stall
127
- // costs nothing. `setImmediate` so the listening callback returns first and
128
- // the port is genuinely open while this runs.
129
- setImmediate(() => {
130
- const t0 = Date.now();
131
- try {
132
- getTasks(process.cwd());
133
- log.info(` → task cache warmed in ${Date.now() - t0}ms`);
134
- } catch (e) {
135
- // A warm-up that failed changes nothing a request would not have hit
136
- // anyway; it must never stop the board from serving.
137
- log.warn(` → could not warm the task cache: ${e?.message || e}`);
138
- }
135
+ // warmTasksAsync (lib/beads.mjs) runs the same `bd list` through `spawn`
136
+ // instead the same non-blocking path already used for warm-cache
137
+ // "stale-while-revalidate" reads — so the event loop is free for the whole
138
+ // fill, cold cache or not.
139
+ warmTasksAsync(process.cwd()).then(({ ok, count, ms, skipped }) => {
140
+ if (skipped) return;
141
+ if (ok) log.info(` → task cache warmed in ${ms}ms (${count} task${count === 1 ? '' : 's'})`);
142
+ // A warm-up that failed changes nothing a request would not have hit
143
+ // anyway; it must never stop the board from serving. bdFailureFor(cwd)
144
+ // carries the reason for whoever asks next.
145
+ else log.warn(` → could not warm the task cache after ${ms}ms (see bdFailureFor)`);
139
146
  });
140
147
 
141
148
  // Discover all great_cto projects on disk asynchronously — don't block
@@ -129,17 +129,38 @@ export function daemonSpec(platform, o) {
129
129
  };
130
130
  }
131
131
  /**
132
- * Decide what `board ensure` should do:
133
- * - no live process → start
134
- * - live process, port hung → restart (the case a liveness supervisor misses)
135
- * - live process, port answering → noop (never kill a healthy board)
132
+ * Decide what `board ensure` should do.
133
+ *
134
+ * THE PORT DECIDES, NOT THE PID FILE.
135
+ *
136
+ * This asked the pid first: `pid === null || !alive → start`. A board started any
137
+ * other way — `node packages/board/server.mjs`, a terminal someone left open, a
138
+ * pid file lost to a reboot — leaves no pid this CLI wrote, so ensure declared it
139
+ * absent and spawned a second server onto the occupied port. That one died on
140
+ * EADDRINUSE, its pid was recorded anyway, and the file then named a corpse while
141
+ * a perfectly healthy board answered every request. Observed exactly that: pid
142
+ * file 56328, dead; port 3141 served by 47326, HTTP 200.
143
+ *
144
+ * Every later run repeated it. A health gate that fails forever while reporting
145
+ * success is worse than no health gate.
146
+ *
147
+ * ADR-007 already said the decision is pid-alive AND port-healthy. This is that
148
+ * sentence, with the port asked first, because the port is what a user opens:
149
+ *
150
+ * port answering, pid ours → noop (nothing to do)
151
+ * port answering, pid not ours → adopt (someone else's healthy board; leave it)
152
+ * port hung, pid alive → restart (the case a liveness supervisor misses)
153
+ * port hung, no live pid → start
154
+ *
155
+ * `adopt` exists so the caller does not overwrite the pid file with a process it
156
+ * did not start. A pid we cannot vouch for is worse than no pid at all.
136
157
  */
137
158
  export function decideEnsureAction(s) {
138
- if (s.pid === null || !s.alive)
139
- return "start";
140
- if (!s.healthy)
159
+ if (s.healthy)
160
+ return s.pid !== null && s.alive ? "noop" : "adopt";
161
+ if (s.pid !== null && s.alive)
141
162
  return "restart";
142
- return "noop";
163
+ return "start";
143
164
  }
144
165
  /**
145
166
  * Is this HTTP response the great_cto board, or just something on that port?
package/dist/main.js CHANGED
@@ -429,6 +429,32 @@ async function spawnDetachedBoard(port) {
429
429
  catch { /* best-effort */ }
430
430
  return child.pid;
431
431
  }
432
+ /**
433
+ * How long a board may take to start answering before something is wrong.
434
+ *
435
+ * Measured, not chosen: this board serves /api/version at ~2.4s, falls silent for
436
+ * ~37s while discovering projects, and recovers. 60s leaves headroom on a slower
437
+ * machine with more projects. It is a ceiling on patience, not a target — a board
438
+ * that answers in two seconds costs two seconds.
439
+ */
440
+ const WARM_WINDOW_MS = 60_000;
441
+ /**
442
+ * Poll until the board answers, or give up.
443
+ *
444
+ * `spawn` returning a pid means the OS created a process, not that a board is
445
+ * serving. A child that dies on EADDRINUSE or a bad build has a pid for a few
446
+ * milliseconds, and the previous code announced "board started" on the strength
447
+ * of it — then wrote that pid to the file the next run would trust.
448
+ */
449
+ async function waitForBoard(port, timeoutMs) {
450
+ const deadline = Date.now() + timeoutMs;
451
+ while (Date.now() < deadline) {
452
+ if (await probeBoardPort(port))
453
+ return true;
454
+ await new Promise((r) => setTimeout(r, 250));
455
+ }
456
+ return false;
457
+ }
432
458
  /**
433
459
  * `great-cto board ensure` — idempotent health gate. Starts the board only if it
434
460
  * isn't already answering; never kills a healthy instance. Safe to call from a
@@ -438,12 +464,38 @@ async function runBoardEnsure(args) {
438
464
  const port = args.boardPort;
439
465
  const pid = readBoardPid();
440
466
  const alive = pid !== null && isPidAlive(pid);
441
- const healthy = alive && await probeBoardPort(port);
467
+ // Probe the port ALWAYS, not only when a pid file happens to exist. The port is
468
+ // what a user opens; a missing pid file means this CLI did not start the board,
469
+ // not that no board is running. Gating the probe on `alive` is what let ensure
470
+ // spawn a second server onto an occupied port, over and over.
471
+ // One failed probe is not a hung board.
472
+ //
473
+ // Measured on this machine: the board binds the port, answers /api/version at
474
+ // ~2.4s, then goes SILENT for roughly 37 seconds while it discovers projects,
475
+ // and only then serves normally. A single probe inside that window says "not
476
+ // answering" about a board that is merely starting — and the old code then
477
+ // restarted it, which starts the window again. That is a restart loop, and it
478
+ // is exactly what a supervisor calling this every minute would have produced.
479
+ //
480
+ // So a live pid gets the benefit of the doubt: several probes across the warm
481
+ // window before anything is declared hung. A board with no live pid needs no
482
+ // patience — one probe answers whether something is already serving.
483
+ const healthy = alive
484
+ ? await waitForBoard(port, WARM_WINDOW_MS)
485
+ : await probeBoardPort(port);
442
486
  const action = decideEnsureAction({ pid, alive, healthy });
443
487
  if (action === "noop") {
444
488
  log(` ${green("✓")} board already running → http://localhost:${port} (pid ${pid})`);
445
489
  return 0;
446
490
  }
491
+ if (action === "adopt") {
492
+ // Deliberately does NOT record a pid: this process is not ours to vouch for,
493
+ // and a pid file naming something we did not start is how the last one came
494
+ // to name a corpse.
495
+ log(` ${green("✓")} board already answering → http://localhost:${port}`);
496
+ log(` ${dim("started outside this CLI — left alone")}`);
497
+ return 0;
498
+ }
447
499
  if (action === "restart") {
448
500
  log(` ${dim(`board pid ${pid} alive but not answering on ${port} — restarting…`)}`);
449
501
  await killExistingBoard();
@@ -451,6 +503,21 @@ async function runBoardEnsure(args) {
451
503
  const newPid = await spawnDetachedBoard(port);
452
504
  if (!newPid)
453
505
  return 1;
506
+ // Spawning is not starting. The previous version announced success the moment
507
+ // it had a pid, so a child that died immediately — EADDRINUSE, a syntax error,
508
+ // a missing build — was reported as a started board and its pid was written
509
+ // down. Wait for the port to actually answer before claiming anything.
510
+ // Same window: a fresh board is not late, it is warming.
511
+ const cameUp = await waitForBoard(port, WARM_WINDOW_MS);
512
+ if (!cameUp) {
513
+ try {
514
+ unlinkSync(boardPidFilePath());
515
+ }
516
+ catch { /* nothing to clean up */ }
517
+ error(`board did not come up on ${port} within 8s — pid ${newPid} exited or is not serving.`);
518
+ log(dim(` Something else may hold the port: lsof -nP -iTCP:${port} -sTCP:LISTEN`));
519
+ return 1;
520
+ }
454
521
  log(` ${green("✓")} board ${action === "restart" ? "restarted" : "started"} → http://localhost:${port} (pid ${newPid})`);
455
522
  return 0;
456
523
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.16.0",
3
+ "version": "3.17.0",
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",
package/postinstall.mjs CHANGED
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
38
+ import { spawn } from 'node:child_process';
38
39
  import { join, dirname } from 'node:path';
39
40
  import { homedir } from 'node:os';
40
41
  import { fileURLToPath } from 'node:url';
@@ -116,6 +117,54 @@ function main() {
116
117
 
117
118
  const r = compare({ cli, plugin: newestPluginVersion(cacheRoot) });
118
119
  if (r.state === 'stale') process.stdout.write(message(r) + '\n');
120
+
121
+ ensureBoard();
122
+ }
123
+
124
+ /**
125
+ * Bring the board up, because an admin panel you have to remember to start is one
126
+ * you find down.
127
+ *
128
+ * ADR-007 accepted "board always-on" in v2.86.0 and shipped `great-cto board
129
+ * ensure` — an idempotent health gate that starts the board only if nothing is
130
+ * answering. Nothing ever called it from an install. The decision existed, the
131
+ * mechanism existed, and the two were never connected, so every upgrade left the
132
+ * panel down until somebody typed the command.
133
+ *
134
+ * Four rules this obeys, because a postinstall hook that breaks an install is
135
+ * worse than one that does nothing:
136
+ *
137
+ * - never fail. `npm install` must succeed even if this cannot run at all.
138
+ * - never block. The board takes the better part of a minute to become
139
+ * responsive; the installer does not wait for it. Detached, unref'd, output
140
+ * discarded.
141
+ * - never in CI, and never when asked not to. Both already guard the notice
142
+ * above; `GREAT_CTO_NO_BOARD=1` opts out of this specifically.
143
+ * - never twice. `ensure` probes the port first and adopts a board that is
144
+ * already answering, whoever started it.
145
+ */
146
+ function ensureBoard() {
147
+ if (process.env.GREAT_CTO_NO_BOARD) return;
148
+ try {
149
+ const here = dirname(fileURLToPath(import.meta.url));
150
+ const entry = join(here, 'index.mjs');
151
+ if (!existsSync(entry)) return;
152
+
153
+ const child = spawn(process.execPath, [entry, 'board', 'ensure'], {
154
+ detached: true,
155
+ stdio: 'ignore',
156
+ });
157
+ child.unref();
158
+ process.stdout.write(' great-cto: starting the board — http://localhost:3141\n');
159
+ } catch (e) {
160
+ // Never fail the install — but never fail SILENTLY either. The first version
161
+ // of this function referenced an import that was not there; the catch ate the
162
+ // ReferenceError and the hook printed nothing, so the board simply did not
163
+ // start and nothing said why. A swallowed error is the defect this project
164
+ // spends most of its checks on.
165
+ process.stdout.write(` great-cto: could not start the board — ${e?.message || e}\n`);
166
+ process.stdout.write(' Start it yourself with: great-cto board\n');
167
+ }
119
168
  }
120
169
 
121
170
  // Only when run as the hook, so the pure parts above stay importable by tests.