great-cto 3.16.0 → 3.18.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.
@@ -109,6 +109,16 @@
109
109
  }
110
110
  /* Light theme — same emerald system, AA-contrast on white. Toggle via data-theme. */
111
111
  [data-theme="light"] {
112
+ /* Four values that had never been measured.
113
+ The contrast audit read `:root` and nothing else, so this theme — 39 token
114
+ overrides, half the board's surface area — was unchecked for months. When it
115
+ was finally measured against `--bg-strong`, the hardest surface here:
116
+ --accent 1.51:1 against a 3:1 floor for UI boundaries. Half.
117
+ --accent-text 4.17, --p1-fg 4.04, --p2-fg 4.09 against a 4.5 floor.
118
+ `--accent` was simply the dark theme's green, which is designed to sit on
119
+ #0a0e0c. Each value below is the same hue darkened until it clears its floor,
120
+ computed rather than eyeballed. */
121
+
112
122
  --bg-page: #eef2f0;
113
123
  --bg-panel: rgba(255, 255, 255, 0.9);
114
124
  --bg-card: #ffffff;
@@ -121,11 +131,11 @@
121
131
  --text2: #56655e;
122
132
  --text3: #55625b;
123
133
  --focus-ring: #047857;
124
- --accent: #00d97e;
134
+ --accent: #009657;
125
135
  --accent-2: #0a7d4d;
126
136
  --accent-soft: rgba(0, 217, 126, 0.12);
127
137
  --accent-strong: rgba(0, 168, 98, 0.20);
128
- --accent-text: #0a7d4d;
138
+ --accent-text: #097648;
129
139
  --status-backlog: #6b7280;
130
140
  --status-todo: #6b7280;
131
141
  --status-progress: #b45309;
@@ -134,8 +144,8 @@
134
144
  --status-blocked: #c43030;
135
145
  --status-gate: #7c3aed;
136
146
  --p0-bg: rgba(196, 48, 48, 0.12); --p0-fg: #b91c1c;
137
- --p1-bg: rgba(180, 83, 9, 0.12); --p1-fg: #b45309;
138
- --p2-bg: rgba(146, 102, 10, 0.12); --p2-fg: #92660a;
147
+ --p1-bg: rgba(180, 83, 9, 0.12); --p1-fg: #a64c08;
148
+ --p2-bg: rgba(146, 102, 10, 0.12); --p2-fg: #885f09;
139
149
  --p3-bg: rgba(0, 0, 0, 0.05); --p3-fg: #5c6b65;
140
150
  --surface-drawer: #ffffff;
141
151
  --surface-translucent: rgba(255, 255, 255, 0.72);
@@ -1389,7 +1399,16 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1389
1399
  background: var(--text3); flex-shrink: 0;
1390
1400
  }
1391
1401
  .pl-stage.idle .pl-name .dot { background: var(--text3); }
1392
- .pl-stage.active .pl-name .dot { background: var(--status-progress); animation: pl-pulse 1.4s infinite; }
1402
+ /* Motion means the data is live.
1403
+
1404
+ A pulsing dot says "this is happening right now". Three stages pulsed on a
1405
+ rail whose newest timestamp was 260 hours old — the strongest liveness signal
1406
+ the design has, spent on work that stopped eleven days ago. The animation is
1407
+ now gated on `.fresh`, which the renderer adds only when the stage's own
1408
+ timestamp is inside the hour. An `active` stage that has not moved keeps its
1409
+ colour and loses its heartbeat. */
1410
+ .pl-stage.active .pl-name .dot { background: var(--status-progress); }
1411
+ .pl-stage.active.fresh .pl-name .dot { animation: pl-pulse 1.4s infinite; }
1393
1412
  .pl-stage.done .pl-name .dot { background: var(--status-review); }
1394
1413
  .pl-stage.failed .pl-name .dot { background: var(--status-blocked); }
1395
1414
  @keyframes pl-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); } 50% { box-shadow: 0 0 0 5px rgba(245, 158, 11, 0); } }
@@ -1471,7 +1490,19 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1471
1490
  border-radius: 8px;
1472
1491
  display: inline-flex; align-items: center; justify-content: center;
1473
1492
  }
1474
- .pl-gate.active .pl-name .gate-glyph { animation: pl-pulse 1.4s infinite; }
1493
+ .pl-gate.active.fresh .pl-name .gate-glyph { animation: pl-pulse 1.4s infinite; }
1494
+
1495
+ /* Reduced motion, declared AFTER the pulse rules on purpose.
1496
+
1497
+ It was first written into the reduced-motion block near the top of this file,
1498
+ where `css-cascade` immediately reported it: same specificity as the two rules
1499
+ above, and the later rule wins, so `animation: none` never applied and a reader
1500
+ who had asked for less movement still got a pulsing dot. The check exists for
1501
+ exactly this, and it caught it within the minute. */
1502
+ @media (prefers-reduced-motion: reduce) {
1503
+ .pl-stage.active.fresh .pl-name .dot,
1504
+ .pl-gate.active.fresh .pl-name .gate-glyph { animation: none; }
1505
+ }
1475
1506
  /* The gate with nothing pending is a node like any other idle stage — the tinted
1476
1507
  card is reserved for the state that actually holds the pipeline, so a clear
1477
1508
  gate stops out-shouting the stages that ran. */
@@ -1657,6 +1688,79 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1657
1688
  }
1658
1689
 
1659
1690
  /* ── Resume card ──────────────────────────────────────────────────────── */
1691
+ /* Project status — one disclosure for everything the operator cannot act on. */
1692
+ /* Focus, visible against every surface it can land on.
1693
+
1694
+ Queue rows became keyboard-reachable and the buttons on them are real buttons,
1695
+ but nothing drew a ring — so a keyboard user could reach a row and not see
1696
+ where they were. `outline-offset: 2px` is not decoration: `.gate-approve` is a
1697
+ filled green button, and a ring drawn ON that fill disappears into it. Offset
1698
+ puts the ring on the surface behind, where it keeps its contrast. */
1699
+ .inbox-row:focus-visible,
1700
+ .gate-btn:focus-visible,
1701
+ .ac-btn:focus-visible,
1702
+ .ac-primary:focus-visible {
1703
+ outline: 2px solid var(--focus-ring);
1704
+ outline-offset: 2px;
1705
+ border-radius: 8px;
1706
+ }
1707
+
1708
+ /* Reason chip — why a row is in the queue, carried by the row itself. */
1709
+ .why {
1710
+ display: inline-block;
1711
+ font-family: var(--mono);
1712
+ font-size: var(--fs-eyebrow);
1713
+ letter-spacing: 0.04em;
1714
+ padding: 1px 7px;
1715
+ margin-right: 8px;
1716
+ border-radius: 5px;
1717
+ border: 1px solid var(--border-strong);
1718
+ color: var(--text2);
1719
+ vertical-align: 1px;
1720
+ }
1721
+ .why-gate { color: var(--status-gate); border-color: var(--status-gate); }
1722
+ .why-p0 { color: var(--status-blocked); border-color: var(--status-blocked); }
1723
+ .why-blocked { color: var(--status-progress); border-color: var(--status-progress); }
1724
+ .why-stale { color: var(--text3); }
1725
+
1726
+ .proj-status {
1727
+ border: 1px solid var(--border);
1728
+ border-radius: 12px;
1729
+ background: var(--bg-card);
1730
+ margin: 10px 0 18px;
1731
+ }
1732
+ .proj-status-summary {
1733
+ cursor: pointer;
1734
+ padding: 11px 16px;
1735
+ font-size: var(--fs-small);
1736
+ color: var(--text2);
1737
+ list-style: none;
1738
+ display: flex; align-items: center; gap: 8px;
1739
+ }
1740
+ .proj-status-summary::-webkit-details-marker { display: none; }
1741
+ .proj-status-summary::before {
1742
+ content: "\25B8";
1743
+ color: var(--text3);
1744
+ transition: transform 0.12s;
1745
+ }
1746
+ .proj-status[open] .proj-status-summary::before { transform: rotate(90deg); }
1747
+ .proj-status-summary:hover { color: var(--text); }
1748
+ .proj-status-summary:focus-visible {
1749
+ outline: 2px solid var(--focus-ring);
1750
+ outline-offset: 2px;
1751
+ border-radius: 8px;
1752
+ }
1753
+ /* A link with no colour rule gets the browser's default, which is on nobody's
1754
+ palette. This one — "stale, listed below" in the empty resume column — was
1755
+ added today without one, and the rendered-layout check found it on both themes
1756
+ within the hour. That is the check working on its author. */
1757
+ .resume-list a, .empty a { color: var(--accent-text); text-decoration: underline; }
1758
+ .resume-list a:hover, .empty a:hover { color: var(--accent); }
1759
+
1760
+ .proj-status-verdict { color: var(--text3); font-family: var(--mono); font-size: var(--fs-eyebrow); }
1761
+ .proj-status-body { padding: 0 16px 14px; }
1762
+ .proj-status-body .inbox-section { margin-top: 6px; }
1763
+
1660
1764
  .resume-card {
1661
1765
  background: linear-gradient(135deg, rgba(0, 217, 126, 0.04), var(--surface-translucent));
1662
1766
  border: 1px solid rgba(0, 217, 126, 0.18);
@@ -2022,7 +2126,11 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2022
2126
  way, the reason is available on hover and to a screen reader. Sized down from
2023
2127
  whatever it sits in, so a missing hero figure does not shout at 52px. */
2024
2128
  .absent {
2025
- font-family: var(--mono); font-size: 0.55em;
2129
+ /* `0.55em` is a size relative to whatever it lands inside. Next to a 52px
2130
+ display numeral that resolves to 28.6px — a step the ramp does not have, and
2131
+ the rendered-layout check flagged it on two screens. The "n/a" beside a
2132
+ figure is a caption; it is sized like one. */
2133
+ font-family: var(--mono); font-size: var(--fs-caption);
2026
2134
  color: var(--text3); letter-spacing: 0.04em;
2027
2135
  cursor: help; border-bottom: 1px dotted var(--border-strong);
2028
2136
  }
@@ -2912,14 +3020,26 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2912
3020
  <div class="panel active" id="panel-inbox">
2913
3021
  <div class="inbox-page">
2914
3022
  <div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
2915
- <div class="inbox-summary" id="inbox-summary"></div>
2916
3023
  <!-- Top rung of the evidence ladder: are the bytes that were reviewed
2917
3024
  still the bytes in the tree? Hidden while they match — and now only
2918
3025
  used for `differs`, the one reading that needs a decision. The other
2919
3026
  two speaking states render into #inbox-receipt-foot at the bottom of
2920
3027
  this page (see refreshReceipt). -->
2921
3028
  <div id="inbox-receipt" style="display:none;margin:6px 0 2px;font-size:var(--fs-small)"></div>
2922
-
3029
+ <!-- Everything below is STATUS, and status is not a decision.
3030
+
3031
+ Six of the seven blocks on this screen were read-only: a pipeline rail,
3032
+ a resume card, two advisory notices, a strip of stat tiles. They sat
3033
+ ABOVE the one item the operator could act on, under a headline promising
3034
+ decisions. The rule the design spec states — a block earns the top of a
3035
+ screen only if the operator can act on it now — puts all of it here,
3036
+ one disclosure, closed by default.
3037
+
3038
+ Not deleted. "How is the project" is a real question; it is simply not
3039
+ the question this screen is named after. -->
3040
+ <details class="proj-status" id="proj-status">
3041
+ <summary class="proj-status-summary">Project status<span class="proj-status-verdict" id="proj-status-verdict"></span></summary>
3042
+ <div class="proj-status-body">
2923
3043
  <!-- Resume — pick up where you left off -->
2924
3044
  <div class="resume-card" id="resume-card" style="display:none">
2925
3045
  <div class="resume-head">
@@ -2944,6 +3064,20 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2944
3064
  </div>
2945
3065
  </div>
2946
3066
  </div>
3067
+ <div id="tool-failure-rate" style="display:none;font-size:var(--fs-small);color:var(--status-blocked,#f87171);background:rgba(248,113,113,0.08);padding:6px 10px;border-radius:6px;margin-bottom:10px;"></div>
3068
+
3069
+ <div class="inbox-section">
3070
+ <div class="inbox-section-head">
3071
+ <span class="dot dot-blue"></span>
3072
+ <h3>Active pipeline</h3>
3073
+ <span class="inbox-count" id="pipeline-status">idle</span>
3074
+ <span class="tier-badge" id="tier-badge" title="change_tier for the current working-tree diff — which gates + judge open (ADR-003/004)"></span>
3075
+ </div>
3076
+ <div class="pipeline-track" id="pipeline-track"></div>
3077
+ </div>
3078
+ </div>
3079
+ </details>
3080
+
2947
3081
 
2948
3082
  <!-- Project archetype / compliance / phase used to be a full-width mono
2949
3083
  row here, between the KPI tiles and the pipeline. On a screen whose
@@ -2956,24 +3090,27 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2956
3090
  Not deleted: `PROJECT.md` is not in the Docs list, so compliance and
2957
3091
  phase would have become unreachable. -->
2958
3092
  <!-- Tool failure rate watchdog warning -->
2959
- <div id="tool-failure-rate" style="display:none;font-size:var(--fs-small);color:var(--status-blocked,#f87171);background:rgba(248,113,113,0.08);padding:6px 10px;border-radius:6px;margin-bottom:10px;"></div>
3093
+ <!-- One queue, not four sections.
2960
3094
 
2961
- <div class="inbox-section">
2962
- <div class="inbox-section-head">
2963
- <span class="dot dot-blue"></span>
2964
- <h3>Active pipeline</h3>
2965
- <span class="inbox-count" id="pipeline-status">idle</span>
2966
- <span class="tier-badge" id="tier-badge" title="change_tier for the current working-tree diff — which gates + judge open (ADR-003/004)"></span>
2967
- </div>
2968
- <div class="pipeline-track" id="pipeline-track"></div>
2969
- </div>
2970
- <div class="inbox-section" id="inbox-gates-section">
3095
+ Gates, P0s, blocked and stale were four headed lists, each with its own
3096
+ count, on a screen that shows five to seven rows in total. The headings
3097
+ cost more vertical space than the rows and forced the reader to work out
3098
+ which bucket a thing was in before reading what it was.
3099
+
3100
+ Linear and GitHub both do the opposite: one ranked stream, and the reason
3101
+ an item is there rides ON the row as a chip. The reason is a property of
3102
+ the item, so it survives the item moving between buckets — which is what
3103
+ made an expired gate unrecognisable when it fell out of the gate list.
3104
+
3105
+ Order: gates first (a signature is owed), then oldest first, because the
3106
+ longest-ignored row is the one a triage list is read for. -->
3107
+ <div class="inbox-section" id="inbox-queue-section">
2971
3108
  <div class="inbox-section-head">
2972
3109
  <span class="dot dot-purple"></span>
2973
- <h3>Pending decisions</h3>
2974
- <span class="inbox-count" id="inbox-gates-count">0</span>
3110
+ <h3>Needs you</h3>
3111
+ <span class="inbox-count" id="inbox-queue-count">0</span>
2975
3112
  </div>
2976
- <div id="inbox-gates"></div>
3113
+ <div id="inbox-queue"></div>
2977
3114
  </div>
2978
3115
  <!-- Gates that did NOT wait for you. Placed directly under the decisions
2979
3116
  you still owe, because "what proceeded without me" is the question a
@@ -2986,30 +3123,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2986
3123
  </div>
2987
3124
  <div id="inbox-standdown"></div>
2988
3125
  </div>
2989
- <div class="inbox-section" id="inbox-p0-section">
2990
- <div class="inbox-section-head">
2991
- <span class="dot dot-red"></span>
2992
- <h3>P0 open</h3>
2993
- <span class="inbox-count" id="inbox-p0-count">0</span>
2994
- </div>
2995
- <div id="inbox-p0"></div>
2996
- </div>
2997
- <div class="inbox-section" id="inbox-blocked-section">
2998
- <div class="inbox-section-head">
2999
- <span class="dot dot-orange"></span>
3000
- <h3>Blocked</h3>
3001
- <span class="inbox-count" id="inbox-blocked-count">0</span>
3002
- </div>
3003
- <div id="inbox-blocked"></div>
3004
- </div>
3005
- <div class="inbox-section" id="inbox-stale-section">
3006
- <div class="inbox-section-head">
3007
- <span class="dot dot-amber"></span>
3008
- <h3>Stale (in progress &gt; 48h)</h3>
3009
- <span class="inbox-count" id="inbox-stale-count">0</span>
3010
- </div>
3011
- <div id="inbox-stale"></div>
3012
- </div>
3013
3126
  <!-- Idle focus: when nothing needs a decision, the four sections above collapse and this shows instead. -->
3014
3127
  <div class="inbox-allclear" id="inbox-allclear" style="display:none">
3015
3128
  <div class="ac-mark">✓</div>
@@ -3464,36 +3577,53 @@ function renderInbox(d) {
3464
3577
  Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
3465
3578
  // Same rule as the all-clear card: "nothing urgent" is a claim about data we
3466
3579
  // may not have. When a read failed, say that instead of reassuring the user.
3467
- const greetTail = anyDegraded()
3468
- ? "Some data could not be read — treat the counts below as incomplete."
3469
- : (s.gates + s.p0 ? "Here's what needs your decision." : 'Nothing urgent — back to deep work.');
3470
- document.getElementById('inbox-greet').textContent = `${greetByHour()} ${greetTail}`;
3471
- // Only the tiles that have something in them.
3580
+ // The headline states the SITUATION, not the time of day.
3472
3581
  //
3473
- // Four rendered unconditionally, and on an ordinary morning two of them read 0
3474
- // — half the header spending a quarter of the width each to say "nothing here".
3475
- // A zero is worth stating when it is the WHOLE answer, so the all-clear is one
3476
- // line instead of four boxes of noughts.
3477
- const TILES = [
3478
- { n: s.gates, dot: 'purple', lbl: 'Pending decisions' },
3479
- { n: s.p0, dot: 'red', lbl: 'P0 open' },
3480
- { n: s.blocked, dot: 'orange', lbl: 'Blocked' },
3481
- { n: s.stale, dot: 'amber', lbl: 'Stale &gt; 48h' },
3482
- ];
3483
- const liveTiles = TILES.filter((t) => Number(t.n) > 0);
3484
- document.getElementById('inbox-summary').innerHTML = liveTiles.length
3485
- ? liveTiles.map((t) => `<div class="pill-stat"><span class="dot dot-${t.dot}"></span>`
3486
- + `<div><div class="num">${t.n}</div><div class="lbl">${t.lbl}</div></div></div>`).join('')
3487
- : `<div class="pill-stat" style="grid-column:1/-1"><span class="dot dot-green"></span>`
3488
- + `<div><div class="lbl">No gates, no P0s, nothing blocked or stale.</div></div></div>`;
3489
- renderInboxList('inbox-gates', d?.pending_gates || [], 'inbox-gates-count', { showApprove: true });
3490
- // `implies` names the fact the section heading already states, so the rows
3491
- // below it stop repeating that fact on every card. "P0 open" states the
3492
- // priority; "Stale (in progress > 48h)" states the age. "Blocked" states
3493
- // neither, so its rows keep both.
3494
- renderInboxList('inbox-p0', d?.p0_open || [], 'inbox-p0-count', { implies: 'priority' });
3495
- renderInboxList('inbox-blocked', d?.blocked || [], 'inbox-blocked-count', {});
3496
- renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', { implies: 'age' });
3582
+ // "Good morning. Here's what needs your decision." sat above a screen whose
3583
+ // every timestamp was 11 to 30 days old. The most important fact available —
3584
+ // that nothing had moved in eleven days — was the one thing the page never
3585
+ // said. Borrowed from Datadog's monitor states, where "No Data" is an alert in
3586
+ // its own right rather than the absence of one: silence is a reading.
3587
+ //
3588
+ // Age is taken across everything the screen knows about, so a quiet queue with
3589
+ // a live pipeline reads differently from a project that has stopped.
3590
+ const newestEventAgeH = (() => {
3591
+ const stamps = [
3592
+ ...(d?.pending_gates || []), ...(d?.p0_open || []),
3593
+ ...(d?.blocked || []), ...(d?.stale_in_progress || []),
3594
+ ].map((t) => t?.updated_at).filter(Boolean)
3595
+ .concat((window.__pipelineStages || []).map((st) => st?.ts).filter(Boolean))
3596
+ .map((t) => Date.parse(t)).filter((n) => Number.isFinite(n));
3597
+ if (!stamps.length) return null;
3598
+ return (Date.now() - Math.max(...stamps)) / 3600e3;
3599
+ })();
3600
+
3601
+ const needs = s.gates + s.p0;
3602
+ const STALL_H = 72; // the same 72h the gate-expiry hook uses
3603
+ const greetEl = document.getElementById('inbox-greet');
3604
+ greetEl.setAttribute('role', 'status');
3605
+ greetEl.setAttribute('aria-live', 'polite');
3606
+
3607
+ if (anyDegraded()) {
3608
+ // A read failed. Reassurance would be a claim about data we do not have.
3609
+ greetEl.textContent = 'Some data could not be read — treat the counts below as incomplete.';
3610
+ } else if (newestEventAgeH != null && newestEventAgeH > STALL_H) {
3611
+ const days = Math.floor(newestEventAgeH / 24);
3612
+ greetEl.textContent = needs
3613
+ ? `Stalled — nothing has moved in ${days} days, and ${needs} item${needs > 1 ? 's' : ''} ${needs > 1 ? 'are' : 'is'} waiting on you.`
3614
+ : `Stalled — nothing has moved in ${days} days, and nothing is waiting on you either.`;
3615
+ } else if (needs) {
3616
+ greetEl.textContent = "Here's what needs your decision.";
3617
+ } else {
3618
+ greetEl.textContent = newestEventAgeH == null
3619
+ ? 'Nothing recorded yet for this project.'
3620
+ : `All clear — last activity ${relTime(new Date(Date.now() - newestEventAgeH * 3600e3).toISOString())}.`;
3621
+ }
3622
+ // The stat-tile row is gone: two of its four boxes read 0 on an ordinary
3623
+ // morning, and the two that did not repeated a count the section headings
3624
+ // below already carry. The counts still reach the nav badge.
3625
+ renderQueue(d);
3626
+ renderStatusVerdict(d, newestEventAgeH);
3497
3627
  refreshStandDowns();
3498
3628
  refreshReceipt();
3499
3629
  refreshBudgetLine();
@@ -3506,11 +3636,122 @@ function renderInbox(d) {
3506
3636
  // zero and the card used to headline "Nothing needs your decision" — maximum
3507
3637
  // confidence at exactly the moment we knew least. Absence of findings is not
3508
3638
  // a finding of absence.
3509
- if (ac) ac.style.display = (attention === 0 && !anyDegraded()) ? '' : 'none';
3639
+ //
3640
+ // And "nothing needs you" has TWO readings that deserve different words.
3641
+ //
3642
+ // quiet because the work is done → rest, and the actions offer more work
3643
+ // quiet because nothing is running → that is not rest, it is a stopped
3644
+ // machine, and "back to deep work" is
3645
+ // the wrong thing to say to it
3646
+ //
3647
+ // Today's board is the second, and until now the screen had no way to say so:
3648
+ // an empty queue rendered the same congratulation either way.
3649
+ if (ac) {
3650
+ const show = attention === 0 && !anyDegraded();
3651
+ ac.style.display = show ? '' : 'none';
3652
+ if (show) {
3653
+ const stalled = newestEventAgeH != null && newestEventAgeH > STALL_H;
3654
+ const txt = ac.querySelector('.ac-text');
3655
+ const mark = ac.querySelector('.ac-mark');
3656
+ if (stalled) {
3657
+ const days = Math.floor(newestEventAgeH / 24);
3658
+ if (mark) mark.textContent = '·';
3659
+ if (txt) txt.innerHTML = `<b>Nothing is waiting on you.</b> Nothing has run in `
3660
+ + `${days} days either — this project is idle, not finished.`;
3661
+ } else {
3662
+ if (mark) mark.textContent = '\u2713';
3663
+ if (txt) txt.innerHTML = '<b>All clear.</b> Nothing needs your decision — back to deep work.';
3664
+ }
3665
+ }
3666
+ }
3510
3667
  renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
3511
3668
  'Some project data could not be read — counts below are incomplete.');
3512
3669
  }
3513
3670
 
3671
+ /**
3672
+ * The line a closed disclosure says out loud.
3673
+ *
3674
+ * "Project status" on its own is a label, not an answer: closed, it told the
3675
+ * reader nothing, so the only way to learn anything was to open it — which is
3676
+ * the cost the disclosure was supposed to remove. It now carries the verdict, and
3677
+ * the reader opens it only when the verdict is worth chasing.
3678
+ */
3679
+ function renderStatusVerdict(d, ageH) {
3680
+ const el = document.getElementById('proj-status-verdict');
3681
+ if (!el) return;
3682
+ const parts = [];
3683
+ if (ageH == null) parts.push('no activity recorded');
3684
+ else if (ageH < 1) parts.push(`last activity ${Math.max(1, Math.round(ageH * 60))}m ago`);
3685
+ else if (ageH < 48) parts.push(`last activity ${Math.round(ageH)}h ago`);
3686
+ else parts.push(`idle ${Math.floor(ageH / 24)}d`);
3687
+
3688
+ const stages = window.__pipelineStages || [];
3689
+ const active = stages.filter((x) => x && x.status === 'active').length;
3690
+ if (stages.length) parts.push(active ? `${active} stage${active > 1 ? 's' : ''} active` : 'pipeline idle');
3691
+
3692
+ // A budget that cannot be measured is not a budget that is fine.
3693
+ const budget = document.getElementById('inbox-budget-line');
3694
+ if (budget) parts.push('budgets unmeasured');
3695
+
3696
+ el.textContent = ' · ' + parts.join(' · ');
3697
+ }
3698
+
3699
+ /**
3700
+ * The one queue. Extracted from renderInbox because the receipt fetch finishes
3701
+ * AFTER the first render and has to put its row in — and calling renderInbox from
3702
+ * refreshReceipt, which renderInbox itself calls, is an infinite loop. Rendering
3703
+ * only the list it needs to change is both correct and cheaper.
3704
+ */
3705
+ function renderQueue(d) {
3706
+ // One queue. The reason an item is here travels ON the item, so it survives the
3707
+ // item changing buckets — the failure that made an expired gate unreadable when
3708
+ // it dropped out of `pending_gates`.
3709
+ const ageD = (t) => {
3710
+ const h = ageHours(t?.updated_at);
3711
+ return h == null ? null : Math.floor(h / 24);
3712
+ };
3713
+ const withReason = (list, reason) => (list || []).map((t) => ({
3714
+ ...t,
3715
+ __reason: typeof reason === 'function' ? reason(t) : reason,
3716
+ }));
3717
+ const queue = [
3718
+ // An expired gate is still a gate, and saying so is the whole point: the row
3719
+ // that lost its buttons for 699 hours looked like an ordinary P0.
3720
+ ...withReason(d?.pending_gates, (t) =>
3721
+ (t.raw_status === 'blocked' ? 'expired gate' : 'gate')),
3722
+ ...withReason(d?.p0_open, (t) => (t.is_gate ? 'expired gate' : 'P0')),
3723
+ ...withReason(d?.blocked, 'blocked'),
3724
+ ...withReason(d?.stale_in_progress, (t) => {
3725
+ const dd = ageD(t);
3726
+ return dd == null ? 'stale' : `stale ${dd}d`;
3727
+ }),
3728
+ ];
3729
+ // The receipt-drift row, when there is one. It is not a task, so it arrives as
3730
+ // a synthetic item rather than from any bucket.
3731
+ if (window.__receiptDrift) queue.push(window.__receiptDrift);
3732
+ // Gates first — a signature is owed. Then oldest first, because the
3733
+ // longest-ignored row is what a triage list is read for.
3734
+ queue.sort((a, b) => {
3735
+ const ga = a.is_gate ? 0 : 1, gb = b.is_gate ? 0 : 1;
3736
+ if (ga !== gb) return ga - gb;
3737
+ const ah = ageHours(a.updated_at), bh = ageHours(b.updated_at);
3738
+ if (ah == null) return 1;
3739
+ if (bh == null) return -1;
3740
+ return bh - ah;
3741
+ });
3742
+ renderInboxList('inbox-queue', queue, 'inbox-queue-count', { reasons: true });
3743
+ // The heading claims what the rows can deliver, and no more. "Needs you" over a
3744
+ // list of things you cannot touch is the same overclaim this screen spent the
3745
+ // day removing — it was simply mine.
3746
+ // "Needs you" means a decision only you can make — a gate to sign. Everything
3747
+ // else is work that continues elsewhere, and saying otherwise is the overclaim
3748
+ // that made six rows promise an action five of them did not have.
3749
+ const decidable = queue.some((t) => t.is_gate);
3750
+ const head = document.querySelector('#inbox-queue-section h3');
3751
+ if (head) head.textContent = queue.length === 0 ? 'Needs you'
3752
+ : (decidable ? 'Needs you' : 'Waiting on work elsewhere');
3753
+ }
3754
+
3514
3755
  /**
3515
3756
  * Does the last approval still describe what is in the tree?
3516
3757
  *
@@ -3569,11 +3810,38 @@ async function refreshReceipt() {
3569
3810
  //
3570
3811
  // Moved, not removed: same wording, same detail, same page, still unmissable
3571
3812
  // if you read to the bottom, and it still never renders as "checked and fine".
3813
+ // `differs` now becomes a ROW IN THE QUEUE rather than a line above it.
3814
+ //
3815
+ // The queue exists so that everything asking for the operator is in one place.
3816
+ // A purple line floating above it was a second place — smaller, but a reader
3817
+ // still had to notice it separately and decide whether it counted. It carries
3818
+ // the chip `review drift`, sorts with everything else, and is reachable by
3819
+ // keyboard like any other row.
3820
+ //
3821
+ // `unreadable` and `extended` stay in the footer. "Cannot check" is true and
3822
+ // careful and asks for nothing; putting it in a queue named for what needs you
3823
+ // would be the overclaim this whole screen has been unwinding.
3572
3824
  const actionable = d.state === 'differs';
3573
- const host = actionable ? el : (foot || el);
3574
- const colour = actionable ? 'var(--status-gate)' : 'var(--text3)';
3575
- host.style.display = '';
3576
- host.innerHTML = `<span style="color:${colour}">${body}</span>`;
3825
+ if (actionable) {
3826
+ el.style.display = 'none';
3827
+ window.__receiptDrift = {
3828
+ id: 'receipt',
3829
+ title: `${changedFiles.length} reviewed file(s) changed after approval`,
3830
+ desc: body,
3831
+ updated_at: d.ts || null,
3832
+ __reason: 'review drift',
3833
+ __synthetic: true,
3834
+ __files: changedFiles,
3835
+ };
3836
+ } else {
3837
+ window.__receiptDrift = null;
3838
+ const host = foot || el;
3839
+ host.style.display = '';
3840
+ host.innerHTML = `<span style="color:var(--text3)">${body}</span>`;
3841
+ }
3842
+ // The queue was rendered before this fetch returned, so it has to be rebuilt
3843
+ // with the row in it. Silent if the Inbox is not the open panel.
3844
+ if (window.__inboxData) renderQueue(window.__inboxData);
3577
3845
  }
3578
3846
 
3579
3847
  /**
@@ -3748,7 +4016,8 @@ function renderInboxList(rootId, items, countId, opts = {}) {
3748
4016
  // longest-ignored row at the top, which is the order a triage list is read in.
3749
4017
  // Gates keep their server order: their own list is already time-ordered and
3750
4018
  // their ⏳ stamp is the loud field.
3751
- const rows = opts.showApprove
4019
+ // A queue built with reasons arrives already ranked; re-sorting would undo it.
4020
+ const rows = (opts.showApprove || opts.reasons)
3752
4021
  ? items
3753
4022
  : items.slice().sort((a, b) => {
3754
4023
  const ah = ageHours(a.updated_at), bh = ageHours(b.updated_at);
@@ -3779,7 +4048,7 @@ function renderInboxList(rootId, items, countId, opts = {}) {
3779
4048
  alsoTag,
3780
4049
  // Gates (the showApprove list) get the exact date+time the decision has been
3781
4050
  // waiting since; other rows carry their age in the right-hand column instead.
3782
- opts.showApprove && t.updated_at
4051
+ (opts.showApprove || t.is_gate === true) && t.updated_at
3783
4052
  ? `<span title="${esc(fmtDate(t.updated_at))}" style="color:var(--status-gate);font-weight:600">⏳ ${fmtDT(t.updated_at)}</span>`
3784
4053
  : '',
3785
4054
  ].filter(Boolean).join(' · ');
@@ -3793,7 +4062,43 @@ function renderInboxList(rootId, items, countId, opts = {}) {
3793
4062
  // this change is removing.
3794
4063
  const hrs = ageHours(t.updated_at);
3795
4064
  const aged = !opts.showApprove && opts.implies !== 'age' && hrs != null && hrs >= 48;
3796
- const actions = opts.showApprove
4065
+ // A gate can be approved because it IS a gate — not because of which list it
4066
+ // happened to land in.
4067
+ //
4068
+ // `gate-expiry.mjs` marks a gate `blocked` once it has waited 72 hours.
4069
+ // `getInbox` excludes blocked gates from `pending_gates`, so the item drops
4070
+ // into `p0_open`, which renders without `showApprove` — and loses its
4071
+ // Approve/Reject buttons. The hook built to draw attention to a neglected
4072
+ // gate quietly removed the only way to answer it. Observed on a gate that had
4073
+ // then waited 699 hours with nothing on screen able to end that wait.
4074
+ //
4075
+ // The affordance now follows `is_gate`, which is a fact about the task rather
4076
+ // than about the query that fetched it.
4077
+ const canDecide = opts.showApprove || t.is_gate === true;
4078
+ // A row with no action is a row that says "needs you" and means "look at me".
4079
+ //
4080
+ // Merging four sections under one "Needs you" heading made that promise for
4081
+ // all of them, and five of six rows could not be acted on from this board at
4082
+ // all: the only write action it has is approving a gate. The operator's words
4083
+ // were "но я не могу ничего сделать" — and they were right.
4084
+ //
4085
+ // The board does not dispatch agents; it hands you the line that does. That is
4086
+ // the same copy-not-dispatch the pipeline rail already uses on a failed stage,
4087
+ // and it is the honest affordance for a task that has sat for a month: this
4088
+ // cannot be finished here, here is what finishes it.
4089
+ // The drift row is not a task and has no agent, so it needs its own line: the
4090
+ // one that shows what changed under an approval. Files come from the receipt.
4091
+ const diffBtn = (t.__synthetic && Array.isArray(t.__files) && t.__files.length)
4092
+ ? `<div class="actions" onclick="event.stopPropagation()">`
4093
+ + `<button type="button" class="gate-btn" title="Copy a git diff limited to the reviewed files"`
4094
+ + ` onclick="event.stopPropagation(); copyDiff(${esc(JSON.stringify(JSON.stringify(t.__files)))})">Copy diff</button></div>`
4095
+ : '';
4096
+ const rerun = (!canDecide && !t.__synthetic && t.agent && t.id)
4097
+ ? `<div class="actions" onclick="event.stopPropagation()">`
4098
+ + `<button type="button" class="gate-btn" title="Copy the line that re-runs this agent"`
4099
+ + ` onclick="event.stopPropagation(); copyRerun('${esc(t.agent)}')">Copy re-run</button></div>`
4100
+ : '';
4101
+ const actions = canDecide
3797
4102
  ? `<div class="actions" onclick="event.stopPropagation()">
3798
4103
  <button class="gate-btn gate-approve" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'approve')">Approve</button>
3799
4104
  <button class="gate-btn gate-reject" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'reject')">Reject</button>
@@ -3814,12 +4119,26 @@ function renderInboxList(rootId, items, countId, opts = {}) {
3814
4119
  ? `<span class="ttl-desc"${ctx.wire ? ` title="${esc(ctx.wire)}"` : ''}>${esc(shown.slice(0, 140))}${shown.length > 140 ? '…' : ''}</span>`
3815
4120
  : '';
3816
4121
  // Only gate rows can stand down, so only they carry the note.
3817
- const tierNote = opts.showApprove ? gateTierNote(t) : '';
4122
+ const tierNote = (opts.showApprove || t.is_gate === true) ? gateTierNote(t) : '';
4123
+ // Reachable by keyboard. Every row in this queue was a bare `div` with an
4124
+ // onclick — no tabindex, no role, no key handler — so the whole list of
4125
+ // things needing the operator could be seen and never opened without a
4126
+ // mouse. Enter and Space now do what a click does, and the row announces
4127
+ // itself as a button instead of as anonymous text.
4128
+ // Why this row is here, as a property of the row. Four headed sections used to
4129
+ // carry this fact in their headings, where it was lost the moment an item moved
4130
+ // between them — which is how an expired gate became indistinguishable from an
4131
+ // ordinary P0 for 699 hours.
4132
+ const REASON_TONE = { 'expired gate': 'gate', gate: 'gate', P0: 'p0', blocked: 'blocked' };
4133
+ const tone = REASON_TONE[t.__reason] || (String(t.__reason || '').startsWith('stale') ? 'stale' : '');
4134
+ const reasonChip = (opts.reasons && t.__reason)
4135
+ ? `<span class="why why-${tone}">${esc(t.__reason)}</span>`
4136
+ : '';
3818
4137
  return `
3819
- <div class="inbox-row${aged ? ' row-aged' : ''}" onclick='openSide(${JSON.stringify(t).replace(/'/g, "&apos;")})'>
4138
+ <div class="inbox-row${aged ? ' row-aged' : ''}" 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;")})'>
3820
4139
  <span class="id">${esc(t.id || '')}</span>
3821
- <span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
3822
- ${actions}
4140
+ <span class="ttl">${reasonChip}${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
4141
+ ${actions}${rerun}${diffBtn}
3823
4142
  </div>`;
3824
4143
  }).join('');
3825
4144
  }
@@ -4677,7 +4996,7 @@ function renderPipeline(stages) {
4677
4996
  ? `oldest ${s.age_min < 60 ? s.age_min + 'm' : Math.round(s.age_min/60)+'h'}`
4678
4997
  : 'waiting';
4679
4998
  return `
4680
- <div class="pl-stage pl-gate active" title="Human gate — irreversible actions need a signature here. Click to see pending gates." onclick="drillToGates()">
4999
+ <div class="pl-stage pl-gate active${(s.age_min != null && s.age_min < 60) ? ' fresh' : ''}" title="Human gate — irreversible actions need a signature here. Click to see pending gates." onclick="drillToGates()">
4681
5000
  <div class="pl-name"><span class="gate-glyph">✍</span>${label}<span class="gate-badge">${s.pending}</span></div>
4682
5001
  ${s.last_message ? `<div class="pl-msg">${esc(s.last_message)}</div>` : ''}
4683
5002
  <div class="pl-meta">${meta}</div>
@@ -4698,6 +5017,9 @@ function renderPipeline(stages) {
4698
5017
  // age, which is what the reader was looking at anyway.
4699
5018
  const msg = s.last_message ? `<div class="pl-msg">${esc(s.last_message)}</div>` : '';
4700
5019
  const meta = s.age_min != null ? `${s.age_min < 60 ? s.age_min + 'm' : Math.round(s.age_min/60)+'h'} ago` : '';
5020
+ // Within the hour is live; anything older is a record of something that
5021
+ // finished. Only the first earns the heartbeat.
5022
+ const fresh = s.age_min != null && s.age_min < 60 ? ' fresh' : '';
4701
5023
  // A failed stage offered one affordance: filter the Kanban by it. That is
4702
5024
  // the same thing every other stage offers, and it is not what you want when
4703
5025
  // something broke. GitHub Actions puts "Re-run failed jobs" on the failure
@@ -4712,7 +5034,7 @@ function renderPipeline(stages) {
4712
5034
  title="Copy the command that re-runs this stage">re-run</button>
4713
5035
  </div>` : '';
4714
5036
  return `
4715
- <div class="pl-stage ${s.status}" title="Click to filter Kanban by ${esc(s.stage)}" onclick="drillToStage('${esc(s.stage)}')">
5037
+ <div class="pl-stage ${s.status}${fresh}" title="Click to filter Kanban by ${esc(s.stage)}" onclick="drillToStage('${esc(s.stage)}')">
4716
5038
  <div class="pl-name"><span class="dot"></span>${label}</div>
4717
5039
  ${msg}
4718
5040
  <div class="pl-meta">${meta || s.status}</div>
@@ -4747,6 +5069,17 @@ function copyRerun(stage) {
4747
5069
  );
4748
5070
  }
4749
5071
 
5072
+ /** The same copy-not-dispatch: the board shows what changed, it does not open it. */
5073
+ function copyDiff(filesJson) {
5074
+ let files = [];
5075
+ try { files = JSON.parse(filesJson); } catch { /* nothing to scope to */ }
5076
+ const cmd = files.length ? `git diff -- ${files.join(' ')}` : 'git diff';
5077
+ navigator.clipboard.writeText(cmd).then(
5078
+ () => showToast(`Copied <code>${esc(cmd.slice(0, 70))}…</code>`, 'success', 3000),
5079
+ () => showToast(`Run <code>${esc(cmd.slice(0, 70))}</code>`, 'info', 4000),
5080
+ );
5081
+ }
5082
+
4750
5083
  // Click the human-gate node → jump to the gates/inbox view where they get signed.
4751
5084
  function drillToGates() {
4752
5085
  const search = document.getElementById('search-input');
@@ -5069,6 +5402,52 @@ async function refreshDocsCount() {
5069
5402
  el.textContent = (d && !d.error && typeof d.total === 'number') ? d.total : '—';
5070
5403
  }
5071
5404
 
5405
+ // How many documents cite this one, and the order of the list.
5406
+ //
5407
+ // 105 of 156 documents have no inbound reference. A badge on two rows in three
5408
+ // marks nothing — so the count itself is the signal, and sorting by it answers
5409
+ // "where would I find X" while letting the zeroes surface without being shouted
5410
+ // at. "not measured" is not a zero: it means the document sits outside the link
5411
+ // graph, which is a different fact from being cited by nobody.
5412
+ let _docsSort = 'recent';
5413
+
5414
+ function setDocsSort(mode) {
5415
+ _docsSort = mode;
5416
+ const host = document.getElementById('docs-groups');
5417
+ if (host && _docsCache) host.innerHTML = renderDocGroups(_docsCache.groups || []);
5418
+ }
5419
+
5420
+ function citedLabel(doc) {
5421
+ if (doc.inbound === null || doc.inbound === undefined) {
5422
+ return '<span class="muted absent" title="Outside docs/ — citations were not measured">not measured</span>';
5423
+ }
5424
+ return `<span class="muted">cited by ${doc.inbound}</span>`;
5425
+ }
5426
+
5427
+ function renderDocGroups(groups) {
5428
+ const sorted = (docs) => _docsSort === 'cited'
5429
+ ? docs.slice().sort((a, b) => (b.inbound ?? -1) - (a.inbound ?? -1) || a.title.localeCompare(b.title))
5430
+ : docs.slice().sort((a, b) => String(b.modified).localeCompare(String(a.modified)));
5431
+
5432
+ const control = `<div class="card" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
5433
+ <b>Sort</b>
5434
+ <button type="button" class="ab-btn" aria-pressed="${_docsSort === 'recent'}" onclick="setDocsSort('recent')">Recently changed</button>
5435
+ <button type="button" class="ab-btn" aria-pressed="${_docsSort === 'cited'}" onclick="setDocsSort('cited')">Most cited</button>
5436
+ <span class="muted" style="font-size:var(--fs-caption)">How many other documents link here.</span>
5437
+ </div>`;
5438
+
5439
+ return control + groups.map(g => `<div class="card">
5440
+ <b>${esc(g.label)}</b> <span class="muted">${g.docs.length} — ${esc(g.why || '')}</span>
5441
+ <div style="margin-top:8px">${sorted(g.docs).map(doc => `
5442
+ <div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
5443
+ <a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
5444
+ <span class="muted" style="font-size:var(--fs-caption)"> · ${esc(doc.path)} · ${ago(doc.modified)} · </span>
5445
+ <span style="font-size:var(--fs-caption)">${citedLabel(doc)}</span>
5446
+ ${freshnessBadge(doc)}
5447
+ </div>`).join('')}</div>
5448
+ </div>`).join('');
5449
+ }
5450
+
5072
5451
  async function loadDocs() {
5073
5452
  const el = document.getElementById('docs-body');
5074
5453
  el.innerHTML = '<span class="muted">loading…</span>';
@@ -5106,15 +5485,7 @@ async function loadDocs() {
5106
5485
  <details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
5107
5486
  <pre style="white-space:pre-wrap;font-size:var(--fs-caption)">${esc(d.map.mermaid)}</pre></details></div>` : '';
5108
5487
 
5109
- const groups = (d.groups || []).map(g => `<div class="card">
5110
- <b>${esc(g.label)}</b> <span class="muted">${g.docs.length} — ${esc(g.why || '')}</span>
5111
- <div style="margin-top:8px">${g.docs.map(doc => `
5112
- <div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
5113
- <a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
5114
- <span class="muted" style="font-size:var(--fs-caption)"> · ${esc(doc.path)} · ${ago(doc.modified)}</span>
5115
- ${freshnessBadge(doc)}
5116
- </div>`).join('')}</div>
5117
- </div>`).join('');
5488
+ const groups = renderDocGroups(d.groups || []);
5118
5489
 
5119
5490
  // Memory used to be its own tab listing PROJECT.md, CODEBASE.md and lessons —
5120
5491
  // the same files this panel's "This project" group already showed. One section
@@ -5124,7 +5495,7 @@ async function loadDocs() {
5124
5495
 
5125
5496
  // The document itself opens in the side panel now, so this panel no longer
5126
5497
  // needs a place to append one.
5127
- el.innerHTML = pipeline + map + mem + groups;
5498
+ el.innerHTML = pipeline + map + mem + `<div id="docs-groups">${groups}</div>`;
5128
5499
  }
5129
5500
 
5130
5501
  /**
@@ -5193,12 +5564,18 @@ async function loadContextLayers() {
5193
5564
  }
5194
5565
 
5195
5566
  const layers = (m.layers || []).map(l => {
5196
- const global = String(l.path || '').includes('/.great_cto/') && !String(l.path || '').includes(projectRootHint());
5197
- const where = global ? ' <span class="muted" style="font-size:var(--fs-caption)">shared by every project</span>' : '';
5567
+ // The reader sees `displayPath` — `.great_cto/PROJECT.md`, or `~/.great_cto/…`
5568
+ // for a shared layer. The absolute path stays behind openDoc(), because it
5569
+ // names this machine's home directory and username and this screen is
5570
+ // photographed for the README. `scope` comes from the server, which knows;
5571
+ // this used to be guessed by matching the breadcrumb text against the path.
5572
+ const shared = l.scope === 'global';
5573
+ const where = shared ? ' <span class="muted" style="font-size:var(--fs-caption)">shared by every project</span>' : '';
5574
+ const shown = l.displayPath || l.name || l.id;
5198
5575
  return l.exists
5199
5576
  ? `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
5200
5577
  <a href="#" onclick="openDoc('${esc(l.path)}');return false">${esc(l.id)}</a>${where}
5201
- <span class="muted" style="font-size:var(--fs-caption)"> · ${esc(l.path)}</span></div>`
5578
+ <span class="muted" style="font-size:var(--fs-caption)"> · ${esc(shown)}</span></div>`
5202
5579
  : `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)" class="muted">
5203
5580
  ${esc(l.id)} — not written yet${where}</div>`;
5204
5581
  }).join('');
@@ -5214,12 +5591,6 @@ async function loadContextLayers() {
5214
5591
  <div style="margin-top:8px">${pats}</div></div>` : '');
5215
5592
  }
5216
5593
 
5217
- /** The selected project's root, for telling a shared layer from a local one. */
5218
- function projectRootHint() {
5219
- const crumb = document.querySelector('.crumb-project, #crumb-project');
5220
- return (crumb && crumb.textContent || '').trim() || '\u0000';
5221
- }
5222
-
5223
5594
  /** The pipeline as boxes and gates — no diagram library, so it always renders. */
5224
5595
  // Named `renderDeclaredPipeline`, not `renderPipeline`, because there is already
5225
5596
  // a `renderPipeline(stages)` above that draws the LIVE rail. Both were declared
@@ -6115,7 +6486,11 @@ function cardHTML(t) {
6115
6486
  const pIdx = t.priority ?? 3;
6116
6487
  const pClass = `p${pIdx}`;
6117
6488
  const pLabel = PRIORITY_LABEL[pIdx];
6118
- const labels = (t.labels || []).filter(l => l !== 'gate');
6489
+ // The agent is stored in BOTH `agent` and `labels` — deliberately, so filtering
6490
+ // by label finds it — and the card drew a chip from each, so every card carried
6491
+ // its owner's name twice. Keep the data; draw it once. The agent chip is the
6492
+ // one that stays, because it is the one that is styled as an owner.
6493
+ const labels = (t.labels || []).filter(l => l !== 'gate' && l !== t.agent);
6119
6494
  const isGate = !!t.is_gate || (t.labels || []).includes('gate');
6120
6495
  // raw_status reflects bd's actual status (open/in_progress/closed/blocked) —
6121
6496
  // mapStatus() returns 'gate' for gate-labeled cards regardless, so we need