SLOPSHOPPER

humanizer-gate

Runs humanizer detection on prose as it leaves the session: markdown writes get a status line, PR bodies and commit messages can be held for a yes/no when an…

newguardstatusprocess
★ 1v0.1.0BSD-3-Clauseupdated 2026-10-09mad01/thismoon/mods/humanizer-gate
A shopper browsing a rack in a slop shop
README

humanizer-gate

A Claude Code mod that runs humanizer detect on prose as it leaves the session, so text other people will read meets the humanizer before it ships. Detection only (vale, sub-second); the LLM judge never runs.

What it checks

MomentWhat is scannedWhat happens
Write or Edit on a .md or .mdx filethe file on disk, after the writea status line under the prompt, humanizer-gate: 2 warnings in README.md (the engine adds the name); cleared when the scan is clean
mcp__gh_com__create_pull_request, mcp__gh_com__update_pull_requestthe body fieldstatus line; with an error-level finding and holdOnError on, a Proceed / Cancel dialog first
Bash running git committhe message of the last git commit segment in the command: every quoted -m (-am, --message too), joined, or a heredoc after -F - or inside -m "$(cat <<'EOF' ...)"status line; with an error-level finding and holdOnCommitError on, the same dialog

Markdown under node_modules or .git is skipped; dot directories such as .claude/worktrees and ~/.worktrees are scanned. Only the commit's own segment of a shell command is read, so a script in a heredoc elsewhere in the command never gets scanned. A commit whose message the mod can't read (-F file, an editor, an unquoted word) passes straight through.

Tool-call hooks run before the permission check, so the hold dialog can appear before a permission dialog for the same call.

What it never blocks

  • A markdown write: the write lands first, then the scan reports.
  • Anything when ~/code/bin/humanizer is missing: one debug line at session start, then every hook is a pass-through.
  • Anything on a slow or failed scan: 5 s budget per run, then the event goes on as if the mod were absent.
  • A PR or commit unless the person picks Cancel in the dialog. Cancel answers the tool call with humanizer-gate: cancelled by user; Proceed, a dismissed dialog, or a -p run with nobody to ask all let it through. belt stays the guard.

Config

Set under pluginConfigs["humanizer-gate"].options in settings, or from /config:

KeyTypeDefaultMeaning
holdOnErrorbooleantrueask before a PR call whose body has an error-level finding
holdOnCommitErrorbooleanfalseask before a git commit whose message has one
minSeveritysuggestion, warning, errorwarningthe lowest severity humanizer detect reports

The last scan's counts sit in $.state under humanizer-gate.lastReport for another mod to read.

pi face

pi/index.ts runs the same three checks in the pi coding agent on its split events: tool_call scans a git commit message or a github.com pull request body (direct mcp__gh_com__<op> or the mcp proxy with server: gh_com) and may hold it for the same Proceed / Cancel; tool_result scans a .md or .mdx file after write or edit landed. Status goes to ctx.ui.setStatus. pi has no per-plugin config, so the face runs on the defaults above. The same pass-through rules apply: no binary, a failed scan, or no UI to ask all let the call through.

Dev loop

claude --plugin-dir mods/humanizer-gate        # from the repo root; reloads on save
claude --debug --plugin-dir mods/humanizer-gate  # the debug log names every scan and skip
claude plugin validate mods/humanizer-gate
node --test mods/humanizer-gate/test/*.test.mjs  # lib and the pi face

Needs ~/code/bin/humanizer (thismoon tools/humanizer) and vale on PATH, which detect shells out to. The engine writes .claude-plugin/types/ beside the mod on load; those files are the authority on event shapes for the build you run.

Source 3 files
hooks/register.ts 148 lines
1import { atom, update } from 'claude-code'
2import type { Caught, EngineInterface, Register } from 'claude-code'
3
4import type { HumanizerGateReport } from '../types'
5import {
6  CANCELLED_REASON,
7  DETECT_BUDGET_MS,
8  HOLD_OPTIONS,
9  basename,
10  extractCommitMessage,
11  holdQuestion,
12  isProseFile,
13  parseDetectOutput,
14  readConfig,
15  statusText,
16} from '../lib/gate'
17import type { FindingsSummary, Severity } from '../lib/gate'
18
19const lastReport = atom(
20  { plugin: 'humanizer-gate', key: 'lastReport' } as const,
21  null as HumanizerGateReport | null,
22)
23
24// The one .catch every hook below carries: say why in the debug log, then
25// let the chain beneath answer as if the hook were absent. The gate never
26// blocks a write, a PR or a commit on its own failure.
27const skipOnFailure = <E, R>($: EngineInterface, e: E, next: ((e: E) => R) & Caught): R => {
28  const { kind, message } = next.error
29  const why = message === undefined ? kind : `${kind}: ${message}`
30  $.ui.log(`humanizer-gate: hook skipped (${why})`, { to: 'debug' })
31  return next(e)
32}
33
34const describe = (err: unknown): string => (err instanceof Error ? err.message : String(err))
35
36type DetectSource = { file: string } | { text: string }
37
38// One `humanizer detect --json` run under the budget: a file by path, or
39// text on stdin. Null when it timed out, failed, or wrote no payload; the
40// debug log says which. `timeoutMs` kills the child and rejects at the
41// budget, so there is no second clock to race it against.
42async function detect(
43  $: EngineInterface,
44  binary: string,
45  source: DetectSource,
46  level: Severity,
47): Promise<FindingsSummary | null> {
48  const argv = [binary, 'detect', '--json', '--min-severity', level]
49  const init: { stdin?: string; timeoutMs: number } = { timeoutMs: DETECT_BUDGET_MS }
50  if ('file' in source) argv.push(source.file)
51  else init.stdin = source.text
52  let ran: { exitCode: number; stdout: string; stderr: string }
53  try {
54    ran = await $.process.run(argv, init)
55  } catch (err) {
56    $.ui.log(`humanizer-gate: detect did not finish (${describe(err)})`, { to: 'debug' })
57    return null
58  }
59  if (ran.exitCode !== 0) {
60    const stderr = ran.stderr.trim().slice(0, 200)
61    $.ui.log(`humanizer-gate: detect exit ${ran.exitCode} (${stderr})`, { to: 'debug' })
62    return null
63  }
64  const summary = parseDetectOutput(ran.stdout)
65  if (summary === null) $.ui.log('humanizer-gate: detect wrote no JSON payload', { to: 'debug' })
66  return summary
67}
68
69// Pins the status line for `target` (clears it on a clean scan) and keeps
70// the counts in state. A failed detect leaves both as they were.
71async function report($: EngineInterface, summary: FindingsSummary | null, target: string): Promise<void> {
72  if (summary === null) return
73  $.ui.status(statusText(summary, target))
74  const { total, errors, warnings, suggestions } = summary
75  await update($, lastReport, () => ({ target, total, errors, warnings, suggestions }))
76}
77
78// True only when the person picked Cancel. A dialog nobody can answer (a
79// `-p` run) or one dismissed lets the call through.
80async function isCancelled($: EngineInterface, summary: FindingsSummary, target: string): Promise<boolean> {
81  try {
82    const answer = await $.ui.ask(holdQuestion(summary, target), { options: HOLD_OPTIONS, header: 'humanizer' })
83    return answer === HOLD_OPTIONS[1]
84  } catch (err) {
85    $.ui.log(`humanizer-gate: hold not asked (${describe(err)}), proceeding`, { to: 'debug' })
86    return false
87  }
88}
89
90export const register: Register = (on, options) => {
91  const config = readConfig(options)
92  // The binary's path once session.start found it. Null until then and when
93  // it is missing, and every hook below is then a pass-through.
94  let binary: string | null = null
95
96  on('session.start', async ($, e, next) => {
97    const started = await next(e)
98    const home = await $.env.get('HOME')
99    const candidate = `${home ?? '~'}/code/bin/humanizer`
100    binary = home !== undefined && (await $.fs.exists(candidate)) ? candidate : null
101    if (binary === null) {
102      $.ui.log(`humanizer-gate: ${candidate} missing, every hook passes through`, { to: 'debug' })
103    } else {
104      $.ui.log(`humanizer-gate: loaded (min severity ${config.minSeverity})`, { to: 'debug' })
105    }
106    return started
107  }).catch(skipOnFailure)
108
109  // The write happens first; the scan only reports on what landed. (This
110  // build registers no MultiEdit tool: its types name Write and Edit only.)
111  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
112    const answered = await next(e)
113    const bin = binary
114    if (bin === null || answered.deny !== undefined || answered.isError === true) return answered
115    if (!isProseFile(e.file_path)) return answered
116    await report($, await detect($, bin, { file: e.file_path }, config.minSeverity), basename(e.file_path))
117    return answered
118  }).catch(skipOnFailure)
119
120  on(
121    'tool.call',
122    { tool: ['mcp__gh_com__create_pull_request', 'mcp__gh_com__update_pull_request'] },
123    async ($, e, next) => {
124      const bin = binary
125      const body = typeof e.body === 'string' ? e.body : ''
126      if (bin === null || body.trim() === '') return next(e)
127      const summary = await detect($, bin, { text: body }, config.minSeverity)
128      await report($, summary, 'PR body')
129      const held = summary !== null && summary.errors > 0 && config.holdOnError
130      if (held && (await isCancelled($, summary, 'PR body'))) return { deny: CANCELLED_REASON }
131      return next(e)
132    },
133  ).catch(skipOnFailure)
134
135  // A loose prefilter; extractCommitMessage decides whether a segment of
136  // the command is really `git commit` and reads only that segment.
137  on('tool.call', { tool: 'Bash', command: /\bgit\b[^|;&\n]*?\bcommit\b/ }, async ($, e, next) => {
138    const bin = binary
139    const message = extractCommitMessage(e.command)
140    if (bin === null || message === null) return next(e)
141    const summary = await detect($, bin, { text: message }, config.minSeverity)
142    await report($, summary, 'commit message')
143    const held = summary !== null && summary.errors > 0 && config.holdOnCommitError
144    if (held && (await isCancelled($, summary, 'commit message'))) return { deny: CANCELLED_REASON }
145    return next(e)
146  }).catch(skipOnFailure)
147}
148
lib/gate.ts 290 lines
1// Pure helpers behind the humanizer-gate mod: no `$`, no I/O, so `node --test`
2// covers them without the engine. register.ts is the only caller.
3
4export type Severity = 'suggestion' | 'warning' | 'error'
5
6const SEVERITIES: readonly Severity[] = ['suggestion', 'warning', 'error']
7
8/** How long one detect run may take before the gate gives up on it. */
9export const DETECT_BUDGET_MS = 5_000
10
11/** How many matched texts a question quotes. */
12const QUOTED_MATCHES = 3
13
14/** The labels of the hold dialog; only the second one stops the call. */
15export const HOLD_OPTIONS = ['Proceed', 'Cancel'] as const
16
17/** The deny text a cancelled call carries back to the model. */
18export const CANCELLED_REASON = 'humanizer-gate: cancelled by user'
19
20export type GateConfig = {
21  holdOnError: boolean
22  holdOnCommitError: boolean
23  minSeverity: Severity
24}
25
26export const DEFAULT_CONFIG: GateConfig = {
27  holdOnError: true,
28  holdOnCommitError: false,
29  minSeverity: 'warning',
30}
31
32function isSeverity(value: unknown): value is Severity {
33  return typeof value === 'string' && (SEVERITIES as readonly string[]).includes(value)
34}
35
36/** The mod's userConfig with defaults filled in; a value of the wrong kind falls back. */
37export function readConfig(options: Readonly<Record<string, unknown>>): GateConfig {
38  const flag = (key: 'holdOnError' | 'holdOnCommitError'): boolean => {
39    const value = options[key]
40    return typeof value === 'boolean' ? value : DEFAULT_CONFIG[key]
41  }
42  return {
43    holdOnError: flag('holdOnError'),
44    holdOnCommitError: flag('holdOnCommitError'),
45    minSeverity: isSeverity(options.minSeverity) ? options.minSeverity : DEFAULT_CONFIG.minSeverity,
46  }
47}
48
49const PROSE_EXTENSION = /\.mdx?$/i
50const SKIPPED_SEGMENTS = new Set(['node_modules', '.git'])
51
52/**
53 * Whether a Write or Edit target is prose the gate scans: a `.md` or `.mdx`
54 * file outside `node_modules` and `.git`. Dot directories are scanned, so
55 * markdown under `.claude/worktrees` and `~/.worktrees` counts.
56 */
57export function isProseFile(path: string): boolean {
58  if (!PROSE_EXTENSION.test(path)) return false
59  return !path.split('/').some(segment => SKIPPED_SEGMENTS.has(segment))
60}
61
62/** The last path segment. */
63export function basename(path: string): string {
64  const parts = path.replace(/\/+$/, '').split('/')
65  return parts[parts.length - 1] ?? path
66}
67
68type Context = 'top' | 'sub' | 'dq' | 'sq'
69
70const HEREDOC_OPEN = /^<<-?\s*(['"]?)(\w+)\1/
71
72/**
73 * Splits a shell command into its top-level segments: the text between
74 * `&&`, `||`, `;`, `|`, `&` and newlines that sit outside quotes, `$(...)`
75 * and parentheses. A heredoc body stays inside the segment that opened it,
76 * so a script fed to `cat` or `python3` never becomes a segment of its own.
77 */
78export function splitSegments(command: string): string[] {
79  const segments: string[] = []
80  const stack: Context[] = ['top']
81  const pending: string[] = []
82  let current = ''
83  let i = 0
84  const flush = (): void => {
85    if (current.trim() !== '') segments.push(current)
86    current = ''
87  }
88  // Called at the newline that ends the line holding the openers: each
89  // pending heredoc's body runs to the line holding its delimiter alone.
90  const consumeHeredocs = (): void => {
91    while (pending.length > 0) {
92      const delimiter = pending.shift()
93      while (i < command.length) {
94        const end = command.indexOf('\n', i)
95        const line = end === -1 ? command.slice(i) : command.slice(i, end + 1)
96        current += line
97        i = end === -1 ? command.length : end + 1
98        if (line.trim() === delimiter) break
99      }
100    }
101  }
102  while (i < command.length) {
103    const context = stack[stack.length - 1]
104    const ch = command[i] ?? ''
105    if (context === 'sq') {
106      current += ch
107      i += 1
108      if (ch === "'") stack.pop()
109      continue
110    }
111    if (ch === '\\') {
112      current += command.slice(i, i + 2)
113      i += 2
114      continue
115    }
116    if (ch === '\n') {
117      current += ch
118      i += 1
119      if (pending.length > 0) consumeHeredocs()
120      if (context === 'top') flush()
121      continue
122    }
123    if (context === 'dq') {
124      current += ch
125      i += 1
126      if (ch === '"') stack.pop()
127      else if (ch === '$' && command[i] === '(') {
128        current += '('
129        i += 1
130        stack.push('sub')
131      }
132      continue
133    }
134    if (ch === "'" || ch === '"') {
135      stack.push(ch === "'" ? 'sq' : 'dq')
136    } else if (ch === '(') {
137      stack.push('sub')
138    } else if (ch === ')') {
139      if (context === 'sub') stack.pop()
140    } else if (ch === '<' && command[i + 1] === '<') {
141      const open = HEREDOC_OPEN.exec(command.slice(i))
142      if (open !== null) {
143        pending.push(open[2] ?? '')
144        current += open[0]
145        i += open[0].length
146        continue
147      }
148    } else if (context === 'top' && (ch === ';' || ch === '|' || ch === '&')) {
149      flush()
150      i += ch !== ';' && command[i + 1] === ch ? 2 : 1
151      continue
152    }
153    current += ch
154    i += 1
155  }
156  flush()
157  return segments
158}
159
160// A segment whose command is git's `commit` subcommand, after any leading
161// variable assignments and git's own options (`-C dir`, `-c key=val`, `--x`).
162const COMMIT_SEGMENT = /^\s*(?:\w+=\S*\s+)*git(?:\s+-[cC]\s+\S+|\s+--\S+)*\s+commit\b/
163// `-m "..."` with backslash escapes, or `-m '...'` with the `'\''` escape; a
164// short-flag cluster ending in m (`-am`), no separator (`-m"x"`), and
165// `--message` or `--message=` the same way.
166const MESSAGE_FLAG = /(?:^|\s)(?:-[a-zA-Z]*m|--message)(?:\s*|=)(?:"((?:[^"\\]|\\[\s\S])*)"|'((?:[^']|'\\'')*)')/g
167
168function unescapeDoubleQuoted(text: string): string {
169  return text.replace(/\\([\\"$`\n])/g, '$1')
170}
171
172function unescapeSingleQuoted(text: string): string {
173  return text.replace(/'\\''/g, "'")
174}
175
176// The body of the first heredoc in a segment: from the line after the
177// `<<DELIM` opener to the line holding the delimiter alone. Null when there
178// is none or it is unterminated.
179function heredocBody(segment: string): string | null {
180  const open = /<<-?\s*(['"]?)(\w+)\1/.exec(segment)
181  if (open === null) return null
182  const delimiter = open[2] ?? ''
183  const bodyStart = segment.indexOf('\n', open.index)
184  if (bodyStart === -1) return null
185  const body: string[] = []
186  for (const line of segment.slice(bodyStart + 1).split('\n')) {
187    if (line.trim() === delimiter) return body.join('\n')
188    body.push(line)
189  }
190  return null
191}
192
193/**
194 * The message of the last `git commit` segment in a shell command, or null
195 * when there is none or it carries no message the gate can read (a message
196 * from a file or an editor, an unquoted word). Only that segment is read, so
197 * a script in a heredoc elsewhere in the command is never scanned, and of
198 * two chained commits the last one (the one that lands) is checked. Inside
199 * the segment a heredoc wins (`-F - <<'EOF'`, `-m "$(cat <<'EOF' ...)"`);
200 * otherwise every quoted `-m` in order, joined as git joins them.
201 */
202export function extractCommitMessage(command: string): string | null {
203  const commits = splitSegments(command).filter(segment => COMMIT_SEGMENT.test(segment))
204  const segment = commits[commits.length - 1]
205  if (segment === undefined) return null
206  const heredoc = heredocBody(segment)
207  if (heredoc !== null) return heredoc.trim() === '' ? null : heredoc
208  const parts: string[] = []
209  for (const match of segment.matchAll(MESSAGE_FLAG)) {
210    const text =
211      match[1] !== undefined ? unescapeDoubleQuoted(match[1]) : unescapeSingleQuoted(match[2] ?? '')
212    if (text.trim() !== '') parts.push(text)
213  }
214  return parts.length === 0 ? null : parts.join('\n\n')
215}
216
217export type FindingsSummary = {
218  total: number
219  errors: number
220  warnings: number
221  suggestions: number
222  /** The first three distinct matched texts, errors first. */
223  matches: string[]
224}
225
226type FindingLike = { severity?: unknown; match?: unknown }
227
228function orderedBySeverity(findings: FindingLike[]): FindingLike[] {
229  const rank = (finding: FindingLike): number =>
230    isSeverity(finding.severity) ? SEVERITIES.indexOf(finding.severity) : -1
231  return [...findings].sort((a, b) => rank(b) - rank(a))
232}
233
234/** Counts a detect payload's findings by severity and quotes the first few. */
235export function summarizeFindings(payload: unknown): FindingsSummary {
236  const summary: FindingsSummary = { total: 0, errors: 0, warnings: 0, suggestions: 0, matches: [] }
237  const findings = (payload as { findings?: unknown } | null)?.findings
238  if (!Array.isArray(findings)) return summary
239  const objects = findings.filter((f): f is FindingLike => f !== null && typeof f === 'object')
240  const seen = new Set<string>()
241  for (const item of orderedBySeverity(objects)) {
242    summary.total += 1
243    if (item.severity === 'error') summary.errors += 1
244    else if (item.severity === 'warning') summary.warnings += 1
245    else if (item.severity === 'suggestion') summary.suggestions += 1
246    const match = typeof item.match === 'string' ? item.match.trim() : ''
247    if (match !== '' && !seen.has(match) && summary.matches.length < QUOTED_MATCHES) {
248      seen.add(match)
249      summary.matches.push(match)
250    }
251  }
252  return summary
253}
254
255/** The summary of a `humanizer detect --json` run, or null when stdout is not its payload. */
256export function parseDetectOutput(stdout: string): FindingsSummary | null {
257  try {
258    const parsed: unknown = JSON.parse(stdout)
259    if (parsed === null || typeof parsed !== 'object' || !('findings' in parsed)) return null
260    return summarizeFindings(parsed)
261  } catch {
262    return null
263  }
264}
265
266function count(n: number, noun: string): string {
267  return `${n} ${noun}${n === 1 ? '' : 's'}`
268}
269
270/**
271 * The status line for a scanned target, or undefined (clear it) with
272 * nothing found. The engine prefixes the line with the mod's name, so the
273 * text never repeats it.
274 */
275export function statusText(summary: FindingsSummary, target: string): string | undefined {
276  if (summary.total === 0) return undefined
277  const parts: string[] = []
278  if (summary.errors > 0) parts.push(count(summary.errors, 'error'))
279  if (summary.warnings > 0) parts.push(count(summary.warnings, 'warning'))
280  if (summary.suggestions > 0) parts.push(count(summary.suggestions, 'suggestion'))
281  return `${parts.join(', ')} in ${target}`
282}
283
284/** The question the hold dialog asks: the error count and the first matches. */
285export function holdQuestion(summary: FindingsSummary, target: string): string {
286  const quoted = summary.matches.map(match => `"${match}"`).join(', ')
287  const detail = quoted === '' ? '' : ` (${quoted})`
288  return `humanizer found ${count(summary.errors, 'error-level finding')} in the ${target}${detail}. Send it anyway?`
289}
290
types/index.d.ts 17 lines
1export type HumanizerGateReport = {
2  /** What was scanned: a file's basename, `PR body`, or `commit message`. */
3  target: string
4  total: number
5  errors: number
6  warnings: number
7  suggestions: number
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'humanizer-gate': {
13      lastReport: HumanizerGateReport | null
14    }
15  }
16}
17