great-cto 2.96.0 → 2.98.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.98.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' }));