SLOPSHOPPER

method-review

Notices when Claude edits method code (genetics, statistics, estimation, simulation) and holds the first git commit or push until a second model has reviewed…

newguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-21samuelbfernandes/moddeck/mods/method-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · method-review
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /method-review ⎿ method-review: method-review · on · nothing waiting for review ⎿ method-review: Watching paths with: blup, herit, selection, meiosis, variance, kinship, relationship, likelihood, estimat, si ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

method-review

Install: /plugin install method-review --marketplace samuelbfernandes/moddeck

Enforces one rule: the model that wrote method code is never its own reviewer. When Claude edits method code, the first git commit or git push is held once until a second model has looked at it.

What it does

  • After a successful Edit, Write, MultiEdit or NotebookEdit, a code file (.R, .rs, .py, .jl, .cpp, .Rmd, .qmd...) whose path holds one of these words (case ignored) is remembered as unreviewed: blup, herit, selection, meiosis, variance, kinship, relationship, likelihood, estimat, simulat, gwas, effects. Files under tests/ or docs/ do not count. The first one adds a one-line note for the model: use the review skill before committing.
  • The first git commit or git push while any file is unreviewed is refused once, with a toast and a reason. Running the command again goes ahead. Nothing else is ever blocked, and nothing is edited.
  • A review clears the list: the review skill, codex exec / codex review, dev/dual.sh, or /method-review reviewed.

Use

/method-review status: what is waiting, and the words watched /method-review add pedigree watch paths holding this word (kept across sessions) /method-review remove effects stop watching a word /method-review reviewed mark everything reviewed /method-review on | off

Limits

It matches path words, not what the code does: a method file with an unlisted name is missed, and an unrelated file with a listed word is flagged. Tune the words with add and remove. Costs no tokens beyond the one note.

State for the moddeck side bar

method-review → isOff, pending (see types/index.d.ts).

Source 3 files
hooks/register.tsx 124 lines
1// method-review: when Claude edits method code (a code file whose path holds a word such as blup, herit, selection,
2// variance), the file is remembered as unreviewed. The first `git commit` or `git push` while any is unreviewed is held
3// once with a short reason; running the command again goes ahead. A review clears the list: the `review` skill, a Codex
4// review (`codex exec` / `codex review`), or `dev/dual.sh`, or `/method-review reviewed`.
5// It looks only at tool calls and never edits anything. FAIL OPEN: any error lets the call through unchanged.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import { DEFAULT_WORDS, isEditTool, isMethodPath, isReviewCommand, isReviewSkill, isSaveCommand, shortList } from './lib'
10
11type Dollar = EngineInterface
12
13const COMMAND = 'method-review'
14const MAX_PENDING = 50
15
16const isOff = atom({ plugin: 'method-review', key: 'isOff' } as const, false)
17const pending = atom({ plugin: 'method-review', key: 'pending' } as const, [])
18
19/** The set of files a hold was already shown for: a second try goes ahead. */
20let held = ''
21
22async function words($: Dollar): Promise<string[]> {
23  try {
24    const stored = await $.store.get('words')
25    return Array.isArray(stored) ? stored.filter((w): w is string => typeof w === 'string') : [...DEFAULT_WORDS]
26  } catch {
27    return [...DEFAULT_WORDS]
28  }
29}
30
31async function relative($: Dollar, file: string): Promise<string> {
32  try {
33    const cwd = (await $.session.cwd()).replace(/\\/g, '/').replace(/\/$/, '')
34    const f = file.replace(/\\/g, '/')
35    return f.startsWith(`${cwd}/`) ? f.slice(cwd.length + 1) : f
36  } catch {
37    return file
38  }
39}
40
41async function run($: Dollar, args: string): Promise<string> {
42  const [verb = '', ...rest] = args.trim().split(/\s+/)
43  const word = rest.join(' ').trim().toLowerCase()
44  const list = await words($)
45  if (verb === '' || verb === 'status') {
46    const files = await read($, pending)
47    return `method-review · ${(await read($, isOff)) ? 'off' : 'on'} · ${files.length === 0 ? 'nothing waiting for review' : `${files.length} unreviewed: ${shortList(files, 5)}`}\nWatching paths with: ${list.join(', ')}`
48  }
49  if (verb === 'on' || verb === 'off') {
50    await $.store.set('off', verb === 'off')
51    await update($, isOff, () => verb === 'off')
52    return `method-review · ${verb}`
53  }
54  if (verb === 'reviewed') {
55    await update($, pending, () => [])
56    held = ''
57    return 'method-review · marked reviewed'
58  }
59  if ((verb === 'add' || verb === 'remove') && word !== '') {
60    const next = verb === 'add' ? [...new Set([...list, word])] : list.filter(w => w !== word)
61    await $.store.set('words', next)
62    return `method-review · ${verb === 'add' ? 'now watching' : 'no longer watching'} paths with "${word}"`
63  }
64  return 'method-review · use /method-review [status|on|off|reviewed|add <word>|remove <word>]; nothing changed'
65}
66
67export const register: Register = on => {
68  on('session.start', async ($, e, next) => {
69    held = ''
70    try {
71      const off = (await $.store.get('off')) === true
72      await update($, isOff, () => off)
73    } catch {
74      // reads as on
75    }
76    try {
77      await $.command.register({ name: COMMAND, description: 'Method code edited without a second-model review is held once at commit or push', argumentHint: '[status|on|off|reviewed|add <word>|remove <word>]', immediate: true })
78    } catch {
79      // fail-open: the mod still works without its command
80    }
81    return next(e)
82  })
83
84  on('command.run', { command: COMMAND }, async ($, e) => ({ text: await run($, e.args) })).catch(() => ({ text: 'method-review · that did not work, nothing changed' }))
85
86  on('tool.call', async ($, e, next) => {
87      if (await read($, isOff)) return next(e)
88      const input = e as unknown as Record<string, unknown>
89      const tool = String(e.tool)
90      const command = typeof input.command === 'string' ? input.command : ''
91
92      if (tool === 'Bash' && isSaveCommand(command)) {
93        const files = await read($, pending)
94        const key = files.join('|')
95        if (files.length > 0 && held !== key) {
96          held = key
97          $.ui.toast(`method-review: ${files.length} method file${files.length === 1 ? '' : 's'} not reviewed by a second model`, { timeoutMs: 8000 })
98          return { deny: `method-review: held once. ${shortList(files)} ${files.length === 1 ? 'is' : 'are'} method code with no second-model review. Run the review skill (a different model must review it), or run this command again to go ahead.` }
99        }
100      }
101
102      const ran = await next(e)
103      if (ran.deny !== undefined || ran.isError === true) return ran
104
105      if ((tool === 'Bash' && isReviewCommand(command)) || (tool === 'Skill' && typeof input.skill === 'string' && isReviewSkill(input.skill))) {
106        await update($, pending, () => [])
107        held = ''
108        return ran
109      }
110      const target = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
111      if (isEditTool(tool) && target !== '' && isMethodPath(target, await words($))) {
112        const shown = await relative($, target)
113        const before = await read($, pending)
114        if (!before.includes(shown)) {
115          await update($, pending, list => [...list, shown].slice(-MAX_PENDING))
116          if (before.length === 0) {
117            return { ...ran, context: [...(ran.context ?? []), `method-review: ${shown} looks like method code. A different model must review it before it is committed or pushed: use the review skill.`] } as typeof ran
118          }
119        }
120      }
121      return ran
122  }).catch(($, e, next) => next(e))
123}
124
hooks/lib.ts 28 lines
1// Pure helpers for method-review: which paths count as method code, and which commands mean "reviewed" or "about to save".
2
3/** Words in a path that mark method code (the person adds or removes words with /method-review add|remove). */
4export const DEFAULT_WORDS = ['blup', 'herit', 'selection', 'meiosis', 'variance', 'kinship', 'relationship', 'likelihood', 'estimat', 'simulat', 'gwas', 'effects']
5
6const CODE = /\.(r|rs|py|jl|cpp|c|h|rmd|qmd)$/i
7const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
8
9export const isEditTool = (tool: string): boolean => EDIT_TOOLS.has(tool)
10
11/** A code file whose path holds one of the words (case ignored). Tests and docs are not method code. */
12export function isMethodPath(path: string, words: readonly string[]): boolean {
13  const p = path.replace(/\\/g, '/').toLowerCase()
14  if (!CODE.test(p) || /(^|\/)(tests?|testthat|docs?)\//.test(p)) return false
15  return words.some(w => w !== '' && p.includes(w.toLowerCase()))
16}
17
18/** A command that reviews with another model: Codex, or the repository's dev/dual.sh. */
19export const isReviewCommand = (cmd: string): boolean => /\bcodex\s+(exec|review)\b|dev\/dual\.sh/.test(cmd)
20
21/** A command that saves or shares the work. */
22export const isSaveCommand = (cmd: string): boolean => /\bgit\b[^|;&\n]*?\b(commit|push)\b/.test(cmd)
23
24/** The review skill, named plainly or with its plugin prefix. */
25export const isReviewSkill = (name: string): boolean => name === 'review' || name.endsWith(':review')
26
27export const shortList = (files: readonly string[], n = 3): string => `${files.slice(0, n).join(', ')}${files.length > n ? ` and ${files.length - n} more` : ''}`
28
types/index.d.ts 11 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'method-review': {
4      /** True while switched off (it ships on). */
5      isOff: boolean
6      /** Method files edited since the last review, as shown (relative where possible), oldest first, at most 50. */
7      pending: string[]
8    }
9  }
10}
11