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.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/agents/continuous-learner.md +12 -2
- package/board/packages/board/lib/beads.mjs +4 -1
- package/board/packages/board/lib/receipt-read-worker.mjs +25 -10
- package/board/packages/board/lib/receipt-read.mjs +6 -1
- package/board/packages/board/lib/routes.mjs +44 -2
- package/board/packages/board/public/index.html +77 -1
- package/board/scripts/lib/affected-tests.mjs +130 -0
- package/board/scripts/lib/gate-brief.mjs +157 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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. **
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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, "'")})'>
|
|
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, "'")})'>
|
|
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