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.15.0",
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 pending = openGates.length;
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
- status: pending > 0 ? 'active' : 'idle',
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
- : 'no gate pending',
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/architecture/ADR-0NN-*.md` files were being filed as
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: #04140d;
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: #0f1115; color: #fff; border: none;
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: #2a2d33; }
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: #1f2937; color: #fff;
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: #14532d; border-left: 3px solid #22c55e; }
915
- .toast-error { background: #7f1d1d; border-left: 3px solid #ef4444; }
916
- .toast-info { background: #1e3a5f; border-left: 3px solid #3b82f6; }
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: #4ade80; }
995
- .rdm-head .rdm-verdict.fail { color: #f87171; }
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: #0a0e0c; font-weight: 500;
1131
+ color: var(--bg-page); font-weight: 500;
1110
1132
  }
1111
- .ni-btn-primary:hover { background: #00ee8a; }
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: #fff; }
1307
- .tier-badge.tier-T1 { background: var(--status-progress); color: #fff; }
1308
- .tier-badge.tier-T2 { background: var(--status-blocked); color: #fff; }
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
- flex: 1; min-width: 100px;
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: #04150d; font-weight: 500; }
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: #fff;
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: #0f1115; color: #fff;
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: #f97316; }
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: #7c3aed; }
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: #0f1115; color: #fff; border: none;
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: #fff; border-color: transparent; }
2535
- .fchip-agent.active { background: #2540d4; }
2536
- .fchip-priority.active { background: #f59e0b; color: #000; }
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
- document.getElementById('inbox-summary').innerHTML = `
3438
- <div class="pill-stat"><span class="dot dot-purple"></span><div><div class="num">${s.gates}</div><div class="lbl">Pending decisions</div></div></div>
3439
- <div class="pill-stat"><span class="dot dot-red"></span><div><div class="num">${s.p0}</div><div class="lbl">P0 open</div></div></div>
3440
- <div class="pill-stat"><span class="dot dot-orange"></span><div><div class="num">${s.blocked}</div><div class="lbl">Blocked</div></div></div>
3441
- <div class="pill-stat"><span class="dot dot-amber"></span><div><div class="num">${s.stale}</div><div class="lbl">Stale &gt; 48h</div></div></div>
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 &gt; 48h' },
3482
+ ];
3483
+ const liveTiles = TILES.filter((t) => Number(t.n) > 0);
3484
+ document.getElementById('inbox-summary').innerHTML = liveTiles.length
3485
+ ? liveTiles.map((t) => `<div class="pill-stat"><span class="dot dot-${t.dot}"></span>`
3486
+ + `<div><div class="num">${t.n}</div><div class="lbl">${t.lbl}</div></div></div>`).join('')
3487
+ : `<div class="pill-stat" style="grid-column:1/-1"><span class="dot dot-green"></span>`
3488
+ + `<div><div class="lbl">No gates, no P0s, nothing blocked or stale.</div></div></div>`;
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>${(d.changed || []).length + (d.removed || []).length} reviewed file(s) changed since ${stamp} approved</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
- <span class="muted">${esc((d.changed || []).slice(0, 4).join(', '))}${(d.changed || []).length > 4 ? ' …' : ''}</span>`
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
- const wip = d.wip_tasks || [];
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
- wipEl.innerHTML = wip.length ? wip.map(t => {
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:#3b82f6;border-radius:50%;flex-shrink:0;margin-top:5px"></span>'}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",