@olegkoval/agent-skills 1.58.1 → 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.
Files changed (35) hide show
  1. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +105 -9
  2. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/prover.md +18 -6
  3. package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +2 -1
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/references/github-post.md +6 -1
  5. package/adapters/claude/olko-github-pr/skills/lekker-review/references/traces.md +39 -0
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/deps.mjs +305 -0
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-engine.sh +7 -2
  8. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +344 -0
  9. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +107 -0
  10. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +16 -1
  11. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/test-runner.mjs +75 -0
  12. package/package.json +1 -1
  13. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  14. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  15. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  16. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  17. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  18. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +105 -9
  19. package/plugins/olko-github-pr/skills/lekker-review/references/agents/prover.md +18 -6
  20. package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +2 -1
  21. package/plugins/olko-github-pr/skills/lekker-review/references/github-post.md +6 -1
  22. package/plugins/olko-github-pr/skills/lekker-review/references/traces.md +39 -0
  23. package/plugins/olko-github-pr/skills/lekker-review/scripts/deps.mjs +305 -0
  24. package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-engine.sh +7 -2
  25. package/plugins/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +344 -0
  26. package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +107 -0
  27. package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +16 -1
  28. package/plugins/olko-github-pr/skills/lekker-review/scripts/test-runner.mjs +75 -0
  29. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +14 -2
  30. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  31. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  32. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  33. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  34. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  35. 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:19]
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
+ }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olegkoval/agent-skills",
3
- "version": "1.58.1",
3
+ "version": "1.59.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-apple-kit",
3
3
  "description": "Build and ship Apple platform apps: macOS menubar apps, App Store submissions.",
4
- "version": "1.58.1",
4
+ "version": "1.59.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-creative",
3
3
  "description": "Creative and personal projects: photo galleries, music players, listings, wiki editing.",
4
- "version": "1.58.1",
4
+ "version": "1.59.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-garmin-kit",
3
3
  "description": "Build, test and publish Garmin Connect IQ watch faces.",
4
- "version": "1.58.1",
4
+ "version": "1.59.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-git-tools",
3
3
  "description": "Everyday git and GitHub CLI operations: conventional commits, branch hygiene.",
4
- "version": "1.58.1",
4
+ "version": "1.59.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-github-pr",
3
3
  "description": "Drive GitHub pull requests to merge-ready: review-bot loops, CI fixes, descriptions, dependency triage.",
4
- "version": "1.58.1",
4
+ "version": "1.59.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },