great-cto 3.77.0 → 3.78.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/config.mjs +27 -6
- package/board/packages/board/lib/util.mjs +19 -1
- package/board/packages/board/public/assets/admin-shell.js +15 -8
- package/board/packages/board/public/index.html +693 -364
- package/board/packages/board/server.mjs +29 -8
- package/dist/main.js +5 -3
- package/package.json +1 -1
|
@@ -346,6 +346,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
346
346
|
width: 100%;
|
|
347
347
|
height: 100vh;
|
|
348
348
|
overflow: hidden;
|
|
349
|
+
/* `hidden` still lets focus and scrollIntoView scroll the box: the closed task
|
|
350
|
+
panel sits off-canvas to the right, and tabbing into it moved the whole board
|
|
351
|
+
464px left with no way back. `clip` cannot be scrolled at all. */
|
|
352
|
+
overflow: clip;
|
|
349
353
|
display: flex;
|
|
350
354
|
background: var(--bg-page);
|
|
351
355
|
}
|
|
@@ -359,15 +363,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
359
363
|
radial-gradient(ellipse 70% 50% at 100% 100%, rgba(0, 217, 126, 0.03), transparent 70%);
|
|
360
364
|
}
|
|
361
365
|
|
|
362
|
-
.stale-board {
|
|
363
|
-
|
|
364
|
-
margin: 0 0 12px; padding: 10px 14px;
|
|
365
|
-
background: rgba(180, 83, 9, 0.10);
|
|
366
|
-
border: 1px solid var(--status-progress);
|
|
367
|
-
border-radius: 0;
|
|
368
|
-
font-size: var(--fs-body); color: var(--text);
|
|
369
|
-
}
|
|
370
|
-
.stale-board code { font-family: var(--mono); font-size: var(--fs-small); }
|
|
366
|
+
.stale-board { color: var(--status-progress); cursor: help; white-space: nowrap; }
|
|
367
|
+
.stale-board:focus-visible { outline: 2px solid var(--focus-ring, var(--accent)); outline-offset: 2px; }
|
|
371
368
|
|
|
372
369
|
/* Per-agent budget. Four states, and they must not look alike: `exceeded` holds
|
|
373
370
|
a dispatch, `unmeasured` deliberately does not. A shared grey chip for
|
|
@@ -963,9 +960,12 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
963
960
|
z-index: 10;
|
|
964
961
|
display: flex; flex-direction: column;
|
|
965
962
|
transform: translateX(100%);
|
|
966
|
-
|
|
963
|
+
/* Closed means out of the tab order too: its Close button used to take focus
|
|
964
|
+
while off-canvas, which is what scrolled the board sideways. */
|
|
965
|
+
visibility: hidden;
|
|
966
|
+
transition: transform 0.2s ease, visibility 0s 0.2s;
|
|
967
967
|
}
|
|
968
|
-
.side.open { transform: translateX(0); }
|
|
968
|
+
.side.open { transform: translateX(0); visibility: visible; transition: transform 0.2s ease; }
|
|
969
969
|
/* A document is not a task.
|
|
970
970
|
*
|
|
971
971
|
* 480px is right for a task: a dozen short property rows read fine in a
|
|
@@ -1576,10 +1576,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1576
1576
|
}
|
|
1577
1577
|
/* Theme toggle: two stroke icons, the theme picks one. No emoji — an emoji is
|
|
1578
1578
|
a font the platform chooses, and it was rendering at three sizes on three OSes. */
|
|
1579
|
-
#board-theme-btn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
|
1580
|
-
#board-theme-btn .ico-sun { display: none; }
|
|
1581
|
-
[data-theme="light"] #board-theme-btn .ico-sun { display: inline; }
|
|
1582
|
-
[data-theme="light"] #board-theme-btn .ico-moon { display: none; }
|
|
1583
1579
|
/* The board has two text inputs styled in place — `#agent-panel input` and
|
|
1584
1580
|
`.fleet-search input[type="search"]` — and a third, the Sessions search box,
|
|
1585
1581
|
that asked for the house look by name and got the user agent's. Same values
|
|
@@ -1731,13 +1727,138 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1731
1727
|
.pl-stage.pl-compact .pl-name { color: var(--text2); }
|
|
1732
1728
|
.pl-stage.active { flex: 2; }
|
|
1733
1729
|
/* Idle-focus all-clear card (shown when nothing needs a decision). */
|
|
1734
|
-
/*
|
|
1735
|
-
|
|
1736
|
-
.
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1730
|
+
/* ── Home (UX audit 2026-11, approved mockup) ──────────────────────────────
|
|
1731
|
+
One headline, one queue, three tiles, one disclosure. Existing components
|
|
1732
|
+
and tokens only: rows are .inbox-row, chips are .why, buttons are .gate-btn. */
|
|
1733
|
+
.home-head { margin-bottom: 4px; }
|
|
1734
|
+
.home-queue { background: var(--bg-card); border: 1px solid var(--border); }
|
|
1735
|
+
.home-queue .inbox-section-head { padding: 12px 16px 10px; margin: 0; }
|
|
1736
|
+
.home-queue .inbox-section-head h3 {
|
|
1737
|
+
font-size: var(--fs-eyebrow); font-weight: 500; letter-spacing: 0.08em;
|
|
1738
|
+
text-transform: uppercase; color: var(--text2);
|
|
1739
|
+
}
|
|
1740
|
+
.home-queue-order { font-size: var(--fs-eyebrow); letter-spacing: 0.08em; text-transform: uppercase; color: var(--text2); }
|
|
1741
|
+
.home-queue .inbox-section-head .inbox-count { margin-left: auto; }
|
|
1742
|
+
.home-queue .inbox-row { margin: 0; border-width: 1px 0 0; }
|
|
1743
|
+
.home-queue .inbox-row:hover { transform: none; background: var(--bg-muted); }
|
|
1744
|
+
.home-row { grid-template-columns: 104px 1fr auto; align-items: center; padding: 14px 16px; }
|
|
1745
|
+
.home-row-static { cursor: default; }
|
|
1746
|
+
.home-row-static:hover { background: var(--bg-card) !important; }
|
|
1747
|
+
/* The title is the row's one keyboard target: a real button, drawn as text. A
|
|
1748
|
+
row that was itself a button could not also hold Unblock (nested controls). */
|
|
1749
|
+
.home-title {
|
|
1750
|
+
background: none; border: 0; padding: 0; margin: 0; text-align: left;
|
|
1751
|
+
font: inherit; font-weight: 600; color: var(--text); cursor: pointer;
|
|
1752
|
+
}
|
|
1753
|
+
.home-title:hover { text-decoration: underline; }
|
|
1754
|
+
.home-title:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
|
1755
|
+
.home-row .ttl > :first-child { font-weight: 600; }
|
|
1756
|
+
.home-chip { justify-self: start; white-space: nowrap; }
|
|
1757
|
+
.home-open { pointer-events: none; padding: 0 12px; height: 30px; flex: 0 0 auto; }
|
|
1758
|
+
.home-row .actions .gate-btn { flex: 0 0 auto; height: 30px; padding: 0 12px; }
|
|
1759
|
+
.home-more, .home-link {
|
|
1760
|
+
background: none; border: 0; padding: 0; margin: 0;
|
|
1761
|
+
font-family: inherit; font-size: var(--fs-small); color: var(--accent-text);
|
|
1762
|
+
cursor: pointer; text-align: left; align-self: flex-start;
|
|
1763
|
+
}
|
|
1764
|
+
.home-more { display: block; width: 100%; padding: 12px 16px; border-top: 1px solid var(--border); }
|
|
1765
|
+
.home-more:hover, .home-link:hover { text-decoration: underline; }
|
|
1766
|
+
.home-more:focus-visible, .home-link:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
|
1767
|
+
.home-tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 20px 0 8px; }
|
|
1768
|
+
.home-tile {
|
|
1769
|
+
background: var(--bg-card); border: 1px solid var(--border);
|
|
1770
|
+
padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; min-width: 0;
|
|
1771
|
+
}
|
|
1772
|
+
.home-tile h2 { margin: 0; }
|
|
1773
|
+
.home-tile-num { font-size: var(--fs-title-m); font-weight: 600; color: var(--text); }
|
|
1774
|
+
.home-tile-unit { font-size: var(--fs-small); font-weight: 400; color: var(--text2); }
|
|
1775
|
+
.home-tile-sub { font-size: var(--fs-small); color: var(--text2); min-height: 1.4em; }
|
|
1776
|
+
.home-warn { color: var(--status-progress); }
|
|
1777
|
+
#home-stale { margin: 8px 0 0; }
|
|
1778
|
+
.home-latest { color: var(--text2); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1779
|
+
.home-log-link { margin-left: auto; color: var(--accent-text); white-space: nowrap; }
|
|
1780
|
+
.proj-status[open] .home-log-link { visibility: hidden; }
|
|
1781
|
+
/* The connection line can be long ("connected · task data unavailable"), so the
|
|
1782
|
+
version takes its own line instead of wrapping mid-phrase. */
|
|
1783
|
+
.footer-version { flex: 0 0 100%; }
|
|
1784
|
+
.sidebar-footer { flex-wrap: wrap; }
|
|
1785
|
+
/* Project tags left the sidebar (F1 table): archetype and compliance are
|
|
1786
|
+
configuration, shown in the project chooser and in Settings. */
|
|
1787
|
+
.sidebar .sw-meta .sw-arch, .sidebar .sw-meta .sw-ancestry { display: none; }
|
|
1788
|
+
/* Settings is the last destination and sits at the bottom, above the footer. */
|
|
1789
|
+
#nav-tablist { flex: 1 1 auto; }
|
|
1790
|
+
#nav-tablist .nav-item[data-tab="settings"] { margin-top: auto; }
|
|
1791
|
+
/* ── Cost (UX audit 2026-11, F4, approved mockup) ─────────────────────────
|
|
1792
|
+
Board tokens and components only; the mockup fixed layout and content. */
|
|
1793
|
+
.cost-page { max-width: 1040px; }
|
|
1794
|
+
.cost-heading { display: flex; flex-direction: column; gap: 4px; }
|
|
1795
|
+
.cost-sub { font-size: var(--fs-small); color: var(--text2); }
|
|
1796
|
+
.cost-alert {
|
|
1797
|
+
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center;
|
|
1798
|
+
gap: 8px 16px; padding: 12px 16px; margin-bottom: 16px;
|
|
1799
|
+
background: var(--bg-card); border: 1px solid var(--status-progress);
|
|
1800
|
+
font-size: var(--fs-body); color: var(--text);
|
|
1801
|
+
}
|
|
1802
|
+
.cost-alert b { color: var(--status-progress); }
|
|
1803
|
+
.cost-totals { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
|
1804
|
+
.cost-total {
|
|
1805
|
+
background: var(--bg-card); border: 1px solid var(--border);
|
|
1806
|
+
padding: 18px 20px; display: flex; flex-direction: column; gap: 6px; min-width: 0;
|
|
1807
|
+
}
|
|
1808
|
+
.cost-total h2 {
|
|
1809
|
+
margin: 0; font-family: var(--mono); font-size: var(--fs-eyebrow); font-weight: 500;
|
|
1810
|
+
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text2);
|
|
1811
|
+
}
|
|
1812
|
+
.cost-total-num { font-size: var(--fs-num-l); font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
|
1813
|
+
.cost-total p { margin: 0; font-size: var(--fs-body); line-height: 1.5; color: var(--text2); }
|
|
1814
|
+
.cost-facts { margin: 14px 0 20px; font-size: var(--fs-body); color: var(--text2); }
|
|
1815
|
+
.cost-agents { background: var(--bg-card); border: 1px solid var(--border); }
|
|
1816
|
+
.cost-agents-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 16px; }
|
|
1817
|
+
.cost-agents-head h2 { margin: 0; font-size: var(--fs-title-s); font-weight: 600; color: var(--text); }
|
|
1818
|
+
.cost-row {
|
|
1819
|
+
display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 0.7fr) minmax(0, 1fr) 96px;
|
|
1820
|
+
gap: 12px; align-items: center; padding: 10px 16px; border-top: 1px solid var(--border);
|
|
1821
|
+
font-size: var(--fs-body); color: var(--text);
|
|
1822
|
+
}
|
|
1823
|
+
.cost-row-head {
|
|
1824
|
+
background: var(--bg-muted); font-family: var(--mono); font-size: var(--fs-eyebrow);
|
|
1825
|
+
letter-spacing: 0.06em; text-transform: uppercase; color: var(--text2);
|
|
1826
|
+
}
|
|
1827
|
+
.cost-row .num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
1828
|
+
.cost-row .act { text-align: right; }
|
|
1829
|
+
.cost-agent { font-family: var(--mono); font-size: var(--fs-small); min-width: 0; overflow-wrap: anywhere; }
|
|
1830
|
+
.cost-over { color: var(--status-progress); font-weight: 600; }
|
|
1831
|
+
.cost-line { display: none; }
|
|
1832
|
+
/* Desktop and phone mockups word some lines differently; both sit in the markup. */
|
|
1833
|
+
.cost-short { display: none; }
|
|
1834
|
+
@media (max-width: 767px) { .cost-long { display: none; } .cost-short { display: inline; } }
|
|
1835
|
+
.cost-idle { border-top: 1px solid var(--border); padding: 10px 16px; font-size: var(--fs-body); color: var(--text2); }
|
|
1836
|
+
.cost-idle summary { cursor: pointer; min-height: 24px; }
|
|
1837
|
+
.cost-idle .budgets-uncapped { margin-top: 10px; }
|
|
1838
|
+
.cost-about { margin-top: 16px; font-size: var(--fs-small); color: var(--text2); }
|
|
1839
|
+
.cost-about summary { cursor: pointer; color: var(--accent-text); min-height: 24px; }
|
|
1840
|
+
.cost-about ul { margin: 8px 0 0; padding-left: 18px; line-height: 1.6; }
|
|
1841
|
+
@media (max-width: 767px) {
|
|
1842
|
+
.cost-page .ledger-title-row { flex-direction: column; align-items: stretch; }
|
|
1843
|
+
.cost-page .period-chips { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
1844
|
+
.cost-page .period-chip { min-height: 44px; }
|
|
1845
|
+
.cost-row { grid-template-columns: minmax(0, 1fr) auto; }
|
|
1846
|
+
.cost-row-head, .cost-row > .cost-col { display: none; }
|
|
1847
|
+
.cost-line { display: block; font-family: var(--sans); font-size: var(--fs-small); color: var(--text2); }
|
|
1848
|
+
.cost-line.cost-over { color: var(--status-progress); }
|
|
1849
|
+
.cost-row .ab-btn, .cost-agents-head .ab-btn { min-height: 44px; }
|
|
1850
|
+
}
|
|
1851
|
+
@media (max-width: 767px) {
|
|
1852
|
+
.home-tiles { grid-template-columns: 1fr; gap: 0; }
|
|
1853
|
+
.home-tile { border-width: 1px 1px 0; flex-direction: row; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 14px 16px; min-height: 48px; }
|
|
1854
|
+
.home-tile:last-child { border-bottom-width: 1px; }
|
|
1855
|
+
.home-tile h2 { flex: 0 0 100%; }
|
|
1856
|
+
.home-tile .home-link { margin-left: auto; min-height: 44px; }
|
|
1857
|
+
.home-row { grid-template-columns: 1fr; gap: 6px; }
|
|
1858
|
+
.home-row .actions { justify-self: start; width: auto; display: inline-flex; }
|
|
1859
|
+
.home-row .actions .gate-btn { flex: 0 0 auto; }
|
|
1860
|
+
.home-row .actions .gate-btn, .home-open { height: 44px; padding: 0 16px; }
|
|
1861
|
+
.home-more { min-height: 44px; }
|
|
1741
1862
|
}
|
|
1742
1863
|
|
|
1743
1864
|
.inbox-allclear {
|
|
@@ -3310,8 +3431,11 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3310
3431
|
.topbar .crumbs > span { overflow: hidden; text-overflow: ellipsis; }
|
|
3311
3432
|
.topbar .crumbs .proj-icon,
|
|
3312
3433
|
.topbar .crumbs .sep,
|
|
3313
|
-
.topbar .crumbs
|
|
3314
|
-
|
|
3434
|
+
.topbar .crumbs .here { display: none; }
|
|
3435
|
+
/* One button is left in the bar, so its name fits and stays on screen. */
|
|
3436
|
+
.topbar .btn-new .btn-label { display: inline; }
|
|
3437
|
+
/* The phone bar names the project; the screen names itself in its heading. */
|
|
3438
|
+
.topbar .crumbs #crumb-project { color: var(--text); font-weight: 600; }
|
|
3315
3439
|
.topbar-actions { flex-shrink: 0; }
|
|
3316
3440
|
/* The search box is the one topbar item that can give up its width; ⌘K still
|
|
3317
3441
|
reaches it, and on a phone there is no ⌘K but there is the Search tab. */
|
|
@@ -3483,7 +3607,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3483
3607
|
</svg>
|
|
3484
3608
|
</span>
|
|
3485
3609
|
<span class="brand-name">greatcto</span>
|
|
3486
|
-
<span id="build-version" title="great_cto build version" style="margin-left:6px;font-size:var(--fs-eyebrow);color:var(--text3);font-family:var(--mono);align-self:center">—</span>
|
|
3487
3610
|
</div>
|
|
3488
3611
|
<div class="proj-switch" id="proj-switch" onclick="toggleProjMenu(event)">
|
|
3489
3612
|
<span class="proj-icon" id="proj-switch-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg></span>
|
|
@@ -3571,6 +3694,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3571
3694
|
the footer asserted a sync that had not happened. -->
|
|
3572
3695
|
<span class="live-dot connecting" id="live-dot"></span>
|
|
3573
3696
|
<span id="live-label">connecting…</span>
|
|
3697
|
+
<!-- Version and the "newer build installed" notice live here, one line
|
|
3698
|
+
(F16). The notice used to be a full-width banner above every screen. -->
|
|
3699
|
+
<span class="footer-version"><span id="build-version" title="great_cto build version">—</span> <span id="stale-board-slot"></span></span>
|
|
3574
3700
|
</div>
|
|
3575
3701
|
</aside>
|
|
3576
3702
|
|
|
@@ -3595,7 +3721,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3595
3721
|
<kbd>⌘K</kbd>
|
|
3596
3722
|
</div>
|
|
3597
3723
|
<div class="topbar-actions">
|
|
3598
|
-
|
|
3724
|
+
<!-- The issue counter is still written by three loaders, so the node stays;
|
|
3725
|
+
it is not shown. A total of every issue ever filed answers none of the
|
|
3726
|
+
questions a screen is opened for (UX audit 2026-11, F12). -->
|
|
3727
|
+
<span class="task-count" id="topbar-task-count" hidden>0 ISSUES</span>
|
|
3599
3728
|
<!-- Share (topbar button, data-tab="share") and the notification bell
|
|
3600
3729
|
(#topbar-bell, opened the drawer) are deleted as top-level surfaces
|
|
3601
3730
|
here — great_cto-ki1x.5. Their panels/functions are untouched for
|
|
@@ -3604,14 +3733,12 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3604
3733
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>
|
|
3605
3734
|
<span class="btn-label">Install app</span>
|
|
3606
3735
|
</button>
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14 M5 12h14"/></svg>
|
|
3614
|
-
<span class="btn-label">New issue</span>
|
|
3736
|
+
<!-- Theme and Settings left the top bar (F12): the theme switch is a row in
|
|
3737
|
+
Settings, and Settings is a destination in the sidebar. One primary
|
|
3738
|
+
action stays. "New issue" is still in the command palette. -->
|
|
3739
|
+
<button class="btn-new" onclick="openNewTask()" aria-label="New task" title="New task: prepare a launch command">
|
|
3740
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 5v14 M5 12h14"/></svg>
|
|
3741
|
+
<span class="btn-label">New task</span>
|
|
3615
3742
|
</button>
|
|
3616
3743
|
</div>
|
|
3617
3744
|
</div>
|
|
@@ -3638,8 +3765,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3638
3765
|
<!-- Fidelity pass (v3.27.1): the design's header is the screen's name and
|
|
3639
3766
|
its count; the sentence the board used to shout in 52px display
|
|
3640
3767
|
type is still said, one line, muted, because it carries the stall. -->
|
|
3641
|
-
|
|
3642
|
-
|
|
3768
|
+
<!-- Home (UX audit 2026-11, approved mockup): one count of what needs the
|
|
3769
|
+
operator, said once. The sidebar badge shows the same number. -->
|
|
3770
|
+
<div class="panel-head home-head"><h1 class="page-title" id="home-headline" style="margin:0">Home</h1></div>
|
|
3771
|
+
<div class="inbox-greet" id="inbox-greet"></div>
|
|
3643
3772
|
<!-- Top rung of the evidence ladder: are the bytes that were reviewed
|
|
3644
3773
|
still the bytes in the tree? Hidden while they match — and now only
|
|
3645
3774
|
used for `differs`, the one reading that needs a decision. The other
|
|
@@ -3649,7 +3778,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3649
3778
|
<!-- Sessions stopped on the operator: a permission prompt, or idle waiting for
|
|
3650
3779
|
an answer (Notification hook → .great_cto/status/). The one piece of
|
|
3651
3780
|
status that IS a decision, so it sits above the status disclosure. -->
|
|
3652
|
-
|
|
3781
|
+
|
|
3653
3782
|
<!-- Everything below is STATUS, and status is not a decision.
|
|
3654
3783
|
|
|
3655
3784
|
Six of the seven blocks on this screen were read-only: a pipeline rail,
|
|
@@ -3661,8 +3790,92 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3661
3790
|
|
|
3662
3791
|
Not deleted. "How is the project" is a real question; it is simply not
|
|
3663
3792
|
the question this screen is named after. -->
|
|
3793
|
+
|
|
3794
|
+
|
|
3795
|
+
<!-- Project archetype / compliance / phase used to be a full-width mono
|
|
3796
|
+
row here, between the KPI tiles and the pipeline. On a screen whose
|
|
3797
|
+
title is "what needs your decision" it answered no question the page
|
|
3798
|
+
asks: it is configuration, it does not change day to day, and it was
|
|
3799
|
+
one of three stacked strips competing with the two that ARE
|
|
3800
|
+
actionable. Moved onto the sidebar project block, where the archetype
|
|
3801
|
+
was already shown — same information, one place, and the stack of
|
|
3802
|
+
strips on this page goes from three to two.
|
|
3803
|
+
Not deleted: `PROJECT.md` is not in the Docs list, so compliance and
|
|
3804
|
+
phase would have become unreachable. -->
|
|
3805
|
+
<!-- Tool failure rate watchdog warning -->
|
|
3806
|
+
<!-- One queue, not four sections.
|
|
3807
|
+
|
|
3808
|
+
Gates, P0s, blocked and stale were four headed lists, each with its own
|
|
3809
|
+
count, on a screen that shows five to seven rows in total. The headings
|
|
3810
|
+
cost more vertical space than the rows and forced the reader to work out
|
|
3811
|
+
which bucket a thing was in before reading what it was.
|
|
3812
|
+
|
|
3813
|
+
Linear and GitHub both do the opposite: one ranked stream, and the reason
|
|
3814
|
+
an item is there rides ON the row as a chip. The reason is a property of
|
|
3815
|
+
the item, so it survives the item moving between buckets — which is what
|
|
3816
|
+
made an expired gate unrecognisable when it fell out of the gate list.
|
|
3817
|
+
|
|
3818
|
+
Order: gates first (a signature is owed), then oldest first, because the
|
|
3819
|
+
longest-ignored row is the one a triage list is read for. -->
|
|
3820
|
+
<div class="inbox-section home-queue" id="inbox-queue-section">
|
|
3821
|
+
<div class="inbox-section-head">
|
|
3822
|
+
<h3>Needs you</h3>
|
|
3823
|
+
<span class="home-queue-order">· oldest first</span>
|
|
3824
|
+
<span class="inbox-count" id="inbox-queue-count">0</span>
|
|
3825
|
+
</div>
|
|
3826
|
+
<div id="inbox-sessions-rows"></div>
|
|
3827
|
+
<div id="inbox-queue"></div>
|
|
3828
|
+
<button type="button" class="home-more" id="home-more" hidden onclick="toggleHomeQueue()"></button>
|
|
3829
|
+
</div>
|
|
3830
|
+
<!-- Gates that did NOT wait for you. Placed directly under the decisions
|
|
3831
|
+
you still owe, because "what proceeded without me" is the question a
|
|
3832
|
+
tiered gate creates and nothing on this board answered. -->
|
|
3833
|
+
<div class="inbox-section" id="inbox-standdown-section" style="display:none">
|
|
3834
|
+
<div class="inbox-section-head">
|
|
3835
|
+
<span class="dot dot-amber"></span>
|
|
3836
|
+
<h3>Proceeded without you</h3>
|
|
3837
|
+
<span class="inbox-count" id="inbox-standdown-count">0</span>
|
|
3838
|
+
</div>
|
|
3839
|
+
<div id="inbox-standdown"></div>
|
|
3840
|
+
</div>
|
|
3841
|
+
<!-- Idle focus: when nothing needs a decision, the four sections above collapse and this shows instead. -->
|
|
3842
|
+
<div class="inbox-allclear" id="inbox-allclear" style="display:none">
|
|
3843
|
+
<div class="ac-mark">✓</div>
|
|
3844
|
+
<div class="ac-text"><b>All clear.</b> Nothing needs your decision — back to deep work.</div>
|
|
3845
|
+
<div class="ac-actions">
|
|
3846
|
+
<button class="ac-btn ac-primary" onclick="openNewIssueModal()">+ New issue</button>
|
|
3847
|
+
<button class="ac-btn" onclick="switchTab('kanban', document.querySelector('.nav-item[data-tab="kanban"]'))">View tasks</button>
|
|
3848
|
+
</div>
|
|
3849
|
+
</div>
|
|
3850
|
+
|
|
3851
|
+
<!-- Three answers that used to take two clicks each: are agents working,
|
|
3852
|
+
what did it cost, what shipped. A tile is a number and a link to the
|
|
3853
|
+
screen that owns it; nothing here is a second copy of that screen. -->
|
|
3854
|
+
<div class="home-tiles" id="home-tiles">
|
|
3855
|
+
<section class="home-tile" aria-labelledby="home-tile-agents-h">
|
|
3856
|
+
<h2 class="eyebrow" id="home-tile-agents-h">Agents</h2>
|
|
3857
|
+
<div class="home-tile-num" id="home-agents-num">—</div>
|
|
3858
|
+
<div class="home-tile-sub" id="home-agents-sub"></div>
|
|
3859
|
+
<button type="button" class="home-link" id="home-stale-toggle" aria-expanded="false" aria-controls="home-stale" hidden onclick="toggleHomeStale()">Review stale tasks</button>
|
|
3860
|
+
</section>
|
|
3861
|
+
<section class="home-tile" aria-labelledby="home-tile-cost-h">
|
|
3862
|
+
<h2 class="eyebrow" id="home-tile-cost-h">Cost · 30 days</h2>
|
|
3863
|
+
<div class="home-tile-num" id="home-cost-num">—</div>
|
|
3864
|
+
<div class="home-tile-sub" id="home-cost-sub"></div>
|
|
3865
|
+
<button type="button" class="home-link" onclick="switchTab('insights/ledger')">Open cost</button>
|
|
3866
|
+
</section>
|
|
3867
|
+
<section class="home-tile" aria-labelledby="home-tile-shipped-h">
|
|
3868
|
+
<h2 class="eyebrow" id="home-tile-shipped-h">Shipped · 30 days</h2>
|
|
3869
|
+
<div class="home-tile-num" id="home-shipped-num">—</div>
|
|
3870
|
+
<div class="home-tile-sub" id="home-shipped-sub"></div>
|
|
3871
|
+
<button type="button" class="home-link" onclick="switchTab('work/completed')">See completed work</button>
|
|
3872
|
+
</section>
|
|
3873
|
+
</div>
|
|
3874
|
+
<div id="home-stale" hidden>
|
|
3875
|
+
<div id="home-stale-list"></div>
|
|
3876
|
+
</div>
|
|
3664
3877
|
<details class="proj-status" id="proj-status">
|
|
3665
|
-
<summary class="proj-status-summary"
|
|
3878
|
+
<summary class="proj-status-summary"><span id="home-latest" class="home-latest"></span><span class="proj-status-verdict" id="proj-status-verdict"></span><span class="home-log-link">Show activity log</span></summary>
|
|
3666
3879
|
<div class="proj-status-body">
|
|
3667
3880
|
<!-- Resume — pick up where you left off -->
|
|
3668
3881
|
<div class="resume-card" id="resume-card" style="display:none">
|
|
@@ -3717,61 +3930,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3717
3930
|
</div>
|
|
3718
3931
|
</details>
|
|
3719
3932
|
|
|
3720
|
-
|
|
3721
|
-
<!-- Project archetype / compliance / phase used to be a full-width mono
|
|
3722
|
-
row here, between the KPI tiles and the pipeline. On a screen whose
|
|
3723
|
-
title is "what needs your decision" it answered no question the page
|
|
3724
|
-
asks: it is configuration, it does not change day to day, and it was
|
|
3725
|
-
one of three stacked strips competing with the two that ARE
|
|
3726
|
-
actionable. Moved onto the sidebar project block, where the archetype
|
|
3727
|
-
was already shown — same information, one place, and the stack of
|
|
3728
|
-
strips on this page goes from three to two.
|
|
3729
|
-
Not deleted: `PROJECT.md` is not in the Docs list, so compliance and
|
|
3730
|
-
phase would have become unreachable. -->
|
|
3731
|
-
<!-- Tool failure rate watchdog warning -->
|
|
3732
|
-
<!-- One queue, not four sections.
|
|
3733
|
-
|
|
3734
|
-
Gates, P0s, blocked and stale were four headed lists, each with its own
|
|
3735
|
-
count, on a screen that shows five to seven rows in total. The headings
|
|
3736
|
-
cost more vertical space than the rows and forced the reader to work out
|
|
3737
|
-
which bucket a thing was in before reading what it was.
|
|
3738
|
-
|
|
3739
|
-
Linear and GitHub both do the opposite: one ranked stream, and the reason
|
|
3740
|
-
an item is there rides ON the row as a chip. The reason is a property of
|
|
3741
|
-
the item, so it survives the item moving between buckets — which is what
|
|
3742
|
-
made an expired gate unrecognisable when it fell out of the gate list.
|
|
3743
|
-
|
|
3744
|
-
Order: gates first (a signature is owed), then oldest first, because the
|
|
3745
|
-
longest-ignored row is the one a triage list is read for. -->
|
|
3746
|
-
<div class="inbox-section" id="inbox-queue-section">
|
|
3747
|
-
<div class="inbox-section-head">
|
|
3748
|
-
<span class="dot dot-purple"></span>
|
|
3749
|
-
<h3>Needs you</h3>
|
|
3750
|
-
<span class="inbox-count" id="inbox-queue-count">0</span>
|
|
3751
|
-
</div>
|
|
3752
|
-
<div id="inbox-queue"></div>
|
|
3753
|
-
</div>
|
|
3754
|
-
<!-- Gates that did NOT wait for you. Placed directly under the decisions
|
|
3755
|
-
you still owe, because "what proceeded without me" is the question a
|
|
3756
|
-
tiered gate creates and nothing on this board answered. -->
|
|
3757
|
-
<div class="inbox-section" id="inbox-standdown-section" style="display:none">
|
|
3758
|
-
<div class="inbox-section-head">
|
|
3759
|
-
<span class="dot dot-amber"></span>
|
|
3760
|
-
<h3>Proceeded without you</h3>
|
|
3761
|
-
<span class="inbox-count" id="inbox-standdown-count">0</span>
|
|
3762
|
-
</div>
|
|
3763
|
-
<div id="inbox-standdown"></div>
|
|
3764
|
-
</div>
|
|
3765
|
-
<!-- Idle focus: when nothing needs a decision, the four sections above collapse and this shows instead. -->
|
|
3766
|
-
<div class="inbox-allclear" id="inbox-allclear" style="display:none">
|
|
3767
|
-
<div class="ac-mark">✓</div>
|
|
3768
|
-
<div class="ac-text"><b>All clear.</b> Nothing needs your decision — back to deep work.</div>
|
|
3769
|
-
<div class="ac-actions">
|
|
3770
|
-
<button class="ac-btn ac-primary" onclick="openNewIssueModal()">+ New issue</button>
|
|
3771
|
-
<button class="ac-btn" onclick="switchTab('kanban', document.querySelector('.nav-item[data-tab="kanban"]'))">View tasks</button>
|
|
3772
|
-
</div>
|
|
3773
|
-
</div>
|
|
3774
|
-
|
|
3775
3933
|
<!-- Evidence-ladder footer: the receipt readings that report a condition
|
|
3776
3934
|
but do not ask for a decision ("could not check", "files added").
|
|
3777
3935
|
Below the decisions, because that is what they rank against them —
|
|
@@ -3950,47 +4108,24 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3950
4108
|
</div>
|
|
3951
4109
|
|
|
3952
4110
|
<div class="panel" id="panel-budgets" role="tabpanel">
|
|
3953
|
-
|
|
4111
|
+
<!-- Cost (UX audit 2026-11, F4; approved Cost mockup, artboards CostDesktop and
|
|
4112
|
+
CostPhone). One screen instead of three tabs: the over-cap alert, the two
|
|
4113
|
+
totals side by side, one line of results, one table by agent with a Cap
|
|
4114
|
+
column. Running now, the stage strip and the stuck list live on Home and
|
|
4115
|
+
Work. Token detail is the old Consumption screen. -->
|
|
4116
|
+
<div class="metrics-page cost-page">
|
|
3954
4117
|
<div class="ledger-title-row">
|
|
3955
|
-
<
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
<
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
<button class="period-chip active" data-period="30">30D</button>
|
|
3964
|
-
<button class="period-chip" data-period="90">90D</button>
|
|
3965
|
-
<button class="period-chip" data-period="365">1Y</button>
|
|
3966
|
-
</div>
|
|
4118
|
+
<div class="cost-heading">
|
|
4119
|
+
<h1 class="page-title" style="margin:0" id="cost-title">Cost · <span class="cost-long">last </span>30 days</h1>
|
|
4120
|
+
<div class="cost-sub" id="cost-sub"></div>
|
|
4121
|
+
</div>
|
|
4122
|
+
<div class="period-chips" id="period-chips" role="group" aria-label="Period">
|
|
4123
|
+
<button type="button" class="period-chip" data-period="7">7 days</button>
|
|
4124
|
+
<button type="button" class="period-chip active" data-period="30">30 days</button>
|
|
4125
|
+
<button type="button" class="period-chip" data-period="90">90 days</button>
|
|
3967
4126
|
</div>
|
|
3968
4127
|
</div>
|
|
3969
|
-
<!-- Ledger (great_cto-ki1x.7): the one decision this screen supports is
|
|
3970
|
-
"is something running that I should stop". Two figures, the stage
|
|
3971
|
-
strip and the stuck list answer it; the caps table below is where
|
|
3972
|
-
the money is stopped. -->
|
|
3973
4128
|
<div id="ledger-head" class="ledger-head"><div class="muted">loading…</div></div>
|
|
3974
|
-
<!-- From the old Metrics tab (great_cto-ki1x.16): the period chips, the caps
|
|
3975
|
-
card and the cost panel keep their ids — the code that fills them did
|
|
3976
|
-
not move, only the screen that shows them. -->
|
|
3977
|
-
<!-- Budgets live HERE, on Metrics, not two clicks deep in the Agents
|
|
3978
|
-
drill-in. A control the operator could not find is a control they do
|
|
3979
|
-
not have — and it was invisible on top of that, hidden whenever a
|
|
3980
|
-
project had declared no caps, which is exactly the project that
|
|
3981
|
-
needs the button to declare one. -->
|
|
3982
|
-
<div class="mp-section-label" style="margin-top:24px">Agent budgets</div>
|
|
3983
|
-
<div id="agent-budgets" class="mp-card" style="padding:12px 16px;font-size:var(--fs-small);margin-bottom:4px;"></div>
|
|
3984
|
-
<!-- The old cost panel (four tiles: 'vs human team', a projected month, a
|
|
3985
|
-
daily burn, an empty chart) left in the fidelity pass — a forecast and a
|
|
3986
|
-
human-team comparison are two of the brief's refusals, and the
|
|
3987
|
-
Ledger head already carries spend with its provenance. -->
|
|
3988
|
-
|
|
3989
|
-
<!-- Whether the judge is connected at all. Without a key nothing breaks
|
|
3990
|
-
— every stage is scored `unverifiable`, honestly — but "not
|
|
3991
|
-
connected" and "nothing to check" read the same from here, and the
|
|
3992
|
-
key is not discoverable: the README names OpenRouter once without
|
|
3993
|
-
saying where it goes. -->
|
|
3994
4129
|
<div id="budgets-page-body">loading…</div>
|
|
3995
4130
|
</div>
|
|
3996
4131
|
</div>
|
|
@@ -4333,12 +4468,12 @@ function gateChip(t) {
|
|
|
4333
4468
|
}
|
|
4334
4469
|
function renderInbox(d) {
|
|
4335
4470
|
inboxSecondOpinion = d?.second_opinion || null;
|
|
4336
|
-
{ const c = document.getElementById('decisions-count'); const n = (d?.pending_gates || []).length; if (c) c.textContent = n ? `${n} waiting` : 'nothing waiting'; }
|
|
4337
4471
|
for (const g of (d?.pending_gates || [])) if (g?.id && g.reversibility) GATE_REVERSIBILITY[g.id] = g.reversibility;
|
|
4338
4472
|
if (d && Array.isArray(d.pending_gates)) d = { ...d, pending_gates: sortGatesByCostOfUndo(d.pending_gates) };
|
|
4339
4473
|
// Kept so renderResume can ask what this section is already showing, rather
|
|
4340
4474
|
// than the two of them rendering the same task independently.
|
|
4341
4475
|
window.__inboxData = d;
|
|
4476
|
+
window.__homeReadAt = Date.now();
|
|
4342
4477
|
inboxData = d || {};
|
|
4343
4478
|
const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
|
|
4344
4479
|
// `needs_you` counts distinct objects. Summing the three categories double-counted
|
|
@@ -4350,15 +4485,19 @@ function renderInbox(d) {
|
|
|
4350
4485
|
// open P0s one project over is the same lie as the headline was. When
|
|
4351
4486
|
// `elsewhere` is unknown the badge shows what it knows and says so in its
|
|
4352
4487
|
// title rather than adding a zero it never measured.
|
|
4353
|
-
|
|
4488
|
+
// One number, one definition (UX audit 2026-11, F1). The badge used to add the
|
|
4489
|
+
// P0s and gates of the person's OTHER projects, so it read 51 on a project whose
|
|
4490
|
+
// own headline said "nothing waiting", and 43 on an empty one. It now counts
|
|
4491
|
+
// exactly what the Home headline counts; other projects stay in the tooltip and
|
|
4492
|
+
// in the sentence under the headline, where they are named as other projects.
|
|
4354
4493
|
const ew = d?.elsewhere;
|
|
4355
4494
|
const ewKnown = ew && ew.state !== 'unreadable';
|
|
4356
4495
|
const away = ewKnown ? (Number(ew.p0) || 0) + (Number(ew.gates) || 0) : 0;
|
|
4496
|
+
renderHomeHead();
|
|
4357
4497
|
const badge = document.getElementById('nav-inbox-count');
|
|
4358
|
-
badge.
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
: `${here} here · other projects not read`;
|
|
4498
|
+
if (badge) badge.title = ewKnown
|
|
4499
|
+
? `${homeNeedsYou().total} here · ${away} in other projects`
|
|
4500
|
+
: `${homeNeedsYou().total} here · other projects not read`;
|
|
4362
4501
|
// Same rule as the all-clear card: "nothing urgent" is a claim about data we
|
|
4363
4502
|
// may not have. When a read failed, say that instead of reassuring the user.
|
|
4364
4503
|
// The headline states the SITUATION, not the time of day.
|
|
@@ -4382,32 +4521,9 @@ function renderInbox(d) {
|
|
|
4382
4521
|
return (Date.now() - Math.max(...stamps)) / 3600e3;
|
|
4383
4522
|
})();
|
|
4384
4523
|
|
|
4385
|
-
const needs = s.gates + s.p0;
|
|
4386
4524
|
const STALL_H = 72; // the same 72h the gate-expiry hook uses
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
greetEl.setAttribute('aria-live', 'polite');
|
|
4390
|
-
|
|
4391
|
-
if (anyDegraded()) {
|
|
4392
|
-
// A read failed. Reassurance would be a claim about data we do not have.
|
|
4393
|
-
greetEl.textContent = 'Some data could not be read — treat the counts below as incomplete.';
|
|
4394
|
-
} else if (newestEventAgeH != null && newestEventAgeH > STALL_H) {
|
|
4395
|
-
const days = Math.floor(newestEventAgeH / 24);
|
|
4396
|
-
// "Here", because the count above is this project's. What the person has
|
|
4397
|
-
// waiting in other projects is appended — or, when it could not be read,
|
|
4398
|
-
// nothing is claimed about them at all.
|
|
4399
|
-
const ew = elsewhereClause(d?.elsewhere);
|
|
4400
|
-
const tail = ew.text ? ` ${ew.text}` : (ew.known ? ' Nothing elsewhere either.' : '');
|
|
4401
|
-
greetEl.textContent = needs
|
|
4402
|
-
? `Stalled — nothing has moved in ${days} days, and ${needs} item${needs > 1 ? 's' : ''} ${needs > 1 ? 'are' : 'is'} waiting on you here.${tail}`
|
|
4403
|
-
: `Stalled — nothing has moved in ${days} days, and nothing is waiting on you here.${tail}`;
|
|
4404
|
-
} else if (needs) {
|
|
4405
|
-
greetEl.textContent = "Here's what needs your decision.";
|
|
4406
|
-
} else {
|
|
4407
|
-
greetEl.textContent = newestEventAgeH == null
|
|
4408
|
-
? 'Nothing recorded yet for this project.'
|
|
4409
|
-
: `All clear — last activity ${relTime(new Date(Date.now() - newestEventAgeH * 3600e3).toISOString())}.`;
|
|
4410
|
-
}
|
|
4525
|
+
window.__homeAgeH = newestEventAgeH;
|
|
4526
|
+
renderHomeHead();
|
|
4411
4527
|
// The stat-tile row is gone: two of its four boxes read 0 on an ordinary
|
|
4412
4528
|
// morning, and the two that did not repeated a count the section headings
|
|
4413
4529
|
// below already carry. The counts still reach the nav badge.
|
|
@@ -4463,6 +4579,7 @@ function renderInbox(d) {
|
|
|
4463
4579
|
}
|
|
4464
4580
|
renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
|
|
4465
4581
|
'Some project data could not be read — counts below are incomplete.');
|
|
4582
|
+
refreshHomeTiles();
|
|
4466
4583
|
}
|
|
4467
4584
|
|
|
4468
4585
|
/**
|
|
@@ -4545,11 +4662,15 @@ function renderQueue(d) {
|
|
|
4545
4662
|
}),
|
|
4546
4663
|
...withReason(d?.p0_open, (t) => (t.is_gate ? 'expired gate' : 'P0')),
|
|
4547
4664
|
...withReason(d?.blocked, 'blocked'),
|
|
4548
|
-
...withReason(d?.stale_in_progress, (t) => {
|
|
4549
|
-
const dd = ageD(t);
|
|
4550
|
-
return dd == null ? 'stale' : `stale ${dd}d`;
|
|
4551
|
-
}),
|
|
4552
4665
|
];
|
|
4666
|
+
// Stale in-progress work is not a decision. It is one number on the Agents
|
|
4667
|
+
// tile, and the rows open from there (F3) — in the queue they outnumbered the
|
|
4668
|
+
// items that do need the operator and pushed them off the first screen.
|
|
4669
|
+
const stale = withReason(d?.stale_in_progress, (t) => {
|
|
4670
|
+
const dd = ageD(t);
|
|
4671
|
+
return dd == null ? 'stale' : `stale ${dd}d`;
|
|
4672
|
+
}).sort((a, b) => (ageHours(b.updated_at) ?? -1) - (ageHours(a.updated_at) ?? -1));
|
|
4673
|
+
renderInboxList('home-stale-list', stale, null, { reasons: true });
|
|
4553
4674
|
// The receipt-drift row, when there is one. It is not a task, so it arrives as
|
|
4554
4675
|
// a synthetic item rather than from any bucket.
|
|
4555
4676
|
if (window.__receiptDrift) queue.push(window.__receiptDrift);
|
|
@@ -4563,17 +4684,27 @@ function renderQueue(d) {
|
|
|
4563
4684
|
if (bh == null) return -1;
|
|
4564
4685
|
return bh - ah;
|
|
4565
4686
|
});
|
|
4566
|
-
|
|
4567
|
-
//
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
const
|
|
4575
|
-
if (
|
|
4576
|
-
|
|
4687
|
+
// The first screen shows the oldest few; the rest is one click away. Gates are
|
|
4688
|
+
// never folded: a signature that is owed must not sit behind "Show more".
|
|
4689
|
+
const HOME_ROWS = 4;
|
|
4690
|
+
const sessionRows = homeSessionRowCount();
|
|
4691
|
+
const gates = queue.filter((t) => t.is_gate).length;
|
|
4692
|
+
const room = Math.max(gates, HOME_ROWS - sessionRows);
|
|
4693
|
+
const folded = homeQueueOpen ? 0 : Math.max(0, queue.length - room);
|
|
4694
|
+
renderInboxList('inbox-queue', folded ? queue.slice(0, room) : queue, null, { reasons: true, home: true });
|
|
4695
|
+
const section = document.getElementById('inbox-queue-section');
|
|
4696
|
+
if (section) section.style.display = (queue.length + sessionRows) ? '' : 'none';
|
|
4697
|
+
renderHomeHead();
|
|
4698
|
+
const total = homeNeedsYou().total;
|
|
4699
|
+
const cnt = document.getElementById('inbox-queue-count');
|
|
4700
|
+
if (cnt) cnt.textContent = folded ? `${total - folded} of ${total}` : String(total);
|
|
4701
|
+
const more = document.getElementById('home-more');
|
|
4702
|
+
if (more) {
|
|
4703
|
+
more.hidden = !(folded || (homeQueueOpen && queue.length > room));
|
|
4704
|
+
more.textContent = homeQueueOpen ? 'Show fewer' : `Show ${folded} more`;
|
|
4705
|
+
more.setAttribute('aria-expanded', String(homeQueueOpen));
|
|
4706
|
+
}
|
|
4707
|
+
window.__homeQueueLen = queue.length;
|
|
4577
4708
|
}
|
|
4578
4709
|
|
|
4579
4710
|
/**
|
|
@@ -5044,6 +5175,22 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
5044
5175
|
const reasonChip = (opts.reasons && t.__reason)
|
|
5045
5176
|
? `<span class="why why-${tone}">${esc(t.__reason)}</span>`
|
|
5046
5177
|
: '';
|
|
5178
|
+
// Home shows a non-gate row as: why it is here and for how long, what it is,
|
|
5179
|
+
// one button. A blocked row keeps Unblock — the one thing the operator can do
|
|
5180
|
+
// about it from here, and the reason this row has a button at all (see the
|
|
5181
|
+
// note on `unblock` above). The id, priority, agent and the rest are in the
|
|
5182
|
+
// task panel the row opens.
|
|
5183
|
+
if (opts.home && !canDecide) {
|
|
5184
|
+
const dd = hrs == null ? null : Math.floor(hrs / 24);
|
|
5185
|
+
const why = String(t.__reason || 'open');
|
|
5186
|
+
const chip = `${why.charAt(0).toUpperCase()}${why.slice(1)}${dd == null || /\d+d$/.test(why) ? '' : dd >= 1 ? ` ${dd}d` : ' today'}`;
|
|
5187
|
+
return `
|
|
5188
|
+
<div class="inbox-row home-row" onclick='openSide(${JSON.stringify(t).replace(/'/g, "'")})'>
|
|
5189
|
+
<span class="why why-${tone} home-chip" title="${esc(t.id || '')}${t.updated_at ? ' · last change ' + esc(fmtDate(t.updated_at)) : ''}">${esc(chip)}</span>
|
|
5190
|
+
<span class="ttl"><button type="button" class="home-title" onclick='event.stopPropagation(); openSide(${JSON.stringify(t).replace(/'/g, "'")})'>${esc(t.title || t.id)}</button>${desc}</span>
|
|
5191
|
+
${t.__synthetic ? diffBtn : (unblock || '<div class="actions"><span class="gate-btn home-open" aria-hidden="true">Open</span></div>')}
|
|
5192
|
+
</div>`;
|
|
5193
|
+
}
|
|
5047
5194
|
return `
|
|
5048
5195
|
<div class="inbox-row${aged ? ' row-aged' : ''}"${canDecide ? ` data-gate-id="${esc(t.id)}"` : ''} tabindex="0" role="button" aria-label="${esc(t.title || t.id)}" onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();this.click();}' onclick='openSide(${JSON.stringify(t).replace(/'/g, "'")})'>
|
|
5049
5196
|
${canDecide ? gateChip(t) : `<span class="id">${esc(t.id || '')}</span>`}
|
|
@@ -5055,24 +5202,188 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
5055
5202
|
reattachGateBriefs(root);
|
|
5056
5203
|
}
|
|
5057
5204
|
|
|
5205
|
+
// ── Home (UX audit 2026-11) ────────────────────────────────────────────────
|
|
5206
|
+
// What "needs you" means, in one place. The headline, the sidebar badge, the
|
|
5207
|
+
// queue count and the sentence under the headline all read this function, so
|
|
5208
|
+
// they cannot disagree again: the audit found 51, 18, 8 and "nothing waiting"
|
|
5209
|
+
// on one project within ten minutes, each from its own arithmetic.
|
|
5210
|
+
//
|
|
5211
|
+
// gates a signature is owed
|
|
5212
|
+
// p0 open P0s
|
|
5213
|
+
// blocked tasks that will not move until somebody unblocks them
|
|
5214
|
+
// replies sessions stopped on the operator (a prompt, or a finished turn)
|
|
5215
|
+
//
|
|
5216
|
+
// `tasks` is the server's own distinct count (`summary.needs_you`): a P0 that is
|
|
5217
|
+
// also blocked is one item. Stale in-progress work is NOT in it — nothing is
|
|
5218
|
+
// asked of the operator there; it is the Agents tile's number.
|
|
5219
|
+
let homeQueueOpen = false;
|
|
5220
|
+
function homeNeedsYou() {
|
|
5221
|
+
const d = window.__inboxData || {};
|
|
5222
|
+
const s = d.summary || {};
|
|
5223
|
+
const gates = (d.pending_gates || []).length;
|
|
5224
|
+
const p0 = (d.p0_open || []).length;
|
|
5225
|
+
const blocked = (d.blocked || []).length;
|
|
5226
|
+
const tasks = Number.isFinite(s.needs_you) ? s.needs_you : gates + p0 + blocked;
|
|
5227
|
+
const ss = window.__sessionStatus || { blocked: [], waiting: 0 };
|
|
5228
|
+
const replies = ss.blocked.length + ss.waiting;
|
|
5229
|
+
// Reviewed files changed after an approval: not a task, still a decision.
|
|
5230
|
+
const drift = window.__receiptDrift ? 1 : 0;
|
|
5231
|
+
return { gates, p0, blocked, tasks, replies, drift, total: tasks + replies + drift, read: !!window.__inboxData };
|
|
5232
|
+
}
|
|
5233
|
+
function homeSessionRowCount() {
|
|
5234
|
+
const ss = window.__sessionStatus || { blocked: [], waiting: 0 };
|
|
5235
|
+
return ss.blocked.length + (ss.waiting ? 1 : 0);
|
|
5236
|
+
}
|
|
5237
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
5238
|
+
function renderHomeHead() {
|
|
5239
|
+
const n = homeNeedsYou();
|
|
5240
|
+
const badge = document.getElementById('nav-inbox-count');
|
|
5241
|
+
if (badge) badge.textContent = n.total;
|
|
5242
|
+
const h = document.getElementById('home-headline');
|
|
5243
|
+
const sub = document.getElementById('inbox-greet');
|
|
5244
|
+
if (!h || !sub) return;
|
|
5245
|
+
sub.setAttribute('role', 'status');
|
|
5246
|
+
sub.setAttribute('aria-live', 'polite');
|
|
5247
|
+
if (!n.read) { h.textContent = 'Home'; sub.textContent = 'Reading project data…'; return; }
|
|
5248
|
+
if (anyDegraded()) {
|
|
5249
|
+
// A read failed. "Nothing needs you" would be a claim about data we do not
|
|
5250
|
+
// have, so the headline says only what was counted, as a floor.
|
|
5251
|
+
h.textContent = n.total ? `At least ${plural(n.total, 'item needs', 'items need')} you` : 'Home';
|
|
5252
|
+
sub.textContent = 'Some data could not be read — treat the counts below as incomplete.';
|
|
5253
|
+
return;
|
|
5254
|
+
}
|
|
5255
|
+
h.textContent = n.total ? `${plural(n.total, 'item needs', 'items need')} you` : 'Nothing needs you here';
|
|
5256
|
+
let parts = [];
|
|
5257
|
+
if (n.replies) parts.push(`${plural(n.replies, 'session waits', 'sessions wait')} for your reply`);
|
|
5258
|
+
if (n.blocked) parts.push(`${plural(n.blocked, 'task is', 'tasks are')} blocked`);
|
|
5259
|
+
if (n.p0) parts.push(`${n.p0} P0 open`);
|
|
5260
|
+
parts.push(n.gates ? `${plural(n.gates, 'gate', 'gates')} waiting` : 'no gates waiting');
|
|
5261
|
+
if (n.drift) parts.push('reviewed files changed after approval');
|
|
5262
|
+
// Silence is a reading: a project where nothing has moved for days says so
|
|
5263
|
+
// here instead of reading as calm.
|
|
5264
|
+
const ageH = window.__homeAgeH;
|
|
5265
|
+
if (!n.total && ageH == null) parts = ['Nothing recorded yet for this project'];
|
|
5266
|
+
else if (ageH != null && ageH > 72) parts.push(`nothing has moved in ${Math.floor(ageH / 24)} days`);
|
|
5267
|
+
// Scoped to "here", and the person's other projects are consulted every time:
|
|
5268
|
+
// "nothing needs you" beside open P0s one project over is the claim this page
|
|
5269
|
+
// already had to stop making once (tests/lib/hero-wording.test.mjs).
|
|
5270
|
+
const ew = elsewhereClause((window.__inboxData || {}).elsewhere);
|
|
5271
|
+
if (ew.text) parts.push(String(ew.text).replace(/\.$/, ''));
|
|
5272
|
+
if (window.__homeReadAt) {
|
|
5273
|
+
const min = Math.floor((Date.now() - window.__homeReadAt) / 60000);
|
|
5274
|
+
parts.push(min < 1 ? 'updated just now' : `updated ${min} min ago`);
|
|
5275
|
+
}
|
|
5276
|
+
sub.textContent = parts.join(' · ');
|
|
5277
|
+
}
|
|
5278
|
+
function toggleHomeQueue() {
|
|
5279
|
+
homeQueueOpen = !homeQueueOpen;
|
|
5280
|
+
renderQueue(window.__inboxData);
|
|
5281
|
+
}
|
|
5282
|
+
function toggleHomeStale() {
|
|
5283
|
+
const box = document.getElementById('home-stale');
|
|
5284
|
+
const btn = document.getElementById('home-stale-toggle');
|
|
5285
|
+
if (!box || !btn) return;
|
|
5286
|
+
box.hidden = !box.hidden;
|
|
5287
|
+
btn.setAttribute('aria-expanded', String(!box.hidden));
|
|
5288
|
+
btn.textContent = box.hidden ? 'Review stale tasks' : 'Hide stale tasks';
|
|
5289
|
+
}
|
|
5290
|
+
// "New task" prepares a launch command; it does not create a Beads issue (that
|
|
5291
|
+
// is "New issue", in the command palette) and does not start a session.
|
|
5292
|
+
function openNewTask() {
|
|
5293
|
+
switchTab('work');
|
|
5294
|
+
const d = document.querySelector('details.admin-compose');
|
|
5295
|
+
if (d) d.open = true;
|
|
5296
|
+
setTimeout(() => document.getElementById('work-goal')?.focus(), 0);
|
|
5297
|
+
}
|
|
5298
|
+
// Running, from ONE source: the task projection Work is drawn from. The Ledger
|
|
5299
|
+
// tile used to count pipeline stages whose last verdict was not final, which
|
|
5300
|
+
// read "6 running" for stages last heard from weeks ago, beside "Running 0" on
|
|
5301
|
+
// Work (F3). `null` means the projection has not been read — not zero.
|
|
5302
|
+
function homeRunningNow() {
|
|
5303
|
+
const snap = window.GctoWork?.getSnapshot?.();
|
|
5304
|
+
if (!snap || !Array.isArray(snap.entries)) return null;
|
|
5305
|
+
return snap.entries.filter((e) => !e.terminal && ['working', 'publishing'].includes(e.phase)).length;
|
|
5306
|
+
}
|
|
5307
|
+
async function refreshHomeTiles() {
|
|
5308
|
+
const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };
|
|
5309
|
+
const d = window.__inboxData || {};
|
|
5310
|
+
// Agents
|
|
5311
|
+
const running = homeRunningNow();
|
|
5312
|
+
const stale = (d.stale_in_progress || []).length;
|
|
5313
|
+
set('home-agents-num', running == null ? absent('unloaded', 'the task projection has not been read') : `${running} running now`);
|
|
5314
|
+
// The task projection loads on its own schedule; look again rather than leave
|
|
5315
|
+
// "not read" on screen after it has been read.
|
|
5316
|
+
if (running == null && (refreshHomeTiles.tries = (refreshHomeTiles.tries || 0) + 1) <= 10) setTimeout(refreshHomeTiles, 1500);
|
|
5317
|
+
else if (running != null) refreshHomeTiles.tries = 0;
|
|
5318
|
+
set('home-agents-sub', stale
|
|
5319
|
+
? `${plural(stale, 'in-progress task has', 'in-progress tasks have')} not moved for 2+ days`
|
|
5320
|
+
: (window.__inboxData ? 'No stale in-progress tasks' : ''));
|
|
5321
|
+
const st = document.getElementById('home-stale-toggle');
|
|
5322
|
+
if (st) st.hidden = !stale;
|
|
5323
|
+
if (!stale) { const box = document.getElementById('home-stale'); if (box) box.hidden = true; }
|
|
5324
|
+
// Cost and Shipped share the Ledger's own reads, pinned to 30 days.
|
|
5325
|
+
// Every inbox arrival lands here (init, refresh, SSE); the 30-day reads are
|
|
5326
|
+
// not repeated more than twice a minute for the same project.
|
|
5327
|
+
const project = currentProject;
|
|
5328
|
+
if (!project) return;
|
|
5329
|
+
let last = refreshHomeTiles.last || {};
|
|
5330
|
+
if (!(last.project === project && Date.now() - last.at < 30000)) {
|
|
5331
|
+
const q = `${pqs()}${pqs() ? '&' : '?'}days=30`;
|
|
5332
|
+
last = refreshHomeTiles.last = { project, at: Date.now(), m: null, cost: null };
|
|
5333
|
+
[last.m, last.cost] = await Promise.all([
|
|
5334
|
+
api(`/api/metrics${q}`).catch(() => null),
|
|
5335
|
+
api(`/api/cost${q}`).catch(() => null),
|
|
5336
|
+
]);
|
|
5337
|
+
}
|
|
5338
|
+
if (typeof refreshAgentsInstalled === 'function' && !(fleetState?.agents || []).length) {
|
|
5339
|
+
await Promise.resolve(refreshAgentsInstalled()).catch(() => null);
|
|
5340
|
+
}
|
|
5341
|
+
const m = last.m, cost = last.cost;
|
|
5342
|
+
if (project !== currentProject) return;
|
|
5343
|
+
const real = m?.cost?.real_llm_usd ?? null, est = m?.cost?.llm_usd ?? null;
|
|
5344
|
+
const spent = real != null ? real : est;
|
|
5345
|
+
set('home-cost-num', !m || !m.cost ? absent('unloaded', 'the metrics payload carried no cost section')
|
|
5346
|
+
: (spent == null || (real === 0 && est === 0)) ? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
|
|
5347
|
+
: `$${spent < 10 ? spent.toFixed(2) : fmtMoney(spent)} <span class="home-tile-unit">${real != null ? 'measured' : 'estimated'}</span>`);
|
|
5348
|
+
const over = (fleetState?.agents || []).filter((a) => a.budget && a.budget.state === 'exceeded');
|
|
5349
|
+
const cap = cost?.monthly_budget;
|
|
5350
|
+
set('home-cost-sub', over.length
|
|
5351
|
+
? `<span class="home-warn">${esc(over[0].slug || over[0].name || 'an agent')}${over.length > 1 ? ` and ${over.length - 1} more` : ''} ${over.length > 1 ? 'are' : 'is'} over ${over.length > 1 ? 'their caps' : 'its cap'}</span>`
|
|
5352
|
+
: cap ? `of $${fmtMoney(cap)} monthly cap` : 'No monthly cap set');
|
|
5353
|
+
const done = m?.tasks ? ((m.tasks.done_in_window || m.tasks.done) ?? 0) : null;
|
|
5354
|
+
set('home-shipped-num', done == null ? absent('unloaded', 'the metrics payload carried no tasks section') : `${plural(done, 'task', 'tasks')} done`);
|
|
5355
|
+
set('home-shipped-sub', m?.avg_completion_min ? `Median ${m.avg_completion_min} min from start to done` : '');
|
|
5356
|
+
}
|
|
5357
|
+
|
|
5058
5358
|
// Sessions waiting on a person (scripts/lib/session-status.mjs). `blocked` is a
|
|
5059
5359
|
// prompt or an unanswered wait and gets a row; `waiting` (turn ended) is what every
|
|
5060
|
-
// open session is between turns, so it is one
|
|
5360
|
+
// open session is between turns, so it is one row for all of them, not a row each.
|
|
5361
|
+
// Both are counted in the Home headline: they are the items most plainly waiting
|
|
5362
|
+
// on the operator, and they used to be a muted sentence above the queue.
|
|
5061
5363
|
async function refreshSessionStatus() {
|
|
5062
|
-
const el = document.getElementById('inbox-sessions');
|
|
5364
|
+
const el = document.getElementById('inbox-sessions-rows');
|
|
5063
5365
|
if (!el) return;
|
|
5366
|
+
const project = currentProject;
|
|
5064
5367
|
const r = await api(`/api/session-status${pqs()}`);
|
|
5368
|
+
if (project !== currentProject) return;
|
|
5065
5369
|
const rows = (r && Array.isArray(r.sessions)) ? r.sessions : [];
|
|
5066
5370
|
const blocked = rows.filter((x) => x.state === 'blocked');
|
|
5067
5371
|
const waiting = rows.filter((x) => x.state === 'waiting').length;
|
|
5068
|
-
|
|
5069
|
-
el.
|
|
5070
|
-
|
|
5071
|
-
<
|
|
5072
|
-
|
|
5073
|
-
<
|
|
5372
|
+
window.__sessionStatus = { blocked, waiting };
|
|
5373
|
+
el.innerHTML = blocked.map((x) => `<div class="inbox-row home-row home-row-static">
|
|
5374
|
+
<span class="why why-gate home-chip">Reply</span>
|
|
5375
|
+
<span class="ttl"><span>Session ${esc(String(x.session).slice(0, 8))} waits for you</span>
|
|
5376
|
+
<span class="ttl-desc">${esc(x.reason || 'waiting for input')} · ${esc(agoStr(x.since))}${x.kind ? ` · ${esc(x.kind)}` : ''}</span></span>
|
|
5377
|
+
<div class="actions"></div>
|
|
5074
5378
|
</div>`).join('')
|
|
5075
|
-
+ (waiting ? `<div class="
|
|
5379
|
+
+ (waiting ? `<div class="inbox-row home-row home-row-static">
|
|
5380
|
+
<span class="why why-gate home-chip">Reply</span>
|
|
5381
|
+
<span class="ttl"><span>${plural(waiting, 'session', 'sessions')} finished a turn and ${waiting === 1 ? 'waits' : 'wait'} for your next message</span>
|
|
5382
|
+
<span class="ttl-desc">The agent stopped and will not continue until you answer in that session.</span></span>
|
|
5383
|
+
<div class="actions"><button type="button" class="gate-btn gate-approve" onclick="switchTab('sessions')">Open sessions</button></div>
|
|
5384
|
+
</div>` : '');
|
|
5385
|
+
renderHomeHead();
|
|
5386
|
+
if (window.__inboxData) renderQueue(window.__inboxData);
|
|
5076
5387
|
}
|
|
5077
5388
|
|
|
5078
5389
|
async function refreshInbox() {
|
|
@@ -5413,89 +5724,98 @@ async function verifyJudgeKey() {
|
|
|
5413
5724
|
loadJudgeStatus();
|
|
5414
5725
|
}
|
|
5415
5726
|
|
|
5416
|
-
//
|
|
5417
|
-
// `measured` only when a verdict carried a cost,
|
|
5418
|
-
// labelled so;
|
|
5419
|
-
//
|
|
5420
|
-
//
|
|
5421
|
-
function
|
|
5727
|
+
// Cost head (UX audit 2026-11, F4; replaces the Ledger head). Every figure
|
|
5728
|
+
// names its state: spend is `measured` only when a verdict carried a cost,
|
|
5729
|
+
// otherwise `estimated` and labelled so; list price is n/a when no price is
|
|
5730
|
+
// set, never $0. Running now, the stage strip and the stuck list moved to Home
|
|
5731
|
+
// and Work — this screen answers "what did it cost".
|
|
5732
|
+
function costOverCap() {
|
|
5733
|
+
const agents = (fleetState?.agents || []).filter((a) => a.budget?.state === 'exceeded');
|
|
5734
|
+
return agents.map((a) => ({ slug: a.slug, spent: a.budget.measuredUsd ?? 0, cap: a.budget.limitUsd }));
|
|
5735
|
+
}
|
|
5736
|
+
function costRange(days) {
|
|
5737
|
+
const end = new Date(), start = new Date(end.getTime() - (days - 1) * 86400000);
|
|
5738
|
+
const f = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
5739
|
+
return `${f(start)} – ${f(end)}`;
|
|
5740
|
+
}
|
|
5741
|
+
function renderLedgerHead({ cost, metrics, usage, over }) {
|
|
5742
|
+
if (!over) over = costOverCap();
|
|
5422
5743
|
const el = document.getElementById('ledger-head');
|
|
5423
5744
|
if (!el) return;
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5745
|
+
const title = document.getElementById('cost-title');
|
|
5746
|
+
if (title) title.innerHTML = `Cost · <span class="cost-long">last </span>${esc(periodLabel(currentPeriod))}`;
|
|
5747
|
+
const sub = document.getElementById('cost-sub');
|
|
5748
|
+
if (sub) sub.textContent = `${costRange(currentPeriod)} · updated just now`;
|
|
5428
5749
|
const costKnown = !!(metrics && metrics.cost);
|
|
5429
5750
|
const realLlmUsd = metrics?.cost?.real_llm_usd ?? null;
|
|
5430
5751
|
const llmUsd = metrics?.cost?.llm_usd ?? null;
|
|
5431
5752
|
const spent = realLlmUsd != null ? realLlmUsd : llmUsd;
|
|
5432
5753
|
const spentState = realLlmUsd != null ? 'measured' : (llmUsd != null ? 'estimated' : 'unmeasured');
|
|
5433
|
-
const cap = cost?.monthly_budget;
|
|
5434
|
-
const pct = (spent != null && cap) ? Math.min(100, Math.round(spent / cap * 100)) : null;
|
|
5435
5754
|
const spentCell = !costKnown
|
|
5436
5755
|
? absent('unloaded', 'the metrics payload carried no cost section')
|
|
5437
5756
|
: (realLlmUsd === 0 && llmUsd === 0) || spent == null
|
|
5438
5757
|
? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
|
|
5439
5758
|
: `$${spent < 10 ? spent.toFixed(2) : fmtMoney(spent)}`;
|
|
5440
|
-
|
|
5759
|
+
|
|
5760
|
+
// One alert: agents over their cap, and the project's monthly cap if passed.
|
|
5761
|
+
// `over` is read once per load: the fleet is re-read in the background and a
|
|
5762
|
+
// redraw mid-reload must not drop the alert.
|
|
5763
|
+
const cap = cost?.monthly_budget;
|
|
5764
|
+
const monthlyOver = !!(cap && spent != null && spent > cap);
|
|
5765
|
+
let alert = '';
|
|
5766
|
+
if (over.length === 1) {
|
|
5767
|
+
const o = over[0];
|
|
5768
|
+
alert = `<b>${esc(o.slug)} is over its cap:</b> $${o.spent.toFixed(2)} spent of $${esc(String(o.cap))}`;
|
|
5769
|
+
} else if (over.length > 1) {
|
|
5770
|
+
alert = `<b>${over.length} agents are over their caps:</b> ${over.map((o) => esc(o.slug)).join(', ')}`;
|
|
5771
|
+
}
|
|
5772
|
+
if (monthlyOver) alert += `${alert ? '<br>' : ''}<b>The project is over its monthly cap:</b> $${fmtMoney(spent)} of $${fmtMoney(cap)}`;
|
|
5773
|
+
const alertAction = over.length === 1
|
|
5774
|
+
? `<button type="button" class="home-link" onclick="editAgentBudget('${esc(over[0].slug)}', ${Number(over[0].cap)})">Change cap</button>` : '';
|
|
5775
|
+
|
|
5776
|
+
// List price: Claude Code tokens priced at the API's list price. Codex has none.
|
|
5777
|
+
let listCell, listNote = '';
|
|
5778
|
+
if (!usage) listCell = absent('unloaded', 'session statistics have not been read yet');
|
|
5779
|
+
else if (usage.state === 'computing') listCell = '<span class="muted">counting…</span>';
|
|
5780
|
+
else if (usage.error || usage.state !== 'counted') listCell = absent('unloaded', usage.why || usage.error || 'session statistics are unavailable');
|
|
5781
|
+
else if (!usage.seen?.claude) listCell = absent('none', 'no Claude Code session logs are attributed to this project');
|
|
5782
|
+
else if (usage.hosts?.claude?.usd == null) listCell = absent('uncomputable', 'no price is set for these models — add one to ~/.great_cto/model-prices.json');
|
|
5783
|
+
else {
|
|
5784
|
+
listCell = esc(fmtUsdEq(usage.hosts.claude.usd));
|
|
5785
|
+
if (usage.hosts.claude.unpricedResponses) listNote = ` ${usage.hosts.claude.unpricedResponses} responses from unpriced models are not in it.`;
|
|
5786
|
+
}
|
|
5787
|
+
|
|
5788
|
+
// One line of results. Each part keeps its three-way distinction: a zero is a
|
|
5789
|
+
// count, an absence names its kind. Omitted only when the metrics never loaded.
|
|
5790
|
+
const acc = metrics?.acceptance || {};
|
|
5441
5791
|
const doneKnown = !!(metrics && metrics.tasks);
|
|
5442
5792
|
const done = (metrics?.tasks?.done_in_window || metrics?.tasks?.done) ?? 0;
|
|
5443
|
-
const acc = metrics?.acceptance || {};
|
|
5444
5793
|
const avgMin = metrics?.avg_completion_min;
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
const facts = [
|
|
5448
|
-
{ label: 'Done', v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'), sub: 'in window' },
|
|
5794
|
+
const facts = metrics ? [
|
|
5795
|
+
`${doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section')} tasks done`,
|
|
5449
5796
|
// A dash here means "could not be computed", not "took no time".
|
|
5450
|
-
{
|
|
5451
|
-
{
|
|
5452
|
-
];
|
|
5453
|
-
|
|
5454
|
-
const s = fleetState?.summary || null;
|
|
5455
|
-
const fleetSpend = s == null ? absent('unloaded', 'the fleet summary has not been read')
|
|
5456
|
-
: s.llm_usd_30d != null ? `$${s.llm_usd_30d.toFixed(2)} <span class="muted">measured for ${s.llm_usd_30d_measured_for} agent(s)</span>`
|
|
5457
|
-
: absent('uncomputable', `no verdict carries a cost — ${s.llm_usd_30d_agents_with_runs ?? 0} agent(s) ran in this window`);
|
|
5458
|
-
facts.push({ label: 'Fleet 30d', v: fleetSpend, sub: '' });
|
|
5459
|
-
const capCell = cap ? `of $${fmtMoney(cap)}${pct != null ? ` · ${pct}%` : ''}` : `<span class="abs" title="no monthly cap in PROJECT.md — spend is not being stopped">no cap</span>`;
|
|
5460
|
-
const stages = Array.isArray(pipeline) ? pipeline : [];
|
|
5461
|
-
const running = stages.filter((st) => st.status === 'active');
|
|
5462
|
-
const dotFor = (st) => st.status === 'done' ? 'dot ok' : st.status === 'active' ? 'dot run' : st.status === 'failed' ? 'dot bad' : 'dot';
|
|
5463
|
-
const strip = stages.length
|
|
5464
|
-
? stages.map((st, i) => `${i ? '<span class="muted">→</span>' : ''}<span class="ledger-stage${st.status === 'idle' ? ' muted' : ''}" title="${esc(st.stage)} · ${esc(st.status)}${st.verdict ? ' · ' + esc(st.verdict) : ''}${st.ts ? ' · ' + esc(fmtDT(st.ts)) : ''}"><span class="${dotFor(st)}"${st.status === 'idle' ? ' style="background:var(--bg-strong)"' : ''}></span>${esc(st.stage)}</span>`).join('')
|
|
5465
|
-
: `<span class="abs" title="/api/pipeline returned nothing — stage state unknown, not idle">· pipeline state unloaded</span>`;
|
|
5466
|
-
const stuck = Array.isArray(heartbeat?.stuck) ? heartbeat.stuck : [];
|
|
5467
|
-
const unmeasurable = Number(heartbeat?.stuck_unmeasurable) || 0;
|
|
5468
|
-
const stuckRows = stuck.slice(0, 5).map((t) => `
|
|
5469
|
-
<div class="ledger-stuck-row">
|
|
5470
|
-
<span class="mono">${esc(t.id || '')}</span>
|
|
5471
|
-
<span class="ttl-desc">${esc(t.title || '')}</span>
|
|
5472
|
-
<span class="mono tab" style="color:var(--status-progress)" title="${t.since ? esc(fmtDate(t.since)) : 'no timestamp'}">${t.age_h != null ? Math.round(t.age_h) + 'h' : '<span class="abs" title="updated_at unreadable — counted, age unknown">?</span>'}</span>
|
|
5473
|
-
</div>`).join('');
|
|
5797
|
+
`median ${avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure')} min<span class="cost-long"> from start to done</span>`,
|
|
5798
|
+
`${acc.cost_per_accepted != null ? `$${Number(acc.cost_per_accepted).toFixed(2)}` : absent('none', 'nothing was accepted in this window')} per accepted task`,
|
|
5799
|
+
] : [];
|
|
5800
|
+
|
|
5474
5801
|
el.innerHTML = `
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
<
|
|
5479
|
-
<div class="
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
<
|
|
5486
|
-
|
|
5802
|
+
${alert ? `<div class="cost-alert" role="alert"><div>${alert}</div>${alertAction}</div>` : ''}
|
|
5803
|
+
<div class="cost-totals">
|
|
5804
|
+
<section class="cost-total">
|
|
5805
|
+
<h2>${spentState === 'estimated' ? 'Estimated spend' : 'Measured spend'}</h2>
|
|
5806
|
+
<div class="cost-total-num">${spentCell}</div>
|
|
5807
|
+
<p>${spentState === 'estimated'
|
|
5808
|
+
? 'No run recorded a cost, so this is estimated from run time. Caps fire only on measured spend.'
|
|
5809
|
+
: 'What agent runs recorded as spent. <span class="cost-long">This is the number the caps are checked against.</span><span class="cost-short">Caps are checked against it.</span>'}</p>
|
|
5810
|
+
</section>
|
|
5811
|
+
<section class="cost-total">
|
|
5812
|
+
<h2>At API list price</h2>
|
|
5813
|
+
<div class="cost-total-num">${listCell}</div>
|
|
5814
|
+
<p>Claude Code tokens<span class="cost-long"> in this project,</span> priced as if bought from the API. On a subscription this is not a charge.<span class="cost-long"> Codex has no list price.</span>${esc(listNote)}</p>
|
|
5815
|
+
<button type="button" class="home-link" onclick="switchTab('insights/consumption')">Token detail</button>
|
|
5816
|
+
</section>
|
|
5487
5817
|
</div>
|
|
5488
|
-
|
|
5489
|
-
${facts.map((f) => `<span><span class="eyebrow">${esc(f.label)}</span> <span class="mono">${f.v}</span>${f.sub ? ` <span class="muted cap">${esc(f.sub)}</span>` : ''}</span>`).join('')}
|
|
5490
|
-
</div>
|
|
5491
|
-
<div class="ledger-strip">${strip}</div>
|
|
5492
|
-
<div class="ledger-stuck">
|
|
5493
|
-
<div class="ledger-stuck-head">
|
|
5494
|
-
<span class="eyebrow">Stuck · in progress over the line</span>
|
|
5495
|
-
<span class="mono cap" style="color:var(--status-progress)">${stuck.length}${unmeasurable ? ` <span class="abs" title="${unmeasurable} in-progress task(s) with no readable age — counted, not dropped">+${unmeasurable}?</span>` : ''}</span>
|
|
5496
|
-
</div>
|
|
5497
|
-
${stuckRows || `<div class="muted cap">${Array.isArray(heartbeat?.stuck) ? 'nothing over the line' : '<span class="abs" title="heartbeat not read">· stuck state unloaded</span>'}</div>`}
|
|
5498
|
-
</div>`;
|
|
5818
|
+
${facts.length ? `<div class="cost-facts">${facts.join(' · ')}</div>` : ''}`;
|
|
5499
5819
|
}
|
|
5500
5820
|
|
|
5501
5821
|
// Settings (great_cto-ki1x.11). A row is a fact and the file that holds it;
|
|
@@ -5578,32 +5898,33 @@ async function registerProjectFromSettings() {
|
|
|
5578
5898
|
} catch (e) { showToast(`Not registered: ${String(e.message || e)}`, 'error', 3000); }
|
|
5579
5899
|
}
|
|
5580
5900
|
|
|
5901
|
+
let _costUsageTimer = null, _costRequest = 0;
|
|
5581
5902
|
async function loadBudgetsPage() {
|
|
5582
5903
|
loadJudgeStatus();
|
|
5583
5904
|
loadJudgeScores();
|
|
5584
5905
|
const body = document.getElementById('budgets-page-body');
|
|
5585
5906
|
if (!body) return;
|
|
5586
5907
|
body.innerHTML = '<div class="muted">loading…</div>';
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5908
|
+
if (![7, 30, 90].includes(currentPeriod)) {
|
|
5909
|
+
// The chips offer 7, 30 and 90 days; an older stored 1D or 1Y reads as 30.
|
|
5910
|
+
currentPeriod = 30;
|
|
5911
|
+
document.querySelectorAll('.period-chip').forEach((c) => c.classList.toggle('active', parseInt(c.dataset.period, 10) === 30));
|
|
5912
|
+
}
|
|
5913
|
+
document.querySelectorAll('.period-chip').forEach((c) => c.setAttribute('aria-pressed', String(c.classList.contains('active'))));
|
|
5914
|
+
const request = ++_costRequest;
|
|
5915
|
+
clearTimeout(_costUsageTimer);
|
|
5916
|
+
|
|
5917
|
+
// Three states, not two: loading | loaded | failed. A page that failed and a
|
|
5918
|
+
// page still working must not look the same.
|
|
5919
|
+
let h, cost = null, m = null;
|
|
5597
5920
|
try {
|
|
5598
|
-
[h,
|
|
5921
|
+
[h, cost, m] = await Promise.all([
|
|
5599
5922
|
api(`/api/heartbeat${pqs()}`),
|
|
5600
|
-
api(`/api/pipeline${pqs()}`).catch(() => null),
|
|
5601
5923
|
api(`/api/cost${pqsd()}`).catch(() => null),
|
|
5602
5924
|
api(`/api/metrics${pqsd()}`).catch(() => null),
|
|
5603
5925
|
(typeof refreshAgentsInstalled === 'function' && !(fleetState?.agents || []).length)
|
|
5604
5926
|
? refreshAgentsInstalled() : Promise.resolve(),
|
|
5605
5927
|
]);
|
|
5606
|
-
renderLedgerHead({ heartbeat: h, pipeline: pipe, cost, metrics: m });
|
|
5607
5928
|
} catch (err) {
|
|
5608
5929
|
body.innerHTML = `
|
|
5609
5930
|
<div class="budgets-failed">
|
|
@@ -5615,114 +5936,103 @@ async function loadBudgetsPage() {
|
|
|
5615
5936
|
</div>`;
|
|
5616
5937
|
return;
|
|
5617
5938
|
}
|
|
5939
|
+
if (request !== _costRequest) return;
|
|
5940
|
+
const over = costOverCap();
|
|
5941
|
+
renderLedgerHead({ cost, metrics: m, usage: null, over });
|
|
5942
|
+
// The list-price total comes from the session logs, which can take a while to
|
|
5943
|
+
// count; it fills in when ready and never holds the rest of the screen.
|
|
5944
|
+
const period = currentPeriod;
|
|
5945
|
+
const readUsage = async (tries) => {
|
|
5946
|
+
const u = await api(`/api/usage?days=${period}${pqs().replace('?', '&')}`).catch(() => null);
|
|
5947
|
+
if (request !== _costRequest || currentTab !== 'budgets') return;
|
|
5948
|
+
renderLedgerHead({ cost, metrics: m, usage: u || { error: 'the board is not reachable' }, over });
|
|
5949
|
+
if (u && (u.state === 'computing' || u.refreshing) && tries < 30) _costUsageTimer = setTimeout(() => readUsage(tries + 1), 2000);
|
|
5950
|
+
};
|
|
5951
|
+
readUsage(0);
|
|
5952
|
+
|
|
5618
5953
|
const caps = h?.budgets || {};
|
|
5619
5954
|
const agents = (fleetState?.agents || []).filter((a) => !a.retired);
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
// what period. Worse, 62 of the 69 had not run in thirty days — a cap on an
|
|
5624
|
-
// agent that never runs is a cap on nothing, and they were the bulk of the
|
|
5625
|
-
// wall.
|
|
5626
|
-
//
|
|
5627
|
-
// Ordered by what it costs to leave uncapped: agents that ran, most spend
|
|
5628
|
-
// first. The ones that never ran are still reachable, behind a count.
|
|
5629
|
-
const spendOf = (a) => (a.llm_usd_30d_real ?? a.llm_usd_30d_est ?? 0);
|
|
5630
|
-
const ran = agents.filter((a) => (a.runs_30d || 0) > 0)
|
|
5631
|
-
.sort((x, y) => spendOf(y) - spendOf(x) || (y.runs_30d || 0) - (x.runs_30d || 0));
|
|
5632
|
-
const idle = agents.filter((a) => !(a.runs_30d > 0));
|
|
5633
|
-
|
|
5955
|
+
const bySlug = new Map((fleetState?.agents || []).map((a) => [a.slug, a]));
|
|
5956
|
+
const ranUncapped = agents.filter((a) => (a.runs_30d || 0) > 0 && !(a.slug in caps));
|
|
5957
|
+
const idle = agents.filter((a) => !(a.runs_30d > 0) && !(a.slug in caps));
|
|
5634
5958
|
const firstSentence = (t) => {
|
|
5635
5959
|
const s1 = String(t || '').split(/(?<=[.!?])\s/)[0] || '';
|
|
5636
5960
|
return s1.length > 150 ? s1.slice(0, 147) + '…' : s1;
|
|
5637
5961
|
};
|
|
5638
5962
|
|
|
5639
|
-
//
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
</tr>`;
|
|
5674
|
-
|
|
5675
|
-
const table = (rows) => `
|
|
5676
|
-
<table class="budgets-table">
|
|
5677
|
-
<thead><tr>
|
|
5678
|
-
<th scope="col">Agent</th>
|
|
5679
|
-
<th scope="col" title="Dispatches in the last 30 days">Runs 30d</th>
|
|
5680
|
-
<th scope="col" title="LLM spend over the same 30 days">Spend 30d</th>
|
|
5681
|
-
<th scope="col">Cap</th><th scope="col">State</th><th scope="col"></th>
|
|
5682
|
-
</tr></thead>
|
|
5683
|
-
<tbody>${rows}</tbody>
|
|
5684
|
-
</table>`;
|
|
5685
|
-
|
|
5686
|
-
// A cap declared for an agent the fleet does not list still has to appear —
|
|
5687
|
-
// otherwise a limit that is holding a stage would be invisible here.
|
|
5688
|
-
const known = new Set(agents.map((a) => a.slug));
|
|
5689
|
-
const orphanCaps = Object.entries(caps).filter(([slug]) => !known.has(slug));
|
|
5690
|
-
|
|
5691
|
-
const capped = ran.filter((a) => a.slug in caps);
|
|
5692
|
-
const uncappedRan = ran.filter((a) => !(a.slug in caps));
|
|
5693
|
-
|
|
5963
|
+
// One table, one row per cap, then the agents that ran without one as one row.
|
|
5964
|
+
const row = ({ agent, spent, cap, state, over, line, action, title }) => `
|
|
5965
|
+
<div class="cost-row"${title ? ` title="${esc(title)}"` : ''}>
|
|
5966
|
+
<div class="cost-agent">${esc(agent)}<span class="cost-line${over ? ' cost-over' : ''}">${esc(line)}</span></div>
|
|
5967
|
+
<div class="cost-col num">${esc(spent)}</div>
|
|
5968
|
+
<div class="cost-col num">${esc(cap)}</div>
|
|
5969
|
+
<div class="cost-col${over ? ' cost-over' : ''}">${esc(state)}</div>
|
|
5970
|
+
<div class="act">${action || ''}</div>
|
|
5971
|
+
</div>`;
|
|
5972
|
+
const capRows = Object.entries(caps).map(([slug, capUsd]) => {
|
|
5973
|
+
const b = bySlug.get(slug)?.budget;
|
|
5974
|
+
const st = b?.state;
|
|
5975
|
+
const spent = st === 'within' || st === 'exceeded' ? `$${(b.measuredUsd ?? 0).toFixed(2)}`
|
|
5976
|
+
: st === 'unmeasured' ? 'nothing yet' : 'not loaded';
|
|
5977
|
+
const state = st === 'exceeded' ? 'Over cap' : (st === 'within' || st === 'unmeasured') ? 'Under cap' : '—';
|
|
5978
|
+
return row({
|
|
5979
|
+
agent: slug, spent, cap: `$${capUsd}`, state, over: st === 'exceeded',
|
|
5980
|
+
line: st === 'exceeded' ? `${spent} of $${capUsd} · over cap` : `${spent} of $${capUsd}`,
|
|
5981
|
+
action: `<button type="button" class="ab-btn" onclick="editAgentBudget('${esc(slug)}', ${Number(capUsd)})" aria-label="Change the ${esc(slug)} cap">Change</button>`,
|
|
5982
|
+
title: bySlug.has(slug) ? firstSentence(bySlug.get(slug).description) : 'not among the installed agents',
|
|
5983
|
+
});
|
|
5984
|
+
}).join('');
|
|
5985
|
+
let otherRow = '';
|
|
5986
|
+
if (ranUncapped.length) {
|
|
5987
|
+
const measured = ranUncapped.filter((a) => a.llm_usd_30d_real != null && a.llm_usd_30d_real > 0);
|
|
5988
|
+
const sum = measured.reduce((t, a) => t + a.llm_usd_30d_real, 0);
|
|
5989
|
+
const unmeasured = ranUncapped.length - measured.length;
|
|
5990
|
+
const spent = measured.length ? `$${sum.toFixed(2)}` : 'not measured';
|
|
5991
|
+
otherRow = row({
|
|
5992
|
+
agent: `other agents (${ranUncapped.length})`, spent: spent + (measured.length && unmeasured ? ` · ${unmeasured} not measured` : ''),
|
|
5993
|
+
cap: 'no cap', state: '—', line: `${spent} · no cap`,
|
|
5994
|
+
title: ranUncapped.map((a) => a.slug).join(', '),
|
|
5995
|
+
});
|
|
5996
|
+
}
|
|
5694
5997
|
const notes = [
|
|
5695
5998
|
h?.budgets_deprecated_key
|
|
5696
5999
|
? `<div class="ab-note">Read from the deprecated <code>${esc(h.budgets_deprecated_key)}:</code> key — rename it to <code>agent-budgets:</code>.</div>` : '',
|
|
5697
6000
|
(h?.budgets_malformed || []).length
|
|
5698
6001
|
? `<div class="ab-note ab-bad">${h.budgets_malformed.length} line(s) in PROJECT.md were not read: `
|
|
5699
|
-
+ h.budgets_malformed.map((
|
|
6002
|
+
+ h.budgets_malformed.map((x) => `<code>${esc(x.line)}</code> — ${esc(x.why)}`).join('; ') + '</div>' : '',
|
|
5700
6003
|
].join('');
|
|
5701
|
-
|
|
5702
|
-
const capsBlock = (capped.length || orphanCaps.length) ? `
|
|
5703
|
-
<div class="mp-section-label">Capped (${capped.length + orphanCaps.length})</div>
|
|
5704
|
-
${table(capped.map((a) => row(a, caps[a.slug])).join('')
|
|
5705
|
-
+ orphanCaps.map(([slug, cap]) => row({ slug, description: 'not among the installed agents', runs_30d: 0 }, cap)).join(''))}`
|
|
5706
|
-
: `<div class="budgets-empty">No agent has a spending cap in this project. Nothing is being held, and nothing will be.</div>`;
|
|
5707
|
-
|
|
5708
|
-
const activeBlock = uncappedRan.length ? `
|
|
5709
|
-
<div class="mp-section-label" style="margin-top:28px">Ran in the last 30 days, no cap (${uncappedRan.length})</div>
|
|
5710
|
-
<p class="muted" style="margin:4px 0 10px">These are the ones a cap would actually apply to.</p>
|
|
5711
|
-
${table(uncappedRan.map((a) => row(a, null)).join(''))}` : '';
|
|
5712
|
-
|
|
5713
|
-
// Capping an agent that has not run is capping nothing. Reachable, not shown.
|
|
5714
6004
|
const idleBlock = idle.length ? `
|
|
5715
|
-
<details class="
|
|
5716
|
-
<summary>${idle.length} installed agents have not run in 30 days</summary>
|
|
6005
|
+
<details class="cost-idle">
|
|
6006
|
+
<summary>${idle.length} installed agents have not run<span class="cost-long"> in 30 days</span></summary>
|
|
5717
6007
|
<div class="budgets-uncapped">${idle.slice(0, 80).map((a) => `
|
|
5718
6008
|
<button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')"
|
|
5719
6009
|
title="${esc(firstSentence(a.description))}">${esc(a.slug)} +</button>`).join('')}
|
|
5720
6010
|
${idle.length > 80 ? `<span class="muted">and ${idle.length - 80} more</span>` : ''}
|
|
5721
6011
|
</div>
|
|
5722
6012
|
</details>` : '';
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
6013
|
+
const empty = !capRows && !otherRow
|
|
6014
|
+
? '<div class="cost-row"><div class="muted" style="grid-column:1/-1">No agent has a cap and none ran in 30 days.</div></div>' : '';
|
|
6015
|
+
|
|
6016
|
+
body.innerHTML = `${notes}
|
|
6017
|
+
<section class="cost-agents" aria-labelledby="cost-agents-title">
|
|
6018
|
+
<div class="cost-agents-head">
|
|
6019
|
+
<h2 id="cost-agents-title">By agent</h2>
|
|
6020
|
+
<button type="button" class="ab-btn" onclick="addAgentBudget()">Add a cap</button>
|
|
6021
|
+
</div>
|
|
6022
|
+
<div class="cost-row cost-row-head" aria-hidden="true">
|
|
6023
|
+
<div>Agent</div><div class="num" title="Last 30 days, from verdicts">Spent</div><div class="num">Cap</div><div>State</div><div></div>
|
|
6024
|
+
</div>
|
|
6025
|
+
${capRows}${otherRow}${empty}${idleBlock}
|
|
6026
|
+
</section>
|
|
6027
|
+
<details class="cost-about">
|
|
6028
|
+
<summary>About this data</summary>
|
|
6029
|
+
<ul>
|
|
6030
|
+
<li>Measured spend counts only runs whose verdict carried a cost. When none did, the figure is estimated from run time and labelled so.</li>
|
|
6031
|
+
<li>The list-price total prices every Claude Code token in this project's session logs at the API's list price. It is not what a subscription is charged.</li>
|
|
6032
|
+
<li>Spent by agent covers the last 30 days whatever the period above. Caps live in <code>.great_cto/PROJECT.md</code> under <code>agent-budgets:</code>.</li>
|
|
6033
|
+
<li>Monthly project cap: ${cost?.monthly_budget ? `$${esc(fmtMoney(cost.monthly_budget))}` : 'none set — spend is not being stopped'}.</li>
|
|
6034
|
+
</ul>
|
|
6035
|
+
</details>`;
|
|
5726
6036
|
|
|
5727
6037
|
const badge = document.getElementById('nav-budget-count');
|
|
5728
6038
|
if (badge) badge.textContent = Object.keys(caps).length ? String(Object.keys(caps).length) : '';
|
|
@@ -5761,9 +6071,12 @@ function budgetPrompt(agent, current) {
|
|
|
5761
6071
|
const raw = prompt(
|
|
5762
6072
|
`Spending cap for ${agent}, in dollars.\n\n`
|
|
5763
6073
|
+ `Measured spend past this figure stops the pipeline dispatching ${agent}.\n`
|
|
5764
|
-
+ `Spend is measured from verdicts; while none carries a cost the cap cannot fire
|
|
6074
|
+
+ `Spend is measured from verdicts; while none carries a cost the cap cannot fire.`
|
|
6075
|
+
+ (current != null ? `\n\nLeave it empty to remove the cap.` : ''),
|
|
5765
6076
|
current != null ? String(current) : '');
|
|
5766
6077
|
if (raw === null) return null;
|
|
6078
|
+
// Cost shows one Change button per cap; an empty answer is how a cap is removed.
|
|
6079
|
+
if (current != null && String(raw).trim() === '') return 'remove';
|
|
5767
6080
|
const usd = Number(String(raw).replace(/[$\s,]/g, ''));
|
|
5768
6081
|
if (!Number.isFinite(usd) || usd <= 0) { alert(`Not a positive dollar amount: ${raw}`); return null; }
|
|
5769
6082
|
return usd;
|
|
@@ -5772,6 +6085,7 @@ function budgetPrompt(agent, current) {
|
|
|
5772
6085
|
function editAgentBudget(agent, current) {
|
|
5773
6086
|
const usd = budgetPrompt(agent, current);
|
|
5774
6087
|
if (usd == null) return;
|
|
6088
|
+
if (usd === 'remove') { clearAgentBudget(agent, current); return; }
|
|
5775
6089
|
writeAgentBudget({ agent, limit_usd: usd },
|
|
5776
6090
|
`Set ${agent} to $${usd}, replacing $${current}?\n\n`
|
|
5777
6091
|
+ `Written to .great_cto/PROJECT.md. Measured spend past $${usd} stops the pipeline dispatching ${agent}.`);
|
|
@@ -5871,6 +6185,16 @@ function renderResume(d) {
|
|
|
5871
6185
|
// still want to know where you left off. The claim about it was wrong, so the
|
|
5872
6186
|
// claim is what changes. It now states the age of the newest thing shown, and
|
|
5873
6187
|
// says "last 24h" only when that is true.
|
|
6188
|
+
// The closed "activity log" line names the newest verdict, so the reader opens
|
|
6189
|
+
// it only when that is worth chasing.
|
|
6190
|
+
const latest = document.getElementById('home-latest');
|
|
6191
|
+
if (latest) {
|
|
6192
|
+
const v = verdicts.filter((x) => x && x.ts && !Number.isNaN(Date.parse(x.ts)))
|
|
6193
|
+
.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts))[0];
|
|
6194
|
+
latest.textContent = v
|
|
6195
|
+
? `Latest: ${v.agent || 'agent'} ${String(v.verdict || 'reported').toLowerCase()} · ${relTime(v.ts)}`
|
|
6196
|
+
: 'No verdict recorded yet';
|
|
6197
|
+
}
|
|
5874
6198
|
const meta = document.getElementById('resume-meta');
|
|
5875
6199
|
if (meta) {
|
|
5876
6200
|
const stamps = [...verdicts.map((v) => v.ts), ...wip.map((t) => t.updated_at)]
|
|
@@ -6093,7 +6417,7 @@ function renderTurns(snap) {
|
|
|
6093
6417
|
// The session goes in as a JSON string, escaped for the attribute: esc() turns '
|
|
6094
6418
|
// into ', which the browser decodes before the handler runs, so a quoted
|
|
6095
6419
|
// string built from esc() alone could be broken out of.
|
|
6096
|
-
buttons.push(`<button type="button" class="tp-turn" onclick="showTurnDiff(${esc(JSON.stringify(String(s.session)))}, ${n})">${n}</button>`);
|
|
6420
|
+
buttons.push(`<button type="button" class="tp-turn" aria-label="Show what turn ${n} of session ${esc(String(s.session).slice(0, 8))} changed" onclick="showTurnDiff(${esc(JSON.stringify(String(s.session)))}, ${n})">${n}</button>`);
|
|
6097
6421
|
}
|
|
6098
6422
|
return `<div class="tp-session"><span class="aa-time">${esc(when(s.newestAt))}</span> <code>${esc(String(s.session).slice(0, 8))}</code> · ${esc(String(s.turns))} turn(s)`
|
|
6099
6423
|
+ `<div class="tp-turns">${buttons.join('')}</div></div>`;
|
|
@@ -7079,6 +7403,7 @@ function setPeriod(days) {
|
|
|
7079
7403
|
// Highlight active chip
|
|
7080
7404
|
document.querySelectorAll('.period-chip').forEach(c => {
|
|
7081
7405
|
c.classList.toggle('active', parseInt(c.dataset.period, 10) === days);
|
|
7406
|
+
c.setAttribute('aria-pressed', String(parseInt(c.dataset.period, 10) === days));
|
|
7082
7407
|
});
|
|
7083
7408
|
// Update BOTH labels immediately on click — so user never sees a mismatch
|
|
7084
7409
|
// between the active chip and the section headers, even before fetch returns
|
|
@@ -9182,19 +9507,23 @@ async function checkBoardFreshness() {
|
|
|
9182
9507
|
// `unknown` are both silent, because a comparison we could not make must not
|
|
9183
9508
|
// render as a mismatch.
|
|
9184
9509
|
if (!v || (v.stale !== 'behind' && v.stale !== 'ahead')) return;
|
|
9185
|
-
|
|
9510
|
+
// One line in the sidebar footer, not a banner over every screen (F16). The
|
|
9511
|
+
// full sentence — which build runs, which is installed, what a restart would
|
|
9512
|
+
// do — is the tooltip and the accessible name, so nothing it said is lost.
|
|
9513
|
+
const host = document.getElementById('stale-board-slot');
|
|
9186
9514
|
if (!host || document.getElementById('stale-board')) return;
|
|
9187
|
-
const el = document.createElement('
|
|
9515
|
+
const el = document.createElement('span');
|
|
9188
9516
|
el.id = 'stale-board';
|
|
9189
9517
|
el.className = 'stale-board';
|
|
9190
9518
|
el.setAttribute('role', 'status');
|
|
9191
|
-
el.
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
:
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9519
|
+
el.tabIndex = 0;
|
|
9520
|
+
const full = v.stale === 'behind'
|
|
9521
|
+
? `This board is running v${v.version} — user-installed plugin v${v.installed} is newer. Restart from that installation to use it.`
|
|
9522
|
+
: `This board is running v${v.version}, which is newer than the user-installed plugin v${v.installed}. Restarting from that installation would use the older build — install this version first.`;
|
|
9523
|
+
el.title = full;
|
|
9524
|
+
el.setAttribute('aria-label', full);
|
|
9525
|
+
el.textContent = v.stale === 'behind' ? `· update ready: v${v.installed}` : `· newer than installed v${v.installed}`;
|
|
9526
|
+
host.appendChild(el);
|
|
9198
9527
|
}
|
|
9199
9528
|
|
|
9200
9529
|
/* ── Command palette ───────────────────────────────────────────────────────
|
|
@@ -9538,10 +9867,10 @@ async function replayBoardRoute() {
|
|
|
9538
9867
|
}
|
|
9539
9868
|
if (project && typeof currentProject !== 'undefined' && currentProject && project !== currentProject) {
|
|
9540
9869
|
const loading = switchProject(project);
|
|
9541
|
-
history.replaceState(history.state, '', expectedHash || '#/
|
|
9870
|
+
history.replaceState(history.state, '', expectedHash || '#/home');
|
|
9542
9871
|
await loading;
|
|
9543
9872
|
if (ticket !== routeReplay || project !== new URLSearchParams(location.search).get('project')) return;
|
|
9544
|
-
history.replaceState(history.state, '', expectedHash || '#/
|
|
9873
|
+
history.replaceState(history.state, '', expectedHash || '#/home');
|
|
9545
9874
|
}
|
|
9546
9875
|
if (ticket !== routeReplay) return;
|
|
9547
9876
|
const r = routeFromHash(); if (r) switchTab(r);
|