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.
@@ -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
- display: flex; align-items: center; gap: 10px;
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
- transition: transform 0.2s ease;
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
- /* A session stopped on a permission prompt or an unanswered wait — the one piece
1735
- of session status the operator can act on, so it is styled like a decision. */
1736
- .session-blocked {
1737
- padding: 6px 10px;
1738
- margin-bottom: 4px;
1739
- border-left: 3px solid var(--status-blocked);
1740
- background: var(--bg-elevated);
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 #crumb-project { display: none; }
3314
- .topbar .crumbs .here { color: var(--text); font-weight: 600; }
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
- <span class="task-count" id="topbar-task-count">0 ISSUES</span>
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
- <button class="btn-bell" id="board-theme-btn" onclick="toggleBoardTheme()" title="Toggle light / dark theme" aria-label="Toggle theme"><svg class="ico-moon" viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 9.5A6 6 0 0 1 6.5 2.5a6 6 0 1 0 7 7z"></path></svg><svg class="ico-sun" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="3"></circle><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"></path></svg></button>
3608
- <button class="btn-bell" id="topbar-settings-btn" data-tab="settings" onclick="switchTab('settings', this)" title="Settings" aria-label="Settings" style="gap:6px;padding:6px 10px;">
3609
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
3610
- <span class="btn-label" style="font-size:var(--fs-small);font-weight:500">Settings</span>
3611
- </button>
3612
- <button class="btn-new" onclick="openNewIssueModal()" aria-label="New issue" title="New issue">
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
- <div class="panel-head" style="margin-bottom:6px"><h1 class="page-title" style="margin:0">Decisions <span id="decisions-count" class="muted" style="font-weight:400;font-size:var(--fs-title-s)"></span></h1></div>
3642
- <div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
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
- <div id="inbox-sessions" style="display:none;margin:6px 0 10px;font-size:var(--fs-small)"></div>
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=&quot;kanban&quot;]'))">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">Project status<span class="proj-status-verdict" id="proj-status-verdict"></span></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=&quot;kanban&quot;]'))">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
- <div class="metrics-page">
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
- <h1 class="page-title" style="margin:0">Ledger</h1>
3956
- <!-- The period governs every figure below it, so it sits where the
3957
- figures start — beside the name, not under the stuck list. -->
3958
- <div class="period-toolbar" style="margin:0">
3959
- <span class="period-label">PERIOD</span>
3960
- <div class="period-chips" id="period-chips">
3961
- <button class="period-chip" data-period="1">1D</button>
3962
- <button class="period-chip" data-period="7">7D</button>
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
- const here = Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
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.textContent = here + away;
4359
- badge.title = ewKnown
4360
- ? `${here} here · ${away} in other projects`
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
- const greetEl = document.getElementById('inbox-greet');
4388
- greetEl.setAttribute('role', 'status');
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
- renderInboxList('inbox-queue', queue, 'inbox-queue-count', { reasons: true });
4567
- // The heading claims what the rows can deliver, and no more. "Needs you" over a
4568
- // list of things you cannot touch is the same overclaim this screen spent the
4569
- // day removing — it was simply mine.
4570
- // "Needs you" means a decision only you can make — a gate to sign. Everything
4571
- // else is work that continues elsewhere, and saying otherwise is the overclaim
4572
- // that made six rows promise an action five of them did not have.
4573
- const decidable = queue.some((t) => t.is_gate);
4574
- const head = document.querySelector('#inbox-queue-section h3');
4575
- if (head) head.textContent = queue.length === 0 ? 'Needs you'
4576
- : (decidable ? 'Needs you' : 'Waiting on work elsewhere');
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, "&apos;")})'>
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, "&apos;")})'>${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, "&apos;")})'>
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 muted count, not a row each.
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
- if (!blocked.length && !waiting) { el.style.display = 'none'; el.innerHTML = ''; return; }
5069
- el.style.display = '';
5070
- el.innerHTML = blocked.map((x) => `<div class="session-blocked">
5071
- <strong>⏸ session ${esc(String(x.session).slice(0, 8))} waits for you</strong>
5072
- <span class="muted">· ${esc(agoStr(x.since))}${x.kind ? ` · ${esc(x.kind)}` : ''}</span><br>
5073
- <span>${esc(x.reason || 'waiting for input')}</span>
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="muted" title="these sessions finished their turn; the next message is yours">${waiting} session${waiting === 1 ? '' : 's'} finished a turn and wait for your next message</div>` : '');
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
- // Ledger head (great_cto-ki1x.7). Every figure names its state: spend is
5417
- // `measured` only when a verdict carried a cost, otherwise `estimated` and
5418
- // labelled so; a cap that is not set says "no cap", never $0; a stage is a
5419
- // filled dot when done, hollow when active, red when failed, bare when idle;
5420
- // a stuck task with no readable age is counted and shown as `?`, never dropped.
5421
- function renderLedgerHead({ heartbeat, pipeline, cost, metrics }) {
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
- // Spend, three states, and $0.00 is only one of them (carried over from the
5425
- // Metrics tab this replaced): a measured figure; a time-based ESTIMATE that
5426
- // says so; and a zero that is the estimate with nothing to estimate from,
5427
- // which is not a figure at all.
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
- // Tasks done: a zero is a count; only a payload with no tasks section is an absence.
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
- // The same tiles the Metrics tab drew, as facts on one line. Each keeps its
5446
- // three-way distinction: a zero is a count, an absence names its kind.
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
- { label: 'Cycle', v: avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure'), sub: avgMin ? 'min median' : '' },
5451
- { label: 'Cost', v: acc.cost_per_accepted != null ? `$${Number(acc.cost_per_accepted).toFixed(2)}` : absent('none', 'nothing was accepted in this window'), sub: acc.cost_per_accepted != null ? '/ accepted' : '' },
5452
- ];
5453
- // Fleet 30d spend from verdicts — measured for some agents, or not at all.
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
- <div class="ledger-kpis">
5476
- <div class="metric-card">
5477
- <div class="metric-num">${spentCell}</div>
5478
- <div class="metric-label">Spent · ${esc(spentState)}</div>
5479
- <div class="metric-trend">${capCell}</div>
5480
- ${pct != null ? `<div class="bar" style="margin-top:8px"><i style="width:${pct}%${pct >= 100 ? ';background:var(--status-blocked)' : ''}"></i></div>` : ''}
5481
- </div>
5482
- <div class="metric-card">
5483
- <div class="metric-num"><span class="dot run" style="width:12px;height:12px;vertical-align:middle;margin-right:8px"></span>${Array.isArray(pipeline) ? running.length : absent('unloaded', 'pipeline state not read')}</div>
5484
- <div class="metric-label">Running now</div>
5485
- <div class="metric-trend">${running.length ? esc(running.map((st) => st.stage).join(' · ')) : (Array.isArray(pipeline) ? 'nothing active' : '')}</div>
5486
- </div>
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
- <div class="ledger-facts">
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
- // Three states, not two: loading | loaded | failed.
5589
- //
5590
- // Without this catch the two reads below were awaited bare, so a rejection
5591
- // returned from the function and left `loading…` on screen permanently. Caught
5592
- // once in the wild: opening Budgets during startup raced the agent list, the
5593
- // promise rejected, and the page sat there looking busy. A page that failed
5594
- // and a page still working are the same picture, and the one you are looking
5595
- // at is the one where nothing more is coming.
5596
- let h, pipe = null, cost = null, m = null;
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, pipe, cost, m] = await Promise.all([
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
- // 69 identical chips in one blob answered none of the three questions a person
5622
- // arrives with: what does this agent do, what does it usually cost, and over
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
- // Spend is stated with its window and its provenance. `est` is a time-based
5640
- // guess and says so; a real figure only exists where a verdict carried a cost.
5641
- const spendCell = (a) => {
5642
- if (a.llm_usd_30d_real != null && a.llm_usd_30d_real > 0) {
5643
- return `<span class="bt-usd">$${a.llm_usd_30d_real.toFixed(2)}</span> <span class="muted">measured</span>`;
5644
- }
5645
- if (a.llm_usd_30d_est != null && a.llm_usd_30d_est > 0) {
5646
- return `<span class="bt-usd">$${a.llm_usd_30d_est.toFixed(2)}</span> <span class="muted">estimated</span>`;
5647
- }
5648
- return '<span class="muted">not measured</span>';
5649
- };
5650
-
5651
- const stateCell = (slug) => {
5652
- const b = (fleetState?.agents || []).find((a) => a.slug === slug)?.budget;
5653
- if (!b || b.state === 'no-limit') return '';
5654
- const spent = (b.state === 'within' || b.state === 'exceeded')
5655
- ? `$${(b.measuredUsd ?? 0).toFixed(2)} measured` : (b.state === 'unmeasured' ? 'nothing measured yet' : '');
5656
- return `${budgetChip(b)} <span class="ab-spent">${esc(spent)}</span>`;
5657
- };
5658
-
5659
- const row = (a, cap) => `
5660
- <tr>
5661
- <td class="bt-agent">
5662
- <div class="bt-slug">${esc(a.slug)}</div>
5663
- <div class="bt-desc">${esc(firstSentence(a.description))}</div>
5664
- </td>
5665
- <td class="bt-runs">${a.runs_30d || 0}</td>
5666
- <td class="bt-spend">${spendCell(a)}</td>
5667
- <td class="bt-cap">${cap != null ? `$${esc(String(cap))}` : '<span class="muted">—</span>'}</td>
5668
- <td class="bt-state">${cap != null ? stateCell(a.slug) : ''}</td>
5669
- <td class="bt-actions">${cap != null
5670
- ? `<button type="button" class="ab-btn" onclick="editAgentBudget('${esc(a.slug)}', ${Number(cap)})">Change</button>`
5671
- + `<button type="button" class="ab-btn ab-rm" onclick="clearAgentBudget('${esc(a.slug)}', ${Number(cap)})">Remove</button>`
5672
- : `<button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')">Set a cap</button>`}</td>
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((m) => `<code>${esc(m.line)}</code> — ${esc(m.why)}`).join('; ') + '</div>' : '',
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="budgets-idle" style="margin-top:28px">
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
- body.innerHTML = `${notes}${capsBlock}${activeBlock}${idleBlock}
5725
- <button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget for any agent</button>`;
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 &#39;, 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
- const host = document.querySelector('.workspace');
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('div');
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.innerHTML = v.stale === 'behind'
9192
- ? `<span>This board is running <code>v${esc(v.version)}</code> — `
9193
- + `user-installed plugin <code>v${esc(v.installed)}</code> is newer. Restart from that installation to use it.</span>`
9194
- : `<span>This board is running <code>v${esc(v.version)}</code>, which is newer than the `
9195
- + `user-installed plugin <code>v${esc(v.installed)}</code>. Restarting from that installation would use the older build — `
9196
- + `install this version first.</span>`;
9197
- host.insertBefore(el, host.firstChild);
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 || '#/work');
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 || '#/work');
9873
+ history.replaceState(history.state, '', expectedHash || '#/home');
9545
9874
  }
9546
9875
  if (ticket !== routeReplay) return;
9547
9876
  const r = routeFromHash(); if (r) switchTab(r);