Reminds Claude to run code-review after a code commit that no review saw: from 30 changed lines, or any change to guard code; adds /simplify from 300 lines

Reminds Claude to review its own code before it calls the work done. After a git commit whose changes no review saw, the tool result gets a short note: run /code-review, and /simplify as well for a large change, or say why the review does not apply. It never blocks a commit or a push.
/code-review did not run while those changes were made.guard, or under git-hooks/. A few changed lines in a pattern can open a gap./simplify is asked for too, since a large diff can crowd the cleanup angles out of code-review's findings.A review counts for every commit whose parent was written before the review ran, so fix commits made after a review are not reminded again. A review counts only for the repository of the session's working directory, and a review call that was denied or failed does not count. wip: commits are skipped. Commits are found from the Bash tool's git report and the reflog, so a commit made by an alias or a script is seen too.
Change them with /plugin configure quality-nudge@lbzepoqo in Claude Code, or from the shell:
echo '{"reviewLines": "50"}' | claude plugin configure quality-nudge@lbzepoqo --values-stdin
| Option | Default | Meaning |
|---|---|---|
reviewLines | 30 | Changed code lines that call for /code-review |
simplifyLines | 300 | Changed code lines that call for /simplify as well |
claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install quality-nudge@lbzepoqo
Restart Claude Code.
No model calls. After a commit it runs a few git commands (log, diff-tree) to measure the change.
hooks/register.ts 126 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 codeChange,
4 commitDirectory,
5 commitNudge,
6 DEFAULT_LIMITS,
7 expandHome,
8 madeCommit,
9 parseCommitLine,
10 reviewSkill,
11 type Limits,
12 type Reviews,
13} from './rules'
14
15type Git = (cwd: string | undefined, args: string[]) => Promise<string | undefined>
16
17function gitRunner($: EngineInterface): Git {
18 return async (cwd, args) => {
19 // Unquoted paths, so a non-ASCII name still matches the extension and path checks. A run that rejects
20 // (git missing, a timeout) counts as a failed one, since a hook that throws is skipped and the call runs.
21 const run = await $.process.run(['git', '-c', 'core.quotePath=false', ...args], cwd === undefined ? {} : { cwd }).catch(() => undefined)
22 if (run?.exitCode !== 0) return undefined
23 // Only the line end goes, since a -z listing can start with a space. Output over 4 MiB is cut: its partial
24 // last record is dropped, and the rest is far over any threshold.
25 const output = run.stdout.replace(/\n$/, '')
26 const lastRecord = output.lastIndexOf('\0')
27 return run.isStdoutTruncated && lastRecord >= 0 ? output.slice(0, lastRecord + 1) : output
28 }
29}
30
31// Author time survives a rebase; 0 when the commit cannot be read (a root
32// commit's missing parent, a shallow clone).
33async function authorTime(git: Git, cwd: string, revision: string): Promise<number> {
34 const time = revision ? await git(cwd, ['log', '-1', '--format=%at', revision, '--']) : undefined
35 return Number(time || 0) * 1000
36}
37
38type HeadState = { head: string | undefined; entries: number }
39
40// HEAD and the length of its reflog; 0 entries when there is no reflog (none yet, or turned off).
41async function headState(git: Git, root: string): Promise<HeadState> {
42 const [head, entries] = await Promise.all([git(root, ['rev-parse', '--verify', '-q', 'HEAD']), git(root, ['rev-list', '-g', '--count', 'HEAD'])])
43 return { head, entries: Number(entries) || 0 }
44}
45
46// A commit that printed nothing: the newest commit among the reflog entries the call added. With the reflog
47// off, a HEAD whose parent is the start HEAD, so a pull or checkout in the same command is not taken for it.
48async function quietCommit(git: Git, root: string, start: HeadState): Promise<string | undefined> {
49 const end = await headState(git, root)
50 const added = end.entries - start.entries
51 if (end.entries > 0) return added > 0 ? madeCommit((await git(root, ['log', '-g', '-n', String(added), '--format=%H%x09%gs', 'HEAD'])) ?? '', added) : undefined
52 if (end.head === undefined || end.head === start.head) return undefined
53 return (await git(root, ['log', '-1', '--format=%P', end.head])) === (start.head ?? '') ? end.head : undefined
54}
55
56// The top of the repository a commit runs in: a -C or cd directory, else the session's.
57async function repositoryRoot($: EngineInterface, git: Git, directory: string | null): Promise<string> {
58 // The order of Node's os.homedir(): USERPROFILE exists only on Windows, where HOME is usually unset.
59 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
60 const cwd = directory === null ? undefined : expandHome(directory, home)
61 return (await git(cwd, ['rev-parse', '--show-toplevel'])) ?? cwd ?? (await $.session.cwd())
62}
63
64// A code commit that no required review saw gets a reminder in the tool
65// result. A hook that fails is skipped by the engine and the call runs
66// unchanged.
67export const limitOption = (value: unknown, fallback: number): number => {
68 const parsed = Number(value)
69 return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
70}
71
72export const register: Register = (on, options) => {
73 const limits: Limits = {
74 reviewLines: limitOption(options.reviewLines, DEFAULT_LIMITS.reviewLines),
75 simplifyLines: limitOption(options.simplifyLines, DEFAULT_LIMITS.simplifyLines),
76 }
77 // Per session and per repository of the session's directory, so a review of one repository does not
78 // cover commits in another. A reload starts over, which costs at most one extra reminder.
79 const reviewsByRoot = new Map<string, Reviews>()
80 const noReviews: Reviews = { simplify: 0, 'code-review': 0 }
81
82 // Reviews count from any agent, so one run by a fork covers the change. A denied or failed call is no review.
83 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
84 const skill = reviewSkill(e.skill)
85 const startedAt = await $.clock.now()
86 const result = await next(e)
87 if (!skill || result.deny !== undefined || result.isError || result.result.success === false) return result
88 const root = await repositoryRoot($, gitRunner($), null)
89 const reviews = reviewsByRoot.get(root) ?? noReviews
90 reviewsByRoot.set(root, { ...reviews, [skill]: startedAt })
91 return result
92 })
93
94 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
95 if (e.agentId) return next(e)
96 const commitAt = commitDirectory(e.command)
97 const git = gitRunner($)
98 // Read before the call, so only the reflog entries the call adds are searched for a quiet commit.
99 const startRoot = commitAt === undefined ? undefined : await repositoryRoot($, git, commitAt)
100 const start = startRoot === undefined ? undefined : await headState(git, startRoot)
101 const result = await next(e)
102 if (result.deny !== undefined || result.isError) return result
103
104 // The engine reports the git operations it reads from a command's output, so commits by an alias or a
105 // script are seen too. `git commit -q` prints nothing, so for a git commit call in the command the
106 // reflog is read as well. The repository is found again after the call, which may have created it.
107 const operation = result.result.gitOperation
108 if (!operation?.commit && commitAt === undefined) return result
109 if (operation?.commit && operation.commit.kind !== 'committed') return result
110 const root = await repositoryRoot($, git, commitAt ?? null)
111 const sha = operation?.commit?.sha ?? (await quietCommit(git, root, startRoot === root && start ? start : { head: undefined, entries: 0 }))
112 if (!sha) return result
113
114 const [line = '', numstat = ''] = await Promise.all([
115 git(root, ['log', '-1', '--format=%s%x09%P', sha]),
116 git(root, ['diff-tree', '--root', '-M', '--no-commit-id', '--numstat', '-z', '-r', sha]),
117 ])
118 const commit = parseCommitLine(line)
119 if (!commit) return result
120 const parentTime = await authorTime(git, root, commit.parent)
121 const text = commitNudge({ subject: commit.subject, change: codeChange(numstat), parentTime }, reviewsByRoot.get(root) ?? noReviews, limits)
122 if (!text) return result
123 return { ...result, context: [...(result.context ?? []), `${$.plugin.name}: ${text}`] }
124 })
125}
126hooks/rules.ts 163 lines1// Pure checks for the quality-nudge hooks.
2
3const PATH = String.raw`("[^"]+"|'[^']+'|\S+)`
4
5export const REVIEW_SKILLS = ['simplify', 'code-review'] as const
6export type ReviewSkill = (typeof REVIEW_SKILLS)[number]
7
8const CODE_EXTENSIONS = /\.(?:[cm]?[jt]sx?|svelte|py|sh|bash|lua|go|rs|java|kt|cs|rb|php|sql)$/
9// Guard code (secret-guard, rule-guards, a push guard, git hooks) is reviewed at any size: a few changed
10// lines of a pattern can open a gap.
11const GUARD_PATH = /(?:^|\/)(?:[\w.-]*guard[\w.-]*|git-hooks)\//
12// Under a guard path every file counts (a hook without an extension, the hooks.json that picks which calls
13// a guard sees), except documentation.
14const DOCUMENTATION = /\.(?:md|txt)$/
15
16export interface Limits {
17 // Changes below this many added plus deleted code lines go unreviewed, unless they touch guard code.
18 reviewLines: number
19 // From this size code-review's findings cap can crowd out its cleanup angles, so /simplify runs too.
20 simplifyLines: number
21}
22export const DEFAULT_LIMITS: Limits = { reviewLines: 30, simplifyLines: 300 }
23
24const GIT_COMMIT = new RegExp(String.raw`\bgit\s+(?:-C\s+${PATH}\s+)?commit\b`, 'd')
25const CHANGE_DIRECTORY = new RegExp(String.raw`(?:^|&&|;)\s*cd\s+${PATH}\s*(?:&&|;)`, 'gmd')
26
27// Where a git commit in the command runs: the -C path, the last `cd <dir>` before it, or the session's
28// working directory (null). undefined when the command has no git commit. A match inside quoted text only
29// costs the git reads around the call, since no new commit is found there.
30export function commitDirectory(command: string): string | null | undefined {
31 const call = GIT_COMMIT.exec(command)
32 if (!call) return undefined
33 const before = command.slice(0, call.index)
34 const directory = call.indices?.[1] ?? [...before.matchAll(CHANGE_DIRECTORY)].at(-1)?.indices?.[1]
35 return directory ? unquote(command.slice(...directory)) : null
36}
37
38// A process cwd is taken relative to the session's directory, but ~ is not expanded.
39export function expandHome(directory: string, home: string): string {
40 if (directory === '~') return home
41 return directory.startsWith('~/') ? `${home}/${directory.slice(2)}` : directory
42}
43
44export function reviewSkill(skill: string): ReviewSkill | undefined {
45 return REVIEW_SKILLS.find(name => name === skill)
46}
47
48// Code files, and every file under a guard path except documentation.
49export function isCounted(path: string): boolean {
50 return CODE_EXTENSIONS.test(path) || isGuard(path)
51}
52
53function isGuard(path: string): boolean {
54 return GUARD_PATH.test(path) && !DOCUMENTATION.test(path)
55}
56
57export interface ChangeSize {
58 lines: number
59 files: number
60 guarded: boolean
61}
62
63// The code part of `git diff-tree --numstat -z` output. Each record is `added<TAB>deleted<TAB>path<NUL>`,
64// or for a rename `added<TAB>deleted<TAB><NUL>old<NUL>new<NUL>`. Binary files show `-` counts and add no
65// lines.
66export function codeChange(numstatOutput: string): ChangeSize {
67 const paths = new Set<string>()
68 let lines = 0
69 let guarded = false
70 const fields = numstatOutput.split('\0')
71 for (let index = 0; index < fields.length; index++) {
72 const record = fields[index] ?? ''
73 if (!record) continue
74 const [added = '', deleted = ''] = record.split('\t', 2)
75 const path = record.slice(added.length + deleted.length + 2)
76 const sides = path ? [path] : [fields[++index] ?? '', fields[++index] ?? '']
77 if (!sides.some(isCounted)) continue
78 paths.add(sides[sides.length - 1] ?? '')
79 lines += (Number(added) || 0) + (Number(deleted) || 0)
80 guarded ||= sides.some(isGuard)
81 }
82 return { lines, files: paths.size, guarded }
83}
84
85// The newest commit made by `git commit` during a call, from `git log -g --format=%H%x09%gs` output: reflog
86// entries newest first, each the sha and the reflog subject. The call's entries are the `added` newest ones,
87// counted against the reflog's length before the call; reflog times are not used, since they are the
88// committer date, which an environment variable can set. A pull, reset or checkout moves HEAD under another
89// subject and is passed over. An amend counts only on top of a commit made in the same call, and then it is
90// the one measured; amending an older commit is not a new change set.
91export function madeCommit(reflog: string, added: number): string | undefined {
92 let amended: string | undefined
93 for (const line of reflog.split('\n').slice(0, added)) {
94 const [sha = '', subject = ''] = line.split('\t', 2)
95 if (/^commit(?: \((?:initial|merge)\))?:/.test(subject)) return amended ?? sha
96 if (/^commit \(amend\):/.test(subject)) amended ??= sha
97 }
98 return undefined
99}
100
101export function needsReview(change: ChangeSize, limits: Limits): boolean {
102 return change.files > 0 && (change.lines >= limits.reviewLines || change.guarded)
103}
104
105// code-review at high effort covers the /simplify angles, so /simplify is only asked for large changes.
106export function requiredReviews(change: ChangeSize, limits: Limits): ReviewSkill[] {
107 return change.lines >= limits.simplifyLines ? ['code-review', 'simplify'] : ['code-review']
108}
109
110function describe(change: ChangeSize): string {
111 return `${change.lines} lines in ${change.files} code file${change.files === 1 ? '' : 's'}${change.guarded ? ', including guard code,' : ''}`
112}
113
114export function isWipSubject(subject: string): boolean {
115 return /^wip\b/i.test(subject.trim())
116}
117
118// When each review skill last ran, in ms. A review cannot be tied to a
119// repository, so these are kept per session.
120export type Reviews = Record<ReviewSkill, number>
121
122// The required skills that did not run after the given time, so did not see
123// the changes made since then.
124export function missingReviews(reviews: Reviews, since: number, required: readonly ReviewSkill[]): ReviewSkill[] {
125 return required.filter(skill => !(reviews[skill] > since))
126}
127
128function skillList(skills: ReviewSkill[]): string {
129 return skills.map(skill => `/${skill}`).join(' and ')
130}
131
132export interface CommitFacts {
133 subject: string
134 change: ChangeSize
135 // When the commit's parent was authored, in ms. The changes in the commit
136 // were made after it, so a review after it saw them. Fix commits made after
137 // a review sit on a parent authored before it, so they are covered.
138 parentTime: number
139}
140
141// A reminder after a commit whose changes no required review saw.
142export function commitNudge(commit: CommitFacts, reviews: Reviews, limits: Limits = DEFAULT_LIMITS): string | undefined {
143 if (isWipSubject(commit.subject) || !needsReview(commit.change, limits)) return undefined
144 const missing = missingReviews(reviews, commit.parentTime, requiredReviews(commit.change, limits))
145 if (missing.length === 0) return undefined
146 return (
147 `This commit changes ${describe(commit.change)} and ${skillList(missing)} did not run while they ` +
148 `were made. Run ${missing.length > 1 ? 'them' : 'it'} before reporting the task done ` +
149 'or pushing, or say in your reply why the review does not apply.'
150 )
151}
152
153// One line of `git log --format=%s%x09%P`: the subject and the first parent.
154export function parseCommitLine(line: string): { subject: string; parent: string } | undefined {
155 const tab = line.lastIndexOf('\t')
156 if (tab < 0) return undefined
157 return { subject: line.slice(0, tab), parent: line.slice(tab + 1).split(' ')[0] ?? '' }
158}
159
160function unquote(value: string): string {
161 return value.replace(/^(["'])(.*)\1$/, '$2')
162}
163