great-cto 2.95.0 → 2.97.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.
- package/board/.claude-plugin/plugin.json +5 -1
- package/board/packages/board/lib/beads.mjs +53 -5
- package/board/packages/board/lib/data-readers.mjs +53 -8
- package/board/packages/board/lib/docs.mjs +176 -0
- package/board/packages/board/lib/fleet.mjs +13 -2
- package/board/packages/board/lib/portfolio.mjs +128 -0
- package/board/packages/board/lib/projects.mjs +31 -0
- package/board/packages/board/lib/routes.mjs +253 -11
- package/board/packages/board/lib/verdicts.mjs +63 -9
- package/board/packages/board/public/index.html +568 -34
- package/board/scripts/lib/freshness.mjs +175 -0
- package/board/scripts/lib/gate-tier.mjs +279 -0
- package/board/scripts/lib/pipeline-wake.mjs +120 -0
- package/board/scripts/lib/receipt.mjs +386 -0
- package/board/scripts/lib/stand-down.mjs +147 -0
- package/board/scripts/lib/system-map.mjs +206 -0
- package/board/scripts/lib/verdict-record.mjs +18 -1
- package/dist/detect.js +1 -0
- package/dist/main.js +40 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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="
|
|
2193
|
-
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="
|
|
2194
|
-
<span>
|
|
2195
|
-
<span class="count" id="nav-
|
|
2196
|
-
</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>
|
|
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>
|
|
2201
2229
|
</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>
|
|
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;">
|
|
@@ -2256,6 +2284,9 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2256
2284
|
<div class="inbox-page">
|
|
2257
2285
|
<div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
|
|
2258
2286
|
<div class="inbox-summary" id="inbox-summary"></div>
|
|
2287
|
+
<!-- Top rung of the evidence ladder: are the bytes that were reviewed
|
|
2288
|
+
still the bytes in the tree? Hidden while they match. -->
|
|
2289
|
+
<div id="inbox-receipt" style="display:none;margin:6px 0 2px;font-size:12px"></div>
|
|
2259
2290
|
|
|
2260
2291
|
<!-- Resume — pick up where you left off -->
|
|
2261
2292
|
<div class="resume-card" id="resume-card" style="display:none">
|
|
@@ -2304,6 +2335,17 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2304
2335
|
</div>
|
|
2305
2336
|
<div id="inbox-gates"></div>
|
|
2306
2337
|
</div>
|
|
2338
|
+
<!-- Gates that did NOT wait for you. Placed directly under the decisions
|
|
2339
|
+
you still owe, because "what proceeded without me" is the question a
|
|
2340
|
+
tiered gate creates and nothing on this board answered. -->
|
|
2341
|
+
<div class="inbox-section" id="inbox-standdown-section" style="display:none">
|
|
2342
|
+
<div class="inbox-section-head">
|
|
2343
|
+
<span class="dot dot-amber"></span>
|
|
2344
|
+
<h3>Proceeded without you</h3>
|
|
2345
|
+
<span class="inbox-count" id="inbox-standdown-count">0</span>
|
|
2346
|
+
</div>
|
|
2347
|
+
<div id="inbox-standdown"></div>
|
|
2348
|
+
</div>
|
|
2307
2349
|
<div class="inbox-section" id="inbox-p0-section">
|
|
2308
2350
|
<div class="inbox-section-head">
|
|
2309
2351
|
<span class="dot dot-red"></span>
|
|
@@ -2379,6 +2421,14 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2379
2421
|
<h2>Session logs</h2>
|
|
2380
2422
|
<span class="logs-hint">Written by <code>/save</code> · Restored by <code>/resume</code></span>
|
|
2381
2423
|
</div>
|
|
2424
|
+
<!-- Sessions used to be its own sidebar entry. Both answered "what did
|
|
2425
|
+
the agents do" — this panel from written summaries, that one from raw
|
|
2426
|
+
transcripts — so a reader had to choose which to open before knowing
|
|
2427
|
+
which held the answer. One entry, two depths. -->
|
|
2428
|
+
<div style="display:flex;gap:8px;margin:0 0 12px">
|
|
2429
|
+
<button id="act-tab-logs" class="facet-chip active" onclick="setActivityView('logs')">Summaries</button>
|
|
2430
|
+
<button id="act-tab-sessions" class="facet-chip" onclick="setActivityView('sessions')">Transcripts</button>
|
|
2431
|
+
</div>
|
|
2382
2432
|
<div class="logs-layout">
|
|
2383
2433
|
<div class="logs-list" id="logs-list"><div class="empty">Loading…</div></div>
|
|
2384
2434
|
<div class="logs-detail" id="logs-detail"><div class="empty">Select a session to view</div></div>
|
|
@@ -2482,6 +2532,11 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2482
2532
|
<div id="sessions-body" class="muted">Loading…</div>
|
|
2483
2533
|
</div>
|
|
2484
2534
|
|
|
2535
|
+
<div class="panel" id="panel-docs">
|
|
2536
|
+
<h1>Docs</h1>
|
|
2537
|
+
<div id="docs-body" class="muted">loading…</div>
|
|
2538
|
+
</div>
|
|
2539
|
+
|
|
2485
2540
|
<div class="panel" id="panel-memory">
|
|
2486
2541
|
<div class="memory-page">
|
|
2487
2542
|
<div class="memory-side">
|
|
@@ -2713,7 +2768,12 @@ function greetByHour() {
|
|
|
2713
2768
|
function renderInbox(d) {
|
|
2714
2769
|
inboxData = d || {};
|
|
2715
2770
|
const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
|
|
2716
|
-
|
|
2771
|
+
// `needs_you` counts distinct objects. Summing the three categories double-counted
|
|
2772
|
+
// anything that matched two of them — a P0 that is also blocked read as two things
|
|
2773
|
+
// needing attention when it was one. Older payloads have no `needs_you`, so the sum
|
|
2774
|
+
// stays as the fallback rather than rendering nothing.
|
|
2775
|
+
document.getElementById('nav-inbox-count').textContent =
|
|
2776
|
+
Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
|
|
2717
2777
|
// Same rule as the all-clear card: "nothing urgent" is a claim about data we
|
|
2718
2778
|
// may not have. When a read failed, say that instead of reassuring the user.
|
|
2719
2779
|
const greetTail = anyDegraded()
|
|
@@ -2730,6 +2790,8 @@ function renderInbox(d) {
|
|
|
2730
2790
|
renderInboxList('inbox-p0', d?.p0_open || [], 'inbox-p0-count', {});
|
|
2731
2791
|
renderInboxList('inbox-blocked', d?.blocked || [], 'inbox-blocked-count', {});
|
|
2732
2792
|
renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', {});
|
|
2793
|
+
refreshStandDowns();
|
|
2794
|
+
refreshReceipt();
|
|
2733
2795
|
// Idle focus: collapse the empty sections into one all-clear card when nothing needs a decision.
|
|
2734
2796
|
const attention = (d?.pending_gates || []).length + (d?.p0_open || []).length
|
|
2735
2797
|
+ (d?.blocked || []).length + (d?.stale_in_progress || []).length;
|
|
@@ -2744,6 +2806,150 @@ function renderInbox(d) {
|
|
|
2744
2806
|
'Some project data could not be read — counts below are incomplete.');
|
|
2745
2807
|
}
|
|
2746
2808
|
|
|
2809
|
+
/**
|
|
2810
|
+
* Does the last approval still describe what is in the tree?
|
|
2811
|
+
*
|
|
2812
|
+
* The reviewing agents already record a hash-bound receipt of the bytes they
|
|
2813
|
+
* read. Nothing on this board looked at it, so the top rung of the evidence
|
|
2814
|
+
* ladder was being written and never read.
|
|
2815
|
+
*
|
|
2816
|
+
* Five readings, and only three of them speak:
|
|
2817
|
+
* differs reviewed files changed after the approval — the actionable one
|
|
2818
|
+
* unreadable we could not tell. Shown, because "cannot check" must never
|
|
2819
|
+
* render as "checked and fine"
|
|
2820
|
+
* extended new files appeared; nothing reviewed was altered — worth a
|
|
2821
|
+
* quiet line, since the addition was never reviewed either
|
|
2822
|
+
* matches silent. A clean state does not need a banner.
|
|
2823
|
+
* no-receipt silent. Nothing has been approved yet; saying so would nag
|
|
2824
|
+
* about a stage that has not happened.
|
|
2825
|
+
*/
|
|
2826
|
+
async function refreshReceipt() {
|
|
2827
|
+
const el = document.getElementById('inbox-receipt');
|
|
2828
|
+
if (!el) return;
|
|
2829
|
+
const d = await api(`/api/receipt${pqs()}`);
|
|
2830
|
+
if (!d || d.state === 'matches' || d.state === 'no-receipt') { el.style.display = 'none'; return; }
|
|
2831
|
+
|
|
2832
|
+
const stamp = d.agent ? `${esc(d.agent)}${d.ts ? ' · ' + relTime(d.ts) : ''}` : '';
|
|
2833
|
+
const body = d.state === 'differs'
|
|
2834
|
+
? `<b>${(d.changed || []).length + (d.removed || []).length} reviewed file(s) changed since ${stamp} approved</b>
|
|
2835
|
+
— the approval no longer describes what is in the tree.
|
|
2836
|
+
<span class="muted">${esc((d.changed || []).slice(0, 4).join(', '))}${(d.changed || []).length > 4 ? ' …' : ''}</span>`
|
|
2837
|
+
: d.state === 'unreadable'
|
|
2838
|
+
? `<b>Could not check the review receipt</b> — <span class="muted">${esc(d.why || '')}</span>. This is not the same as a clean tree.`
|
|
2839
|
+
: `${(d.added || []).length} file(s) added since ${stamp} approved; nothing reviewed was altered.`;
|
|
2840
|
+
|
|
2841
|
+
const colour = d.state === 'matches' ? 'var(--text3)' : 'var(--status-gate)';
|
|
2842
|
+
el.style.display = '';
|
|
2843
|
+
el.innerHTML = `<span style="color:${colour}">${body}</span>`;
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/**
|
|
2847
|
+
* Is this document still true? — which is not the question `modified` answers.
|
|
2848
|
+
*
|
|
2849
|
+
* Three states, and the two that are not `fresh` look different from each other:
|
|
2850
|
+
*
|
|
2851
|
+
* stale the author's own `stale_after` has passed, or its date is older
|
|
2852
|
+
* than the threshold — say so, loudly, it is the actionable one
|
|
2853
|
+
* unknown nothing to judge it by, OR the file could not be read. Both are
|
|
2854
|
+
* shown, because a document nobody could judge must never render the
|
|
2855
|
+
* same as one that passed. That equivalence is why stale_after exists.
|
|
2856
|
+
* fresh nothing — a clean row should stay quiet
|
|
2857
|
+
*
|
|
2858
|
+
* Absent freshness data (an older board payload) also renders nothing, since
|
|
2859
|
+
* inventing a verdict from a missing field is the defect itself.
|
|
2860
|
+
*/
|
|
2861
|
+
function freshnessBadge(doc) {
|
|
2862
|
+
const v = doc?.freshness;
|
|
2863
|
+
if (!v || v === 'fresh') return '';
|
|
2864
|
+
const unreadable = doc.freshnessBasis === 'unreadable';
|
|
2865
|
+
const colour = v === 'stale' ? 'var(--status-gate)' : 'var(--text3)';
|
|
2866
|
+
const label = v === 'stale' ? 'stale' : (unreadable ? 'could not read' : 'unknown');
|
|
2867
|
+
return `<span title="${esc(doc.freshnessWhy || '')}" style="font-family:var(--mono);font-size:10px;margin-left:6px;padding:0 4px;border:1px solid var(--border);border-radius:3px;color:${colour}">${esc(label)}</span>`;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
// Will this gate wait for you, or did the pipeline already go past it?
|
|
2871
|
+
//
|
|
2872
|
+
// `null` until fetched, and it stays null on any failure — every render path
|
|
2873
|
+
// treats "no tier information" as "this gate waits", which is the reading that
|
|
2874
|
+
// keeps a human in the loop when we do not know.
|
|
2875
|
+
let gateTiers = null;
|
|
2876
|
+
async function refreshGateTiers() {
|
|
2877
|
+
const d = await api(`/api/gate-tiers${pqs()}`);
|
|
2878
|
+
gateTiers = d && d.agents ? d : null;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
/**
|
|
2882
|
+
* The tier line for a gate card, or '' when it would say nothing true.
|
|
2883
|
+
*
|
|
2884
|
+
* Three readings, not two:
|
|
2885
|
+
* - tiering off for this project, or the agent unmeasured → nothing shown, the
|
|
2886
|
+
* gate waits, which is what the buttons already imply
|
|
2887
|
+
* - `gated` → nothing shown for the same reason
|
|
2888
|
+
* - `notify` / `notify-thin` → said out loud, because the pipeline did NOT
|
|
2889
|
+
* wait for this decision and the Approve button alone implies it did
|
|
2890
|
+
*/
|
|
2891
|
+
function gateTierNote(t) {
|
|
2892
|
+
if (!gateTiers?.enabled || !gateTiers?.measured) return '';
|
|
2893
|
+
const info = gateTiers.agents?.[t.agent];
|
|
2894
|
+
if (!info || info.tier === 'gated') return '';
|
|
2895
|
+
const thin = info.tier === 'notify-thin';
|
|
2896
|
+
const colour = thin ? 'var(--status-gate)' : 'var(--text3)';
|
|
2897
|
+
// `notify-thin` is sound statistics over unmeasured coverage — one eval passed
|
|
2898
|
+
// conclusively, which says nothing about the rest of what this agent is for.
|
|
2899
|
+
// It is the reading most worth a second look, so it reads differently.
|
|
2900
|
+
const caveat = thin ? ' — one eval, coverage unmeasured' : '';
|
|
2901
|
+
return `<div style="margin-top:3px;font-size:11px;color:${colour}">
|
|
2902
|
+
<span style="font-family:var(--mono);border:1px solid var(--border);border-radius:3px;padding:0 4px">${esc(info.tier)}</span>
|
|
2903
|
+
the pipeline did not wait for this${esc(caveat)} · <span title="${esc(info.why || '')}">${esc((info.why || '').slice(0, 90))}</span>
|
|
2904
|
+
</div>`;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// Gates that announced instead of waiting.
|
|
2908
|
+
//
|
|
2909
|
+
// Three states, not two. `none` hides the section — nothing has proceeded
|
|
2910
|
+
// unattended, which is a real and common answer. `unreadable` SHOWS it and says
|
|
2911
|
+
// so: a ledger that exists and could not be read must never render the same as
|
|
2912
|
+
// an empty one, because the whole reason this record exists is that a
|
|
2913
|
+
// stand-down nobody can see is indistinguishable from a gate nobody configured.
|
|
2914
|
+
async function refreshStandDowns() {
|
|
2915
|
+
const section = document.getElementById('inbox-standdown-section');
|
|
2916
|
+
const root = document.getElementById('inbox-standdown');
|
|
2917
|
+
const cnt = document.getElementById('inbox-standdown-count');
|
|
2918
|
+
if (!section || !root) return;
|
|
2919
|
+
|
|
2920
|
+
const d = await api(`/api/stand-downs${pqs()}`);
|
|
2921
|
+
if (!d) { section.style.display = 'none'; return; }
|
|
2922
|
+
|
|
2923
|
+
if (d.state === 'unreadable') {
|
|
2924
|
+
section.style.display = '';
|
|
2925
|
+
if (cnt) cnt.textContent = '?';
|
|
2926
|
+
root.innerHTML = `<div class="inbox-row"><span class="ttl">The stand-down ledger could not be read
|
|
2927
|
+
<span class="meta">${esc(d.why || '')} — gates may have proceeded unattended without appearing here</span></span></div>`;
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
if (d.state !== 'some' || !d.rows.length) { section.style.display = 'none'; root.innerHTML = ''; return; }
|
|
2931
|
+
|
|
2932
|
+
section.style.display = '';
|
|
2933
|
+
if (cnt) cnt.textContent = d.rows.length;
|
|
2934
|
+
// Newest first — the ledger appends, but a reader wants the most recent thing
|
|
2935
|
+
// that happened without them.
|
|
2936
|
+
root.innerHTML = d.rows.slice().reverse().map(r => {
|
|
2937
|
+
// `notify-thin` means the gate stood down on ONE eval: sound statistics,
|
|
2938
|
+
// unmeasured coverage. That is the row most worth a second look, so it is
|
|
2939
|
+
// the row that looks different.
|
|
2940
|
+
const thin = r.tier === 'notify-thin';
|
|
2941
|
+
const tierTag = `<span style="font-family:var(--mono);font-size:10px;padding:1px 5px;border-radius:3px;border:1px solid var(--border);color:${thin ? 'var(--status-gate)' : 'var(--text3)'}">${esc(r.tier || 'unknown')}</span>`;
|
|
2942
|
+
return `
|
|
2943
|
+
<div class="inbox-row">
|
|
2944
|
+
<span class="id">gate:${esc(r.gate || '?')}</span>
|
|
2945
|
+
<span class="ttl">${esc(r.agent || 'unknown agent')} proceeded without asking
|
|
2946
|
+
<span class="ttl-desc">${esc(r.evidence || '')}</span>
|
|
2947
|
+
<span class="meta">${tierTag} · <span title="${esc(r.ts || '')}">${r.ts ? relTime(r.ts) : 'no timestamp'}</span></span>
|
|
2948
|
+
</span>
|
|
2949
|
+
</div>`;
|
|
2950
|
+
}).join('');
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2747
2953
|
function renderInboxList(rootId, items, countId, opts = {}) {
|
|
2748
2954
|
const root = document.getElementById(rootId);
|
|
2749
2955
|
const cnt = document.getElementById(countId);
|
|
@@ -2753,9 +2959,17 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
2753
2959
|
if (section) section.style.display = items.length ? '' : 'none';
|
|
2754
2960
|
if (!items.length) { root.innerHTML = ''; return; }
|
|
2755
2961
|
root.innerHTML = items.map(t => {
|
|
2962
|
+
// A task now appears in one section only, so the states it ALSO matched are
|
|
2963
|
+
// shown here rather than lost. Deduplicating the rows must not deduplicate
|
|
2964
|
+
// the facts: a gate that is also blocked is still blocked, and hiding that
|
|
2965
|
+
// to fix a double count would trade one wrong reading for another.
|
|
2966
|
+
const alsoTag = (t.also || []).length
|
|
2967
|
+
? `<span title="also matched: ${esc((t.also || []).join(', '))}" style="color:var(--text3)">also ${esc((t.also || []).join(' + '))}</span>`
|
|
2968
|
+
: '';
|
|
2756
2969
|
const meta = [
|
|
2757
2970
|
t.agent ? `agent: ${esc(t.agent)}` : '',
|
|
2758
2971
|
t.priority != null ? PRIORITY_LABEL[t.priority] : '',
|
|
2972
|
+
alsoTag,
|
|
2759
2973
|
// Gates (the showApprove list) get the exact date+time the decision has been
|
|
2760
2974
|
// waiting since; other rows keep the compact relative stamp.
|
|
2761
2975
|
t.updated_at
|
|
@@ -2771,16 +2985,21 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
2771
2985
|
</div>`
|
|
2772
2986
|
: `<div class="actions"><span style="font-family:var(--mono);font-size:11px;color:var(--text3)">${t.status || ''}</span></div>`;
|
|
2773
2987
|
const desc = t.description ? `<span class="ttl-desc">${esc(t.description.slice(0, 140))}${t.description.length > 140 ? '…' : ''}</span>` : '';
|
|
2988
|
+
// Only gate rows can stand down, so only they carry the note.
|
|
2989
|
+
const tierNote = opts.showApprove ? gateTierNote(t) : '';
|
|
2774
2990
|
return `
|
|
2775
2991
|
<div class="inbox-row" onclick='openSide(${JSON.stringify(t).replace(/'/g, "'")})'>
|
|
2776
2992
|
<span class="id">${esc(t.id || '')}</span>
|
|
2777
|
-
<span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span
|
|
2993
|
+
<span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
|
|
2778
2994
|
${actions}
|
|
2779
2995
|
</div>`;
|
|
2780
2996
|
}).join('');
|
|
2781
2997
|
}
|
|
2782
2998
|
|
|
2783
2999
|
async function refreshInbox() {
|
|
3000
|
+
// Tiers first: the gate rows are rendered from this same call, and a row drawn
|
|
3001
|
+
// before the tiers arrive would silently claim every gate is waiting.
|
|
3002
|
+
await refreshGateTiers();
|
|
2784
3003
|
const d = await api(`/api/inbox${pqs()}`);
|
|
2785
3004
|
if (d) renderInbox(d);
|
|
2786
3005
|
refreshPipeline();
|
|
@@ -2842,6 +3061,33 @@ async function refreshResume() {
|
|
|
2842
3061
|
if (d) renderResume(d);
|
|
2843
3062
|
}
|
|
2844
3063
|
|
|
3064
|
+
/**
|
|
3065
|
+
* What a verdict says, in the width of a column.
|
|
3066
|
+
*
|
|
3067
|
+
* Two shapes reach here: the legacy `agent: free text` line, and the versioned
|
|
3068
|
+
* JSON record the verdict helper writes today. Only the first has anything
|
|
3069
|
+
* readable after the colon — for the second, splitting on `': '` yields the
|
|
3070
|
+
* whole record, which is how a JSON blob ended up rendered as a summary.
|
|
3071
|
+
*/
|
|
3072
|
+
function summariseVerdict(v, raw) {
|
|
3073
|
+
let meta = null;
|
|
3074
|
+
const t = String(raw || '').trim();
|
|
3075
|
+
if (t.startsWith('{')) { try { meta = JSON.parse(t).meta || null; } catch { meta = null; } }
|
|
3076
|
+
|
|
3077
|
+
if (meta) {
|
|
3078
|
+
// `scope` is what the run was about; the rest are the artefacts it produced.
|
|
3079
|
+
const what = meta.scope || meta.feature || meta.task || null;
|
|
3080
|
+
const artefact = Object.values(meta).find(x => typeof x === 'string' && x.endsWith('.md'));
|
|
3081
|
+
const tail = what || (artefact ? artefact.split('/').pop() : null);
|
|
3082
|
+
return tail ? `${v.verdict || 'verdict'} · ${tail}` : (v.verdict || 'verdict');
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
const i = t.indexOf(': ');
|
|
3086
|
+
if (i !== -1) return t.slice(i + 2);
|
|
3087
|
+
// Not JSON and not `agent: text` — a bare line, which is still readable.
|
|
3088
|
+
return t || (v.verdict || 'verdict');
|
|
3089
|
+
}
|
|
3090
|
+
|
|
2845
3091
|
function renderResume(d) {
|
|
2846
3092
|
const card = document.getElementById('resume-card');
|
|
2847
3093
|
if (!card) return;
|
|
@@ -2868,8 +3114,16 @@ function renderResume(d) {
|
|
|
2868
3114
|
const vu = (v.verdict || '').toUpperCase();
|
|
2869
3115
|
const cls = ['APPROVED','DONE','PASS'].includes(vu) ? 'ok' :
|
|
2870
3116
|
['BLOCKED','FAIL','REJECTED'].includes(vu) ? 'fail' : '';
|
|
3117
|
+
// A verdict is written in one of two shapes: the legacy `AGENT: text` line,
|
|
3118
|
+
// and the versioned JSON record the helper writes today. This column split
|
|
3119
|
+
// on `': '` and printed whatever followed — which for the JSON shape is the
|
|
3120
|
+
// whole record, so the column that should read "DONE · board-review" showed
|
|
3121
|
+
// `{"v":1,"ts":"2026-08-09T06:19:37Z","agent":"design-advis…`.
|
|
3122
|
+
//
|
|
3123
|
+
// The structured fields are already parsed by the time they arrive; the raw
|
|
3124
|
+
// line is a fallback for the legacy shape, not the primary source.
|
|
2871
3125
|
const raw = v.raw || '';
|
|
2872
|
-
const after =
|
|
3126
|
+
const after = summariseVerdict(v, raw);
|
|
2873
3127
|
window.__resumeVerdicts = verdicts;
|
|
2874
3128
|
return `<div class="resume-item" onclick="showVerdictDetail(${i})" role="button" tabindex="0">
|
|
2875
3129
|
<span class="ri-tag ${cls}">${esc(v.agent || 'agent')}</span>
|
|
@@ -3302,6 +3556,234 @@ function mdToHtml(md) {
|
|
|
3302
3556
|
});
|
|
3303
3557
|
}
|
|
3304
3558
|
|
|
3559
|
+
// Activity — one entry, two depths.
|
|
3560
|
+
//
|
|
3561
|
+
// Sessions and Logs were separate sidebar items answering the same question,
|
|
3562
|
+
// "what did the agents do", from written summaries and from raw transcripts. A
|
|
3563
|
+
// reader had to choose which to open before knowing which one held the answer.
|
|
3564
|
+
function setActivityView(view) {
|
|
3565
|
+
const logsPanel = document.getElementById('logs-list')?.closest('.panel') || document.getElementById('panel-logs');
|
|
3566
|
+
const sessHost = document.getElementById('sessions-body');
|
|
3567
|
+
const logsHost = document.getElementById('logs-list')?.parentElement;
|
|
3568
|
+
const inPanel = document.getElementById('activity-sessions-host');
|
|
3569
|
+
|
|
3570
|
+
document.getElementById('act-tab-logs')?.classList.toggle('active', view === 'logs');
|
|
3571
|
+
document.getElementById('act-tab-sessions')?.classList.toggle('active', view === 'sessions');
|
|
3572
|
+
|
|
3573
|
+
// The transcript view is rendered by the Sessions panel's own code; it is moved
|
|
3574
|
+
// into this panel rather than reimplemented, so the two cannot drift apart.
|
|
3575
|
+
let host = inPanel;
|
|
3576
|
+
if (!host) {
|
|
3577
|
+
host = document.createElement('div');
|
|
3578
|
+
host.id = 'activity-sessions-host';
|
|
3579
|
+
logsPanel?.appendChild(host);
|
|
3580
|
+
}
|
|
3581
|
+
if (view === 'sessions') {
|
|
3582
|
+
if (logsHost) logsHost.style.display = 'none';
|
|
3583
|
+
host.style.display = '';
|
|
3584
|
+
if (sessHost && sessHost.parentElement !== host) host.appendChild(sessHost);
|
|
3585
|
+
loadSessions();
|
|
3586
|
+
} else {
|
|
3587
|
+
host.style.display = 'none';
|
|
3588
|
+
if (logsHost) logsHost.style.display = '';
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
// ── Docs ────────────────────────────────────────────────────────────────────
|
|
3593
|
+
//
|
|
3594
|
+
// A project accumulates documentation nobody can find: this repository has
|
|
3595
|
+
// twenty architecture documents, eleven ADRs and thirty-one plans, and the only
|
|
3596
|
+
// way to read one was to know its filename.
|
|
3597
|
+
//
|
|
3598
|
+
// The two diagrams are computed on request rather than stored. docs/ARCHITECTURE.md
|
|
3599
|
+
// is hand-drawn, three months old, and says "34 agents" where there are
|
|
3600
|
+
// sixty-nine — a drawn picture goes stale silently, a derived one cannot.
|
|
3601
|
+
let _docsCache = null;
|
|
3602
|
+
|
|
3603
|
+
/**
|
|
3604
|
+
* The sidebar badge, without rendering the panel.
|
|
3605
|
+
*
|
|
3606
|
+
* It was written only inside `loadDocs`, which runs when the tab is opened — so
|
|
3607
|
+
* a project with a hundred and seventy-five documents showed `0` until you
|
|
3608
|
+
* clicked, and `0` is the one number that reads as "there is nothing here".
|
|
3609
|
+
* A dash says "not counted yet", which is what it actually means.
|
|
3610
|
+
*/
|
|
3611
|
+
async function refreshDocsCount() {
|
|
3612
|
+
const el = document.getElementById('nav-docs-count');
|
|
3613
|
+
if (!el) return;
|
|
3614
|
+
let d;
|
|
3615
|
+
try { d = await api(`/api/docs${pqs()}`); } catch { d = null; }
|
|
3616
|
+
el.textContent = (d && !d.error && typeof d.total === 'number') ? d.total : '—';
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
async function loadDocs() {
|
|
3620
|
+
const el = document.getElementById('docs-body');
|
|
3621
|
+
el.innerHTML = '<span class="muted">loading…</span>';
|
|
3622
|
+
let d;
|
|
3623
|
+
try {
|
|
3624
|
+
// pqs() carries the selected project — without it this panel would repeat
|
|
3625
|
+
// the defect that made sixteen endpoints show the server's own directory.
|
|
3626
|
+
d = await api(`/api/docs${pqs()}`);
|
|
3627
|
+
} catch (e) {
|
|
3628
|
+
// "Could not read" must never render as "there is nothing here" — the
|
|
3629
|
+
// failure this board keeps making in other forms.
|
|
3630
|
+
el.innerHTML = `<div class="card"><b>Could not read the project's documents.</b>
|
|
3631
|
+
<div class="muted" style="margin-top:6px">${String(e && e.message || e)}</div></div>`;
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3634
|
+
_docsCache = d;
|
|
3635
|
+
document.getElementById('nav-docs-count').textContent = d.total || 0;
|
|
3636
|
+
|
|
3637
|
+
const pipeline = d.pipeline
|
|
3638
|
+
? `<div class="card"><b>Pipeline</b> <span class="muted">— how a feature moves, drawn from shared/pipeline.toml</span>
|
|
3639
|
+
${renderDeclaredPipeline(d.pipeline)}
|
|
3640
|
+
<details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
|
|
3641
|
+
<pre style="white-space:pre-wrap;font-size:11px">${esc(d.pipeline)}</pre></details></div>`
|
|
3642
|
+
: '';
|
|
3643
|
+
|
|
3644
|
+
const map = d.map ? `<div class="card"><b>Components</b>
|
|
3645
|
+
<span class="muted">— derived ${ago(d.map.generatedAt)}, so it cannot disagree with the code</span>
|
|
3646
|
+
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:10px">
|
|
3647
|
+
${d.map.nodes.map(n => `<div style="border:1px solid var(--border,#ddd);border-radius:6px;padding:8px 10px;min-width:120px">
|
|
3648
|
+
<div><b>${esc(n.label)}</b> <span class="muted">${n.count}</span></div>
|
|
3649
|
+
<div class="muted" style="font-size:11px">${esc(n.what || '')}</div></div>`).join('')}
|
|
3650
|
+
</div>
|
|
3651
|
+
${d.map.edges.length ? `<div class="muted" style="margin-top:8px;font-size:11px">
|
|
3652
|
+
${d.map.edges.map(e => `${esc(e.from)}→${esc(e.to)} (${e.count})`).join(' · ')}</div>` : ''}
|
|
3653
|
+
<details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
|
|
3654
|
+
<pre style="white-space:pre-wrap;font-size:11px">${esc(d.map.mermaid)}</pre></details></div>` : '';
|
|
3655
|
+
|
|
3656
|
+
const groups = (d.groups || []).map(g => `<div class="card">
|
|
3657
|
+
<b>${esc(g.label)}</b> <span class="muted">${g.docs.length} — ${esc(g.why || '')}</span>
|
|
3658
|
+
<div style="margin-top:8px">${g.docs.map(doc => `
|
|
3659
|
+
<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
|
|
3660
|
+
<a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
|
|
3661
|
+
<span class="muted" style="font-size:11px"> · ${esc(doc.path)} · ${ago(doc.modified)}</span>
|
|
3662
|
+
${freshnessBadge(doc)}
|
|
3663
|
+
</div>`).join('')}</div>
|
|
3664
|
+
</div>`).join('');
|
|
3665
|
+
|
|
3666
|
+
// Memory used to be its own tab listing PROJECT.md, CODEBASE.md and lessons —
|
|
3667
|
+
// the same files this panel's "This project" group already showed. One section
|
|
3668
|
+
// for documentation, as asked; what Memory knew that a directory walk does not
|
|
3669
|
+
// is folded in rather than dropped.
|
|
3670
|
+
const mem = await loadContextLayers();
|
|
3671
|
+
|
|
3672
|
+
// The document itself opens in the side panel now, so this panel no longer
|
|
3673
|
+
// needs a place to append one.
|
|
3674
|
+
el.innerHTML = pipeline + map + mem + groups;
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
/**
|
|
3678
|
+
* The context an agent reads, and the patterns learned across projects.
|
|
3679
|
+
*
|
|
3680
|
+
* A directory walk finds files that exist. This list is the CANONICAL set of
|
|
3681
|
+
* layers, so a layer that is absent is information — "this project has recorded
|
|
3682
|
+
* no lessons yet" is a fact about the project, not an empty row to hide. Three
|
|
3683
|
+
* of them live in ~/.great_cto and are shared by every project, which is why
|
|
3684
|
+
* they never appeared in a per-project walk at all.
|
|
3685
|
+
*/
|
|
3686
|
+
async function loadContextLayers() {
|
|
3687
|
+
let m;
|
|
3688
|
+
try { m = await api(`/api/memory${pqs()}`); } catch { m = null; }
|
|
3689
|
+
if (!m || m.error) {
|
|
3690
|
+
return `<div class="card"><b>Agent context</b>
|
|
3691
|
+
<div class="muted" style="margin-top:6px">could not be read — ${esc((m && m.error) || degradedFor('/api/memory') || 'no response')}</div></div>`;
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
const layers = (m.layers || []).map(l => {
|
|
3695
|
+
const global = String(l.path || '').includes('/.great_cto/') && !String(l.path || '').includes(projectRootHint());
|
|
3696
|
+
const where = global ? ' <span class="muted" style="font-size:11px">shared by every project</span>' : '';
|
|
3697
|
+
return l.exists
|
|
3698
|
+
? `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
|
|
3699
|
+
<a href="#" onclick="openDoc('${esc(l.path)}');return false">${esc(l.id)}</a>${where}
|
|
3700
|
+
<span class="muted" style="font-size:11px"> · ${esc(l.path)}</span></div>`
|
|
3701
|
+
: `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)" class="muted">
|
|
3702
|
+
${esc(l.id)} — not written yet${where}</div>`;
|
|
3703
|
+
}).join('');
|
|
3704
|
+
|
|
3705
|
+
const pats = (m.patterns || []).map(p =>
|
|
3706
|
+
`<div style="padding:4px 0" class="muted">${esc(p.id)} — ${esc(p.title || '')}</div>`).join('');
|
|
3707
|
+
|
|
3708
|
+
return `<div class="card">
|
|
3709
|
+
<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>
|
|
3710
|
+
<div style="margin-top:8px">${layers}</div>
|
|
3711
|
+
</div>` + (pats ? `<div class="card">
|
|
3712
|
+
<b>Learned patterns</b> <span class="muted">${(m.patterns || []).length} — failures seen often enough to be written down</span>
|
|
3713
|
+
<div style="margin-top:8px">${pats}</div></div>` : '');
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
/** The selected project's root, for telling a shared layer from a local one. */
|
|
3717
|
+
function projectRootHint() {
|
|
3718
|
+
const crumb = document.querySelector('.crumb-project, #crumb-project');
|
|
3719
|
+
return (crumb && crumb.textContent || '').trim() || '\u0000';
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
/** The pipeline as boxes and gates — no diagram library, so it always renders. */
|
|
3723
|
+
// Named `renderDeclaredPipeline`, not `renderPipeline`, because there is already
|
|
3724
|
+
// a `renderPipeline(stages)` above that draws the LIVE rail. Both were declared
|
|
3725
|
+
// at the top level of this one <script>, so the later declaration won and every
|
|
3726
|
+
// call carrying an array of stages — refreshPipeline() and the SSE handler —
|
|
3727
|
+
// reached this mermaid parser instead. It returned a string nobody used and
|
|
3728
|
+
// never touched `#pipeline-track`, so the rail stayed the empty div in the
|
|
3729
|
+
// markup and the status stayed the hardcoded `idle` beside it.
|
|
3730
|
+
//
|
|
3731
|
+
// The first section of the first screen therefore read "idle" whatever the
|
|
3732
|
+
// pipeline was doing: not an empty state, but a confident wrong answer, which is
|
|
3733
|
+
// the failure this project spends its time removing.
|
|
3734
|
+
function renderDeclaredPipeline(mermaid) {
|
|
3735
|
+
const edges = [];
|
|
3736
|
+
for (const line of String(mermaid).split('\n')) {
|
|
3737
|
+
const m = line.match(/^\s*(\w+)\s*-->(?:\|([^|]*)\|)?\s*(\w+)/);
|
|
3738
|
+
if (m) edges.push({ from: m[1], gate: (m[2] || '').trim(), to: m[3], join: false });
|
|
3739
|
+
const j = line.match(/^\s*(\w+)\s*-\.->\|join\|\s*(\w+)/);
|
|
3740
|
+
if (j) edges.push({ from: j[1], to: j[2], join: true });
|
|
3741
|
+
}
|
|
3742
|
+
if (!edges.length) return '<div class="muted">no transitions declared</div>';
|
|
3743
|
+
const name = (id) => id.replace(/_/g, '-');
|
|
3744
|
+
return `<div style="margin-top:10px;font-size:12px">${edges.map(e => `
|
|
3745
|
+
<div style="display:flex;align-items:center;gap:8px;padding:3px 0">
|
|
3746
|
+
<span style="border:1px solid var(--border,#ddd);border-radius:4px;padding:2px 8px">${esc(name(e.from))}</span>
|
|
3747
|
+
<span class="muted">${e.join ? '⋯ join ⋯' : (e.gate ? `→ ${esc(e.gate)} →` : '→')}</span>
|
|
3748
|
+
<span style="border:1px solid var(--border,#ddd);border-radius:4px;padding:2px 8px">${esc(name(e.to))}</span>
|
|
3749
|
+
</div>`).join('')}</div>`;
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
/**
|
|
3753
|
+
* Open a document in the side panel — the same drawer a task, a verdict and a
|
|
3754
|
+
* decision open in.
|
|
3755
|
+
*
|
|
3756
|
+
* It used to append the rendered markdown to the BOTTOM of the Docs panel and
|
|
3757
|
+
* scroll there, so reading a document meant losing the list you were browsing,
|
|
3758
|
+
* and closing it meant scrolling back up. Every other section of this board
|
|
3759
|
+
* answers a click by sliding the detail in from the right; this one invented its
|
|
3760
|
+
* own mechanic, and a reader has to learn two.
|
|
3761
|
+
*/
|
|
3762
|
+
async function openDoc(p) {
|
|
3763
|
+
const panel = document.getElementById('side-panel');
|
|
3764
|
+
const body = document.getElementById('side-body');
|
|
3765
|
+
const idEl = document.getElementById('side-id');
|
|
3766
|
+
const tl = document.getElementById('side-timeline');
|
|
3767
|
+
if (!panel || !body) return;
|
|
3768
|
+
|
|
3769
|
+
idEl.textContent = p.split('/').pop();
|
|
3770
|
+
body.innerHTML = '<div style="padding:18px" class="muted">loading…</div>';
|
|
3771
|
+
if (tl) tl.innerHTML = '';
|
|
3772
|
+
panel.classList.add('open', 'wide');
|
|
3773
|
+
|
|
3774
|
+
try {
|
|
3775
|
+
const q = pqs();
|
|
3776
|
+
const d = await api(`/api/doc?path=${encodeURIComponent(p)}${q ? '&' + q.slice(1) : ''}`);
|
|
3777
|
+
if (!d || d.error) throw new Error(d?.error || 'doc not found');
|
|
3778
|
+
idEl.textContent = d.name || p;
|
|
3779
|
+
body.innerHTML = `<div class="muted" style="font-size:11px;margin-bottom:10px">${esc(d.path)}</div>
|
|
3780
|
+
<div class="side-desc md">${mdToHtml(d.content)}</div>`;
|
|
3781
|
+
} catch (e) {
|
|
3782
|
+
body.innerHTML = `<div class="side-desc"><b>Could not open ${esc(p)}</b>
|
|
3783
|
+
<div class="muted" style="margin-top:6px">${esc(String(e && e.message || e))}</div></div>`;
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3305
3787
|
// ── Sessions ────────────────────────────────────────────────────────────────
|
|
3306
3788
|
//
|
|
3307
3789
|
// Every other panel answers "what happened". This one answers "how", which is
|
|
@@ -3402,10 +3884,28 @@ async function searchTranscripts() {
|
|
|
3402
3884
|
|
|
3403
3885
|
async function loadMemory() {
|
|
3404
3886
|
const d = await api(`/api/memory${pqs()}`);
|
|
3405
|
-
|
|
3887
|
+
// `d || {layers: []}` collapsed a failed read into "this project has no
|
|
3888
|
+
// memory" — the two look identical to a reader and mean opposite things.
|
|
3889
|
+
if (!d || d._neterr || d.error) {
|
|
3890
|
+
memoryData = { layers: [], patterns: [] };
|
|
3891
|
+
renderMemorySidebar();
|
|
3892
|
+
const c = document.getElementById('nav-memory-count'); if (c) c.textContent = '—';
|
|
3893
|
+
renderDegradedBanner('degraded-memory',
|
|
3894
|
+
(d && (d.error || (d._neterr && 'board not reachable — restart it'))) || 'no response',
|
|
3895
|
+
'Memory could not be read');
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
renderDegradedBanner('degraded-memory', degradedFor('/api/memory'), 'Memory could not be read');
|
|
3899
|
+
memoryData = d;
|
|
3406
3900
|
renderMemorySidebar();
|
|
3407
|
-
|
|
3408
|
-
|
|
3901
|
+
// Memory folded into Docs, so this badge no longer exists. It was still
|
|
3902
|
+
// written unconditionally, and the TypeError left the rest of this function —
|
|
3903
|
+
// including the auto-select below — unreached on every page load.
|
|
3904
|
+
const memCount = document.getElementById('nav-memory-count');
|
|
3905
|
+
if (memCount) {
|
|
3906
|
+
memCount.textContent =
|
|
3907
|
+
(memoryData.layers || []).filter(l => l.exists).length + (memoryData.patterns || []).length;
|
|
3908
|
+
}
|
|
3409
3909
|
// Auto-select first existing layer
|
|
3410
3910
|
if (!activeMemKey) {
|
|
3411
3911
|
const first = (memoryData.layers || []).find(l => l.exists);
|
|
@@ -3628,6 +4128,7 @@ async function switchProject(slug) {
|
|
|
3628
4128
|
try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
|
|
3629
4129
|
try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
|
|
3630
4130
|
loadMemory();
|
|
4131
|
+
refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
|
|
3631
4132
|
refreshCost();
|
|
3632
4133
|
refreshPipeline();
|
|
3633
4134
|
refreshLogs(); // populate the Logs nav badge (was lazy — stuck at 0 until the tab was opened)
|
|
@@ -3816,6 +4317,7 @@ async function init() {
|
|
|
3816
4317
|
try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
|
|
3817
4318
|
try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
|
|
3818
4319
|
loadMemory();
|
|
4320
|
+
refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
|
|
3819
4321
|
refreshCost();
|
|
3820
4322
|
refreshPipeline();
|
|
3821
4323
|
refreshLogs(); // populate the Logs nav badge (was lazy — stuck at 0 until the tab was opened)
|
|
@@ -3875,7 +4377,15 @@ function api(url, opts) {
|
|
|
3875
4377
|
if (d) { try { BOARD_DEGRADED[key] = decodeURIComponent(d); } catch { BOARD_DEGRADED[key] = d; } }
|
|
3876
4378
|
else delete BOARD_DEGRADED[key];
|
|
3877
4379
|
return r.json();
|
|
3878
|
-
}).catch(() =>
|
|
4380
|
+
}).catch(() => {
|
|
4381
|
+
// A request that never completed is the one case the degradation mechanism
|
|
4382
|
+
// could not see: the header lives on a response, and there is no response.
|
|
4383
|
+
// So the board went quiet exactly when it had lost contact with itself —
|
|
4384
|
+
// and worse, a key deleted by an earlier success stayed deleted, leaving
|
|
4385
|
+
// every panel looking healthy while nothing could be read at all.
|
|
4386
|
+
BOARD_DEGRADED[key] = 'the board is not reachable — is the server still running?';
|
|
4387
|
+
return null;
|
|
4388
|
+
});
|
|
3879
4389
|
}
|
|
3880
4390
|
|
|
3881
4391
|
function connectSSE() {
|
|
@@ -4647,7 +5157,10 @@ function openSide(task) {
|
|
|
4647
5157
|
${acceptanceSec}
|
|
4648
5158
|
${notesSec}
|
|
4649
5159
|
${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')
|
|
5160
|
+
const sidePanel = document.getElementById('side-panel');
|
|
5161
|
+
sidePanel.classList.add('open');
|
|
5162
|
+
// A task after a document: back to the narrower column the rows were sized for.
|
|
5163
|
+
sidePanel.classList.remove('wide');
|
|
4651
5164
|
// Clear and load timeline
|
|
4652
5165
|
const tlEl = document.getElementById('side-timeline');
|
|
4653
5166
|
if (tlEl) tlEl.innerHTML = '';
|
|
@@ -4682,6 +5195,7 @@ async function openDocInSide(rel) {
|
|
|
4682
5195
|
const d = await api(`/api/doc?path=${encodeURIComponent(rel)}${qs}`);
|
|
4683
5196
|
if (!d || d.error) { showToast('✗ ' + esc(d?.error || 'doc not found'), 'error', 3500); return; }
|
|
4684
5197
|
document.getElementById('side-id').textContent = d.name;
|
|
5198
|
+
document.getElementById('side-panel').classList.add('wide');
|
|
4685
5199
|
document.getElementById('side-body').innerHTML = `
|
|
4686
5200
|
<button class="gate-btn" onclick="openSide(_sideTask)" style="margin-bottom:12px">← back to task</button>
|
|
4687
5201
|
<div class="side-desc md">${mdRender(d.content)}</div>`;
|
|
@@ -4694,6 +5208,11 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSide();
|
|
|
4694
5208
|
|
|
4695
5209
|
/* ── Tabs ───────────────────────────────────────────────────────────────── */
|
|
4696
5210
|
function switchTab(id, el) {
|
|
5211
|
+
// `agents` has no sidebar entry by design: it is a drill-in from Dashboard.
|
|
5212
|
+
// Without this the call fell through to code that expects a nav element and
|
|
5213
|
+
// left the sidebar with nothing selected.
|
|
5214
|
+
if (!el) el = document.querySelector(`.nav-item[data-tab="${id}"]`)
|
|
5215
|
+
|| document.querySelector('.nav-item[data-tab="dashboard"]');
|
|
4697
5216
|
currentTab = id;
|
|
4698
5217
|
// sidebar nav
|
|
4699
5218
|
document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
|
|
@@ -4708,15 +5227,24 @@ function switchTab(id, el) {
|
|
|
4708
5227
|
// crumb — `labels` used to be referenced here but is only ever a function-local in
|
|
4709
5228
|
// this file, so this threw ReferenceError on every tab switch and skipped the per-tab
|
|
4710
5229
|
// 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',
|
|
5230
|
+
const TAB_LABELS = { docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
|
|
4712
5231
|
const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
|
|
4713
5232
|
if (id === 'dashboard' || id === 'agents') {
|
|
4714
|
-
|
|
5233
|
+
// pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
|
|
5234
|
+
// its 30-day default and overwrote whatever window the period chips had
|
|
5235
|
+
// selected. Pick "7d", switch tabs, come back — the chip still read 7d and
|
|
5236
|
+
// the numbers were thirty days old, which is the period selector appearing
|
|
5237
|
+
// not to work at all.
|
|
5238
|
+
//
|
|
5239
|
+
// Through the sequence guard too, so a tab switch cannot land after a newer
|
|
5240
|
+
// period request and clobber it.
|
|
5241
|
+
_refreshMetricsRaced(`/api/metrics${pqsd()}`).then(m => { if (m) { metrics = m; renderDashboard(m); } });
|
|
4715
5242
|
if (id === 'dashboard') refreshCost();
|
|
4716
5243
|
if (id === 'agents') refreshAgentsInstalled();
|
|
4717
5244
|
}
|
|
4718
5245
|
if (id === 'inbox') refreshInbox();
|
|
4719
|
-
if (id === '
|
|
5246
|
+
if (id === 'logs') setActivityView('logs');
|
|
5247
|
+
if (id === 'docs') loadDocs();
|
|
4720
5248
|
if (id === 'sessions') loadSessions();
|
|
4721
5249
|
if (id === 'logs') refreshLogs();
|
|
4722
5250
|
if (id === 'notifications') loadNotifications();
|
|
@@ -4732,6 +5260,12 @@ async function refreshLogs() {
|
|
|
4732
5260
|
const countEl = document.getElementById('nav-logs-count'); if (countEl) countEl.textContent = '0';
|
|
4733
5261
|
return;
|
|
4734
5262
|
}
|
|
5263
|
+
// The server already says WHY a read came back short — /api/logs has emitted
|
|
5264
|
+
// X-Board-Degraded since the tasks fix, and until now the UI threw it away.
|
|
5265
|
+
// An unreadable logs directory rendered as a project that had never run.
|
|
5266
|
+
renderDegradedBanner('degraded-logs', degradedFor('/api/logs'),
|
|
5267
|
+
'Session logs could not be read');
|
|
5268
|
+
|
|
4735
5269
|
allLogs = data.logs || [];
|
|
4736
5270
|
const countEl = document.getElementById('nav-logs-count');
|
|
4737
5271
|
if (countEl) countEl.textContent = allLogs.length;
|