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…

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.
| Moment | What is scanned | What happens |
|---|---|---|
Write or Edit on a .md or .mdx file | the file on disk, after the write | a 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_request | the body field | status line; with an error-level finding and holdOnError on, a Proceed / Cancel dialog first |
Bash running git commit | the 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.
~/code/bin/humanizer is missing: one debug line at session start, then every hook is a pass-through.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.Set under pluginConfigs["humanizer-gate"].options in settings, or from /config:
| Key | Type | Default | Meaning |
|---|---|---|---|
holdOnError | boolean | true | ask before a PR call whose body has an error-level finding |
holdOnCommitError | boolean | false | ask before a git commit whose message has one |
minSeverity | suggestion, warning, error | warning | the lowest severity humanizer detect reports |
The last scan's counts sit in $.state under humanizer-gate.lastReport for another mod to read.
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.
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.
hooks/register.ts 148 lines1import { 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}
148lib/gate.ts 290 lines1// 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}
290types/index.d.ts 17 lines1export 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