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.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/beads.mjs +53 -5
- package/board/packages/board/lib/docs.mjs +137 -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 +146 -11
- package/board/packages/board/lib/verdicts.mjs +63 -9
- package/board/packages/board/public/index.html +376 -32
- package/board/scripts/lib/verdict-record.mjs +18 -1
- 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-
|
|
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="
|
|
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>
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
3408
|
-
|
|
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(() =>
|
|
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')
|
|
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',
|
|
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
|
-
|
|
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 === '
|
|
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) {
|