great-cto 2.96.0 → 2.97.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.96.0",
5
+ "version": "2.97.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -195,6 +195,10 @@
195
195
  "command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/reviewer-nudge.mjs\" 2>/dev/null || true",
196
196
  "timeout": 5,
197
197
  "statusMessage": "Checking reviewer rules for this file..."
198
+ },
199
+ {
200
+ "type": "command",
201
+ "command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/lesson-rules-check.mjs\" 2>/dev/null; true"
198
202
  }
199
203
  ]
200
204
  },
@@ -322,17 +322,62 @@ function getInbox(cwd = process.cwd()) {
322
322
  return ageH > 48;
323
323
  });
324
324
  const sec = readSecStats(cwd);
325
+
326
+ // One object, one section.
327
+ //
328
+ // These four filters overlap — a P0 that is also blocked matched two of them,
329
+ // so it rendered as two rows and the nav badge (gates + p0 + blocked) counted
330
+ // it twice. "Two things need you" when one thing does is a small lie that
331
+ // costs real attention, and it is the same defect as any other count that
332
+ // measures the query rather than the world.
333
+ //
334
+ // Each task lands in its strongest section only, strongest first: a gate is
335
+ // waiting on a signature, a P0 is an emergency, blocked is a state, stale is
336
+ // an observation. The other states it is in are kept on the row as `also`, so
337
+ // deduplicating loses nothing — the row can still say "and it is blocked".
338
+ const ORDER = [
339
+ ['gate', pendingGates],
340
+ ['p0', p0],
341
+ ['blocked', blocked],
342
+ ['stale', stale],
343
+ ];
344
+ const homeOf = new Map(); // task id → the section that owns it
345
+ const alsoOf = new Map(); // task id → the other sections it matched
346
+ for (const [name, list] of ORDER) {
347
+ for (const t of list) {
348
+ const id = t?.id ?? t?.title;
349
+ if (id === undefined) continue;
350
+ if (homeOf.has(id)) { alsoOf.set(id, [...(alsoOf.get(id) || []), name]); continue; }
351
+ homeOf.set(id, name);
352
+ }
353
+ }
354
+ const own = (name, list, limit) => list
355
+ .filter((t) => homeOf.get(t?.id ?? t?.title) === name)
356
+ .map((t) => ({ ...t, also: alsoOf.get(t?.id ?? t?.title) || [] }))
357
+ .slice(0, limit);
358
+
359
+ const ownedGates = own('gate', pendingGates, 20);
360
+ const ownedP0 = own('p0', p0, 10);
361
+ const ownedBlocked = own('blocked', blocked, 10);
362
+ const ownedStale = own('stale', stale, 10);
363
+
325
364
  return {
326
- pending_gates: pendingGates.slice(0, 20),
327
- blocked: blocked.slice(0, 10),
328
- p0_open: p0.slice(0, 10),
329
- stale_in_progress: stale.slice(0, 10),
365
+ pending_gates: ownedGates,
366
+ blocked: ownedBlocked,
367
+ p0_open: ownedP0,
368
+ stale_in_progress: ownedStale,
330
369
  security: { blocked: sec.blocked, approved: sec.approved },
331
370
  summary: {
332
- gates: pendingGates.length,
333
- blocked: blocked.length,
334
- p0: p0.length,
335
- stale: stale.length,
371
+ // Counts of distinct objects, so gates + p0 + blocked is a real total
372
+ // rather than a sum over overlapping sets.
373
+ gates: [...homeOf.values()].filter((s) => s === 'gate').length,
374
+ blocked: [...homeOf.values()].filter((s) => s === 'blocked').length,
375
+ p0: [...homeOf.values()].filter((s) => s === 'p0').length,
376
+ stale: [...homeOf.values()].filter((s) => s === 'stale').length,
377
+ // How many distinct things want attention at all — the number the nav
378
+ // badge means, stated once here rather than re-derived by every caller
379
+ // that might re-derive it wrongly.
380
+ needs_you: [...homeOf.values()].filter((s) => s !== 'stale').length,
336
381
  },
337
382
  };
338
383
  }
@@ -13,6 +13,7 @@
13
13
  // Zero dependencies, like the rest of the board.
14
14
 
15
15
  import fs from 'node:fs';
16
+ import { judgeFreshness } from '../../../scripts/lib/freshness.mjs';
16
17
  import path from 'node:path';
17
18
 
18
19
  /**
@@ -100,6 +101,38 @@ export function groupFor(rel) {
100
101
  * differently — the same reason the pipeline view labels a stale verdict rather
101
102
  * than hiding it.
102
103
  */
104
+ /**
105
+ * One document's freshness, as three states rather than a date.
106
+ *
107
+ * `unknown` covers two different absences and says which: a file we could not
108
+ * read at all, and a file that simply declares no date. Neither may render as
109
+ * fresh — the whole reason `stale_after` exists is that a document nobody can
110
+ * judge must not look like one that passed.
111
+ */
112
+ function freshnessOf(abs, nowMs = Date.now(), staleDays = 180) {
113
+ let text;
114
+ try { text = fs.readFileSync(abs, 'utf8'); }
115
+ catch (e) {
116
+ return { freshness: 'unknown', freshnessBasis: 'unreadable', staleAfter: null,
117
+ freshnessWhy: `could not read this file: ${String(e?.message || e)}` };
118
+ }
119
+ try {
120
+ const j = judgeFreshness({ text, dateType: 'any', nowMs, staleDays });
121
+ return {
122
+ freshness: j.verdict,
123
+ freshnessBasis: j.basis,
124
+ staleAfter: j.staleAfter,
125
+ freshnessWhy: j.basis === 'declared'
126
+ ? `the author declared it good until ${j.staleAfter}`
127
+ : (j.date ? `judged by its own date ${j.date} (${j.ageDays}d, threshold ${staleDays}d)`
128
+ : 'no stale_after and no date — nothing to judge it by'),
129
+ };
130
+ } catch (e) {
131
+ return { freshness: 'unknown', freshnessBasis: 'unreadable', staleAfter: null,
132
+ freshnessWhy: String(e?.message || e) };
133
+ }
134
+ }
135
+
103
136
  export function listDocs(root, { max = MAX_DOCS } = {}) {
104
137
  const found = [];
105
138
  for (const g of DOC_GROUPS) {
@@ -125,6 +158,12 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
125
158
  group: groupFor(d.rel),
126
159
  size: d.size,
127
160
  modified: d.modified,
161
+ // A modification time answers "when was this file last touched", which is
162
+ // a different question from "is this still true". A typo fix rejuvenates a
163
+ // document that stopped being true months earlier, and the list showed
164
+ // only the former. `judgeFreshness` gives three verdicts and names which
165
+ // rule produced each — see scripts/lib/freshness.mjs.
166
+ ...freshnessOf(d.abs),
128
167
  });
129
168
  }
130
169
 
@@ -551,6 +551,113 @@ async function dispatch(req, res, url, cwd) {
551
551
  return true;
552
552
  }
553
553
 
554
+ // Receipt — does the approval still apply to what is in the tree?
555
+ //
556
+ // The reviewing agents already record a hash-bound receipt of the bytes they
557
+ // read, and those verdicts are in the log this board reads. Nothing surfaced
558
+ // them, so the top rung of the evidence ladder — the reviewed bytes are the
559
+ // shipped bytes — was being written and never looked at. An approval that no
560
+ // longer describes the tree is the one thing a gate button cannot tell you.
561
+ if (pathname === '/api/receipt') {
562
+ const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
563
+ let out;
564
+ try {
565
+ const { latestApproval, treeReceipt, compareReceipts, mergeBase } = await import('../../../scripts/lib/receipt.mjs');
566
+ const approval = latestApproval(c);
567
+ if (!approval?.receipt) {
568
+ out = { state: 'no-receipt', why: 'no approving verdict carries a receipt yet', agent: approval?.agent || null, ts: approval?.ts || null };
569
+ } else {
570
+ const current = treeReceipt(c, { base: approval.receipt.base || mergeBase(c) });
571
+ const cmp = compareReceipts(approval.receipt, current, { cwd: c });
572
+ out = { ...cmp, agent: approval.agent, ts: approval.ts };
573
+ }
574
+ } catch (e) {
575
+ // Never "matches" on an error. Not knowing whether the approval still
576
+ // holds is its own state, and calling it a match would put the strongest
577
+ // reassurance exactly where the least is known.
578
+ out = { state: 'unreadable', why: String(e?.message || e), agent: null, ts: null };
579
+ }
580
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
581
+ res.end(JSON.stringify(out));
582
+ return true;
583
+ }
584
+
585
+ // Gate tiers — will this gate wait for you, or announce and proceed?
586
+ //
587
+ // A gate card offered Approve/Reject regardless, which is the right control
588
+ // for a `gated` gate and the wrong one for a tiered gate that already went
589
+ // ahead. Two states rendered where the machinery has three.
590
+ //
591
+ // `enabled` is returned alongside, because a tier is only in force when the
592
+ // project opted in with `gate-tiering: evidence`. Showing "notify" on a
593
+ // project that never opted in would describe a stand-down that will not
594
+ // happen — the mirror image of the defect this whole feature guards.
595
+ if (pathname === '/api/gate-tiers') {
596
+ const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
597
+ try {
598
+ const { tierAll, tieringEnabled } = await import('../../../scripts/lib/gate-tier.mjs');
599
+ const { fileURLToPath: f2u } = await import('node:url');
600
+ const libDir = path.dirname(f2u(import.meta.url));
601
+ const histPath = path.join(libDir, '..', '..', '..', 'tests', 'eval', 'results-history.jsonl');
602
+
603
+ let enabled = false;
604
+ try { enabled = tieringEnabled(fs.readFileSync(path.join(c, '.great_cto', 'PROJECT.md'), 'utf8')); }
605
+ catch { /* no PROJECT.md — tiering is off, which is the safe reading */ }
606
+
607
+ const rows = [];
608
+ let historyRead = false;
609
+ try {
610
+ for (const line of fs.readFileSync(histPath, 'utf8').split('\n')) {
611
+ if (!line.trim()) continue;
612
+ try { rows.push(JSON.parse(line)); } catch { /* a bad row is not evidence */ }
613
+ }
614
+ historyRead = true;
615
+ } catch { /* reported as unmeasured below, never as gated-because-fine */ }
616
+
617
+ const byAgent = {};
618
+ for (const t of tierAll(rows)) byAgent[t.agent] = { tier: t.tier, why: t.why, evals: t.evals };
619
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
620
+ res.end(JSON.stringify({ enabled, measured: historyRead, agents: byAgent }));
621
+ } catch (e) {
622
+ // The real error. An agent missing from `agents` reads as unmeasured on the
623
+ // client, which keeps its gate — the correct direction to be wrong in.
624
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
625
+ res.end(JSON.stringify({ enabled: false, measured: false, agents: {}, why: String(e?.message || e) }));
626
+ }
627
+ return true;
628
+ }
629
+
630
+ // Stand-downs — gates that proceeded WITHOUT being asked, and on what evidence.
631
+ //
632
+ // ADR-009: a gate is not the only valid answer, but silence is never one. An
633
+ // evidence-tiered gate announces instead of waiting, and until now that
634
+ // announcement had nowhere on this board to land — the record was being
635
+ // written and nothing read it.
636
+ if (pathname === '/api/stand-downs') {
637
+ const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
638
+ let rows = null;
639
+ let why = '';
640
+ try {
641
+ const { readStandDowns } = await import('../../../scripts/lib/stand-down.mjs');
642
+ rows = readStandDowns(c, { limit: 50 });
643
+ } catch (e) {
644
+ // The real error. A handler that names a cause it did not observe sends
645
+ // the reader looking in the wrong place — this file has done that once.
646
+ why = String(e?.message || e);
647
+ }
648
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
649
+ // Three states, because two cannot carry them: `readStandDowns` returns null
650
+ // when the ledger exists and could not be read, [] when nothing has stood
651
+ // down, and rows when something has. "I could not look" must never render as
652
+ // "nothing happened".
653
+ res.end(JSON.stringify(
654
+ rows === null
655
+ ? { state: 'unreadable', why: why || 'the stand-down ledger exists but could not be read', rows: [] }
656
+ : { state: rows.length ? 'some' : 'none', why: '', rows },
657
+ ));
658
+ return true;
659
+ }
660
+
554
661
  // Resume — pick up where you left off (last verdicts + WIP + recent decisions)
555
662
  if (pathname === '/api/resume') {
556
663
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
@@ -2284,6 +2284,9 @@ button { font-family: inherit; cursor: pointer; }
2284
2284
  <div class="inbox-page">
2285
2285
  <div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
2286
2286
  <div class="inbox-summary" id="inbox-summary"></div>
2287
+ <!-- Top rung of the evidence ladder: are the bytes that were reviewed
2288
+ still the bytes in the tree? Hidden while they match. -->
2289
+ <div id="inbox-receipt" style="display:none;margin:6px 0 2px;font-size:12px"></div>
2287
2290
 
2288
2291
  <!-- Resume — pick up where you left off -->
2289
2292
  <div class="resume-card" id="resume-card" style="display:none">
@@ -2332,6 +2335,17 @@ button { font-family: inherit; cursor: pointer; }
2332
2335
  </div>
2333
2336
  <div id="inbox-gates"></div>
2334
2337
  </div>
2338
+ <!-- Gates that did NOT wait for you. Placed directly under the decisions
2339
+ you still owe, because "what proceeded without me" is the question a
2340
+ tiered gate creates and nothing on this board answered. -->
2341
+ <div class="inbox-section" id="inbox-standdown-section" style="display:none">
2342
+ <div class="inbox-section-head">
2343
+ <span class="dot dot-amber"></span>
2344
+ <h3>Proceeded without you</h3>
2345
+ <span class="inbox-count" id="inbox-standdown-count">0</span>
2346
+ </div>
2347
+ <div id="inbox-standdown"></div>
2348
+ </div>
2335
2349
  <div class="inbox-section" id="inbox-p0-section">
2336
2350
  <div class="inbox-section-head">
2337
2351
  <span class="dot dot-red"></span>
@@ -2754,7 +2768,12 @@ function greetByHour() {
2754
2768
  function renderInbox(d) {
2755
2769
  inboxData = d || {};
2756
2770
  const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
2757
- document.getElementById('nav-inbox-count').textContent = s.gates + s.p0 + s.blocked;
2771
+ // `needs_you` counts distinct objects. Summing the three categories double-counted
2772
+ // anything that matched two of them — a P0 that is also blocked read as two things
2773
+ // needing attention when it was one. Older payloads have no `needs_you`, so the sum
2774
+ // stays as the fallback rather than rendering nothing.
2775
+ document.getElementById('nav-inbox-count').textContent =
2776
+ Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
2758
2777
  // Same rule as the all-clear card: "nothing urgent" is a claim about data we
2759
2778
  // may not have. When a read failed, say that instead of reassuring the user.
2760
2779
  const greetTail = anyDegraded()
@@ -2771,6 +2790,8 @@ function renderInbox(d) {
2771
2790
  renderInboxList('inbox-p0', d?.p0_open || [], 'inbox-p0-count', {});
2772
2791
  renderInboxList('inbox-blocked', d?.blocked || [], 'inbox-blocked-count', {});
2773
2792
  renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', {});
2793
+ refreshStandDowns();
2794
+ refreshReceipt();
2774
2795
  // Idle focus: collapse the empty sections into one all-clear card when nothing needs a decision.
2775
2796
  const attention = (d?.pending_gates || []).length + (d?.p0_open || []).length
2776
2797
  + (d?.blocked || []).length + (d?.stale_in_progress || []).length;
@@ -2785,6 +2806,150 @@ function renderInbox(d) {
2785
2806
  'Some project data could not be read — counts below are incomplete.');
2786
2807
  }
2787
2808
 
2809
+ /**
2810
+ * Does the last approval still describe what is in the tree?
2811
+ *
2812
+ * The reviewing agents already record a hash-bound receipt of the bytes they
2813
+ * read. Nothing on this board looked at it, so the top rung of the evidence
2814
+ * ladder was being written and never read.
2815
+ *
2816
+ * Five readings, and only three of them speak:
2817
+ * differs reviewed files changed after the approval — the actionable one
2818
+ * unreadable we could not tell. Shown, because "cannot check" must never
2819
+ * render as "checked and fine"
2820
+ * extended new files appeared; nothing reviewed was altered — worth a
2821
+ * quiet line, since the addition was never reviewed either
2822
+ * matches silent. A clean state does not need a banner.
2823
+ * no-receipt silent. Nothing has been approved yet; saying so would nag
2824
+ * about a stage that has not happened.
2825
+ */
2826
+ async function refreshReceipt() {
2827
+ const el = document.getElementById('inbox-receipt');
2828
+ if (!el) return;
2829
+ const d = await api(`/api/receipt${pqs()}`);
2830
+ if (!d || d.state === 'matches' || d.state === 'no-receipt') { el.style.display = 'none'; return; }
2831
+
2832
+ const stamp = d.agent ? `${esc(d.agent)}${d.ts ? ' · ' + relTime(d.ts) : ''}` : '';
2833
+ const body = d.state === 'differs'
2834
+ ? `<b>${(d.changed || []).length + (d.removed || []).length} reviewed file(s) changed since ${stamp} approved</b>
2835
+ — the approval no longer describes what is in the tree.
2836
+ <span class="muted">${esc((d.changed || []).slice(0, 4).join(', '))}${(d.changed || []).length > 4 ? ' …' : ''}</span>`
2837
+ : d.state === 'unreadable'
2838
+ ? `<b>Could not check the review receipt</b> — <span class="muted">${esc(d.why || '')}</span>. This is not the same as a clean tree.`
2839
+ : `${(d.added || []).length} file(s) added since ${stamp} approved; nothing reviewed was altered.`;
2840
+
2841
+ const colour = d.state === 'matches' ? 'var(--text3)' : 'var(--status-gate)';
2842
+ el.style.display = '';
2843
+ el.innerHTML = `<span style="color:${colour}">${body}</span>`;
2844
+ }
2845
+
2846
+ /**
2847
+ * Is this document still true? — which is not the question `modified` answers.
2848
+ *
2849
+ * Three states, and the two that are not `fresh` look different from each other:
2850
+ *
2851
+ * stale the author's own `stale_after` has passed, or its date is older
2852
+ * than the threshold — say so, loudly, it is the actionable one
2853
+ * unknown nothing to judge it by, OR the file could not be read. Both are
2854
+ * shown, because a document nobody could judge must never render the
2855
+ * same as one that passed. That equivalence is why stale_after exists.
2856
+ * fresh nothing — a clean row should stay quiet
2857
+ *
2858
+ * Absent freshness data (an older board payload) also renders nothing, since
2859
+ * inventing a verdict from a missing field is the defect itself.
2860
+ */
2861
+ function freshnessBadge(doc) {
2862
+ const v = doc?.freshness;
2863
+ if (!v || v === 'fresh') return '';
2864
+ const unreadable = doc.freshnessBasis === 'unreadable';
2865
+ const colour = v === 'stale' ? 'var(--status-gate)' : 'var(--text3)';
2866
+ const label = v === 'stale' ? 'stale' : (unreadable ? 'could not read' : 'unknown');
2867
+ return `<span title="${esc(doc.freshnessWhy || '')}" style="font-family:var(--mono);font-size:10px;margin-left:6px;padding:0 4px;border:1px solid var(--border);border-radius:3px;color:${colour}">${esc(label)}</span>`;
2868
+ }
2869
+
2870
+ // Will this gate wait for you, or did the pipeline already go past it?
2871
+ //
2872
+ // `null` until fetched, and it stays null on any failure — every render path
2873
+ // treats "no tier information" as "this gate waits", which is the reading that
2874
+ // keeps a human in the loop when we do not know.
2875
+ let gateTiers = null;
2876
+ async function refreshGateTiers() {
2877
+ const d = await api(`/api/gate-tiers${pqs()}`);
2878
+ gateTiers = d && d.agents ? d : null;
2879
+ }
2880
+
2881
+ /**
2882
+ * The tier line for a gate card, or '' when it would say nothing true.
2883
+ *
2884
+ * Three readings, not two:
2885
+ * - tiering off for this project, or the agent unmeasured → nothing shown, the
2886
+ * gate waits, which is what the buttons already imply
2887
+ * - `gated` → nothing shown for the same reason
2888
+ * - `notify` / `notify-thin` → said out loud, because the pipeline did NOT
2889
+ * wait for this decision and the Approve button alone implies it did
2890
+ */
2891
+ function gateTierNote(t) {
2892
+ if (!gateTiers?.enabled || !gateTiers?.measured) return '';
2893
+ const info = gateTiers.agents?.[t.agent];
2894
+ if (!info || info.tier === 'gated') return '';
2895
+ const thin = info.tier === 'notify-thin';
2896
+ const colour = thin ? 'var(--status-gate)' : 'var(--text3)';
2897
+ // `notify-thin` is sound statistics over unmeasured coverage — one eval passed
2898
+ // conclusively, which says nothing about the rest of what this agent is for.
2899
+ // It is the reading most worth a second look, so it reads differently.
2900
+ const caveat = thin ? ' — one eval, coverage unmeasured' : '';
2901
+ return `<div style="margin-top:3px;font-size:11px;color:${colour}">
2902
+ <span style="font-family:var(--mono);border:1px solid var(--border);border-radius:3px;padding:0 4px">${esc(info.tier)}</span>
2903
+ the pipeline did not wait for this${esc(caveat)} · <span title="${esc(info.why || '')}">${esc((info.why || '').slice(0, 90))}</span>
2904
+ </div>`;
2905
+ }
2906
+
2907
+ // Gates that announced instead of waiting.
2908
+ //
2909
+ // Three states, not two. `none` hides the section — nothing has proceeded
2910
+ // unattended, which is a real and common answer. `unreadable` SHOWS it and says
2911
+ // so: a ledger that exists and could not be read must never render the same as
2912
+ // an empty one, because the whole reason this record exists is that a
2913
+ // stand-down nobody can see is indistinguishable from a gate nobody configured.
2914
+ async function refreshStandDowns() {
2915
+ const section = document.getElementById('inbox-standdown-section');
2916
+ const root = document.getElementById('inbox-standdown');
2917
+ const cnt = document.getElementById('inbox-standdown-count');
2918
+ if (!section || !root) return;
2919
+
2920
+ const d = await api(`/api/stand-downs${pqs()}`);
2921
+ if (!d) { section.style.display = 'none'; return; }
2922
+
2923
+ if (d.state === 'unreadable') {
2924
+ section.style.display = '';
2925
+ if (cnt) cnt.textContent = '?';
2926
+ root.innerHTML = `<div class="inbox-row"><span class="ttl">The stand-down ledger could not be read
2927
+ <span class="meta">${esc(d.why || '')} — gates may have proceeded unattended without appearing here</span></span></div>`;
2928
+ return;
2929
+ }
2930
+ if (d.state !== 'some' || !d.rows.length) { section.style.display = 'none'; root.innerHTML = ''; return; }
2931
+
2932
+ section.style.display = '';
2933
+ if (cnt) cnt.textContent = d.rows.length;
2934
+ // Newest first — the ledger appends, but a reader wants the most recent thing
2935
+ // that happened without them.
2936
+ root.innerHTML = d.rows.slice().reverse().map(r => {
2937
+ // `notify-thin` means the gate stood down on ONE eval: sound statistics,
2938
+ // unmeasured coverage. That is the row most worth a second look, so it is
2939
+ // the row that looks different.
2940
+ const thin = r.tier === 'notify-thin';
2941
+ const tierTag = `<span style="font-family:var(--mono);font-size:10px;padding:1px 5px;border-radius:3px;border:1px solid var(--border);color:${thin ? 'var(--status-gate)' : 'var(--text3)'}">${esc(r.tier || 'unknown')}</span>`;
2942
+ return `
2943
+ <div class="inbox-row">
2944
+ <span class="id">gate:${esc(r.gate || '?')}</span>
2945
+ <span class="ttl">${esc(r.agent || 'unknown agent')} proceeded without asking
2946
+ <span class="ttl-desc">${esc(r.evidence || '')}</span>
2947
+ <span class="meta">${tierTag} · <span title="${esc(r.ts || '')}">${r.ts ? relTime(r.ts) : 'no timestamp'}</span></span>
2948
+ </span>
2949
+ </div>`;
2950
+ }).join('');
2951
+ }
2952
+
2788
2953
  function renderInboxList(rootId, items, countId, opts = {}) {
2789
2954
  const root = document.getElementById(rootId);
2790
2955
  const cnt = document.getElementById(countId);
@@ -2794,9 +2959,17 @@ function renderInboxList(rootId, items, countId, opts = {}) {
2794
2959
  if (section) section.style.display = items.length ? '' : 'none';
2795
2960
  if (!items.length) { root.innerHTML = ''; return; }
2796
2961
  root.innerHTML = items.map(t => {
2962
+ // A task now appears in one section only, so the states it ALSO matched are
2963
+ // shown here rather than lost. Deduplicating the rows must not deduplicate
2964
+ // the facts: a gate that is also blocked is still blocked, and hiding that
2965
+ // to fix a double count would trade one wrong reading for another.
2966
+ const alsoTag = (t.also || []).length
2967
+ ? `<span title="also matched: ${esc((t.also || []).join(', '))}" style="color:var(--text3)">also ${esc((t.also || []).join(' + '))}</span>`
2968
+ : '';
2797
2969
  const meta = [
2798
2970
  t.agent ? `agent: ${esc(t.agent)}` : '',
2799
2971
  t.priority != null ? PRIORITY_LABEL[t.priority] : '',
2972
+ alsoTag,
2800
2973
  // Gates (the showApprove list) get the exact date+time the decision has been
2801
2974
  // waiting since; other rows keep the compact relative stamp.
2802
2975
  t.updated_at
@@ -2812,16 +2985,21 @@ function renderInboxList(rootId, items, countId, opts = {}) {
2812
2985
  </div>`
2813
2986
  : `<div class="actions"><span style="font-family:var(--mono);font-size:11px;color:var(--text3)">${t.status || ''}</span></div>`;
2814
2987
  const desc = t.description ? `<span class="ttl-desc">${esc(t.description.slice(0, 140))}${t.description.length > 140 ? '…' : ''}</span>` : '';
2988
+ // Only gate rows can stand down, so only they carry the note.
2989
+ const tierNote = opts.showApprove ? gateTierNote(t) : '';
2815
2990
  return `
2816
2991
  <div class="inbox-row" onclick='openSide(${JSON.stringify(t).replace(/'/g, "&apos;")})'>
2817
2992
  <span class="id">${esc(t.id || '')}</span>
2818
- <span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span></span>
2993
+ <span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
2819
2994
  ${actions}
2820
2995
  </div>`;
2821
2996
  }).join('');
2822
2997
  }
2823
2998
 
2824
2999
  async function refreshInbox() {
3000
+ // Tiers first: the gate rows are rendered from this same call, and a row drawn
3001
+ // before the tiers arrive would silently claim every gate is waiting.
3002
+ await refreshGateTiers();
2825
3003
  const d = await api(`/api/inbox${pqs()}`);
2826
3004
  if (d) renderInbox(d);
2827
3005
  refreshPipeline();
@@ -3458,7 +3636,7 @@ async function loadDocs() {
3458
3636
 
3459
3637
  const pipeline = d.pipeline
3460
3638
  ? `<div class="card"><b>Pipeline</b> <span class="muted">— how a feature moves, drawn from shared/pipeline.toml</span>
3461
- ${renderPipeline(d.pipeline)}
3639
+ ${renderDeclaredPipeline(d.pipeline)}
3462
3640
  <details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
3463
3641
  <pre style="white-space:pre-wrap;font-size:11px">${esc(d.pipeline)}</pre></details></div>`
3464
3642
  : '';
@@ -3481,6 +3659,7 @@ async function loadDocs() {
3481
3659
  <div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
3482
3660
  <a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
3483
3661
  <span class="muted" style="font-size:11px"> · ${esc(doc.path)} · ${ago(doc.modified)}</span>
3662
+ ${freshnessBadge(doc)}
3484
3663
  </div>`).join('')}</div>
3485
3664
  </div>`).join('');
3486
3665
 
@@ -3541,7 +3720,18 @@ function projectRootHint() {
3541
3720
  }
3542
3721
 
3543
3722
  /** The pipeline as boxes and gates — no diagram library, so it always renders. */
3544
- function renderPipeline(mermaid) {
3723
+ // Named `renderDeclaredPipeline`, not `renderPipeline`, because there is already
3724
+ // a `renderPipeline(stages)` above that draws the LIVE rail. Both were declared
3725
+ // at the top level of this one <script>, so the later declaration won and every
3726
+ // call carrying an array of stages — refreshPipeline() and the SSE handler —
3727
+ // reached this mermaid parser instead. It returned a string nobody used and
3728
+ // never touched `#pipeline-track`, so the rail stayed the empty div in the
3729
+ // markup and the status stayed the hardcoded `idle` beside it.
3730
+ //
3731
+ // The first section of the first screen therefore read "idle" whatever the
3732
+ // pipeline was doing: not an empty state, but a confident wrong answer, which is
3733
+ // the failure this project spends its time removing.
3734
+ function renderDeclaredPipeline(mermaid) {
3545
3735
  const edges = [];
3546
3736
  for (const line of String(mermaid).split('\n')) {
3547
3737
  const m = line.match(/^\s*(\w+)\s*-->(?:\|([^|]*)\|)?\s*(\w+)/);