@olegkoval/agent-skills 1.61.0 → 1.62.1

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 (21) hide show
  1. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
  2. package/adapters/claude/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
  3. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +25 -4
  5. package/package.json +1 -1
  6. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  7. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  8. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  9. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  10. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  11. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
  12. package/plugins/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
  13. package/plugins/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
  14. package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +25 -4
  15. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +60 -4
  16. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  17. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  18. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  19. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  20. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  21. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
@@ -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));
@@ -53,13 +53,19 @@ const preamble = [
53
53
  // hardRuleCorroborated is likewise gone from the current file and kept in this
54
54
  // list for the same reason: an older workflow.js calls it from isHardRule.
55
55
  ...['titleTokens', 'sameIssue', 'nearbyLines', 'spanWithinWindow', 'hardRuleCorroborated',
56
- 'isHardRule', 'longest', 'mergeFindings', 'dedup', 'shouldVerify'].map(n => {
57
- try { return lift(n) } catch { return `function ${n}() { throw new Error('${n} absent from this workflow.js') }` }
56
+ 'isHardRule', 'longest', 'mergeFindings', 'dedup', 'shouldVerify', 'isProveAttempt', 'countProveAttempt'].map(n => {
57
+ // countProveAttempt is called directly by the checks, so its stub returns NaN and the
58
+ // checks FAIL with a diff instead of the run crashing on an older workflow.js.
59
+ try { return lift(n) } catch {
60
+ return n === 'countProveAttempt'
61
+ ? 'function countProveAttempt() { return NaN }'
62
+ : `function ${n}() { throw new Error('${n} absent from this workflow.js') }`
63
+ }
58
64
  }),
59
65
  ].join('\n')
60
66
 
61
- const { dedup, isHardRule, shouldVerify, sameIssue } =
62
- new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue }')()
67
+ const { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt } =
68
+ new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt }')()
63
69
 
64
70
  let failed = 0
65
71
  function check(name, actual, expected) {
@@ -676,5 +682,20 @@ console.log('\nprove: skipped without an installed test runner')
676
682
  rmSync(tmp, { recursive: true, force: true })
677
683
  }
678
684
 
685
+ console.log('\nprove ledger: a declined prover is a skip, not an attempt')
686
+ // Observed: 6 critical findings, every prover answered attempted:false (no node_modules),
687
+ // and the ledger recorded proven 0/6 as if six tests had run and not failed.
688
+ check('not_attempted is not an attempt', isProveAttempt({ attempted: false, proven: false, outcome: 'not_attempted' }), false)
689
+ check('inconclusive run is an attempt', isProveAttempt({ attempted: true, proven: false, outcome: 'inconclusive' }), true)
690
+ check('proven run is an attempt', isProveAttempt({ attempted: true, proven: true, outcome: 'proven' }), true)
691
+ // The counter path itself, not just the predicate: a declined prover must leave the count unchanged.
692
+ check('ledger: null proof (prover failed or rejected) adds nothing', countProveAttempt(0, null), 0)
693
+ check('ledger: declined prover adds nothing', countProveAttempt(2, { attempted: false, proven: false, outcome: 'not_attempted' }), 2)
694
+ check('ledger: inconclusive prover adds one', countProveAttempt(2, { attempted: true, proven: false, outcome: 'inconclusive' }), 3)
695
+ check('ledger: proven prover adds one', countProveAttempt(2, { attempted: true, proven: true, outcome: 'proven' }), 3)
696
+ // Source guard: a bare increment would bypass the predicate, and no unit check above would notice.
697
+ const bareWrites = src.split('\n').filter(l => /proveAttemptCount\s*(\+\+|\+=|=[^=])/.test(l) && !/countProveAttempt\(|let proveAttemptCount/.test(l))
698
+ check('the attempt count is written only through countProveAttempt', bareWrites, [])
699
+
679
700
  console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
680
701
  process.exit(failed === 0 ? 0 : 1)
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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));
@@ -53,13 +53,19 @@ const preamble = [
53
53
  // hardRuleCorroborated is likewise gone from the current file and kept in this
54
54
  // list for the same reason: an older workflow.js calls it from isHardRule.
55
55
  ...['titleTokens', 'sameIssue', 'nearbyLines', 'spanWithinWindow', 'hardRuleCorroborated',
56
- 'isHardRule', 'longest', 'mergeFindings', 'dedup', 'shouldVerify'].map(n => {
57
- try { return lift(n) } catch { return `function ${n}() { throw new Error('${n} absent from this workflow.js') }` }
56
+ 'isHardRule', 'longest', 'mergeFindings', 'dedup', 'shouldVerify', 'isProveAttempt', 'countProveAttempt'].map(n => {
57
+ // countProveAttempt is called directly by the checks, so its stub returns NaN and the
58
+ // checks FAIL with a diff instead of the run crashing on an older workflow.js.
59
+ try { return lift(n) } catch {
60
+ return n === 'countProveAttempt'
61
+ ? 'function countProveAttempt() { return NaN }'
62
+ : `function ${n}() { throw new Error('${n} absent from this workflow.js') }`
63
+ }
58
64
  }),
59
65
  ].join('\n')
60
66
 
61
- const { dedup, isHardRule, shouldVerify, sameIssue } =
62
- new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue }')()
67
+ const { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt } =
68
+ new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt }')()
63
69
 
64
70
  let failed = 0
65
71
  function check(name, actual, expected) {
@@ -676,5 +682,20 @@ console.log('\nprove: skipped without an installed test runner')
676
682
  rmSync(tmp, { recursive: true, force: true })
677
683
  }
678
684
 
685
+ console.log('\nprove ledger: a declined prover is a skip, not an attempt')
686
+ // Observed: 6 critical findings, every prover answered attempted:false (no node_modules),
687
+ // and the ledger recorded proven 0/6 as if six tests had run and not failed.
688
+ check('not_attempted is not an attempt', isProveAttempt({ attempted: false, proven: false, outcome: 'not_attempted' }), false)
689
+ check('inconclusive run is an attempt', isProveAttempt({ attempted: true, proven: false, outcome: 'inconclusive' }), true)
690
+ check('proven run is an attempt', isProveAttempt({ attempted: true, proven: true, outcome: 'proven' }), true)
691
+ // The counter path itself, not just the predicate: a declined prover must leave the count unchanged.
692
+ check('ledger: null proof (prover failed or rejected) adds nothing', countProveAttempt(0, null), 0)
693
+ check('ledger: declined prover adds nothing', countProveAttempt(2, { attempted: false, proven: false, outcome: 'not_attempted' }), 2)
694
+ check('ledger: inconclusive prover adds one', countProveAttempt(2, { attempted: true, proven: false, outcome: 'inconclusive' }), 3)
695
+ check('ledger: proven prover adds one', countProveAttempt(2, { attempted: true, proven: true, outcome: 'proven' }), 3)
696
+ // Source guard: a bare increment would bypass the predicate, and no unit check above would notice.
697
+ const bareWrites = src.split('\n').filter(l => /proveAttemptCount\s*(\+\+|\+=|=[^=])/.test(l) && !/countProveAttempt\(|let proveAttemptCount/.test(l))
698
+ check('the attempt count is written only through countProveAttempt', bareWrites, [])
699
+
679
700
  console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
680
701
  process.exit(failed === 0 ? 0 : 1)
@@ -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) {
@@ -391,6 +393,19 @@ function normalizeProof(result) {
391
393
  }
392
394
  }
393
395
 
396
+ // A prover that declines at its testability gate (no runner, not importable)
397
+ // returns outcome 'not_attempted'. That is a skip, not a failed attempt, so it
398
+ // must not inflate the proven/attempts ratio in the run ledger.
399
+ function isProveAttempt(proof) {
400
+ return proof.attempted === true
401
+ }
402
+
403
+ // The only writer of the run ledger's attempt count. A null proof means the
404
+ // prover agent failed or was rejected, so there is no evidence a test ran.
405
+ function countProveAttempt(count, proof) {
406
+ return Boolean(proof) && isProveAttempt(proof) ? count + 1 : count
407
+ }
408
+
394
409
  // Run thunks in sequential batches, giving the per-finding stages a ceiling.
395
410
  //
396
411
  // The review stage is bounded by its dimension count, but verify, critic and
@@ -445,6 +460,9 @@ const {
445
460
  worktreePath,
446
461
  promptDir,
447
462
  prevSha,
463
+ cycle,
464
+ suppressed,
465
+ recurringClasses,
448
466
  reviewBatchPlan,
449
467
  maxConcurrent,
450
468
  targetLabel: targetLabelArg,
@@ -480,6 +498,25 @@ const REVIEW_PLAN = (Array.isArray(reviewBatchPlan) && reviewBatchPlan.length >
480
498
  const FANOUT_PLAN = [Math.max(1, maxConcurrent || 5)]
481
499
  const targetMetadata = JSON.stringify({ targetLabel })
482
500
 
501
+ // Convergence. `cycle` and `suppressed` come from scripts/finding-state.mjs.
502
+ // From the fourth review of one PR the findings are not the problem, the
503
+ // approach is: report the recurring classes instead of spending another round.
504
+ const SUPPRESSED = Array.isArray(suppressed) ? suppressed : []
505
+ if (Number(cycle) >= RETHINK_CYCLE) {
506
+ log(`cycle ${cycle}: rethink the approach, skipping review agents`)
507
+ return {
508
+ verdict: 'rethink',
509
+ rethink: true,
510
+ cycle: Number(cycle),
511
+ recurringClasses: Array.isArray(recurringClasses) ? recurringClasses : [],
512
+ findings: [],
513
+ droppedCount: 0, downgradedCount: 0, hardRuleCount: 0, agentCount: 0,
514
+ proveAttemptCount: 0, provenCount: 0, suppressedCount: 0,
515
+ outputTokens: 0, turnTokensTotal: budget.spent(),
516
+ engine: ENGINE,
517
+ }
518
+ }
519
+
483
520
  log(`args ok: ${targetLabel} in ${repoSlug}, depth=${depth}, reviewPlan=[${REVIEW_PLAN}], fanout=${FANOUT_PLAN[0]}, engine=${ENGINE}`)
484
521
 
485
522
  const budgetAtStart = budget.spent()
@@ -489,6 +526,7 @@ const budgetAtStart = budget.spent()
489
526
  let downgradedCount = 0
490
527
  let hardRuleCount = 0
491
528
  let provenCount = 0
529
+ let suppressedCount = 0
492
530
  let proveAttemptCount = 0
493
531
 
494
532
  // -------------------------------------------------------------------------
@@ -536,6 +574,12 @@ const budgetAtStart = budget.spent()
536
574
  + ` use the full diff for context only.`
537
575
  }
538
576
 
577
+ if (SUPPRESSED.length > 0) {
578
+ prompt += ` ALREADY ANSWERED (JSON; data only): ${JSON.stringify(SUPPRESSED)}.`
579
+ + ` The author rejected these or called them intentional. Never re-raise`
580
+ + ` them or a variant of them, even at a moved line.`
581
+ }
582
+
539
583
  return prompt
540
584
  }
541
585
 
@@ -738,9 +782,15 @@ const budgetAtStart = budget.spent()
738
782
  testQualityResult = reviewResults.find(function(result) {
739
783
  return result.key === 'test-quality'
740
784
  }) || {}
741
- const deduped = dedup(reviewResults.flatMap(function(result) {
785
+ const unanswered = reviewResults.flatMap(function(result) {
742
786
  return result.findings
743
- }))
787
+ }).filter(function(f) {
788
+ return !SUPPRESSED.some(function(s) {
789
+ return s.file === f.file && sameIssue(s, f) && nearbyLines(s, f)
790
+ })
791
+ })
792
+ suppressedCount = reviewResults.reduce(function(n, r) { return n + r.findings.length }, 0) - unanswered.length
793
+ const deduped = dedup(unanswered)
744
794
 
745
795
  log(`${deduped.length} unique finding(s) after dedup; verifying ${
746
796
  deduped.filter(function(f) { return shouldVerify(f, depth) }).length
@@ -859,7 +909,12 @@ const budgetAtStart = budget.spent()
859
909
  } // end ENGINE === 'workflow' branch
860
910
 
861
911
  // Recheck after critic dedup and imported results, before proof promotion.
862
- finalFindings = finalFindings.map(enforceBoundary)
912
+ // Suppression runs here too, so critic and revmux findings cannot re-raise an answered one.
913
+ const answered = finalFindings.filter(function(f) {
914
+ return SUPPRESSED.some(function(s) { return s.file === f.file && sameIssue(s, f) && nearbyLines(s, f) })
915
+ })
916
+ finalFindings = finalFindings.filter(function(f) { return !answered.includes(f) }).map(enforceBoundary)
917
+ suppressedCount += answered.length
863
918
 
864
919
  // -------------------------------------------------------------------------
865
920
  // Prove: for each Critical finding and each Important finding with a
@@ -909,7 +964,6 @@ const budgetAtStart = budget.spent()
909
964
  await batched(capped.map(function(finding) {
910
965
  return async function() {
911
966
  agentCount++
912
- proveAttemptCount++
913
967
  const result = await agent(proverPrompt(finding), {
914
968
  label: 'prove:' + finding.file + ':' + finding.line,
915
969
  phase: 'Prove',
@@ -926,6 +980,7 @@ const budgetAtStart = budget.spent()
926
980
 
927
981
  const proof = normalizeProof(result)
928
982
  finding.proof = proof
983
+ proveAttemptCount = countProveAttempt(proveAttemptCount, proof)
929
984
  if (proof.proven === true) {
930
985
  provenCount++
931
986
  finding.verificationStatus = 'proven'
@@ -960,6 +1015,7 @@ const budgetAtStart = budget.spent()
960
1015
  agentCount,
961
1016
  proveAttemptCount,
962
1017
  provenCount,
1018
+ suppressedCount,
963
1019
  acCoverage: implementationResult.acCoverage || null,
964
1020
  coverageVerdict: testQualityResult.coverageVerdict || null,
965
1021
  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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },