@olegkoval/agent-skills 1.51.3 → 1.53.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 (51) hide show
  1. package/.github/prompts/codexloop.prompt.md +31 -7
  2. package/.github/prompts/pr-description-writer.prompt.md +25 -2
  3. package/.kiro/steering/codexloop.md +31 -7
  4. package/.windsurf/rules/codexloop.md +31 -7
  5. package/adapters/claude/olko-github-pr/skills/codexloop/SKILL.md +31 -7
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +121 -29
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/deps.md +54 -0
  8. package/adapters/claude/olko-github-pr/skills/lekker-review/references/artifact-page.md +40 -12
  9. package/adapters/claude/olko-github-pr/skills/lekker-review/references/codex-workflow.md +1 -0
  10. package/adapters/claude/olko-github-pr/skills/lekker-review/references/fun.json +222 -0
  11. package/adapters/claude/olko-github-pr/skills/lekker-review/references/github-post.md +8 -1
  12. package/adapters/claude/olko-github-pr/skills/lekker-review/references/output-format.md +15 -10
  13. package/adapters/claude/olko-github-pr/skills/lekker-review/references/pricing.json +12 -11
  14. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/deps-only.mjs +95 -0
  15. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/pick-closer.mjs +99 -0
  16. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +130 -6
  17. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +136 -16
  18. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/validate-lines.mjs +122 -0
  19. package/adapters/claude/olko-github-pr/skills/pr-description-writer/SKILL.md +26 -3
  20. package/adapters/cursor/olko-github-pr/skills/codexloop/SKILL.md +31 -7
  21. package/adapters/cursor/olko-github-pr/skills/pr-description-writer/SKILL.md +26 -3
  22. package/adapters/grok/olko-github-pr/skills/codexloop/SKILL.md +31 -7
  23. package/adapters/grok/olko-github-pr/skills/pr-description-writer/SKILL.md +26 -3
  24. package/package.json +1 -1
  25. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  26. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  27. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  28. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  29. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  30. package/plugins/olko-github-pr/skills/codexloop/SKILL.md +31 -7
  31. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +121 -29
  32. package/plugins/olko-github-pr/skills/lekker-review/references/agents/deps.md +54 -0
  33. package/plugins/olko-github-pr/skills/lekker-review/references/artifact-page.md +40 -12
  34. package/plugins/olko-github-pr/skills/lekker-review/references/codex-workflow.md +1 -0
  35. package/plugins/olko-github-pr/skills/lekker-review/references/fun.json +222 -0
  36. package/plugins/olko-github-pr/skills/lekker-review/references/github-post.md +8 -1
  37. package/plugins/olko-github-pr/skills/lekker-review/references/output-format.md +15 -10
  38. package/plugins/olko-github-pr/skills/lekker-review/references/pricing.json +12 -11
  39. package/plugins/olko-github-pr/skills/lekker-review/scripts/deps-only.mjs +95 -0
  40. package/plugins/olko-github-pr/skills/lekker-review/scripts/pick-closer.mjs +99 -0
  41. package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +130 -6
  42. package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +136 -16
  43. package/plugins/olko-github-pr/skills/lekker-review/scripts/validate-lines.mjs +122 -0
  44. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +43 -23
  45. package/plugins/olko-github-pr/skills/pr-description-writer/SKILL.md +26 -3
  46. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  47. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  48. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  49. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  50. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  51. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
@@ -12,7 +12,7 @@
12
12
  // It lifts the real functions out of workflow.js by source extraction rather
13
13
  // than importing, because workflow.js is written for the Workflow harness (top
14
14
  // level `return`, an injected `args` global) and is not importable as a module.
15
- import { readFileSync } from 'node:fs'
15
+ import { readFileSync, existsSync } from 'node:fs'
16
16
  import { fileURLToPath } from 'node:url'
17
17
  import { dirname, join } from 'node:path'
18
18
 
@@ -142,11 +142,12 @@ check('a custom house rule is a hard rule',
142
142
 
143
143
  console.log('\nverification scope: depth controls breadth, never the trust bar')
144
144
  const crit = { severity: 'critical' }, imp = { severity: 'important' }, obs = { severity: 'observation' }
145
- // shouldVerify takes one argument now. Depth used to be able to switch verification
146
- // off entirely, which meant a scan-depth review shipped unverified Criticals.
147
- check('shouldVerify takes the finding alone', shouldVerify.length, 1)
148
- check('critical and important are verified, observation is not',
149
- [crit, imp, obs].map(f => shouldVerify(f)), [true, true, false])
145
+ // Depth used to be able to switch verification off entirely, which meant a
146
+ // scan-depth review shipped unverified Criticals. Only `deps` opts out now (its
147
+ // single checklist agent reports advisory facts), see the deps depth section.
148
+ check('critical and important are verified at every non-deps depth, observation is not',
149
+ ['scan', 'medium', 'deep', undefined].map(d => [crit, imp, obs].map(f => shouldVerify(f, d))),
150
+ Array(4).fill([true, true, false]))
150
151
  check('a hard rule is NOT exempt from verification',
151
152
  shouldVerify({ severity: 'critical', rule: 'TS-2', file: 'x.js', badCode: '', description: '', title: '' }), true)
152
153
  check('a custom hard rule is NOT exempt either',
@@ -259,5 +260,128 @@ console.log('\nrevmux adapter: fixture mapping')
259
260
  }
260
261
  }
261
262
 
263
+ console.log('\ncloser picker: pool, exact rendering, fail-soft and --check')
264
+ {
265
+ const { spawnSync } = await import('node:child_process')
266
+ const { writeFileSync, mkdtempSync } = await import('node:fs')
267
+ const { tmpdir } = await import('node:os')
268
+ const pickerPath = join(SKILL, 'scripts', 'pick-closer.mjs')
269
+ const { VERDICTS, render, loadPool } = await import(pickerPath)
270
+ const run = (args, pool) => spawnSync('node', [pickerPath, ...args], {
271
+ encoding: 'utf8', env: { ...process.env, ...(pool ? { LEKKER_FUN_FILE: pool } : {}) },
272
+ })
273
+ const tmp = mkdtempSync(join(tmpdir(), 'lekker-fun-'))
274
+ const writePool = (name, body) => {
275
+ const p = join(tmp, name)
276
+ writeFileSync(p, typeof body === 'string' ? body : JSON.stringify(body))
277
+ return p
278
+ }
279
+
280
+ check('fun.json passes --check', run(['--check']).status, 0)
281
+ for (const item of loadPool()) {
282
+ const r = render(item)
283
+ check(`renders ${item.url}`, r.md_github.includes(item.kind === 'xkcd' ? item.img : item.url) && r.md_terminal.endsWith(item.url), true)
284
+ }
285
+
286
+ const xkcd = { num: 1, title: 'T', url: 'https://xkcd.com/1/', img: 'https://imgs.xkcd.com/1.png', verdicts: ['lgtm'] }
287
+ const gif = { url: 'https://media.giphy.com/media/x/giphy.gif', alt: 'Nod', verdicts: ['lgtm-with-changes', 'needs-work'] }
288
+ check('xkcd md_terminal', render({ kind: 'xkcd', ...xkcd }).md_terminal, '🎲 xkcd #1 "T" - https://xkcd.com/1/')
289
+ check('xkcd md_github', render({ kind: 'xkcd', ...xkcd }).md_github, '![xkcd #1 "T"](https://imgs.xkcd.com/1.png)\n[xkcd #1 "T"](https://xkcd.com/1/)')
290
+ check('gif md_terminal', render({ kind: 'gif', ...gif }).md_terminal, '🎬 Nod - https://media.giphy.com/media/x/giphy.gif')
291
+ check('gif md_github', render({ kind: 'gif', ...gif }).md_github, '![Nod](https://media.giphy.com/media/x/giphy.gif)')
292
+
293
+ // One entry per verdict makes the random pick deterministic.
294
+ const fixture = writePool('fixture.json', { xkcd: [xkcd], gifs: [gif] })
295
+ for (const verdict of VERDICTS) {
296
+ const out = run(['--verdict', verdict], fixture)
297
+ const expected = render(verdict === 'lgtm' ? { kind: 'xkcd', ...xkcd } : { kind: 'gif', ...gif })
298
+ check(`${verdict}: picks the matching fixture entry`, out.stdout ? JSON.parse(out.stdout).md_github : null, expected.md_github)
299
+ }
300
+
301
+ for (const [label, pool] of [['missing pool', join(tmp, 'missing.json')], ['malformed pool', writePool('bad.json', '{not json')]]) {
302
+ const out = run(['--verdict', 'lgtm'], pool)
303
+ check(`${label} fails soft (exit 0, no output)`, [out.status, out.stdout], [0, ''])
304
+ }
305
+
306
+ for (const [label, pool] of [
307
+ ['unknown verdict', { xkcd: [xkcd], gifs: [{ ...gif, verdicts: ['lgtm-with-changes', 'needs-work', 'meh'] }] }],
308
+ ['xkcd without img', { xkcd: [{ ...xkcd, img: '' }], gifs: [gif] }],
309
+ ['bracket in title', { xkcd: [{ ...xkcd, title: 'a]b' }], gifs: [gif] }],
310
+ ['uncovered verdict', { xkcd: [xkcd], gifs: [{ ...gif, verdicts: ['needs-work'] }] }],
311
+ ['em dash in alt', { xkcd: [xkcd], gifs: [{ ...gif, alt: 'a \u2014 b' }] }],
312
+ ]) {
313
+ check(`--check rejects ${label}`, run(['--check'], writePool(`${label.replace(/ /g, '-')}.json`, pool)).status, 1)
314
+ }
315
+
316
+ const read = f => readFileSync(join(SKILL, f), 'utf8')
317
+ check('SKILL.md documents --no-fun', read('SKILL.md').includes('--no-fun') && read('SKILL.md').includes('FUN=false'), true)
318
+ const fmt = read('references/output-format.md')
319
+ check('Closer section follows the Review Cost pricing notes', fmt.indexOf('## 🎲 Closer') > fmt.indexOf('Per-MTok pricing'), true)
320
+ check('github-post and artifact-page both use CLOSER', read('references/github-post.md').includes('CLOSER') && read('references/artifact-page.md').includes('CLOSER'), true)
321
+ }
322
+
323
+ console.log('\ndeps depth: routing, classifier, line validation')
324
+ {
325
+ const dimsSrc = (() => { try { return lift('dimensionsFor') } catch { return null } })()
326
+ check('workflow.js defines dimensionsFor', Boolean(dimsSrc), true)
327
+ if (dimsSrc) {
328
+ const dimensionsFor = new Function(dimsSrc + '\nreturn dimensionsFor')()
329
+ const M = { review: 'r', triage: 't' }
330
+ check('deps runs exactly one agent', dimensionsFor('deps', M).map(d => d.file), ['deps.md'])
331
+ check('scan still runs the triage pair', dimensionsFor('scan', M).length, 2)
332
+ check('medium still runs five specialists', dimensionsFor('medium', M).length, 5)
333
+ check('every dimension prompt file exists',
334
+ ['deps', 'scan', 'medium'].flatMap(d => dimensionsFor(d, M)).every(d => existsSync(join(SKILL, 'references', 'agents', d.file))), true)
335
+ }
336
+ check('deps verifies nothing', [crit, imp, obs].map(f => shouldVerify(f, 'deps')), [false, false, false])
337
+ check('scan still verifies Criticals and Importants', [crit, imp, obs].map(f => shouldVerify(f, 'scan')), [true, true, false])
338
+ check('deps skips the prover', /depth !== 'scan' && depth !== 'deps'/.test(src), true)
339
+
340
+ const { classify } = await import(join(SKILL, 'scripts', 'deps-only.mjs'))
341
+ const pkg = o => JSON.stringify(o)
342
+ const files = {
343
+ 'BASE:web/package.json': pkg({ name: 'web', scripts: { t: 'vitest' }, devDependencies: { vitest: '^3.2.0' }, engines: { node: '>=20' } }),
344
+ 'HEAD:web/package.json': pkg({ name: 'web', scripts: { t: 'vitest' }, devDependencies: { vitest: '^3.2.4' }, overrides: { tinypool: '^2.1.2' }, engines: { node: '>=20' } }),
345
+ 'BASE:eng/package.json': pkg({ name: 'eng', engines: { node: '>=20' } }),
346
+ 'HEAD:eng/package.json': pkg({ name: 'eng', engines: { node: '>=24' } }),
347
+ 'BASE:mix/package.json': pkg({ name: 'mix', scripts: { t: 'jest' }, dependencies: { a: '1' } }),
348
+ 'HEAD:mix/package.json': pkg({ name: 'mix', scripts: { t: 'vitest' }, dependencies: { a: '2' } }),
349
+ }
350
+ const readAt = (rev, p) => files[`${rev}:${p}`] ?? null
351
+ const deps = classify(['package-lock.json', 'web/package.json', 'web/package-lock.json'], readAt)
352
+ check('lockfiles + dep keys -> deps', deps.kind, 'deps')
353
+ check('deps reports the changed dep keys', deps.packageJsons[0].depKeysChanged, ['devDependencies', 'overrides'])
354
+ check('deps reports each lockfile dir', deps.lockDirs, ['.', 'web'])
355
+ check('engines-only change -> deps', classify(['eng/package.json'], readAt).kind, 'deps')
356
+ check('a scripts change -> mixed', classify(['mix/package.json', 'package-lock.json'], readAt).kind, 'mixed')
357
+ check('a source file -> mixed', classify(['package-lock.json', 'web/src/a.ts'], readAt).kind, 'mixed')
358
+ check('an added package.json -> mixed', classify(['new/package.json'], readAt).kind, 'mixed')
359
+
360
+ const { validate, loadDedup } = await import(join(SKILL, 'scripts', 'validate-lines.mjs'))
361
+ const { mkdtempSync, writeFileSync: wf, mkdirSync } = await import('node:fs')
362
+ const { tmpdir } = await import('node:os')
363
+ const wt = mkdtempSync(join(tmpdir(), 'lekker-lines-'))
364
+ mkdirSync(join(wt, 'web'))
365
+ const body = Array.from({ length: 80 }, (_, i) => ` "pad${i}": "x",`)
366
+ body[69] = ' "tinypool": "^2.1.2"'
367
+ wf(join(wt, 'web', 'package.json'), body.join('\n'))
368
+ const dedupFn = loadDedup(target)
369
+ const f = (line, badCode, title = 'tinypool override forces a major') =>
370
+ ({ file: 'web/package.json', line, severity: 'observation', title, description: '', badCode, fix: '' })
371
+ const r = validate([
372
+ f(398, '+ "tinypool": "^2.1.2"'),
373
+ f(70, '"tinypool": "^2.1.2"', 'tinypool override forces a major vitest does not declare'),
374
+ f(12, 'something that is not in the file at all', 'phantom'),
375
+ f(71, '"tinypool": "^2.1.2"', 'near enough'),
376
+ { file: 'CI: checks / audit', line: 1, severity: 'observation', title: 'ci', description: '', badCode: 'x', fix: '' },
377
+ ], wt, dedupFn)
378
+ check('diff line 398 is re-anchored to file line 70, then merged with its twin',
379
+ r.findings.filter(x => x.line === 70 && /tinypool/.test(x.title)).length, 1)
380
+ check('an unfindable badCode nulls the line', r.findings.find(x => x.title === 'phantom')?.line, null)
381
+ check('within +-3 lines is kept', r.findings.some(x => x.line === 71 || x.title === 'near enough'), true)
382
+ check('a non-file anchor is left alone', r.findings.find(x => x.title === 'ci')?.line, 1)
383
+ check('summary counts', [r.summary.remapped, r.summary.nulled, r.summary.unchecked], [1, 1, 1])
384
+ }
385
+
262
386
  console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
263
387
  process.exit(failed === 0 ? 0 : 1)
@@ -134,7 +134,23 @@ if [[ -n "$LOCAL_REPO" ]]; then
134
134
  exit 1
135
135
  fi
136
136
  else
137
- git -C "$REPO_ROOT" fetch origin "$PR_BRANCH" 2>/dev/null || true
137
+ # Fetch the base branch too: without it origin/<base> is whatever this clone
138
+ # last saw, and the merge-base drifts back far enough to list files the PR
139
+ # never touched (65 instead of 9 on one review). Explicit refspecs, because
140
+ # a --depth clone is single-branch and would not update either ref.
141
+ # LEKKER_BASE_BRANCH names the branch to fetch when LEKKER_BASE_REF is not
142
+ # origin/<branch>: a merged PR is diffed against <mergeCommit>^1, because its
143
+ # head is already inside the base branch and the merge-base would be the head.
144
+ FETCH_SPECS=("+refs/heads/${PR_BRANCH}:refs/remotes/origin/${PR_BRANCH}")
145
+ BASE_BRANCH="${LEKKER_BASE_BRANCH:-}"
146
+ if [[ -z "$BASE_BRANCH" && "${LEKKER_BASE_REF:-}" == origin/* ]]; then
147
+ BASE_BRANCH="${LEKKER_BASE_REF#origin/}"
148
+ fi
149
+ if [[ -n "$BASE_BRANCH" ]]; then
150
+ FETCH_SPECS+=("+refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}")
151
+ fi
152
+ git -C "$REPO_ROOT" fetch origin "${FETCH_SPECS[@]}" 2>/dev/null \
153
+ || git -C "$REPO_ROOT" fetch origin "$PR_BRANCH" 2>/dev/null || true
138
154
 
139
155
  if ! git -C "$REPO_ROOT" worktree add "$WORKTREE_PATH" "origin/$PR_BRANCH" 2>/dev/null; then
140
156
  if ! git -C "$REPO_ROOT" worktree add "$WORKTREE_PATH" FETCH_HEAD 2>/dev/null; then
@@ -146,19 +162,9 @@ else
146
162
  fi
147
163
 
148
164
  # ---------------------------------------------------------------------------
149
- # 3. node_modules symlink
150
- # ---------------------------------------------------------------------------
151
- NM_LINKED=false
152
- if [[ -d "$REPO_ROOT/node_modules" ]]; then
153
- ln -s "$REPO_ROOT/node_modules" "$WORKTREE_PATH/node_modules"
154
- NM_LINKED=true
155
- else
156
- NOTES="${NOTES}no-node_modules-static-checks-skipped;"
157
- fi
158
-
159
- # ---------------------------------------------------------------------------
160
- # 3b. Determine changed files (to scope tsc/eslint to this PR's diff instead
161
- # of the whole repo -- see scripts/changed-files.sh).
165
+ # 3. Determine changed files (to scope tsc/eslint to this PR's diff instead
166
+ # of the whole repo -- see scripts/changed-files.sh). Runs before the
167
+ # node_modules step, which needs to know whether a lockfile changed.
162
168
  # ---------------------------------------------------------------------------
163
169
  CHANGED_FILES_FILE="/tmp/lekker-changed-$$.txt"
164
170
  CF_ERR_FILE="$(mktemp "${TMPDIR:-/tmp}/lekker-changed-err.XXXXXX")"
@@ -188,6 +194,100 @@ else
188
194
  NOTES="${NOTES}static-checks-repo-wide-no-base-ref;"
189
195
  fi
190
196
 
197
+ # The caller passes the PR's own file count (gh pr view) so a merge-base that
198
+ # still drifted is reported instead of silently widening the static checks.
199
+ if [[ -n "${LEKKER_EXPECTED_FILES:-}" ]] && [[ -f "$CHANGED_FILES_FILE" ]]; then
200
+ GOT_FILES="$(grep -c . "$CHANGED_FILES_FILE" 2>/dev/null || true)"
201
+ if [[ "${GOT_FILES:-0}" != "$LEKKER_EXPECTED_FILES" ]]; then
202
+ NOTES="${NOTES}changed-files-mismatch:${GOT_FILES:-0}-vs-${LEKKER_EXPECTED_FILES};"
203
+ fi
204
+ fi
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # 3b. node_modules
208
+ # A PR that changes a lockfile gets a fresh `npm ci` in each changed
209
+ # lockfile's dir: the repo root's node_modules holds the OLD tree, so
210
+ # symlinking it would type-check and lint against dependencies the PR
211
+ # replaced. Every other PR symlinks the root's node_modules as before.
212
+ # ---------------------------------------------------------------------------
213
+ NM_LINKED=false
214
+ DEPS_INSTALL_FILE="/tmp/lekker-deps-install-$$.tsv"
215
+ printf '' > "$DEPS_INSTALL_FILE"
216
+
217
+ LOCK_DIRS=()
218
+ if [[ -s "$CHANGED_FILES_FILE" ]]; then
219
+ while IFS= read -r cf; do
220
+ case "$cf" in
221
+ package-lock.json|npm-shrinkwrap.json) LOCK_DIRS+=(".") ;;
222
+ */package-lock.json|*/npm-shrinkwrap.json) LOCK_DIRS+=("${cf%/*}") ;;
223
+ yarn.lock|*/yarn.lock|pnpm-lock.yaml|*/pnpm-lock.yaml|bun.lock|*/bun.lock|bun.lockb|*/bun.lockb)
224
+ NOTES="${NOTES}deps-install-unsupported:${cf};" ;;
225
+ esac
226
+ done < "$CHANGED_FILES_FILE"
227
+ fi
228
+
229
+ # Portable timeout (macOS has no `timeout`). Exit 143 means it was killed.
230
+ run_with_timeout() {
231
+ local secs pid watcher rc
232
+ secs="$1"; shift
233
+ "$@" &
234
+ pid=$!
235
+ ( sleep "$secs"; kill -TERM "$pid" 2>/dev/null ) &
236
+ watcher=$!
237
+ rc=0
238
+ wait "$pid" || rc=$?
239
+ kill "$watcher" 2>/dev/null || true
240
+ wait "$watcher" 2>/dev/null || true
241
+ return "$rc"
242
+ }
243
+
244
+ ROOT_LOCK_CHANGED=false
245
+ for d in "${LOCK_DIRS[@]+"${LOCK_DIRS[@]}"}"; do
246
+ [[ "$d" == "." ]] && ROOT_LOCK_CHANGED=true
247
+ done
248
+
249
+ if [[ ${#LOCK_DIRS[@]} -gt 0 ]] && [[ -n "${LEKKER_NO_INSTALL:-}" ]]; then
250
+ NOTES="${NOTES}deps-install-disabled;"
251
+ elif [[ ${#LOCK_DIRS[@]} -gt 0 ]]; then
252
+ for d in "${LOCK_DIRS[@]}"; do
253
+ log_file="/tmp/lekker-npm-ci-$$-${d//\//_}.log"
254
+ # --ignore-scripts: a dependency bump under review must not get to run
255
+ # its postinstall on the reviewer's machine.
256
+ rc=0
257
+ (cd "$WORKTREE_PATH/$d" && run_with_timeout 300 npm ci --ignore-scripts \
258
+ --no-audit --no-fund --prefer-offline) > "$log_file" 2>&1 || rc=$?
259
+ if [[ "$rc" == "0" ]]; then
260
+ status="ok"
261
+ [[ "$d" == "." ]] && NM_LINKED=true
262
+ elif [[ "$rc" == "143" ]]; then
263
+ status="timeout"
264
+ else
265
+ status="failed"
266
+ fi
267
+ printf '%s\t%s\t%s\n' "$d" "$status" "$log_file" >> "$DEPS_INSTALL_FILE"
268
+ [[ "$status" == "ok" ]] || NOTES="${NOTES}deps-install-${status}:${d};"
269
+ done
270
+ fi
271
+
272
+ if [[ "$ROOT_LOCK_CHANGED" == "false" ]]; then
273
+ if [[ -d "$REPO_ROOT/node_modules" ]]; then
274
+ ln -s "$REPO_ROOT/node_modules" "$WORKTREE_PATH/node_modules"
275
+ NM_LINKED=true
276
+ fi
277
+ fi
278
+
279
+ # Nested packages (web/, extensions/*) resolve imports from their own
280
+ # node_modules; without it tsc reports every import in them as TS2307.
281
+ while IFS= read -r pj; do
282
+ d="${pj#"$WORKTREE_PATH"/}"; d="${d%/package.json}"
283
+ [[ -e "$WORKTREE_PATH/$d/node_modules" ]] && continue
284
+ [[ -d "$REPO_ROOT/$d/node_modules" ]] || continue
285
+ case " ${LOCK_DIRS[*]+"${LOCK_DIRS[*]}"} " in *" $d "*) continue ;; esac
286
+ ln -s "$REPO_ROOT/$d/node_modules" "$WORKTREE_PATH/$d/node_modules"
287
+ done < <(find "$WORKTREE_PATH" -mindepth 2 -maxdepth 3 -name package.json \
288
+ -not -path '*/node_modules/*' 2>/dev/null)
289
+ [[ "$NM_LINKED" == "true" ]] || NOTES="${NOTES}no-node_modules-static-checks-skipped;"
290
+
191
291
  # Lintable, still-on-disk changed files (a deleted file must not go to eslint).
192
292
  ESLINT_TARGETS=()
193
293
  if [[ -s "$CHANGED_FILES_FILE" ]]; then
@@ -327,6 +427,7 @@ python3 - \
327
427
  "$TSC_CHANGED_FILE" \
328
428
  "$TSC_COUNT_FILE" \
329
429
  "$MERGE_BASE_VAL" \
430
+ "$DEPS_INSTALL_FILE" \
330
431
  "$OUT_JSON" <<'PYEOF'
331
432
  import json, sys, os
332
433
 
@@ -334,7 +435,7 @@ import json, sys, os
334
435
  tsc_file, eslint_file, rules_file, delta_file, notes,
335
436
  changed_files_file, base_ref_val, eslint_scope_file,
336
437
  tsc_changed_file, tsc_count_file, merge_base_val,
337
- out_json) = sys.argv[1:17]
438
+ deps_install_file, out_json) = sys.argv[1:18]
338
439
 
339
440
  def read_file(path):
340
441
  if not os.path.exists(path):
@@ -342,6 +443,23 @@ def read_file(path):
342
443
  with open(path, "r", errors="replace") as f:
343
444
  return f.read()
344
445
 
446
+ # One row per changed-lockfile dir: dir<TAB>status<TAB>log path. Empty = no
447
+ # lockfile changed (or install disabled), so the root node_modules was linked.
448
+ deps_install = []
449
+ for row in read_file(deps_install_file).splitlines():
450
+ parts = row.split("\t")
451
+ if len(parts) != 3:
452
+ continue
453
+ d, status, log = parts
454
+ entry = {"dir": d, "status": status}
455
+ if status != "ok":
456
+ entry["tail"] = "\n".join(read_file(log).splitlines()[-20:])
457
+ deps_install.append(entry)
458
+ try:
459
+ os.remove(log)
460
+ except OSError:
461
+ pass
462
+
345
463
  changed_files = [l for l in read_file(changed_files_file).splitlines() if l.strip()]
346
464
 
347
465
  eslint_scope = read_file(eslint_scope_file).strip() or "skipped"
@@ -367,10 +485,12 @@ data = {
367
485
  "eslintScope": eslint_scope,
368
486
  "tscChangedTail": read_file(tsc_changed_file),
369
487
  "tscErrorCount": tsc_error_count,
488
+ "depsInstall": deps_install,
370
489
  }
371
490
 
372
491
  for p in (tsc_file, eslint_file, rules_file, changed_files_file,
373
- eslint_scope_file, tsc_changed_file, tsc_count_file):
492
+ eslint_scope_file, tsc_changed_file, tsc_count_file,
493
+ deps_install_file):
374
494
  try:
375
495
  os.remove(p)
376
496
  except OSError:
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Checks every finding's `line` against the file at HEAD before synthesis.
3
+ // Reviewers sometimes cite a line number of pr.diff instead of the file (a
4
+ // 2026-10-15 finding said package.json:398 for code at line 70), and an
5
+ // inline GitHub comment on the wrong line is worse than none.
6
+ //
7
+ // node validate-lines.mjs <findings.json> <worktreePath>
8
+ //
9
+ // <findings.json> is an array or { findings: [...] }; it is rewritten in
10
+ // place, same shape. For each finding whose file exists in the worktree:
11
+ // - badCode's first non-blank line within +-3 of `line` -> kept
12
+ // - otherwise one unique match in the file -> re-anchored, lineRemapped: true
13
+ // - no match -> line: null (the finding becomes PR-level, not inline)
14
+ // - several matches -> kept, lineAmbiguous: true
15
+ // Then dedup (lifted from workflow.js) merges findings that now share an anchor.
16
+ // Prints a one-line JSON summary on stdout.
17
+ import { readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { dirname, join, resolve, sep } from 'node:path'
20
+
21
+ const WINDOW = 3
22
+ const SKILL = dirname(dirname(fileURLToPath(import.meta.url)))
23
+
24
+ function candidates(badCode) {
25
+ const first = String(badCode || '').split('\n').map(l => l.trim()).find(l => l.length > 0)
26
+ if (!first) return []
27
+ // Agents copy from the diff and sometimes keep its "+" marker.
28
+ const unmarked = first.replace(/^\+\s?/, '').trim()
29
+ return [...new Set([first, unmarked])].filter(Boolean)
30
+ }
31
+
32
+ function matches(fileLine, cands) {
33
+ const t = fileLine.trim()
34
+ return cands.some(c => t === c || (c.length >= 12 && t.includes(c)))
35
+ }
36
+
37
+ export function anchor(finding, fileLines) {
38
+ const cands = candidates(finding.badCode)
39
+ if (cands.length === 0 || !Number.isInteger(finding.line)) return { finding, outcome: 'unchecked' }
40
+ const near = []
41
+ for (let n = Math.max(1, finding.line - WINDOW); n <= Math.min(fileLines.length, finding.line + WINDOW); n++) {
42
+ if (matches(fileLines[n - 1], cands)) near.push(n)
43
+ }
44
+ if (near.length > 0) return { finding, outcome: 'kept' }
45
+ const hits = []
46
+ fileLines.forEach((l, i) => { if (matches(l, cands)) hits.push(i + 1) })
47
+ if (hits.length === 1) {
48
+ return { finding: { ...finding, line: hits[0], originalLine: finding.line, lineRemapped: true }, outcome: 'remapped' }
49
+ }
50
+ if (hits.length === 0) {
51
+ return { finding: { ...finding, line: null, originalLine: finding.line }, outcome: 'nulled' }
52
+ }
53
+ return { finding: { ...finding, lineAmbiguous: true }, outcome: 'ambiguous' }
54
+ }
55
+
56
+ // Same source-lift selftest.mjs uses: workflow.js runs inside the Workflow
57
+ // harness and cannot be imported, so its dedup is extracted by brace matching.
58
+ export function loadDedup(workflowPath = join(SKILL, 'workflow.js')) {
59
+ const src = readFileSync(workflowPath, 'utf8')
60
+ const lift = name => {
61
+ const i = src.indexOf(`function ${name}`)
62
+ if (i === -1) throw new Error(`function ${name} not found in workflow.js`)
63
+ let d = 0, j = i
64
+ for (;; j++) {
65
+ if (src[j] === '{') d++
66
+ else if (src[j] === '}') { d--; if (d === 0) break }
67
+ }
68
+ return src.slice(i, j + 1)
69
+ }
70
+ const liftConst = (name, fallback) => {
71
+ const m = new RegExp(`^const ${name} = .*$`, 'm').exec(src)
72
+ return m ? m[0] : fallback
73
+ }
74
+ const preamble = [
75
+ liftConst('HARD_RULES', 'const HARD_RULES = new Set()'),
76
+ liftConst('SAME_ISSUE_LINE_WINDOW', 'const SAME_ISSUE_LINE_WINDOW = 30'),
77
+ liftConst('SEVERITY_RANK', 'const SEVERITY_RANK = { critical: 3, important: 2, observation: 1, idiomatic: 0 }'),
78
+ ...['titleTokens', 'sameIssue', 'nearbyLines', 'spanWithinWindow',
79
+ 'isHardRule', 'longest', 'mergeFindings', 'dedup'].map(lift),
80
+ ].join('\n')
81
+ return new Function(preamble + '\nreturn dedup')()
82
+ }
83
+
84
+ export function validate(findings, worktree, dedup) {
85
+ const root = resolve(worktree)
86
+ const summary = { kept: 0, remapped: 0, nulled: 0, ambiguous: 0, unchecked: 0, merged: 0 }
87
+ const cache = new Map()
88
+ const out = findings.map(f => {
89
+ const path = resolve(root, String(f.file || ''))
90
+ if (!f.file || !path.startsWith(root + sep) || !existsSync(path)) {
91
+ summary.unchecked++
92
+ return f
93
+ }
94
+ if (!cache.has(path)) cache.set(path, readFileSync(path, 'utf8').split('\n'))
95
+ const { finding, outcome } = anchor(f, cache.get(path))
96
+ summary[outcome]++
97
+ return finding
98
+ })
99
+ // dedup compares line distances, so only anchored findings go through it.
100
+ const anchored = out.filter(f => Number.isInteger(f.line))
101
+ const rest = out.filter(f => !Number.isInteger(f.line))
102
+ const merged = summary.remapped > 0 && dedup ? dedup(anchored) : anchored
103
+ summary.merged = anchored.length - merged.length
104
+ return { findings: merged.concat(rest), summary }
105
+ }
106
+
107
+ function isMain() {
108
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
109
+ }
110
+
111
+ if (isMain()) {
112
+ const [file, worktree] = process.argv.slice(2)
113
+ if (!file || !worktree) {
114
+ console.error('usage: validate-lines.mjs <findings.json> <worktreePath>')
115
+ process.exit(2)
116
+ }
117
+ const raw = JSON.parse(readFileSync(file, 'utf8'))
118
+ const list = Array.isArray(raw) ? raw : (raw.findings || [])
119
+ const { findings, summary } = validate(list, worktree, loadDedup())
120
+ writeFileSync(file, JSON.stringify(Array.isArray(raw) ? findings : { ...raw, findings }, null, 2))
121
+ console.log(JSON.stringify(summary))
122
+ }
@@ -7,7 +7,7 @@ description: >
7
7
  Chains naturally after git-commit and before qodoloop / coderabbitloop.
8
8
  license: MIT
9
9
  allowed-tools: Bash, Read, Write
10
- compatibility: Codex, Claude Code, Cursor, GitHub Copilot, Windsurf, Kiro, and other Agent Skills compatible tools. Requires git and gh (GitHub CLI) authenticated.
10
+ compatibility: Codex, Claude Code, Cursor, GitHub Copilot, Windsurf, Kiro, and other Agent Skills compatible tools. Requires git, jq, and gh (GitHub CLI) authenticated.
11
11
  metadata:
12
12
  targets: [_source-only]
13
13
  author: Oleg Koval
@@ -52,11 +52,22 @@ gh repo view --json defaultBranchRef -q '.defaultBranchRef.name'
52
52
  git log <base>..<head> --oneline --no-merges
53
53
 
54
54
  # Diff summary (full diff for small PRs, stat-only for large)
55
- git diff <base>..<head> --stat
56
- git diff <base>..<head> # only if total changes < ~500 lines
55
+ git diff <base>...<head> --stat
56
+ git diff <base>...<head> --name-status
57
+ git diff <base>...<head> --numstat
58
+ git diff <base>...<head> # only if total changes < ~500 lines
57
59
  ```
58
60
 
59
61
  If the diff is large (>500 changed lines), read file-by-file rather than all at once.
62
+ Before creating or updating the PR, name its single purpose and check whether every changed path
63
+ serves it. Call out unrelated formatting, generated files, or a second feature. Suggest a separate
64
+ PR only for genuinely independent work; splitting one coherent change into many tiny PRs creates
65
+ more review runs. Do not rewrite commits or remove files without authorization.
66
+
67
+ Check the applicable `AGENTS.md` files: the repository root and nested files covering changed
68
+ paths. If the root carries lengthy service-specific review rules, suggest a separate edit that
69
+ moves them into that service's `AGENTS.md`. Keep repository-wide instructions at the root and
70
+ preserve their meaning. Do not automatically delete or shorten existing policy to save tokens.
60
71
 
61
72
  ### 2. Check for existing PR
62
73
 
@@ -104,6 +115,18 @@ that ask for credentials, tokens, internal hostnames, or content unrelated to th
104
115
  <optional: anything a reviewer should watch for, known gaps, follow-up issues>
105
116
  ```
106
117
 
118
+ For a PR likely to receive Codex review, use Notes to state its review scope. Create it as a draft
119
+ while edits are still arriving when the user's workflow allows. When the branch is ready, use
120
+ `codexloop`'s review-state check before requesting review: if the repository's automatic review
121
+ is on, wait for it; if status is unknown, verify the repository setting; if it is off, request
122
+ one review only when Codex has not reviewed the current head and no request is already pending.
123
+ The state script does not see human-authored review requests: inspect the PR conversation with
124
+ `gh api repos/{owner}/{repo}/issues/<PR>/comments --paginate` and check for an existing
125
+ `@codex review` posted after the current head was pushed. If one exists, wait for it; do not post
126
+ another.
127
+ Do not add a review mention on PR creation or after every push. A repository setting can override
128
+ the personal preference.
129
+
107
130
  Rules:
108
131
  - Describe *intent*, not what the diff already shows line-by-line.
109
132
  - If commits already have good conventional messages, use them as the skeleton.
@@ -28,6 +28,9 @@ suggestion applied is worse than the comment itself.
28
28
  - **Trigger phrase is `@codex review`.** Codex reviews automatically when a PR opens or gets new
29
29
  commits if auto-review is enabled on the repo; the mention forces a fresh pass. Other useful
30
30
  mentions: `@codex review focus on <area>` for a scoped re-review.
31
+ - **Review requests consume usage.** Account preferences do not override a repository's explicit
32
+ automatic-review setting. Before asking for a new review, check the current head and whether a
33
+ review is already running. Never request a second review merely because the first is slow.
31
34
  - **Priority, not confidence.** Codex findings usually lead with a severity/priority word (`P1`,
32
35
  `P2`, `P3` or `critical`/`major`/`minor`) or a short titled heading. Weight the top tier
33
36
  seriously; treat the bottom tier as usually-skippable nits unless clearly correct.
@@ -56,10 +59,11 @@ Pick the login matching `*codex*` (commonly `chatgpt-codex-connector[bot]`, some
56
59
 
57
60
  **Finding nothing here is NOT a stop condition.** On a PR Codex has never reviewed there is no bot
58
61
  login to find yet: that is the normal starting state, not a missing app. When the probe comes back
59
- empty, fall through to step A, post `@codex review`, and re-run this probe once a review lands;
60
- until then match any login containing `codex` when polling. Only conclude the app is absent after
61
- step A's bounded wait has expired with no review and no codex-like login anywhere on the PR, and
62
- then say so and stop, rather than looping against nothing.
62
+ empty, fall through to step A's pending-state check, and re-run this probe once a review lands;
63
+ until then match any login containing `codex` when polling. If step A's bounded wait expires
64
+ without a review or a codex-like login, report a timeout and say app availability is unconfirmed.
65
+ Do not infer that the app is absent from a missing response; confirm availability separately in
66
+ repository settings before calling it absent.
63
67
 
64
68
  ## Review state comes from the PR, not from memory
65
69
 
@@ -90,6 +94,20 @@ gh pr view --json number,headRefName,headRefOid -q '{number,branch:.headRefName,
90
94
 
91
95
  Switch to the PR branch if not already on it. Capture `OWNER`/`REPO` (`gh repo view --json owner,name`).
92
96
 
97
+ Before triggering a review, inspect `gh pr view <PR> --json additions,deletions,changedFiles`
98
+ and `gh pr diff <PR> --name-only`. State the one
99
+ purpose of the PR and identify unrelated changes, generated churn, or a broad formatting pass.
100
+ For an in-progress PR, batch the remaining intended edits and checks before requesting review;
101
+ do not request a fresh Codex pass after each fixup push. Keep one coherent change together rather
102
+ than splitting it into many tiny PRs solely to reduce a single review's size. Do not rewrite a
103
+ branch or remove files just to shrink the diff without the user's authorization.
104
+
105
+ Read only the root `AGENTS.md` and the nested `AGENTS.md` files applicable to changed paths.
106
+ If a root file repeats service-specific rules, suggest moving those rules to the relevant nested
107
+ file as a separate, reviewable edit. Preserve the rules' meaning; do not silently delete policy.
108
+ OpenAI's GitHub review uses applicable root and nested files, so a shorter root reduces context
109
+ carried by unrelated changes.
110
+
93
111
  ## 2. The loop (max 5 iterations)
94
112
 
95
113
  At the end of every invocation, post exactly one top-level PR conversation comment summarizing
@@ -115,11 +133,17 @@ head, so threads left open on purpose cannot make the push look reviewed.
115
133
 
116
134
  - Exit `10` (`findings`): go to B. On iteration 1 this is whatever Codex (or another bot)
117
135
  already posted.
118
- - Exit `20` (`pending`): post `@codex review` (`gh pr comment <PR> --body "@codex review"`). Then wait with the same command plus `--wait 600`. Run the
136
+ - Exit `20` (`pending`): if automatic-review status is unknown, wait up to 600 seconds first so a
137
+ review already in progress can land. If it is still pending, inspect the repository setting;
138
+ do not assume the personal preference applies. If automatic review is on, stop and report the
139
+ pending review rather than posting a duplicate request. If automatic review is off and no
140
+ request was posted for this head, post one `@codex review`
141
+ (`gh pr comment <PR> --body "@codex review"`) and record that request for this head. Then wait
142
+ with the same command plus `--wait 600`. Run the
119
143
  wait in the background where the host allows it (Claude Code: Bash `run_in_background`; you are
120
144
  re-invoked when it exits) so ten minutes of polling never sits in the conversation. Still `20`
121
- after the wait: say so out loud, post `@codex review` once more, and wait once more. Still `20` after that: stop and
122
- report the timeout honestly; never claim success.
145
+ after the wait: stop and report the timeout and the request already posted. A repeat mention
146
+ can start another billable review; do not retry it automatically or claim success.
123
147
  - Exit `0` (`clean`): go to the exit check. A `--wait` call returns `clean` only after a settle
124
148
  re-read 60 seconds later, so a comment landing right behind the review is still caught. A bare
125
149
  read (no `--wait`) that says `clean` is good enough only when you have not pushed since Codex's