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

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.
.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.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.review skill, codex exec / codex review, dev/dual.sh, or /method-review reviewed./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
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.
method-review → isOff, pending (see types/index.d.ts).
hooks/register.tsx 124 lines1// 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}
124hooks/lib.ts 28 lines1// 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` : ''}`
28types/index.d.ts 11 lines1declare 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