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.
- 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 +127 -13
- 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;
|
|
@@ -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, "'")})'>
|
|
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, "'")})'>
|
|
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
|
-
//
|
|
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
|
-
//
|
|
9471
|
-
|
|
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
|
|
9572
|
+
return fleetRequirementsKnown() ? r.reviewers.find((x) => x.agent === slug) || null : null;
|
|
9486
9573
|
}
|
|
9487
9574
|
async function refreshRequiredReviewers() {
|
|
9488
|
-
const
|
|
9489
|
-
|
|
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${
|
|
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