great-cto 3.63.5 → 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.63.5",
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;
@@ -3480,6 +3485,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3480
3485
  <div class="nav-views-label" id="nav-views-label" hidden>Views</div>
3481
3486
  <!-- Fleet filters share the row pattern, but stay buttons, not destinations. -->
3482
3487
  <div class="nav-views" hidden>
3488
+ <div class="nav-view-item" id="nav-view-project" role="button" tabindex="0" onclick="setFleetView('project')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('project')}">Project focus</div>
3483
3489
  <div class="nav-view-item" id="nav-view-needs" role="button" tabindex="0" onclick="setFleetView('needs')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('needs')}">Needs attention</div>
3484
3490
  <div class="nav-view-item" id="nav-view-expensive" role="button" tabindex="0" onclick="setFleetView('expensive')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('expensive')}">Expensive grants</div>
3485
3491
  <div class="nav-view-item" id="nav-view-never" role="button" tabindex="0" onclick="setFleetView('never')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('never')}">Never observed</div>
@@ -3795,6 +3801,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3795
3801
  <section aria-label="Fleet summary" class="fleet-title-row">
3796
3802
  <h1 class="page-title" id="fleet-view-title" style="margin:0">Fleet</h1>
3797
3803
  </section>
3804
+ <p id="fleet-focus-note" class="muted" role="status"></p>
3798
3805
 
3799
3806
  <!-- §3.1 Fleet controls -->
3800
3807
  <section aria-label="Fleet controls" class="fleet-controls-row">
@@ -4733,6 +4740,75 @@ function ageHours(ts) {
4733
4740
  return Number.isFinite(ms) ? ms / 36e5 : null;
4734
4741
  }
4735
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
+
4736
4812
  function renderInboxList(rootId, items, countId, opts = {}) {
4737
4813
  const root = document.getElementById(rootId);
4738
4814
  const cnt = document.getElementById(countId);
@@ -4841,6 +4917,7 @@ function renderInboxList(rootId, items, countId, opts = {}) {
4841
4917
  const guardedGate = canDecide && t.reversibility && t.reversibility.state !== 'routine';
4842
4918
  const actions = canDecide
4843
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>
4844
4921
  <button class="gate-btn gate-reject" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'reject')">Reject…</button>
4845
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>
4846
4923
  </div>`
@@ -4883,13 +4960,14 @@ function renderInboxList(rootId, items, countId, opts = {}) {
4883
4960
  ? `<span class="why why-${tone}">${esc(t.__reason)}</span>`
4884
4961
  : '';
4885
4962
  return `
4886
- <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;")})'>
4887
4964
  ${canDecide ? gateChip(t) : `<span class="id">${esc(t.id || '')}</span>`}
4888
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>
4889
4966
  ${canDecide ? reviewerCells(t) : ''}
4890
4967
  ${actions}${unblock}${rerun}${diffBtn}
4891
4968
  </div>`;
4892
4969
  }).join('');
4970
+ reattachGateBriefs(root);
4893
4971
  }
4894
4972
 
4895
4973
  // Sessions waiting on a person (scripts/lib/session-status.mjs). `blocked` is a
@@ -9463,13 +9541,10 @@ const fleetState = {
9463
9541
  agents: [],
9464
9542
  summary: null,
9465
9543
  legacy_count: 0,
9466
- // Default: hide never-run / retired agents. 35 installed → ~10 actually
9467
- // doing work on most projects; showing all 35 by default is noise.
9468
- // User flips activity to `all` to see the full installation.
9544
+ // View membership is distinct from optional activity filters.
9469
9545
  filters: { domain: 'all', activity: 'all', health: 'all' },
9470
- // Five saved views, and only five (DESIGN §5): the default is the subset
9471
- // nobody has judged yet, not the roster.
9472
- view: 'needs',
9546
+ // Presentation only: dispatch and required-reviewer policy are unchanged.
9547
+ view: 'project',
9473
9548
  search: '',
9474
9549
  sort: 'runs_30d',
9475
9550
  drawerSlug: null,
@@ -9479,15 +9554,32 @@ const fleetState = {
9479
9554
  // Reviewers this project requires (/api/required-reviewers) — gate:ship refuses
9480
9555
  // while one has no verdict.
9481
9556
  required: null,
9557
+ requiredScope: null,
9558
+ requiredRequest: 0,
9559
+ rosterRequest: 0,
9560
+ rosterScope: null,
9482
9561
  };
9562
+ const FLEET_CORE_ROLES = new Set([
9563
+ 'product-owner', 'architect', 'pm', 'senior-dev', 'code-reviewer',
9564
+ 'qa-engineer', 'security-officer', 'devops',
9565
+ ]);
9566
+ function fleetRequirementsKnown() {
9567
+ return fleetState.requiredScope === pqs() && fleetState.required?.state === 'read'
9568
+ && Array.isArray(fleetState.required.reviewers);
9569
+ }
9483
9570
  function requiredOf(slug) {
9484
9571
  const r = fleetState.required;
9485
- return r && r.state === 'read' ? (r.reviewers || []).find((x) => x.agent === slug) || null : null;
9572
+ return fleetRequirementsKnown() ? r.reviewers.find((x) => x.agent === slug) || null : null;
9486
9573
  }
9487
9574
  async function refreshRequiredReviewers() {
9488
- const r = await api(`/api/required-reviewers${pqs()}`);
9489
- if (!r) return;
9575
+ const scope = pqs(), request = ++fleetState.requiredRequest;
9576
+ fleetState.required = null;
9577
+ fleetState.requiredScope = null;
9578
+ renderFleetList();
9579
+ const r = await api(`/api/required-reviewers${scope}`);
9580
+ if (request !== fleetState.requiredRequest || scope !== pqs()) return;
9490
9581
  fleetState.required = r;
9582
+ fleetState.requiredScope = scope;
9491
9583
  renderFleetList();
9492
9584
  }
9493
9585
 
@@ -9507,10 +9599,16 @@ async function refreshAgentUsage() {
9507
9599
  }
9508
9600
 
9509
9601
  async function refreshAgentsInstalled() {
9602
+ const scope = pqs(), request = ++fleetState.rosterRequest;
9603
+ if (fleetState.rosterScope !== scope) {
9604
+ fleetState.agents = [];
9605
+ fleetState.summary = null;
9606
+ fleetState.rosterScope = scope;
9607
+ }
9510
9608
  refreshAgentUsage();
9511
9609
  refreshRequiredReviewers();
9512
- const data = await api(`/api/agents-installed${pqs()}`);
9513
- if (!data) return;
9610
+ const data = await api(`/api/agents-installed${scope}`);
9611
+ if (!data || request !== fleetState.rosterRequest || scope !== pqs()) return;
9514
9612
  fleetState.agents = data.agents || [];
9515
9613
  fleetState.summary = data.summary || null;
9516
9614
  // If this project has no verdicts at all, show all agents (not just 'used').
@@ -9534,6 +9632,7 @@ async function refreshAgentsInstalled() {
9534
9632
 
9535
9633
 
9536
9634
  const FLEET_VIEWS = [
9635
+ ['project', 'Project focus'],
9537
9636
  ['needs', 'Needs attention'], ['expensive', 'Expensive grants'], ['never', 'Never observed'], ['all', 'All agents'], ['retired', 'Retired'],
9538
9637
  ];
9539
9638
  // What "needs attention" means, in one place: failing, an unjudged posture, a
@@ -9548,6 +9647,11 @@ function agentNeedsAttention(a) {
9548
9647
  || String(a.budget?.state || '').startsWith('over');
9549
9648
  }
9550
9649
  function inFleetView(a, view) {
9650
+ if (view === 'project') {
9651
+ // An unavailable policy is not evidence that a specialist is unnecessary.
9652
+ if (!fleetRequirementsKnown()) return !a.retired;
9653
+ return !!requiredOf(a.slug) || (!a.retired && FLEET_CORE_ROLES.has(a.slug));
9654
+ }
9551
9655
  if (view === 'retired') return !!a.retired;
9552
9656
  if (a.retired) return false;
9553
9657
  if (view === 'needs') return agentNeedsAttention(a);
@@ -9568,6 +9672,16 @@ function setFleetView(view) {
9568
9672
  // a count, because "2" would read as two new items.
9569
9673
  function renderFleetViews() {
9570
9674
  const agents = fleetState.agents || [];
9675
+ const note = document.getElementById('fleet-focus-note');
9676
+ if (note) {
9677
+ note.hidden = fleetState.view !== 'project';
9678
+ const attention = agents.filter(a => inFleetView(a, 'needs')).length;
9679
+ const missing = fleetRequirementsKnown()
9680
+ ? fleetState.required.reviewers.filter(r => !agents.some(a => a.slug === r.agent)).map(r => r.agent) : [];
9681
+ note.textContent = fleetRequirementsKnown()
9682
+ ? `Core roles and this project's required reviewers. ${missing.length} required reviewer(s) missing from the installed catalog${missing.length ? ': ' + missing.join(', ') : ''}. ${attention} installed agent(s) need attention across the full catalog; open Needs attention. Display only, not an execution plan or proof of expertise.`
9683
+ : 'Project reviewer requirements are unknown; showing the installed catalog rather than hiding specialists. Display only; execution policy is unchanged.';
9684
+ }
9571
9685
  for (const [key, label] of FLEET_VIEWS) {
9572
9686
  const el = document.getElementById(`nav-view-${key}`);
9573
9687
  if (!el) continue;
@@ -9601,7 +9715,7 @@ function applyFleetFilters(agents) {
9601
9715
  if (activity === 'idle' && (a.runs_30d > 0 || a.runs_total === 0)) return false;
9602
9716
  if (activity === 'never' && a.runs_total > 0) return false;
9603
9717
  if (activity === 'retired' && !a.retired) return false;
9604
- if (activity !== 'retired' && a.retired) return false;
9718
+ if (activity !== 'retired' && a.retired && !(fleetState.view === 'project' && requiredOf(a.slug))) return false;
9605
9719
  if (health === 'ok' && a.health === 'failing') return false;
9606
9720
  if (health === 'failing' && a.health !== 'failing') return false;
9607
9721
  return true;
@@ -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.63.5",
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",