great-cto 2.95.0 → 2.96.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.
@@ -46,6 +46,18 @@
46
46
  --text2: #8a9a92;
47
47
  --text3: #8b958c;
48
48
 
49
+
50
+ /* Focus ring — a separate token from --accent on purpose.
51
+ #00d97e measures 9.68:1 on the dark background and 1.87:1 on white, against
52
+ a 3.0 minimum for a non-text indicator. One colour cannot serve both themes,
53
+ and using the brand colour for focus made the light theme's ring effectively
54
+ invisible. Dark keeps the brand green; light gets #047857 (5.48:1). */
55
+ --focus-ring: #00d97e;
56
+ --rail-ok-bg: rgba(0, 217, 126, 0.12);
57
+ --rail-fail-bg: rgba(255, 84, 102, 0.12);
58
+ --surface-drawer: #0f1411;
59
+ --drawer-shadow: 0 16px 32px rgba(0, 0, 0, 0.5);
60
+
49
61
  --accent: #00d97e;
50
62
  --accent-2: #34e69b;
51
63
  --accent-soft: rgba(0, 217, 126, 0.10);
@@ -86,6 +98,7 @@
86
98
  --text: #0d1a14;
87
99
  --text2: #56655e;
88
100
  --text3: #55625b;
101
+ --focus-ring: #047857;
89
102
  --accent: #00d97e;
90
103
  --accent-2: #0a7d4d;
91
104
  --accent-soft: rgba(0, 217, 126, 0.12);
@@ -590,6 +603,24 @@ button { font-family: inherit; cursor: pointer; }
590
603
  transition: transform 0.2s ease;
591
604
  }
592
605
  .side.open { transform: translateX(0); }
606
+ /* A document is not a task.
607
+ *
608
+ * 480px is right for a task: a dozen short property rows read fine in a
609
+ * column. A markdown document is prose and tables — ARCH docs, ADRs, plans —
610
+ * and at 480px a table wraps into unreadable confetti and a paragraph becomes
611
+ * six words a line. Opening one at half the window is the difference between
612
+ * reading it here and giving up and opening an editor, which is the failure
613
+ * this panel exists to prevent.
614
+ *
615
+ * `vw` rather than `%`: the panel is absolutely positioned inside the main
616
+ * column, so 50% is half of what is left after the sidebar — 41% of the window
617
+ * on a 1280px laptop, which is not the half screen this is meant to be.
618
+ *
619
+ * `clamp` rather than a flat 50vw: never narrower than the task width on a
620
+ * laptop, and never so wide on an ultrawide that the line length itself becomes
621
+ * the problem. */
622
+ .side.wide { width: clamp(480px, 50vw, 1100px); }
623
+ @media (max-width: 900px) { .side, .side.wide { width: 100%; } }
593
624
  .side-top {
594
625
  display: flex; align-items: center; gap: 10px;
595
626
  padding: 14px 18px;
@@ -1653,10 +1684,12 @@ button { font-family: inherit; cursor: pointer; }
1653
1684
  --agent-failing: var(--status-blocked);
1654
1685
  --agent-retired: var(--text3);
1655
1686
  --agent-unused: var(--text3);
1656
- --rail-ok-bg: rgba(0, 217, 126, 0.12);
1657
- --rail-fail-bg: rgba(255, 84, 102, 0.12);
1658
- --surface-drawer: #0f1411;
1659
- --drawer-shadow: 0 16px 32px rgba(0, 0, 0, 0.5);
1687
+ /* The four theme-specific values that used to sit here have moved to the
1688
+ theme blocks at the top of this file. A bare `:root` this far down the
1689
+ stylesheet has the same specificity as `[data-theme="light"]` and comes
1690
+ after it, so it won — the LIGHT theme was being served the dark drawer
1691
+ surface (#0f1411) and the dark rail tints. The review read this as the dark
1692
+ theme missing tokens; it was the other way round, and the cascade is why. */
1660
1693
  }
1661
1694
 
1662
1695
  /* Fleet summary — uses existing .metric-card; nothing new */
@@ -1671,7 +1704,7 @@ button { font-family: inherit; cursor: pointer; }
1671
1704
  padding: 8px 12px; border-radius: 6px;
1672
1705
  }
1673
1706
  .fleet-search input[type="search"]:focus {
1674
- outline: 2px solid var(--accent); outline-offset: 1px;
1707
+ outline: 2px solid var(--focus-ring); outline-offset: 1px;
1675
1708
  border-color: transparent;
1676
1709
  }
1677
1710
  .fleet-search .search-hint { font-size: 11px; color: var(--text3); font-family: var(--mono); }
@@ -1696,7 +1729,7 @@ button { font-family: inherit; cursor: pointer; }
1696
1729
  transition: background-color 0.15s, color 0.15s, border-color 0.15s;
1697
1730
  }
1698
1731
  .facet-chip:hover { color: var(--text); border-color: var(--border-strong); }
1699
- .facet-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1732
+ .facet-chip:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1700
1733
  .facet-chip[aria-checked="true"] {
1701
1734
  background: var(--accent-soft); color: var(--accent);
1702
1735
  border-color: var(--accent-strong);
@@ -1735,7 +1768,7 @@ button { font-family: inherit; cursor: pointer; }
1735
1768
  transition: background-color 0.15s, border-color 0.15s;
1736
1769
  }
1737
1770
  .agent-row:hover { background: var(--bg-muted); border-color: var(--border); }
1738
- .agent-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1771
+ .agent-row:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1739
1772
  .agent-row[aria-current="true"] {
1740
1773
  background: var(--accent-soft);
1741
1774
  border-color: var(--accent-strong);
@@ -1824,7 +1857,7 @@ button { font-family: inherit; cursor: pointer; }
1824
1857
  display: flex; align-items: center; justify-content: center;
1825
1858
  }
1826
1859
  .agent-drawer-close:hover { color: var(--text); border-color: var(--border-strong); }
1827
- .agent-drawer-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1860
+ .agent-drawer-close:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1828
1861
  .agent-drawer-body {
1829
1862
  flex: 1; overflow-y: auto; padding: 16px 20px;
1830
1863
  display: flex; flex-direction: column; gap: 16px;
@@ -1866,7 +1899,7 @@ button { font-family: inherit; cursor: pointer; }
1866
1899
  padding: 6px 12px; border-radius: 6px;
1867
1900
  }
1868
1901
  .agent-actions button:hover { border-color: var(--border-strong); }
1869
- .agent-actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1902
+ .agent-actions button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1870
1903
  .agent-actions button.danger { color: var(--agent-failing); }
1871
1904
  .agent-actions button.danger:hover { border-color: var(--agent-failing); }
1872
1905
  .agent-actions button[disabled] { opacity: 0.4; cursor: not-allowed; }
@@ -2180,31 +2213,26 @@ button { font-family: inherit; cursor: pointer; }
2180
2213
  <span>Inbox</span>
2181
2214
  <span class="count" id="nav-inbox-count">0</span>
2182
2215
  </div>
2183
- <div class="nav-item" data-tab="kanban" onclick="switchTab('kanban', this)">
2216
+ <div class="nav-item" data-tab="kanban" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('kanban', this)">
2184
2217
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
2185
2218
  <span>Tasks</span>
2186
2219
  <span class="count" id="nav-task-count">0</span>
2187
2220
  </div>
2188
- <div class="nav-item" data-tab="dashboard" onclick="switchTab('dashboard', this)">
2221
+ <div class="nav-item" data-tab="dashboard" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('dashboard', this)">
2189
2222
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18 M7 14l3-3 4 4 5-5"/></svg>
2190
2223
  <span>Metrics</span>
2191
2224
  </div>
2192
- <div class="nav-item" data-tab="memory" onclick="switchTab('memory', this)">
2193
- <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
2194
- <span>Memory</span>
2195
- <span class="count" id="nav-memory-count">0</span>
2225
+ <div class="nav-item" data-tab="docs" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('docs', this)">
2226
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
2227
+ <span>Docs</span>
2228
+ <span class="count" id="nav-docs-count">0</span>
2196
2229
  </div>
2197
- <div class="nav-item" data-tab="sessions" onclick="switchTab('sessions', this)">
2198
- <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
2199
- <span>Sessions</span>
2200
- <span class="count" id="nav-sessions-count">0</span>
2201
- </div>
2202
- <div class="nav-item" data-tab="logs" onclick="switchTab('logs', this)">
2230
+ <div class="nav-item" data-tab="logs" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('logs', this)">
2203
2231
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
2204
- <span>Logs</span>
2232
+ <span>Activity</span>
2205
2233
  <span class="count" id="nav-logs-count">0</span>
2206
2234
  </div>
2207
- <div class="nav-item" data-tab="notifications" onclick="switchTab('notifications', this); loadNotifHistory()">
2235
+ <div class="nav-item" data-tab="notifications" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('notifications', this); loadNotifHistory()">
2208
2236
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9 M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
2209
2237
  <span>Notifications</span>
2210
2238
  <span id="nav-notifications-badge" class="share-badge off" style="margin-left:auto;">
@@ -2379,6 +2407,14 @@ button { font-family: inherit; cursor: pointer; }
2379
2407
  <h2>Session logs</h2>
2380
2408
  <span class="logs-hint">Written by <code>/save</code> · Restored by <code>/resume</code></span>
2381
2409
  </div>
2410
+ <!-- Sessions used to be its own sidebar entry. Both answered "what did
2411
+ the agents do" — this panel from written summaries, that one from raw
2412
+ transcripts — so a reader had to choose which to open before knowing
2413
+ which held the answer. One entry, two depths. -->
2414
+ <div style="display:flex;gap:8px;margin:0 0 12px">
2415
+ <button id="act-tab-logs" class="facet-chip active" onclick="setActivityView('logs')">Summaries</button>
2416
+ <button id="act-tab-sessions" class="facet-chip" onclick="setActivityView('sessions')">Transcripts</button>
2417
+ </div>
2382
2418
  <div class="logs-layout">
2383
2419
  <div class="logs-list" id="logs-list"><div class="empty">Loading…</div></div>
2384
2420
  <div class="logs-detail" id="logs-detail"><div class="empty">Select a session to view</div></div>
@@ -2482,6 +2518,11 @@ button { font-family: inherit; cursor: pointer; }
2482
2518
  <div id="sessions-body" class="muted">Loading…</div>
2483
2519
  </div>
2484
2520
 
2521
+ <div class="panel" id="panel-docs">
2522
+ <h1>Docs</h1>
2523
+ <div id="docs-body" class="muted">loading…</div>
2524
+ </div>
2525
+
2485
2526
  <div class="panel" id="panel-memory">
2486
2527
  <div class="memory-page">
2487
2528
  <div class="memory-side">
@@ -2842,6 +2883,33 @@ async function refreshResume() {
2842
2883
  if (d) renderResume(d);
2843
2884
  }
2844
2885
 
2886
+ /**
2887
+ * What a verdict says, in the width of a column.
2888
+ *
2889
+ * Two shapes reach here: the legacy `agent: free text` line, and the versioned
2890
+ * JSON record the verdict helper writes today. Only the first has anything
2891
+ * readable after the colon — for the second, splitting on `': '` yields the
2892
+ * whole record, which is how a JSON blob ended up rendered as a summary.
2893
+ */
2894
+ function summariseVerdict(v, raw) {
2895
+ let meta = null;
2896
+ const t = String(raw || '').trim();
2897
+ if (t.startsWith('{')) { try { meta = JSON.parse(t).meta || null; } catch { meta = null; } }
2898
+
2899
+ if (meta) {
2900
+ // `scope` is what the run was about; the rest are the artefacts it produced.
2901
+ const what = meta.scope || meta.feature || meta.task || null;
2902
+ const artefact = Object.values(meta).find(x => typeof x === 'string' && x.endsWith('.md'));
2903
+ const tail = what || (artefact ? artefact.split('/').pop() : null);
2904
+ return tail ? `${v.verdict || 'verdict'} · ${tail}` : (v.verdict || 'verdict');
2905
+ }
2906
+
2907
+ const i = t.indexOf(': ');
2908
+ if (i !== -1) return t.slice(i + 2);
2909
+ // Not JSON and not `agent: text` — a bare line, which is still readable.
2910
+ return t || (v.verdict || 'verdict');
2911
+ }
2912
+
2845
2913
  function renderResume(d) {
2846
2914
  const card = document.getElementById('resume-card');
2847
2915
  if (!card) return;
@@ -2868,8 +2936,16 @@ function renderResume(d) {
2868
2936
  const vu = (v.verdict || '').toUpperCase();
2869
2937
  const cls = ['APPROVED','DONE','PASS'].includes(vu) ? 'ok' :
2870
2938
  ['BLOCKED','FAIL','REJECTED'].includes(vu) ? 'fail' : '';
2939
+ // A verdict is written in one of two shapes: the legacy `AGENT: text` line,
2940
+ // and the versioned JSON record the helper writes today. This column split
2941
+ // on `': '` and printed whatever followed — which for the JSON shape is the
2942
+ // whole record, so the column that should read "DONE · board-review" showed
2943
+ // `{"v":1,"ts":"2026-08-09T06:19:37Z","agent":"design-advis…`.
2944
+ //
2945
+ // The structured fields are already parsed by the time they arrive; the raw
2946
+ // line is a fallback for the legacy shape, not the primary source.
2871
2947
  const raw = v.raw || '';
2872
- const after = raw.indexOf(': ') !== -1 ? raw.slice(raw.indexOf(': ') + 2) : raw;
2948
+ const after = summariseVerdict(v, raw);
2873
2949
  window.__resumeVerdicts = verdicts;
2874
2950
  return `<div class="resume-item" onclick="showVerdictDetail(${i})" role="button" tabindex="0">
2875
2951
  <span class="ri-tag ${cls}">${esc(v.agent || 'agent')}</span>
@@ -3302,6 +3378,222 @@ function mdToHtml(md) {
3302
3378
  });
3303
3379
  }
3304
3380
 
3381
+ // Activity — one entry, two depths.
3382
+ //
3383
+ // Sessions and Logs were separate sidebar items answering the same question,
3384
+ // "what did the agents do", from written summaries and from raw transcripts. A
3385
+ // reader had to choose which to open before knowing which one held the answer.
3386
+ function setActivityView(view) {
3387
+ const logsPanel = document.getElementById('logs-list')?.closest('.panel') || document.getElementById('panel-logs');
3388
+ const sessHost = document.getElementById('sessions-body');
3389
+ const logsHost = document.getElementById('logs-list')?.parentElement;
3390
+ const inPanel = document.getElementById('activity-sessions-host');
3391
+
3392
+ document.getElementById('act-tab-logs')?.classList.toggle('active', view === 'logs');
3393
+ document.getElementById('act-tab-sessions')?.classList.toggle('active', view === 'sessions');
3394
+
3395
+ // The transcript view is rendered by the Sessions panel's own code; it is moved
3396
+ // into this panel rather than reimplemented, so the two cannot drift apart.
3397
+ let host = inPanel;
3398
+ if (!host) {
3399
+ host = document.createElement('div');
3400
+ host.id = 'activity-sessions-host';
3401
+ logsPanel?.appendChild(host);
3402
+ }
3403
+ if (view === 'sessions') {
3404
+ if (logsHost) logsHost.style.display = 'none';
3405
+ host.style.display = '';
3406
+ if (sessHost && sessHost.parentElement !== host) host.appendChild(sessHost);
3407
+ loadSessions();
3408
+ } else {
3409
+ host.style.display = 'none';
3410
+ if (logsHost) logsHost.style.display = '';
3411
+ }
3412
+ }
3413
+
3414
+ // ── Docs ────────────────────────────────────────────────────────────────────
3415
+ //
3416
+ // A project accumulates documentation nobody can find: this repository has
3417
+ // twenty architecture documents, eleven ADRs and thirty-one plans, and the only
3418
+ // way to read one was to know its filename.
3419
+ //
3420
+ // The two diagrams are computed on request rather than stored. docs/ARCHITECTURE.md
3421
+ // is hand-drawn, three months old, and says "34 agents" where there are
3422
+ // sixty-nine — a drawn picture goes stale silently, a derived one cannot.
3423
+ let _docsCache = null;
3424
+
3425
+ /**
3426
+ * The sidebar badge, without rendering the panel.
3427
+ *
3428
+ * It was written only inside `loadDocs`, which runs when the tab is opened — so
3429
+ * a project with a hundred and seventy-five documents showed `0` until you
3430
+ * clicked, and `0` is the one number that reads as "there is nothing here".
3431
+ * A dash says "not counted yet", which is what it actually means.
3432
+ */
3433
+ async function refreshDocsCount() {
3434
+ const el = document.getElementById('nav-docs-count');
3435
+ if (!el) return;
3436
+ let d;
3437
+ try { d = await api(`/api/docs${pqs()}`); } catch { d = null; }
3438
+ el.textContent = (d && !d.error && typeof d.total === 'number') ? d.total : '—';
3439
+ }
3440
+
3441
+ async function loadDocs() {
3442
+ const el = document.getElementById('docs-body');
3443
+ el.innerHTML = '<span class="muted">loading…</span>';
3444
+ let d;
3445
+ try {
3446
+ // pqs() carries the selected project — without it this panel would repeat
3447
+ // the defect that made sixteen endpoints show the server's own directory.
3448
+ d = await api(`/api/docs${pqs()}`);
3449
+ } catch (e) {
3450
+ // "Could not read" must never render as "there is nothing here" — the
3451
+ // failure this board keeps making in other forms.
3452
+ el.innerHTML = `<div class="card"><b>Could not read the project's documents.</b>
3453
+ <div class="muted" style="margin-top:6px">${String(e && e.message || e)}</div></div>`;
3454
+ return;
3455
+ }
3456
+ _docsCache = d;
3457
+ document.getElementById('nav-docs-count').textContent = d.total || 0;
3458
+
3459
+ const pipeline = d.pipeline
3460
+ ? `<div class="card"><b>Pipeline</b> <span class="muted">— how a feature moves, drawn from shared/pipeline.toml</span>
3461
+ ${renderPipeline(d.pipeline)}
3462
+ <details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
3463
+ <pre style="white-space:pre-wrap;font-size:11px">${esc(d.pipeline)}</pre></details></div>`
3464
+ : '';
3465
+
3466
+ const map = d.map ? `<div class="card"><b>Components</b>
3467
+ <span class="muted">— derived ${ago(d.map.generatedAt)}, so it cannot disagree with the code</span>
3468
+ <div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:10px">
3469
+ ${d.map.nodes.map(n => `<div style="border:1px solid var(--border,#ddd);border-radius:6px;padding:8px 10px;min-width:120px">
3470
+ <div><b>${esc(n.label)}</b> <span class="muted">${n.count}</span></div>
3471
+ <div class="muted" style="font-size:11px">${esc(n.what || '')}</div></div>`).join('')}
3472
+ </div>
3473
+ ${d.map.edges.length ? `<div class="muted" style="margin-top:8px;font-size:11px">
3474
+ ${d.map.edges.map(e => `${esc(e.from)}→${esc(e.to)} (${e.count})`).join(' · ')}</div>` : ''}
3475
+ <details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
3476
+ <pre style="white-space:pre-wrap;font-size:11px">${esc(d.map.mermaid)}</pre></details></div>` : '';
3477
+
3478
+ const groups = (d.groups || []).map(g => `<div class="card">
3479
+ <b>${esc(g.label)}</b> <span class="muted">${g.docs.length} — ${esc(g.why || '')}</span>
3480
+ <div style="margin-top:8px">${g.docs.map(doc => `
3481
+ <div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
3482
+ <a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
3483
+ <span class="muted" style="font-size:11px"> · ${esc(doc.path)} · ${ago(doc.modified)}</span>
3484
+ </div>`).join('')}</div>
3485
+ </div>`).join('');
3486
+
3487
+ // Memory used to be its own tab listing PROJECT.md, CODEBASE.md and lessons —
3488
+ // the same files this panel's "This project" group already showed. One section
3489
+ // for documentation, as asked; what Memory knew that a directory walk does not
3490
+ // is folded in rather than dropped.
3491
+ const mem = await loadContextLayers();
3492
+
3493
+ // The document itself opens in the side panel now, so this panel no longer
3494
+ // needs a place to append one.
3495
+ el.innerHTML = pipeline + map + mem + groups;
3496
+ }
3497
+
3498
+ /**
3499
+ * The context an agent reads, and the patterns learned across projects.
3500
+ *
3501
+ * A directory walk finds files that exist. This list is the CANONICAL set of
3502
+ * layers, so a layer that is absent is information — "this project has recorded
3503
+ * no lessons yet" is a fact about the project, not an empty row to hide. Three
3504
+ * of them live in ~/.great_cto and are shared by every project, which is why
3505
+ * they never appeared in a per-project walk at all.
3506
+ */
3507
+ async function loadContextLayers() {
3508
+ let m;
3509
+ try { m = await api(`/api/memory${pqs()}`); } catch { m = null; }
3510
+ if (!m || m.error) {
3511
+ return `<div class="card"><b>Agent context</b>
3512
+ <div class="muted" style="margin-top:6px">could not be read — ${esc((m && m.error) || degradedFor('/api/memory') || 'no response')}</div></div>`;
3513
+ }
3514
+
3515
+ const layers = (m.layers || []).map(l => {
3516
+ const global = String(l.path || '').includes('/.great_cto/') && !String(l.path || '').includes(projectRootHint());
3517
+ const where = global ? ' <span class="muted" style="font-size:11px">shared by every project</span>' : '';
3518
+ return l.exists
3519
+ ? `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
3520
+ <a href="#" onclick="openDoc('${esc(l.path)}');return false">${esc(l.id)}</a>${where}
3521
+ <span class="muted" style="font-size:11px"> · ${esc(l.path)}</span></div>`
3522
+ : `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)" class="muted">
3523
+ ${esc(l.id)} — not written yet${where}</div>`;
3524
+ }).join('');
3525
+
3526
+ const pats = (m.patterns || []).map(p =>
3527
+ `<div style="padding:4px 0" class="muted">${esc(p.id)} — ${esc(p.title || '')}</div>`).join('');
3528
+
3529
+ return `<div class="card">
3530
+ <b>Agent context</b> <span class="muted">${(m.layers || []).filter(l => l.exists).length} of ${(m.layers || []).length} written — what an agent reads before it starts</span>
3531
+ <div style="margin-top:8px">${layers}</div>
3532
+ </div>` + (pats ? `<div class="card">
3533
+ <b>Learned patterns</b> <span class="muted">${(m.patterns || []).length} — failures seen often enough to be written down</span>
3534
+ <div style="margin-top:8px">${pats}</div></div>` : '');
3535
+ }
3536
+
3537
+ /** The selected project's root, for telling a shared layer from a local one. */
3538
+ function projectRootHint() {
3539
+ const crumb = document.querySelector('.crumb-project, #crumb-project');
3540
+ return (crumb && crumb.textContent || '').trim() || '\u0000';
3541
+ }
3542
+
3543
+ /** The pipeline as boxes and gates — no diagram library, so it always renders. */
3544
+ function renderPipeline(mermaid) {
3545
+ const edges = [];
3546
+ for (const line of String(mermaid).split('\n')) {
3547
+ const m = line.match(/^\s*(\w+)\s*-->(?:\|([^|]*)\|)?\s*(\w+)/);
3548
+ if (m) edges.push({ from: m[1], gate: (m[2] || '').trim(), to: m[3], join: false });
3549
+ const j = line.match(/^\s*(\w+)\s*-\.->\|join\|\s*(\w+)/);
3550
+ if (j) edges.push({ from: j[1], to: j[2], join: true });
3551
+ }
3552
+ if (!edges.length) return '<div class="muted">no transitions declared</div>';
3553
+ const name = (id) => id.replace(/_/g, '-');
3554
+ return `<div style="margin-top:10px;font-size:12px">${edges.map(e => `
3555
+ <div style="display:flex;align-items:center;gap:8px;padding:3px 0">
3556
+ <span style="border:1px solid var(--border,#ddd);border-radius:4px;padding:2px 8px">${esc(name(e.from))}</span>
3557
+ <span class="muted">${e.join ? '⋯ join ⋯' : (e.gate ? `→ ${esc(e.gate)} →` : '→')}</span>
3558
+ <span style="border:1px solid var(--border,#ddd);border-radius:4px;padding:2px 8px">${esc(name(e.to))}</span>
3559
+ </div>`).join('')}</div>`;
3560
+ }
3561
+
3562
+ /**
3563
+ * Open a document in the side panel — the same drawer a task, a verdict and a
3564
+ * decision open in.
3565
+ *
3566
+ * It used to append the rendered markdown to the BOTTOM of the Docs panel and
3567
+ * scroll there, so reading a document meant losing the list you were browsing,
3568
+ * and closing it meant scrolling back up. Every other section of this board
3569
+ * answers a click by sliding the detail in from the right; this one invented its
3570
+ * own mechanic, and a reader has to learn two.
3571
+ */
3572
+ async function openDoc(p) {
3573
+ const panel = document.getElementById('side-panel');
3574
+ const body = document.getElementById('side-body');
3575
+ const idEl = document.getElementById('side-id');
3576
+ const tl = document.getElementById('side-timeline');
3577
+ if (!panel || !body) return;
3578
+
3579
+ idEl.textContent = p.split('/').pop();
3580
+ body.innerHTML = '<div style="padding:18px" class="muted">loading…</div>';
3581
+ if (tl) tl.innerHTML = '';
3582
+ panel.classList.add('open', 'wide');
3583
+
3584
+ try {
3585
+ const q = pqs();
3586
+ const d = await api(`/api/doc?path=${encodeURIComponent(p)}${q ? '&' + q.slice(1) : ''}`);
3587
+ if (!d || d.error) throw new Error(d?.error || 'doc not found');
3588
+ idEl.textContent = d.name || p;
3589
+ body.innerHTML = `<div class="muted" style="font-size:11px;margin-bottom:10px">${esc(d.path)}</div>
3590
+ <div class="side-desc md">${mdToHtml(d.content)}</div>`;
3591
+ } catch (e) {
3592
+ body.innerHTML = `<div class="side-desc"><b>Could not open ${esc(p)}</b>
3593
+ <div class="muted" style="margin-top:6px">${esc(String(e && e.message || e))}</div></div>`;
3594
+ }
3595
+ }
3596
+
3305
3597
  // ── Sessions ────────────────────────────────────────────────────────────────
3306
3598
  //
3307
3599
  // Every other panel answers "what happened". This one answers "how", which is
@@ -3402,10 +3694,28 @@ async function searchTranscripts() {
3402
3694
 
3403
3695
  async function loadMemory() {
3404
3696
  const d = await api(`/api/memory${pqs()}`);
3405
- memoryData = d || { layers: [], patterns: [] };
3697
+ // `d || {layers: []}` collapsed a failed read into "this project has no
3698
+ // memory" — the two look identical to a reader and mean opposite things.
3699
+ if (!d || d._neterr || d.error) {
3700
+ memoryData = { layers: [], patterns: [] };
3701
+ renderMemorySidebar();
3702
+ const c = document.getElementById('nav-memory-count'); if (c) c.textContent = '—';
3703
+ renderDegradedBanner('degraded-memory',
3704
+ (d && (d.error || (d._neterr && 'board not reachable — restart it'))) || 'no response',
3705
+ 'Memory could not be read');
3706
+ return;
3707
+ }
3708
+ renderDegradedBanner('degraded-memory', degradedFor('/api/memory'), 'Memory could not be read');
3709
+ memoryData = d;
3406
3710
  renderMemorySidebar();
3407
- document.getElementById('nav-memory-count').textContent =
3408
- (memoryData.layers || []).filter(l => l.exists).length + (memoryData.patterns || []).length;
3711
+ // Memory folded into Docs, so this badge no longer exists. It was still
3712
+ // written unconditionally, and the TypeError left the rest of this function —
3713
+ // including the auto-select below — unreached on every page load.
3714
+ const memCount = document.getElementById('nav-memory-count');
3715
+ if (memCount) {
3716
+ memCount.textContent =
3717
+ (memoryData.layers || []).filter(l => l.exists).length + (memoryData.patterns || []).length;
3718
+ }
3409
3719
  // Auto-select first existing layer
3410
3720
  if (!activeMemKey) {
3411
3721
  const first = (memoryData.layers || []).find(l => l.exists);
@@ -3628,6 +3938,7 @@ async function switchProject(slug) {
3628
3938
  try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
3629
3939
  try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
3630
3940
  loadMemory();
3941
+ refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
3631
3942
  refreshCost();
3632
3943
  refreshPipeline();
3633
3944
  refreshLogs(); // populate the Logs nav badge (was lazy — stuck at 0 until the tab was opened)
@@ -3816,6 +4127,7 @@ async function init() {
3816
4127
  try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
3817
4128
  try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
3818
4129
  loadMemory();
4130
+ refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
3819
4131
  refreshCost();
3820
4132
  refreshPipeline();
3821
4133
  refreshLogs(); // populate the Logs nav badge (was lazy — stuck at 0 until the tab was opened)
@@ -3875,7 +4187,15 @@ function api(url, opts) {
3875
4187
  if (d) { try { BOARD_DEGRADED[key] = decodeURIComponent(d); } catch { BOARD_DEGRADED[key] = d; } }
3876
4188
  else delete BOARD_DEGRADED[key];
3877
4189
  return r.json();
3878
- }).catch(() => null);
4190
+ }).catch(() => {
4191
+ // A request that never completed is the one case the degradation mechanism
4192
+ // could not see: the header lives on a response, and there is no response.
4193
+ // So the board went quiet exactly when it had lost contact with itself —
4194
+ // and worse, a key deleted by an earlier success stayed deleted, leaving
4195
+ // every panel looking healthy while nothing could be read at all.
4196
+ BOARD_DEGRADED[key] = 'the board is not reachable — is the server still running?';
4197
+ return null;
4198
+ });
3879
4199
  }
3880
4200
 
3881
4201
  function connectSSE() {
@@ -4647,7 +4967,10 @@ function openSide(task) {
4647
4967
  ${acceptanceSec}
4648
4968
  ${notesSec}
4649
4969
  ${task.close_reason ? `<div class="result-box"><div class="side-section-head">Close reason</div>${esc(task.close_reason)}</div>` : ''}`;
4650
- document.getElementById('side-panel').classList.add('open');
4970
+ const sidePanel = document.getElementById('side-panel');
4971
+ sidePanel.classList.add('open');
4972
+ // A task after a document: back to the narrower column the rows were sized for.
4973
+ sidePanel.classList.remove('wide');
4651
4974
  // Clear and load timeline
4652
4975
  const tlEl = document.getElementById('side-timeline');
4653
4976
  if (tlEl) tlEl.innerHTML = '';
@@ -4682,6 +5005,7 @@ async function openDocInSide(rel) {
4682
5005
  const d = await api(`/api/doc?path=${encodeURIComponent(rel)}${qs}`);
4683
5006
  if (!d || d.error) { showToast('✗ ' + esc(d?.error || 'doc not found'), 'error', 3500); return; }
4684
5007
  document.getElementById('side-id').textContent = d.name;
5008
+ document.getElementById('side-panel').classList.add('wide');
4685
5009
  document.getElementById('side-body').innerHTML = `
4686
5010
  <button class="gate-btn" onclick="openSide(_sideTask)" style="margin-bottom:12px">← back to task</button>
4687
5011
  <div class="side-desc md">${mdRender(d.content)}</div>`;
@@ -4694,6 +5018,11 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSide();
4694
5018
 
4695
5019
  /* ── Tabs ───────────────────────────────────────────────────────────────── */
4696
5020
  function switchTab(id, el) {
5021
+ // `agents` has no sidebar entry by design: it is a drill-in from Dashboard.
5022
+ // Without this the call fell through to code that expects a nav element and
5023
+ // left the sidebar with nothing selected.
5024
+ if (!el) el = document.querySelector(`.nav-item[data-tab="${id}"]`)
5025
+ || document.querySelector('.nav-item[data-tab="dashboard"]');
4697
5026
  currentTab = id;
4698
5027
  // sidebar nav
4699
5028
  document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
@@ -4708,15 +5037,24 @@ function switchTab(id, el) {
4708
5037
  // crumb — `labels` used to be referenced here but is only ever a function-local in
4709
5038
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
4710
5039
  // loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
4711
- const TAB_LABELS = { dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', memory: 'Memory', sessions: 'Sessions', logs: 'Logs', notifications: 'Notifications', metrics: 'Metrics' };
5040
+ const TAB_LABELS = { docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
4712
5041
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
4713
5042
  if (id === 'dashboard' || id === 'agents') {
4714
- api(`/api/metrics${pqs()}`).then(m => { if (m) { metrics = m; renderDashboard(m); } });
5043
+ // pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
5044
+ // its 30-day default and overwrote whatever window the period chips had
5045
+ // selected. Pick "7d", switch tabs, come back — the chip still read 7d and
5046
+ // the numbers were thirty days old, which is the period selector appearing
5047
+ // not to work at all.
5048
+ //
5049
+ // Through the sequence guard too, so a tab switch cannot land after a newer
5050
+ // period request and clobber it.
5051
+ _refreshMetricsRaced(`/api/metrics${pqsd()}`).then(m => { if (m) { metrics = m; renderDashboard(m); } });
4715
5052
  if (id === 'dashboard') refreshCost();
4716
5053
  if (id === 'agents') refreshAgentsInstalled();
4717
5054
  }
4718
5055
  if (id === 'inbox') refreshInbox();
4719
- if (id === 'memory') loadMemory();
5056
+ if (id === 'logs') setActivityView('logs');
5057
+ if (id === 'docs') loadDocs();
4720
5058
  if (id === 'sessions') loadSessions();
4721
5059
  if (id === 'logs') refreshLogs();
4722
5060
  if (id === 'notifications') loadNotifications();
@@ -4732,6 +5070,12 @@ async function refreshLogs() {
4732
5070
  const countEl = document.getElementById('nav-logs-count'); if (countEl) countEl.textContent = '0';
4733
5071
  return;
4734
5072
  }
5073
+ // The server already says WHY a read came back short — /api/logs has emitted
5074
+ // X-Board-Degraded since the tasks fix, and until now the UI threw it away.
5075
+ // An unreadable logs directory rendered as a project that had never run.
5076
+ renderDegradedBanner('degraded-logs', degradedFor('/api/logs'),
5077
+ 'Session logs could not be read');
5078
+
4735
5079
  allLogs = data.logs || [];
4736
5080
  const countEl = document.getElementById('nav-logs-count');
4737
5081
  if (countEl) countEl.textContent = allLogs.length;
@@ -74,7 +74,7 @@ export function validateVerdict(rec) {
74
74
  }
75
75
 
76
76
  /** Build a v1 record, normalising what the caller passed. Throws if unusable. */
77
- export function makeVerdict({ ts, agent, verdict, project, cost_usd, meta } = {}) {
77
+ export function makeVerdict({ ts, agent, verdict, project, cost_usd, meta, receipt } = {}) {
78
78
  const rec = {
79
79
  v: VERDICT_FORMAT_VERSION,
80
80
  ts: String(ts ?? '').trim(),
@@ -84,6 +84,12 @@ export function makeVerdict({ ts, agent, verdict, project, cost_usd, meta } = {}
84
84
  if (project) rec.project = String(project).trim();
85
85
  if (cost_usd !== undefined && cost_usd !== null && cost_usd !== '') rec.cost_usd = Number(cost_usd);
86
86
  if (meta && Object.keys(meta).length) rec.meta = meta;
87
+ // The fingerprint of the tree this verdict was formed over — see receipt.mjs.
88
+ // Optional by design: every log written before this existed still validates,
89
+ // and a verdict whose receipt could not be built is still a verdict. Absent
90
+ // means "no receipt", which the checker reports as its own state rather than
91
+ // as a match.
92
+ if (receipt && typeof receipt === 'object' && receipt.head) rec.receipt = receipt;
87
93
 
88
94
  const { valid, errors } = validateVerdict(rec);
89
95
  // Refusing here is the point: a malformed record that reaches the log is read
@@ -92,6 +98,16 @@ export function makeVerdict({ ts, agent, verdict, project, cost_usd, meta } = {}
92
98
  return rec;
93
99
  }
94
100
 
101
+ /** A receipt from the environment, or nothing. Never throws — an unparseable
102
+ * receipt must not stop a verdict from being recorded. */
103
+ export function parseReceiptEnv(raw) {
104
+ if (!raw || !String(raw).trim()) return undefined;
105
+ try {
106
+ const r = JSON.parse(raw);
107
+ return r && r.head ? r : undefined;
108
+ } catch { return undefined; }
109
+ }
110
+
95
111
  /** One record → one NDJSON line (no trailing newline). */
96
112
  export function formatVerdict(rec) {
97
113
  return JSON.stringify(rec);
@@ -202,6 +218,7 @@ async function main(argv) {
202
218
  project: process.env.PROJECT_SLUG || undefined,
203
219
  cost_usd: process.env.COST === '' || process.env.COST === undefined ? undefined : Number(process.env.COST),
204
220
  meta,
221
+ receipt: parseReceiptEnv(process.env.RECEIPT),
205
222
  })) + '\n');
206
223
  return 0;
207
224
  } catch (e) {