great-cto 3.15.0 → 3.16.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.
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "great_cto",
|
|
3
3
|
"id": "great_cto",
|
|
4
4
|
"description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.16.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -114,8 +114,21 @@ function getPipeline(cwd = process.cwd()) {
|
|
|
114
114
|
// signature. Surface that checkpoint AS A STAGE in the pipeline, sitting just
|
|
115
115
|
// before the irreversible steps (devops/ship). It lights up when a gate is
|
|
116
116
|
// awaiting a human — so the operator always sees where the rails are.
|
|
117
|
+
// Awaiting a signature and blocked are not the same thing, and this said they
|
|
118
|
+
// were. `getInbox`'s pendingGates excludes blocked gates — deliberately, and
|
|
119
|
+
// under test: in a tasks.md project a gate marked `blocked` carried raw_status
|
|
120
|
+
// 'open' and never left the inbox. This filter excluded only done/closed, so
|
|
121
|
+
// the same gate the tile reported as 0 PENDING DECISIONS was announced here as
|
|
122
|
+
// 1 GATE AWAITING SIGNATURE. Two counts of one concept, on one screen,
|
|
123
|
+
// disagreeing over a single word.
|
|
124
|
+
//
|
|
125
|
+
// Dropping it from the rail would have been the other wrong answer: a gate
|
|
126
|
+
// stuck for 29 days is exactly what the rail exists to show. It is still
|
|
127
|
+
// shown, and now it is named for what it is.
|
|
117
128
|
const openGates = tasks.filter(t => t.is_gate && t.status !== 'done' && t.status !== 'closed' && t.raw_status !== 'closed');
|
|
118
|
-
const
|
|
129
|
+
const blockedGates = openGates.filter(t => t.raw_status === 'blocked' || t.status === 'blocked');
|
|
130
|
+
const awaitingGates = openGates.filter(t => !blockedGates.includes(t));
|
|
131
|
+
const pending = awaitingGates.length;
|
|
119
132
|
const newestGate = openGates.reduce((acc, t) => {
|
|
120
133
|
const ts = t.updated_at || t.created_at; return (!acc || (ts && ts > acc)) ? ts : acc;
|
|
121
134
|
}, null);
|
|
@@ -123,11 +136,15 @@ function getPipeline(cwd = process.cwd()) {
|
|
|
123
136
|
const gateNode = {
|
|
124
137
|
stage: 'human-gate',
|
|
125
138
|
is_human_gate: true,
|
|
126
|
-
|
|
139
|
+
// Blocked is not active: an active stage is one somebody can act on now.
|
|
140
|
+
status: pending > 0 ? 'active' : (blockedGates.length ? 'blocked' : 'idle'),
|
|
127
141
|
pending,
|
|
142
|
+
blocked: blockedGates.length,
|
|
128
143
|
last_message: pending > 0
|
|
129
144
|
? `${pending} gate${pending > 1 ? 's' : ''} awaiting signature`
|
|
130
|
-
:
|
|
145
|
+
: (blockedGates.length
|
|
146
|
+
? `${blockedGates.length} gate${blockedGates.length > 1 ? 's' : ''} blocked`
|
|
147
|
+
: 'no gate pending'),
|
|
131
148
|
verdict: null,
|
|
132
149
|
ts: newestGate,
|
|
133
150
|
age_min: gateAgeMs != null ? Math.round(gateAgeMs / 60000) : null,
|
|
@@ -182,7 +182,7 @@ function walk(dir, root, out, depth = 0) {
|
|
|
182
182
|
* 8. a bare README a name that describes nothing else
|
|
183
183
|
*
|
|
184
184
|
* The leading filename token outranks the directory, and that ordering was
|
|
185
|
-
* bought: 14 `docs/
|
|
185
|
+
* bought: 14 `docs/adr/ADR-0NN-*.md` files were being filed as
|
|
186
186
|
* Architecture, and `docs/design/PLAN-*.md` as Design. A directory is where a
|
|
187
187
|
* project dumps a category; the front of a filename is what the author called
|
|
188
188
|
* THIS document. Mid-name tokens rank below the directory instead, because
|
|
@@ -31,6 +31,28 @@
|
|
|
31
31
|
<script src="/assets/vendor/purify.min.js"></script>
|
|
32
32
|
<style>
|
|
33
33
|
:root {
|
|
34
|
+
/* Colours that used to be written straight into rules.
|
|
35
|
+
Twenty-nine declarations bypassed the token system, so the contrast audit —
|
|
36
|
+
which reads `:root` — could not see any of them. Values are carried across
|
|
37
|
+
unchanged: this is a rename, not a redesign, and the rendered-layout check
|
|
38
|
+
proves it by comparing the colours the page actually paints. */
|
|
39
|
+
--on-solid: #fff; /* text on a solid coloured chip or button */
|
|
40
|
+
--on-warm: #000; /* text on amber, where white fails contrast */
|
|
41
|
+
--on-accent: #04140d; /* text on the green accent */
|
|
42
|
+
--bg-solid: #0f1115; /* opaque button and tooltip surface */
|
|
43
|
+
--bg-solid-hover: #2a2d33;
|
|
44
|
+
--accent-hover: #00ee8a;
|
|
45
|
+
--verdict-ok: #4ade80;
|
|
46
|
+
--verdict-fail: #f87171;
|
|
47
|
+
--toast-bg: #1f2937;
|
|
48
|
+
--toast-ok: #14532d;
|
|
49
|
+
--toast-err: #7f1d1d;
|
|
50
|
+
--toast-info: #1e3a5f;
|
|
51
|
+
--dot-orange: #f97316;
|
|
52
|
+
--dot-unread: #3b82f6;
|
|
53
|
+
--metric-purple: #7c3aed;
|
|
54
|
+
--chip-agent: #2540d4;
|
|
55
|
+
|
|
34
56
|
/* Dark emerald — matches landing greatcto.systems */
|
|
35
57
|
--bg-page: #0a0e0c;
|
|
36
58
|
--bg-panel: rgba(17, 22, 26, 0.85);
|
|
@@ -545,7 +567,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
545
567
|
.btn-bell:hover { border-color: var(--border-strong); color: var(--text); }
|
|
546
568
|
.btn-bell-badge {
|
|
547
569
|
position: absolute; top: -5px; right: -5px;
|
|
548
|
-
background: var(--accent); color:
|
|
570
|
+
background: var(--accent); color: var(--on-accent);
|
|
549
571
|
font-size: var(--fs-eyebrow); font-weight: 700; line-height: 1;
|
|
550
572
|
min-width: 16px; height: 16px; border-radius: 8px;
|
|
551
573
|
display: none; align-items: center; justify-content: center;
|
|
@@ -574,13 +596,13 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
574
596
|
.icon-btn:hover { background: var(--bg-muted); color: var(--text); }
|
|
575
597
|
|
|
576
598
|
.btn-new {
|
|
577
|
-
background:
|
|
599
|
+
background: var(--bg-solid); color: var(--on-solid); border: none;
|
|
578
600
|
height: 30px; padding: 0 14px;
|
|
579
601
|
border-radius: 999px;
|
|
580
602
|
font-size: var(--fs-body); font-weight: 500;
|
|
581
603
|
display: inline-flex; align-items: center; gap: 6px;
|
|
582
604
|
}
|
|
583
|
-
.btn-new:hover { background:
|
|
605
|
+
.btn-new:hover { background: var(--bg-solid-hover); }
|
|
584
606
|
|
|
585
607
|
.task-count {
|
|
586
608
|
font-family: var(--mono); font-size: var(--fs-caption);
|
|
@@ -902,7 +924,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
902
924
|
pointer-events: none;
|
|
903
925
|
}
|
|
904
926
|
.toast {
|
|
905
|
-
background:
|
|
927
|
+
background: var(--toast-bg); color: var(--on-solid);
|
|
906
928
|
padding: 8px 14px; border-radius: 6px;
|
|
907
929
|
font-size: var(--fs-body); min-width: 200px; max-width: 360px;
|
|
908
930
|
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
|
|
@@ -911,9 +933,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
911
933
|
pointer-events: auto;
|
|
912
934
|
}
|
|
913
935
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
914
|
-
.toast-success { background:
|
|
915
|
-
.toast-error { background:
|
|
916
|
-
.toast-info { background:
|
|
936
|
+
.toast-success { background: var(--toast-ok); border-left: 3px solid #22c55e; }
|
|
937
|
+
.toast-error { background: var(--toast-err); border-left: 3px solid #ef4444; }
|
|
938
|
+
.toast-info { background: var(--toast-info); border-left: 3px solid #3b82f6; }
|
|
917
939
|
.toast code { background: rgba(255,255,255,0.1); padding: 1px 4px; border-radius: 3px; }
|
|
918
940
|
.pl-stage { cursor: pointer; transition: background 0.12s; }
|
|
919
941
|
.pl-stage:hover { background: var(--bg-muted); border-radius: 4px; }
|
|
@@ -991,8 +1013,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
991
1013
|
}
|
|
992
1014
|
.rdm-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
|
993
1015
|
.rdm-head .rdm-agent { font-weight: 600; }
|
|
994
|
-
.rdm-head .rdm-verdict.ok { color:
|
|
995
|
-
.rdm-head .rdm-verdict.fail { color:
|
|
1016
|
+
.rdm-head .rdm-verdict.ok { color: var(--verdict-ok); }
|
|
1017
|
+
.rdm-head .rdm-verdict.fail { color: var(--verdict-fail); }
|
|
996
1018
|
.rdm-head .rdm-ts { color: var(--text2); font-size: var(--fs-caption); margin-left: auto; }
|
|
997
1019
|
.rdm-raw {
|
|
998
1020
|
background: var(--bg-muted); padding: 8px 10px; border-radius: 4px;
|
|
@@ -1106,9 +1128,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1106
1128
|
.ni-btn-ghost:hover { background: var(--bg-muted, rgba(255,255,255,0.05)); color: var(--text); }
|
|
1107
1129
|
.ni-btn-primary {
|
|
1108
1130
|
background: var(--accent, #00d97e);
|
|
1109
|
-
color:
|
|
1131
|
+
color: var(--bg-page); font-weight: 500;
|
|
1110
1132
|
}
|
|
1111
|
-
.ni-btn-primary:hover { background:
|
|
1133
|
+
.ni-btn-primary:hover { background: var(--accent-hover); }
|
|
1112
1134
|
.ni-btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
1113
1135
|
@media (max-width: 600px) {
|
|
1114
1136
|
.ni-row { flex-direction: column; gap: 14px; }
|
|
@@ -1303,9 +1325,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1303
1325
|
.tier-badge { font-size: var(--fs-caption); padding: 1px 8px; border-radius: 999px; margin-left: 6px;
|
|
1304
1326
|
font-family: var(--mono); white-space: nowrap; }
|
|
1305
1327
|
.tier-badge:empty { display: none; }
|
|
1306
|
-
.tier-badge.tier-T0 { background: var(--status-review); color:
|
|
1307
|
-
.tier-badge.tier-T1 { background: var(--status-progress); color:
|
|
1308
|
-
.tier-badge.tier-T2 { background: var(--status-blocked); color:
|
|
1328
|
+
.tier-badge.tier-T0 { background: var(--status-review); color: var(--on-solid); }
|
|
1329
|
+
.tier-badge.tier-T1 { background: var(--status-progress); color: var(--on-solid); }
|
|
1330
|
+
.tier-badge.tier-T2 { background: var(--status-blocked); color: var(--on-solid); }
|
|
1309
1331
|
|
|
1310
1332
|
/* ── Pipeline track ───────────────────────────────────────────────────────── */
|
|
1311
1333
|
.pipeline-track {
|
|
@@ -1318,7 +1340,16 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1318
1340
|
overflow-x: auto;
|
|
1319
1341
|
}
|
|
1320
1342
|
.pl-stage {
|
|
1321
|
-
|
|
1343
|
+
/* `flex: 1` let a cell shrink below its own label, and `.pl-name` below
|
|
1344
|
+
refuses to wrap — so the text simply ran over its neighbour. Measured at
|
|
1345
|
+
1440px: three of ten labels overflowed, "12-angle review" needing 127px
|
|
1346
|
+
inside an 80px cell. Box geometry hid it (getBoundingClientRect reports the
|
|
1347
|
+
BOX, and the boxes were 20px apart while the ink was not), which is why it
|
|
1348
|
+
read as a rendering quirk for so long.
|
|
1349
|
+
`min-width: max-content` is the missing half of the decision recorded on
|
|
1350
|
+
`.pl-name`: refusing to wrap is only honest if the row may overflow and
|
|
1351
|
+
scroll, which `overflow-x: auto` on the track already allows. */
|
|
1352
|
+
flex: 1 0 auto; min-width: max-content;
|
|
1322
1353
|
position: relative;
|
|
1323
1354
|
padding: 8px 10px 6px;
|
|
1324
1355
|
display: flex; flex-direction: column; gap: 4px;
|
|
@@ -1412,7 +1443,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1412
1443
|
color: var(--text); cursor: pointer; transition: background 0.12s, border-color 0.12s;
|
|
1413
1444
|
}
|
|
1414
1445
|
.inbox-allclear .ac-btn:hover { background: var(--bg-muted); border-color: var(--accent, #00d97e); }
|
|
1415
|
-
.inbox-allclear .ac-btn.ac-primary { background: var(--accent, #00d97e); border-color: var(--accent, #00d97e); color:
|
|
1446
|
+
.inbox-allclear .ac-btn.ac-primary { background: var(--accent, #00d97e); border-color: var(--accent, #00d97e); color: var(--on-accent); font-weight: 500; }
|
|
1416
1447
|
.inbox-allclear .ac-btn.ac-primary:hover { filter: brightness(1.06); }
|
|
1417
1448
|
|
|
1418
1449
|
/* Human gate — the signing checkpoint. Visually set apart from agent stages:
|
|
@@ -1434,7 +1465,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1434
1465
|
.pl-gate.active .pl-msg, .pl-gate.active .pl-meta { opacity: 1; color: var(--text); }
|
|
1435
1466
|
.pl-gate .gate-badge {
|
|
1436
1467
|
margin-left: auto;
|
|
1437
|
-
background: var(--status-blocked); color:
|
|
1468
|
+
background: var(--status-blocked); color: var(--on-solid);
|
|
1438
1469
|
font-size: var(--fs-eyebrow); font-weight: 700;
|
|
1439
1470
|
min-width: 16px; height: 16px; padding: 0 4px;
|
|
1440
1471
|
border-radius: 8px;
|
|
@@ -1506,7 +1537,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1506
1537
|
position: absolute;
|
|
1507
1538
|
bottom: calc(100% + 4px); left: 50%;
|
|
1508
1539
|
transform: translateX(-50%);
|
|
1509
|
-
background:
|
|
1540
|
+
background: var(--bg-solid); color: var(--on-solid);
|
|
1510
1541
|
padding: 4px 8px; border-radius: 4px;
|
|
1511
1542
|
font-family: var(--mono); font-size: var(--fs-eyebrow);
|
|
1512
1543
|
white-space: nowrap;
|
|
@@ -1717,7 +1748,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1717
1748
|
|
|
1718
1749
|
.dot-purple { background: var(--status-gate); }
|
|
1719
1750
|
.dot-red { background: var(--status-blocked); }
|
|
1720
|
-
.dot-orange { background:
|
|
1751
|
+
.dot-orange { background: var(--dot-orange); }
|
|
1721
1752
|
.dot-amber { background: var(--status-progress); }
|
|
1722
1753
|
|
|
1723
1754
|
.inbox-row {
|
|
@@ -1977,7 +2008,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1977
2008
|
.metric-card.green::before { background: var(--status-review); }
|
|
1978
2009
|
.metric-card.amber::before { background: var(--status-progress); }
|
|
1979
2010
|
.metric-card.red::before { background: var(--status-blocked); }
|
|
1980
|
-
.metric-card.purple::before { background:
|
|
2011
|
+
.metric-card.purple::before { background: var(--metric-purple); }
|
|
1981
2012
|
.metric-num {
|
|
1982
2013
|
font-family: var(--serif); font-weight: 500;
|
|
1983
2014
|
/* The board is live (SSE). In a proportional face a 1 is narrower than an 8,
|
|
@@ -2461,7 +2492,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2461
2492
|
text-overflow: ellipsis;
|
|
2462
2493
|
}
|
|
2463
2494
|
.btn-black {
|
|
2464
|
-
background:
|
|
2495
|
+
background: var(--bg-solid); color: var(--on-solid); border: none;
|
|
2465
2496
|
padding: 8px 14px; border-radius: 999px;
|
|
2466
2497
|
font-size: var(--fs-small); font-weight: 500;
|
|
2467
2498
|
}
|
|
@@ -2531,9 +2562,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2531
2562
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
|
2532
2563
|
}
|
|
2533
2564
|
.fchip:hover { background: var(--bg-elevated); color: var(--text); border-color: var(--border-strong); }
|
|
2534
|
-
.fchip.active { background: var(--accent); color:
|
|
2535
|
-
.fchip-agent.active { background:
|
|
2536
|
-
.fchip-priority.active { background:
|
|
2565
|
+
.fchip.active { background: var(--accent); color: var(--on-solid); border-color: transparent; }
|
|
2566
|
+
.fchip-agent.active { background: var(--chip-agent); }
|
|
2567
|
+
.fchip-priority.active { background: var(--status-progress); color: var(--on-warm); }
|
|
2537
2568
|
.fchip-label.active { background: var(--accent); }
|
|
2538
2569
|
.fchip-clear { background: transparent; color: var(--text3); border-color: transparent; }
|
|
2539
2570
|
.fchip-clear:hover { background: var(--bg-strong); color: var(--status-blocked); }
|
|
@@ -3420,6 +3451,9 @@ function greetByHour() {
|
|
|
3420
3451
|
}
|
|
3421
3452
|
|
|
3422
3453
|
function renderInbox(d) {
|
|
3454
|
+
// Kept so renderResume can ask what this section is already showing, rather
|
|
3455
|
+
// than the two of them rendering the same task independently.
|
|
3456
|
+
window.__inboxData = d;
|
|
3423
3457
|
inboxData = d || {};
|
|
3424
3458
|
const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
|
|
3425
3459
|
// `needs_you` counts distinct objects. Summing the three categories double-counted
|
|
@@ -3434,12 +3468,24 @@ function renderInbox(d) {
|
|
|
3434
3468
|
? "Some data could not be read — treat the counts below as incomplete."
|
|
3435
3469
|
: (s.gates + s.p0 ? "Here's what needs your decision." : 'Nothing urgent — back to deep work.');
|
|
3436
3470
|
document.getElementById('inbox-greet').textContent = `${greetByHour()} ${greetTail}`;
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3471
|
+
// Only the tiles that have something in them.
|
|
3472
|
+
//
|
|
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 > 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>`;
|
|
3443
3489
|
renderInboxList('inbox-gates', d?.pending_gates || [], 'inbox-gates-count', { showApprove: true });
|
|
3444
3490
|
// `implies` names the fact the section heading already states, so the rows
|
|
3445
3491
|
// below it stop repeating that fact on every card. "P0 open" states the
|
|
@@ -3492,10 +3538,18 @@ async function refreshReceipt() {
|
|
|
3492
3538
|
if (!d || d.state === 'matches' || d.state === 'no-receipt') return;
|
|
3493
3539
|
|
|
3494
3540
|
const stamp = d.agent ? `${esc(d.agent)}${d.ts ? ' · ' + relTime(d.ts) : ''}` : '';
|
|
3541
|
+
// The FACT is one line; the file list is evidence for it.
|
|
3542
|
+
//
|
|
3543
|
+
// Four full paths printed inline wrapped the notice onto three lines and buried
|
|
3544
|
+
// the sentence that matters — that the approval no longer describes the tree —
|
|
3545
|
+
// under `docs/plans/PLAN-2026-08-17-gate-fail-closed.md`. The paths are still
|
|
3546
|
+
// here, one click away: a reader who wants to act needs them, a reader deciding
|
|
3547
|
+
// whether to care does not.
|
|
3548
|
+
const changedFiles = [...(d.changed || []), ...(d.removed || [])];
|
|
3495
3549
|
const body = d.state === 'differs'
|
|
3496
|
-
? `<b>${
|
|
3550
|
+
? `<b>${changedFiles.length} reviewed file(s) changed since ${stamp} approved</b>
|
|
3497
3551
|
— the approval no longer describes what is in the tree.
|
|
3498
|
-
|
|
3552
|
+
${changedFiles.length ? `<details style="display:inline"><summary class="muted" style="display:inline;cursor:pointer">which files</summary><div class="muted" style="margin-top:4px">${esc(changedFiles.join(', '))}</div></details>` : ''}`
|
|
3499
3553
|
: d.state === 'unreadable'
|
|
3500
3554
|
? `<b>Could not check the review receipt</b> — <span class="muted">${esc(d.why || '')}</span>. This is not the same as a clean tree.`
|
|
3501
3555
|
: `${(d.added || []).length} file(s) added since ${stamp} approved; nothing reviewed was altered.`;
|
|
@@ -4341,7 +4395,25 @@ function summariseVerdict(v, raw) {
|
|
|
4341
4395
|
function renderResume(d) {
|
|
4342
4396
|
const card = document.getElementById('resume-card');
|
|
4343
4397
|
if (!card) return;
|
|
4344
|
-
|
|
4398
|
+
|
|
4399
|
+
// Whatever the Stale section is already showing does not need showing again.
|
|
4400
|
+
//
|
|
4401
|
+
// Measured on a real morning: three of the five stale rows also filled the
|
|
4402
|
+
// "in progress" column here, so 60% of "pick up where you left off" was the
|
|
4403
|
+
// list immediately below it. Both blocks were honest; together they made one
|
|
4404
|
+
// fact look like two, which is the same arithmetic error as counting a task
|
|
4405
|
+
// twice in the nav badge — just spent in vertical space instead of a number.
|
|
4406
|
+
//
|
|
4407
|
+
// Stale wins the duplicate because it says more: it carries the age, which is
|
|
4408
|
+
// the reason the row deserves attention at all.
|
|
4409
|
+
const shownElsewhere = new Set(
|
|
4410
|
+
(window.__inboxData?.stale_in_progress || []).map((t) => t?.id).filter(Boolean));
|
|
4411
|
+
const allWip = d.wip_tasks || [];
|
|
4412
|
+
const wip = allWip.filter((t) => !shownElsewhere.has(t?.id));
|
|
4413
|
+
// How many were dropped, so the empty column can say WHY it is empty. Rendering
|
|
4414
|
+
// "Nothing in progress" over three tasks that are in progress — and listed a
|
|
4415
|
+
// screen below — would be a worse lie than the duplication this removed.
|
|
4416
|
+
const wipHiddenAsStale = allWip.length - wip.length;
|
|
4345
4417
|
const verdicts = d.recent_verdicts || [];
|
|
4346
4418
|
const decisions = d.decisions || [];
|
|
4347
4419
|
const totalItems = wip.length + verdicts.length + decisions.length;
|
|
@@ -4373,7 +4445,11 @@ function renderResume(d) {
|
|
|
4373
4445
|
|
|
4374
4446
|
// WIP column
|
|
4375
4447
|
const wipEl = document.getElementById('resume-wip');
|
|
4376
|
-
|
|
4448
|
+
if (!wip.length && wipHiddenAsStale > 0) {
|
|
4449
|
+
wipEl.innerHTML = `<div class="empty">${wipHiddenAsStale} in progress — all of them `
|
|
4450
|
+
+ `<a href="#" onclick="document.getElementById('inbox-stale')`
|
|
4451
|
+
+ `?.scrollIntoView({behavior:'smooth'});return false">stale, listed below</a></div>`;
|
|
4452
|
+
} else wipEl.innerHTML = wip.length ? wip.map(t => {
|
|
4377
4453
|
const tag = (t.agent || 'wip').slice(0, 12);
|
|
4378
4454
|
return `<div class="resume-item" onclick="openSideById('${esc(t.id)}')">
|
|
4379
4455
|
<span class="ri-tag">${esc(tag)}</span>
|
|
@@ -7856,7 +7932,7 @@ function renderNotifDrawer() {
|
|
|
7856
7932
|
${new Date(n.ts).toLocaleString()}
|
|
7857
7933
|
</div>
|
|
7858
7934
|
</div>
|
|
7859
|
-
${n.read ? '' : '<span style="width:7px;height:7px;background
|
|
7935
|
+
${n.read ? '' : '<span style="width:7px;height:7px;background:var(--dot-unread);border-radius:50%;flex-shrink:0;margin-top:5px"></span>'}
|
|
7860
7936
|
</div>
|
|
7861
7937
|
</div>`).join('');
|
|
7862
7938
|
}
|
package/package.json
CHANGED