@olegkoval/agent-skills 1.63.0 → 1.64.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 (28) hide show
  1. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +1 -1
  2. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/fixer.md +8 -0
  3. package/adapters/claude/olko-github-pr/skills/lekker-review/references/artifact-page.md +6 -2
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/references/codex-workflow.md +14 -2
  5. package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +13 -2
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/references/output-format.md +2 -0
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +11 -0
  8. package/package.json +1 -1
  9. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  10. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  11. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  12. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  13. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  14. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +1 -1
  15. package/plugins/olko-github-pr/skills/lekker-review/fix-workflow.js +115 -10
  16. package/plugins/olko-github-pr/skills/lekker-review/references/agents/fixer.md +8 -0
  17. package/plugins/olko-github-pr/skills/lekker-review/references/artifact-page.md +6 -2
  18. package/plugins/olko-github-pr/skills/lekker-review/references/codex-workflow.md +14 -2
  19. package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +13 -2
  20. package/plugins/olko-github-pr/skills/lekker-review/references/output-format.md +2 -0
  21. package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +11 -0
  22. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +41 -4
  23. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  24. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  25. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  26. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  27. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  28. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
@@ -775,7 +775,7 @@ The workflow runs three phases:
775
775
  carries `proof: {attempted: false, reason: "no runnable test runner: ..."}`.
776
776
 
777
777
  Findings have schema:
778
- `{file, line, severity, title, description, badCode, fix, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
778
+ `{file, line, severity, title, description, badCode, fix, bugClass, otherInstances, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
779
779
  `badCode` and `fix` are schema-required: an empty string is allowed only on
780
780
  `observation` / `idiomatic` findings.
781
781
 
@@ -74,6 +74,14 @@ outcome; a half-applied fix that breaks callers is the worst outcome.
74
74
 
75
75
  ---
76
76
 
77
+ ## Same-class instances
78
+
79
+ A finding with `instanceOf` is another occurrence of a bug class already
80
+ reported elsewhere. Treat it like any finding: confirm the pattern is really
81
+ there at that spot, apply the same fix adapted to local names and types, and
82
+ skip it (with the reason) when the code differs enough that the fix does not
83
+ carry over. Its `title` is its own join key.
84
+
77
85
  ## Multiple findings in one file
78
86
 
79
87
  Apply them in file order, top to bottom, re-reading after each edit so later
@@ -18,7 +18,8 @@ without creating a new artifact identity.
18
18
  All of the following are already in hand after Step 3 of SKILL.md:
19
19
 
20
20
  - `findings.json` path (scratchpad) -- each finding carries: `file`, `line`,
21
- `severity`, `title`, `description`, `badCode`, `fix`, `rule?`, `precedent?`,
21
+ `severity`, `title`, `description`, `badCode`, `fix`, `bugClass`,
22
+ `otherInstances`, `rule?`, `precedent?`,
22
23
  `agreedBy?`, `verifierReasoning?`, `proof?` (proof = `{attempted, proven,
23
24
  outcome, reason, testCode?, testCommand?, redOutput?}`).
24
25
  - The saved review file path: `~/code-reviews/YYYY-MM-DD-pr-N-repo.md`.
@@ -154,7 +155,10 @@ Hard requirements for the HTML page:
154
155
  - **Findings**, grouped Critical → Important → Observations → Idiomatic. Each
155
156
  finding is an expandable `<details>` block:
156
157
  - summary row: severity dot + title + `file:line`
157
- - body: description, `badCode` in a `<pre>`, `fix` in a `<pre>`, a small
158
+ - body: description, `badCode` in a `<pre>`, `fix` in a `<pre>`, a
159
+ "Bug class: `bugClass`" line followed by "Also at:" and each
160
+ `otherInstances` entry as a `file:line` chip (omit "Also at:" when the
161
+ list is empty), a small
158
162
  hard-rule chip (e.g. "TS-1 · hard rule") when `rule` is set, `agreedBy`
159
163
  chips when 2+ dimensions agreed, `verifierReasoning` as a muted footnote.
160
164
  - **Proof block:** when `proof.proven === true`, render a visually distinct
@@ -43,12 +43,17 @@ path. The file is an object with a required `findings` array. Each finding is:
43
43
  "description": "failure mode and reachability",
44
44
  "badCode": "verbatim changed code",
45
45
  "fix": "minimal correction",
46
+ "bugClass": "kebab-case-defect-class",
47
+ "otherInstances": ["other/file.ts:17"],
46
48
  "rule": "optional house-rule tag",
47
49
  "precedent": "required for idiomatic findings"
48
50
  }
49
51
  ```
50
52
 
51
53
  `badCode` and `fix` may be empty only for Observation or Idiomatic findings.
54
+ `bugClass` and `otherInstances` are required: after the first hit, search the
55
+ whole diff for the same pattern and list every other place as a
56
+ repository-relative `file:line` (empty array when there is none).
52
57
  Implementation additionally returns `acCoverage`. Test-quality additionally
53
58
  returns `coverageVerdict`, `mutationSlip`, and `mockSmells`.
54
59
 
@@ -95,6 +100,8 @@ findings before spending verifier agents:
95
100
  - candidates must name the same file;
96
101
  - their anchor lines must fit within a 30-line total span;
97
102
  - normalized title-token Jaccard similarity must be at least 0.4;
103
+ - findings whose `bugClass` values differ never merge; merged findings union
104
+ their `otherInstances`;
98
105
  - keep the highest severity and longest `description`, `badCode`, `fix`, and
99
106
  `precedent` values;
100
107
  - union contributing dimensions into `agreedBy`;
@@ -202,8 +209,13 @@ to measurements the host actually exposes.
202
209
  Follow `references/fix-mode.md` for selection, preconditions, verification,
203
210
  commit, landing/push confirmation, and cleanup. Replace only its Workflow call:
204
211
 
205
- 1. Group eligible findings by primary file. Resolve cross-file dependencies
206
- before spawning; overlapping groups must be combined.
212
+ 1. Expand `otherInstances` first, as `fix-workflow.js` does: each entry that is
213
+ a validated repository-relative path (rules in item 5) naming a file changed
214
+ in the diff becomes a sibling finding with `instanceOf` set to the source
215
+ title, unless the same bug class already claims that `file:line`. Drop every
216
+ other entry and report it. Then group primary and sibling findings by
217
+ normalized file. Resolve cross-file dependencies before spawning;
218
+ overlapping groups must be combined.
207
219
  2. Spawn one `worker` fixer per disjoint group. Assign exact file ownership and
208
220
  state that other agents share the worktree; it must preserve their changes.
209
221
  The fixer reads `<SKILL_ROOT>/references/agents/fixer.md`, edits only its
@@ -101,6 +101,13 @@ downgrades a `good` verdict that arrives without verified evidence. Pass the ACs
101
101
  even when they look irrelevant to the finding: the finding's own rationale may
102
102
  be the thing that contradicts them.
103
103
 
104
+ A finding may carry `bugClass` and `otherInstances` (`file:line` list). The
105
+ workflow expands each other instance into a sibling finding (`instanceOf` set)
106
+ in its own file's group, so one pass fixes the whole class. An instance outside
107
+ the diff's changed files, in a generated file (Step 2 rule), or with an absolute
108
+ or `..` path, is dropped. Report siblings in
109
+ the Step 8 table under their own title.
110
+
104
111
  Pass `findings` as a real JSON array, not a stringified one. The workflow groups
105
112
  by file (one agent per file, so no two agents ever edit the same file), applies
106
113
  the fix, then runs a read-only fix-verifier over the actual `git diff`. A
@@ -115,8 +122,12 @@ the whole turn's pool. Report the former on the `Fix agents:` cost line.
115
122
 
116
123
  If Claude's Workflow tool is unavailable, fall back to one Agent per file group
117
124
  with `<SKILL_ROOT>/references/agents/fixer.md`, then one read-only Agent per
118
- group with `<SKILL_ROOT>/references/agents/fix-verifier.md`. Same rules, same
119
- verdict handling. State the fallback in the report.
125
+ group with `<SKILL_ROOT>/references/agents/fix-verifier.md`. Expand
126
+ `otherInstances` into sibling findings before grouping, with the same rules as
127
+ the workflow: only repository-relative paths (no leading `/`, drive letter,
128
+ backslash or `..`) that name a file changed in the diff, normalized so
129
+ `./src/a.ts` and `src/a.ts` form one group. Same rules, same verdict handling.
130
+ State the fallback in the report.
120
131
 
121
132
  ---
122
133
 
@@ -118,6 +118,8 @@ proof inline.>
118
118
  <corrected version: exact drop-in replacement or minimal diff>
119
119
  \```
120
120
 
121
+ **Bug class:** `<bugClass>` *(always shown)* · **Also at:** `<file:line>`, `<file:line>` *(from `otherInstances`; omit the clause when the list is empty)*
122
+
121
123
  **Proof (ran in the worktree):** *(include only when the finding carries a `proof` with `proven: true`)*
122
124
  \```
123
125
  <redOutput, verbatim>
@@ -97,6 +97,13 @@ function extractRule(title) {
97
97
  return m && HARD_RULES.has(m[1]) ? m[1] : undefined
98
98
  }
99
99
 
100
+ // revmux reports one location per finding and no class, so the class is derived
101
+ // from the rule tag or title and no other instances are claimed.
102
+ function bugClassOf(rule, title) {
103
+ const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
104
+ return words.slice(0, 6).join('-') || 'unclassified'
105
+ }
106
+
100
107
  function mapSeverity(f) {
101
108
  if (f.severity === 'critical') return 'critical'
102
109
  if (f.severity === 'major') return 'important'
@@ -131,6 +138,8 @@ for (const f of report.findings || []) {
131
138
  description: f.body,
132
139
  badCode,
133
140
  fix: f.fix,
141
+ bugClass: bugClassOf(rule, f.title),
142
+ otherInstances: [],
134
143
  confidence: f.confidence,
135
144
  rule,
136
145
  dimension: f.lenses || [],
@@ -171,6 +180,8 @@ for (const pe of report.pre_existing || []) {
171
180
  description: `Pre-existing: ${pe.body}`,
172
181
  badCode: extractBadCode(pe.body),
173
182
  fix: '',
183
+ bugClass: bugClassOf(null, pe.title),
184
+ otherInstances: [],
174
185
  dimension: pe.lenses || [],
175
186
  })
176
187
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olegkoval/agent-skills",
3
- "version": "1.63.0",
3
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -774,7 +774,7 @@ The workflow runs three phases:
774
774
  carries `proof: {attempted: false, reason: "no runnable test runner: ..."}`.
775
775
 
776
776
  Findings have schema:
777
- `{file, line, severity, title, description, badCode, fix, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
777
+ `{file, line, severity, title, description, badCode, fix, bugClass, otherInstances, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
778
778
  `badCode` and `fix` are schema-required: an empty string is allowed only on
779
779
  `observation` / `idiomatic` findings.
780
780
 
@@ -97,19 +97,124 @@ let retryCount = 0
97
97
  // same file concurrently.
98
98
  // ---------------------------------------------------------------------------
99
99
 
100
- const byFile = new Map()
101
- for (const f of findings) {
102
- if (!byFile.has(f.file)) {
103
- byFile.set(f.file, [])
100
+ // Paths come from model output, so they are untrusted. Accept only a lexically
101
+ // normalized repository-relative path; './src/a.ts' and 'src/a.ts' must land in
102
+ // one file group, or two fixers edit the same file concurrently.
103
+ function safeRelativePath(raw) {
104
+ const text = String(raw == null ? '' : raw)
105
+ if (!text || /[\\\u0000-\u001f]/.test(text) || text.startsWith('/') || /^[A-Za-z]:/.test(text)) {
106
+ return null
104
107
  }
105
- byFile.get(f.file).push(f)
108
+ const parts = text.split('/').filter(function(part) { return part !== '' && part !== '.' })
109
+ if (parts.length === 0 || parts.includes('..')) {
110
+ return null
111
+ }
112
+ return parts.join('/')
106
113
  }
107
114
 
108
- const groups = Array.from(byFile.entries()).map(function(entry) {
109
- return { file: entry[0], findings: entry[1] }
110
- })
115
+ function changedFilesFromDiff(diffText) {
116
+ const files = new Set()
117
+ for (const match of String(diffText || '').matchAll(/^\+\+\+ b\/(.+)$/gm)) {
118
+ const file = safeRelativePath(match[1])
119
+ if (file) {
120
+ files.add(file)
121
+ }
122
+ }
123
+ return files
124
+ }
125
+
126
+ // A finding names its bug class and the other places the same pattern occurs.
127
+ // Each other instance becomes a sibling finding in ITS file's group, so the whole
128
+ // class is fixed in one pass and the one-agent-per-file rule still holds.
129
+ function parseInstance(ref) {
130
+ const at = String(ref).lastIndexOf(':')
131
+ const line = Number(String(ref).slice(at + 1))
132
+ const file = at > 0 ? safeRelativePath(String(ref).slice(0, at)) : null
133
+ return file && Number.isInteger(line) && line > 0 ? { file, line } : null
134
+ }
135
+
136
+ // Step 2 of fix-mode.md skips generated primaries before the workflow runs, but
137
+ // siblings are created here, after that filter, so the same rule applies again.
138
+ function isGeneratedPath(file) {
139
+ return /(^|\/)generated\//.test(file)
140
+ || /\.snap$/.test(file)
141
+ || /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?)$/.test(file)
142
+ }
143
+
144
+ function classKey(f, file, line) {
145
+ return (f.bugClass || f.title) + '|' + file + ':' + line
146
+ }
147
+
148
+ // A sibling must point at a file the diff changed: an instance outside the diff
149
+ // would let a fixer edit, and commit, unrelated code.
150
+ function expandInstances(list, changedFiles) {
151
+ const taken = new Set(list.map(function(f) { return classKey(f, f.file, f.line) }))
152
+ const siblings = []
153
+ for (const f of list) {
154
+ for (const ref of (Array.isArray(f.otherInstances) ? f.otherInstances : [])) {
155
+ const spot = parseInstance(ref)
156
+ if (!spot || !changedFiles.has(spot.file) || isGeneratedPath(spot.file) || taken.has(classKey(f, spot.file, spot.line))) {
157
+ continue
158
+ }
159
+ taken.add(classKey(f, spot.file, spot.line))
160
+ siblings.push(Object.assign({}, f, {
161
+ file: spot.file,
162
+ line: spot.line,
163
+ title: f.title + ' (same bug class at ' + spot.file + ':' + spot.line + ')',
164
+ description: 'Same bug class' + (f.bugClass ? ' "' + f.bugClass + '"' : '') + ' as '
165
+ + f.file + ':' + f.line + '. ' + f.description,
166
+ otherInstances: [],
167
+ instanceOf: f.title,
168
+ }))
169
+ }
170
+ }
171
+ return list.concat(siblings)
172
+ }
173
+
174
+ function groupFindings(list, changedFiles) {
175
+ const primaries = list
176
+ .map(function(f) { return Object.assign({}, f, { file: safeRelativePath(f.file) }) })
177
+ .filter(function(f) { return f.file })
178
+ const all = expandInstances(primaries, changedFiles)
179
+ const byFile = new Map()
180
+ for (const f of all) {
181
+ if (!byFile.has(f.file)) {
182
+ byFile.set(f.file, [])
183
+ }
184
+ byFile.get(f.file).push(f)
185
+ }
186
+ return {
187
+ all,
188
+ rejected: list.length - primaries.length,
189
+ groups: Array.from(byFile.entries()).map(function(entry) {
190
+ return { file: entry[0], findings: entry[1] }
191
+ }),
192
+ }
193
+ }
194
+
195
+ function readChangedFiles() {
196
+ if (!diffFile) {
197
+ return new Set()
198
+ }
199
+ try {
200
+ return changedFilesFromDiff(readFileSync(diffFile, 'utf8'))
201
+ } catch (_) {
202
+ return new Set()
203
+ }
204
+ }
205
+
206
+ const changedFiles = readChangedFiles()
207
+ if (changedFiles.size === 0) {
208
+ log('no changed-file list from diffFile; same-class instances are not expanded')
209
+ }
210
+ const grouped = groupFindings(findings, changedFiles)
211
+ const allFindings = grouped.all
212
+ const groups = grouped.groups
213
+ if (grouped.rejected > 0) {
214
+ log(`rejected ${grouped.rejected} finding(s) with an absolute or traversing file path`)
215
+ }
111
216
 
112
- log(`fixing ${findings.length} finding(s) across ${groups.length} file(s)`)
217
+ log(`fixing ${allFindings.length} finding(s) (${findings.length} reported) across ${groups.length} file(s)`)
113
218
 
114
219
  // ---------------------------------------------------------------------------
115
220
  // Prompts
@@ -330,7 +435,7 @@ const groupsOut = results.filter(Boolean).map(function(state) {
330
435
  return {
331
436
  file: state.file,
332
437
  findings: state.findings.map(function(f) {
333
- return { title: f.title, line: f.line, severity: f.severity }
438
+ return { title: f.title, line: f.line, severity: f.severity, instanceOf: f.instanceOf }
334
439
  }),
335
440
  results: (state.fixResult && state.fixResult.results) || [],
336
441
  filesTouched: (state.fixResult && state.fixResult.filesTouched) || [],
@@ -74,6 +74,14 @@ outcome; a half-applied fix that breaks callers is the worst outcome.
74
74
 
75
75
  ---
76
76
 
77
+ ## Same-class instances
78
+
79
+ A finding with `instanceOf` is another occurrence of a bug class already
80
+ reported elsewhere. Treat it like any finding: confirm the pattern is really
81
+ there at that spot, apply the same fix adapted to local names and types, and
82
+ skip it (with the reason) when the code differs enough that the fix does not
83
+ carry over. Its `title` is its own join key.
84
+
77
85
  ## Multiple findings in one file
78
86
 
79
87
  Apply them in file order, top to bottom, re-reading after each edit so later
@@ -18,7 +18,8 @@ without creating a new artifact identity.
18
18
  All of the following are already in hand after Step 3 of SKILL.md:
19
19
 
20
20
  - `findings.json` path (scratchpad) -- each finding carries: `file`, `line`,
21
- `severity`, `title`, `description`, `badCode`, `fix`, `rule?`, `precedent?`,
21
+ `severity`, `title`, `description`, `badCode`, `fix`, `bugClass`,
22
+ `otherInstances`, `rule?`, `precedent?`,
22
23
  `agreedBy?`, `verifierReasoning?`, `proof?` (proof = `{attempted, proven,
23
24
  outcome, reason, testCode?, testCommand?, redOutput?}`).
24
25
  - The saved review file path: `~/code-reviews/YYYY-MM-DD-pr-N-repo.md`.
@@ -154,7 +155,10 @@ Hard requirements for the HTML page:
154
155
  - **Findings**, grouped Critical → Important → Observations → Idiomatic. Each
155
156
  finding is an expandable `<details>` block:
156
157
  - summary row: severity dot + title + `file:line`
157
- - body: description, `badCode` in a `<pre>`, `fix` in a `<pre>`, a small
158
+ - body: description, `badCode` in a `<pre>`, `fix` in a `<pre>`, a
159
+ "Bug class: `bugClass`" line followed by "Also at:" and each
160
+ `otherInstances` entry as a `file:line` chip (omit "Also at:" when the
161
+ list is empty), a small
158
162
  hard-rule chip (e.g. "TS-1 · hard rule") when `rule` is set, `agreedBy`
159
163
  chips when 2+ dimensions agreed, `verifierReasoning` as a muted footnote.
160
164
  - **Proof block:** when `proof.proven === true`, render a visually distinct
@@ -43,12 +43,17 @@ path. The file is an object with a required `findings` array. Each finding is:
43
43
  "description": "failure mode and reachability",
44
44
  "badCode": "verbatim changed code",
45
45
  "fix": "minimal correction",
46
+ "bugClass": "kebab-case-defect-class",
47
+ "otherInstances": ["other/file.ts:17"],
46
48
  "rule": "optional house-rule tag",
47
49
  "precedent": "required for idiomatic findings"
48
50
  }
49
51
  ```
50
52
 
51
53
  `badCode` and `fix` may be empty only for Observation or Idiomatic findings.
54
+ `bugClass` and `otherInstances` are required: after the first hit, search the
55
+ whole diff for the same pattern and list every other place as a
56
+ repository-relative `file:line` (empty array when there is none).
52
57
  Implementation additionally returns `acCoverage`. Test-quality additionally
53
58
  returns `coverageVerdict`, `mutationSlip`, and `mockSmells`.
54
59
 
@@ -95,6 +100,8 @@ findings before spending verifier agents:
95
100
  - candidates must name the same file;
96
101
  - their anchor lines must fit within a 30-line total span;
97
102
  - normalized title-token Jaccard similarity must be at least 0.4;
103
+ - findings whose `bugClass` values differ never merge; merged findings union
104
+ their `otherInstances`;
98
105
  - keep the highest severity and longest `description`, `badCode`, `fix`, and
99
106
  `precedent` values;
100
107
  - union contributing dimensions into `agreedBy`;
@@ -202,8 +209,13 @@ to measurements the host actually exposes.
202
209
  Follow `references/fix-mode.md` for selection, preconditions, verification,
203
210
  commit, landing/push confirmation, and cleanup. Replace only its Workflow call:
204
211
 
205
- 1. Group eligible findings by primary file. Resolve cross-file dependencies
206
- before spawning; overlapping groups must be combined.
212
+ 1. Expand `otherInstances` first, as `fix-workflow.js` does: each entry that is
213
+ a validated repository-relative path (rules in item 5) naming a file changed
214
+ in the diff becomes a sibling finding with `instanceOf` set to the source
215
+ title, unless the same bug class already claims that `file:line`. Drop every
216
+ other entry and report it. Then group primary and sibling findings by
217
+ normalized file. Resolve cross-file dependencies before spawning;
218
+ overlapping groups must be combined.
207
219
  2. Spawn one `worker` fixer per disjoint group. Assign exact file ownership and
208
220
  state that other agents share the worktree; it must preserve their changes.
209
221
  The fixer reads `<SKILL_ROOT>/references/agents/fixer.md`, edits only its
@@ -101,6 +101,13 @@ downgrades a `good` verdict that arrives without verified evidence. Pass the ACs
101
101
  even when they look irrelevant to the finding: the finding's own rationale may
102
102
  be the thing that contradicts them.
103
103
 
104
+ A finding may carry `bugClass` and `otherInstances` (`file:line` list). The
105
+ workflow expands each other instance into a sibling finding (`instanceOf` set)
106
+ in its own file's group, so one pass fixes the whole class. An instance outside
107
+ the diff's changed files, in a generated file (Step 2 rule), or with an absolute
108
+ or `..` path, is dropped. Report siblings in
109
+ the Step 8 table under their own title.
110
+
104
111
  Pass `findings` as a real JSON array, not a stringified one. The workflow groups
105
112
  by file (one agent per file, so no two agents ever edit the same file), applies
106
113
  the fix, then runs a read-only fix-verifier over the actual `git diff`. A
@@ -115,8 +122,12 @@ the whole turn's pool. Report the former on the `Fix agents:` cost line.
115
122
 
116
123
  If Claude's Workflow tool is unavailable, fall back to one Agent per file group
117
124
  with `<SKILL_ROOT>/references/agents/fixer.md`, then one read-only Agent per
118
- group with `<SKILL_ROOT>/references/agents/fix-verifier.md`. Same rules, same
119
- verdict handling. State the fallback in the report.
125
+ group with `<SKILL_ROOT>/references/agents/fix-verifier.md`. Expand
126
+ `otherInstances` into sibling findings before grouping, with the same rules as
127
+ the workflow: only repository-relative paths (no leading `/`, drive letter,
128
+ backslash or `..`) that name a file changed in the diff, normalized so
129
+ `./src/a.ts` and `src/a.ts` form one group. Same rules, same verdict handling.
130
+ State the fallback in the report.
120
131
 
121
132
  ---
122
133
 
@@ -118,6 +118,8 @@ proof inline.>
118
118
  <corrected version: exact drop-in replacement or minimal diff>
119
119
  \```
120
120
 
121
+ **Bug class:** `<bugClass>` *(always shown)* · **Also at:** `<file:line>`, `<file:line>` *(from `otherInstances`; omit the clause when the list is empty)*
122
+
121
123
  **Proof (ran in the worktree):** *(include only when the finding carries a `proof` with `proven: true`)*
122
124
  \```
123
125
  <redOutput, verbatim>
@@ -97,6 +97,13 @@ function extractRule(title) {
97
97
  return m && HARD_RULES.has(m[1]) ? m[1] : undefined
98
98
  }
99
99
 
100
+ // revmux reports one location per finding and no class, so the class is derived
101
+ // from the rule tag or title and no other instances are claimed.
102
+ function bugClassOf(rule, title) {
103
+ const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
104
+ return words.slice(0, 6).join('-') || 'unclassified'
105
+ }
106
+
100
107
  function mapSeverity(f) {
101
108
  if (f.severity === 'critical') return 'critical'
102
109
  if (f.severity === 'major') return 'important'
@@ -131,6 +138,8 @@ for (const f of report.findings || []) {
131
138
  description: f.body,
132
139
  badCode,
133
140
  fix: f.fix,
141
+ bugClass: bugClassOf(rule, f.title),
142
+ otherInstances: [],
134
143
  confidence: f.confidence,
135
144
  rule,
136
145
  dimension: f.lenses || [],
@@ -171,6 +180,8 @@ for (const pe of report.pre_existing || []) {
171
180
  description: `Pre-existing: ${pe.body}`,
172
181
  badCode: extractBadCode(pe.body),
173
182
  fix: '',
183
+ bugClass: bugClassOf(null, pe.title),
184
+ otherInstances: [],
174
185
  dimension: pe.lenses || [],
175
186
  })
176
187
  }
@@ -23,11 +23,13 @@ const BOUNDARY_SCHEMA = {
23
23
  },
24
24
  }
25
25
 
26
+ const INSTANCE_PATTERN_SOURCE = '^(?!/)(?!.*\\\\)(?![A-Za-z]:)(?!(?:.*/)?\\.\\.(?:/|:)).+:[1-9][0-9]*$'
27
+
26
28
  const FINDINGS_ARRAY_SCHEMA = {
27
29
  type: 'array',
28
30
  items: {
29
31
  type: 'object',
30
- required: ['file', 'line', 'severity', 'title', 'description', 'badCode', 'fix'],
32
+ required: ['file', 'line', 'severity', 'title', 'description', 'badCode', 'fix', 'bugClass', 'otherInstances'],
31
33
  anyOf: [
32
34
  { properties: { severity: { enum: ['observation', 'idiomatic'] } } },
33
35
  { required: ['boundary'] },
@@ -43,6 +45,11 @@ const FINDINGS_ARRAY_SCHEMA = {
43
45
  precedent: { type: 'string' },
44
46
  rule: { type: 'string', minLength: 1, pattern: '\\S' },
45
47
  boundary: BOUNDARY_SCHEMA,
48
+ // Short kebab-case slug naming the class of bug ("missing-null-check").
49
+ bugClass: { type: 'string', minLength: 1, pattern: '^[a-z0-9]+(-[a-z0-9]+)*$' },
50
+ // Other places in the diff with the same pattern, as "file:line".
51
+ // Repository-relative only: no leading slash, drive letter or '..' segment.
52
+ otherInstances: { type: 'array', items: { type: 'string', pattern: INSTANCE_PATTERN_SOURCE } },
46
53
  },
47
54
  },
48
55
  }
@@ -155,6 +162,10 @@ function titleTokens(title) {
155
162
  }
156
163
 
157
164
  function sameIssue(a, b) {
165
+ // Merging two classes would send one class's instances to the other's fix.
166
+ if (a.bugClass && b.bugClass && a.bugClass !== b.bugClass) {
167
+ return false
168
+ }
158
169
  const ta = titleTokens(a.title)
159
170
  const tb = titleTokens(b.title)
160
171
  if (ta.length === 0 || tb.length === 0) {
@@ -166,6 +177,29 @@ function sameIssue(a, b) {
166
177
  return (shared / union) >= 0.4
167
178
  }
168
179
 
180
+ const BUG_CLASS_INSTRUCTION = `BUG CLASS: give every finding a "bugClass" (short kebab-case slug for the`
181
+ + ` kind of defect, e.g. missing-null-check) and "otherInstances": after the first`
182
+ + ` hit, search the whole diff for the same pattern and list every other place as`
183
+ + ` "file:line" (empty array when there is none). Report the class, not just the first hit.`
184
+
185
+ const BUG_CLASS_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
186
+ const INSTANCE_PATTERN = new RegExp(INSTANCE_PATTERN_SOURCE)
187
+
188
+ function slugify(text) {
189
+ const words = String(text || '').toLowerCase().match(/[a-z0-9]+/g) || []
190
+ return words.slice(0, 6).join('-') || 'unclassified'
191
+ }
192
+
193
+ // Adapter findings skip the review schema, so the class fields are filled here:
194
+ // fix mode and every renderer rely on them being present and well-formed.
195
+ function withClassFields(f) {
196
+ return Object.assign({}, f, {
197
+ bugClass: BUG_CLASS_PATTERN.test(String(f.bugClass || '')) ? f.bugClass : slugify(f.rule || f.title),
198
+ otherInstances: (Array.isArray(f.otherInstances) ? f.otherInstances : [])
199
+ .filter(function(ref) { return typeof ref === 'string' && INSTANCE_PATTERN.test(ref) }),
200
+ })
201
+ }
202
+
169
203
  function longest(a, b) {
170
204
  const sa = a || ''
171
205
  const sb = b || ''
@@ -195,6 +229,8 @@ function mergeFindings(a, b) {
195
229
  merged.badCode = longest(a.badCode, b.badCode)
196
230
  merged.fix = longest(a.fix, b.fix)
197
231
  merged.precedent = longest(a.precedent, b.precedent)
232
+ merged.bugClass = a.bugClass || b.bugClass
233
+ merged.otherInstances = Array.from(new Set((a.otherInstances || []).concat(b.otherInstances || [])))
198
234
 
199
235
  if (!merged.rule && b.rule) {
200
236
  merged.rule = b.rule
@@ -558,6 +594,8 @@ const budgetAtStart = budget.spent()
558
594
  + ` number of DIFF_FILE itself. Quote the code at that line verbatim in "badCode".`,
559
595
  ].join(' ')
560
596
 
597
+ prompt += ' ' + BUG_CLASS_INSTRUCTION
598
+
561
599
  // The deterministic lint pre-flight has already collected every rule the
562
600
  // repo's own eslint config fires on this diff, and those are exactly the
563
601
  // comments the bot reviewers bill for. An agent re-reporting them costs a
@@ -748,9 +786,7 @@ const budgetAtStart = budget.spent()
748
786
 
749
787
  if (ENGINE === 'revmux') {
750
788
  const adapterOutput = JSON.parse(readFileSync(findingsFile, 'utf8'))
751
- finalFindings = (adapterOutput.findings || []).map(function(f) {
752
- return Object.assign({}, f)
753
- })
789
+ finalFindings = (adapterOutput.findings || []).map(withClassFields)
754
790
  droppedCount = adapterOutput.droppedCount || 0
755
791
  hardRuleCount = adapterOutput.hardRuleCount || 0
756
792
  agentCount = adapterOutput.agentCount || 0
@@ -847,6 +883,7 @@ const budgetAtStart = budget.spent()
847
883
  `Reason for re-examination: ${angle.reason}.`,
848
884
  `Apply the same diff-anchor and verification rules from ${promptDir}/verifier.md.`,
849
885
  `Return zero or one finding using DIFF_FILE=${diffFile} and WORKTREE_PATH=${wtDisplay}.`,
886
+ BUG_CLASS_INSTRUCTION,
850
887
  ].join(' '),
851
888
  {
852
889
  label: `critic-reexamine:${angle.file}:${angle.line}`,
@@ -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.63.0",
4
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.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.63.0",
4
+ "version": "1.64.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },