great-cto 3.76.1 → 3.78.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 +3 -2
- package/board/agents/devops.md +19 -0
- package/board/agents/e2e-test-engineer.md +20 -1
- package/board/agents/release-manager.md +213 -0
- package/board/agents/security-officer.md +53 -14
- package/board/packages/board/lib/config.mjs +27 -6
- package/board/packages/board/lib/installed-version.mjs +52 -0
- package/board/packages/board/lib/routes.mjs +5 -12
- 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/assets/agent-groups.js +1 -1
- package/board/packages/board/public/index.html +690 -364
- package/board/packages/board/server.mjs +29 -8
- package/board/scripts/codex-pipeline.mjs +57 -12
- package/board/scripts/lib/approval-level.mjs +9 -2
- package/board/scripts/lib/codex-pipeline.mjs +114 -29
- package/board/scripts/lib/codex-role-profiles.mjs +1 -0
- package/board/scripts/lib/exceptions.mjs +167 -0
- package/board/scripts/lib/gate-check-bound.mjs +478 -0
- package/board/scripts/lib/gate-check.mjs +173 -0
- package/board/scripts/lib/gate-reversibility.mjs +4 -0
- package/board/scripts/lib/gate-tier.mjs +1 -1
- package/board/scripts/lib/ship-evidence-context.mjs +174 -0
- package/board/scripts/lib/ship-evidence-identity.mjs +294 -0
- package/board/scripts/lib/work-tasks.mjs +16 -2
- package/board/shared/pipeline.toml +41 -0
- 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,135 @@ 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
|
+
.cost-idle { border-top: 1px solid var(--border); padding: 10px 16px; font-size: var(--fs-body); color: var(--text2); }
|
|
1833
|
+
.cost-idle summary { cursor: pointer; min-height: 24px; }
|
|
1834
|
+
.cost-idle .budgets-uncapped { margin-top: 10px; }
|
|
1835
|
+
.cost-about { margin-top: 16px; font-size: var(--fs-small); color: var(--text2); }
|
|
1836
|
+
.cost-about summary { cursor: pointer; color: var(--accent-text); min-height: 24px; }
|
|
1837
|
+
.cost-about ul { margin: 8px 0 0; padding-left: 18px; line-height: 1.6; }
|
|
1838
|
+
@media (max-width: 767px) {
|
|
1839
|
+
.cost-page .ledger-title-row { flex-direction: column; align-items: stretch; }
|
|
1840
|
+
.cost-page .period-chips { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
1841
|
+
.cost-page .period-chip { min-height: 44px; }
|
|
1842
|
+
.cost-row { grid-template-columns: minmax(0, 1fr) auto; }
|
|
1843
|
+
.cost-row-head, .cost-row > .cost-col { display: none; }
|
|
1844
|
+
.cost-line { display: block; font-family: var(--sans); font-size: var(--fs-small); color: var(--text2); }
|
|
1845
|
+
.cost-line.cost-over { color: var(--status-progress); }
|
|
1846
|
+
.cost-row .ab-btn, .cost-agents-head .ab-btn { min-height: 44px; }
|
|
1847
|
+
}
|
|
1848
|
+
@media (max-width: 767px) {
|
|
1849
|
+
.home-tiles { grid-template-columns: 1fr; gap: 0; }
|
|
1850
|
+
.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; }
|
|
1851
|
+
.home-tile:last-child { border-bottom-width: 1px; }
|
|
1852
|
+
.home-tile h2 { flex: 0 0 100%; }
|
|
1853
|
+
.home-tile .home-link { margin-left: auto; min-height: 44px; }
|
|
1854
|
+
.home-row { grid-template-columns: 1fr; gap: 6px; }
|
|
1855
|
+
.home-row .actions { justify-self: start; width: auto; display: inline-flex; }
|
|
1856
|
+
.home-row .actions .gate-btn { flex: 0 0 auto; }
|
|
1857
|
+
.home-row .actions .gate-btn, .home-open { height: 44px; padding: 0 16px; }
|
|
1858
|
+
.home-more { min-height: 44px; }
|
|
1741
1859
|
}
|
|
1742
1860
|
|
|
1743
1861
|
.inbox-allclear {
|
|
@@ -3310,8 +3428,11 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3310
3428
|
.topbar .crumbs > span { overflow: hidden; text-overflow: ellipsis; }
|
|
3311
3429
|
.topbar .crumbs .proj-icon,
|
|
3312
3430
|
.topbar .crumbs .sep,
|
|
3313
|
-
.topbar .crumbs
|
|
3314
|
-
|
|
3431
|
+
.topbar .crumbs .here { display: none; }
|
|
3432
|
+
/* One button is left in the bar, so its name fits and stays on screen. */
|
|
3433
|
+
.topbar .btn-new .btn-label { display: inline; }
|
|
3434
|
+
/* The phone bar names the project; the screen names itself in its heading. */
|
|
3435
|
+
.topbar .crumbs #crumb-project { color: var(--text); font-weight: 600; }
|
|
3315
3436
|
.topbar-actions { flex-shrink: 0; }
|
|
3316
3437
|
/* The search box is the one topbar item that can give up its width; ⌘K still
|
|
3317
3438
|
reaches it, and on a phone there is no ⌘K but there is the Search tab. */
|
|
@@ -3483,7 +3604,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3483
3604
|
</svg>
|
|
3484
3605
|
</span>
|
|
3485
3606
|
<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
3607
|
</div>
|
|
3488
3608
|
<div class="proj-switch" id="proj-switch" onclick="toggleProjMenu(event)">
|
|
3489
3609
|
<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 +3691,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3571
3691
|
the footer asserted a sync that had not happened. -->
|
|
3572
3692
|
<span class="live-dot connecting" id="live-dot"></span>
|
|
3573
3693
|
<span id="live-label">connecting…</span>
|
|
3694
|
+
<!-- Version and the "newer build installed" notice live here, one line
|
|
3695
|
+
(F16). The notice used to be a full-width banner above every screen. -->
|
|
3696
|
+
<span class="footer-version"><span id="build-version" title="great_cto build version">—</span> <span id="stale-board-slot"></span></span>
|
|
3574
3697
|
</div>
|
|
3575
3698
|
</aside>
|
|
3576
3699
|
|
|
@@ -3595,7 +3718,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3595
3718
|
<kbd>⌘K</kbd>
|
|
3596
3719
|
</div>
|
|
3597
3720
|
<div class="topbar-actions">
|
|
3598
|
-
|
|
3721
|
+
<!-- The issue counter is still written by three loaders, so the node stays;
|
|
3722
|
+
it is not shown. A total of every issue ever filed answers none of the
|
|
3723
|
+
questions a screen is opened for (UX audit 2026-11, F12). -->
|
|
3724
|
+
<span class="task-count" id="topbar-task-count" hidden>0 ISSUES</span>
|
|
3599
3725
|
<!-- Share (topbar button, data-tab="share") and the notification bell
|
|
3600
3726
|
(#topbar-bell, opened the drawer) are deleted as top-level surfaces
|
|
3601
3727
|
here — great_cto-ki1x.5. Their panels/functions are untouched for
|
|
@@ -3604,14 +3730,12 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3604
3730
|
<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
3731
|
<span class="btn-label">Install app</span>
|
|
3606
3732
|
</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>
|
|
3733
|
+
<!-- Theme and Settings left the top bar (F12): the theme switch is a row in
|
|
3734
|
+
Settings, and Settings is a destination in the sidebar. One primary
|
|
3735
|
+
action stays. "New issue" is still in the command palette. -->
|
|
3736
|
+
<button class="btn-new" onclick="openNewTask()" aria-label="New task" title="New task: prepare a launch command">
|
|
3737
|
+
<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>
|
|
3738
|
+
<span class="btn-label">New task</span>
|
|
3615
3739
|
</button>
|
|
3616
3740
|
</div>
|
|
3617
3741
|
</div>
|
|
@@ -3638,8 +3762,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3638
3762
|
<!-- Fidelity pass (v3.27.1): the design's header is the screen's name and
|
|
3639
3763
|
its count; the sentence the board used to shout in 52px display
|
|
3640
3764
|
type is still said, one line, muted, because it carries the stall. -->
|
|
3641
|
-
|
|
3642
|
-
|
|
3765
|
+
<!-- Home (UX audit 2026-11, approved mockup): one count of what needs the
|
|
3766
|
+
operator, said once. The sidebar badge shows the same number. -->
|
|
3767
|
+
<div class="panel-head home-head"><h1 class="page-title" id="home-headline" style="margin:0">Home</h1></div>
|
|
3768
|
+
<div class="inbox-greet" id="inbox-greet"></div>
|
|
3643
3769
|
<!-- Top rung of the evidence ladder: are the bytes that were reviewed
|
|
3644
3770
|
still the bytes in the tree? Hidden while they match — and now only
|
|
3645
3771
|
used for `differs`, the one reading that needs a decision. The other
|
|
@@ -3649,7 +3775,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3649
3775
|
<!-- Sessions stopped on the operator: a permission prompt, or idle waiting for
|
|
3650
3776
|
an answer (Notification hook → .great_cto/status/). The one piece of
|
|
3651
3777
|
status that IS a decision, so it sits above the status disclosure. -->
|
|
3652
|
-
|
|
3778
|
+
|
|
3653
3779
|
<!-- Everything below is STATUS, and status is not a decision.
|
|
3654
3780
|
|
|
3655
3781
|
Six of the seven blocks on this screen were read-only: a pipeline rail,
|
|
@@ -3661,8 +3787,92 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3661
3787
|
|
|
3662
3788
|
Not deleted. "How is the project" is a real question; it is simply not
|
|
3663
3789
|
the question this screen is named after. -->
|
|
3790
|
+
|
|
3791
|
+
|
|
3792
|
+
<!-- Project archetype / compliance / phase used to be a full-width mono
|
|
3793
|
+
row here, between the KPI tiles and the pipeline. On a screen whose
|
|
3794
|
+
title is "what needs your decision" it answered no question the page
|
|
3795
|
+
asks: it is configuration, it does not change day to day, and it was
|
|
3796
|
+
one of three stacked strips competing with the two that ARE
|
|
3797
|
+
actionable. Moved onto the sidebar project block, where the archetype
|
|
3798
|
+
was already shown — same information, one place, and the stack of
|
|
3799
|
+
strips on this page goes from three to two.
|
|
3800
|
+
Not deleted: `PROJECT.md` is not in the Docs list, so compliance and
|
|
3801
|
+
phase would have become unreachable. -->
|
|
3802
|
+
<!-- Tool failure rate watchdog warning -->
|
|
3803
|
+
<!-- One queue, not four sections.
|
|
3804
|
+
|
|
3805
|
+
Gates, P0s, blocked and stale were four headed lists, each with its own
|
|
3806
|
+
count, on a screen that shows five to seven rows in total. The headings
|
|
3807
|
+
cost more vertical space than the rows and forced the reader to work out
|
|
3808
|
+
which bucket a thing was in before reading what it was.
|
|
3809
|
+
|
|
3810
|
+
Linear and GitHub both do the opposite: one ranked stream, and the reason
|
|
3811
|
+
an item is there rides ON the row as a chip. The reason is a property of
|
|
3812
|
+
the item, so it survives the item moving between buckets — which is what
|
|
3813
|
+
made an expired gate unrecognisable when it fell out of the gate list.
|
|
3814
|
+
|
|
3815
|
+
Order: gates first (a signature is owed), then oldest first, because the
|
|
3816
|
+
longest-ignored row is the one a triage list is read for. -->
|
|
3817
|
+
<div class="inbox-section home-queue" id="inbox-queue-section">
|
|
3818
|
+
<div class="inbox-section-head">
|
|
3819
|
+
<h3>Needs you</h3>
|
|
3820
|
+
<span class="home-queue-order">· oldest first</span>
|
|
3821
|
+
<span class="inbox-count" id="inbox-queue-count">0</span>
|
|
3822
|
+
</div>
|
|
3823
|
+
<div id="inbox-sessions-rows"></div>
|
|
3824
|
+
<div id="inbox-queue"></div>
|
|
3825
|
+
<button type="button" class="home-more" id="home-more" hidden onclick="toggleHomeQueue()"></button>
|
|
3826
|
+
</div>
|
|
3827
|
+
<!-- Gates that did NOT wait for you. Placed directly under the decisions
|
|
3828
|
+
you still owe, because "what proceeded without me" is the question a
|
|
3829
|
+
tiered gate creates and nothing on this board answered. -->
|
|
3830
|
+
<div class="inbox-section" id="inbox-standdown-section" style="display:none">
|
|
3831
|
+
<div class="inbox-section-head">
|
|
3832
|
+
<span class="dot dot-amber"></span>
|
|
3833
|
+
<h3>Proceeded without you</h3>
|
|
3834
|
+
<span class="inbox-count" id="inbox-standdown-count">0</span>
|
|
3835
|
+
</div>
|
|
3836
|
+
<div id="inbox-standdown"></div>
|
|
3837
|
+
</div>
|
|
3838
|
+
<!-- Idle focus: when nothing needs a decision, the four sections above collapse and this shows instead. -->
|
|
3839
|
+
<div class="inbox-allclear" id="inbox-allclear" style="display:none">
|
|
3840
|
+
<div class="ac-mark">✓</div>
|
|
3841
|
+
<div class="ac-text"><b>All clear.</b> Nothing needs your decision — back to deep work.</div>
|
|
3842
|
+
<div class="ac-actions">
|
|
3843
|
+
<button class="ac-btn ac-primary" onclick="openNewIssueModal()">+ New issue</button>
|
|
3844
|
+
<button class="ac-btn" onclick="switchTab('kanban', document.querySelector('.nav-item[data-tab="kanban"]'))">View tasks</button>
|
|
3845
|
+
</div>
|
|
3846
|
+
</div>
|
|
3847
|
+
|
|
3848
|
+
<!-- Three answers that used to take two clicks each: are agents working,
|
|
3849
|
+
what did it cost, what shipped. A tile is a number and a link to the
|
|
3850
|
+
screen that owns it; nothing here is a second copy of that screen. -->
|
|
3851
|
+
<div class="home-tiles" id="home-tiles">
|
|
3852
|
+
<section class="home-tile" aria-labelledby="home-tile-agents-h">
|
|
3853
|
+
<h2 class="eyebrow" id="home-tile-agents-h">Agents</h2>
|
|
3854
|
+
<div class="home-tile-num" id="home-agents-num">—</div>
|
|
3855
|
+
<div class="home-tile-sub" id="home-agents-sub"></div>
|
|
3856
|
+
<button type="button" class="home-link" id="home-stale-toggle" aria-expanded="false" aria-controls="home-stale" hidden onclick="toggleHomeStale()">Review stale tasks</button>
|
|
3857
|
+
</section>
|
|
3858
|
+
<section class="home-tile" aria-labelledby="home-tile-cost-h">
|
|
3859
|
+
<h2 class="eyebrow" id="home-tile-cost-h">Cost · 30 days</h2>
|
|
3860
|
+
<div class="home-tile-num" id="home-cost-num">—</div>
|
|
3861
|
+
<div class="home-tile-sub" id="home-cost-sub"></div>
|
|
3862
|
+
<button type="button" class="home-link" onclick="switchTab('insights/ledger')">Open cost</button>
|
|
3863
|
+
</section>
|
|
3864
|
+
<section class="home-tile" aria-labelledby="home-tile-shipped-h">
|
|
3865
|
+
<h2 class="eyebrow" id="home-tile-shipped-h">Shipped · 30 days</h2>
|
|
3866
|
+
<div class="home-tile-num" id="home-shipped-num">—</div>
|
|
3867
|
+
<div class="home-tile-sub" id="home-shipped-sub"></div>
|
|
3868
|
+
<button type="button" class="home-link" onclick="switchTab('work/completed')">See completed work</button>
|
|
3869
|
+
</section>
|
|
3870
|
+
</div>
|
|
3871
|
+
<div id="home-stale" hidden>
|
|
3872
|
+
<div id="home-stale-list"></div>
|
|
3873
|
+
</div>
|
|
3664
3874
|
<details class="proj-status" id="proj-status">
|
|
3665
|
-
<summary class="proj-status-summary"
|
|
3875
|
+
<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
3876
|
<div class="proj-status-body">
|
|
3667
3877
|
<!-- Resume — pick up where you left off -->
|
|
3668
3878
|
<div class="resume-card" id="resume-card" style="display:none">
|
|
@@ -3717,61 +3927,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3717
3927
|
</div>
|
|
3718
3928
|
</details>
|
|
3719
3929
|
|
|
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
3930
|
<!-- Evidence-ladder footer: the receipt readings that report a condition
|
|
3776
3931
|
but do not ask for a decision ("could not check", "files added").
|
|
3777
3932
|
Below the decisions, because that is what they rank against them —
|
|
@@ -3950,47 +4105,24 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3950
4105
|
</div>
|
|
3951
4106
|
|
|
3952
4107
|
<div class="panel" id="panel-budgets" role="tabpanel">
|
|
3953
|
-
|
|
4108
|
+
<!-- Cost (UX audit 2026-11, F4; approved Cost mockup, artboards CostDesktop and
|
|
4109
|
+
CostPhone). One screen instead of three tabs: the over-cap alert, the two
|
|
4110
|
+
totals side by side, one line of results, one table by agent with a Cap
|
|
4111
|
+
column. Running now, the stage strip and the stuck list live on Home and
|
|
4112
|
+
Work. Token detail is the old Consumption screen. -->
|
|
4113
|
+
<div class="metrics-page cost-page">
|
|
3954
4114
|
<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>
|
|
4115
|
+
<div class="cost-heading">
|
|
4116
|
+
<h1 class="page-title" style="margin:0" id="cost-title">Cost · last 30 days</h1>
|
|
4117
|
+
<div class="cost-sub" id="cost-sub"></div>
|
|
4118
|
+
</div>
|
|
4119
|
+
<div class="period-chips" id="period-chips" role="group" aria-label="Period">
|
|
4120
|
+
<button type="button" class="period-chip" data-period="7">7 days</button>
|
|
4121
|
+
<button type="button" class="period-chip active" data-period="30">30 days</button>
|
|
4122
|
+
<button type="button" class="period-chip" data-period="90">90 days</button>
|
|
3967
4123
|
</div>
|
|
3968
4124
|
</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
4125
|
<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
4126
|
<div id="budgets-page-body">loading…</div>
|
|
3995
4127
|
</div>
|
|
3996
4128
|
</div>
|
|
@@ -4333,12 +4465,12 @@ function gateChip(t) {
|
|
|
4333
4465
|
}
|
|
4334
4466
|
function renderInbox(d) {
|
|
4335
4467
|
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
4468
|
for (const g of (d?.pending_gates || [])) if (g?.id && g.reversibility) GATE_REVERSIBILITY[g.id] = g.reversibility;
|
|
4338
4469
|
if (d && Array.isArray(d.pending_gates)) d = { ...d, pending_gates: sortGatesByCostOfUndo(d.pending_gates) };
|
|
4339
4470
|
// Kept so renderResume can ask what this section is already showing, rather
|
|
4340
4471
|
// than the two of them rendering the same task independently.
|
|
4341
4472
|
window.__inboxData = d;
|
|
4473
|
+
window.__homeReadAt = Date.now();
|
|
4342
4474
|
inboxData = d || {};
|
|
4343
4475
|
const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
|
|
4344
4476
|
// `needs_you` counts distinct objects. Summing the three categories double-counted
|
|
@@ -4350,15 +4482,19 @@ function renderInbox(d) {
|
|
|
4350
4482
|
// open P0s one project over is the same lie as the headline was. When
|
|
4351
4483
|
// `elsewhere` is unknown the badge shows what it knows and says so in its
|
|
4352
4484
|
// title rather than adding a zero it never measured.
|
|
4353
|
-
|
|
4485
|
+
// One number, one definition (UX audit 2026-11, F1). The badge used to add the
|
|
4486
|
+
// P0s and gates of the person's OTHER projects, so it read 51 on a project whose
|
|
4487
|
+
// own headline said "nothing waiting", and 43 on an empty one. It now counts
|
|
4488
|
+
// exactly what the Home headline counts; other projects stay in the tooltip and
|
|
4489
|
+
// in the sentence under the headline, where they are named as other projects.
|
|
4354
4490
|
const ew = d?.elsewhere;
|
|
4355
4491
|
const ewKnown = ew && ew.state !== 'unreadable';
|
|
4356
4492
|
const away = ewKnown ? (Number(ew.p0) || 0) + (Number(ew.gates) || 0) : 0;
|
|
4493
|
+
renderHomeHead();
|
|
4357
4494
|
const badge = document.getElementById('nav-inbox-count');
|
|
4358
|
-
badge.
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
: `${here} here · other projects not read`;
|
|
4495
|
+
if (badge) badge.title = ewKnown
|
|
4496
|
+
? `${homeNeedsYou().total} here · ${away} in other projects`
|
|
4497
|
+
: `${homeNeedsYou().total} here · other projects not read`;
|
|
4362
4498
|
// Same rule as the all-clear card: "nothing urgent" is a claim about data we
|
|
4363
4499
|
// may not have. When a read failed, say that instead of reassuring the user.
|
|
4364
4500
|
// The headline states the SITUATION, not the time of day.
|
|
@@ -4382,32 +4518,9 @@ function renderInbox(d) {
|
|
|
4382
4518
|
return (Date.now() - Math.max(...stamps)) / 3600e3;
|
|
4383
4519
|
})();
|
|
4384
4520
|
|
|
4385
|
-
const needs = s.gates + s.p0;
|
|
4386
4521
|
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
|
-
}
|
|
4522
|
+
window.__homeAgeH = newestEventAgeH;
|
|
4523
|
+
renderHomeHead();
|
|
4411
4524
|
// The stat-tile row is gone: two of its four boxes read 0 on an ordinary
|
|
4412
4525
|
// morning, and the two that did not repeated a count the section headings
|
|
4413
4526
|
// below already carry. The counts still reach the nav badge.
|
|
@@ -4463,6 +4576,7 @@ function renderInbox(d) {
|
|
|
4463
4576
|
}
|
|
4464
4577
|
renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
|
|
4465
4578
|
'Some project data could not be read — counts below are incomplete.');
|
|
4579
|
+
refreshHomeTiles();
|
|
4466
4580
|
}
|
|
4467
4581
|
|
|
4468
4582
|
/**
|
|
@@ -4545,11 +4659,15 @@ function renderQueue(d) {
|
|
|
4545
4659
|
}),
|
|
4546
4660
|
...withReason(d?.p0_open, (t) => (t.is_gate ? 'expired gate' : 'P0')),
|
|
4547
4661
|
...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
4662
|
];
|
|
4663
|
+
// Stale in-progress work is not a decision. It is one number on the Agents
|
|
4664
|
+
// tile, and the rows open from there (F3) — in the queue they outnumbered the
|
|
4665
|
+
// items that do need the operator and pushed them off the first screen.
|
|
4666
|
+
const stale = withReason(d?.stale_in_progress, (t) => {
|
|
4667
|
+
const dd = ageD(t);
|
|
4668
|
+
return dd == null ? 'stale' : `stale ${dd}d`;
|
|
4669
|
+
}).sort((a, b) => (ageHours(b.updated_at) ?? -1) - (ageHours(a.updated_at) ?? -1));
|
|
4670
|
+
renderInboxList('home-stale-list', stale, null, { reasons: true });
|
|
4553
4671
|
// The receipt-drift row, when there is one. It is not a task, so it arrives as
|
|
4554
4672
|
// a synthetic item rather than from any bucket.
|
|
4555
4673
|
if (window.__receiptDrift) queue.push(window.__receiptDrift);
|
|
@@ -4563,17 +4681,27 @@ function renderQueue(d) {
|
|
|
4563
4681
|
if (bh == null) return -1;
|
|
4564
4682
|
return bh - ah;
|
|
4565
4683
|
});
|
|
4566
|
-
|
|
4567
|
-
//
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
const
|
|
4575
|
-
if (
|
|
4576
|
-
|
|
4684
|
+
// The first screen shows the oldest few; the rest is one click away. Gates are
|
|
4685
|
+
// never folded: a signature that is owed must not sit behind "Show more".
|
|
4686
|
+
const HOME_ROWS = 4;
|
|
4687
|
+
const sessionRows = homeSessionRowCount();
|
|
4688
|
+
const gates = queue.filter((t) => t.is_gate).length;
|
|
4689
|
+
const room = Math.max(gates, HOME_ROWS - sessionRows);
|
|
4690
|
+
const folded = homeQueueOpen ? 0 : Math.max(0, queue.length - room);
|
|
4691
|
+
renderInboxList('inbox-queue', folded ? queue.slice(0, room) : queue, null, { reasons: true, home: true });
|
|
4692
|
+
const section = document.getElementById('inbox-queue-section');
|
|
4693
|
+
if (section) section.style.display = (queue.length + sessionRows) ? '' : 'none';
|
|
4694
|
+
renderHomeHead();
|
|
4695
|
+
const total = homeNeedsYou().total;
|
|
4696
|
+
const cnt = document.getElementById('inbox-queue-count');
|
|
4697
|
+
if (cnt) cnt.textContent = folded ? `${total - folded} of ${total}` : String(total);
|
|
4698
|
+
const more = document.getElementById('home-more');
|
|
4699
|
+
if (more) {
|
|
4700
|
+
more.hidden = !(folded || (homeQueueOpen && queue.length > room));
|
|
4701
|
+
more.textContent = homeQueueOpen ? 'Show fewer' : `Show ${folded} more`;
|
|
4702
|
+
more.setAttribute('aria-expanded', String(homeQueueOpen));
|
|
4703
|
+
}
|
|
4704
|
+
window.__homeQueueLen = queue.length;
|
|
4577
4705
|
}
|
|
4578
4706
|
|
|
4579
4707
|
/**
|
|
@@ -5044,6 +5172,22 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
5044
5172
|
const reasonChip = (opts.reasons && t.__reason)
|
|
5045
5173
|
? `<span class="why why-${tone}">${esc(t.__reason)}</span>`
|
|
5046
5174
|
: '';
|
|
5175
|
+
// Home shows a non-gate row as: why it is here and for how long, what it is,
|
|
5176
|
+
// one button. A blocked row keeps Unblock — the one thing the operator can do
|
|
5177
|
+
// about it from here, and the reason this row has a button at all (see the
|
|
5178
|
+
// note on `unblock` above). The id, priority, agent and the rest are in the
|
|
5179
|
+
// task panel the row opens.
|
|
5180
|
+
if (opts.home && !canDecide) {
|
|
5181
|
+
const dd = hrs == null ? null : Math.floor(hrs / 24);
|
|
5182
|
+
const why = String(t.__reason || 'open');
|
|
5183
|
+
const chip = `${why.charAt(0).toUpperCase()}${why.slice(1)}${dd == null || /\d+d$/.test(why) ? '' : dd >= 1 ? ` ${dd}d` : ' today'}`;
|
|
5184
|
+
return `
|
|
5185
|
+
<div class="inbox-row home-row" onclick='openSide(${JSON.stringify(t).replace(/'/g, "'")})'>
|
|
5186
|
+
<span class="why why-${tone} home-chip" title="${esc(t.id || '')}${t.updated_at ? ' · last change ' + esc(fmtDate(t.updated_at)) : ''}">${esc(chip)}</span>
|
|
5187
|
+
<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>
|
|
5188
|
+
${t.__synthetic ? diffBtn : (unblock || '<div class="actions"><span class="gate-btn home-open" aria-hidden="true">Open</span></div>')}
|
|
5189
|
+
</div>`;
|
|
5190
|
+
}
|
|
5047
5191
|
return `
|
|
5048
5192
|
<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
5193
|
${canDecide ? gateChip(t) : `<span class="id">${esc(t.id || '')}</span>`}
|
|
@@ -5055,24 +5199,188 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
5055
5199
|
reattachGateBriefs(root);
|
|
5056
5200
|
}
|
|
5057
5201
|
|
|
5202
|
+
// ── Home (UX audit 2026-11) ────────────────────────────────────────────────
|
|
5203
|
+
// What "needs you" means, in one place. The headline, the sidebar badge, the
|
|
5204
|
+
// queue count and the sentence under the headline all read this function, so
|
|
5205
|
+
// they cannot disagree again: the audit found 51, 18, 8 and "nothing waiting"
|
|
5206
|
+
// on one project within ten minutes, each from its own arithmetic.
|
|
5207
|
+
//
|
|
5208
|
+
// gates a signature is owed
|
|
5209
|
+
// p0 open P0s
|
|
5210
|
+
// blocked tasks that will not move until somebody unblocks them
|
|
5211
|
+
// replies sessions stopped on the operator (a prompt, or a finished turn)
|
|
5212
|
+
//
|
|
5213
|
+
// `tasks` is the server's own distinct count (`summary.needs_you`): a P0 that is
|
|
5214
|
+
// also blocked is one item. Stale in-progress work is NOT in it — nothing is
|
|
5215
|
+
// asked of the operator there; it is the Agents tile's number.
|
|
5216
|
+
let homeQueueOpen = false;
|
|
5217
|
+
function homeNeedsYou() {
|
|
5218
|
+
const d = window.__inboxData || {};
|
|
5219
|
+
const s = d.summary || {};
|
|
5220
|
+
const gates = (d.pending_gates || []).length;
|
|
5221
|
+
const p0 = (d.p0_open || []).length;
|
|
5222
|
+
const blocked = (d.blocked || []).length;
|
|
5223
|
+
const tasks = Number.isFinite(s.needs_you) ? s.needs_you : gates + p0 + blocked;
|
|
5224
|
+
const ss = window.__sessionStatus || { blocked: [], waiting: 0 };
|
|
5225
|
+
const replies = ss.blocked.length + ss.waiting;
|
|
5226
|
+
// Reviewed files changed after an approval: not a task, still a decision.
|
|
5227
|
+
const drift = window.__receiptDrift ? 1 : 0;
|
|
5228
|
+
return { gates, p0, blocked, tasks, replies, drift, total: tasks + replies + drift, read: !!window.__inboxData };
|
|
5229
|
+
}
|
|
5230
|
+
function homeSessionRowCount() {
|
|
5231
|
+
const ss = window.__sessionStatus || { blocked: [], waiting: 0 };
|
|
5232
|
+
return ss.blocked.length + (ss.waiting ? 1 : 0);
|
|
5233
|
+
}
|
|
5234
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
5235
|
+
function renderHomeHead() {
|
|
5236
|
+
const n = homeNeedsYou();
|
|
5237
|
+
const badge = document.getElementById('nav-inbox-count');
|
|
5238
|
+
if (badge) badge.textContent = n.total;
|
|
5239
|
+
const h = document.getElementById('home-headline');
|
|
5240
|
+
const sub = document.getElementById('inbox-greet');
|
|
5241
|
+
if (!h || !sub) return;
|
|
5242
|
+
sub.setAttribute('role', 'status');
|
|
5243
|
+
sub.setAttribute('aria-live', 'polite');
|
|
5244
|
+
if (!n.read) { h.textContent = 'Home'; sub.textContent = 'Reading project data…'; return; }
|
|
5245
|
+
if (anyDegraded()) {
|
|
5246
|
+
// A read failed. "Nothing needs you" would be a claim about data we do not
|
|
5247
|
+
// have, so the headline says only what was counted, as a floor.
|
|
5248
|
+
h.textContent = n.total ? `At least ${plural(n.total, 'item needs', 'items need')} you` : 'Home';
|
|
5249
|
+
sub.textContent = 'Some data could not be read — treat the counts below as incomplete.';
|
|
5250
|
+
return;
|
|
5251
|
+
}
|
|
5252
|
+
h.textContent = n.total ? `${plural(n.total, 'item needs', 'items need')} you` : 'Nothing needs you here';
|
|
5253
|
+
let parts = [];
|
|
5254
|
+
if (n.replies) parts.push(`${plural(n.replies, 'session waits', 'sessions wait')} for your reply`);
|
|
5255
|
+
if (n.blocked) parts.push(`${plural(n.blocked, 'task is', 'tasks are')} blocked`);
|
|
5256
|
+
if (n.p0) parts.push(`${n.p0} P0 open`);
|
|
5257
|
+
parts.push(n.gates ? `${plural(n.gates, 'gate', 'gates')} waiting` : 'no gates waiting');
|
|
5258
|
+
if (n.drift) parts.push('reviewed files changed after approval');
|
|
5259
|
+
// Silence is a reading: a project where nothing has moved for days says so
|
|
5260
|
+
// here instead of reading as calm.
|
|
5261
|
+
const ageH = window.__homeAgeH;
|
|
5262
|
+
if (!n.total && ageH == null) parts = ['Nothing recorded yet for this project'];
|
|
5263
|
+
else if (ageH != null && ageH > 72) parts.push(`nothing has moved in ${Math.floor(ageH / 24)} days`);
|
|
5264
|
+
// Scoped to "here", and the person's other projects are consulted every time:
|
|
5265
|
+
// "nothing needs you" beside open P0s one project over is the claim this page
|
|
5266
|
+
// already had to stop making once (tests/lib/hero-wording.test.mjs).
|
|
5267
|
+
const ew = elsewhereClause((window.__inboxData || {}).elsewhere);
|
|
5268
|
+
if (ew.text) parts.push(String(ew.text).replace(/\.$/, ''));
|
|
5269
|
+
if (window.__homeReadAt) {
|
|
5270
|
+
const min = Math.floor((Date.now() - window.__homeReadAt) / 60000);
|
|
5271
|
+
parts.push(min < 1 ? 'updated just now' : `updated ${min} min ago`);
|
|
5272
|
+
}
|
|
5273
|
+
sub.textContent = parts.join(' · ');
|
|
5274
|
+
}
|
|
5275
|
+
function toggleHomeQueue() {
|
|
5276
|
+
homeQueueOpen = !homeQueueOpen;
|
|
5277
|
+
renderQueue(window.__inboxData);
|
|
5278
|
+
}
|
|
5279
|
+
function toggleHomeStale() {
|
|
5280
|
+
const box = document.getElementById('home-stale');
|
|
5281
|
+
const btn = document.getElementById('home-stale-toggle');
|
|
5282
|
+
if (!box || !btn) return;
|
|
5283
|
+
box.hidden = !box.hidden;
|
|
5284
|
+
btn.setAttribute('aria-expanded', String(!box.hidden));
|
|
5285
|
+
btn.textContent = box.hidden ? 'Review stale tasks' : 'Hide stale tasks';
|
|
5286
|
+
}
|
|
5287
|
+
// "New task" prepares a launch command; it does not create a Beads issue (that
|
|
5288
|
+
// is "New issue", in the command palette) and does not start a session.
|
|
5289
|
+
function openNewTask() {
|
|
5290
|
+
switchTab('work');
|
|
5291
|
+
const d = document.querySelector('details.admin-compose');
|
|
5292
|
+
if (d) d.open = true;
|
|
5293
|
+
setTimeout(() => document.getElementById('work-goal')?.focus(), 0);
|
|
5294
|
+
}
|
|
5295
|
+
// Running, from ONE source: the task projection Work is drawn from. The Ledger
|
|
5296
|
+
// tile used to count pipeline stages whose last verdict was not final, which
|
|
5297
|
+
// read "6 running" for stages last heard from weeks ago, beside "Running 0" on
|
|
5298
|
+
// Work (F3). `null` means the projection has not been read — not zero.
|
|
5299
|
+
function homeRunningNow() {
|
|
5300
|
+
const snap = window.GctoWork?.getSnapshot?.();
|
|
5301
|
+
if (!snap || !Array.isArray(snap.entries)) return null;
|
|
5302
|
+
return snap.entries.filter((e) => !e.terminal && ['working', 'publishing'].includes(e.phase)).length;
|
|
5303
|
+
}
|
|
5304
|
+
async function refreshHomeTiles() {
|
|
5305
|
+
const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };
|
|
5306
|
+
const d = window.__inboxData || {};
|
|
5307
|
+
// Agents
|
|
5308
|
+
const running = homeRunningNow();
|
|
5309
|
+
const stale = (d.stale_in_progress || []).length;
|
|
5310
|
+
set('home-agents-num', running == null ? absent('unloaded', 'the task projection has not been read') : `${running} running now`);
|
|
5311
|
+
// The task projection loads on its own schedule; look again rather than leave
|
|
5312
|
+
// "not read" on screen after it has been read.
|
|
5313
|
+
if (running == null && (refreshHomeTiles.tries = (refreshHomeTiles.tries || 0) + 1) <= 10) setTimeout(refreshHomeTiles, 1500);
|
|
5314
|
+
else if (running != null) refreshHomeTiles.tries = 0;
|
|
5315
|
+
set('home-agents-sub', stale
|
|
5316
|
+
? `${plural(stale, 'in-progress task has', 'in-progress tasks have')} not moved for 2+ days`
|
|
5317
|
+
: (window.__inboxData ? 'No stale in-progress tasks' : ''));
|
|
5318
|
+
const st = document.getElementById('home-stale-toggle');
|
|
5319
|
+
if (st) st.hidden = !stale;
|
|
5320
|
+
if (!stale) { const box = document.getElementById('home-stale'); if (box) box.hidden = true; }
|
|
5321
|
+
// Cost and Shipped share the Ledger's own reads, pinned to 30 days.
|
|
5322
|
+
// Every inbox arrival lands here (init, refresh, SSE); the 30-day reads are
|
|
5323
|
+
// not repeated more than twice a minute for the same project.
|
|
5324
|
+
const project = currentProject;
|
|
5325
|
+
if (!project) return;
|
|
5326
|
+
let last = refreshHomeTiles.last || {};
|
|
5327
|
+
if (!(last.project === project && Date.now() - last.at < 30000)) {
|
|
5328
|
+
const q = `${pqs()}${pqs() ? '&' : '?'}days=30`;
|
|
5329
|
+
last = refreshHomeTiles.last = { project, at: Date.now(), m: null, cost: null };
|
|
5330
|
+
[last.m, last.cost] = await Promise.all([
|
|
5331
|
+
api(`/api/metrics${q}`).catch(() => null),
|
|
5332
|
+
api(`/api/cost${q}`).catch(() => null),
|
|
5333
|
+
]);
|
|
5334
|
+
}
|
|
5335
|
+
if (typeof refreshAgentsInstalled === 'function' && !(fleetState?.agents || []).length) {
|
|
5336
|
+
await Promise.resolve(refreshAgentsInstalled()).catch(() => null);
|
|
5337
|
+
}
|
|
5338
|
+
const m = last.m, cost = last.cost;
|
|
5339
|
+
if (project !== currentProject) return;
|
|
5340
|
+
const real = m?.cost?.real_llm_usd ?? null, est = m?.cost?.llm_usd ?? null;
|
|
5341
|
+
const spent = real != null ? real : est;
|
|
5342
|
+
set('home-cost-num', !m || !m.cost ? absent('unloaded', 'the metrics payload carried no cost section')
|
|
5343
|
+
: (spent == null || (real === 0 && est === 0)) ? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
|
|
5344
|
+
: `$${spent < 10 ? spent.toFixed(2) : fmtMoney(spent)} <span class="home-tile-unit">${real != null ? 'measured' : 'estimated'}</span>`);
|
|
5345
|
+
const over = (fleetState?.agents || []).filter((a) => a.budget && a.budget.state === 'exceeded');
|
|
5346
|
+
const cap = cost?.monthly_budget;
|
|
5347
|
+
set('home-cost-sub', over.length
|
|
5348
|
+
? `<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>`
|
|
5349
|
+
: cap ? `of $${fmtMoney(cap)} monthly cap` : 'No monthly cap set');
|
|
5350
|
+
const done = m?.tasks ? ((m.tasks.done_in_window || m.tasks.done) ?? 0) : null;
|
|
5351
|
+
set('home-shipped-num', done == null ? absent('unloaded', 'the metrics payload carried no tasks section') : `${plural(done, 'task', 'tasks')} done`);
|
|
5352
|
+
set('home-shipped-sub', m?.avg_completion_min ? `Median ${m.avg_completion_min} min from start to done` : '');
|
|
5353
|
+
}
|
|
5354
|
+
|
|
5058
5355
|
// Sessions waiting on a person (scripts/lib/session-status.mjs). `blocked` is a
|
|
5059
5356
|
// 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
|
|
5357
|
+
// open session is between turns, so it is one row for all of them, not a row each.
|
|
5358
|
+
// Both are counted in the Home headline: they are the items most plainly waiting
|
|
5359
|
+
// on the operator, and they used to be a muted sentence above the queue.
|
|
5061
5360
|
async function refreshSessionStatus() {
|
|
5062
|
-
const el = document.getElementById('inbox-sessions');
|
|
5361
|
+
const el = document.getElementById('inbox-sessions-rows');
|
|
5063
5362
|
if (!el) return;
|
|
5363
|
+
const project = currentProject;
|
|
5064
5364
|
const r = await api(`/api/session-status${pqs()}`);
|
|
5365
|
+
if (project !== currentProject) return;
|
|
5065
5366
|
const rows = (r && Array.isArray(r.sessions)) ? r.sessions : [];
|
|
5066
5367
|
const blocked = rows.filter((x) => x.state === 'blocked');
|
|
5067
5368
|
const waiting = rows.filter((x) => x.state === 'waiting').length;
|
|
5068
|
-
|
|
5069
|
-
el.
|
|
5070
|
-
|
|
5071
|
-
<
|
|
5072
|
-
|
|
5073
|
-
<
|
|
5369
|
+
window.__sessionStatus = { blocked, waiting };
|
|
5370
|
+
el.innerHTML = blocked.map((x) => `<div class="inbox-row home-row home-row-static">
|
|
5371
|
+
<span class="why why-gate home-chip">Reply</span>
|
|
5372
|
+
<span class="ttl"><span>Session ${esc(String(x.session).slice(0, 8))} waits for you</span>
|
|
5373
|
+
<span class="ttl-desc">${esc(x.reason || 'waiting for input')} · ${esc(agoStr(x.since))}${x.kind ? ` · ${esc(x.kind)}` : ''}</span></span>
|
|
5374
|
+
<div class="actions"></div>
|
|
5074
5375
|
</div>`).join('')
|
|
5075
|
-
+ (waiting ? `<div class="
|
|
5376
|
+
+ (waiting ? `<div class="inbox-row home-row home-row-static">
|
|
5377
|
+
<span class="why why-gate home-chip">Reply</span>
|
|
5378
|
+
<span class="ttl"><span>${plural(waiting, 'session', 'sessions')} finished a turn and ${waiting === 1 ? 'waits' : 'wait'} for your next message</span>
|
|
5379
|
+
<span class="ttl-desc">The agent stopped and will not continue until you answer in that session.</span></span>
|
|
5380
|
+
<div class="actions"><button type="button" class="gate-btn gate-approve" onclick="switchTab('sessions')">Open sessions</button></div>
|
|
5381
|
+
</div>` : '');
|
|
5382
|
+
renderHomeHead();
|
|
5383
|
+
if (window.__inboxData) renderQueue(window.__inboxData);
|
|
5076
5384
|
}
|
|
5077
5385
|
|
|
5078
5386
|
async function refreshInbox() {
|
|
@@ -5413,89 +5721,98 @@ async function verifyJudgeKey() {
|
|
|
5413
5721
|
loadJudgeStatus();
|
|
5414
5722
|
}
|
|
5415
5723
|
|
|
5416
|
-
//
|
|
5417
|
-
// `measured` only when a verdict carried a cost,
|
|
5418
|
-
// labelled so;
|
|
5419
|
-
//
|
|
5420
|
-
//
|
|
5421
|
-
function
|
|
5724
|
+
// Cost head (UX audit 2026-11, F4; replaces the Ledger head). Every figure
|
|
5725
|
+
// names its state: spend is `measured` only when a verdict carried a cost,
|
|
5726
|
+
// otherwise `estimated` and labelled so; list price is n/a when no price is
|
|
5727
|
+
// set, never $0. Running now, the stage strip and the stuck list moved to Home
|
|
5728
|
+
// and Work — this screen answers "what did it cost".
|
|
5729
|
+
function costOverCap() {
|
|
5730
|
+
const agents = (fleetState?.agents || []).filter((a) => a.budget?.state === 'exceeded');
|
|
5731
|
+
return agents.map((a) => ({ slug: a.slug, spent: a.budget.measuredUsd ?? 0, cap: a.budget.limitUsd }));
|
|
5732
|
+
}
|
|
5733
|
+
function costRange(days) {
|
|
5734
|
+
const end = new Date(), start = new Date(end.getTime() - (days - 1) * 86400000);
|
|
5735
|
+
const f = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
5736
|
+
return `${f(start)} – ${f(end)}`;
|
|
5737
|
+
}
|
|
5738
|
+
function renderLedgerHead({ cost, metrics, usage, over }) {
|
|
5739
|
+
if (!over) over = costOverCap();
|
|
5422
5740
|
const el = document.getElementById('ledger-head');
|
|
5423
5741
|
if (!el) return;
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5742
|
+
const title = document.getElementById('cost-title');
|
|
5743
|
+
if (title) title.textContent = `Cost · last ${periodLabel(currentPeriod)}`;
|
|
5744
|
+
const sub = document.getElementById('cost-sub');
|
|
5745
|
+
if (sub) sub.textContent = `${costRange(currentPeriod)} · updated just now`;
|
|
5428
5746
|
const costKnown = !!(metrics && metrics.cost);
|
|
5429
5747
|
const realLlmUsd = metrics?.cost?.real_llm_usd ?? null;
|
|
5430
5748
|
const llmUsd = metrics?.cost?.llm_usd ?? null;
|
|
5431
5749
|
const spent = realLlmUsd != null ? realLlmUsd : llmUsd;
|
|
5432
5750
|
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
5751
|
const spentCell = !costKnown
|
|
5436
5752
|
? absent('unloaded', 'the metrics payload carried no cost section')
|
|
5437
5753
|
: (realLlmUsd === 0 && llmUsd === 0) || spent == null
|
|
5438
5754
|
? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
|
|
5439
5755
|
: `$${spent < 10 ? spent.toFixed(2) : fmtMoney(spent)}`;
|
|
5440
|
-
|
|
5756
|
+
|
|
5757
|
+
// One alert: agents over their cap, and the project's monthly cap if passed.
|
|
5758
|
+
// `over` is read once per load: the fleet is re-read in the background and a
|
|
5759
|
+
// redraw mid-reload must not drop the alert.
|
|
5760
|
+
const cap = cost?.monthly_budget;
|
|
5761
|
+
const monthlyOver = !!(cap && spent != null && spent > cap);
|
|
5762
|
+
let alert = '';
|
|
5763
|
+
if (over.length === 1) {
|
|
5764
|
+
const o = over[0];
|
|
5765
|
+
alert = `<b>${esc(o.slug)} is over its cap:</b> $${o.spent.toFixed(2)} spent of $${esc(String(o.cap))}`;
|
|
5766
|
+
} else if (over.length > 1) {
|
|
5767
|
+
alert = `<b>${over.length} agents are over their caps:</b> ${over.map((o) => esc(o.slug)).join(', ')}`;
|
|
5768
|
+
}
|
|
5769
|
+
if (monthlyOver) alert += `${alert ? '<br>' : ''}<b>The project is over its monthly cap:</b> $${fmtMoney(spent)} of $${fmtMoney(cap)}`;
|
|
5770
|
+
const alertAction = over.length === 1
|
|
5771
|
+
? `<button type="button" class="home-link" onclick="editAgentBudget('${esc(over[0].slug)}', ${Number(over[0].cap)})">Change cap</button>` : '';
|
|
5772
|
+
|
|
5773
|
+
// List price: Claude Code tokens priced at the API's list price. Codex has none.
|
|
5774
|
+
let listCell, listNote = '';
|
|
5775
|
+
if (!usage) listCell = absent('unloaded', 'session statistics have not been read yet');
|
|
5776
|
+
else if (usage.state === 'computing') listCell = '<span class="muted">counting…</span>';
|
|
5777
|
+
else if (usage.error || usage.state !== 'counted') listCell = absent('unloaded', usage.why || usage.error || 'session statistics are unavailable');
|
|
5778
|
+
else if (!usage.seen?.claude) listCell = absent('none', 'no Claude Code session logs are attributed to this project');
|
|
5779
|
+
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');
|
|
5780
|
+
else {
|
|
5781
|
+
listCell = esc(fmtUsdEq(usage.hosts.claude.usd));
|
|
5782
|
+
if (usage.hosts.claude.unpricedResponses) listNote = ` ${usage.hosts.claude.unpricedResponses} responses from unpriced models are not in it.`;
|
|
5783
|
+
}
|
|
5784
|
+
|
|
5785
|
+
// One line of results. Each part keeps its three-way distinction: a zero is a
|
|
5786
|
+
// count, an absence names its kind. Omitted only when the metrics never loaded.
|
|
5787
|
+
const acc = metrics?.acceptance || {};
|
|
5441
5788
|
const doneKnown = !!(metrics && metrics.tasks);
|
|
5442
5789
|
const done = (metrics?.tasks?.done_in_window || metrics?.tasks?.done) ?? 0;
|
|
5443
|
-
const acc = metrics?.acceptance || {};
|
|
5444
5790
|
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' },
|
|
5791
|
+
const facts = metrics ? [
|
|
5792
|
+
`${doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section')} tasks done`,
|
|
5449
5793
|
// 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('');
|
|
5794
|
+
`median ${avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure')} min from start to done`,
|
|
5795
|
+
`${acc.cost_per_accepted != null ? `$${Number(acc.cost_per_accepted).toFixed(2)}` : absent('none', 'nothing was accepted in this window')} per accepted task`,
|
|
5796
|
+
] : [];
|
|
5797
|
+
|
|
5474
5798
|
el.innerHTML = `
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
<
|
|
5479
|
-
<div class="
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
<
|
|
5486
|
-
|
|
5799
|
+
${alert ? `<div class="cost-alert" role="alert"><div>${alert}</div>${alertAction}</div>` : ''}
|
|
5800
|
+
<div class="cost-totals">
|
|
5801
|
+
<section class="cost-total">
|
|
5802
|
+
<h2>${spentState === 'estimated' ? 'Estimated spend' : 'Measured spend'}</h2>
|
|
5803
|
+
<div class="cost-total-num">${spentCell}</div>
|
|
5804
|
+
<p>${spentState === 'estimated'
|
|
5805
|
+
? 'No run recorded a cost, so this is estimated from run time. Caps fire only on measured spend.'
|
|
5806
|
+
: 'What agent runs recorded as spent. This is the number the caps are checked against.'}</p>
|
|
5807
|
+
</section>
|
|
5808
|
+
<section class="cost-total">
|
|
5809
|
+
<h2>At API list price</h2>
|
|
5810
|
+
<div class="cost-total-num">${listCell}</div>
|
|
5811
|
+
<p>All Claude Code tokens in this project, priced as if bought from the API. On a subscription this is not a charge. Codex has no list price.${esc(listNote)}</p>
|
|
5812
|
+
<button type="button" class="home-link" onclick="switchTab('insights/consumption')">Token detail</button>
|
|
5813
|
+
</section>
|
|
5487
5814
|
</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>`;
|
|
5815
|
+
${facts.length ? `<div class="cost-facts">${facts.join(' · ')}</div>` : ''}`;
|
|
5499
5816
|
}
|
|
5500
5817
|
|
|
5501
5818
|
// Settings (great_cto-ki1x.11). A row is a fact and the file that holds it;
|
|
@@ -5578,32 +5895,33 @@ async function registerProjectFromSettings() {
|
|
|
5578
5895
|
} catch (e) { showToast(`Not registered: ${String(e.message || e)}`, 'error', 3000); }
|
|
5579
5896
|
}
|
|
5580
5897
|
|
|
5898
|
+
let _costUsageTimer = null, _costRequest = 0;
|
|
5581
5899
|
async function loadBudgetsPage() {
|
|
5582
5900
|
loadJudgeStatus();
|
|
5583
5901
|
loadJudgeScores();
|
|
5584
5902
|
const body = document.getElementById('budgets-page-body');
|
|
5585
5903
|
if (!body) return;
|
|
5586
5904
|
body.innerHTML = '<div class="muted">loading…</div>';
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5905
|
+
if (![7, 30, 90].includes(currentPeriod)) {
|
|
5906
|
+
// The chips offer 7, 30 and 90 days; an older stored 1D or 1Y reads as 30.
|
|
5907
|
+
currentPeriod = 30;
|
|
5908
|
+
document.querySelectorAll('.period-chip').forEach((c) => c.classList.toggle('active', parseInt(c.dataset.period, 10) === 30));
|
|
5909
|
+
}
|
|
5910
|
+
document.querySelectorAll('.period-chip').forEach((c) => c.setAttribute('aria-pressed', String(c.classList.contains('active'))));
|
|
5911
|
+
const request = ++_costRequest;
|
|
5912
|
+
clearTimeout(_costUsageTimer);
|
|
5913
|
+
|
|
5914
|
+
// Three states, not two: loading | loaded | failed. A page that failed and a
|
|
5915
|
+
// page still working must not look the same.
|
|
5916
|
+
let h, cost = null, m = null;
|
|
5597
5917
|
try {
|
|
5598
|
-
[h,
|
|
5918
|
+
[h, cost, m] = await Promise.all([
|
|
5599
5919
|
api(`/api/heartbeat${pqs()}`),
|
|
5600
|
-
api(`/api/pipeline${pqs()}`).catch(() => null),
|
|
5601
5920
|
api(`/api/cost${pqsd()}`).catch(() => null),
|
|
5602
5921
|
api(`/api/metrics${pqsd()}`).catch(() => null),
|
|
5603
5922
|
(typeof refreshAgentsInstalled === 'function' && !(fleetState?.agents || []).length)
|
|
5604
5923
|
? refreshAgentsInstalled() : Promise.resolve(),
|
|
5605
5924
|
]);
|
|
5606
|
-
renderLedgerHead({ heartbeat: h, pipeline: pipe, cost, metrics: m });
|
|
5607
5925
|
} catch (err) {
|
|
5608
5926
|
body.innerHTML = `
|
|
5609
5927
|
<div class="budgets-failed">
|
|
@@ -5615,104 +5933,73 @@ async function loadBudgetsPage() {
|
|
|
5615
5933
|
</div>`;
|
|
5616
5934
|
return;
|
|
5617
5935
|
}
|
|
5936
|
+
if (request !== _costRequest) return;
|
|
5937
|
+
const over = costOverCap();
|
|
5938
|
+
renderLedgerHead({ cost, metrics: m, usage: null, over });
|
|
5939
|
+
// The list-price total comes from the session logs, which can take a while to
|
|
5940
|
+
// count; it fills in when ready and never holds the rest of the screen.
|
|
5941
|
+
const period = currentPeriod;
|
|
5942
|
+
const readUsage = async (tries) => {
|
|
5943
|
+
const u = await api(`/api/usage?days=${period}${pqs().replace('?', '&')}`).catch(() => null);
|
|
5944
|
+
if (request !== _costRequest || currentTab !== 'budgets') return;
|
|
5945
|
+
renderLedgerHead({ cost, metrics: m, usage: u || { error: 'the board is not reachable' }, over });
|
|
5946
|
+
if (u && (u.state === 'computing' || u.refreshing) && tries < 30) _costUsageTimer = setTimeout(() => readUsage(tries + 1), 2000);
|
|
5947
|
+
};
|
|
5948
|
+
readUsage(0);
|
|
5949
|
+
|
|
5618
5950
|
const caps = h?.budgets || {};
|
|
5619
5951
|
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
|
-
|
|
5952
|
+
const bySlug = new Map((fleetState?.agents || []).map((a) => [a.slug, a]));
|
|
5953
|
+
const ranUncapped = agents.filter((a) => (a.runs_30d || 0) > 0 && !(a.slug in caps));
|
|
5954
|
+
const idle = agents.filter((a) => !(a.runs_30d > 0) && !(a.slug in caps));
|
|
5634
5955
|
const firstSentence = (t) => {
|
|
5635
5956
|
const s1 = String(t || '').split(/(?<=[.!?])\s/)[0] || '';
|
|
5636
5957
|
return s1.length > 150 ? s1.slice(0, 147) + '…' : s1;
|
|
5637
5958
|
};
|
|
5638
5959
|
|
|
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
|
-
|
|
5960
|
+
// One table, one row per cap, then the agents that ran without one as one row.
|
|
5961
|
+
const row = ({ agent, spent, cap, state, over, line, action, title }) => `
|
|
5962
|
+
<div class="cost-row"${title ? ` title="${esc(title)}"` : ''}>
|
|
5963
|
+
<div class="cost-agent">${esc(agent)}<span class="cost-line${over ? ' cost-over' : ''}">${esc(line)}</span></div>
|
|
5964
|
+
<div class="cost-col num">${esc(spent)}</div>
|
|
5965
|
+
<div class="cost-col num">${esc(cap)}</div>
|
|
5966
|
+
<div class="cost-col${over ? ' cost-over' : ''}">${esc(state)}</div>
|
|
5967
|
+
<div class="act">${action || ''}</div>
|
|
5968
|
+
</div>`;
|
|
5969
|
+
const capRows = Object.entries(caps).map(([slug, capUsd]) => {
|
|
5970
|
+
const b = bySlug.get(slug)?.budget;
|
|
5971
|
+
const st = b?.state;
|
|
5972
|
+
const spent = st === 'within' || st === 'exceeded' ? `$${(b.measuredUsd ?? 0).toFixed(2)}`
|
|
5973
|
+
: st === 'unmeasured' ? 'nothing yet' : 'not loaded';
|
|
5974
|
+
const state = st === 'exceeded' ? 'Over cap' : (st === 'within' || st === 'unmeasured') ? 'Under cap' : '—';
|
|
5975
|
+
return row({
|
|
5976
|
+
agent: slug, spent, cap: `$${capUsd}`, state, over: st === 'exceeded',
|
|
5977
|
+
line: st === 'exceeded' ? `${spent} of $${capUsd} · over cap` : `${spent} of $${capUsd}`,
|
|
5978
|
+
action: `<button type="button" class="ab-btn" onclick="editAgentBudget('${esc(slug)}', ${Number(capUsd)})" aria-label="Change the ${esc(slug)} cap">Change</button>`,
|
|
5979
|
+
title: bySlug.has(slug) ? firstSentence(bySlug.get(slug).description) : 'not among the installed agents',
|
|
5980
|
+
});
|
|
5981
|
+
}).join('');
|
|
5982
|
+
let otherRow = '';
|
|
5983
|
+
if (ranUncapped.length) {
|
|
5984
|
+
const measured = ranUncapped.filter((a) => a.llm_usd_30d_real != null && a.llm_usd_30d_real > 0);
|
|
5985
|
+
const sum = measured.reduce((t, a) => t + a.llm_usd_30d_real, 0);
|
|
5986
|
+
const unmeasured = ranUncapped.length - measured.length;
|
|
5987
|
+
const spent = measured.length ? `$${sum.toFixed(2)}` : 'not measured';
|
|
5988
|
+
otherRow = row({
|
|
5989
|
+
agent: `other agents (${ranUncapped.length})`, spent: spent + (measured.length && unmeasured ? ` · ${unmeasured} not measured` : ''),
|
|
5990
|
+
cap: 'no cap', state: '—', line: `${spent} · no cap`,
|
|
5991
|
+
title: ranUncapped.map((a) => a.slug).join(', '),
|
|
5992
|
+
});
|
|
5993
|
+
}
|
|
5694
5994
|
const notes = [
|
|
5695
5995
|
h?.budgets_deprecated_key
|
|
5696
5996
|
? `<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
5997
|
(h?.budgets_malformed || []).length
|
|
5698
5998
|
? `<div class="ab-note ab-bad">${h.budgets_malformed.length} line(s) in PROJECT.md were not read: `
|
|
5699
|
-
+ h.budgets_malformed.map((
|
|
5999
|
+
+ h.budgets_malformed.map((x) => `<code>${esc(x.line)}</code> — ${esc(x.why)}`).join('; ') + '</div>' : '',
|
|
5700
6000
|
].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
6001
|
const idleBlock = idle.length ? `
|
|
5715
|
-
<details class="
|
|
6002
|
+
<details class="cost-idle">
|
|
5716
6003
|
<summary>${idle.length} installed agents have not run in 30 days</summary>
|
|
5717
6004
|
<div class="budgets-uncapped">${idle.slice(0, 80).map((a) => `
|
|
5718
6005
|
<button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')"
|
|
@@ -5720,9 +6007,29 @@ async function loadBudgetsPage() {
|
|
|
5720
6007
|
${idle.length > 80 ? `<span class="muted">and ${idle.length - 80} more</span>` : ''}
|
|
5721
6008
|
</div>
|
|
5722
6009
|
</details>` : '';
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
6010
|
+
const empty = !capRows && !otherRow
|
|
6011
|
+
? '<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>' : '';
|
|
6012
|
+
|
|
6013
|
+
body.innerHTML = `${notes}
|
|
6014
|
+
<section class="cost-agents" aria-labelledby="cost-agents-title">
|
|
6015
|
+
<div class="cost-agents-head">
|
|
6016
|
+
<h2 id="cost-agents-title">By agent</h2>
|
|
6017
|
+
<button type="button" class="ab-btn" onclick="addAgentBudget()">Add a cap</button>
|
|
6018
|
+
</div>
|
|
6019
|
+
<div class="cost-row cost-row-head" aria-hidden="true">
|
|
6020
|
+
<div>Agent</div><div class="num" title="Last 30 days, from verdicts">Spent</div><div class="num">Cap</div><div>State</div><div></div>
|
|
6021
|
+
</div>
|
|
6022
|
+
${capRows}${otherRow}${empty}${idleBlock}
|
|
6023
|
+
</section>
|
|
6024
|
+
<details class="cost-about">
|
|
6025
|
+
<summary>About this data</summary>
|
|
6026
|
+
<ul>
|
|
6027
|
+
<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>
|
|
6028
|
+
<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>
|
|
6029
|
+
<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>
|
|
6030
|
+
<li>Monthly project cap: ${cost?.monthly_budget ? `$${esc(fmtMoney(cost.monthly_budget))}` : 'none set — spend is not being stopped'}.</li>
|
|
6031
|
+
</ul>
|
|
6032
|
+
</details>`;
|
|
5726
6033
|
|
|
5727
6034
|
const badge = document.getElementById('nav-budget-count');
|
|
5728
6035
|
if (badge) badge.textContent = Object.keys(caps).length ? String(Object.keys(caps).length) : '';
|
|
@@ -5761,9 +6068,12 @@ function budgetPrompt(agent, current) {
|
|
|
5761
6068
|
const raw = prompt(
|
|
5762
6069
|
`Spending cap for ${agent}, in dollars.\n\n`
|
|
5763
6070
|
+ `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
|
|
6071
|
+
+ `Spend is measured from verdicts; while none carries a cost the cap cannot fire.`
|
|
6072
|
+
+ (current != null ? `\n\nLeave it empty to remove the cap.` : ''),
|
|
5765
6073
|
current != null ? String(current) : '');
|
|
5766
6074
|
if (raw === null) return null;
|
|
6075
|
+
// Cost shows one Change button per cap; an empty answer is how a cap is removed.
|
|
6076
|
+
if (current != null && String(raw).trim() === '') return 'remove';
|
|
5767
6077
|
const usd = Number(String(raw).replace(/[$\s,]/g, ''));
|
|
5768
6078
|
if (!Number.isFinite(usd) || usd <= 0) { alert(`Not a positive dollar amount: ${raw}`); return null; }
|
|
5769
6079
|
return usd;
|
|
@@ -5772,6 +6082,7 @@ function budgetPrompt(agent, current) {
|
|
|
5772
6082
|
function editAgentBudget(agent, current) {
|
|
5773
6083
|
const usd = budgetPrompt(agent, current);
|
|
5774
6084
|
if (usd == null) return;
|
|
6085
|
+
if (usd === 'remove') { clearAgentBudget(agent, current); return; }
|
|
5775
6086
|
writeAgentBudget({ agent, limit_usd: usd },
|
|
5776
6087
|
`Set ${agent} to $${usd}, replacing $${current}?\n\n`
|
|
5777
6088
|
+ `Written to .great_cto/PROJECT.md. Measured spend past $${usd} stops the pipeline dispatching ${agent}.`);
|
|
@@ -5871,6 +6182,16 @@ function renderResume(d) {
|
|
|
5871
6182
|
// still want to know where you left off. The claim about it was wrong, so the
|
|
5872
6183
|
// claim is what changes. It now states the age of the newest thing shown, and
|
|
5873
6184
|
// says "last 24h" only when that is true.
|
|
6185
|
+
// The closed "activity log" line names the newest verdict, so the reader opens
|
|
6186
|
+
// it only when that is worth chasing.
|
|
6187
|
+
const latest = document.getElementById('home-latest');
|
|
6188
|
+
if (latest) {
|
|
6189
|
+
const v = verdicts.filter((x) => x && x.ts && !Number.isNaN(Date.parse(x.ts)))
|
|
6190
|
+
.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts))[0];
|
|
6191
|
+
latest.textContent = v
|
|
6192
|
+
? `Latest: ${v.agent || 'agent'} ${String(v.verdict || 'reported').toLowerCase()} · ${relTime(v.ts)}`
|
|
6193
|
+
: 'No verdict recorded yet';
|
|
6194
|
+
}
|
|
5874
6195
|
const meta = document.getElementById('resume-meta');
|
|
5875
6196
|
if (meta) {
|
|
5876
6197
|
const stamps = [...verdicts.map((v) => v.ts), ...wip.map((t) => t.updated_at)]
|
|
@@ -6093,7 +6414,7 @@ function renderTurns(snap) {
|
|
|
6093
6414
|
// The session goes in as a JSON string, escaped for the attribute: esc() turns '
|
|
6094
6415
|
// into ', which the browser decodes before the handler runs, so a quoted
|
|
6095
6416
|
// 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>`);
|
|
6417
|
+
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
6418
|
}
|
|
6098
6419
|
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
6420
|
+ `<div class="tp-turns">${buttons.join('')}</div></div>`;
|
|
@@ -6795,7 +7116,7 @@ async function renderHarnesses() {
|
|
|
6795
7116
|
|
|
6796
7117
|
body.innerHTML = `
|
|
6797
7118
|
<div class="mp-card harness-hosts">
|
|
6798
|
-
<div><span class="dot ok"></span><b>Claude Code</b> <span class="mono cap muted">host · v${esc(h.claude_code?.version || '')}</span><span class="cap muted" style="margin-left:auto" title="
|
|
7119
|
+
<div><span class="dot ok"></span><b>Claude Code</b> <span class="mono cap muted">host · v${esc(h.claude_code?.version || '')}</span><span class="cap muted" style="margin-left:auto" title="73 agents · commands · skills · hooks">full pipeline</span></div>
|
|
6799
7120
|
<div><span class="${codexDot}"></span><b>OpenAI Codex</b> <span class="mono cap">${codexState}</span>${cx.version ? `<span class="mono cap muted">${esc(cx.version)}</span>` : ''}${cx.model ? `<span class="mono cap muted" style="margin-left:auto">${esc(cx.model)}</span>` : ''}</div>
|
|
6800
7121
|
</div>
|
|
6801
7122
|
<div class="mp-card" style="margin-top:12px">
|
|
@@ -7079,6 +7400,7 @@ function setPeriod(days) {
|
|
|
7079
7400
|
// Highlight active chip
|
|
7080
7401
|
document.querySelectorAll('.period-chip').forEach(c => {
|
|
7081
7402
|
c.classList.toggle('active', parseInt(c.dataset.period, 10) === days);
|
|
7403
|
+
c.setAttribute('aria-pressed', String(parseInt(c.dataset.period, 10) === days));
|
|
7082
7404
|
});
|
|
7083
7405
|
// Update BOTH labels immediately on click — so user never sees a mismatch
|
|
7084
7406
|
// between the active chip and the section headers, even before fetch returns
|
|
@@ -9182,19 +9504,23 @@ async function checkBoardFreshness() {
|
|
|
9182
9504
|
// `unknown` are both silent, because a comparison we could not make must not
|
|
9183
9505
|
// render as a mismatch.
|
|
9184
9506
|
if (!v || (v.stale !== 'behind' && v.stale !== 'ahead')) return;
|
|
9185
|
-
|
|
9507
|
+
// One line in the sidebar footer, not a banner over every screen (F16). The
|
|
9508
|
+
// full sentence — which build runs, which is installed, what a restart would
|
|
9509
|
+
// do — is the tooltip and the accessible name, so nothing it said is lost.
|
|
9510
|
+
const host = document.getElementById('stale-board-slot');
|
|
9186
9511
|
if (!host || document.getElementById('stale-board')) return;
|
|
9187
|
-
const el = document.createElement('
|
|
9512
|
+
const el = document.createElement('span');
|
|
9188
9513
|
el.id = 'stale-board';
|
|
9189
9514
|
el.className = 'stale-board';
|
|
9190
9515
|
el.setAttribute('role', 'status');
|
|
9191
|
-
el.
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
:
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9516
|
+
el.tabIndex = 0;
|
|
9517
|
+
const full = v.stale === 'behind'
|
|
9518
|
+
? `This board is running v${v.version} — user-installed plugin v${v.installed} is newer. Restart from that installation to use it.`
|
|
9519
|
+
: `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.`;
|
|
9520
|
+
el.title = full;
|
|
9521
|
+
el.setAttribute('aria-label', full);
|
|
9522
|
+
el.textContent = v.stale === 'behind' ? `· update ready: v${v.installed}` : `· newer than installed v${v.installed}`;
|
|
9523
|
+
host.appendChild(el);
|
|
9198
9524
|
}
|
|
9199
9525
|
|
|
9200
9526
|
/* ── Command palette ───────────────────────────────────────────────────────
|
|
@@ -9538,10 +9864,10 @@ async function replayBoardRoute() {
|
|
|
9538
9864
|
}
|
|
9539
9865
|
if (project && typeof currentProject !== 'undefined' && currentProject && project !== currentProject) {
|
|
9540
9866
|
const loading = switchProject(project);
|
|
9541
|
-
history.replaceState(history.state, '', expectedHash || '#/
|
|
9867
|
+
history.replaceState(history.state, '', expectedHash || '#/home');
|
|
9542
9868
|
await loading;
|
|
9543
9869
|
if (ticket !== routeReplay || project !== new URLSearchParams(location.search).get('project')) return;
|
|
9544
|
-
history.replaceState(history.state, '', expectedHash || '#/
|
|
9870
|
+
history.replaceState(history.state, '', expectedHash || '#/home');
|
|
9545
9871
|
}
|
|
9546
9872
|
if (ticket !== routeReplay) return;
|
|
9547
9873
|
const r = routeFromHash(); if (r) switchTab(r);
|