@olegkoval/agent-skills 1.58.0 → 1.59.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/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +105 -9
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/prover.md +18 -6
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +2 -1
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/github-post.md +6 -1
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/traces.md +39 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/deps.mjs +305 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-engine.sh +7 -2
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +344 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +107 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +16 -1
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/test-runner.mjs +75 -0
- package/package.json +1 -1
- package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +105 -9
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/prover.md +18 -6
- package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +2 -1
- package/plugins/olko-github-pr/skills/lekker-review/references/github-post.md +6 -1
- package/plugins/olko-github-pr/skills/lekker-review/references/traces.md +39 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/deps.mjs +305 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-engine.sh +7 -2
- package/plugins/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +344 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +107 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +16 -1
- package/plugins/olko-github-pr/skills/lekker-review/scripts/test-runner.mjs +75 -0
- package/plugins/olko-github-pr/skills/lekker-review/workflow.js +14 -2
- package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run ledger for lekker-review: one row per review run, plus a small archive of
|
|
3
|
+
// the run's own files, so past runs can be measured and compared.
|
|
4
|
+
//
|
|
5
|
+
// Before this, the evidence of a run was split across four places that nothing
|
|
6
|
+
// joined: the review markdown, the scratchpad (deleted with the session), the
|
|
7
|
+
// Workflow tool's own record under ~/.claude/projects (owned by Claude Code and
|
|
8
|
+
// cleaned with old sessions), and the revmux task archive.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// node run-log.mjs start --repo <slug> --target <pr-N|branch-x> --mode pr|branch \
|
|
12
|
+
// --depth <d> --engine workflow|revmux [--fix] [--host claude]
|
|
13
|
+
// node run-log.mjs mark <runId> <phase> [--note <text>]
|
|
14
|
+
// node run-log.mjs attach <runId> <file>...
|
|
15
|
+
// node run-log.mjs finish <runId> --status ok|failed|aborted [--verdict lgtm|lgtm-with-changes|needs-work]
|
|
16
|
+
// [--review-file <path>] [--artifact-url <url>] [--wf <wf_id>]... [--note <text>]
|
|
17
|
+
// node run-log.mjs report [--days 30] [--json]
|
|
18
|
+
// node run-log.mjs backfill import past Workflow-tool records and revmux rounds
|
|
19
|
+
// node run-log.mjs prune [--keep-days 90]
|
|
20
|
+
//
|
|
21
|
+
// Layout under ${LEKKER_STATE_DIR:-~/.local/state/lekker-review}:
|
|
22
|
+
// runs.jsonl one row per finish; `report` keeps the LAST row per runId
|
|
23
|
+
// runs/<runId>/run.json the same row, with the timeline
|
|
24
|
+
// runs/<runId>/timeline.jsonl
|
|
25
|
+
// runs/<runId>/<attached files>
|
|
26
|
+
//
|
|
27
|
+
// `finish` may be called more than once for one run (after the review, again
|
|
28
|
+
// after fix mode); every call appends a fresh row and the newest one wins.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
|
|
31
|
+
import { homedir } from 'node:os';
|
|
32
|
+
import { basename, join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
const STATE_DIR = process.env.LEKKER_STATE_DIR || join(homedir(), '.local', 'state', 'lekker-review');
|
|
35
|
+
const RUNS_DIR = join(STATE_DIR, 'runs');
|
|
36
|
+
const LEDGER = join(STATE_DIR, 'runs.jsonl');
|
|
37
|
+
const PROJECTS_DIR = process.env.LEKKER_CLAUDE_PROJECTS || join(homedir(), '.claude', 'projects');
|
|
38
|
+
const REVMUX_TASKS = process.env.LEKKER_REVMUX_TASKS_DIR || join(homedir(), 'code-reviews', 'revmux-tasks');
|
|
39
|
+
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
41
|
+
const cmd = argv[0];
|
|
42
|
+
|
|
43
|
+
function flag(name, fallback = null) {
|
|
44
|
+
const i = argv.indexOf('--' + name);
|
|
45
|
+
return i === -1 ? fallback : argv[i + 1];
|
|
46
|
+
}
|
|
47
|
+
function flags(name) {
|
|
48
|
+
const out = [];
|
|
49
|
+
argv.forEach((a, i) => { if (a === '--' + name && argv[i + 1]) out.push(argv[i + 1]); });
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
const has = (name) => argv.includes('--' + name);
|
|
53
|
+
|
|
54
|
+
const readJson = (p) => {
|
|
55
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
|
|
56
|
+
};
|
|
57
|
+
const readJsonl = (p) => (existsSync(p) ? readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => {
|
|
58
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
59
|
+
}).filter(Boolean) : []);
|
|
60
|
+
|
|
61
|
+
function runDir(runId) {
|
|
62
|
+
if (!/^[A-Za-z0-9._-]+$/.test(runId || '')) fail(`bad runId "${runId}"`);
|
|
63
|
+
return join(RUNS_DIR, runId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fail(msg) {
|
|
67
|
+
console.log(JSON.stringify({ error: msg }));
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
72
|
+
|
|
73
|
+
// The Workflow tool keeps its own record of every run (duration, total tokens,
|
|
74
|
+
// agent count, the returned result). Copying the numbers out at finish time is
|
|
75
|
+
// what keeps them once Claude Code cleans up the session directory.
|
|
76
|
+
function findWorkflowRecord(wfId) {
|
|
77
|
+
if (!existsSync(PROJECTS_DIR)) return null;
|
|
78
|
+
for (const project of readdirSync(PROJECTS_DIR)) {
|
|
79
|
+
const projectDir = join(PROJECTS_DIR, project);
|
|
80
|
+
let sessions;
|
|
81
|
+
try { sessions = readdirSync(projectDir); } catch { continue; }
|
|
82
|
+
for (const session of sessions) {
|
|
83
|
+
const p = join(projectDir, session, 'workflows', `${wfId}.json`);
|
|
84
|
+
if (existsSync(p)) return readJson(p);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function summariseWorkflow(rec) {
|
|
91
|
+
const result = rec?.result && typeof rec.result === 'object' ? rec.result : {};
|
|
92
|
+
return {
|
|
93
|
+
wfId: rec?.runId ?? null,
|
|
94
|
+
name: rec?.workflowName ?? null,
|
|
95
|
+
status: rec?.status ?? null,
|
|
96
|
+
durationS: rec?.durationMs != null ? Math.round(rec.durationMs / 1000) : null,
|
|
97
|
+
totalTokens: rec?.totalTokens ?? null,
|
|
98
|
+
agentCount: rec?.agentCount ?? null,
|
|
99
|
+
toolCalls: rec?.totalToolCalls ?? null,
|
|
100
|
+
outputTokens: result.outputTokens ?? null,
|
|
101
|
+
found: Array.isArray(result.findings) ? result.findings.length : null,
|
|
102
|
+
dropped: result.droppedCount ?? null,
|
|
103
|
+
downgraded: result.downgradedCount ?? null,
|
|
104
|
+
hardRule: result.hardRuleCount ?? null,
|
|
105
|
+
proveAttempts: result.proveAttemptCount ?? null,
|
|
106
|
+
proven: result.provenCount ?? null,
|
|
107
|
+
sessionModel: rec?.defaultModel ?? null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function severityCounts(findings) {
|
|
112
|
+
const list = Array.isArray(findings) ? findings : findings?.findings;
|
|
113
|
+
if (!Array.isArray(list)) return null;
|
|
114
|
+
const counts = { total: list.length };
|
|
115
|
+
for (const f of list) counts[f.severity || 'unknown'] = (counts[f.severity || 'unknown'] || 0) + 1;
|
|
116
|
+
return counts;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function phaseDurations(timeline) {
|
|
120
|
+
const out = {};
|
|
121
|
+
for (let i = 0; i < timeline.length - 1; i++) {
|
|
122
|
+
const ms = Date.parse(timeline[i + 1].at) - Date.parse(timeline[i].at);
|
|
123
|
+
out[timeline[i].phase] = (out[timeline[i].phase] || 0) + Math.round(ms / 1000);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function append(row) {
|
|
129
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
130
|
+
appendFileSync(LEDGER, JSON.stringify(row) + '\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function latestRows(days) {
|
|
134
|
+
const cutoff = days ? Date.now() - days * 86400000 : 0;
|
|
135
|
+
const byId = new Map();
|
|
136
|
+
for (const row of readJsonl(LEDGER)) byId.set(row.runId, row);
|
|
137
|
+
return [...byId.values()].filter((r) => Date.parse(r.startedAt || r.finishedAt) >= cutoff);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const median = (xs) => {
|
|
141
|
+
const s = xs.filter((x) => typeof x === 'number').sort((a, b) => a - b);
|
|
142
|
+
return s.length ? s[Math.floor((s.length - 1) / 2)] : null;
|
|
143
|
+
};
|
|
144
|
+
const p90 = (xs) => {
|
|
145
|
+
const s = xs.filter((x) => typeof x === 'number').sort((a, b) => a - b);
|
|
146
|
+
return s.length ? s[Math.floor(0.9 * (s.length - 1))] : null;
|
|
147
|
+
};
|
|
148
|
+
const sum = (xs) => xs.reduce((a, x) => a + (typeof x === 'number' ? x : 0), 0);
|
|
149
|
+
|
|
150
|
+
if (cmd === 'start') {
|
|
151
|
+
const repo = flag('repo');
|
|
152
|
+
const target = flag('target');
|
|
153
|
+
if (!repo || !target) fail('start needs --repo and --target');
|
|
154
|
+
const startedAt = new Date().toISOString();
|
|
155
|
+
const runId = `${startedAt.replace(/[-:]/g, '').slice(0, 15)}-${slug(repo.split('/').pop())}-${slug(target)}`;
|
|
156
|
+
const dir = runDir(runId);
|
|
157
|
+
mkdirSync(dir, { recursive: true });
|
|
158
|
+
const meta = {
|
|
159
|
+
schema: 1, runId, startedAt, repo, target,
|
|
160
|
+
mode: flag('mode', 'pr'), depth: flag('depth'), engine: flag('engine', 'workflow'),
|
|
161
|
+
fix: has('fix'), host: flag('host', 'claude'),
|
|
162
|
+
};
|
|
163
|
+
writeFileSync(join(dir, 'run.json'), JSON.stringify(meta, null, 2) + '\n');
|
|
164
|
+
appendFileSync(join(dir, 'timeline.jsonl'), JSON.stringify({ phase: 'start', at: startedAt }) + '\n');
|
|
165
|
+
console.log(JSON.stringify({ runId, dir }));
|
|
166
|
+
} else if (cmd === 'mark') {
|
|
167
|
+
const [runId, phase] = [argv[1], argv[2]];
|
|
168
|
+
if (!phase) fail('mark needs <runId> <phase>');
|
|
169
|
+
const dir = runDir(runId);
|
|
170
|
+
if (!existsSync(dir)) fail(`no run ${runId}`);
|
|
171
|
+
appendFileSync(join(dir, 'timeline.jsonl'), JSON.stringify({ phase, at: new Date().toISOString(), note: flag('note') ?? undefined }) + '\n');
|
|
172
|
+
console.log(JSON.stringify({ runId, phase }));
|
|
173
|
+
} else if (cmd === 'attach') {
|
|
174
|
+
const runId = argv[1];
|
|
175
|
+
const dir = runDir(runId);
|
|
176
|
+
if (!existsSync(dir)) fail(`no run ${runId}`);
|
|
177
|
+
const copied = [];
|
|
178
|
+
const missing = [];
|
|
179
|
+
for (const file of argv.slice(2)) {
|
|
180
|
+
if (existsSync(file)) { copyFileSync(file, join(dir, basename(file))); copied.push(basename(file)); }
|
|
181
|
+
else missing.push(file);
|
|
182
|
+
}
|
|
183
|
+
console.log(JSON.stringify({ runId, copied, missing }));
|
|
184
|
+
} else if (cmd === 'finish') {
|
|
185
|
+
const runId = argv[1];
|
|
186
|
+
const dir = runDir(runId);
|
|
187
|
+
const meta = readJson(join(dir, 'run.json'));
|
|
188
|
+
if (!meta) fail(`no run ${runId}`);
|
|
189
|
+
const finishedAt = new Date().toISOString();
|
|
190
|
+
const timeline = [...readJsonl(join(dir, 'timeline.jsonl')), { phase: 'finish', at: finishedAt }];
|
|
191
|
+
const workflows = flags('wf').map((id) => summariseWorkflow(findWorkflowRecord(id) || { runId: id }));
|
|
192
|
+
const reviewWf = workflows.find((w) => w.name === 'lekker-review-core') || null;
|
|
193
|
+
const row = {
|
|
194
|
+
...meta,
|
|
195
|
+
finishedAt,
|
|
196
|
+
status: flag('status', 'ok'),
|
|
197
|
+
verdict: flag('verdict', meta.verdict ?? null),
|
|
198
|
+
wallS: Math.round((Date.parse(finishedAt) - Date.parse(meta.startedAt)) / 1000),
|
|
199
|
+
phasesS: phaseDurations(timeline),
|
|
200
|
+
findings: severityCounts(readJson(join(dir, 'findings.json'))),
|
|
201
|
+
review: reviewWf,
|
|
202
|
+
workflows,
|
|
203
|
+
tokensTotal: sum(workflows.map((w) => w.totalTokens)) || null,
|
|
204
|
+
reviewFile: flag('review-file', meta.reviewFile ?? null),
|
|
205
|
+
artifactUrl: flag('artifact-url'),
|
|
206
|
+
note: flag('note', meta.note ?? null),
|
|
207
|
+
};
|
|
208
|
+
writeFileSync(join(dir, 'run.json'), JSON.stringify({ ...row, timeline }, null, 2) + '\n');
|
|
209
|
+
append(row);
|
|
210
|
+
const unresolved = workflows.filter((w) => w.durationS == null).map((w) => w.wfId);
|
|
211
|
+
console.log(JSON.stringify({ runId, ledger: LEDGER, wallS: row.wallS, tokensTotal: row.tokensTotal, unresolvedWorkflows: unresolved }));
|
|
212
|
+
} else if (cmd === 'report') {
|
|
213
|
+
const days = Number(flag('days', '30'));
|
|
214
|
+
const rows = latestRows(days);
|
|
215
|
+
const groups = new Map();
|
|
216
|
+
for (const r of rows) {
|
|
217
|
+
const key = `${r.engine || 'workflow'}/${r.depth || 'unknown'}`;
|
|
218
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
219
|
+
groups.get(key).push(r);
|
|
220
|
+
}
|
|
221
|
+
const table = [...groups.entries()].sort().map(([key, rs]) => {
|
|
222
|
+
const found = sum(rs.map((r) => r.review?.found));
|
|
223
|
+
const dropped = sum(rs.map((r) => r.review?.dropped));
|
|
224
|
+
return {
|
|
225
|
+
group: key,
|
|
226
|
+
runs: rs.length,
|
|
227
|
+
failed: rs.filter((r) => r.status !== 'ok').length,
|
|
228
|
+
reviewMedianS: median(rs.map((r) => r.review?.durationS)),
|
|
229
|
+
reviewP90S: p90(rs.map((r) => r.review?.durationS)),
|
|
230
|
+
wallMedianS: median(rs.map((r) => r.wallS)),
|
|
231
|
+
tokensMedianK: median(rs.map((r) => (r.tokensTotal ? Math.round(r.tokensTotal / 1000) : null))),
|
|
232
|
+
agentsMedian: median(rs.map((r) => r.review?.agentCount)),
|
|
233
|
+
findingsMedian: median(rs.map((r) => r.review?.found ?? r.findings?.total)),
|
|
234
|
+
verifierDropRate: found + dropped ? +(dropped / (found + dropped)).toFixed(3) : null,
|
|
235
|
+
proven: `${sum(rs.map((r) => r.review?.proven))}/${sum(rs.map((r) => r.review?.proveAttempts))}`,
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
const verdicts = {};
|
|
239
|
+
for (const r of rows) if (r.verdict) verdicts[r.verdict] = (verdicts[r.verdict] || 0) + 1;
|
|
240
|
+
const revmux = rows.filter((r) => r.source === 'revmux-backfill');
|
|
241
|
+
// Cost medians over rounds that produced a result: a fully degraded round costs ~$0 and drags them down.
|
|
242
|
+
const revmuxOk = revmux.filter((r) => r.status === 'ok');
|
|
243
|
+
const out = {
|
|
244
|
+
window: `${days}d`, runs: rows.length, ledger: LEDGER, table, verdicts,
|
|
245
|
+
revmuxRounds: revmux.length ? {
|
|
246
|
+
rounds: revmux.length,
|
|
247
|
+
fullyDegraded: revmux.filter((r) => r.revmux.degraded >= r.revmux.agents && r.revmux.agents > 0).length,
|
|
248
|
+
medianUsd: median(revmuxOk.map((r) => r.revmux.usd)),
|
|
249
|
+
medianTokensK: median(revmuxOk.map((r) => (r.tokensTotal ? Math.round(r.tokensTotal / 1000) : null))),
|
|
250
|
+
medianWallS: median(revmuxOk.map((r) => r.wallS)),
|
|
251
|
+
} : null,
|
|
252
|
+
};
|
|
253
|
+
if (has('json')) {
|
|
254
|
+
console.log(JSON.stringify(out, null, 2));
|
|
255
|
+
} else {
|
|
256
|
+
console.log(`lekker-review runs, last ${out.window}: ${out.runs} (ledger ${LEDGER})`);
|
|
257
|
+
const cols = ['group', 'runs', 'failed', 'reviewMedianS', 'reviewP90S', 'tokensMedianK', 'agentsMedian', 'findingsMedian', 'verifierDropRate', 'proven'];
|
|
258
|
+
console.log(cols.join('\t'));
|
|
259
|
+
for (const t of table) console.log(cols.map((c) => t[c] ?? '-').join('\t'));
|
|
260
|
+
if (Object.keys(verdicts).length) console.log('verdicts:', JSON.stringify(verdicts));
|
|
261
|
+
if (out.revmuxRounds) console.log('revmux rounds:', JSON.stringify(out.revmuxRounds));
|
|
262
|
+
}
|
|
263
|
+
} else if (cmd === 'backfill') {
|
|
264
|
+
const known = new Set(latestRows(0).flatMap((r) => [r.runId, ...(r.workflows || []).map((w) => w.wfId)]));
|
|
265
|
+
let added = 0;
|
|
266
|
+
if (existsSync(PROJECTS_DIR)) {
|
|
267
|
+
for (const project of readdirSync(PROJECTS_DIR)) {
|
|
268
|
+
let sessions;
|
|
269
|
+
try { sessions = readdirSync(join(PROJECTS_DIR, project)); } catch { continue; }
|
|
270
|
+
for (const session of sessions) {
|
|
271
|
+
const wfDir = join(PROJECTS_DIR, project, session, 'workflows');
|
|
272
|
+
if (!existsSync(wfDir)) continue;
|
|
273
|
+
for (const f of readdirSync(wfDir)) {
|
|
274
|
+
if (!f.endsWith('.json')) continue;
|
|
275
|
+
const rec = readJson(join(wfDir, f));
|
|
276
|
+
if (!rec?.workflowName?.startsWith('lekker-review') || known.has(rec.runId)) continue;
|
|
277
|
+
const args = rec.args && typeof rec.args === 'object' ? rec.args : {};
|
|
278
|
+
const w = summariseWorkflow(rec);
|
|
279
|
+
append({
|
|
280
|
+
schema: 1, source: 'harness-backfill', runId: rec.runId, startedAt: rec.timestamp,
|
|
281
|
+
repo: args.repoSlug ?? null, target: args.prNumber ? `pr-${args.prNumber}` : args.targetLabel ?? null,
|
|
282
|
+
depth: rec.workflowName === 'lekker-review-fix' ? 'fix' : args.depth ?? null,
|
|
283
|
+
engine: args.engine || 'workflow', status: rec.status === 'completed' ? 'ok' : 'failed',
|
|
284
|
+
review: rec.workflowName === 'lekker-review-core' ? w : null, workflows: [w], tokensTotal: w.totalTokens,
|
|
285
|
+
});
|
|
286
|
+
known.add(rec.runId);
|
|
287
|
+
added++;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (existsSync(REVMUX_TASKS)) {
|
|
293
|
+
for (const task of readdirSync(REVMUX_TASKS)) {
|
|
294
|
+
let rounds;
|
|
295
|
+
try { rounds = readdirSync(join(REVMUX_TASKS, task)); } catch { continue; }
|
|
296
|
+
for (const round of rounds) {
|
|
297
|
+
const roundDir = join(REVMUX_TASKS, task, round);
|
|
298
|
+
const events = readJsonl(join(roundDir, 'events.jsonl'));
|
|
299
|
+
const runId = `revmux-${task}-${round}`;
|
|
300
|
+
if (!events.length || known.has(runId)) continue;
|
|
301
|
+
const started = new Set(events.filter((e) => e.kind === 'agent_started').map((e) => e.agent));
|
|
302
|
+
const degraded = new Set(events.filter((e) => e.kind === 'agent_degraded').map((e) => e.agent));
|
|
303
|
+
let usd = 0;
|
|
304
|
+
let tokens = 0;
|
|
305
|
+
const agentsDir = join(roundDir, 'agents');
|
|
306
|
+
if (existsSync(agentsDir)) {
|
|
307
|
+
for (const a of readdirSync(agentsDir)) {
|
|
308
|
+
for (const line of readJsonl(join(agentsDir, a))) {
|
|
309
|
+
if (line.type !== 'result') continue;
|
|
310
|
+
usd += line.total_cost_usd || 0;
|
|
311
|
+
const u = line.usage || {};
|
|
312
|
+
tokens += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.output_tokens || 0);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const times = events.map((e) => Date.parse(e.at)).filter(Number.isFinite);
|
|
317
|
+
append({
|
|
318
|
+
schema: 1, source: 'revmux-backfill', runId, startedAt: new Date(Math.min(...times)).toISOString(),
|
|
319
|
+
target: task, engine: 'revmux', depth: 'round', status: degraded.size >= started.size && started.size ? 'failed' : 'ok',
|
|
320
|
+
wallS: Math.round((Math.max(...times) - Math.min(...times)) / 1000), tokensTotal: tokens || null,
|
|
321
|
+
revmux: { agents: started.size, degraded: degraded.size, usd: +usd.toFixed(2) },
|
|
322
|
+
});
|
|
323
|
+
known.add(runId);
|
|
324
|
+
added++;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
console.log(JSON.stringify({ added, ledger: LEDGER }));
|
|
329
|
+
} else if (cmd === 'prune') {
|
|
330
|
+
const keepDays = Number(flag('keep-days', '90'));
|
|
331
|
+
const cutoff = Date.now() - keepDays * 86400000;
|
|
332
|
+
const removed = [];
|
|
333
|
+
if (existsSync(RUNS_DIR)) {
|
|
334
|
+
for (const id of readdirSync(RUNS_DIR)) {
|
|
335
|
+
const dir = join(RUNS_DIR, id);
|
|
336
|
+
// Only directories this script created: they all hold a run.json.
|
|
337
|
+
if (!existsSync(join(dir, 'run.json'))) continue;
|
|
338
|
+
if (statSync(dir).mtimeMs < cutoff) { rmSync(dir, { recursive: true, force: true }); removed.push(id); }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
console.log(JSON.stringify({ removed: removed.length, keptLedger: LEDGER }));
|
|
342
|
+
} else {
|
|
343
|
+
fail(`unknown command "${cmd}"`);
|
|
344
|
+
}
|
|
@@ -569,5 +569,112 @@ console.log('\nbot pre-emption: lint pre-flight, pattern mining, precision store
|
|
|
569
569
|
rmSync(tmp, { recursive: true, force: true })
|
|
570
570
|
}
|
|
571
571
|
|
|
572
|
+
console.log('\ndeps: obsolete ledger tools are ignored')
|
|
573
|
+
{
|
|
574
|
+
const { spawnSync } = await import('node:child_process')
|
|
575
|
+
const { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } = await import('node:fs')
|
|
576
|
+
const { tmpdir } = await import('node:os')
|
|
577
|
+
const tmp = mkdtempSync(join(tmpdir(), 'lekker-deps-'))
|
|
578
|
+
try {
|
|
579
|
+
const bin = join(tmp, 'bin')
|
|
580
|
+
mkdirSync(bin)
|
|
581
|
+
for (const name of ['git', 'gh', 'node', 'npx', 'jq', 'python3', 'icm']) {
|
|
582
|
+
const output = name === 'gh' ? 'v2.0.0' : '1.0.0'
|
|
583
|
+
writeFileSync(join(bin, name), `#!/bin/sh\necho ${output}\n`)
|
|
584
|
+
chmodSync(join(bin, name), 0o755)
|
|
585
|
+
}
|
|
586
|
+
const now = new Date().toISOString()
|
|
587
|
+
const obsolete = { choice: 'installed', version: '0.1.0' }
|
|
588
|
+
const ledger = join(tmp, 'deps.json')
|
|
589
|
+
writeFileSync(ledger, JSON.stringify({
|
|
590
|
+
initialisedAt: now, checkedAt: now, lastAttemptAt: now, cadenceDays: 7, autoUpdate: false,
|
|
591
|
+
tools: { legacy: obsolete, constructor: obsolete, icm: { choice: 'installed' }, revmux: { choice: 'skipped' } },
|
|
592
|
+
}))
|
|
593
|
+
const run = args => spawnSync(process.execPath, [join(SKILL, 'scripts', 'deps.mjs'), 'check', ...args], {
|
|
594
|
+
encoding: 'utf8', env: { ...process.env, PATH: bin, LEKKER_STATE_DIR: tmp },
|
|
595
|
+
})
|
|
596
|
+
for (const args of [[], ['--force']]) {
|
|
597
|
+
const result = run(args)
|
|
598
|
+
check(`deps ${args.length ? 'full' : 'presence'} check exits successfully`, result.status, 0)
|
|
599
|
+
const out = JSON.parse(result.stdout)
|
|
600
|
+
check('deps reports only registered installed tools', out.presence, { icm: 'present' })
|
|
601
|
+
check('deps preserves skipped tools', out.skipped, ['revmux'])
|
|
602
|
+
}
|
|
603
|
+
const saved = JSON.parse(readFileSync(ledger, 'utf8'))
|
|
604
|
+
check('deps leaves obsolete entries untouched', [saved.tools.legacy, saved.tools.constructor], [obsolete, obsolete])
|
|
605
|
+
check('deps still checks registered versions', [saved.tools.icm.version, saved.tools.icm.latest, saved.tools.icm.outdated], ['1.0.0', '2.0.0', true])
|
|
606
|
+
} finally {
|
|
607
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
console.log('\nrun log: repeated finishes retain optional metadata')
|
|
612
|
+
{
|
|
613
|
+
const { execFileSync } = await import('node:child_process')
|
|
614
|
+
const { mkdtempSync, rmSync } = await import('node:fs')
|
|
615
|
+
const { tmpdir } = await import('node:os')
|
|
616
|
+
const tmp = mkdtempSync(join(tmpdir(), 'lekker-run-log-'))
|
|
617
|
+
try {
|
|
618
|
+
const run = args => JSON.parse(execFileSync(process.execPath, [join(SKILL, 'scripts', 'run-log.mjs'), ...args], {
|
|
619
|
+
encoding: 'utf8', env: { ...process.env, LEKKER_STATE_DIR: tmp },
|
|
620
|
+
}))
|
|
621
|
+
const { runId } = run(['start', '--repo', 'example/repo', '--target', 'pr-1'])
|
|
622
|
+
const meta = () => JSON.parse(readFileSync(join(tmp, 'runs', runId, 'run.json'), 'utf8'))
|
|
623
|
+
const values = row => [row.verdict, row.reviewFile, row.note]
|
|
624
|
+
run(['finish', runId])
|
|
625
|
+
check('run log defaults absent metadata to null', values(meta()), [null, null, null])
|
|
626
|
+
run(['finish', runId, '--verdict', 'needs-work', '--review-file', '/tmp/review.md', '--note', 'initial review'])
|
|
627
|
+
run(['finish', runId, '--artifact-url', 'https://example.invalid/review'])
|
|
628
|
+
check('run log retains omitted metadata', values(meta()), ['needs-work', '/tmp/review.md', 'initial review'])
|
|
629
|
+
const rows = readFileSync(join(tmp, 'runs.jsonl'), 'utf8').trim().split('\n').map(JSON.parse)
|
|
630
|
+
check('run log appends retained values to the ledger', values(rows.at(-1)), values(meta()))
|
|
631
|
+
run(['finish', runId, '--verdict', 'lgtm', '--review-file', '/tmp/updated.md', '--note', ''])
|
|
632
|
+
check('run log accepts explicit replacements including empty values', values(meta()), ['lgtm', '/tmp/updated.md', ''])
|
|
633
|
+
run(['finish', runId])
|
|
634
|
+
check('run log retains explicit empty values', values(meta()), ['lgtm', '/tmp/updated.md', ''])
|
|
635
|
+
} finally {
|
|
636
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
console.log('\nprove: skipped without an installed test runner')
|
|
641
|
+
{
|
|
642
|
+
const { spawnSync } = await import('node:child_process')
|
|
643
|
+
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs')
|
|
644
|
+
const { tmpdir } = await import('node:os')
|
|
645
|
+
const wf = readFileSync(join(SKILL, 'workflow.js'), 'utf8')
|
|
646
|
+
check('prover is skipped without a usable test runner', /proveCandidates\.length > 0 && !hasRunner/.test(wf), true)
|
|
647
|
+
check('workflow reads testRunner from args', /^\s+testRunner,$/m.test(wf), true)
|
|
648
|
+
|
|
649
|
+
const runnerOf = dir => JSON.parse(spawnSync('node', [join(SKILL, 'scripts', 'test-runner.mjs'), dir], { encoding: 'utf8' }).stdout)
|
|
650
|
+
const tmp = mkdtempSync(join(tmpdir(), 'lekker-runner-'))
|
|
651
|
+
writeFileSync(join(tmp, 'package.json'), JSON.stringify({ devDependencies: { vitest: '^2' }, scripts: { test: 'vitest' } }))
|
|
652
|
+
const missing = runnerOf(tmp)
|
|
653
|
+
check('declared but uninstalled runner is not usable', [missing.runner, /node_modules\/\.bin\/vitest missing/.test(missing.reason)], [null, true])
|
|
654
|
+
mkdirSync(join(tmp, 'node_modules', '.bin'), { recursive: true })
|
|
655
|
+
writeFileSync(join(tmp, 'node_modules', '.bin', 'vitest'), '')
|
|
656
|
+
check('installed runner is usable at the root', runnerOf(tmp), { runner: 'vitest', dirs: [{ dir: '.', runner: 'vitest' }], reason: null })
|
|
657
|
+
const pkg = (dir, data) => {
|
|
658
|
+
mkdirSync(join(tmp, dir), { recursive: true })
|
|
659
|
+
writeFileSync(join(tmp, dir, 'package.json'), JSON.stringify(data))
|
|
660
|
+
}
|
|
661
|
+
pkg('packages/a', { devDependencies: { vitest: '^2' } })
|
|
662
|
+
pkg('packages/b', { devDependencies: { jest: '^29' } })
|
|
663
|
+
pkg('packages/c', { scripts: { test: 'node --test' } })
|
|
664
|
+
mkdirSync(join(tmp, 'packages', 'node_modules', '.bin'), { recursive: true })
|
|
665
|
+
writeFileSync(join(tmp, 'packages', 'node_modules', '.bin', 'jest'), '')
|
|
666
|
+
check('each package retains its runner, including root and intermediate hoists', runnerOf(tmp), {
|
|
667
|
+
runner: 'vitest', dirs: [
|
|
668
|
+
{ dir: '.', runner: 'vitest' }, { dir: 'packages/a', runner: 'vitest' },
|
|
669
|
+
{ dir: 'packages/b', runner: 'jest' }, { dir: 'packages/c', runner: 'node-test' },
|
|
670
|
+
], reason: null,
|
|
671
|
+
})
|
|
672
|
+
pkg('isolated', { devDependencies: { vitest: '^2' } })
|
|
673
|
+
const isolated = runnerOf(join(tmp, 'isolated') + '/')
|
|
674
|
+
check('binary lookup stops at the worktree root, including a trailing slash',
|
|
675
|
+
[isolated.runner, isolated.dirs, /node_modules\/\.bin\/vitest missing/.test(isolated.reason)], [null, [], true])
|
|
676
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
677
|
+
}
|
|
678
|
+
|
|
572
679
|
console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
|
|
573
680
|
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -290,6 +290,12 @@ done < <(find "$WORKTREE_PATH" -mindepth 2 -maxdepth 3 -name package.json \
|
|
|
290
290
|
-not -path '*/node_modules/*' 2>/dev/null)
|
|
291
291
|
[[ "$NM_LINKED" == "true" ]] || NOTES="${NOTES}no-node_modules-static-checks-skipped;"
|
|
292
292
|
|
|
293
|
+
# Can a single test file run here? The prove phase reads this instead of
|
|
294
|
+
# paying a prover agent to discover a missing node_modules.
|
|
295
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
296
|
+
TEST_RUNNER_JSON="$(node "$SCRIPT_DIR/test-runner.mjs" "$WORKTREE_PATH" 2>/dev/null \
|
|
297
|
+
|| printf '{"runner":null,"dirs":[],"reason":"test-runner.mjs failed"}')"
|
|
298
|
+
|
|
293
299
|
# Lintable, still-on-disk changed files (a deleted file must not go to eslint).
|
|
294
300
|
ESLINT_TARGETS=()
|
|
295
301
|
if [[ -s "$CHANGED_FILES_FILE" ]]; then
|
|
@@ -475,6 +481,7 @@ python3 - \
|
|
|
475
481
|
"$MERGE_BASE_VAL" \
|
|
476
482
|
"$DEPS_INSTALL_FILE" \
|
|
477
483
|
"$ESLINT_JSON_FILE" \
|
|
484
|
+
"$TEST_RUNNER_JSON" \
|
|
478
485
|
"$OUT_JSON" <<'PYEOF'
|
|
479
486
|
import json, sys, os
|
|
480
487
|
|
|
@@ -482,7 +489,13 @@ import json, sys, os
|
|
|
482
489
|
tsc_file, eslint_file, rules_file, delta_file, notes,
|
|
483
490
|
changed_files_file, base_ref_val, eslint_scope_file,
|
|
484
491
|
tsc_changed_file, tsc_count_file, merge_base_val,
|
|
485
|
-
deps_install_file, eslint_json_file, out_json) = sys.argv[1:
|
|
492
|
+
deps_install_file, eslint_json_file, test_runner_json, out_json) = sys.argv[1:20]
|
|
493
|
+
|
|
494
|
+
def parse_runner(raw):
|
|
495
|
+
try:
|
|
496
|
+
return json.loads(raw)
|
|
497
|
+
except ValueError:
|
|
498
|
+
return {"runner": None, "dirs": [], "reason": "unparseable test-runner output"}
|
|
486
499
|
|
|
487
500
|
def read_file(path):
|
|
488
501
|
if not os.path.exists(path):
|
|
@@ -541,6 +554,8 @@ data = {
|
|
|
541
554
|
"tscChangedTail": read_file(tsc_changed_file),
|
|
542
555
|
"tscErrorCount": tsc_error_count,
|
|
543
556
|
"depsInstall": deps_install,
|
|
557
|
+
# {runner, dirs, reason}; runner null means Prove is skipped.
|
|
558
|
+
"testRunner": parse_runner(test_runner_json),
|
|
544
559
|
# Path, not content: lint-findings.mjs reads it. None when eslint produced
|
|
545
560
|
# no structured report (skipped, no config, crashed, or the repo-wide
|
|
546
561
|
# fallback), so a consumer can tell "no lint report" from "no problems".
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Decide whether a worktree can run a single test file, before any prover
|
|
3
|
+
// agent is paid to find out.
|
|
4
|
+
//
|
|
5
|
+
// The prove phase attempted 7 proofs between 2026-08 and 2026-10 and proved
|
|
6
|
+
// none; 5 of the 7 provers reported the same thing: vitest is declared in
|
|
7
|
+
// package.json but node_modules is absent from the worktree. A prover agent is
|
|
8
|
+
// an expensive way to run `ls node_modules/.bin`.
|
|
9
|
+
//
|
|
10
|
+
// Usage: node test-runner.mjs <worktreePath>
|
|
11
|
+
// Prints {runner, dirs, reason}. `runner` is the first usable runner found
|
|
12
|
+
// (vitest|jest|mocha|node-test) or null; `dirs` lists every package dir with a
|
|
13
|
+
// usable one as {dir, runner}, relative to the worktree ("." for the root).
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const root = process.argv[2];
|
|
19
|
+
if (!root || !existsSync(root)) {
|
|
20
|
+
console.log(JSON.stringify({ runner: null, dirs: [], reason: 'no worktree' }));
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const RUNNERS = ['vitest', 'jest', 'mocha'];
|
|
25
|
+
|
|
26
|
+
function packageDirs(dir, depth) {
|
|
27
|
+
const out = existsSync(join(dir, 'package.json')) ? [dir] : [];
|
|
28
|
+
if (depth === 0) return out;
|
|
29
|
+
let entries;
|
|
30
|
+
try { entries = readdirSync(dir); } catch { return out; }
|
|
31
|
+
for (const e of entries) {
|
|
32
|
+
if (e === 'node_modules' || e.startsWith('.')) continue;
|
|
33
|
+
const p = join(dir, e);
|
|
34
|
+
try { if (statSync(p).isDirectory()) out.push(...packageDirs(p, depth - 1)); } catch { /* unreadable */ }
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function usableRunner(dir) {
|
|
40
|
+
let pkg;
|
|
41
|
+
try { pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); } catch { return { reason: 'unreadable package.json' }; }
|
|
42
|
+
const declared = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
43
|
+
const testScript = String(pkg.scripts?.test ?? '');
|
|
44
|
+
for (const r of RUNNERS) {
|
|
45
|
+
if (!declared[r] && !testScript.includes(r)) continue;
|
|
46
|
+
// existsSync follows the symlinked node_modules setup-worktree.sh creates.
|
|
47
|
+
for (let binDir = resolve(dir); ; binDir = dirname(binDir)) {
|
|
48
|
+
if (existsSync(join(binDir, 'node_modules', '.bin', r))) return { runner: r };
|
|
49
|
+
if (binDir === resolve(root) || dirname(binDir) === binDir) break;
|
|
50
|
+
}
|
|
51
|
+
return { reason: `${r} declared but node_modules/.bin/${r} missing` };
|
|
52
|
+
}
|
|
53
|
+
if (/\bnode\b[^&|;]*--test\b/.test(testScript)) return { runner: 'node-test' };
|
|
54
|
+
return { reason: 'no test runner declared' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const dirs = [];
|
|
58
|
+
const reasons = [];
|
|
59
|
+
let runner = null;
|
|
60
|
+
for (const dir of packageDirs(root, 3)) {
|
|
61
|
+
const rel = relative(root, dir) || '.';
|
|
62
|
+
const r = usableRunner(dir);
|
|
63
|
+
if (r.runner) {
|
|
64
|
+
dirs.push({ dir: rel, runner: r.runner });
|
|
65
|
+
runner ??= r.runner;
|
|
66
|
+
} else {
|
|
67
|
+
reasons.push(`${rel}: ${r.reason}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log(JSON.stringify({
|
|
72
|
+
runner,
|
|
73
|
+
dirs,
|
|
74
|
+
reason: runner ? null : (reasons.length ? reasons.join('; ') : 'no package.json'),
|
|
75
|
+
}));
|
|
@@ -450,6 +450,7 @@ const {
|
|
|
450
450
|
targetLabel: targetLabelArg,
|
|
451
451
|
engine: engineArg,
|
|
452
452
|
findingsFile,
|
|
453
|
+
testRunner,
|
|
453
454
|
} = input
|
|
454
455
|
|
|
455
456
|
// 'revmux' skips straight to Prove on pre-computed findings, so Prove's
|
|
@@ -866,7 +867,9 @@ const budgetAtStart = budget.spent()
|
|
|
866
867
|
// with no runtime failure to demonstrate), attempt to produce an
|
|
867
868
|
// executable failing test in the worktree. Only runs with a worktree and
|
|
868
869
|
// outside scan depth, and is capped so a pathological finding count can't
|
|
869
|
-
// blow the budget.
|
|
870
|
+
// blow the budget. `testRunner` comes from worktree.json: without a runner
|
|
871
|
+
// whose binary is installed a prover cannot run its test, which is how 5 of
|
|
872
|
+
// the first 7 proof attempts failed.
|
|
870
873
|
// -------------------------------------------------------------------------
|
|
871
874
|
|
|
872
875
|
function proverPrompt(finding) {
|
|
@@ -876,6 +879,8 @@ const budgetAtStart = budget.spent()
|
|
|
876
879
|
`Read and follow ${promptDir}/prover.md.`,
|
|
877
880
|
`FINDING (JSON): ${JSON.stringify(finding)}.`,
|
|
878
881
|
`DIFF_FILE=${diffFile}, CONTEXT_FILE=${contextFile}, WORKTREE_PATH=${worktreePath}.`,
|
|
882
|
+
`TEST_RUNNER per package (JSON; dirs relative to WORKTREE_PATH): ${JSON.stringify(testRunner.dirs || [])}.`,
|
|
883
|
+
`Use the runner paired with the most specific package dir containing the finding.`,
|
|
879
884
|
`You get ONE attempt. Leave the worktree exactly as you found it.`,
|
|
880
885
|
].join(' ')
|
|
881
886
|
}
|
|
@@ -884,8 +889,15 @@ const budgetAtStart = budget.spent()
|
|
|
884
889
|
const proveCandidates = finalFindings.filter(function(f) {
|
|
885
890
|
return shouldProve(f) && !isHardRule(f)
|
|
886
891
|
})
|
|
892
|
+
const hasRunner = Boolean(testRunner && testRunner.runner)
|
|
887
893
|
|
|
888
|
-
if (proveCandidates.length > 0) {
|
|
894
|
+
if (proveCandidates.length > 0 && !hasRunner) {
|
|
895
|
+
const reason = 'no runnable test runner: ' + ((testRunner && testRunner.reason) || 'testRunner not passed')
|
|
896
|
+
for (const f of proveCandidates) {
|
|
897
|
+
f.proof = { attempted: false, proven: false, outcome: 'not_attempted', reason: reason }
|
|
898
|
+
}
|
|
899
|
+
log(`Prove: skipped ${proveCandidates.length} finding(s), ${reason}`)
|
|
900
|
+
} else if (proveCandidates.length > 0) {
|
|
889
901
|
phase('Prove')
|
|
890
902
|
|
|
891
903
|
const capped = proveCandidates.slice(0, 5)
|