great-cto 3.75.9 → 3.76.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.
Files changed (29) hide show
  1. package/board/.claude-plugin/plugin.json +1 -1
  2. package/board/agents/architect.md +4 -0
  3. package/board/agents/ci-resolver.md +1 -0
  4. package/board/agents/design-advisor.md +28 -0
  5. package/board/agents/devops.md +1 -0
  6. package/board/agents/product-owner.md +13 -1
  7. package/board/agents/senior-dev.md +25 -9
  8. package/board/packages/board/lib/alerts.mjs +46 -1
  9. package/board/packages/board/lib/gate-run.sh +30 -0
  10. package/board/packages/board/lib/instant-alerts.mjs +159 -0
  11. package/board/packages/board/lib/merge-readiness.mjs +315 -0
  12. package/board/packages/board/lib/routes.mjs +29 -3
  13. package/board/packages/board/openapi.json +2996 -0
  14. package/board/packages/board/public/assets/icon-192.png +0 -0
  15. package/board/packages/board/public/assets/icon-512.png +0 -0
  16. package/board/packages/board/public/assets/icon-maskable-512.png +0 -0
  17. package/board/packages/board/public/assets/work-view.js +83 -3
  18. package/board/packages/board/public/index.html +112 -1
  19. package/board/packages/board/public/manifest.webmanifest +16 -0
  20. package/board/packages/board/public/sw.js +1 -1
  21. package/board/packages/board/route-coverage-baseline.json +25 -0
  22. package/board/packages/board/server.mjs +8 -1
  23. package/board/scripts/lib/gate-run-record.mjs +75 -0
  24. package/board/scripts/lib/handoff-state.mjs +478 -0
  25. package/board/scripts/lib/workspace.mjs +277 -0
  26. package/board/scripts/lib/worktree-state.mjs +128 -0
  27. package/board/shared/pipeline.toml +29 -0
  28. package/dist/release-activation.mjs +42 -10
  29. package/package.json +1 -1
@@ -251,6 +251,84 @@
251
251
  svg.dataset.zoom = String(next); svg.setAttribute('width', String(bounds.width * next)); svg.setAttribute('height', String(bounds.height * next));
252
252
  return true;
253
253
  }
254
+ // ── Merge readiness (design v12): on top of Overview, filled after the paint because gh is slow. ──
255
+ const READINESS_MS = 30000;
256
+ const readiness = new Map();
257
+ const MARKS = { pass: ['✓', 'passed'], fail: ['✗', 'failed'], 'not-read': ['✗', 'not read'], na: ['–', 'not required'] };
258
+ const ago = iso => { const min = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000)); return min ? `${min} min ago` : 'just now'; };
259
+ const safeHref = href => /^https:\/\/(github\.com|claude\.ai)\//.test(href || '') ? href : null;
260
+ function readinessAction(action) {
261
+ if (!action) return '';
262
+ if (action.kind === 'link') return safeHref(action.href) ? `<a class="rdy-link" href="${esc(action.href)}" target="_blank" rel="noopener">${esc(action.label)}</a>` : '';
263
+ if (action.kind === 'tab') return `<button type="button" class="rdy-link" data-work-tab="${esc(action.tab)}">${esc(action.label)}</button>`;
264
+ if (action.kind === 'issue') return `<button type="button" class="rdy-link" data-readiness-issue="${esc(action.issue)}"><span class="rdy-wide">${esc(action.label)}</span><span class="rdy-narrow">${esc(action.narrowLabel || action.label)}</span></button>`;
265
+ if (action.kind === 'copy') return `<button type="button" class="rdy-btn" data-readiness-copy="${esc(action.text)}">${esc(action.label)}</button>`;
266
+ if (action.kind === 'run-gate') return `<button type="button" class="rdy-btn" data-readiness-run>${esc(action.label)}</button>`;
267
+ return `<button type="button" class="rdy-btn" disabled>${esc(action.label)}</button>`;
268
+ }
269
+ function readinessRow(r) {
270
+ const [mark, label] = MARKS[r.state] || MARKS['not-read'];
271
+ const queue = r.queue ? ` New run: <b class="rdy-queue">${esc(r.queue)}</b>` : '';
272
+ const queueBrief = r.queue ? ` New run <b class="rdy-queue">${esc(r.queueBrief || r.queue)}</b>` : '';
273
+ const text = `<span class="rdy-detail"><span class="rdy-wide">${esc(r.detail)}${queue}</span><span class="rdy-narrow">${esc(r.brief || r.detail)}${queueBrief}</span></span>`;
274
+ return `<li class="rdy-row" data-state="${esc(r.state)}"><span class="rdy-mark" aria-label="${label}">${mark}</span><span class="rdy-label">${esc(r.label)}</span>${text}${readinessAction(r.action)}</li>`;
275
+ }
276
+ function renderReadiness(card) {
277
+ const noun = `${card.blockers} blocker${card.blockers === 1 ? '' : 's'}`;
278
+ const wide = card.ready ? 'Ready to merge' : `Ready to merge? Not yet — ${noun}`;
279
+ const narrow = card.ready ? 'Ready to merge' : `Not ready — ${noun}`;
280
+ const when = ago(card.checkedAt);
281
+ return `<section class="rdy" aria-labelledby="rdy-h" data-ready="${card.ready}"><div class="rdy-head"><div class="rdy-count">${card.ready ? '✓' : card.blockers}</div>`
282
+ + `<div class="rdy-title"><h2 id="rdy-h"><span class="rdy-wide">${esc(wide)}</span><span class="rdy-narrow">${esc(narrow)}</span></h2>`
283
+ + `<div class="rdy-sub"><span class="rdy-wide">Checked against head ${esc(card.shortHead)} · ${esc(when)}</span><span class="rdy-narrow">Head ${esc(card.shortHead)} · checked ${esc(when)}</span></div></div>`
284
+ + `<div class="rdy-note"><span class="rdy-wide">The board does not merge. When every row is green it shows the merge command to copy, or you ask an agent to merge.</span><span class="rdy-narrow">The board does not merge; when all rows pass it shows the command to copy.</span></div></div>`
285
+ + `<ul class="rdy-rows"><li class="rdy-group" data-group="blockers">BLOCKERS FIRST</li><li class="rdy-group" data-group="passed">PASSED</li>${card.rows.map(readinessRow).join('')}</ul>`
286
+ + `<div class="rdy-foot">A row that could not be read shows “Not read” and counts as a blocker, never as passed.</div></section>`
287
+ + (card.mergeCommand ? `<section class="rdy-ok" aria-label="Ready to merge"><span class="rdy-ok-mark" aria-hidden="true">✓</span><b>Ready to merge</b><code>${esc(card.mergeCommand)}</code><button type="button" class="rdy-btn" data-readiness-copy="${esc(card.mergeCommand)}">Copy command</button></section>` : '');
288
+ }
289
+ function readinessHtml(state) {
290
+ if (!state?.card && !state?.failed) return '';
291
+ if (state.failed) return '<p class="work-warning">Merge readiness could not be read. No row is assumed to pass.</p>';
292
+ return state.card.branch ? renderReadiness(state.card) : '';
293
+ }
294
+ const readinessSlot = entry => `<div data-readiness-slot="${esc(entry.key)}">${readinessHtml(readiness.get(entry.key))}</div>`;
295
+ async function hydrateReadiness(force = false) {
296
+ const slot = document.querySelector?.('[data-readiness-slot]');
297
+ if (!slot) return;
298
+ const key = slot.dataset.readinessSlot, project = config.project(), held = readiness.get(key);
299
+ if (!force && held && Date.now() - held.at < READINESS_MS) return;
300
+ readiness.set(key, { ...held, at: Date.now() });
301
+ let next;
302
+ try {
303
+ const card = await config.read(`/api/work/readiness?task=${encodeURIComponent(key)}${project ? '&project=' + encodeURIComponent(project) : ''}`);
304
+ next = !card || card.error ? { failed: true, at: Date.now() } : { card, at: Date.now() };
305
+ } catch { next = { failed: true, at: Date.now() }; }
306
+ if (project !== config.project()) return;
307
+ readiness.set(key, next);
308
+ const live = document.querySelector?.('[data-readiness-slot]');
309
+ if (live?.dataset.readinessSlot === key) live.innerHTML = readinessHtml(next);
310
+ }
311
+ async function readinessClick(event) {
312
+ const copy = event.target.closest?.('[data-readiness-copy]');
313
+ if (copy) {
314
+ try {
315
+ if (!navigator.clipboard) throw Error('no clipboard');
316
+ await navigator.clipboard.writeText(copy.dataset.readinessCopy);
317
+ } catch { status('Could not copy. Select the text and copy it by hand.'); return; }
318
+ const label = copy.textContent; copy.textContent = 'Copied'; setTimeout(() => { copy.textContent = label; }, 1500);
319
+ return;
320
+ }
321
+ const issue = event.target.closest?.('[data-readiness-issue]');
322
+ if (issue) { config.openIssue?.(issue.dataset.readinessIssue); return; }
323
+ const start = event.target.closest?.('[data-readiness-run]');
324
+ if (!start) return;
325
+ start.disabled = true;
326
+ const slot = start.closest('[data-readiness-slot]'), project = config.project();
327
+ const result = await config.publish(`/api/work/readiness/run-gate?task=${encodeURIComponent(slot.dataset.readinessSlot)}${project ? '&project=' + encodeURIComponent(project) : ''}`, {}).catch(() => null);
328
+ if (!result || result.error) status(result?.error || 'The gate could not be started.');
329
+ await hydrateReadiness(true);
330
+ }
331
+
254
332
  function renderCockpit(data, options = {}) {
255
333
  if (options.canvas) return renderWorkspace(data, options);
256
334
  const category = categories.some(([key]) => key === options.category) ? options.category : 'active';
@@ -267,7 +345,7 @@
267
345
  // Only the selected Overview owns mutation controls; observation tabs cannot duplicate them.
268
346
  const detail = tab === 'execution' ? renderExecution(selected) : tab === 'evidence'
269
347
  ? (Array.isArray(selected.evidenceSections) && selected.evidenceSections.length ? renderEvidence(selected.evidenceSections).replace('<details ', '<details open ') : '<p>Evidence unavailable. No verification or acceptance is inferred.</p>')
270
- : render({ ...data, sources: [], sessions: [], entries: [{ ...selected, inspector: null, evidenceSections: null }] }, selected.terminal);
348
+ : readinessSlot(selected) + render({ ...data, sources: [], sessions: [], entries: [{ ...selected, inspector: null, evidenceSections: null }] }, selected.terminal);
271
349
  return toolbar + (tab === 'overview' ? '' : observed) + feedback + '<p class="work-meta">Running means host activity was recorded; current liveness is not measured. Closed records do not by themselves prove acceptance.</p>'
272
350
  + `<div class="work-cockpit">${list}<section class="work-detail" aria-label="Selected task details">${tabs}<div id="work-detail-panel" role="tabpanel" aria-labelledby="work-tab-${tab}" tabindex="0">${detail}</div></section></div>`
273
351
  + (data.sessions.length ? `<details><summary>Claude sessions (${data.sessions.length}) · task linkage not recorded</summary>${data.sessions.map(s => `<p>${esc(s.session)} · ${esc(s.state)} · ${esc(s.reason || '')}</p>`).join('')}</details>` : '');
@@ -316,7 +394,7 @@
316
394
  const tabs = '<div class="work-tabs" role="tablist" aria-label="Selected task">' + taskTabs.map(name => `<button type="button" class="work-tab" id="work-tab-${name}" data-work-tab="${name}" role="tab" aria-selected="${tab === name}" aria-controls="work-detail-panel" tabindex="${tab === name ? 0 : -1}">${name[0].toUpperCase() + name.slice(1)}</button>`).join('') + '</div>';
317
395
  const detail = tab === 'execution' ? renderExecution(selected, { canvas: true, direction: options.direction }) : tab === 'evidence'
318
396
  ? (Array.isArray(selected.evidenceSections) && selected.evidenceSections.length ? renderEvidence(selected.evidenceSections).replace('<details ', '<details open ') : '<p>Evidence unavailable. No verification or acceptance is inferred.</p>')
319
- : render({ ...data, sources: [], sessions: [], entries: [{ ...selected, inspector: null, evidenceSections: null }] }, selected.terminal);
397
+ : readinessSlot(selected) + render({ ...data, sources: [], sessions: [], entries: [{ ...selected, inspector: null, evidenceSections: null }] }, selected.terminal);
320
398
  const runLabel = selected.title.match(/^Controlled run ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
321
399
  const title = runLabel ? `Controlled run <small class="work-meta work-run-identifier">${esc(runLabel[1].slice(0, 8))}</small>` : esc(selected.title);
322
400
  return `<section class="work-workspace work-detail-only" data-selected-work="${esc(selected.key)}" data-observation-scope="${esc(JSON.stringify([options.observationProject || data.projectId || null, selected.key]))}"><header class="work-task-header"><div class="work-task-navigation">${back}<span class="work-meta">${esc(selected.kind)} · ${esc(selected.host || 'Host not linked')}</span><button type="button" data-work-switch>Switch task</button><button type="button" data-work-previous${rows.findIndex(e => e.key === selected.key) > 0 ? '' : ' disabled'} aria-label="Previous task in current filter">Previous</button><button type="button" data-work-next${rows.findIndex(e => e.key === selected.key) >= 0 && rows.findIndex(e => e.key === selected.key) < rows.length - 1 ? '' : ' disabled'} aria-label="Next task in current filter">Next</button></div><h2>${title}</h2><p>${esc(phaseLabel(selected))}</p></header>${tabs}${feedback}${tab === 'overview' ? '' : observation}<div id="work-detail-panel" role="tabpanel" aria-labelledby="work-tab-${tab}" tabindex="0">${detail}</div></section>`;
@@ -495,6 +573,7 @@
495
573
  const graphScroll = oldCanvas?.closest('[data-selected-work]')?.dataset.selectedWork === selectedKey && oldSvg?.dataset.graphLayout === graphDirection ? { left: oldCanvas.scrollLeft, top: oldCanvas.scrollTop } : null;
496
574
  document.getElementById('work-list').innerHTML = renderCockpit(snapshot, { search: filter, selectedKey, category, tab, canvas: !!config.canvas, direction: graphDirection, observationProject: snapshotProject });
497
575
  document.getElementById('history-list').innerHTML = render(snapshot, true, filter);
576
+ hydrateReadiness();
498
577
  if (graphScroll) {
499
578
  const canvas = document.querySelector?.('.execution-graph-canvas'), svg = canvas?.querySelector('svg');
500
579
  if (svg && Number.isFinite(oldZoom) && oldZoom > 0) { const bounds = svg.viewBox.baseVal; svg.dataset.zoom = String(oldZoom); svg.setAttribute('width', String(bounds.width * oldZoom)); svg.setAttribute('height', String(bounds.height * oldZoom)); canvas.scrollLeft = graphScroll.left; canvas.scrollTop = graphScroll.top; }
@@ -531,6 +610,7 @@
531
610
  document.getElementById('work-allow-row').hidden = event.target.value !== 'codex';
532
611
  document.getElementById('work-command').textContent = ''; status('');
533
612
  });
613
+ document.getElementById('work-list').addEventListener('click', readinessClick);
534
614
  for (const id of ['work-list', 'history-list']) document.getElementById(id).addEventListener('click', event => {
535
615
  if (config.canvas && snapshotProject !== config.project()) return;
536
616
  const pageButton = event.target.closest('[data-host-graph-next]');
@@ -578,5 +658,5 @@
578
658
  });
579
659
  setInterval(() => { if (!document.hidden && options.visible()) refresh(); }, 10000);
580
660
  }
581
- window.GctoWork = { bind, refresh, reset, leaveView, parseRoute, navigateRoute, render, renderCockpit, renderExecutionGraph, commandFor, publicationCommand, getSnapshot() { return snapshot; }, showCompleted() { category = 'completed'; selectedKey = null; tab = 'overview'; notifyView(); paint(); }, search(value) { filter = value; if (config?.canvas) { if (!selectedKey) changeWorkspace(); } else paint(); } };
661
+ window.GctoWork = { bind, refresh, reset, leaveView, parseRoute, navigateRoute, render, renderCockpit, renderReadiness, renderExecutionGraph, commandFor, publicationCommand, getSnapshot() { return snapshot; }, showCompleted() { category = 'completed'; selectedKey = null; tab = 'overview'; notifyView(); paint(); }, search(value) { filter = value; if (config?.canvas) { if (!selectedKey) changeWorkspace(); } else paint(); } };
582
662
  })();
@@ -23,6 +23,8 @@
23
23
  <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png" />
24
24
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16.png" />
25
25
  <link rel="shortcut icon" href="/assets/favicon.ico" />
26
+ <link rel="manifest" href="/manifest.webmanifest" />
27
+ <link rel="apple-touch-icon" href="/assets/apple-touch-icon.png" />
26
28
  <link href="/assets/fonts/fonts.css" rel="stylesheet" />
27
29
  <link href="/assets/admin-shell.css" rel="stylesheet" />
28
30
  <script src="/assets/vendor/marked.min.js"></script>
@@ -95,6 +97,7 @@
95
97
  --focus-ring: #00d97e;
96
98
  --rail-ok-bg: rgba(0, 217, 126, 0.12);
97
99
  --rail-fail-bg: rgba(255, 84, 102, 0.12);
100
+ --rdy-fail-bg: rgba(255, 84, 102, 0.06);
98
101
  --surface-drawer: #0f1411;
99
102
 
100
103
  --accent: #00d97e;
@@ -217,6 +220,7 @@
217
220
  --surface-translucent: rgba(255, 255, 255, 0.72);
218
221
  --rail-ok-bg: rgba(10, 125, 77, 0.12);
219
222
  --rail-fail-bg: rgba(196, 48, 48, 0.12);
223
+ --rdy-fail-bg: rgba(196, 48, 48, 0.05);
220
224
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.08);
221
225
  --shadow-card-hover: 0 1px 3px rgba(0, 0, 0, 0.12), 0 8px 24px rgba(0, 168, 98, 0.08);
222
226
  --drawer-shadow: 0 16px 32px rgba(0, 0, 0, 0.18);
@@ -668,6 +672,16 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
668
672
  display: flex; align-items: center; transition: border-color .15s, color .15s;
669
673
  }
670
674
  .btn-bell:hover { border-color: var(--border-strong); color: var(--text); }
675
+ /* Install app: shown only after the browser fires beforeinstallprompt, hidden in the
676
+ installed window (display-mode: standalone). */
677
+ .btn-install {
678
+ display: none; align-items: center; gap: 6px; padding: 6px 10px; cursor: pointer;
679
+ border: 1px solid var(--accent); border-radius: 0; background: var(--accent-soft);
680
+ color: var(--text); font: inherit; font-size: var(--fs-small); font-weight: 500; white-space: nowrap;
681
+ }
682
+ .btn-install.on { display: flex; }
683
+ .btn-install:hover { background: var(--accent-strong); }
684
+ @media (display-mode: standalone) { .btn-install.on { display: none; } }
671
685
  .btn-bell-badge {
672
686
  position: absolute; top: -5px; right: -5px;
673
687
  background: var(--accent); color: var(--on-accent);
@@ -2963,6 +2977,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2963
2977
  .notif-row .emoji { font-size: 16px; flex-shrink: 0; }
2964
2978
  .notif-row strong { color: var(--text); margin-right: 4px; }
2965
2979
  .notif-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
2980
+ .notif-new { font-family: var(--mono); font-size: var(--fs-eyebrow); color: var(--accent-text); border: 1px solid var(--accent-text); padding: 1px 6px; margin-left: 4px; }
2966
2981
 
2967
2982
  .toggle {
2968
2983
  width: 38px; height: 22px;
@@ -3390,6 +3405,57 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3390
3405
  .work-meta { color: var(--text2); font-size: var(--fs-caption); }
3391
3406
  .work-card [data-work-detail="evidence"] dt { margin-top: 12px; font-weight: 600; }
3392
3407
  .work-card [data-work-detail="evidence"] dd { margin: 4px 0 12px; color: var(--text2); font-size: var(--fs-body); overflow-wrap: anywhere; }
3408
+ .rdy { margin: 18px 0 16px; border: 1px solid var(--border-strong); border-radius: 12px; overflow: hidden; }
3409
+ .rdy-head { display: flex; align-items: center; gap: 14px; padding: 16px 20px; background: var(--bg-muted); flex-wrap: wrap; }
3410
+ .rdy-count { width: 36px; height: 36px; border-radius: 50%; background: var(--p0-fg); color: var(--bg-card); display: flex; align-items: center; justify-content: center; font-weight: 600; flex-shrink: 0; }
3411
+ .rdy[data-ready="true"] .rdy-count { background: var(--accent-text); }
3412
+ .rdy-title { flex: 1; min-width: 220px; }
3413
+ .work-page .rdy-title h2, .rdy-title h2 { margin: 0; font-size: var(--fs-title-s); font-weight: 600; }
3414
+ .rdy-sub { font-size: var(--fs-small); color: var(--text2); margin-top: 2px; }
3415
+ .rdy-note { font-size: var(--fs-small); color: var(--text2); max-width: 300px; }
3416
+ .rdy-narrow { display: none; }
3417
+ .rdy-rows { list-style: none; margin: 0; padding: 0; font-size: var(--fs-body); display: flex; flex-direction: column; }
3418
+ .rdy-group { display: none; }
3419
+ .rdy-row { display: flex; gap: 12px; align-items: center; padding: 12px 20px; border-top: 1px solid var(--border); flex-wrap: wrap; }
3420
+ .rdy-row[data-state="fail"], .rdy-row[data-state="not-read"] { background: var(--rdy-fail-bg); }
3421
+ .rdy-mark { width: 22px; font-weight: 600; color: var(--text2); }
3422
+ .rdy-row[data-state="pass"] .rdy-mark { color: var(--accent-text); }
3423
+ .rdy-row[data-state="fail"] .rdy-mark, .rdy-row[data-state="not-read"] .rdy-mark { color: var(--status-blocked-ink); }
3424
+ .rdy-label { width: 170px; font-weight: 500; }
3425
+ .rdy-detail { flex: 1; color: var(--text2); min-width: 200px; }
3426
+ .rdy-queue { color: var(--absent-warn-fg); font-weight: 500; }
3427
+ .rdy-link { font-size: var(--fs-small); color: var(--text); text-decoration: underline; background: none; border: 0; padding: 0; font-family: inherit; cursor: pointer; }
3428
+ .rdy-btn { height: 34px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--border-strong); background: var(--bg-card); color: var(--text); font: inherit; font-size: var(--fs-small); cursor: pointer; }
3429
+ .rdy-btn:disabled { color: var(--text2); cursor: default; }
3430
+ .rdy-foot { padding: 10px 20px; border-top: 1px solid var(--border); font-size: var(--fs-caption); color: var(--text2); }
3431
+ .rdy-ok { margin: 16px 0; border: 1px solid var(--accent-text); border-radius: 12px; padding: 14px 20px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; font-size: var(--fs-body); }
3432
+ .rdy-ok-mark { width: 32px; height: 32px; border-radius: 50%; background: var(--accent-text); color: var(--bg-card); display: flex; align-items: center; justify-content: center; font-weight: 600; }
3433
+ .rdy-ok b { font-weight: 600; flex: 1; min-width: 200px; }
3434
+ .rdy-ok code { font-family: var(--mono); font-size: var(--fs-caption); background: var(--bg-muted); padding: 8px 10px; border-radius: 6px; overflow-wrap: anywhere; }
3435
+ @media (max-width: 600px) {
3436
+ .rdy { margin: 12px 0; }
3437
+ .rdy-wide { display: none; }
3438
+ .rdy-narrow { display: inline; }
3439
+ .rdy-head { flex-direction: column; align-items: stretch; gap: 10px; padding: 14px; }
3440
+ .rdy-title { min-width: 0; }
3441
+ .rdy-count { width: 32px; height: 32px; }
3442
+ .rdy-title h2 { font-size: var(--fs-title-s); }
3443
+ .rdy-note, .rdy-sub { font-size: var(--fs-caption); max-width: none; }
3444
+ .rdy-group { display: block; order: 0; padding: 10px 14px; font-size: var(--fs-caption); font-weight: 600; color: var(--p0-fg); border-top: 1px solid var(--border); }
3445
+ .rdy-group[data-group="passed"] { order: 2; color: var(--accent-text); }
3446
+ .rdy-row { order: 3; padding: 10px 14px; font-size: var(--fs-small); color: var(--text2); gap: 4px; flex-wrap: nowrap; }
3447
+ .rdy-row .rdy-mark { width: auto; margin-right: 2px; }
3448
+ .rdy-row .rdy-label { width: auto; font-weight: 400; }
3449
+ .rdy-row:not([data-state="fail"]):not([data-state="not-read"]) .rdy-label::after { content: " ·"; }
3450
+ .rdy-row:not([data-state="fail"]):not([data-state="not-read"]) .rdy-detail { min-width: 0; }
3451
+ .rdy-row:not([data-state="fail"]):not([data-state="not-read"]) .rdy-link, .rdy-row:not([data-state="fail"]):not([data-state="not-read"]) .rdy-btn { display: none; }
3452
+ .rdy-row[data-state="fail"], .rdy-row[data-state="not-read"] { order: 1; flex-wrap: wrap; padding: 12px 14px; gap: 8px; font-size: var(--fs-body); color: var(--text); }
3453
+ .rdy-row[data-state="fail"] .rdy-label, .rdy-row[data-state="not-read"] .rdy-label { font-weight: 500; flex: 1; }
3454
+ .rdy-row[data-state="fail"] .rdy-detail, .rdy-row[data-state="not-read"] .rdy-detail { flex: 0 0 100%; min-width: 0; font-size: var(--fs-small); }
3455
+ .rdy-row[data-state="fail"] .rdy-btn, .rdy-row[data-state="not-read"] .rdy-btn { flex: 0 0 100%; height: 44px; }
3456
+ .rdy-row[data-state="fail"] .rdy-link, .rdy-row[data-state="not-read"] .rdy-link { min-height: 44px; display: flex; align-items: center; }
3457
+ .rdy-foot { display: none; }
3458
+ }
3393
3459
  .work-warning { color: var(--absent-warn-fg); background: var(--bg-card); border: 1px solid var(--absent-warn-fg); padding: 12px; }
3394
3460
  .work-page pre { font-weight: 400; white-space: pre-wrap; overflow-wrap: anywhere; margin: 12px 0; }
3395
3461
  #work-compose textarea, #work-compose input, #work-compose select { display: block; width: 100%; margin: 8px 0 12px; padding: 8px; background: var(--bg-muted); color: var(--text); border: 1px solid var(--border-strong); border-radius: 4px; }
@@ -3534,6 +3600,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3534
3600
  (#topbar-bell, opened the drawer) are deleted as top-level surfaces
3535
3601
  here — great_cto-ki1x.5. Their panels/functions are untouched for
3536
3602
  .11 to re-wire as Settings rows; nothing here calls them anymore. -->
3603
+ <button class="btn-install" id="install-app-btn" type="button" onclick="installBoardApp()" title="Install the board as an app: own window and Dock icon">
3604
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>
3605
+ <span class="btn-label">Install app</span>
3606
+ </button>
3537
3607
  <button class="btn-bell" id="board-theme-btn" onclick="toggleBoardTheme()" title="Toggle light / dark theme" aria-label="Toggle theme"><svg class="ico-moon" viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 9.5A6 6 0 0 1 6.5 2.5a6 6 0 1 0 7 7z"></path></svg><svg class="ico-sun" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="3"></circle><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"></path></svg></button>
3538
3608
  <button class="btn-bell" id="topbar-settings-btn" data-tab="settings" onclick="switchTab('settings', this)" title="Settings" aria-label="Settings" style="gap:6px;padding:6px 10px;">
3539
3609
  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
@@ -4051,6 +4121,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
4051
4121
  <label class="notif-row"><input type="checkbox" data-trigger="gate.blocked"> <span class="emoji">🛡️</span><strong>Security BLOCKED</strong> — security-officer rejected a merge</label>
4052
4122
  <label class="notif-row"><input type="checkbox" data-trigger="cost.threshold"> <span class="emoji">💸</span><strong>Budget alert</strong> — LLM spend &gt; 80% / 100% of monthly cap</label>
4053
4123
  <label class="notif-row"><input type="checkbox" data-trigger="digest.weekly"> <span class="emoji">📊</span><strong>Weekly digest</strong> — Friday 09:00 DORA recap</label>
4124
+ <label class="notif-row"><input type="checkbox" data-trigger="agent.blocked"> <span class="emoji">✋</span><strong>Agent blocked</strong> — a session is waiting for your input <span class="notif-new">NEW</span></label>
4125
+ <label class="notif-row"><input type="checkbox" data-trigger="gate.local_red"> <span class="emoji">🔴</span><strong>Local gate red</strong> — a ci-local run finished with failures <span class="notif-new">NEW</span></label>
4126
+ <label class="notif-row"><input type="checkbox" data-trigger="gate.waiting"> <span class="emoji">⏳</span><strong>Gate waiting now</strong> — at once, not after 2h <span class="notif-new">NEW</span></label>
4054
4127
  </div>
4055
4128
 
4056
4129
  <div class="cta-row" style="margin-top:24px;display:flex;gap:10px;flex-wrap:wrap">
@@ -4080,7 +4153,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
4080
4153
  </div>
4081
4154
  <div id="push-status" style="margin-top:10px;font-size: var(--fs-small);font-family:var(--mono);color:var(--text3)"></div>
4082
4155
  <div class="share-note" style="margin-top:16px">
4083
- Push subscriptions are stored locally in <code>~/.great_cto/push-subscriptions.json</code>. Notifications fire for the same 5 triggers as email alerts.
4156
+ Push subscriptions are stored locally in <code>~/.great_cto/push-subscriptions.json</code>. Notifications fire for the same 8 triggers as email alerts; the three marked NEW only when ticked there.
4084
4157
  </div>
4085
4158
  </div>
4086
4159
 
@@ -10591,8 +10664,46 @@ async function _disablePush() {
10591
10664
  }
10592
10665
  }
10593
10666
 
10667
+ // ── Install app (PWA) ──────────────────────────────────────────────────────────
10668
+ // The button appears only when the browser offers installation (Chrome/Edge fire
10669
+ // beforeinstallprompt once the manifest is valid). Safari has no such event, so
10670
+ // the button stays hidden there. Once installed, the board runs in its own
10671
+ // window (display-mode: standalone) and the button never shows.
10672
+ let _installPrompt = null;
10673
+ function _installBtn() { return document.getElementById('install-app-btn'); }
10674
+ function _isStandalone() {
10675
+ try { return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; }
10676
+ catch { return false; }
10677
+ }
10678
+ function initInstallApp() {
10679
+ window.addEventListener('beforeinstallprompt', (e) => {
10680
+ e.preventDefault();
10681
+ _installPrompt = e;
10682
+ const btn = _installBtn();
10683
+ if (btn && !_isStandalone()) btn.classList.add('on');
10684
+ });
10685
+ window.addEventListener('appinstalled', () => {
10686
+ _installPrompt = null;
10687
+ const btn = _installBtn();
10688
+ if (btn) btn.classList.remove('on');
10689
+ showToast('Board installed as an app', 'success', 3000);
10690
+ });
10691
+ }
10692
+ async function installBoardApp() {
10693
+ if (!_installPrompt) return;
10694
+ const evt = _installPrompt;
10695
+ _installPrompt = null;
10696
+ // A prompt event can be used once: hide the button whatever the answer. If the
10697
+ // user dismissed it, the browser fires beforeinstallprompt again later and the
10698
+ // button comes back with a fresh event.
10699
+ _installBtn()?.classList.remove('on');
10700
+ evt.prompt();
10701
+ try { await evt.userChoice; } catch { /* nothing to undo */ }
10702
+ }
10703
+
10594
10704
  init();
10595
10705
  initPush();
10706
+ initInstallApp();
10596
10707
  loadNotifHistory();
10597
10708
  initPeriodChips(); // attaches click handlers + highlights active chip
10598
10709
  // Apply persisted period to labels on first paint
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "great_cto board",
3
+ "short_name": "great_cto",
4
+ "description": "Local board for great_cto: work, gates, decisions and agent activity.",
5
+ "id": "/",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "background_color": "#0a0e0c",
10
+ "theme_color": "#0a0e0c",
11
+ "icons": [
12
+ { "src": "/assets/icon-192.png", "sizes": "192x192", "type": "image/png" },
13
+ { "src": "/assets/icon-512.png", "sizes": "512x512", "type": "image/png" },
14
+ { "src": "/assets/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
15
+ ]
16
+ }
@@ -37,7 +37,7 @@ async function handlePush() {
37
37
  body,
38
38
  tag,
39
39
  // icon may not exist — browsers handle missing icon gracefully
40
- icon: `${BOARD_ORIGIN}/assets/logo-192.png`,
40
+ icon: `${BOARD_ORIGIN}/assets/icon-192.png`,
41
41
  badge: `${BOARD_ORIGIN}/assets/favicon-32.png`,
42
42
  data: {
43
43
  url: `${BOARD_ORIGIN}/`,
@@ -0,0 +1,25 @@
1
+ {
2
+ "note": "API routes with no test referencing them. Shrink only: add a test, then remove the line. See docs/testing/BOARD-TEST-STRATEGY.md.",
3
+ "measured": "19 of 57 routes",
4
+ "untested": [
5
+ "/api/agent-budgets",
6
+ "/api/agents/",
7
+ "/api/change-tier",
8
+ "/api/gate-tiers",
9
+ "/api/memory",
10
+ "/api/memory-pattern",
11
+ "/api/notif-history",
12
+ "/api/notif-history/read",
13
+ "/api/notifications",
14
+ "/api/portfolio",
15
+ "/api/push/subscribe",
16
+ "/api/push/vapid-key",
17
+ "/api/receipt",
18
+ "/api/scores",
19
+ "/api/session-search",
20
+ "/api/sessions",
21
+ "/api/sessions/",
22
+ "/api/stand-downs",
23
+ "/api/views"
24
+ ]
25
+ }
@@ -105,7 +105,8 @@ const server = http.createServer(async (req, res) => {
105
105
  // signal to a prober about what exists outside PUBLIC.
106
106
  if (isInsideDir(PUBLIC, fullPath) && fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
107
107
  const ext = path.extname(fullPath);
108
- const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
108
+ const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml',
109
+ '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json' };
109
110
  // HTML must never cache — board UI is iterated daily and stale layouts
110
111
  // hide new features (period selector, push toggle, etc.) until hard refresh
111
112
  const headers = { 'Content-Type': mime[ext] || 'text/plain' };
@@ -186,6 +187,12 @@ server.listen(PORT, HOST, () => {
186
187
 
187
188
  server.on('error', e => {
188
189
  if (e.code === 'EADDRINUSE') {
190
+ // A workspace must not defer: the board already on this port belongs to another
191
+ // workspace, and its verdicts are not the ones this operator is looking at.
192
+ if (process.env.GREAT_CTO_WORKSPACE_PORT === String(PORT)) {
193
+ log.error(`Port ${PORT} is taken and this workspace was given it. Not attaching to another board.`);
194
+ process.exit(1);
195
+ }
189
196
  log.info(`Port ${PORT} in use — board already running at http://localhost:${PORT}`);
190
197
  } else {
191
198
  log.error(e);
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Append one line per ci-local run to <main checkout>/.great_cto/gate-runs.jsonl,
4
+ * so the board can say "the local gate went red" (trigger gate.local_red) instead
5
+ * of the result living only in a terminal.
6
+ *
7
+ * node scripts/lib/gate-run-record.mjs --result green|skipped|red [--failed "step a\nstep b"]
8
+ *
9
+ * The MAIN checkout, not the current one: gates run in worktrees, and the board
10
+ * watches the project. `git rev-parse --git-common-dir` points at the main
11
+ * repository's .git from any of its worktrees.
12
+ *
13
+ * A record is a report, never a reason to change the gate's result: every
14
+ * failure here is printed and swallowed, and the exit code is always 0.
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { execFileSync } from 'node:child_process';
19
+ import { isMainModule } from './is-main.mjs';
20
+
21
+ export const MAX_LINES = 200;
22
+ export const RESULTS = Object.freeze(['green', 'skipped', 'red']);
23
+
24
+ function git(args, cwd) {
25
+ try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
26
+ catch { return ''; }
27
+ }
28
+
29
+ /** The main checkout's root, from the main checkout or any of its worktrees. Null outside git. */
30
+ export function mainCheckout(cwd = process.cwd()) {
31
+ const common = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
32
+ if (!common) return null;
33
+ return path.basename(common) === '.git' ? path.dirname(common) : null;
34
+ }
35
+
36
+ export function buildRecord({ result, failed = '', cwd = process.cwd(), now = Date.now() }) {
37
+ if (!RESULTS.includes(result)) throw new Error(`result must be one of ${RESULTS.join(', ')}`);
38
+ return {
39
+ v: 1,
40
+ ts: new Date(now).toISOString(),
41
+ result,
42
+ branch: git(['branch', '--show-current'], cwd) || null,
43
+ sha: git(['rev-parse', '--short', 'HEAD'], cwd) || null,
44
+ failed: String(failed).split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 20),
45
+ };
46
+ }
47
+
48
+ /** Append and keep the newest MAX_LINES. Returns the file written, or null when there is no project to write into. */
49
+ export function appendRecord(record, { cwd = process.cwd() } = {}) {
50
+ const root = mainCheckout(cwd);
51
+ if (!root) return null;
52
+ const dir = path.join(root, '.great_cto');
53
+ // Only into a great_cto project: a gate in some other repository has no board to tell.
54
+ if (!fs.existsSync(path.join(dir, 'PROJECT.md'))) return null;
55
+ const file = path.join(dir, 'gate-runs.jsonl');
56
+ let lines = [];
57
+ try { lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean); }
58
+ catch (e) { if (e.code !== 'ENOENT') throw e; } // unreadable: refuse to rewrite it from nothing
59
+ lines.push(JSON.stringify(record));
60
+ const tmp = `${file}.${process.pid}.tmp`;
61
+ fs.writeFileSync(tmp, lines.slice(-MAX_LINES).join('\n') + '\n');
62
+ fs.renameSync(tmp, file);
63
+ return file;
64
+ }
65
+
66
+ if (isMainModule(import.meta.url)) {
67
+ const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.argv[i + 1] : undefined; };
68
+ try {
69
+ const file = appendRecord(buildRecord({ result: arg('--result'), failed: arg('--failed') || '' }));
70
+ if (file) process.stdout.write(` run recorded: ${path.relative(process.cwd(), file) || file}\n`);
71
+ } catch (e) {
72
+ process.stdout.write(` ⚠ run not recorded (${e.message}) — the board will not report this run\n`);
73
+ }
74
+ process.exit(0);
75
+ }