great-cto 3.77.0 → 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.
@@ -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,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
- /* 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
+ .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 #crumb-project { display: none; }
3314
- .topbar .crumbs .here { color: var(--text); font-weight: 600; }
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
- <span class="task-count" id="topbar-task-count">0 ISSUES</span>
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
- <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>
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
- <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>
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
- <div id="inbox-sessions" style="display:none;margin:6px 0 10px;font-size:var(--fs-small)"></div>
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=&quot;kanban&quot;]'))">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">Project status<span class="proj-status-verdict" id="proj-status-verdict"></span></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=&quot;kanban&quot;]'))">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
- <div class="metrics-page">
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
- <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>
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
- const here = Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
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.textContent = here + away;
4359
- badge.title = ewKnown
4360
- ? `${here} here · ${away} in other projects`
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
- 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
- }
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
- 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');
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, "&apos;")})'>
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, "&apos;")})'>${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, "&apos;")})'>
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 muted count, not a row each.
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
- 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>
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="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>` : '');
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
- // 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 }) {
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
- // 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.
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
- // Tasks done: a zero is a count; only a payload with no tasks section is an absence.
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
- // 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' },
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
- { 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('');
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
- <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>
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
- <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>`;
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
- // 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;
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, pipe, cost, m] = await Promise.all([
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
- // 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
-
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
- // 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
-
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((m) => `<code>${esc(m.line)}</code> — ${esc(m.why)}`).join('; ') + '</div>' : '',
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="budgets-idle" style="margin-top:28px">
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
- body.innerHTML = `${notes}${capsBlock}${activeBlock}${idleBlock}
5725
- <button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget for any agent</button>`;
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 &#39;, 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>`;
@@ -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
- const host = document.querySelector('.workspace');
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('div');
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.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);
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 || '#/work');
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 || '#/work');
9870
+ history.replaceState(history.state, '', expectedHash || '#/home');
9545
9871
  }
9546
9872
  if (ticket !== routeReplay) return;
9547
9873
  const r = routeFromHash(); if (r) switchTab(r);