SLOPSHOPPER

quality-nudge

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

newguardprocess
★ 2v0.4.2MITupdated 2026-10-21lbzepoqo/claude-plugins/plugins/quality-nudge
A shopper browsing a rack in a slop shop
README

quality-nudge

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.

When it reminds

  • 30 or more changed code lines (added plus deleted, code files only) in a commit, and /code-review did not run while those changes were made.
  • Any change to guard code, at any size: a file under a folder whose name contains guard, or under git-hooks/. A few changed lines in a pattern can open a gap.
  • 300 or more lines: /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.

Options

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
OptionDefaultMeaning
reviewLines30Changed code lines that call for /code-review
simplifyLines300Changed code lines that call for /simplify as well

Install

claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install quality-nudge@lbzepoqo

Restart Claude Code.

Cost

No model calls. After a commit it runs a few git commands (log, diff-tree) to measure the change.

Source 2 files
hooks/register.ts 126 lines
1import 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}
126
hooks/rules.ts 163 lines
1// 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