great-cto 2.98.0 → 2.99.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": "2.98.0",
5
+ "version": "2.99.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -11,7 +11,18 @@ import { log } from './log.mjs';
11
11
  // .beads/interactions.jsonl changes (the file watcher in watchBeads() calls
12
12
  // bdCacheInvalidate(cwd) before broadcasting). This avoids spawning `bd list`
13
13
  // on every API call when 5+ projects are open in tabs.
14
- const BD_CACHE_TTL_MS = 2000;
14
+ // Measured, not guessed: `bd list` on this repository takes 3.0–4.6 s and
15
+ // `bd --version` — pure process startup, no query — takes 1.13 s. At a 2 s TTL
16
+ // the entry expired before the call that filled it had returned, so a page load
17
+ // firing /api/inbox, /api/tasks and /api/metrics paid full price three times and
18
+ // every endpoint sat at ~5 s. A cache whose TTL is shorter than the operation it
19
+ // caches is not a cache; it is a counter of missed opportunities.
20
+ //
21
+ // 30 s is safe because staleness is bounded by invalidation, not by the clock:
22
+ // `bdCacheInvalidate` fires on every write through the board, and the file
23
+ // watchers fire when anything changes the store underneath us. The TTL is only
24
+ // the backstop for a change that arrived through neither.
25
+ const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 30000);
15
26
 
16
27
  function bdCacheInvalidate(cwd) { bdCache.delete(cwd); }
17
28
 
@@ -500,6 +511,7 @@ function detectAgent(task) {
500
511
 
501
512
  export {
502
513
  bdCacheInvalidate,
514
+ BD_CACHE_TTL_MS,
503
515
  BD_BIN,
504
516
  bdEnv,
505
517
  bd,
@@ -1144,8 +1144,32 @@ async function dispatch(req, res, url, cwd) {
1144
1144
 
1145
1145
  // Build version — so the board shows which great_cto version it's running.
1146
1146
  if (pathname === '/api/version') {
1147
+ // A board is a process, and a process keeps the version it started with.
1148
+ // This one served v2.95.0 for nine days while three installs in a row
1149
+ // reported success: the installer updated files, the running server kept
1150
+ // answering, and nothing compared the two. The installer restarts the board
1151
+ // now, but a board started any other way still needs to be able to say that
1152
+ // it is behind — an install the operator cannot see is not an install.
1153
+ //
1154
+ // Three states, not two. `unknown` when the cache cannot be read: a version
1155
+ // we failed to look up must not render as agreement.
1156
+ let installed = null;
1157
+ try {
1158
+ const root = path.join(os.homedir(), '.claude', 'plugins', 'cache', 'local', 'great_cto');
1159
+ const versions = fs.readdirSync(root).filter((d) => /^\d+\.\d+\.\d+$/.test(d));
1160
+ installed = versions.sort((a, b) => {
1161
+ const x = a.split('.').map(Number), y = b.split('.').map(Number);
1162
+ return (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]);
1163
+ }).pop() || null;
1164
+ } catch { installed = null; }
1165
+ const stale = installed && BUILD_VERSION !== 'unknown'
1166
+ ? (installed !== BUILD_VERSION ? 'yes' : 'no')
1167
+ : 'unknown';
1147
1168
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1148
- res.end(JSON.stringify({ version: BUILD_VERSION, surface: 'builder', node: process.version.replace(/^v/, '') }));
1169
+ res.end(JSON.stringify({
1170
+ version: BUILD_VERSION, installed, stale,
1171
+ surface: 'builder', node: process.version.replace(/^v/, ''),
1172
+ }));
1149
1173
  return true;
1150
1174
  }
1151
1175
 
@@ -153,6 +153,16 @@ button { font-family: inherit; cursor: pointer; }
153
153
  radial-gradient(ellipse 70% 50% at 100% 100%, rgba(0, 217, 126, 0.03), transparent 70%);
154
154
  }
155
155
 
156
+ .stale-board {
157
+ display: flex; align-items: center; gap: 10px;
158
+ margin: 0 0 12px; padding: 10px 14px;
159
+ background: rgba(180, 83, 9, 0.10);
160
+ border: 1px solid var(--status-progress);
161
+ border-radius: 8px;
162
+ font-size: 13px; color: var(--text);
163
+ }
164
+ .stale-board code { font-family: var(--mono); font-size: 12px; }
165
+
156
166
  /* ── Sidebar ──────────────────────────────────────────────────────────────── */
157
167
  .sidebar {
158
168
  position: relative; z-index: 1;
@@ -564,11 +574,7 @@ button { font-family: inherit; cursor: pointer; }
564
574
  cannot be un-advanced. Scoped to `pointer: coarse` so the desktop density that
565
575
  makes a long gate list scannable is left exactly as it is. 44px is the iOS
566
576
  minimum; Android asks for 48dp, and the gap does the rest. */
567
- @media (pointer: coarse) {
568
- .gate-btn { height: 44px; font-size: 13px; }
569
- .inbox-row .actions .gate-btn { height: 44px; font-size: 13px; }
570
- .gate-actions, .side-gate-actions, .inbox-row .actions { gap: 12px; }
571
- }
577
+
572
578
  .gate-approve { color: var(--status-review); border-color: rgba(22, 163, 74, 0.35); }
573
579
  .gate-approve:hover { background: rgba(22, 163, 74, 0.08); border-color: var(--status-review); }
574
580
  /* Agent runner (launch control) */
@@ -2241,8 +2247,16 @@ button { font-family: inherit; cursor: pointer; }
2241
2247
  .sidebar-scrim.open { opacity: 1; pointer-events: auto; }
2242
2248
  .menu-btn { display: inline-flex; }
2243
2249
  .topbar { padding: 0 10px; gap: 8px; }
2250
+ /* Raising every control to 44px gave the actions 223px of a 375px bar and
2251
+ crushed the breadcrumb to "g… / B." — present, unreadable, worse than absent.
2252
+ The project name is already at the top of the drawer and the tab name is the
2253
+ row you tapped to get here, so the phone keeps only where you are. */
2244
2254
  .topbar .crumbs { min-width: 0; overflow: hidden; white-space: nowrap; }
2245
2255
  .topbar .crumbs > span { overflow: hidden; text-overflow: ellipsis; }
2256
+ .topbar .crumbs .proj-icon,
2257
+ .topbar .crumbs .sep,
2258
+ .topbar .crumbs #crumb-project { display: none; }
2259
+ .topbar .crumbs .here { color: var(--text); font-weight: 600; }
2246
2260
  .topbar-actions { flex-shrink: 0; }
2247
2261
  /* The search box is the one topbar item that can give up its width; ⌘K still
2248
2262
  reaches it, and on a phone there is no ⌘K but there is the Search tab. */
@@ -2252,6 +2266,34 @@ button { font-family: inherit; cursor: pointer; }
2252
2266
  /* Icon-only, with the name moved to aria-label/title rather than deleted. */
2253
2267
  .btn-label { display: none; }
2254
2268
 
2269
+ /* The row's own arithmetic, measured rather than eyeballed: the page gives
2270
+ 64px to its side padding and the row another 30px to border and padding,
2271
+ leaving 281px for the tracks. The fixed ones — id 110, two 14px gaps, and a
2272
+ 44px Approve/Reject pair at about 156 — come to 294. The 1fr title column
2273
+ resolves to ZERO and the row overflows its own border box.
2274
+ Raising the buttons to a real touch target is what finished it off: at 24px
2275
+ they fit, at 44px they do not. Fixing one defect deepened its neighbour.
2276
+ So on a phone the row stops being three columns and becomes three rows:
2277
+ title first at full width, the id demoted to a meta line, the actions
2278
+ spanning the bottom. CSS only — the markup this renders from is shared with
2279
+ the desktop layout, and the desktop layout is fine. */
2280
+ .inbox-page { padding: 16px 16px 48px; }
2281
+ .inbox-row {
2282
+ display: flex; flex-wrap: wrap;
2283
+ gap: 8px 10px; align-items: baseline;
2284
+ }
2285
+ .inbox-row .ttl { order: 1; flex: 1 1 100%; font-size: 14px; }
2286
+ /* Two lines, wrapped — a phone has the vertical room it lacks sideways. */
2287
+ .inbox-row .ttl > :first-child { -webkit-line-clamp: 2; white-space: normal; }
2288
+ /* The id and the status share a line: stacked, each took a full row of its
2289
+ own and a four-line card said three things. */
2290
+ .inbox-row .id { order: 2; flex: 0 1 auto; padding-top: 0; }
2291
+ .inbox-row .actions { order: 3; margin-left: auto; }
2292
+ /* Except on a gate, where the line is a pair of 44px buttons and wants the
2293
+ whole width — the one row on this screen where the target size is the point. */
2294
+ .inbox-row .actions:has(.gate-btn) { flex: 1 0 100%; margin-left: 0; }
2295
+ .inbox-row .actions .gate-btn { flex: 1; }
2296
+
2255
2297
  /* Four columns of numbers do not fit in 375px; they were being clipped, which
2256
2298
  silently dropped the fourth stat rather than wrapping it. */
2257
2299
  .inbox-summary { grid-template-columns: repeat(2, 1fr); }
@@ -2259,6 +2301,29 @@ button { font-family: inherit; cursor: pointer; }
2259
2301
  .mp-hero { grid-template-columns: 1fr; }
2260
2302
  .mp-row { grid-template-columns: 1fr; }
2261
2303
  }
2304
+ @media (pointer: coarse) {
2305
+ /* A floor, not a list.
2306
+ Placed here, at the END of the stylesheet, for the same reason the
2307
+ phone-width block is: at equal specificity the later rule wins. Written
2308
+ higher up, its `.inbox-row .actions { gap: 12px }` lost to the base rule's
2309
+ 4px declared 900 lines below it — so Approve and Reject sat 4px apart on a
2310
+ touch screen while a block that read as correct sat above saying 12.
2311
+ A declaration that loses is not a declaration.
2312
+ The first version of this block named .gate-btn and stopped there, because
2313
+ those were the buttons a review had pointed at. Two controls this same file
2314
+ had just added — the drawer's own hamburger at 40px and the topbar's icon
2315
+ buttons at ~32px — sat under the minimum from the day they were written,
2316
+ and nothing objected, because the rule was a list of what somebody had
2317
+ remembered rather than a property of the page.
2318
+ So: every control gets the floor, and the ones with something to say about
2319
+ their own size say it after. A button added tomorrow is covered by default;
2320
+ one that must stay small has to opt out in writing. */
2321
+ button, [role="button"], .nav-item, .toggle, summary { min-height: 44px; }
2322
+ .gate-btn, .inbox-row .actions .gate-btn { height: 44px; font-size: 13px; }
2323
+ .menu-btn { width: 44px; height: 44px; }
2324
+ .btn-bell { min-width: 44px; justify-content: center; }
2325
+ .gate-actions, .side-gate-actions, .inbox-row .actions { gap: 12px; }
2326
+ }
2262
2327
  @keyframes pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(76,175,80,.6);} 100% { box-shadow: 0 0 0 6px rgba(76,175,80,0);} }
2263
2328
  /* ── Scrollbar ────────────────────────────────────────────────────────────── */
2264
2329
  ::-webkit-scrollbar { width: 10px; height: 10px; }
@@ -4509,6 +4574,7 @@ function connectSSE() {
4509
4574
  // Sidebar counter FIRST, independent of render
4510
4575
  updateNavCounts();
4511
4576
  try { renderKanban(filterTasks(searchQuery)); } catch (e) { console.warn('SSE renderKanban failed', e); }
4577
+ checkBoardFreshness();
4512
4578
  refreshInbox();
4513
4579
  };
4514
4580
  es.addEventListener('tasks', e => {
@@ -5397,6 +5463,33 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSide();
5397
5463
  // Three things a drawer owes a keyboard, all of them things a transform alone
5398
5464
  // does not give you: it leaves the tab order when closed, Escape closes it, and
5399
5465
  // focus moves in on open and back to the button that opened it on close.
5466
+ /* The board tells on itself.
5467
+ *
5468
+ * A board is a process, and a process keeps the version it started with. This
5469
+ * one served v2.95.0 for nine days while three installs in a row reported
5470
+ * success — the installer wrote files, the running server kept answering, and
5471
+ * nothing compared the two. The installer restarts the board now; this is for a
5472
+ * board started any other way, so that being behind is visible rather than
5473
+ * merely true.
5474
+ *
5475
+ * Three states, like everywhere else on this page: `yes` says so, `no` is
5476
+ * silent, and `unknown` — the plugin cache could not be read — is also silent,
5477
+ * because a comparison we could not make must not be announced as a mismatch.
5478
+ */
5479
+ async function checkBoardFreshness() {
5480
+ const v = await api('/api/version');
5481
+ if (!v || v.stale !== 'yes') return;
5482
+ const host = document.querySelector('.workspace');
5483
+ if (!host || document.getElementById('stale-board')) return;
5484
+ const el = document.createElement('div');
5485
+ el.id = 'stale-board';
5486
+ el.className = 'stale-board';
5487
+ el.setAttribute('role', 'status');
5488
+ el.innerHTML = `<span>This board is running <code>v${esc(v.version)}</code> — `
5489
+ + `<code>v${esc(v.installed)}</code> is installed. Restart it to pick up the new version.</span>`;
5490
+ host.insertBefore(el, host.firstChild);
5491
+ }
5492
+
5400
5493
  function menuIsOpen() {
5401
5494
  return !!document.getElementById('sidebar')?.classList.contains('open');
5402
5495
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.98.0",
3
+ "version": "2.99.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",