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.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/fixtures/fake-bd-fail.sh +9 -0
- package/board/packages/board/fixtures/fake-bd-slow.sh +12 -0
- package/board/packages/board/lib/alert-recurrence.mjs +73 -0
- package/board/packages/board/lib/alerts.mjs +42 -9
- package/board/packages/board/lib/beads.mjs +120 -15
- package/board/packages/board/lib/data-readers.mjs +8 -0
- package/board/packages/board/lib/docs.mjs +21 -0
- package/board/packages/board/public/index.html +485 -110
- package/board/packages/board/server.mjs +27 -20
- package/board/scripts/lib/doc-links.mjs +103 -0
- package/dist/board-daemon.js +29 -8
- package/dist/main.js +68 -1
- package/package.json +1 -1
- package/postinstall.mjs +49 -0
|
@@ -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: #
|
|
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: #
|
|
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: #
|
|
138
|
-
--p2-bg: rgba(146, 102, 10, 0.12); --p2-fg: #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3093
|
+
<!-- One queue, not four sections.
|
|
2960
3094
|
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
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>
|
|
2974
|
-
<span class="inbox-count" id="inbox-
|
|
3110
|
+
<h3>Needs you</h3>
|
|
3111
|
+
<span class="inbox-count" id="inbox-queue-count">0</span>
|
|
2975
3112
|
</div>
|
|
2976
|
-
<div id="inbox-
|
|
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 > 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
|
-
|
|
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
|
-
//
|
|
3474
|
-
//
|
|
3475
|
-
//
|
|
3476
|
-
//
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
//
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "'")})'>
|
|
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, "'")})'>
|
|
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 || [])
|
|
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
|
-
|
|
5197
|
-
|
|
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(
|
|
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
|
-
|
|
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
|