great-cto 3.64.0 → 3.65.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.64.0",
4
+ "version": "3.65.0",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: continuous-learner
3
- description: Use at session end (auto-triggered by SessionEnd hook) or via /crystallize learn. Extracts repeatable patterns, decisions, and cost outliers from the session and writes structured entries to .great_cto/lessons.md. Promotes high-confidence patterns to ~/.great_cto/decisions.md after ≥3 occurrences.
3
+ description: Use at session end (auto-triggered by SessionEnd hook) or via /crystallize learn. Extracts repeatable patterns, decisions, and cost outliers from the session and writes structured entries to .great_cto/lessons.md. Lessons seen in ≥3 projects become candidates; only an A/B-measured one reaches ~/.great_cto/decisions.md (ADR-037).
4
4
  model: claude-haiku-4-5
5
5
  authority: proposes
6
6
  tools: Read, Write, Edit, Glob, Grep, Bash(git:*), Bash(bd:*), Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(head:*), Bash(tail:*), Bash(wc:*), Bash(date:*), Bash(printf:*), Bash(echo:*), Bash(mkdir:*), Bash(node:*), WebFetch, WebSearch, memory_20250929
@@ -20,7 +20,7 @@ You are the **Continuous Learner** — a low-cost, low-noise pattern extractor.
20
20
  Read the session context (transcript, git state, beads, cost log, recent files written) and emit:
21
21
 
22
22
  1. **Append 0-3 new lesson entries** to `.great_cto/lessons.md` (project-local memory)
23
- 2. **Promote patterns seen across enough projects** to `~/.great_cto/decisions.md` (cross-project memory) — `scripts/lessons-merge.mjs` owns the threshold and counts distinct projects, not raw occurrences (Step 4)
23
+ 2. **Hand cross-project candidates to `scripts/lessons-merge.mjs`** — it counts distinct projects (3 make a candidate) and writes to `~/.great_cto/decisions.md` only a lesson whose A/B eval did not show harm (Step 4, ADR-037)
24
24
  3. **Reject everything else.** Silence > noise.
25
25
 
26
26
  You are graded on **precision, not recall**. False positives erode trust; misses are recoverable.
@@ -192,6 +192,7 @@ archetype: <from PROJECT.md>
192
192
  project: <basename of cwd>
193
193
  confidence: medium|high # YOUR OWN rating. Never authoritative — see below
194
194
  shape: A|B|C|D|E
195
+ eval: EVAL-<name>.md # optional — the eval whose agent this lesson changes
195
196
  ---
196
197
 
197
198
  **Two axes, and only one of them is earned.** `support:` is written by
@@ -242,6 +243,15 @@ node "$_LM" --dry-run
242
243
  node "$_LM"
243
244
  ```
244
245
 
246
+ **Recurrence is not evidence (ADR-037).** Three projects make a lesson a
247
+ *candidate*. It is written to `decisions.md` only with an eval stamp that is not
248
+ a regression — measured by `node "$_LM" --measure <slug>` (prints the cost;
249
+ `--yes` spends it), which runs the lesson's `eval:` with and without the lesson.
250
+ A lesson with no `eval:` stays in its projects. Never write `eval_delta:` or
251
+ `eval_confidence:` yourself: `lessons-write.mjs` discards them, and a lesson
252
+ cannot vouch for itself. Do not run `--measure` or `--no-eval` on your own — they
253
+ spend money or bypass evidence, and that is the operator's call.
254
+
245
255
  Flags: `--dry-run` previews without writing, `--force` re-promotes a slug already
246
256
  present. The threshold and entry format live in the script; if either needs to
247
257
  change, change it there so both paths move together.
@@ -108,7 +108,10 @@ function bd(args, opts = {}) {
108
108
  // — so the default cap sat BELOW the known worst case, and every call site that
109
109
  // did not name its own timeout inherited a cap the machine could exceed while
110
110
  // working correctly. What came back was indistinguishable from beads failing.
111
- return spawnSync(BD_BIN, args, { encoding: 'utf8', timeout: 20000, ...opts, env: { ...bdEnv(), ...(opts.env || {}) } });
111
+ // SIGKILL: a synchronous timeout sends its signal and then waits for the child
112
+ // to exit. bd holding a dolt lock ignored SIGTERM and held the board's whole
113
+ // event loop — four minutes without even /api/heartbeat. SIGKILL cannot wait.
114
+ return spawnSync(BD_BIN, args, { encoding: 'utf8', timeout: 20000, killSignal: 'SIGKILL', ...opts, env: { ...bdEnv(), ...(opts.env || {}) } });
112
115
  }
113
116
  // Turn a failed bd result into an actionable message (the bare "bd update failed" hid ENOENT).
114
117
  function bdErr(r, what) {
@@ -4,12 +4,26 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { treeReceipt, latestApproval, compareReceipts, mergeBase } from '../../../scripts/lib/receipt.mjs';
6
6
  import { secondOpinionForTree } from '../../../scripts/lib/second-opinion-for-tree.mjs';
7
+ import { affectedTests } from '../../../scripts/lib/affected-tests.mjs';
7
8
  const entry = fileURLToPath(import.meta.url);
8
9
  export const receiptReadEntry = entry;
10
+
11
+ // The tree a gate token is bound to. Null only outside Git: a real repository
12
+ // whose receipt could not be read fails, so it never reads as "not a repo".
13
+ function bindingTree() {
14
+ let dir = process.cwd(), gitMetadata = false;
15
+ for (;;) {
16
+ try { fs.lstatSync(path.join(dir, '.git')); gitMetadata = true; break; } catch (e) { if (e.code !== 'ENOENT') throw e; }
17
+ const parent = path.dirname(dir); if (parent === dir) break; dir = parent;
18
+ }
19
+ const receipt = treeReceipt(process.cwd(), { exclude: ['.great_cto', '.beads'], hashFilesInProcess: true });
20
+ if (!receipt && gitMetadata) throw Error('Repository receipt unavailable');
21
+ return receipt;
22
+ }
9
23
  if (process.argv[1] === entry) {
10
24
  try {
11
25
  const mode = process.argv[2] || 'binding';
12
- if (!['binding', 'approval', 'second-opinion'].includes(mode) || process.argv.length > 3) throw Error('Invalid evidence operation');
26
+ if (!['binding', 'approval', 'second-opinion', 'touches'].includes(mode) || process.argv.length > 3) throw Error('Invalid evidence operation');
13
27
  let receipt;
14
28
  if (mode === 'second-opinion') {
15
29
  receipt = secondOpinionForTree(process.cwd());
@@ -21,16 +35,17 @@ if (process.argv[1] === entry) {
21
35
  const current = treeReceipt(process.cwd(), { base: approval.receipt.base || mergeBase(process.cwd()), hashFilesInProcess: true });
22
36
  receipt = { ...compareReceipts(approval.receipt, current, { cwd: process.cwd() }), agent: approval.agent, ts: approval.ts };
23
37
  }
38
+ } else if (mode === 'touches') {
39
+ // The gate brief (ADR-036): the change set plus the tests it maps to. The
40
+ // test mapping walks the repository, so it runs here, off the board's
41
+ // request thread, and its failure is reported rather than read as "none".
42
+ const binding = bindingTree();
43
+ let tests;
44
+ try { tests = binding ? { ok: true, value: affectedTests({ cwd: process.cwd(), changed: Object.keys(binding.files || {}) }) } : { ok: true, value: null }; }
45
+ catch (e) { tests = { ok: false, why: String(e?.message || e) }; }
46
+ receipt = { binding, tests };
24
47
  } else {
25
- let dir = process.cwd(), gitMetadata = false;
26
- for (;;) {
27
- try { fs.lstatSync(path.join(dir, '.git')); gitMetadata = true; break; } catch (e) { if (e.code !== 'ENOENT') throw e; }
28
- const parent = path.dirname(dir); if (parent === dir) break; dir = parent;
29
- }
30
- receipt = treeReceipt(process.cwd(), { exclude: ['.great_cto', '.beads'], hashFilesInProcess: true });
31
- // Preserve explicit non-Git binding, but never mint an unbound token when
32
- // a real repository's evidence read failed.
33
- if (!receipt && gitMetadata) throw Error('Repository receipt unavailable');
48
+ receipt = bindingTree();
34
49
  }
35
50
  process.stdout.write(JSON.stringify({ receipt }), error => process.exit(error ? 1 : 0));
36
51
  } catch { process.exit(1); }
@@ -12,6 +12,9 @@ export function readApprovalReceipt(cwd, { timeoutMs = 20000 } = {}) {
12
12
  export function readSecondOpinion(cwd, { timeoutMs = 20000 } = {}) {
13
13
  return readEvidence(cwd, 'second-opinion', timeoutMs);
14
14
  }
15
+ export function readTouches(cwd, { timeoutMs = 20000 } = {}) {
16
+ return readEvidence(cwd, 'touches', timeoutMs);
17
+ }
15
18
  function readEvidence(cwd, mode, timeoutMs) {
16
19
  cwd = path.resolve(cwd);
17
20
  const key = JSON.stringify([cwd, mode]);
@@ -53,7 +56,9 @@ function readEvidence(cwd, mode, timeoutMs) {
53
56
  const value = JSON.parse(out);
54
57
  if (!value || !Object.hasOwn(value, 'receipt')) throw Error();
55
58
  const evidence = value.receipt;
56
- if (mode === 'binding') {
59
+ if (mode === 'touches') {
60
+ if (!evidence || !Object.hasOwn(evidence, 'binding') || typeof evidence.tests?.ok !== 'boolean') throw Error();
61
+ } else if (mode === 'binding') {
57
62
  if (evidence !== null && (!evidence?.head || !evidence.files)) throw Error();
58
63
  } else {
59
64
  const states = mode === 'approval' ? ['no-receipt', 'unreadable', 'differs', 'extended', 'matches'] : ['not-run', 'unmeasured', 'unreadable', 'ok'];
@@ -32,7 +32,8 @@ import { log } from './log.mjs';
32
32
  import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, getTasksAsync, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
33
33
  import { getMetrics } from './metrics.mjs';
34
34
  import { agentActivity, agentActivitySince } from './agent-activity.mjs';
35
- import { issueTokensAsync, checkToken, checkBinding, consumeToken, approvalSnapshot, beginGateDecision } from './gate-tokens.mjs';
35
+ import { issueTokensAsync, checkToken, checkBinding, consumeToken, approvalSnapshot, beginGateDecision, readTokens } from './gate-tokens.mjs';
36
+ import { gateBrief, settle, readVerdictLogs } from '../../../scripts/lib/gate-brief.mjs';
36
37
  import { readTurns, turnPatch } from '../../../scripts/lib/turn-snapshot.mjs';
37
38
  import { appendEvent } from '../../../scripts/lib/agent-events.mjs';
38
39
 
@@ -63,7 +64,7 @@ import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/li
63
64
  // Moved to scripts/lib so the cross-review Stop hook can ask the same question
64
65
  // without importing a server. Re-exported here: this module's callers are unchanged.
65
66
  import { secondOpinionForTree } from '../../../scripts/lib/second-opinion-for-tree.mjs';
66
- import { readApprovalReceipt, readSecondOpinion } from './receipt-read.mjs';
67
+ import { readApprovalReceipt, readSecondOpinion, readTouches } from './receipt-read.mjs';
67
68
  import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
68
69
  import { getResume, getShareState, toggleShare } from './share.mjs';
69
70
  import { listSessions, readSession, editedFiles, searchSessions } from './transcripts.mjs';
@@ -926,6 +927,47 @@ async function dispatch(req, res, url, cwd) {
926
927
  return true;
927
928
  }
928
929
 
930
+ // Gate brief (ADR-036) — one page of what this gate approves.
931
+ //
932
+ // A projection over evidence already recorded: the tree the token was issued
933
+ // for, the change and its tier, reversibility, verdicts, the second model and
934
+ // what the diff touches. It never approves and never writes. Each source is
935
+ // read on its own; one that fails makes its section `unknown`, not empty.
936
+ const briefPath = req.method === 'GET' && pathname.match(/^\/api\/gates\/([^/]+)\/brief$/);
937
+ if (briefPath) {
938
+ const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
939
+ const id = decodeURIComponent(briefPath[1]);
940
+ let inbox;
941
+ try { inbox = await getInboxAsync(c); }
942
+ catch {
943
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
944
+ res.end(JSON.stringify({ error: 'Task snapshot is unavailable; retry', state: 'unreadable' }));
945
+ return true;
946
+ }
947
+ const gate = (inbox.pending_gates || []).find((g) => g.id === id);
948
+ if (!gate) {
949
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
950
+ res.end(JSON.stringify({ error: 'not a pending gate', id }));
951
+ return true;
952
+ }
953
+ // Sequential: the evidence reader runs at most two workers at once, and the
954
+ // inbox poll may hold one.
955
+ const touches = await settle(readTouches(c));
956
+ const secondOpinion = await settle(readSecondOpinion(c));
957
+ const brief = gateBrief({
958
+ gate,
959
+ token: readTokens(c).tokens[id] || null,
960
+ binding: touches.ok ? { ok: true, value: touches.value.binding } : touches,
961
+ tests: touches.ok ? touches.value.tests : touches,
962
+ verdictLogs: await settle(() => readVerdictLogs(c)),
963
+ secondOpinion,
964
+ tier: await settle(() => getChangeTier(c)),
965
+ });
966
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
967
+ res.end(JSON.stringify(brief));
968
+ return true;
969
+ }
970
+
929
971
  // Receipt — does the approval still apply to what is in the tree?
930
972
  //
931
973
  // The reviewing agents already record a hash-bound receipt of the bytes they
@@ -2147,6 +2147,11 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2147
2147
  .dot-orange { background: var(--dot-orange); }
2148
2148
  .dot-amber { background: var(--status-progress); }
2149
2149
 
2150
+ .gate-brief { margin: -2px 0 8px 0; padding: 10px 14px; border-left: 3px solid var(--status-gate); background: var(--bg-muted); font-size: var(--fs-small, 12px); display: grid; gap: 4px; }
2151
+ .gate-brief .brief-sec { display: grid; grid-template-columns: 110px 1fr; gap: 10px; align-items: baseline; }
2152
+ .gate-brief .brief-k { color: var(--text3); }
2153
+ .gate-brief .brief-v { min-width: 0; overflow-wrap: anywhere; }
2154
+ .gate-brief .brief-moved { color: var(--status-blocked); margin-bottom: 4px; }
2150
2155
  .inbox-row {
2151
2156
  display: grid;
2152
2157
  grid-template-columns: 110px 1fr auto;
@@ -4735,6 +4740,75 @@ function ageHours(ts) {
4735
4740
  return Number.isFinite(ms) ? ms / 36e5 : null;
4736
4741
  }
4737
4742
 
4743
+ // Gate brief (ADR-036) — what this gate approves, on one panel.
4744
+ //
4745
+ // The inbox row says which gate is waiting; this says what approving it means:
4746
+ // whether the tree moved since the token was issued, what changed and its tier,
4747
+ // how expensive it is to undo, which reviewers saw THESE bytes, what the second
4748
+ // model said, and which parts of the system and which tests the change touches.
4749
+ // A section the server could not read says "unknown" — it is never drawn as an
4750
+ // empty, reassuring list. The panel stays open across inbox refreshes.
4751
+ const OPEN_BRIEFS = new Map(); // gate id → rendered html
4752
+ const BRIEF_TONE = { recorded: 'var(--text2)', empty: 'var(--status-blocked)', unknown: 'var(--status-blocked)' };
4753
+ function briefList(xs, max = 12) {
4754
+ const shown = (xs || []).slice(0, max).map(esc).join(', ');
4755
+ return (xs || []).length > max ? `${shown} … +${xs.length - max}` : shown;
4756
+ }
4757
+ function renderGateBrief(d) {
4758
+ if (!d || d.error || !Array.isArray(d.sections)) {
4759
+ return `<div class="muted">Brief unavailable — ${esc((d && (d.error || d.state)) || 'no response')}. This is not the same as nothing to review.</div>`;
4760
+ }
4761
+ const row = (label, s, body) => `<div class="brief-sec"><span class="brief-k">${label}</span>`
4762
+ + `<span class="brief-v" style="color:${BRIEF_TONE[s.state] || 'inherit'}">${s.state === 'unknown' ? `unknown — ${esc(s.why || '')}` : body}</span></div>`;
4763
+ const by = Object.fromEntries(d.sections.map((s) => [s.key, s]));
4764
+ const t = by.tree, c = by.change, r = by.risk, v = by.reviews, o = by.second, x = by.touches;
4765
+ const head = t.moved
4766
+ ? `<div class="brief-moved"><b>The tree moved after this gate was shown</b> — ${[
4767
+ [...(t.changed || []), ...(t.removed || [])].length ? `changed: ${briefList([...(t.changed || []), ...(t.removed || [])])}` : '',
4768
+ (t.added || []).length ? `added: ${briefList(t.added)}` : '',
4769
+ ].filter(Boolean).join('; ')}. Approving now approves bytes nobody on this page has seen.</div>`
4770
+ : '';
4771
+ const reviews = v.state === 'empty'
4772
+ ? 'no reviewer has recorded a verdict — nobody has looked'
4773
+ : (v.verdicts || []).map((x) => `<span class="${x.onThisTree ? '' : 'muted'}" title="${x.onThisTree ? 'recorded over these exact bytes' : 'saw other bytes, or recorded no receipt'}">${esc(x.agent)} ${esc(x.verdict)}${x.onThisTree ? ' ✓ this tree' : ' · other tree'}</span>`).join(' · ')
4774
+ + (v.rejected ? ` · <span style="color:var(--status-blocked)">${v.rejected} unreadable line(s)</span>` : '');
4775
+ const second = o.opinion === 'ok' ? `${esc(o.provider || '')} ${esc(o.verdict || '')}${o.p0 ? ` · P0 ${o.p0}` : ''}` : `${esc(o.opinion || '')}${o.why ? ` — ${esc(o.why)}` : ''}`;
4776
+ const touches = (x.containers || []).map((g) => `<b>${esc(g.name)}</b> ${g.total}`).join(' · ')
4777
+ + (x.tests ? ` · tests: ${x.tests.length ? briefList(x.tests, 6) : 'none mapped'}` : ` · tests unknown — ${esc(x.testsWhy || '')}`)
4778
+ + (x.unmapped?.length ? ` · <span style="color:var(--status-blocked)">${x.unmapped.length} file(s) without a test</span>` : '');
4779
+ return head
4780
+ + row('Tree', t, t.moved ? 'moved' : 'same as when the gate was shown')
4781
+ + row('Change', c, `${c.total} file(s)${c.tier ? ` · tier ${esc(c.tier)}` : ` · tier unknown${c.tierWhy ? ` — ${esc(c.tierWhy)}` : ''}`}${c.files?.length ? `<details><summary class="muted">files</summary>${briefList(c.files, 100)}</details>` : ''}`)
4782
+ + row('Undo', r, `${esc(r.reversibility)}${r.categories?.length ? ` · ${esc(r.categories.join(', '))}` : ''}`)
4783
+ + row('Reviews', v, reviews)
4784
+ + row('Second model', o, second)
4785
+ + row('Touches', x, touches);
4786
+ }
4787
+ async function toggleGateBrief(id, btn) {
4788
+ const row = btn.closest('.inbox-row');
4789
+ const open = row && row.nextElementSibling;
4790
+ if (open && open.classList.contains('gate-brief') && open.dataset.gate === id) { open.remove(); OPEN_BRIEFS.delete(id); return; }
4791
+ const box = document.createElement('div');
4792
+ box.className = 'gate-brief'; box.dataset.gate = id;
4793
+ box.innerHTML = '<span class="muted">Reading the evidence…</span>';
4794
+ row.after(box);
4795
+ OPEN_BRIEFS.set(id, box.innerHTML);
4796
+ const d = await api(`/api/gates/${encodeURIComponent(id)}/brief${pqs()}`);
4797
+ const html = renderGateBrief(d);
4798
+ OPEN_BRIEFS.set(id, html);
4799
+ for (const el of document.querySelectorAll('.gate-brief')) if (el.dataset.gate === id) el.innerHTML = html;
4800
+ }
4801
+ function reattachGateBriefs(root) {
4802
+ if (!OPEN_BRIEFS.size || !root) return;
4803
+ for (const row of root.querySelectorAll('.inbox-row[data-gate-id]')) {
4804
+ const id = row.dataset.gateId;
4805
+ if (!OPEN_BRIEFS.has(id)) continue;
4806
+ const box = document.createElement('div');
4807
+ box.className = 'gate-brief'; box.dataset.gate = id; box.innerHTML = OPEN_BRIEFS.get(id);
4808
+ row.after(box);
4809
+ }
4810
+ }
4811
+
4738
4812
  function renderInboxList(rootId, items, countId, opts = {}) {
4739
4813
  const root = document.getElementById(rootId);
4740
4814
  const cnt = document.getElementById(countId);
@@ -4843,6 +4917,7 @@ function renderInboxList(rootId, items, countId, opts = {}) {
4843
4917
  const guardedGate = canDecide && t.reversibility && t.reversibility.state !== 'routine';
4844
4918
  const actions = canDecide
4845
4919
  ? `<div class="actions${guardedGate ? ' gate-actions-stack' : ''}" onclick="event.stopPropagation()">
4920
+ <button type="button" class="gate-btn" title="What this gate approves: the change, the reviews on this tree, what it touches" onclick="event.stopPropagation(); toggleGateBrief('${esc(t.id)}', this)">Brief</button>
4846
4921
  <button class="gate-btn gate-reject" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'reject')">Reject…</button>
4847
4922
  <button class="gate-btn gate-approve" title="${guardedGate ? 'expensive to undo — you will be asked to type the gate name' : 'one confirm names the consequence'}" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'approve')">${guardedGate ? 'Approve…' : 'Approve'}</button>
4848
4923
  </div>`
@@ -4885,13 +4960,14 @@ function renderInboxList(rootId, items, countId, opts = {}) {
4885
4960
  ? `<span class="why why-${tone}">${esc(t.__reason)}</span>`
4886
4961
  : '';
4887
4962
  return `
4888
- <div class="inbox-row${aged ? ' row-aged' : ''}" tabindex="0" role="button" aria-label="${esc(t.title || t.id)}" onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();this.click();}' onclick='openSide(${JSON.stringify(t).replace(/'/g, "&apos;")})'>
4963
+ <div class="inbox-row${aged ? ' row-aged' : ''}"${canDecide ? ` data-gate-id="${esc(t.id)}"` : ''} tabindex="0" role="button" aria-label="${esc(t.title || t.id)}" onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();this.click();}' onclick='openSide(${JSON.stringify(t).replace(/'/g, "&apos;")})'>
4889
4964
  ${canDecide ? gateChip(t) : `<span class="id">${esc(t.id || '')}</span>`}
4890
4965
  <span class="ttl">${canDecide ? '' : reasonChip}${esc(canDecide ? String(t.title || '').replace(/^gate:[a-z-]+\s*[—–-]?\s*/, '') : t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
4891
4966
  ${canDecide ? reviewerCells(t) : ''}
4892
4967
  ${actions}${unblock}${rerun}${diffBtn}
4893
4968
  </div>`;
4894
4969
  }).join('');
4970
+ reattachGateBriefs(root);
4895
4971
  }
4896
4972
 
4897
4973
  // Sessions waiting on a person (scripts/lib/session-status.mjs). `blocked` is a
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * affected-tests — the test command for the files you changed, so the TDD loop runs
4
+ * those tests and the full suite runs once, before the verdict.
5
+ *
6
+ * Why this exists
7
+ * ---------------
8
+ * senior-dev ran the whole suite after each edit: `flutter test` 1,029 times, `cargo
9
+ * test` 454, on the projects measured (PLAN-2026-09-23-agent-speed). A red-green loop
10
+ * needs the test that covers the edit, not every test in the repository.
11
+ *
12
+ * Mapping, by stack, from the changed files (tracked changes vs HEAD + untracked):
13
+ * JS/TS a changed test file itself; else a sibling or any tests-dir file named
14
+ * <base>.test.* / <base>.spec.* → vitest | jest | node --test
15
+ * Rust the crate owning the file (nearest Cargo.toml [package] name) → cargo test -p
16
+ * Dart lib/a/b.dart → test/a/b_test.dart (or the changed _test.dart) → flutter/dart test
17
+ * Python pkg/mod.py → a test_mod.py anywhere in the tree (or the changed test_*.py) → pytest
18
+ * Go the package directory → go test ./dir
19
+ * A changed source file with no test found is reported, never silently dropped — and
20
+ * when nothing maps, the answer is `full`: the honest command is the whole suite.
21
+ *
22
+ * Usage: node scripts/lib/affected-tests.mjs [--cwd DIR] [--json]
23
+ */
24
+ import { spawnSync } from 'node:child_process';
25
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
26
+ import { join, dirname, basename, extname, relative, sep } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ // Dependency, build and tool-state directories: they hold no test of the project's
30
+ // own code, and walking them (node_modules alone can be 100k files) is the slow part.
31
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'target', '.dart_tool', '.venv', 'venv', '__pycache__', '.great_cto']);
32
+
33
+ function changedFiles(cwd) {
34
+ const run = (args) => spawnSync('git', args, { cwd, encoding: 'utf8' }).stdout.split('\n').filter(Boolean);
35
+ const set = new Set([...run(['diff', '--name-only', 'HEAD']), ...run(['ls-files', '--others', '--exclude-standard'])]);
36
+ return [...set].filter((f) => existsSync(join(cwd, f)));
37
+ }
38
+
39
+ function walk(dir, out = [], depth = 0) {
40
+ if (depth > 8) return out;
41
+ let entries = [];
42
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
43
+ for (const e of entries) {
44
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(join(dir, e.name), out, depth + 1); }
45
+ else out.push(join(dir, e.name));
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const JS = /\.(m?[jt]sx?|cjs)$/;
51
+ const isJsTest = (f) => /\.(test|spec)\.(m?[jt]sx?|cjs)$/.test(f);
52
+
53
+ function jsRunner(cwd) {
54
+ try {
55
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
56
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
57
+ if (deps.vitest) return (files) => `npx vitest run ${files.join(' ')}`;
58
+ if (deps.jest) return (files) => `npx jest ${files.join(' ')}`;
59
+ } catch { /* no package.json: node's own runner */ }
60
+ return (files) => `node --test ${files.join(' ')}`;
61
+ }
62
+
63
+ function crateOf(cwd, file) {
64
+ let d = dirname(join(cwd, file));
65
+ while (d.startsWith(cwd)) {
66
+ const t = join(d, 'Cargo.toml');
67
+ if (existsSync(t)) {
68
+ const m = readFileSync(t, 'utf8').match(/\[package\][^[]*?name\s*=\s*"([^"]+)"/);
69
+ if (m) return m[1];
70
+ }
71
+ if (d === cwd) break;
72
+ d = dirname(d);
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * @returns {{mode:'targeted'|'full'|'none', commands:string[], tests:string[], unmapped:string[]}}
79
+ */
80
+ export function affectedTests({ cwd = process.cwd(), changed } = {}) {
81
+ const files = (changed || changedFiles(cwd)).map((f) => f.split(sep).join('/'));
82
+ if (!files.length) return { mode: 'none', commands: [], tests: [], unmapped: [] };
83
+ let all = null;
84
+ const allFiles = () => (all ||= walk(cwd).map((f) => relative(cwd, f).split(sep).join('/')));
85
+ const js = new Set(); const dart = new Set(); const py = new Set(); const go = new Set(); const crates = new Set();
86
+ const unmapped = [];
87
+ for (const f of files) {
88
+ const base = basename(f).replace(/\.[^.]+$/, '');
89
+ if (JS.test(f)) {
90
+ if (isJsTest(f)) { js.add(f); continue; }
91
+ const hit = allFiles().filter((t) => isJsTest(t) && basename(t).replace(/\.(test|spec)\.[^.]+$/, '') === base);
92
+ if (hit.length) hit.forEach((t) => js.add(t)); else unmapped.push(f);
93
+ } else if (f.endsWith('.rs')) {
94
+ const c = crateOf(cwd, f); if (c) crates.add(c); else unmapped.push(f);
95
+ } else if (f.endsWith('.dart')) {
96
+ if (f.endsWith('_test.dart')) { dart.add(f); continue; }
97
+ const t = f.replace(/^lib\//, 'test/').replace(/\.dart$/, '_test.dart');
98
+ if (existsSync(join(cwd, t))) dart.add(t); else unmapped.push(f);
99
+ } else if (f.endsWith('.py')) {
100
+ if (/(^|\/)test_[^/]+\.py$|_test\.py$/.test(f)) { py.add(f); continue; }
101
+ const hit = allFiles().filter((t) => basename(t) === `test_${base}.py` || basename(t) === `${base}_test.py`);
102
+ if (hit.length) hit.forEach((t) => py.add(t)); else unmapped.push(f);
103
+ } else if (f.endsWith('.go')) {
104
+ go.add(`./${dirname(f)}`);
105
+ }
106
+ // Other files (docs, config, assets) carry no test mapping and are not code.
107
+ }
108
+ const commands = [];
109
+ if (js.size) commands.push(jsRunner(cwd)([...js]));
110
+ if (crates.size) commands.push(`cargo test ${[...crates].map((c) => `-p ${c}`).join(' ')}`);
111
+ if (dart.size) commands.push(`${existsSync(join(cwd, 'pubspec.yaml')) && /flutter:/.test(readFileSync(join(cwd, 'pubspec.yaml'), 'utf8')) ? 'flutter' : 'dart'} test ${[...dart].join(' ')}`);
112
+ if (py.size) commands.push(`pytest ${[...py].join(' ')}`);
113
+ if (go.size) commands.push(`go test ${[...go].join(' ')}`);
114
+ const tests = [...js, ...dart, ...py];
115
+ return { mode: commands.length ? 'targeted' : 'full', commands, tests, unmapped };
116
+ }
117
+
118
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
119
+ if (isMain) {
120
+ const a = process.argv.slice(2);
121
+ const i = a.indexOf('--cwd');
122
+ const r = affectedTests({ cwd: i >= 0 ? a[i + 1] : process.cwd() });
123
+ if (a.includes('--json')) process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
124
+ else {
125
+ if (r.mode === 'none') process.stdout.write('affected-tests: nothing changed\n');
126
+ else if (r.mode === 'full') process.stdout.write('affected-tests: full — no test maps to the changed files; run the whole suite\n');
127
+ else r.commands.forEach((c) => process.stdout.write(`${c}\n`));
128
+ if (r.unmapped.length) process.stderr.write(`affected-tests: no test found for ${r.unmapped.join(', ')} — cover them, or run the full suite\n`);
129
+ }
130
+ }
@@ -0,0 +1,157 @@
1
+ // One brief per gate (ADR-036) — what the approver is actually approving.
2
+ //
3
+ // Everything a gate decision needs was already recorded, in six places: the
4
+ // tree receipt, the change tier, the reversibility of the gate, the reviewers'
5
+ // verdicts, the second model's opinion, and which parts of the system the diff
6
+ // touches. The inbox showed a title. So a gate was approved on its title, or
7
+ // after reading logs by hand in a terminal.
8
+ //
9
+ // This is a projection, never an authority. It does not approve, does not
10
+ // write, and changes nothing about the token, the binding or who may approve.
11
+ // Three rules carry its weight:
12
+ //
13
+ // • unknown is not pass — a source that could not be read gives a section
14
+ // `unknown` with the reason; it never becomes an empty list;
15
+ // • bound to the tree — the receipt the token was issued for is compared
16
+ // with the tree now, and a move is the first thing said;
17
+ // • a verdict counts only for the tree it saw — an approval of other bytes is
18
+ // shown, and marked as such.
19
+ //
20
+ // Every input arrives already read, as `{ok, value}` or `{ok:false, why}`
21
+ // (see `settle`), so `gateBrief` does no I/O and each rule is testable alone.
22
+ import { readdirSync, readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { compareReceipts } from './receipt.mjs';
25
+ import { reversibilityOf } from './gate-reversibility.mjs';
26
+ import { groupOf } from './system-map.mjs';
27
+ import { parseVerdictLog } from './verdict-record.mjs';
28
+
29
+ /** A read, whatever happened to it, as a value or a reason. Never throws. */
30
+ export async function settle(read) {
31
+ try {
32
+ const value = await (typeof read === 'function' ? read() : read);
33
+ return { ok: true, value };
34
+ } catch (e) {
35
+ return { ok: false, why: String(e?.message || e) };
36
+ }
37
+ }
38
+
39
+ const MAX_FILES_SHOWN = 100;
40
+
41
+ function treeSection(token, binding, digest) {
42
+ if (!binding.ok) return { key: 'tree', state: 'unknown', moved: null, why: binding.why };
43
+ if (binding.value === null) return { key: 'tree', state: 'unknown', moved: null, why: 'not a git repository — there is no tree to bind an approval to' };
44
+ if (!token?.receipt) return { key: 'tree', state: 'unknown', moved: null, why: 'no approval token was issued for this gate yet' };
45
+ const cmp = compareReceipts(token.receipt, binding.value, { digest });
46
+ // `landed` is committed-since and byte-identical: the same bytes, not a move.
47
+ const moved = cmp.state !== 'matches';
48
+ return { key: 'tree', state: 'recorded', moved, changed: cmp.changed || [], added: cmp.added || [], removed: cmp.removed || [], why: cmp.why };
49
+ }
50
+
51
+ function changeSection(binding, tier) {
52
+ if (!binding.ok) return { key: 'change', state: 'unknown', why: binding.why };
53
+ if (binding.value === null) return { key: 'change', state: 'unknown', why: 'not a git repository — no change set to show' };
54
+ const all = Object.keys(binding.value.files || {}).sort();
55
+ const out = { key: 'change', state: 'recorded', total: all.length, files: all.slice(0, MAX_FILES_SHOWN),
56
+ head: binding.value.head || null, base: binding.value.base || null, dirty: binding.value.dirty ?? null,
57
+ tier: null, tierReasons: [] };
58
+ if (tier.ok && tier.value?.tier && !tier.value.error) { out.tier = tier.value.tier; out.tierReasons = tier.value.reasons || []; }
59
+ else out.tierWhy = tier.ok ? String(tier.value?.error || 'no tier was computed') : tier.why;
60
+ return out;
61
+ }
62
+
63
+ function riskSection(gate) {
64
+ const r = gate?.reversibility || reversibilityOf(gate);
65
+ return { key: 'risk', state: 'recorded', reversibility: r.state, categories: r.categories || [], why: r.why || '' };
66
+ }
67
+
68
+ function reviewsSection(verdictLogs, binding, digest) {
69
+ if (!verdictLogs.ok) return { key: 'reviews', state: 'unknown', why: verdictLogs.why, verdicts: [] };
70
+ const latest = new Map();
71
+ let rejected = 0;
72
+ for (const [agent, text] of Object.entries(verdictLogs.value || {})) {
73
+ const { records, rejected: bad } = parseVerdictLog(text, { agent });
74
+ rejected += bad.length;
75
+ for (const rec of records) {
76
+ const prev = latest.get(rec.agent);
77
+ if (!prev || String(rec.ts) > String(prev.ts)) latest.set(rec.agent, rec);
78
+ }
79
+ }
80
+ const current = binding.ok ? binding.value : null;
81
+ const verdicts = [...latest.values()]
82
+ .sort((a, b) => a.agent.localeCompare(b.agent))
83
+ .map((rec) => ({
84
+ agent: rec.agent, verdict: rec.verdict, ts: rec.ts,
85
+ // Only a recorded receipt of these exact bytes counts. No receipt, or no
86
+ // readable tree to compare with, is "not shown to be this tree".
87
+ onThisTree: Boolean(rec.receipt && current && compareReceipts(rec.receipt, current, { digest }).state === 'matches'),
88
+ }));
89
+ return { key: 'reviews', state: verdicts.length ? 'recorded' : 'empty', verdicts, rejected,
90
+ ...(verdicts.length ? {} : { why: 'no reviewer has recorded a verdict in this project' }) };
91
+ }
92
+
93
+ function secondSection(secondOpinion) {
94
+ if (!secondOpinion.ok) return { key: 'second', state: 'unknown', why: secondOpinion.why };
95
+ const v = secondOpinion.value || {};
96
+ return { key: 'second', state: 'recorded', opinion: v.state, provider: v.provider || null,
97
+ verdict: v.verdict || null, p0: v.p0 ?? null, why: v.why || null };
98
+ }
99
+
100
+ function touchesSection(binding, tests) {
101
+ if (!binding.ok) return { key: 'touches', state: 'unknown', why: binding.why };
102
+ if (binding.value === null) return { key: 'touches', state: 'unknown', why: 'not a git repository — nothing to group' };
103
+ const groups = new Map();
104
+ for (const f of Object.keys(binding.value.files || {}).sort()) {
105
+ const name = groupOf(f) || (f.includes('/') ? f.split('/')[0] : '(root)');
106
+ if (!groups.has(name)) groups.set(name, []);
107
+ groups.get(name).push(f);
108
+ }
109
+ const out = { key: 'touches', state: 'recorded',
110
+ containers: [...groups].map(([name, files]) => ({ name, files: files.slice(0, MAX_FILES_SHOWN), total: files.length })),
111
+ tests: null, unmapped: [] };
112
+ if (tests.ok && tests.value) { out.tests = tests.value.tests || []; out.unmapped = tests.value.unmapped || []; out.testsMode = tests.value.mode; }
113
+ else out.testsWhy = tests.ok ? 'no test mapping was computed' : tests.why;
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * @param {object} p
119
+ * @param {object} p.gate pending gate as the inbox returns it
120
+ * @param {object|null} p.token the token store entry ({receipt, receiptHash}) or null
121
+ * @param {{ok:boolean}} p.binding the current tree receipt (null outside git)
122
+ * @param {{ok:boolean}} p.verdictLogs {agent: log text}
123
+ * @param {{ok:boolean}} p.secondOpinion
124
+ * @param {{ok:boolean}} p.tier
125
+ * @param {{ok:boolean}} p.tests affectedTests() result
126
+ * @param {Function} [p.digest] file digest for compareReceipts (injected in tests)
127
+ */
128
+ export function gateBrief({ gate, token = null, binding, verdictLogs, secondOpinion, tier, tests, digest = undefined }) {
129
+ const tree = treeSection(token, binding, digest);
130
+ return {
131
+ gate: { id: gate?.id || null, title: gate?.title || '', gate: gate?.gate || null },
132
+ moved: tree.moved,
133
+ sections: [
134
+ tree,
135
+ changeSection(binding, tier),
136
+ riskSection(gate),
137
+ reviewsSection(verdictLogs, binding, digest),
138
+ secondSection(secondOpinion),
139
+ touchesSection(binding, tests),
140
+ ],
141
+ };
142
+ }
143
+
144
+ /**
145
+ * The project's verdict logs as `{agent: text}`. No directory is "nothing
146
+ * recorded" — a real answer. Any other failure throws, so `settle` turns it
147
+ * into `unknown` instead of an empty list.
148
+ */
149
+ export function readVerdictLogs(cwd) {
150
+ const dir = join(cwd, '.great_cto', 'verdicts');
151
+ let names;
152
+ try { names = readdirSync(dir); }
153
+ catch (e) { if (e?.code === 'ENOENT') return {}; throw e; }
154
+ const out = {};
155
+ for (const n of names) if (n.endsWith('.log')) out[n.slice(0, -4)] = readFileSync(join(dir, n), 'utf8');
156
+ return out;
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.64.0",
3
+ "version": "3.65.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",