great-cto 3.14.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 {
|
|
@@ -1822,6 +1853,39 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1822
1853
|
font-size: var(--fs-body);
|
|
1823
1854
|
}
|
|
1824
1855
|
.judge-status[hidden] { display: none; }
|
|
1856
|
+
|
|
1857
|
+
/* What the judge CONCLUDED, under whether it is connected.
|
|
1858
|
+
The scores existed for weeks and no page read them: /api/scores had zero
|
|
1859
|
+
callers, so every verification ran, wrote a record, and vanished. */
|
|
1860
|
+
.jscores { margin-bottom: 16px; }
|
|
1861
|
+
.jscores[hidden] { display: none; }
|
|
1862
|
+
.jscores-head {
|
|
1863
|
+
display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
|
|
1864
|
+
padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px 8px 0 0;
|
|
1865
|
+
background: var(--bg-muted); font-size: var(--fs-body);
|
|
1866
|
+
}
|
|
1867
|
+
.jscores-head .rate { font-size: var(--fs-num-s); font-weight: 600; }
|
|
1868
|
+
.jscores-rows { border: 1px solid var(--border); border-top: 0; border-radius: 0 0 8px 8px; }
|
|
1869
|
+
.jscore {
|
|
1870
|
+
display: grid; grid-template-columns: minmax(9rem, 12rem) 7.5rem 1fr auto;
|
|
1871
|
+
gap: 12px; align-items: baseline;
|
|
1872
|
+
padding: 7px 12px; border-top: 1px solid var(--border); font-size: var(--fs-small);
|
|
1873
|
+
}
|
|
1874
|
+
.jscore:first-child { border-top: 0; }
|
|
1875
|
+
.jscore .who { font-weight: 500; }
|
|
1876
|
+
.jscore .why { color: var(--text3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1877
|
+
.jscore .when { color: var(--text3); font-family: var(--mono); font-size: var(--fs-caption); }
|
|
1878
|
+
.jstate { font-weight: 600; }
|
|
1879
|
+
.jstate.verified { color: var(--status-done); }
|
|
1880
|
+
.jstate.rework { color: var(--status-progress); }
|
|
1881
|
+
/* Deliberately grey, not red. `unverifiable` is not a failing grade — it is the
|
|
1882
|
+
absence of one, and colouring it like a failure is the same collapse of three
|
|
1883
|
+
states into two that the scores exist to prevent. */
|
|
1884
|
+
.jstate.unverifiable { color: var(--text3); }
|
|
1885
|
+
@media (max-width: 640px) {
|
|
1886
|
+
.jscore { grid-template-columns: 1fr auto; }
|
|
1887
|
+
.jscore .why, .jscore .when { grid-column: 1 / -1; }
|
|
1888
|
+
}
|
|
1825
1889
|
.judge-status .js-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
1826
1890
|
.judge-status.ok .js-dot { background: var(--status-done); }
|
|
1827
1891
|
.judge-status.off .js-dot { background: var(--status-blocked); }
|
|
@@ -1944,7 +2008,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1944
2008
|
.metric-card.green::before { background: var(--status-review); }
|
|
1945
2009
|
.metric-card.amber::before { background: var(--status-progress); }
|
|
1946
2010
|
.metric-card.red::before { background: var(--status-blocked); }
|
|
1947
|
-
.metric-card.purple::before { background:
|
|
2011
|
+
.metric-card.purple::before { background: var(--metric-purple); }
|
|
1948
2012
|
.metric-num {
|
|
1949
2013
|
font-family: var(--serif); font-weight: 500;
|
|
1950
2014
|
/* The board is live (SSE). In a proportional face a 1 is narrower than an 8,
|
|
@@ -2428,7 +2492,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2428
2492
|
text-overflow: ellipsis;
|
|
2429
2493
|
}
|
|
2430
2494
|
.btn-black {
|
|
2431
|
-
background:
|
|
2495
|
+
background: var(--bg-solid); color: var(--on-solid); border: none;
|
|
2432
2496
|
padding: 8px 14px; border-radius: 999px;
|
|
2433
2497
|
font-size: var(--fs-small); font-weight: 500;
|
|
2434
2498
|
}
|
|
@@ -2498,9 +2562,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2498
2562
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
|
2499
2563
|
}
|
|
2500
2564
|
.fchip:hover { background: var(--bg-elevated); color: var(--text); border-color: var(--border-strong); }
|
|
2501
|
-
.fchip.active { background: var(--accent); color:
|
|
2502
|
-
.fchip-agent.active { background:
|
|
2503
|
-
.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); }
|
|
2504
2568
|
.fchip-label.active { background: var(--accent); }
|
|
2505
2569
|
.fchip-clear { background: transparent; color: var(--text3); border-color: transparent; }
|
|
2506
2570
|
.fchip-clear:hover { background: var(--bg-strong); color: var(--status-blocked); }
|
|
@@ -3136,6 +3200,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3136
3200
|
key is not discoverable: the README names OpenRouter once without
|
|
3137
3201
|
saying where it goes. -->
|
|
3138
3202
|
<div id="judge-status" class="judge-status" hidden></div>
|
|
3203
|
+
<!-- The verdicts of the verifier itself. Kept beside the connection
|
|
3204
|
+
status because the two answer consecutive questions: is anything
|
|
3205
|
+
checking, and what did it find. -->
|
|
3206
|
+
<div id="judge-scores" class="jscores" hidden></div>
|
|
3139
3207
|
<div id="budgets-page-body">loading…</div>
|
|
3140
3208
|
</div>
|
|
3141
3209
|
</div>
|
|
@@ -3383,6 +3451,9 @@ function greetByHour() {
|
|
|
3383
3451
|
}
|
|
3384
3452
|
|
|
3385
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;
|
|
3386
3457
|
inboxData = d || {};
|
|
3387
3458
|
const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
|
|
3388
3459
|
// `needs_you` counts distinct objects. Summing the three categories double-counted
|
|
@@ -3397,12 +3468,24 @@ function renderInbox(d) {
|
|
|
3397
3468
|
? "Some data could not be read — treat the counts below as incomplete."
|
|
3398
3469
|
: (s.gates + s.p0 ? "Here's what needs your decision." : 'Nothing urgent — back to deep work.');
|
|
3399
3470
|
document.getElementById('inbox-greet').textContent = `${greetByHour()} ${greetTail}`;
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
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>`;
|
|
3406
3489
|
renderInboxList('inbox-gates', d?.pending_gates || [], 'inbox-gates-count', { showApprove: true });
|
|
3407
3490
|
// `implies` names the fact the section heading already states, so the rows
|
|
3408
3491
|
// below it stop repeating that fact on every card. "P0 open" states the
|
|
@@ -3455,10 +3538,18 @@ async function refreshReceipt() {
|
|
|
3455
3538
|
if (!d || d.state === 'matches' || d.state === 'no-receipt') return;
|
|
3456
3539
|
|
|
3457
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 || [])];
|
|
3458
3549
|
const body = d.state === 'differs'
|
|
3459
|
-
? `<b>${
|
|
3550
|
+
? `<b>${changedFiles.length} reviewed file(s) changed since ${stamp} approved</b>
|
|
3460
3551
|
— the approval no longer describes what is in the tree.
|
|
3461
|
-
|
|
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>` : ''}`
|
|
3462
3553
|
: d.state === 'unreadable'
|
|
3463
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.`
|
|
3464
3555
|
: `${(d.added || []).length} file(s) added since ${stamp} approved; nothing reviewed was altered.`;
|
|
@@ -3960,6 +4051,75 @@ async function loadJudgeStatus() {
|
|
|
3960
4051
|
</span>`;
|
|
3961
4052
|
}
|
|
3962
4053
|
|
|
4054
|
+
/**
|
|
4055
|
+
* What the verifier concluded, per stage.
|
|
4056
|
+
*
|
|
4057
|
+
* `/api/scores` existed with ZERO front-end callers: every verification ran,
|
|
4058
|
+
* appended a record, and was never shown to anyone. The user's question — "how
|
|
4059
|
+
* much did introducing the judge improve anything, and why can I not see it?" —
|
|
4060
|
+
* had a correct answer of "you cannot, nothing renders it".
|
|
4061
|
+
*
|
|
4062
|
+
* The rate divides by ASSESSED, never by total, and says how many that was.
|
|
4063
|
+
* An agent with nine unverifiable runs and one verified run scores 100% here
|
|
4064
|
+
* with `1 assessed` beside it; reporting 10% would be a made-up number about
|
|
4065
|
+
* work nobody looked at.
|
|
4066
|
+
*/
|
|
4067
|
+
async function loadJudgeScores() {
|
|
4068
|
+
const el = document.getElementById('judge-scores');
|
|
4069
|
+
if (!el) return;
|
|
4070
|
+
let d;
|
|
4071
|
+
try { d = await api(`/api/scores${pqs()}&limit=40`); } catch { d = null; }
|
|
4072
|
+
|
|
4073
|
+
// Three states, and the empty one earns a sentence rather than a blank space:
|
|
4074
|
+
// "nothing has been verified yet" and "the panel is broken" look identical
|
|
4075
|
+
// when a component renders nothing.
|
|
4076
|
+
if (!d || !Array.isArray(d.scores)) {
|
|
4077
|
+
el.hidden = false;
|
|
4078
|
+
el.innerHTML = `<div class="jscores-head"><span class="muted">Verification results could not be read.</span></div>`;
|
|
4079
|
+
return;
|
|
4080
|
+
}
|
|
4081
|
+
if (!d.scores.length) {
|
|
4082
|
+
el.hidden = false;
|
|
4083
|
+
el.innerHTML = `<div class="jscores-head"><span class="muted">No stage has been verified yet — run a pipeline and the judge's conclusions land here.</span></div>`;
|
|
4084
|
+
return;
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
const sum = d.summary || {};
|
|
4088
|
+
const by = sum.byState || {};
|
|
4089
|
+
const rate = sum.rate === null || sum.rate === undefined ? '—' : `${sum.rate}%`;
|
|
4090
|
+
|
|
4091
|
+
// Newest first, one row per agent: an older score for the same stage is
|
|
4092
|
+
// history, and history belongs in the file, not in a status panel.
|
|
4093
|
+
const seen = new Set();
|
|
4094
|
+
const rows = d.scores.filter((s) => {
|
|
4095
|
+
if (seen.has(s.agent)) return false;
|
|
4096
|
+
seen.add(s.agent);
|
|
4097
|
+
return true;
|
|
4098
|
+
});
|
|
4099
|
+
|
|
4100
|
+
el.hidden = false;
|
|
4101
|
+
el.innerHTML = `
|
|
4102
|
+
<div class="jscores-head">
|
|
4103
|
+
<span class="rate">${rate}</span>
|
|
4104
|
+
<span class="muted">verified, of ${sum.assessed || 0} assessed</span>
|
|
4105
|
+
<span class="jstate verified">${by.verified || 0} verified</span>
|
|
4106
|
+
<span class="jstate rework">${by.rework || 0} rework</span>
|
|
4107
|
+
<span class="jstate unverifiable"
|
|
4108
|
+
title="Nothing was checked — not a failing grade. Excluded from the rate rather than counted as zeros, which is why the rate says how many it divided by.">${by.unverifiable || 0} unverifiable</span>
|
|
4109
|
+
${d.unreadable_lines
|
|
4110
|
+
? `<span class="jstate rework">${d.unreadable_lines} unreadable line(s)</span>` : ''}
|
|
4111
|
+
</div>
|
|
4112
|
+
<div class="jscores-rows">
|
|
4113
|
+
${rows.map((s) => `
|
|
4114
|
+
<div class="jscore">
|
|
4115
|
+
<span class="who">${esc(s.agent)}</span>
|
|
4116
|
+
<span class="jstate ${esc(s.state)}">${esc(s.state)}</span>
|
|
4117
|
+
<span class="why" title="${esc(s.comment || '')}">${esc(s.comment || '')}</span>
|
|
4118
|
+
<span class="when">${esc(String(s.ts || '').slice(0, 10))} · ${esc(s.scorer || '')}</span>
|
|
4119
|
+
</div>`).join('')}
|
|
4120
|
+
</div>`;
|
|
4121
|
+
}
|
|
4122
|
+
|
|
3963
4123
|
async function saveJudgeKey() {
|
|
3964
4124
|
const input = document.getElementById('judge-key');
|
|
3965
4125
|
const key = (input?.value || '').trim();
|
|
@@ -3982,6 +4142,7 @@ async function saveJudgeKey() {
|
|
|
3982
4142
|
|
|
3983
4143
|
async function loadBudgetsPage() {
|
|
3984
4144
|
loadJudgeStatus();
|
|
4145
|
+
loadJudgeScores();
|
|
3985
4146
|
const body = document.getElementById('budgets-page-body');
|
|
3986
4147
|
if (!body) return;
|
|
3987
4148
|
body.innerHTML = '<div class="muted">loading…</div>';
|
|
@@ -4234,7 +4395,25 @@ function summariseVerdict(v, raw) {
|
|
|
4234
4395
|
function renderResume(d) {
|
|
4235
4396
|
const card = document.getElementById('resume-card');
|
|
4236
4397
|
if (!card) return;
|
|
4237
|
-
|
|
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;
|
|
4238
4417
|
const verdicts = d.recent_verdicts || [];
|
|
4239
4418
|
const decisions = d.decisions || [];
|
|
4240
4419
|
const totalItems = wip.length + verdicts.length + decisions.length;
|
|
@@ -4266,7 +4445,11 @@ function renderResume(d) {
|
|
|
4266
4445
|
|
|
4267
4446
|
// WIP column
|
|
4268
4447
|
const wipEl = document.getElementById('resume-wip');
|
|
4269
|
-
|
|
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 => {
|
|
4270
4453
|
const tag = (t.agent || 'wip').slice(0, 12);
|
|
4271
4454
|
return `<div class="resume-item" onclick="openSideById('${esc(t.id)}')">
|
|
4272
4455
|
<span class="ri-tag">${esc(tag)}</span>
|
|
@@ -7749,7 +7932,7 @@ function renderNotifDrawer() {
|
|
|
7749
7932
|
${new Date(n.ts).toLocaleString()}
|
|
7750
7933
|
</div>
|
|
7751
7934
|
</div>
|
|
7752
|
-
${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>'}
|
|
7753
7936
|
</div>
|
|
7754
7937
|
</div>`).join('');
|
|
7755
7938
|
}
|
package/package.json
CHANGED
package/postinstall.mjs
CHANGED
|
@@ -1,5 +1,124 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* great-cto postinstall —
|
|
4
|
-
*
|
|
3
|
+
* great-cto postinstall — says when the plugin is now older than the CLI.
|
|
4
|
+
*
|
|
5
|
+
* THE PROBLEM
|
|
6
|
+
* -----------
|
|
7
|
+
* great-cto ships as two things on purpose: an npm package (the `great-cto`
|
|
8
|
+
* command) and a Claude Code plugin (agents, skills, commands, the board). They
|
|
9
|
+
* update through different channels — `npm i -g great-cto` moves one of them and
|
|
10
|
+
* nothing at all moves the other.
|
|
11
|
+
*
|
|
12
|
+
* So a user upgrades, sees a new version number, and runs a pipeline whose
|
|
13
|
+
* agents are three releases behind. Nothing errors. The board opened, the agents
|
|
14
|
+
* ran, the verdicts were written — by the old code. That is this project's
|
|
15
|
+
* governing defect in its own installer: a thing that did not happen looking
|
|
16
|
+
* exactly like a thing that did.
|
|
17
|
+
*
|
|
18
|
+
* WHAT THIS DELIBERATELY DOES NOT DO
|
|
19
|
+
* ----------------------------------
|
|
20
|
+
* It does not update the plugin. An npm lifecycle hook writing into
|
|
21
|
+
* ~/.claude/plugins/ would reach across a project boundary into global state
|
|
22
|
+
* that every other project's agents read, from a script the user never chose to
|
|
23
|
+
* run — the ADR-009 case that wants a human decision, not a silent one. `npm i`
|
|
24
|
+
* is not consent to rewrite the agent definitions of every project on the
|
|
25
|
+
* machine.
|
|
26
|
+
*
|
|
27
|
+
* So it detects and it tells. The user runs one command, or does not.
|
|
28
|
+
*
|
|
29
|
+
* IT ALSO MUST NOT BREAK AN INSTALL
|
|
30
|
+
* ---------------------------------
|
|
31
|
+
* A postinstall hook that throws fails `npm i`. Every path here is wrapped, the
|
|
32
|
+
* exit code is always 0, and every unknown answers "say nothing": no plugin
|
|
33
|
+
* directory is a perfectly good CLI-only install, and an unreadable one is not
|
|
34
|
+
* evidence of staleness.
|
|
5
35
|
*/
|
|
36
|
+
|
|
37
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
38
|
+
import { join, dirname } from 'node:path';
|
|
39
|
+
import { homedir } from 'node:os';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
|
|
42
|
+
/** Numeric semver compare. Returns >0 when a is newer. Non-numeric parts sort last. */
|
|
43
|
+
export function cmpVersion(a, b) {
|
|
44
|
+
const parse = (v) => String(v).split('.').map((n) => (/^\d+$/.test(n) ? Number(n) : -1));
|
|
45
|
+
const x = parse(a);
|
|
46
|
+
const y = parse(b);
|
|
47
|
+
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
|
|
48
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
49
|
+
if (d) return d;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Newest great_cto version across EVERY marketplace, not just `local`.
|
|
56
|
+
*
|
|
57
|
+
* Same reasoning as board-path: a user who installed the plugin from a
|
|
58
|
+
* marketplace of their own is not running a broken setup, and looking in one
|
|
59
|
+
* hard-coded directory would report them as having no plugin at all.
|
|
60
|
+
*/
|
|
61
|
+
export function newestPluginVersion(cacheRoot) {
|
|
62
|
+
let best = null;
|
|
63
|
+
let dirs;
|
|
64
|
+
try { dirs = readdirSync(cacheRoot); } catch { return null; }
|
|
65
|
+
for (const marketplace of dirs) {
|
|
66
|
+
const base = join(cacheRoot, marketplace, 'great_cto');
|
|
67
|
+
let versions;
|
|
68
|
+
try { versions = readdirSync(base); } catch { continue; }
|
|
69
|
+
for (const v of versions) {
|
|
70
|
+
if (!/^\d/.test(v)) continue;
|
|
71
|
+
if (best === null || cmpVersion(v, best) > 0) best = v;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return best;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @returns {{state: 'stale'|'current'|'no-plugin', cli: string, plugin: string|null}}
|
|
79
|
+
*
|
|
80
|
+
* Three states, and `no-plugin` is not a problem to report. Someone who wants
|
|
81
|
+
* only the CLI has a correct installation, and telling them to update a plugin
|
|
82
|
+
* they never installed is the false alarm that teaches people to ignore output.
|
|
83
|
+
*/
|
|
84
|
+
export function compare({ cli, plugin }) {
|
|
85
|
+
if (!plugin) return { state: 'no-plugin', cli, plugin: null };
|
|
86
|
+
return { state: cmpVersion(cli, plugin) > 0 ? 'stale' : 'current', cli, plugin };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function message({ cli, plugin }) {
|
|
90
|
+
return [
|
|
91
|
+
'',
|
|
92
|
+
` great-cto ${cli} is installed, but the Claude Code plugin is still ${plugin}.`,
|
|
93
|
+
'',
|
|
94
|
+
' They update separately. Until the plugin is updated, your agents, skills',
|
|
95
|
+
' and commands are the ones from ' + plugin + ' — pipelines will run, and they',
|
|
96
|
+
' will run the old definitions.',
|
|
97
|
+
'',
|
|
98
|
+
' In Claude Code: /plugin update great_cto',
|
|
99
|
+
'',
|
|
100
|
+
].join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function main() {
|
|
104
|
+
// CI installs nobody reads, and a notice there is noise in a build log.
|
|
105
|
+
if (process.env.CI || process.env.GREAT_CTO_QUIET_POSTINSTALL) return;
|
|
106
|
+
|
|
107
|
+
let cli;
|
|
108
|
+
try {
|
|
109
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
110
|
+
cli = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).version;
|
|
111
|
+
} catch { return; }
|
|
112
|
+
if (!cli) return;
|
|
113
|
+
|
|
114
|
+
const cacheRoot = join(homedir(), '.claude', 'plugins', 'cache');
|
|
115
|
+
if (!existsSync(cacheRoot)) return; // not a Claude Code machine — nothing to say
|
|
116
|
+
|
|
117
|
+
const r = compare({ cli, plugin: newestPluginVersion(cacheRoot) });
|
|
118
|
+
if (r.state === 'stale') process.stdout.write(message(r) + '\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Only when run as the hook, so the pure parts above stay importable by tests.
|
|
122
|
+
if (process.argv[1] && process.argv[1].endsWith('postinstall.mjs')) {
|
|
123
|
+
try { main(); } catch { /* an installer must never fail the install */ }
|
|
124
|
+
}
|