@olegkoval/agent-skills 1.61.0 → 1.62.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.
@@ -353,6 +353,33 @@ treat as a full review and leave PREV_SHA unset. Also grep the same file for
353
353
  - Step 3.5 reuses that exact URL or local path so the artifact stays a living
354
354
  page for this PR.
355
355
 
356
+ **Convergence state (every PR, first review included):** the review file
357
+ records only the last head sha, so finding status lives in
358
+ `scripts/finding-state.mjs` (one JSON file per PR under
359
+ `~/.local/state/lekker-review/findings/`). In `pr` mode, on a re-review, first
360
+ fetch the PR's review threads into `<scratchpad>/threads.json` as
361
+ `{ "prAuthor": <login>, "threads": [{ path, line, body, isResolved, isOutdated,
362
+ replies: [{ author, body }] }] }` (`gh api graphql` over `reviewThreads`; `body`
363
+ is the first comment, `replies` the later ones). Then run
364
+ `node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/finding-state.mjs" begin --repo <REPO_SLUG> --target <TARGET_SLUG> --run <RUN_ID> [--threads <scratchpad>/threads.json]`.
365
+ `RUN_ID` is one id per review attempt, saved to `<scratchpad>/finding-run-id`
366
+ and reused when that attempt is retried, so a retry does not count as a new
367
+ cycle. In Codex, replace the quoted script path with the absolute path to
368
+ `scripts/finding-state.mjs` beside the selected `SKILL.md`, as for run-log. Pass
369
+ its `cycle`, `suppressed` and `recurringClasses` to the workflow. A
370
+ finding is `fixed` when its thread is outdated, `intentional` when the author
371
+ replied "by design" and nothing changed, `rejected` when the author dismissed it
372
+ or the thread was resolved with no code change. The workflow never re-raises a
373
+ `suppressed` (rejected or intentional) finding. From `cycle` 4 the workflow
374
+ returns `verdict: "rethink"` with the recurring finding classes and runs no
375
+ agents: skip Steps 2 to 4, render the verdict as `RETHINK THE APPROACH: this PR
376
+ has been reviewed N times; the same classes keep coming back: <classes>`, and
377
+ suggest the author and reviewer agree the approach before another round.
378
+ Run `finding-state.mjs record --run <RUN_ID> --repo ... --target ... --findings <findings.json>`
379
+ before the review is published or the artifact is updated, so a stop after
380
+ publishing cannot lose what was raised. `--run` files the findings under this
381
+ attempt's cycle even when another review of the PR started in the meantime.
382
+
356
383
  **Open the run log** once mode, depth and engine are settled (see
357
384
  `references/traces.md` for where every trace of a run lives):
358
385
 
@@ -688,6 +715,9 @@ args: {
688
715
  worktreePath: <null for scan, else from worktree.json>,
689
716
  promptDir: "${SKILL_ROOT}/references/agents",
690
717
  prevSha: <null unless re-review>,
718
+ cycle, // finding-state.mjs begin output; omit when unknown
719
+ suppressed, // rejected/intentional findings never to re-raise
720
+ recurringClasses, // shown in the rethink verdict
691
721
  testRunner: <worktree.json testRunner, null for scan>
692
722
  }
693
723
  ```
@@ -142,6 +142,9 @@ Collect into `EXISTING_REVIEWS`. Purpose:
142
142
  - **Deduplication**: if an existing review already raised a finding at a
143
143
  specific `file:line`, skip that finding in your output and note
144
144
  `(already raised by <reviewer>)` internally in your deduplication pass.
145
+ - **Answered findings**: file:line dedup is not enough across re-reviews. A finding the
146
+ author rejected or called intentional is carried in `suppressed` from
147
+ `scripts/finding-state.mjs` (SKILL.md, Convergence state) and is never re-raised.
145
148
  - **Design decisions**: if the author replied to a review comment explaining
146
149
  an intentional choice, that context informs whether a pattern is a bug or
147
150
  a deliberate trade-off.
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // Per-PR finding state for lekker-review, so re-reviews converge.
3
+ //
4
+ // A re-review used to know only the last head sha and dedupe by file:line, so a
5
+ // finding the author had already rejected came back every round. This keeps one
6
+ // small JSON file per PR next to the run ledger (${LEKKER_STATE_DIR:-
7
+ // ~/.local/state/lekker-review}/findings/) holding each finding's status and the
8
+ // number of review cycles.
9
+ //
10
+ // Statuses: open | fixed | rejected | intentional. A finding is
11
+ // fixed when GitHub marks its thread outdated (the line changed)
12
+ // intentional when the author replied "by design" and the code did not change
13
+ // rejected when the author replied with another dismissal, or the thread
14
+ // was resolved without a code change and the PR author is known
15
+ //
16
+ // Usage:
17
+ // node finding-state.mjs begin --repo <slug> --target <pr-N> [--threads <threads.json>]
18
+ // Folds thread outcomes into the stored statuses, counts the cycle, and
19
+ // prints {cycle, rethink, suppressed, recurringClasses} for workflow.js.
20
+ // node finding-state.mjs record --repo <slug> --target <pr-N> --findings <findings.json>
21
+ // Stores this run's findings as open (an already-known finding keeps its status).
22
+ // node finding-state.mjs show --repo <slug> --target <pr-N>
23
+ //
24
+ // threads.json: { prAuthor, threads: [{ path, line, title?, body, isResolved, isOutdated,
25
+ // replies: [{ author, body }] }] }, where `body` is lekker's comment and only
26
+ // replies by `prAuthor` count as the author's answer. A bare array is accepted
27
+ // when the author is unknown; it can then only yield fixed (outdated) threads.
28
+ // When title is absent, the first nonempty comment line supplies the title.
29
+
30
+ import { createHash, randomUUID } from 'node:crypto';
31
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
32
+ import { homedir } from 'node:os';
33
+ import { dirname, join } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ export const RETHINK_CYCLE = 4;
37
+ const INTENT = /\b(intentional(ly)?|by design|on purpose|deliberate(ly)?|as designed|expected behaviou?r)\b/i;
38
+ const DISMISS = /\b(won'?t fix|wontfix|not a (bug|problem|concern)|false positive|disagree|n\/a|not applicable|out of scope|ignor(e|ing)|nit)\b/i;
39
+
40
+ export function titleTokens(title) {
41
+ return (String(title || '').toLowerCase().match(/[a-z0-9]+/g) || []).filter((w) => w.length > 2);
42
+ }
43
+
44
+ export function sameIssue(a, b) {
45
+ const normalize = (title) => String(title || '').trim().replace(/\s+/g, ' ').toLowerCase();
46
+ const title = normalize(a.title);
47
+ return title !== '' && title === normalize(b.title);
48
+ }
49
+
50
+ export function sameFinding(a, b) {
51
+ // Status reuse requires the same anchor and title; word overlap is only a
52
+ // class signal, never proof that two findings identify the same issue.
53
+ return Boolean(a.file) && a.file === b.file && a.line === b.line && sameIssue(a, b);
54
+ }
55
+
56
+ // Group key for "the same kind of finding came back again".
57
+ export function findingClass(f) {
58
+ return f.bugClass || titleTokens(f.title).slice(0, 4).sort().join('-') || 'unclassified';
59
+ }
60
+
61
+ const NEGATOR = /\b(not|no|never|nor|isn'?t|aren'?t|wasn'?t|don'?t|doesn'?t|won'?t|cannot)\b/i;
62
+
63
+ // Check only the three words before each keyword in its clause for negation.
64
+ function mentions(pattern, text) {
65
+ return [...text.matchAll(new RegExp(pattern.source, 'gi'))]
66
+ .some((match) => {
67
+ const before = text.slice(0, match.index).split(/[.!?;:,\n-]/).pop();
68
+ return !NEGATOR.test(before.trim().split(/\s+/).slice(-3).join(' '));
69
+ });
70
+ }
71
+
72
+ export function classifyThread(thread, prAuthor) {
73
+ const authorReplies = (thread.replies || []).filter((r) => r.author && r.author === prAuthor);
74
+ const text = authorReplies.map((r) => r.body).join('\n');
75
+ if (thread.isOutdated) return 'fixed';
76
+ if (mentions(INTENT, text)) return 'intentional';
77
+ if (mentions(DISMISS, text)) return 'rejected';
78
+ if (thread.isResolved && prAuthor) return 'rejected';
79
+ return 'open';
80
+ }
81
+
82
+ export function applyThreads(findings, threads, prAuthor) {
83
+ return findings.map((f) => {
84
+ const thread = threads.find((t) => {
85
+ const title = t.title?.trim() || String(t.body || '').split(/\r?\n/).find((line) => line.trim())
86
+ ?.trim().replace(/^#{1,6}\s+/, '').replace(/^(\*\*|__)(.*?)\1[.:]?$/, '$2');
87
+ return sameFinding(f, { file: t.path, line: t.line, title });
88
+ });
89
+ if (!thread) return f;
90
+ const status = classifyThread(thread, prAuthor);
91
+ return f.status === status ? f : { ...f, status };
92
+ });
93
+ }
94
+
95
+ export function mergeFindings(prior, found, cycle) {
96
+ const added = found
97
+ .filter((f) => !prior.some((p) => sameFinding(p, f)))
98
+ .map((f) => ({ file: f.file, line: f.line, title: f.title, class: findingClass(f), status: 'open', cycles: [cycle] }));
99
+ const seen = prior.map((p) => (found.some((f) => sameFinding(p, f)) && !p.cycles.includes(cycle)
100
+ ? { ...p, cycles: [...p.cycles, cycle] }
101
+ : p));
102
+ return seen.concat(added);
103
+ }
104
+
105
+ export function recurringClasses(findings) {
106
+ const byClass = findings.reduce((acc, f) => {
107
+ const cycles = new Set([...(acc[f.class] || []), ...f.cycles]);
108
+ return { ...acc, [f.class]: [...cycles] };
109
+ }, {});
110
+ return Object.entries(byClass)
111
+ .filter(([, cycles]) => cycles.length >= 2)
112
+ .map(([cls, cycles]) => ({ class: cls, cycles: cycles.length }))
113
+ .sort((a, b) => b.cycles - a.cycles);
114
+ }
115
+
116
+ export function suppressedFindings(findings) {
117
+ return findings
118
+ .filter((f) => f.status === 'rejected' || f.status === 'intentional')
119
+ .map(({ file, line, title, status }) => ({ file, line, title, status }));
120
+ }
121
+
122
+ // A run id makes begin idempotent: a retry of the same run reuses its cycle instead of inflating the count.
123
+ export function begin(state, threads, prAuthor, run = null) {
124
+ const findings = applyThreads(state.findings, threads, prAuthor);
125
+ const known = run === null ? undefined : state.runs?.[run];
126
+ const cycle = known ?? state.cycle + 1;
127
+ const runs = run === null || known !== undefined ? state.runs : { ...state.runs, [run]: cycle };
128
+ return {
129
+ state: { ...state, cycle: Math.max(state.cycle, cycle), findings, ...(runs && { runs }) },
130
+ summary: {
131
+ cycle,
132
+ rethink: cycle >= RETHINK_CYCLE,
133
+ suppressed: suppressedFindings(findings),
134
+ recurringClasses: recurringClasses(findings),
135
+ },
136
+ };
137
+ }
138
+
139
+ const STATE_DIR = process.env.LEKKER_STATE_DIR || join(homedir(), '.local', 'state', 'lekker-review');
140
+ // Hash the tuple so punctuation, case and repo/target boundaries stay distinct.
141
+ const statePath = (repo, target) => join(STATE_DIR, 'findings',
142
+ `${createHash('sha256').update(JSON.stringify([repo, target])).digest('hex')}.json`);
143
+
144
+ function load(path) {
145
+ let raw;
146
+ try {
147
+ raw = readFileSync(path, 'utf8');
148
+ } catch (error) {
149
+ if (error.code === 'ENOENT') return { cycle: 0, findings: [] };
150
+ throw error;
151
+ }
152
+ const state = JSON.parse(raw);
153
+ if (!state || typeof state !== 'object' || Array.isArray(state)
154
+ || !Number.isSafeInteger(state.cycle) || state.cycle < 0 || !Array.isArray(state.findings)) {
155
+ throw new Error(`Invalid finding state: ${path}`);
156
+ }
157
+ if (state.runs !== undefined && (!state.runs || typeof state.runs !== 'object' || Array.isArray(state.runs)
158
+ || !Object.values(state.runs).every((c) => Number.isSafeInteger(c) && c >= 1))) {
159
+ throw new Error(`Invalid finding state runs: ${path}`);
160
+ }
161
+ return state;
162
+ }
163
+
164
+ function save(path, state) {
165
+ mkdirSync(dirname(path), { recursive: true });
166
+ const temporary = `${path}.${randomUUID()}.tmp`;
167
+ try {
168
+ writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
169
+ renameSync(temporary, path);
170
+ } finally {
171
+ rmSync(temporary, { force: true });
172
+ }
173
+ }
174
+
175
+ function main(argv) {
176
+ const flag = (name) => { const i = argv.indexOf('--' + name); return i === -1 ? null : argv[i + 1]; };
177
+ const repo = flag('repo');
178
+ const target = flag('target');
179
+ if (!repo || !target) { console.log(JSON.stringify({ error: '--repo and --target are required' })); process.exit(2); }
180
+ const path = statePath(repo, target);
181
+ const state = load(path);
182
+ const cmd = argv[0];
183
+ if (cmd === 'begin') {
184
+ const raw = flag('threads') ? JSON.parse(readFileSync(flag('threads'), 'utf8')) : [];
185
+ const threads = Array.isArray(raw) ? raw : raw.threads || [];
186
+ const prAuthor = Array.isArray(raw) ? null : raw.prAuthor || null;
187
+ const next = begin(state, threads, prAuthor, flag('run') ?? null);
188
+ save(path, next.state);
189
+ console.log(JSON.stringify(next.summary));
190
+ } else if (cmd === 'record') {
191
+ const raw = JSON.parse(readFileSync(flag('findings'), 'utf8'));
192
+ const found = Array.isArray(raw) ? raw : raw.findings || [];
193
+ const run = flag('run') ?? null;
194
+ // A late record for an earlier run keeps that run's cycle; the current cycle stays as it was.
195
+ const cycle = run === null ? Math.max(state.cycle, 1) : state.runs?.[run];
196
+ if (cycle === undefined) { console.log(JSON.stringify({ error: `unknown --run ${run}; call begin --run first` })); process.exit(2); }
197
+ save(path, { ...state, cycle: Math.max(state.cycle, 1), findings: mergeFindings(state.findings, found, cycle) });
198
+ console.log(JSON.stringify({ recorded: found.length, cycle }));
199
+ } else if (cmd === 'show') {
200
+ console.log(JSON.stringify(state, null, 2));
201
+ } else {
202
+ console.log(JSON.stringify({ error: 'usage: begin|record|show' }));
203
+ process.exit(2);
204
+ }
205
+ }
206
+
207
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olegkoval/agent-skills",
3
- "version": "1.61.0",
3
+ "version": "1.62.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.61.0",
4
+ "version": "1.62.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.61.0",
4
+ "version": "1.62.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.61.0",
4
+ "version": "1.62.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.61.0",
4
+ "version": "1.62.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.61.0",
4
+ "version": "1.62.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -352,6 +352,33 @@ treat as a full review and leave PREV_SHA unset. Also grep the same file for
352
352
  - Step 3.5 reuses that exact URL or local path so the artifact stays a living
353
353
  page for this PR.
354
354
 
355
+ **Convergence state (every PR, first review included):** the review file
356
+ records only the last head sha, so finding status lives in
357
+ `scripts/finding-state.mjs` (one JSON file per PR under
358
+ `~/.local/state/lekker-review/findings/`). In `pr` mode, on a re-review, first
359
+ fetch the PR's review threads into `<scratchpad>/threads.json` as
360
+ `{ "prAuthor": <login>, "threads": [{ path, line, body, isResolved, isOutdated,
361
+ replies: [{ author, body }] }] }` (`gh api graphql` over `reviewThreads`; `body`
362
+ is the first comment, `replies` the later ones). Then run
363
+ `node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/finding-state.mjs" begin --repo <REPO_SLUG> --target <TARGET_SLUG> --run <RUN_ID> [--threads <scratchpad>/threads.json]`.
364
+ `RUN_ID` is one id per review attempt, saved to `<scratchpad>/finding-run-id`
365
+ and reused when that attempt is retried, so a retry does not count as a new
366
+ cycle. In Codex, replace the quoted script path with the absolute path to
367
+ `scripts/finding-state.mjs` beside the selected `SKILL.md`, as for run-log. Pass
368
+ its `cycle`, `suppressed` and `recurringClasses` to the workflow. A
369
+ finding is `fixed` when its thread is outdated, `intentional` when the author
370
+ replied "by design" and nothing changed, `rejected` when the author dismissed it
371
+ or the thread was resolved with no code change. The workflow never re-raises a
372
+ `suppressed` (rejected or intentional) finding. From `cycle` 4 the workflow
373
+ returns `verdict: "rethink"` with the recurring finding classes and runs no
374
+ agents: skip Steps 2 to 4, render the verdict as `RETHINK THE APPROACH: this PR
375
+ has been reviewed N times; the same classes keep coming back: <classes>`, and
376
+ suggest the author and reviewer agree the approach before another round.
377
+ Run `finding-state.mjs record --run <RUN_ID> --repo ... --target ... --findings <findings.json>`
378
+ before the review is published or the artifact is updated, so a stop after
379
+ publishing cannot lose what was raised. `--run` files the findings under this
380
+ attempt's cycle even when another review of the PR started in the meantime.
381
+
355
382
  **Open the run log** once mode, depth and engine are settled (see
356
383
  `references/traces.md` for where every trace of a run lives):
357
384
 
@@ -687,6 +714,9 @@ args: {
687
714
  worktreePath: <null for scan, else from worktree.json>,
688
715
  promptDir: "${SKILL_ROOT}/references/agents",
689
716
  prevSha: <null unless re-review>,
717
+ cycle, // finding-state.mjs begin output; omit when unknown
718
+ suppressed, // rejected/intentional findings never to re-raise
719
+ recurringClasses, // shown in the rethink verdict
690
720
  testRunner: <worktree.json testRunner, null for scan>
691
721
  }
692
722
  ```
@@ -142,6 +142,9 @@ Collect into `EXISTING_REVIEWS`. Purpose:
142
142
  - **Deduplication**: if an existing review already raised a finding at a
143
143
  specific `file:line`, skip that finding in your output and note
144
144
  `(already raised by <reviewer>)` internally in your deduplication pass.
145
+ - **Answered findings**: file:line dedup is not enough across re-reviews. A finding the
146
+ author rejected or called intentional is carried in `suppressed` from
147
+ `scripts/finding-state.mjs` (SKILL.md, Convergence state) and is never re-raised.
145
148
  - **Design decisions**: if the author replied to a review comment explaining
146
149
  an intentional choice, that context informs whether a pattern is a bug or
147
150
  a deliberate trade-off.
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // Per-PR finding state for lekker-review, so re-reviews converge.
3
+ //
4
+ // A re-review used to know only the last head sha and dedupe by file:line, so a
5
+ // finding the author had already rejected came back every round. This keeps one
6
+ // small JSON file per PR next to the run ledger (${LEKKER_STATE_DIR:-
7
+ // ~/.local/state/lekker-review}/findings/) holding each finding's status and the
8
+ // number of review cycles.
9
+ //
10
+ // Statuses: open | fixed | rejected | intentional. A finding is
11
+ // fixed when GitHub marks its thread outdated (the line changed)
12
+ // intentional when the author replied "by design" and the code did not change
13
+ // rejected when the author replied with another dismissal, or the thread
14
+ // was resolved without a code change and the PR author is known
15
+ //
16
+ // Usage:
17
+ // node finding-state.mjs begin --repo <slug> --target <pr-N> [--threads <threads.json>]
18
+ // Folds thread outcomes into the stored statuses, counts the cycle, and
19
+ // prints {cycle, rethink, suppressed, recurringClasses} for workflow.js.
20
+ // node finding-state.mjs record --repo <slug> --target <pr-N> --findings <findings.json>
21
+ // Stores this run's findings as open (an already-known finding keeps its status).
22
+ // node finding-state.mjs show --repo <slug> --target <pr-N>
23
+ //
24
+ // threads.json: { prAuthor, threads: [{ path, line, title?, body, isResolved, isOutdated,
25
+ // replies: [{ author, body }] }] }, where `body` is lekker's comment and only
26
+ // replies by `prAuthor` count as the author's answer. A bare array is accepted
27
+ // when the author is unknown; it can then only yield fixed (outdated) threads.
28
+ // When title is absent, the first nonempty comment line supplies the title.
29
+
30
+ import { createHash, randomUUID } from 'node:crypto';
31
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
32
+ import { homedir } from 'node:os';
33
+ import { dirname, join } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ export const RETHINK_CYCLE = 4;
37
+ const INTENT = /\b(intentional(ly)?|by design|on purpose|deliberate(ly)?|as designed|expected behaviou?r)\b/i;
38
+ const DISMISS = /\b(won'?t fix|wontfix|not a (bug|problem|concern)|false positive|disagree|n\/a|not applicable|out of scope|ignor(e|ing)|nit)\b/i;
39
+
40
+ export function titleTokens(title) {
41
+ return (String(title || '').toLowerCase().match(/[a-z0-9]+/g) || []).filter((w) => w.length > 2);
42
+ }
43
+
44
+ export function sameIssue(a, b) {
45
+ const normalize = (title) => String(title || '').trim().replace(/\s+/g, ' ').toLowerCase();
46
+ const title = normalize(a.title);
47
+ return title !== '' && title === normalize(b.title);
48
+ }
49
+
50
+ export function sameFinding(a, b) {
51
+ // Status reuse requires the same anchor and title; word overlap is only a
52
+ // class signal, never proof that two findings identify the same issue.
53
+ return Boolean(a.file) && a.file === b.file && a.line === b.line && sameIssue(a, b);
54
+ }
55
+
56
+ // Group key for "the same kind of finding came back again".
57
+ export function findingClass(f) {
58
+ return f.bugClass || titleTokens(f.title).slice(0, 4).sort().join('-') || 'unclassified';
59
+ }
60
+
61
+ const NEGATOR = /\b(not|no|never|nor|isn'?t|aren'?t|wasn'?t|don'?t|doesn'?t|won'?t|cannot)\b/i;
62
+
63
+ // Check only the three words before each keyword in its clause for negation.
64
+ function mentions(pattern, text) {
65
+ return [...text.matchAll(new RegExp(pattern.source, 'gi'))]
66
+ .some((match) => {
67
+ const before = text.slice(0, match.index).split(/[.!?;:,\n-]/).pop();
68
+ return !NEGATOR.test(before.trim().split(/\s+/).slice(-3).join(' '));
69
+ });
70
+ }
71
+
72
+ export function classifyThread(thread, prAuthor) {
73
+ const authorReplies = (thread.replies || []).filter((r) => r.author && r.author === prAuthor);
74
+ const text = authorReplies.map((r) => r.body).join('\n');
75
+ if (thread.isOutdated) return 'fixed';
76
+ if (mentions(INTENT, text)) return 'intentional';
77
+ if (mentions(DISMISS, text)) return 'rejected';
78
+ if (thread.isResolved && prAuthor) return 'rejected';
79
+ return 'open';
80
+ }
81
+
82
+ export function applyThreads(findings, threads, prAuthor) {
83
+ return findings.map((f) => {
84
+ const thread = threads.find((t) => {
85
+ const title = t.title?.trim() || String(t.body || '').split(/\r?\n/).find((line) => line.trim())
86
+ ?.trim().replace(/^#{1,6}\s+/, '').replace(/^(\*\*|__)(.*?)\1[.:]?$/, '$2');
87
+ return sameFinding(f, { file: t.path, line: t.line, title });
88
+ });
89
+ if (!thread) return f;
90
+ const status = classifyThread(thread, prAuthor);
91
+ return f.status === status ? f : { ...f, status };
92
+ });
93
+ }
94
+
95
+ export function mergeFindings(prior, found, cycle) {
96
+ const added = found
97
+ .filter((f) => !prior.some((p) => sameFinding(p, f)))
98
+ .map((f) => ({ file: f.file, line: f.line, title: f.title, class: findingClass(f), status: 'open', cycles: [cycle] }));
99
+ const seen = prior.map((p) => (found.some((f) => sameFinding(p, f)) && !p.cycles.includes(cycle)
100
+ ? { ...p, cycles: [...p.cycles, cycle] }
101
+ : p));
102
+ return seen.concat(added);
103
+ }
104
+
105
+ export function recurringClasses(findings) {
106
+ const byClass = findings.reduce((acc, f) => {
107
+ const cycles = new Set([...(acc[f.class] || []), ...f.cycles]);
108
+ return { ...acc, [f.class]: [...cycles] };
109
+ }, {});
110
+ return Object.entries(byClass)
111
+ .filter(([, cycles]) => cycles.length >= 2)
112
+ .map(([cls, cycles]) => ({ class: cls, cycles: cycles.length }))
113
+ .sort((a, b) => b.cycles - a.cycles);
114
+ }
115
+
116
+ export function suppressedFindings(findings) {
117
+ return findings
118
+ .filter((f) => f.status === 'rejected' || f.status === 'intentional')
119
+ .map(({ file, line, title, status }) => ({ file, line, title, status }));
120
+ }
121
+
122
+ // A run id makes begin idempotent: a retry of the same run reuses its cycle instead of inflating the count.
123
+ export function begin(state, threads, prAuthor, run = null) {
124
+ const findings = applyThreads(state.findings, threads, prAuthor);
125
+ const known = run === null ? undefined : state.runs?.[run];
126
+ const cycle = known ?? state.cycle + 1;
127
+ const runs = run === null || known !== undefined ? state.runs : { ...state.runs, [run]: cycle };
128
+ return {
129
+ state: { ...state, cycle: Math.max(state.cycle, cycle), findings, ...(runs && { runs }) },
130
+ summary: {
131
+ cycle,
132
+ rethink: cycle >= RETHINK_CYCLE,
133
+ suppressed: suppressedFindings(findings),
134
+ recurringClasses: recurringClasses(findings),
135
+ },
136
+ };
137
+ }
138
+
139
+ const STATE_DIR = process.env.LEKKER_STATE_DIR || join(homedir(), '.local', 'state', 'lekker-review');
140
+ // Hash the tuple so punctuation, case and repo/target boundaries stay distinct.
141
+ const statePath = (repo, target) => join(STATE_DIR, 'findings',
142
+ `${createHash('sha256').update(JSON.stringify([repo, target])).digest('hex')}.json`);
143
+
144
+ function load(path) {
145
+ let raw;
146
+ try {
147
+ raw = readFileSync(path, 'utf8');
148
+ } catch (error) {
149
+ if (error.code === 'ENOENT') return { cycle: 0, findings: [] };
150
+ throw error;
151
+ }
152
+ const state = JSON.parse(raw);
153
+ if (!state || typeof state !== 'object' || Array.isArray(state)
154
+ || !Number.isSafeInteger(state.cycle) || state.cycle < 0 || !Array.isArray(state.findings)) {
155
+ throw new Error(`Invalid finding state: ${path}`);
156
+ }
157
+ if (state.runs !== undefined && (!state.runs || typeof state.runs !== 'object' || Array.isArray(state.runs)
158
+ || !Object.values(state.runs).every((c) => Number.isSafeInteger(c) && c >= 1))) {
159
+ throw new Error(`Invalid finding state runs: ${path}`);
160
+ }
161
+ return state;
162
+ }
163
+
164
+ function save(path, state) {
165
+ mkdirSync(dirname(path), { recursive: true });
166
+ const temporary = `${path}.${randomUUID()}.tmp`;
167
+ try {
168
+ writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
169
+ renameSync(temporary, path);
170
+ } finally {
171
+ rmSync(temporary, { force: true });
172
+ }
173
+ }
174
+
175
+ function main(argv) {
176
+ const flag = (name) => { const i = argv.indexOf('--' + name); return i === -1 ? null : argv[i + 1]; };
177
+ const repo = flag('repo');
178
+ const target = flag('target');
179
+ if (!repo || !target) { console.log(JSON.stringify({ error: '--repo and --target are required' })); process.exit(2); }
180
+ const path = statePath(repo, target);
181
+ const state = load(path);
182
+ const cmd = argv[0];
183
+ if (cmd === 'begin') {
184
+ const raw = flag('threads') ? JSON.parse(readFileSync(flag('threads'), 'utf8')) : [];
185
+ const threads = Array.isArray(raw) ? raw : raw.threads || [];
186
+ const prAuthor = Array.isArray(raw) ? null : raw.prAuthor || null;
187
+ const next = begin(state, threads, prAuthor, flag('run') ?? null);
188
+ save(path, next.state);
189
+ console.log(JSON.stringify(next.summary));
190
+ } else if (cmd === 'record') {
191
+ const raw = JSON.parse(readFileSync(flag('findings'), 'utf8'));
192
+ const found = Array.isArray(raw) ? raw : raw.findings || [];
193
+ const run = flag('run') ?? null;
194
+ // A late record for an earlier run keeps that run's cycle; the current cycle stays as it was.
195
+ const cycle = run === null ? Math.max(state.cycle, 1) : state.runs?.[run];
196
+ if (cycle === undefined) { console.log(JSON.stringify({ error: `unknown --run ${run}; call begin --run first` })); process.exit(2); }
197
+ save(path, { ...state, cycle: Math.max(state.cycle, 1), findings: mergeFindings(state.findings, found, cycle) });
198
+ console.log(JSON.stringify({ recorded: found.length, cycle }));
199
+ } else if (cmd === 'show') {
200
+ console.log(JSON.stringify(state, null, 2));
201
+ } else {
202
+ console.log(JSON.stringify({ error: 'usage: begin|record|show' }));
203
+ process.exit(2);
204
+ }
205
+ }
206
+
207
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
@@ -145,6 +145,8 @@ function isHardRule(finding) {
145
145
  return typeof finding.rule === 'string' && finding.rule.trim().length > 0
146
146
  }
147
147
 
148
+ const RETHINK_CYCLE = 4
149
+
148
150
  const SEVERITY_RANK = { critical: 3, important: 2, observation: 1, idiomatic: 0 }
149
151
 
150
152
  function titleTokens(title) {
@@ -445,6 +447,9 @@ const {
445
447
  worktreePath,
446
448
  promptDir,
447
449
  prevSha,
450
+ cycle,
451
+ suppressed,
452
+ recurringClasses,
448
453
  reviewBatchPlan,
449
454
  maxConcurrent,
450
455
  targetLabel: targetLabelArg,
@@ -480,6 +485,25 @@ const REVIEW_PLAN = (Array.isArray(reviewBatchPlan) && reviewBatchPlan.length >
480
485
  const FANOUT_PLAN = [Math.max(1, maxConcurrent || 5)]
481
486
  const targetMetadata = JSON.stringify({ targetLabel })
482
487
 
488
+ // Convergence. `cycle` and `suppressed` come from scripts/finding-state.mjs.
489
+ // From the fourth review of one PR the findings are not the problem, the
490
+ // approach is: report the recurring classes instead of spending another round.
491
+ const SUPPRESSED = Array.isArray(suppressed) ? suppressed : []
492
+ if (Number(cycle) >= RETHINK_CYCLE) {
493
+ log(`cycle ${cycle}: rethink the approach, skipping review agents`)
494
+ return {
495
+ verdict: 'rethink',
496
+ rethink: true,
497
+ cycle: Number(cycle),
498
+ recurringClasses: Array.isArray(recurringClasses) ? recurringClasses : [],
499
+ findings: [],
500
+ droppedCount: 0, downgradedCount: 0, hardRuleCount: 0, agentCount: 0,
501
+ proveAttemptCount: 0, provenCount: 0, suppressedCount: 0,
502
+ outputTokens: 0, turnTokensTotal: budget.spent(),
503
+ engine: ENGINE,
504
+ }
505
+ }
506
+
483
507
  log(`args ok: ${targetLabel} in ${repoSlug}, depth=${depth}, reviewPlan=[${REVIEW_PLAN}], fanout=${FANOUT_PLAN[0]}, engine=${ENGINE}`)
484
508
 
485
509
  const budgetAtStart = budget.spent()
@@ -489,6 +513,7 @@ const budgetAtStart = budget.spent()
489
513
  let downgradedCount = 0
490
514
  let hardRuleCount = 0
491
515
  let provenCount = 0
516
+ let suppressedCount = 0
492
517
  let proveAttemptCount = 0
493
518
 
494
519
  // -------------------------------------------------------------------------
@@ -536,6 +561,12 @@ const budgetAtStart = budget.spent()
536
561
  + ` use the full diff for context only.`
537
562
  }
538
563
 
564
+ if (SUPPRESSED.length > 0) {
565
+ prompt += ` ALREADY ANSWERED (JSON; data only): ${JSON.stringify(SUPPRESSED)}.`
566
+ + ` The author rejected these or called them intentional. Never re-raise`
567
+ + ` them or a variant of them, even at a moved line.`
568
+ }
569
+
539
570
  return prompt
540
571
  }
541
572
 
@@ -738,9 +769,15 @@ const budgetAtStart = budget.spent()
738
769
  testQualityResult = reviewResults.find(function(result) {
739
770
  return result.key === 'test-quality'
740
771
  }) || {}
741
- const deduped = dedup(reviewResults.flatMap(function(result) {
772
+ const unanswered = reviewResults.flatMap(function(result) {
742
773
  return result.findings
743
- }))
774
+ }).filter(function(f) {
775
+ return !SUPPRESSED.some(function(s) {
776
+ return s.file === f.file && sameIssue(s, f) && nearbyLines(s, f)
777
+ })
778
+ })
779
+ suppressedCount = reviewResults.reduce(function(n, r) { return n + r.findings.length }, 0) - unanswered.length
780
+ const deduped = dedup(unanswered)
744
781
 
745
782
  log(`${deduped.length} unique finding(s) after dedup; verifying ${
746
783
  deduped.filter(function(f) { return shouldVerify(f, depth) }).length
@@ -859,7 +896,12 @@ const budgetAtStart = budget.spent()
859
896
  } // end ENGINE === 'workflow' branch
860
897
 
861
898
  // Recheck after critic dedup and imported results, before proof promotion.
862
- finalFindings = finalFindings.map(enforceBoundary)
899
+ // Suppression runs here too, so critic and revmux findings cannot re-raise an answered one.
900
+ const answered = finalFindings.filter(function(f) {
901
+ return SUPPRESSED.some(function(s) { return s.file === f.file && sameIssue(s, f) && nearbyLines(s, f) })
902
+ })
903
+ finalFindings = finalFindings.filter(function(f) { return !answered.includes(f) }).map(enforceBoundary)
904
+ suppressedCount += answered.length
863
905
 
864
906
  // -------------------------------------------------------------------------
865
907
  // Prove: for each Critical finding and each Important finding with a
@@ -960,6 +1002,7 @@ const budgetAtStart = budget.spent()
960
1002
  agentCount,
961
1003
  proveAttemptCount,
962
1004
  provenCount,
1005
+ suppressedCount,
963
1006
  acCoverage: implementationResult.acCoverage || null,
964
1007
  coverageVerdict: testQualityResult.coverageVerdict || null,
965
1008
  mutationSlip: testQualityResult.mutationSlip || null,
@@ -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.61.0",
4
+ "version": "1.62.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.61.0",
4
+ "version": "1.62.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.61.0",
4
+ "version": "1.62.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.61.0",
4
+ "version": "1.62.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-skill-meta",
3
3
  "description": "Author and maintain agent skills and the AI toolchain itself.",
4
- "version": "1.61.0",
4
+ "version": "1.62.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-web-ops",
3
3
  "description": "Operate a website: WAF rules, search console audits, analytics bootstrap, docs indexes.",
4
- "version": "1.61.0",
4
+ "version": "1.62.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },