@olegkoval/agent-skills 1.54.3 → 1.55.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 (29) hide show
  1. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +135 -8
  2. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
  3. package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
  5. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
  8. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
  9. package/package.json +1 -1
  10. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  11. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  12. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  13. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  14. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  15. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +135 -8
  16. package/plugins/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
  17. package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
  18. package/plugins/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
  19. package/plugins/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
  20. package/plugins/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
  21. package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
  22. package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
  23. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +10 -0
  24. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  25. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  26. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  27. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  28. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  29. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env bash
2
+ # Mine real Greptile / Gemini review comments from a repo's newest PR review comments
3
+ # and cluster them into the patterns worth pre-empting.
4
+ #
5
+ # Why this exists: predicting bot-reviewer comments from a model's intuition is
6
+ # guesswork. The bots have already told us what they comment on - dozens of
7
+ # times, in this exact repo, on this exact codebase. Mining that is free and it
8
+ # is evidence, not a guess.
9
+ #
10
+ # Usage:
11
+ # mine-bot-comments.sh <repo-slug> [out-md] [--limit N]
12
+ #
13
+ # repo-slug e.g. acme/web-app
14
+ # out-md default: $LEKKER_DATA_DIR/bot-patterns.md, where LEKKER_DATA_DIR
15
+ # defaults to ~/.cache/lekker-review (outside the skill dir, so a
16
+ # plugin update does not wipe the mined history)
17
+ # --limit N max review comments to fetch, newest first (default 400);
18
+ # paging stops there, so a long history is never pulled whole
19
+ #
20
+ # Writes/replaces a "## <repo-slug>" section in out-md, leaving other repos'
21
+ # sections alone, and prints a one-line summary. The section carries a
22
+ # "<!-- mined: YYYY-MM-DD -->" marker that SKILL.md reads to decide staleness;
23
+ # a repo with no bot comments still gets a section, so it is not re-mined on
24
+ # every run.
25
+
26
+ set -uo pipefail
27
+
28
+ REPO_SLUG="${1:-}"
29
+ if [[ -z "$REPO_SLUG" ]]; then
30
+ echo "usage: mine-bot-comments.sh <repo-slug> [out-md] [--limit N]" >&2
31
+ exit 2
32
+ fi
33
+ shift
34
+
35
+ DATA_DIR="${LEKKER_DATA_DIR:-$HOME/.cache/lekker-review}"
36
+ OUT_MD="$DATA_DIR/bot-patterns.md"
37
+ LIMIT=400
38
+
39
+ while [[ $# -gt 0 ]]; do
40
+ case "$1" in
41
+ --limit) LIMIT="${2:-}"; shift; [[ $# -gt 0 ]] && shift ;;
42
+ *) OUT_MD="$1"; shift ;;
43
+ esac
44
+ done
45
+
46
+ if ! [[ "$LIMIT" =~ ^[1-9][0-9]*$ ]]; then
47
+ echo "mine-bot-comments: --limit needs a positive integer, got '$LIMIT'" >&2
48
+ exit 2
49
+ fi
50
+
51
+ mkdir -p "$(dirname "$OUT_MD")"
52
+
53
+ python3 - "$REPO_SLUG" "$OUT_MD" "$LIMIT" <<'PYEOF'
54
+ import datetime, json, os, re, subprocess, sys
55
+ from collections import defaultdict
56
+
57
+ repo_slug, out_md, limit = sys.argv[1:4]
58
+ limit = int(limit)
59
+
60
+ # Review comments are the inline ones - the class the bots actually bill for.
61
+ # Page by hand rather than `gh api --paginate`, which fetches the whole history
62
+ # before LIMIT could apply.
63
+ comments = []
64
+ page = 1
65
+ while len(comments) < limit:
66
+ res = subprocess.run(
67
+ ["gh", "api", "repos/%s/pulls/comments?per_page=100&sort=created&direction=desc&page=%d"
68
+ % (repo_slug, page)],
69
+ capture_output=True, text=True)
70
+ if res.returncode != 0:
71
+ print("mine-bot-comments: gh api failed for %s (auth? repo name?)" % repo_slug, file=sys.stderr)
72
+ sys.exit(1)
73
+ batch = json.loads(res.stdout or "[]")
74
+ if not isinstance(batch, list):
75
+ break
76
+ comments.extend(batch)
77
+ if len(batch) < 100:
78
+ break
79
+ page += 1
80
+ comments = comments[:limit]
81
+
82
+ BOT_RE = re.compile(r'gemini-code-assist|greptile|coderabbit', re.I)
83
+
84
+
85
+ def is_bot(c):
86
+ # A reply in a bot's own thread ("Thanks, updated") is not a new finding.
87
+ # The account type too: a human login can contain a bot's name.
88
+ user = c.get("user") or {}
89
+ return (user.get("type") == "Bot" and bool(BOT_RE.search(user.get("login", "")))
90
+ and not c.get("in_reply_to_id"))
91
+
92
+
93
+ bot_comments = [c for c in comments if is_bot(c)]
94
+
95
+
96
+ def first_claim(body):
97
+ """The sentence that carries the finding, with the bot's chrome removed."""
98
+ text = body or ""
99
+ # Both bots lead with chrome: Gemini an image badge, Greptile an <a><img>
100
+ # priority pill. Neither is the claim.
101
+ text = re.sub(r'!\[[^\]]*\]\([^)]*\)', '', text)
102
+ text = re.sub(r'<!--.*?-->', '', text, flags=re.S)
103
+ text = re.sub(r'<[^>]+>', ' ', text)
104
+ # Fenced code is the suggestion, not the claim.
105
+ text = re.sub(r'```.*?```', ' ', text, flags=re.S)
106
+ text = re.sub(r'`[^`]*`', ' CODE ', text)
107
+ text = re.sub(r'^\s*[#>*\-]+\s*', '', text, flags=re.M)
108
+ text = re.sub(r'\s+', ' ', text).strip()
109
+ parts = re.split(r'(?<=[.!?])\s', text)
110
+ for p in parts:
111
+ if len(p.strip()) > 25:
112
+ return p.strip()[:400]
113
+ return text[:400]
114
+
115
+
116
+ STOP = set("""a an and are as at be been but by can could does for from has have if in into is it
117
+ its may not of on or should that the their them there these this to was were will with would you your
118
+ we i be his her they code line file change changes when which what while also""".split())
119
+
120
+
121
+ def signature(claim):
122
+ words = re.findall(r'[a-z]+', claim.lower())
123
+ words = [w for w in words if len(w) > 3 and w not in STOP]
124
+ # The 6 longest words are a good enough fingerprint at this scale: short
125
+ # common words carry little. Ties break alphabetically so wording order
126
+ # does not split one pattern into two clusters.
127
+ return tuple(sorted(set(words), key=lambda w: (-len(w), w))[:6])
128
+
129
+
130
+ # A curated taxonomy on top of the free-text clustering. Fingerprint clustering
131
+ # splits one concern across several wordings ("casting violates policy", "the use
132
+ # of CODE is a type cast", "TypeScript casts bypass checks" are one comment the
133
+ # bots make three ways), and the CATEGORY is what a reviewer can act on before
134
+ # the bot ever runs.
135
+ CATEGORIES = [
136
+ ("type cast / any", r'\btype (cast|assertion)|\bcasting\b|\bas unknown\b|\bany\b type|\bnon-null assertion'),
137
+ ("null / undefined guard", r'\bnull\b|\bundefined\b|optional chain|\bmissing (a )?check'),
138
+ ("error handling", r'\berror (handling|is swallowed)|silently (fail|ignor)|catch block|\bthrow\b|unhandled'),
139
+ ("pagination", r'\bpaginat|\bpageInfo\b|\bhasNextPage\b|\bcursor\b|page size'),
140
+ ("await / concurrency", r'\bawait\b|floating promise|not awaited|\brace\b|concurren|fire.and.forget'),
141
+ ("unused / dead code", r'\bunused\b|not used|dead code|\bunreachable\b|never referenced'),
142
+ ("debug / logging", r'\bconsole\.|debug(ging)? (statement|log)|log(ging)? (noise|level)|should be removed before'),
143
+ ("hardcoded / magic value",r'\bhardcoded\b|magic (number|string)|should be (a )?constant|configurable'),
144
+ ("naming", r'\bnaming\b|\brename\b|\bmisleading name|the name CODE'),
145
+ ("comment / docs", r'\bcomment (is|does|adds)|\bredundant comment|\bjsdoc\b|\btypo\b'),
146
+ ("test coverage", r'\btest (coverage|case)|\bnot (covered|tested)|missing (a )?test'),
147
+ ("performance / N+1", r'\bN\+1\b|\bin a loop\b|inefficien|\bperformance\b|every (run|iteration)|batch'),
148
+ ("schedule / config", r'\bcron\b|\bschedul|\benv (var|variable)|\byaml\b|\bvalues\.'),
149
+ ("SQL / query", r'\bSQL\b|\bquery uses\b|\bwhere clause\b|\bindex\b|\btransaction\b'),
150
+ ]
151
+
152
+
153
+ def categorize(body):
154
+ text = body or ""
155
+ hits = [name for name, rx in CATEGORIES if re.search(rx, text, re.I)]
156
+ return hits or ["other"]
157
+
158
+
159
+ PRIORITY_RE = re.compile(r'badges/(p[0-9])\.svg|\b(P[0-9])\b')
160
+
161
+
162
+ def priority(body):
163
+ m = PRIORITY_RE.search(body or "")
164
+ if not m:
165
+ return None
166
+ return (m.group(1) or m.group(2) or "").upper()
167
+
168
+
169
+ category_counts = defaultdict(int)
170
+ category_priority = defaultdict(list)
171
+ for c in bot_comments:
172
+ prio = priority(c.get("body", ""))
173
+ for name in categorize(c.get("body", "")):
174
+ category_counts[name] += 1
175
+ if prio:
176
+ category_priority[name].append(prio)
177
+
178
+ clusters = defaultdict(list)
179
+ for c in bot_comments:
180
+ claim = first_claim(c.get("body", ""))
181
+ if not claim:
182
+ continue
183
+ clusters[signature(claim)].append((claim, c))
184
+
185
+ # Merge clusters that share most of their fingerprint - one pattern phrased two
186
+ # ways should not read as two patterns with a count of 1 each.
187
+ merged = []
188
+ for sig, items in sorted(clusters.items(), key=lambda kv: -len(kv[1])):
189
+ for m in merged:
190
+ shared = len(set(sig) & set(m["sig"]))
191
+ if sig and m["sig"] and shared / max(len(set(sig) | set(m["sig"])), 1) >= 0.5:
192
+ m["items"].extend(items)
193
+ break
194
+ else:
195
+ merged.append({"sig": sig, "items": list(items)})
196
+
197
+ merged.sort(key=lambda m: -len(m["items"]))
198
+
199
+ lines = []
200
+ lines.append("## %s" % repo_slug)
201
+ lines.append("<!-- mined: %s -->" % datetime.date.today().isoformat())
202
+ lines.append("")
203
+ if not bot_comments:
204
+ lines.append("No bot review comments in the newest %d review comment(s)." % len(comments))
205
+ lines.append("")
206
+ else:
207
+ lines.append("Mined from %d bot review comment(s) (%s). Counts are how many times the "
208
+ "bots have raised that pattern in THIS repo - the higher the count, the more "
209
+ "certain it is to come back on the next PR."
210
+ % (len(bot_comments), ", ".join(sorted({(c.get("user") or {}).get("login", "?")
211
+ for c in bot_comments}))))
212
+ lines.append("")
213
+ lines.append("### Categories, most-raised first")
214
+ lines.append("")
215
+ lines.append("| times raised | category | typical priority |")
216
+ lines.append("|---|---|---|")
217
+ for name, count in sorted(category_counts.items(), key=lambda kv: -kv[1]):
218
+ prios = category_priority.get(name, [])
219
+ typical = min(prios) if prios else "-"
220
+ lines.append("| %d | %s | %s |" % (count, name, typical))
221
+ lines.append("")
222
+ lines.append("A comment can land in more than one category, so the column sums above the")
223
+ lines.append("comment total. Read the categories as priors: on a diff that touches one of")
224
+ lines.append("them, assume the bots will raise it and settle it before opening the PR.")
225
+ lines.append("")
226
+ lines.append("### Concrete comments, most-repeated first")
227
+ lines.append("")
228
+
229
+ for m in merged:
230
+ if len(m["items"]) < 2 and len(merged) > 12:
231
+ continue
232
+ claim, c = m["items"][0]
233
+ count = len(m["items"])
234
+ path = c.get("path") or "?"
235
+ lines.append("- **x%d** - %s" % (count, claim))
236
+ lines.append(" - e.g. `%s` ([comment](%s))" % (path, c.get("html_url", "")))
237
+
238
+ lines.append("")
239
+
240
+ body = "\n".join(lines)
241
+
242
+ # The file is shared by every repo, so two reviews mining at once must not
243
+ # lose each other's sections: hold a lock across read-modify-write, and replace
244
+ # the file atomically so a reader never sees half of it.
245
+ import fcntl, tempfile
246
+ lock = open(out_md + ".lock", "w")
247
+ fcntl.flock(lock, fcntl.LOCK_EX)
248
+
249
+ existing = ""
250
+ if os.path.exists(out_md):
251
+ with open(out_md) as f:
252
+ existing = f.read()
253
+
254
+ header = ("# Bot reviewer patterns\n\n"
255
+ "What Greptile / Gemini / CodeRabbit have actually commented on, per repo, mined\n"
256
+ "from review-comment history by `scripts/mine-bot-comments.sh`. This is evidence,\n"
257
+ "not prediction: every line below is a comment a bot really left.\n\n"
258
+ "Delivered to the review agents as `botPatternsFile`. Regenerate per repo with\n"
259
+ "`scripts/mine-bot-comments.sh <repo-slug>`.\n\n")
260
+
261
+ if not existing.startswith("# Bot reviewer patterns"):
262
+ existing = header + existing
263
+
264
+ # Replace this repo's section in place; leave every other repo alone.
265
+ pattern = re.compile(r'^## %s$.*?(?=^## |\Z)' % re.escape(repo_slug), re.M | re.S)
266
+ if pattern.search(existing):
267
+ # A function replacement, so backslashes in mined bot text are literal.
268
+ existing = pattern.sub(lambda _: body, existing)
269
+ else:
270
+ existing = existing.rstrip() + "\n\n" + body
271
+
272
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(out_md)), prefix=".bot-patterns.")
273
+ with os.fdopen(fd, "w") as f:
274
+ f.write(existing)
275
+ os.replace(tmp, out_md)
276
+ fcntl.flock(lock, fcntl.LOCK_UN)
277
+
278
+ print("mine-bot-comments: %d bot comment(s) -> %d pattern(s) -> %s"
279
+ % (len(bot_comments), len(merged), out_md))
280
+ PYEOF
@@ -383,5 +383,191 @@ console.log('\ndeps depth: routing, classifier, line validation')
383
383
  check('summary counts', [r.summary.remapped, r.summary.nulled, r.summary.unchecked], [1, 1, 1])
384
384
  }
385
385
 
386
+ console.log('\nbot pre-emption: lint pre-flight, pattern mining, precision store')
387
+ {
388
+ const { spawnSync } = await import('node:child_process')
389
+ const { mkdtempSync, writeFileSync: wf, readFileSync: rf, mkdirSync, chmodSync, symlinkSync, existsSync, rmSync } = await import('node:fs')
390
+ const { tmpdir } = await import('node:os')
391
+ const tmp = mkdtempSync(join(tmpdir(), 'lekker-bots-'))
392
+ const script = name => join(SKILL, 'scripts', name)
393
+
394
+ const lint = (name, content, worktree = '/wt') => {
395
+ const inPath = join(tmp, `${name}.json`)
396
+ const outPath = join(tmp, `${name}.out.json`)
397
+ if (content !== undefined) wf(inPath, content)
398
+ const res = spawnSync('node', [script('lint-findings.mjs'), inPath, outPath], {
399
+ encoding: 'utf8', env: { ...process.env, LEKKER_WORKTREE_PATH: worktree },
400
+ })
401
+ return { status: res.status, out: JSON.parse(rf(outPath, 'utf8')) }
402
+ }
403
+ const msg = (ruleId, line, severity = 2, fix) => ({ ruleId, line, column: 1, severity, message: `${ruleId} hit`, ...(fix ? { fix } : {}) })
404
+ const report = JSON.stringify([
405
+ { filePath: '/wt/web/a.ts', messages: [msg('no-unused-vars', 3), msg('no-console', 9, 1), msg('no-unused-vars', 7, 2, { range: [0, 1], text: '' })] },
406
+ { filePath: '/wt/web/b.ts', messages: [msg('no-unused-vars', 1)] },
407
+ { filePath: '/wt/web/c.ts', messages: [] },
408
+ ])
409
+ const issues = lint('issues', report)
410
+ check('lint: one finding per rule and file, errors first',
411
+ issues.out.findings.map(f => `${f.rule} ${f.file}`), ['LINT:no-unused-vars web/a.ts', 'LINT:no-unused-vars web/b.ts', 'LINT:no-console web/a.ts'])
412
+ check('lint: error -> important, warning -> idiomatic', issues.out.findings.map(f => f.severity), ['important', 'important', 'idiomatic'])
413
+ check('lint: paths relative to the worktree', issues.out.findings[0].file, 'web/a.ts')
414
+ check('lint: fixable and site count carried', [issues.out.findings[0].lintFixable, issues.out.findings[0].lintSiteCount], [true, 2])
415
+ check('lint: every finding is botPredicted', issues.out.findings.every(f => f.botPredicted === true), true)
416
+ check('lint: summary', [issues.out.summary.status, issues.out.summary.messageCount, issues.out.summary.ruleCount], ['issues', 4, 2])
417
+ check('lint: a report with no messages is clean', lint('clean', '[{"filePath":"/wt/a.ts","messages":[]}]').out.summary.status, 'clean')
418
+ check('lint: an empty report is not clean', lint('empty', '').out.summary.status, 'empty-report')
419
+ check('lint: a report listing no files is not clean', lint('nofiles', '[]').out.summary.status, 'empty-report')
420
+ // eslint reports the resolved path while the worktree arrives via a symlink
421
+ // (macOS: /tmp -> /private/tmp); the finding must still be repo-relative.
422
+ const realWt = join(tmp, 'real-wt')
423
+ mkdirSync(join(realWt, 'web'), { recursive: true })
424
+ wf(join(realWt, 'web', 'd.ts'), 'const a = 1\nconst unused = 2\n')
425
+ symlinkSync(realWt, join(tmp, 'link-wt'))
426
+ const viaLink = lint('symlink', JSON.stringify([{ filePath: join(realWt, 'web', 'd.ts'), messages: [msg('no-unused-vars', 2)] }]), join(tmp, 'link-wt'))
427
+ check('lint: symlinked worktree still gives a relative path', viaLink.out.findings[0].file, 'web/d.ts')
428
+ check('lint: badCode is the source line, not a location', viaLink.out.findings[0].badCode, 'const unused = 2')
429
+ check('lint: a non-array report is not clean', lint('obj', '{}').out.summary.status, 'not-an-eslint-json-report')
430
+ const missing = lint('missing')
431
+ check('lint: a missing report is unknown, exit 0', [missing.status, missing.out.summary.status.startsWith('unreadable:')], [0, true])
432
+ check('lint: every site is carried for scoring', issues.out.findings[0].lintSites.map(x => `${x.file}:${x.line}`), ['web/a.ts:3', 'web/a.ts:7'])
433
+ const notices = lint('notices', JSON.stringify([{ filePath: '/wt/web/e.ts', messages: [
434
+ { ruleId: null, severity: 1, line: 0, message: 'File ignored because of a matching ignore pattern.' },
435
+ { ruleId: null, fatal: true, severity: 2, line: 4, message: 'Parsing error: Unexpected token' },
436
+ ] }]))
437
+ check('lint: an ignored-file notice is not a finding, a fatal parse error is',
438
+ [notices.out.summary.messageCount, notices.out.findings.map(f => f.rule)], [1, ['LINT:parse-error']])
439
+
440
+ // --base: only lines the change added count. A real repo, since the filter
441
+ // reads git diff -U0 <base> HEAD in the worktree.
442
+ const repo = join(tmp, 'diffrepo')
443
+ mkdirSync(join(repo, 'web'), { recursive: true })
444
+ const git = (...a) => spawnSync('git', ['-C', repo, '-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { encoding: 'utf8' })
445
+ git('init', '-q')
446
+ wf(join(repo, 'web', 'f.ts'), 'one\ntwo\nthree\n')
447
+ git('config', 'diff.noprefix', 'true')
448
+ git('add', '.'); git('commit', '-qm', 'base')
449
+ const base = git('rev-parse', 'HEAD').stdout.trim()
450
+ wf(join(repo, 'web', 'f.ts'), 'one\nTWO\nthree\nfour\n')
451
+ git('commit', '-qam', 'change')
452
+ const inDiff = (() => {
453
+ const inPath = join(tmp, 'diff.json'), outPath = join(tmp, 'diff.out.json')
454
+ wf(inPath, JSON.stringify([{ filePath: join(repo, 'web', 'f.ts'), messages: [
455
+ msg('no-console', 1), msg('no-console', 2), msg('no-unused-vars', 4),
456
+ { ruleId: null, fatal: true, severity: 2, line: 1, message: 'Parsing error' },
457
+ ] }]))
458
+ spawnSync('node', [script('lint-findings.mjs'), inPath, outPath, '--base', base], { encoding: 'utf8', env: { ...process.env, LEKKER_WORKTREE_PATH: repo } })
459
+ return JSON.parse(rf(outPath, 'utf8'))
460
+ })()
461
+ check('lint --base: untouched lines dropped, added lines and parse errors kept',
462
+ [inDiff.summary.messageCount, inDiff.summary.outOfDiff, inDiff.findings.flatMap(f => f.lintSites.map(x => x.line)).sort()], [3, 1, [1, 2, 4]])
463
+
464
+ const store = join(tmp, 'precision.jsonl')
465
+ const prec = args => spawnSync('node', [script('bot-precision.mjs'), ...args, '--jsonl', store], { encoding: 'utf8' })
466
+ const predicted = join(tmp, 'issues.out.json')
467
+ const rec = prec(['record', '--repo', 'acme/app', '--pr', '7', '--predicted', predicted])
468
+ check('precision: record appends one prediction per lint site', [rec.status, JSON.parse(rf(store, 'utf8').trim()).predictedCount], [0, 4])
469
+ check('precision: lint findings are tagged lint-preflight',
470
+ JSON.parse(rf(store, 'utf8').trim()).predicted.every(p => p.source === 'lint-preflight'), true)
471
+ check('precision: no prediction scores as nothing, not 0%', /no recorded prediction/.test(prec(['score', '--repo', 'acme/app', '--pr', '8']).stdout), true)
472
+ check('precision: summary with no scores', /no scored reviews yet/.test(prec(['summary']).stdout), true)
473
+
474
+ // Data dir: LEKKER_DATA_DIR wins, else ~/.cache/lekker-review - never the
475
+ // skill dir, which a plugin update replaces.
476
+ const noJsonl = (env, args) => spawnSync('node', [script('bot-precision.mjs'), ...args], { encoding: 'utf8', env: { ...process.env, ...env } })
477
+ const dataDir = join(tmp, 'data')
478
+ noJsonl({ LEKKER_DATA_DIR: dataDir }, ['record', '--repo', 'acme/app', '--pr', '1', '--predicted', predicted])
479
+ check('precision: LEKKER_DATA_DIR sets the store', existsSync(join(dataDir, 'bot-precision.jsonl')), true)
480
+ const home = join(tmp, 'home')
481
+ const envNoData = { ...process.env, HOME: home }
482
+ delete envNoData.LEKKER_DATA_DIR
483
+ spawnSync('node', [script('bot-precision.mjs'), 'record', '--repo', 'acme/app', '--pr', '1', '--predicted', predicted], { encoding: 'utf8', env: envNoData })
484
+ check('precision: default store is ~/.cache/lekker-review', existsSync(join(home, '.cache', 'lekker-review', 'bot-precision.jsonl')), true)
485
+
486
+ // Stub gh on PATH so the miner runs offline against a fixed comment set.
487
+ const bin = join(tmp, 'bin')
488
+ mkdirSync(bin)
489
+ const comments = [
490
+ { user: { login: 'greptile-apps[bot]', type: 'Bot' }, path: 'src/a.ts', in_reply_to_id: 1, html_url: 'https://x/r', body: 'Thanks for the update, this looks resolved now to me.' },
491
+ { user: { login: 'coderabbit-fan', type: 'User' }, path: 'src/a.ts', html_url: 'https://x/h', body: 'A human whose login names a bot is still a human here.' },
492
+ ...[1, 2, 3].map(i => ({ user: { login: 'greptile-apps[bot]', type: 'Bot' }, path: 'src/a.ts', html_url: `https://x/${i}`, body: 'This type cast bypasses the compiler and hides a real mismatch here.' })),
493
+ { user: { login: 'gemini-code-assist[bot]', type: 'Bot' }, path: 'src/b.ts', html_url: 'https://x/4', body: 'The promise is not awaited, so errors are silently ignored by the caller.' },
494
+ { user: { login: 'a-human' }, path: 'src/c.ts', html_url: 'https://x/5', body: 'Humans are not mined, only the bots are counted here.' },
495
+ ]
496
+ wf(join(tmp, 'comments.json'), JSON.stringify(comments))
497
+ // Precision fixture, served --slurp style (a list of pages) for pulls/9.
498
+ const bot = (login, path, line) => ({ user: { login, type: login.endsWith('[bot]') ? 'Bot' : 'User' }, path, line, html_url: `https://x/${path}`, body: `${path} remark` })
499
+ wf(join(tmp, 'pr9.json'), JSON.stringify([[
500
+ bot('greptile-apps[bot]', 'web/a.ts', 10),
501
+ bot('gemini-code-assist[bot]', 'web/c.ts', 5),
502
+ bot('greptile-apps[bot]', 'web/b.ts', 50),
503
+ bot('a-human', 'web/a.ts', 100),
504
+ bot('coderabbit-fan', 'web/z.ts', 1),
505
+ { ...bot('greptile-apps[bot]', 'web/x.ts', 1), in_reply_to_id: 1 },
506
+ { ...bot('greptile-apps[bot]', 'web/y.ts', 1), created_at: '2000-01-01T00:00:00Z' },
507
+ ]]))
508
+ wf(join(bin, 'gh'), [
509
+ '#!/bin/sh',
510
+ 'case "$*" in',
511
+ ` *pulls/9/comments*) cat "${join(tmp, 'pr9.json')}" ;;`,
512
+ " *acme/empty*) echo '[]' ;;",
513
+ ` *'&page=1') cat "${join(tmp, 'comments.json')}" ;;`,
514
+ " *) echo '[]' ;;",
515
+ 'esac',
516
+ '',
517
+ ].join('\n'))
518
+ chmodSync(join(bin, 'gh'), 0o755)
519
+ const ghEnv = { ...process.env, PATH: `${bin}:${process.env.PATH}` }
520
+
521
+ // Predictions: a.ts:3 is 7 lines from a bot comment (hit), a.ts:100 has no
522
+ // bot comment within 20 lines (the human one does not count), b.ts with no
523
+ // line matches any comment on b.ts. c.ts was not predicted (missed).
524
+ const pr9 = join(tmp, 'pr9-predicted.json')
525
+ wf(pr9, JSON.stringify([{ file: 'web/a.ts', line: 3 }, { file: 'web/a.ts', line: 100 }, { file: 'web/b.ts', line: null }]))
526
+ const score9 = args => spawnSync('node', [script('bot-precision.mjs'), ...args, '--jsonl', store], { encoding: 'utf8', env: ghEnv })
527
+ score9(['record', '--repo', 'acme/app', '--pr', '9', '--predicted', pr9, '--at', new Date(Date.now() - 60000).toISOString()])
528
+ check('precision: --at must be a timestamp', score9(['record', '--repo', 'acme/app', '--pr', '9', '--predicted', pr9, '--at', 'soon']).status, 2)
529
+ // A later prediction (a re-review after the bots ran) must not be scored.
530
+ const late = join(tmp, 'pr9-late.json')
531
+ wf(late, JSON.stringify([{ file: 'web/c.ts', line: 5 }]))
532
+ score9(['record', '--repo', 'acme/app', '--pr', '9', '--predicted', late])
533
+ const s9 = score9(['score', '--repo', 'acme/app', '--pr', '9'])
534
+ const last = () => rf(store, 'utf8').trim().split('\n').map(l => JSON.parse(l)).filter(r => r.kind === 'score').pop()
535
+ const sc = last()
536
+ check('precision: score counts only bot comments, no replies', [s9.status, sc.botCommentCount], [0, 3])
537
+ check('precision: comments older than the prediction excluded', sc.preexistingExcluded, 1)
538
+ check('precision: the first prediction is scored, not a later one', sc.predictedCount, 3)
539
+ check('precision: precision and recall', [sc.predictedAndRaised, sc.botCommentsPredicted, sc.precision.toFixed(3), sc.recall.toFixed(3)], [2, 2, '0.667', '0.667'])
540
+ check('precision: a null line is "no line", not line 0', sc.predictedAndRaised, 2)
541
+ check('precision: missed comments listed', sc.missedBotComments.map(c => c.path), ['web/c.ts'])
542
+ score9(['score', '--repo', 'acme/app', '--pr', '9'])
543
+ check('precision: a re-scored PR counts once in the summary', /summary: 1 scored review/.test(score9(['summary']).stdout), true)
544
+ const out = join(tmp, 'patterns.md')
545
+ const mine = slug => spawnSync('bash', [script('mine-bot-comments.sh'), slug, out], { encoding: 'utf8', env: ghEnv })
546
+ const m1 = mine('acme/app')
547
+ const md1 = rf(out, 'utf8')
548
+ check('mine: exit 0 and a section for the repo', [m1.status, md1.includes('## acme/app\n')], [0, true])
549
+ check('mine: counts only bot comments', /Mined from 4 bot review comment/.test(md1), true)
550
+ check('mine: a repeated claim is one pattern with its count', /\*\*x3\*\* - This type cast/.test(md1), true)
551
+ check('mine: categories table', /\| 3 \| type cast \/ any \|/.test(md1), true)
552
+ check('mine: section carries its own mined date', md1.includes(`## acme/app\n<!-- mined: ${new Date().toISOString().slice(0, 10)} -->`), true)
553
+ const noLimit = spawnSync('bash', [script('mine-bot-comments.sh'), 'acme/app', out, '--limit'], { encoding: 'utf8', env: ghEnv, timeout: 10000 })
554
+ check('mine: --limit with no value fails fast', noLimit.status, 2)
555
+ const empty = mine('acme/empty')
556
+ check('mine: a repo with no bot comments still gets a section', [empty.status, rf(out, 'utf8').includes('## acme/empty\n<!-- mined: ')], [0, true])
557
+ mine('acme/other')
558
+ mine('acme/app')
559
+ const md2 = rf(out, 'utf8')
560
+ check('mine: re-mining replaces the section, other repos kept',
561
+ [md2.split('## acme/app\n').length - 1, md2.includes('## acme/other\n')], [1, true])
562
+
563
+ check('workflow.js tells agents not to re-report the pre-flight', src.includes('"preflightFile"'), true)
564
+ const skillMd = readFileSync(join(SKILL, 'SKILL.md'), 'utf8')
565
+ check('SKILL.md passes preflightFile and botPatternsFile in CONTEXT_FILE',
566
+ skillMd.includes('"preflightFile":') && skillMd.includes('"botPatternsFile":'), true)
567
+ check('SKILL.md ages bot patterns by the section marker, not file mtime',
568
+ skillMd.includes('<!-- mined: /') && !skillMd.includes('-mtime +30'), true)
569
+ rmSync(tmp, { recursive: true, force: true })
570
+ }
571
+
386
572
  console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
387
573
  process.exit(failed === 0 ? 0 : 1)
@@ -14,6 +14,8 @@ set -euo pipefail
14
14
  REPO_SLUG="${1:?REPO_SLUG required}"
15
15
  PR_BRANCH="${2:?PR_BRANCH required}"
16
16
  OUT_JSON="${3:?OUT_JSON required}"
17
+ # Absolute, because the static checks run after cd into the worktree.
18
+ case "$OUT_JSON" in /*) ;; *) OUT_JSON="$PWD/$OUT_JSON" ;; esac
17
19
  PREV_SHA="${4:-}"
18
20
 
19
21
  LOCAL_REPO="${LEKKER_LOCAL_REPO:-}"
@@ -311,11 +313,50 @@ TSC_CHANGED_FILE="/tmp/lekker-tsc-changed-$$.txt"
311
313
  TSC_COUNT_FILE="/tmp/lekker-tsc-count-$$.txt"
312
314
  ESLINT_TAIL_FILE="/tmp/lekker-eslint-$$.txt"
313
315
  ESLINT_SCOPE_FILE="/tmp/lekker-eslint-scope-$$.txt"
316
+ # Structured lint report, kept beside the output JSON (NOT deleted with the
317
+ # temp files): lint-findings.mjs turns it into botPredicted findings, so the
318
+ # review can fix what the bot reviewers would have charged for. Exported by
319
+ # path as eslintJson.
320
+ ESLINT_JSON_FILE="$(dirname "$OUT_JSON")/eslint-report.json"
314
321
  printf '' > "$TSC_TAIL_FILE"
315
322
  printf '' > "$TSC_CHANGED_FILE"
316
323
  printf '0' > "$TSC_COUNT_FILE"
317
324
  printf '' > "$ESLINT_TAIL_FILE"
318
325
  printf 'skipped' > "$ESLINT_SCOPE_FILE"
326
+ printf '' > "$ESLINT_JSON_FILE"
327
+
328
+ # Render a stylish-style tail from the JSON report. An empty or unparseable
329
+ # report (eslint crashed, bad config) keeps whatever eslint wrote on stderr
330
+ # instead of rendering "0 errors" from nothing.
331
+ render_eslint_tail() {
332
+ local rendered="$ESLINT_JSON_FILE.txt"
333
+ node -e '
334
+ const fs = require("fs");
335
+ const raw = fs.readFileSync(process.argv[1], "utf8");
336
+ if (!raw.trim()) process.exit(3);
337
+ let report;
338
+ try { report = JSON.parse(raw); } catch { process.exit(3); }
339
+ if (!Array.isArray(report) || !report.length) process.exit(3);
340
+ const lines = [];
341
+ let errors = 0, warnings = 0;
342
+ for (const f of report) {
343
+ const msgs = f.messages || [];
344
+ if (!msgs.length) continue;
345
+ lines.push(f.filePath);
346
+ for (const m of msgs) {
347
+ if (m.severity === 2) errors++; else warnings++;
348
+ lines.push(` ${m.line}:${m.column} ${m.severity === 2 ? "error" : "warning"} ${m.message} ${m.ruleId || ""}`);
349
+ }
350
+ }
351
+ if (!errors && !warnings) process.exit(0);
352
+ lines.push(`total: ${errors} error(s), ${warnings} warning(s)`);
353
+ process.stdout.write(lines.slice(-40).join("\n") + "\n");
354
+ ' "$ESLINT_JSON_FILE" > "$rendered" 2>/dev/null
355
+ if [[ -s "$rendered" ]]; then
356
+ cat "$rendered" > "$ESLINT_TAIL_FILE"
357
+ fi
358
+ rm -f "$rendered"
359
+ }
319
360
 
320
361
  run_tsc() {
321
362
  # tsc needs the whole program to resolve types, so it always runs
@@ -345,8 +386,11 @@ run_eslint() {
345
386
  if [[ "$has_config" == "true" ]]; then
346
387
  if [[ -s "$CHANGED_FILES_FILE" ]]; then
347
388
  if [[ ${#ESLINT_TARGETS[@]} -gt 0 ]]; then
348
- # Default (stylish) formatter: ESLint 9 removed the bundled "compact" formatter
349
- (cd "$WORKTREE_PATH" && npx eslint --max-warnings=0 "${ESLINT_TARGETS[@]}" 2>&1 | tail -40 >> "$ESLINT_TAIL_FILE") || true
389
+ # One run, JSON formatter: the machine-readable report feeds
390
+ # lint-findings.mjs and the human tail is rendered from the
391
+ # same report, so the two can never disagree.
392
+ (cd "$WORKTREE_PATH" && npx eslint --max-warnings=0 -f json -o "$ESLINT_JSON_FILE" "${ESLINT_TARGETS[@]}" 2> "$ESLINT_TAIL_FILE") || true
393
+ render_eslint_tail
350
394
  printf 'changed-files' > "$ESLINT_SCOPE_FILE"
351
395
  else
352
396
  printf 'no-changed-lintable-files\n' > "$ESLINT_TAIL_FILE"
@@ -354,6 +398,8 @@ run_eslint() {
354
398
  fi
355
399
  else
356
400
  # Base ref could not be resolved -- fall back to the old repo-wide run.
401
+ # No JSON here on purpose: a repo-wide report is standing debt,
402
+ # not this change's, and must not become botPredicted findings.
357
403
  (cd "$WORKTREE_PATH" && npx eslint . --max-warnings=0 2>&1 | tail -40 >> "$ESLINT_TAIL_FILE") || true
358
404
  printf 'full-fallback' > "$ESLINT_SCOPE_FILE"
359
405
  fi
@@ -428,6 +474,7 @@ python3 - \
428
474
  "$TSC_COUNT_FILE" \
429
475
  "$MERGE_BASE_VAL" \
430
476
  "$DEPS_INSTALL_FILE" \
477
+ "$ESLINT_JSON_FILE" \
431
478
  "$OUT_JSON" <<'PYEOF'
432
479
  import json, sys, os
433
480
 
@@ -435,7 +482,7 @@ import json, sys, os
435
482
  tsc_file, eslint_file, rules_file, delta_file, notes,
436
483
  changed_files_file, base_ref_val, eslint_scope_file,
437
484
  tsc_changed_file, tsc_count_file, merge_base_val,
438
- deps_install_file, out_json) = sys.argv[1:18]
485
+ deps_install_file, eslint_json_file, out_json) = sys.argv[1:19]
439
486
 
440
487
  def read_file(path):
441
488
  if not os.path.exists(path):
@@ -462,6 +509,14 @@ for row in read_file(deps_install_file).splitlines():
462
509
 
463
510
  changed_files = [l for l in read_file(changed_files_file).splitlines() if l.strip()]
464
511
 
512
+
513
+ def file_has_content(path):
514
+ try:
515
+ return os.path.getsize(path) > 0
516
+ except OSError:
517
+ return False
518
+
519
+
465
520
  eslint_scope = read_file(eslint_scope_file).strip() or "skipped"
466
521
 
467
522
  try:
@@ -486,6 +541,10 @@ data = {
486
541
  "tscChangedTail": read_file(tsc_changed_file),
487
542
  "tscErrorCount": tsc_error_count,
488
543
  "depsInstall": deps_install,
544
+ # Path, not content: lint-findings.mjs reads it. None when eslint produced
545
+ # no structured report (skipped, no config, crashed, or the repo-wide
546
+ # fallback), so a consumer can tell "no lint report" from "no problems".
547
+ "eslintJson": eslint_json_file if file_has_content(eslint_json_file) else None,
489
548
  }
490
549
 
491
550
  for p in (tsc_file, eslint_file, rules_file, changed_files_file,
@@ -519,6 +519,16 @@ const budgetAtStart = budget.spent()
519
519
  + ` number of DIFF_FILE itself. Quote the code at that line verbatim in "badCode".`,
520
520
  ].join(' ')
521
521
 
522
+ // The deterministic lint pre-flight has already collected every rule the
523
+ // repo's own eslint config fires on this diff, and those are exactly the
524
+ // comments the bot reviewers bill for. An agent re-reporting them costs a
525
+ // verifier round and produces a worse description than the linter's.
526
+ prompt += ` PRE-FLIGHT: CONTEXT_FILE may carry a key "preflightFile" pointing`
527
+ + ` at lint findings already collected deterministically from the repo's own`
528
+ + ` eslint config. If it is present and non-null, Read it and do NOT re-report`
529
+ + ` anything it already lists - those are already queued for fixing. Report`
530
+ + ` only what it missed.`
531
+
522
532
  if (prevSha) {
523
533
  prompt += ` RE-REVIEW MODE: a prior review covered up to commit ${prevSha}.`
524
534
  + ` Focus findings on the delta (see key deltaFile in CONTEXT_FILE);`
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-obsidian",
3
3
  "description": "Keep an Obsidian vault in sync with work: PR sync, task rollover, morning routine.",
4
- "version": "1.54.3",
4
+ "version": "1.55.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-product",
3
3
  "description": "Take a product idea to a shippable build: MVP passes, full-stack scaffolds, UX/UI quality loops, launch plans.",
4
- "version": "1.54.3",
4
+ "version": "1.55.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-reflection",
3
3
  "description": "Look back and improve: self-critique, retrospectives, performance review, rapid learning.",
4
- "version": "1.54.3",
4
+ "version": "1.55.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-release",
3
3
  "description": "Ship a release: semantic-release setup, changelogs, store listing copy, release-day routine.",
4
- "version": "1.54.3",
4
+ "version": "1.55.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },