Catches stock AI phrases, vague claims and unexplained jargon before a reader sees them. It checks Markdown writes, commit and pull request messages and…

A Claude Code plugin that checks prose the way a code linter checks source. It refuses a Markdown write, a commit or pull request message, or a tracker issue that breaks the ruleset, and holds a chat reply that does. Each refusal quotes the passage, names the rule and gives a rewrite hint. The ruleset and its exceptions are in the rule guide.
The plugin is a mod: TypeScript functions Claude Code runs on its own events. The settings-hook install that npx plain-english init --agent claude-code writes does the same job from outside the agent. The agent guide says how the two differ.
In a Claude Code session, version 2.1.293 or later:
/plugin install plain-english --marketplace nordscope-fi/plain-english
The plugin carries its own copy of the CLI under dist/, one file with every dependency inlined, and the ruleset beside it. Nothing is downloaded at install and no script runs.
Standalone compressed archives (ZIP) and checksums are attached to GitHub releases. Extract an archive, then pass its plain-english directory to claude --plugin-dir. The repository marketplace already supplies the plugin. An Anthropic directory listing requires a separate account-owner submission; the release guide contains the prepared details. The event checks described here target Claude Code.
The plugin checks writes and completed replies. Select Plain English under /config > Output style for writing guidance, then start a new session. Brief and full styles are also included. The bundled writing-a-document skill supplies document guidance when Claude invokes it.
notes.md opening with Furthermore, the build is slow. Before the file is written, a dialog headed Prose check opens: plain-english found one passage in notes.md that breaks its rules:
line 1: "Furthermore" (furthermore) Start the sentence with its own point.
Refusing hands Claude the findings and a way to fix each. Save the file as it is?
Two answers: save it as it is, or refuse so Claude rewrites. A refusal hands Claude the finding and the ways to resolve it, and Claude rewrites. With failOn: error in the project config there is no dialog: the save is refused outright and Claude reads the finding.
git commit -m "Leverage the new cache" waits on the dialog, which names the commit message and quotes "leverage" (leverage) Use 'use'. Under failOn: error the command is refused and Claude commits with a plainer message.The example sentences above sit in code spans and a code block so this file passes its own check.
Commands you run yourself:
/plain-english docs/ checks files without an extra model call. Quote paths containing spaces./plain-english review opens the recent findings panel./plain-english status reports the session's repair setting and recent findings./plain-english repair on allows one automatic rewrite of an advisory write. A repeated attempt asks you before proceeding. /plain-english repair off restores the immediate question. Required checks still refuse the write.| Event | What the hook does |
|---|---|
tool.call | On Write, Edit and MultiEdit of a .md or .mdx file, on a supported Bash Markdown write or message command (git commit, gh pr, gh issue or gh release), and on the Linear MCP save tools: runs the CLI's hook adapter on the call and returns its decision. A deny refuses the call with the reason. An ask opens a dialog headed Prose check that names the file or command and quotes one passage with its rule; a refusal there hands Claude the full finding, and where nobody can answer it refuses. Everything else passes through untouched. |
classic.Stop, classic.SubagentStop | Runs the chat adapter on the reply. A block holds the turn with the reason, in an interactive session and under claude -p alike. |
session.start | Registers /plain-english. |
prompt.context | Adds declared project vocabulary and loaded writing-profile observations to the conversation. The selected output style supplies general writing guidance. |
command.run | /plain-english [paths] lints the working tree and prints the findings. No model turn. |
$.model.complete), with your account and model service. Where Claude Code cannot make that call, the check falls back to claude -p. Document checks receive the complete proposed document as context, including its code examples and quoted material, but judge changed prose. Chat checks can also include your last question. These calls use your plan or API usage. Set modelChecks: false to disable them.claude -p fallback disables local session persistence. Provider retention follows your account and provider settings. The host conversation still follows Claude Code's normal storage behavior.Each extra check used to start a second copy of Claude Code, which took 1.3 to 1.6 seconds before the question was sent (measured on 2.1.294). The mod now asks the session's model itself. The CLI hands the question back, and the mod runs the CLI again with the answer, which takes about 0.18 seconds. A check can ask two questions at most. Both routes gave the same verdicts on 16 test prompts, recorded in ADR-006.
A failed checker produces a notice rather than a clean result. If the bundled CLI cannot run, the original action proceeds. If an extra model check cannot run, the pattern checks still apply. Document model calls share a 15-second deadline inside a 20-second tool check. Chat model calls share 45 seconds inside a 60-second hook.
On macOS, cancellation and timeouts were verified to stop the checker and its model child on Claude Code 2.1.293 and 2.1.294. Linux uses the same handling to stop a group of related processes and is checked in CI. Windows uses a process-tree termination command, but native Windows cancellation has not been verified. The checker wrapper also enforces its own timeout. Optional maintainer measurements record provider-reported usage and API price estimates without recording source prose. A missing measurement remains unknown; a reported price is not an account charge.
This section answers the Claude directory's checks one call at a time. Paths are relative to the plugin folder.
Every program is node, the runtime Claude Code itself uses, started in the plugin folder. No shell is started, so no command line is parsed or expanded. Every command is fixed text. The project folder reaches the checker in the PLAIN_ENGLISH_CWD setting, and the paths you type in PLAIN_ENGLISH_LINT_PATHS.
Where in hooks/register.ts | Command | Why |
|---|---|---|
spawnHook, through $.process.spawn | node hooks/run-checker.mjs hook docs --agent claude-code, and the same with github, issue or chat, each written out in full, with the event as JSON on standard input | Runs the bundled checker on one proposed write or finished reply. The event on standard input is data for the checker to read, not code. |
term approval, through $.process.run | node hooks/run-checker.mjs approve, with the request as JSON on standard input | Checks and then saves one approved term in the project config. The mod runs it twice: once to check, and once to write after you confirm. |
prompt.context, through $.process.run | node hooks/run-checker.mjs guidance | Reads the project's declared vocabulary to add to the conversation. |
/plain-english, through $.process.run | node hooks/run-checker.mjs lint | Checks the files you name. |
hooks/run-checker.mjs starts the checker, dist/cli.mjs beside it, as a child process in the project folder, and stops it, with any child of its own, when Claude Code cancels the hook or its time runs out. On Windows it uses taskkill for that. The checker starts one more program in a single case: claude -p, as the fallback for an extra model check when $.model.complete cannot be made.
Only the extra model checks send anything, and only to Claude. Through $.model.complete, the session's own model and account receive the text being checked: a proposed document, a commit or issue text, or a finished reply and your last question. The fallback claude -p sends the same text to the same account. Neither the mod nor the checker makes any other network request. Set modelChecks: false to keep everything on the machine.
The mod reads no credential. It reads no environment variable, token or key. hooks/run-checker.mjs reads one variable, PLAIN_ENGLISH_CHECK_TIMEOUT_MS, which the mod itself sets. The ruleset in rules/default.yml contains words such as "secret" and "token" only inside example sentences, and names github.com only in documentation links.
The mod itself reads and writes no files. The checker it starts writes two kinds:
.plain-english.yml. Written only when you approve a term in the review panel and then confirm it in a dialog. node dist/cli.mjs approve adds one exception for that term, and the checker reads it on its next run. The write step refuses unless the project folder and the file are byte for byte what the check step saw. It refuses an inherited or linked config too. It never writes Claude Code's own settings, instruction files or build files.tool.call: reads proposed writes, commands and tracker-tool calls. It can refuse one or ask you about it, and never changes the call's input.classic.Stop and classic.SubagentStop: read finished replies, and can hold one for a rewrite.prompt.context: adds one section with your project's declared vocabulary. It leaves the existing context in place.command.run: answers /plain-english, which the mod registers on session.start.dist/cli.mjs is the plain-english checker from this repository, bundled with its dependencies by scripts/build-plugin.mjs and left unminified. Two of those dependencies, @babel/parser and yaml, are bundled into dist/vendor/ and imported from there, so every file stays under the directory's read limit of 1,048,576 bytes. The same build is published on npm with a signed record of the GitHub build that produced it.
The CLI reads .plain-english.yml in the project as it does everywhere else. With no file, a finding is advisory and the mod asks before the write. To make findings refuse outright, set failOn: error there. Chat has its own setting and blocks errors by default. Set chat.failOn: never to report chat findings without holding the reply. Extra model checks follow modelChecks: false disables them, true enables them, and omission retains the Claude Code default. The adoption guide walks through the file, and the vocabulary section of the main README covers project terms.
A term the ruleset flags that is ordinary in your field goes in the config under allow, or on the line itself as an HTML comment naming the rule and, after a colon, the reason. The rule guide shows the comment. A suppression without a reason is itself a finding. The review panel offers a one-use exception for the identical advisory attempt, approval of a term for one rule across the project, or a suppression comment copied with your reason. Paste that comment above the intended passage yourself. Inherited or linked configuration needs a manual edit. Term approval asks you to confirm: the named rule is waived on matching lines across the project. A required check must be fixed or resolved through the project config or a valid suppression comment. plain-english doctor prints the environment for a bug report.
Report a problem at <https://github.com/nordscope-fi/plain-english/issues>. Security concerns go to <peter@nordscope.fi>, not to the public tracker.
npm run build at the repository root compiles the CLI and writes the bundle and ruleset copy into this folder. The build also copies generated styles and the document skill. These files are committed, and CI fails when a build changes them and the change was not committed. Then, from this folder:
claude --plugin-dir .
claude plugin validate --strict .
claude plugin test .
The manifest's version moves with each release; npm version does that.
MIT, as the rest of the repository.
hooks/register.ts 494 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { classifyShellCommand } from './shell.mjs'
4import { ISSUE_TOOLS } from './issue-tools.mjs'
5import { askFor, noticeLine, oneLine, readChatVerdict, readPassages, readPaths, readToolVerdict, toolPayload } from './wire'
6
7/** Files the docs channel judges. The CLI strips code and frontmatter itself. */
8const MARKDOWN = /\.(md|markdown|mdx)$/i
9
10/**
11 * The CLI's own hook budget is half a second of matching; the chat judge may
12 * shell to a model and take seconds. Both stay far under the ten-minute cap,
13 * and time inside `$.process.spawn` never counts against the hook's budget.
14 */
15const TOOL_TIMEOUT_MS = 20_000
16const CHAT_TIMEOUT_MS = 60_000
17
18/** Fixed CLI diagnostics only; checker stderr can otherwise contain private text. */
19const SAFE_CHECK_NOTICES = new Set([
20 'plain-english: extra model check could not start; pattern checks still apply.',
21 'plain-english: extra model check timed out; pattern checks still apply.',
22 'plain-english: extra model check failed; pattern checks still apply.',
23 'plain-english: extra model check returned no usable answer; pattern checks still apply.',
24 'plain-english: model usage capture unavailable.',
25 'plain-english: configuration unavailable; using local built-in pattern checks as advice only.',
26])
27
28const COMMAND = 'plain-english'
29const PANE = 'plain-english-review'
30
31type Channel = 'docs' | 'github' | 'issue' | 'chat'
32
33interface ReviewFinding {
34 channel: Channel
35 reason: string
36 strict: boolean
37 event: Readonly<Record<string, unknown>>
38 cwd: string
39 key: string
40}
41
42/** One step of a term approval, as the checker reports it (ADR-007). */
43interface ApprovalStep { root: string; config: string; exists: boolean; hash: string; modelVocabulary: boolean }
44
45/**
46 * Asks the checker to run one approval step. The request goes on standard
47 * input, so the command is fixed text; the checker does every file read and
48 * write, and the mod reads and writes no file of its own.
49 */
50async function approvalStep($: EngineInterface, cwd: string, request: Record<string, unknown>): Promise<ApprovalStep> {
51 const ran = await $.process.run(['node', 'hooks/run-checker.mjs', 'approve'], {
52 cwd: $.plugin.root, env: projectEnv(cwd, 5_000), stdin: JSON.stringify(request), timeoutMs: 6_000,
53 })
54 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || 'The approval check did not run.')
55 let answer: Record<string, unknown>
56 try { answer = JSON.parse(ran.stdout) as Record<string, unknown> } catch { throw new Error('The approval check returned an unreadable answer.') }
57 if (answer['ok'] !== true) throw new Error(typeof answer['message'] === 'string' ? answer['message'] : 'This term cannot be approved.')
58 return answer as unknown as ApprovalStep
59}
60
61/** Only a deliberate, confirmed user action can save project vocabulary. */
62async function approveProjectTerm($: EngineInterface, finding: ReviewFinding, term: string, ruleId: string, reason: string): Promise<string> {
63 try {
64 const request = { term, rule: ruleId, reason }
65 const checked = await approvalStep($, finding.cwd, { ...request, phase: 'check' })
66 const modelVocabulary = checked.modelVocabulary ? ' The extra model check and writing guidance will also treat this term as known vocabulary.' : ''
67 const answer = await $.ui.ask(`Approve ${JSON.stringify(term)} project-wide for the ${ruleId} rule? This waives that rule on every line containing this exact term, across the project. Other rules still apply.${modelVocabulary} This saves an exception in ${checked.config}. Reason: ${reason}`, {
68 header: 'Approve term', options: ['Approve for this project', 'Cancel'],
69 })
70 if (answer !== 'Approve for this project') return 'Project vocabulary was not changed.'
71 await approvalStep($, finding.cwd, { ...request, phase: 'write', expect: checked })
72 $.ui.invalidate('prompt.context')
73 return `Approved ${JSON.stringify(term)} for ${ruleId} across this project. Other rules still apply. Retry the checked write.`
74 } catch (error) {
75 return `Project vocabulary was not changed: ${String(error)}`
76 }
77}
78
79/** One question the checker handed back instead of a decision (ADR-006). */
80interface ModelRequest { key: string; prompt: string; timeoutMs: number; deadline?: number; model?: string }
81
82/** The mod's answer to one request, as the checker reads it back. */
83interface ModelAnswer { key: string; text?: string; unavailable?: 'timed out' | 'failed'; usage?: Record<string, number> }
84
85/** One run per model question, plus the run that decides: two questions at most. */
86const MAX_CHECKER_RUNS = 3
87
88/**
89 * Runs the CLI's hook adapter on one payload and returns its stdout.
90 *
91 * The CLI hands each model question back instead of starting `claude -p`,
92 * which saves about 1.2 seconds per question on 2.1.294. The mod asks the
93 * session's model and runs the CLI again with every answer so far; the CLI
94 * replays its decision and finds them (ADR-006). Where the call cannot be made
95 * at all, the check runs once more the old way.
96 */
97async function adapter(
98 $: EngineInterface,
99 channel: Channel,
100 payload: Record<string, unknown>,
101 cwd: string,
102 timeoutMs: number,
103 signal: AbortSignal,
104): Promise<string> {
105 const plain: Record<string, string> = { CLAUDE_PROJECT_DIR: cwd, PLAIN_ENGLISH_CHECK_TIMEOUT_MS: String(timeoutMs) }
106 const variables = { ...plain, PLAIN_ENGLISH_MODEL_ROUTE: 'host' }
107 const answers: ModelAnswer[] = []
108 let deadline: number | undefined
109 for (let run = 1; ; run++) {
110 const input = run === 1 ? payload : { ...payload, plainEnglishModel: { deadline, answers } }
111 const ran = await runChecker($, channel, input, cwd, variables, signal)
112 const asked = modelRequest(ran.stdout)
113 if (asked === undefined) return finish($, channel, ran)
114 if (run === MAX_CHECKER_RUNS) throw new Error('check unavailable: the checker kept asking for a model answer.')
115 deadline = asked.deadline
116 const answer = await answerModel($, asked, signal)
117 if (answer === undefined) return finish($, channel, await runChecker($, channel, payload, cwd, plain, signal))
118 answers.push(answer)
119 }
120}
121
122/** The request in a checker's stdout, or `undefined` for anything else. */
123function modelRequest(stdout: string): ModelRequest | undefined {
124 try {
125 const request = (JSON.parse(stdout) as Record<string, unknown>)['plainEnglishModelRequest'] as Record<string, unknown> | undefined
126 if (!request || typeof request['key'] !== 'string' || typeof request['prompt'] !== 'string' || typeof request['timeoutMs'] !== 'number') return undefined
127 return {
128 key: request['key'], prompt: request['prompt'], timeoutMs: request['timeoutMs'],
129 ...(typeof request['deadline'] === 'number' ? { deadline: request['deadline'] } : {}),
130 ...(typeof request['model'] === 'string' ? { model: request['model'] } : {}),
131 }
132 } catch {
133 return undefined
134 }
135}
136
137/**
138 * Asks the session's model one question. A provider failure becomes an answer
139 * the checker reads as unavailable, so the pattern result stands, as it does
140 * when `claude -p` fails. A call that could not be made at all (an engine
141 * without it, or a request it refuses to send) returns `undefined`, and the
142 * check runs the old way. A cancelled turn is not an answer: it ends the check.
143 */
144async function answerModel($: EngineInterface, asked: ModelRequest, signal: AbortSignal): Promise<ModelAnswer | undefined> {
145 try {
146 const model = asked.model ?? await $.session.model()
147 const reply = await $.model.complete({ model, prompt: asked.prompt, timeoutMs: Math.max(1, Math.round(asked.timeoutMs)) }, { signal })
148 if (reply.isAnswered) return { key: asked.key, text: reply.text, usage: { ...reply.usage } }
149 signal.throwIfAborted()
150 return { key: asked.key, unavailable: reply.reason === 'aborted' ? 'timed out' : 'failed' }
151 } catch {
152 signal.throwIfAborted()
153 return undefined
154 }
155}
156
157/**
158 * The settings every checker run gets. The mod starts `hooks/run-checker.mjs`
159 * from the plugin folder with fixed arguments, so the Claude directory can read
160 * each command in full; the wrapper runs the CLI in this project folder.
161 */
162function projectEnv(cwd: string, timeoutMs: number): Record<string, string> {
163 return { PLAIN_ENGLISH_CWD: cwd, PLAIN_ENGLISH_CHECK_TIMEOUT_MS: String(timeoutMs) }
164}
165
166/** One hook run per channel, each command written out in full. */
167function spawnHook($: EngineInterface, channel: Channel, init: { cwd: string; env: Record<string, string>; input: string }) {
168 switch (channel) {
169 case 'docs': return $.process.spawn({ argv: ['node', 'hooks/run-checker.mjs', 'hook', 'docs', '--agent', 'claude-code'], ...init })
170 case 'github': return $.process.spawn({ argv: ['node', 'hooks/run-checker.mjs', 'hook', 'github', '--agent', 'claude-code'], ...init })
171 case 'issue': return $.process.spawn({ argv: ['node', 'hooks/run-checker.mjs', 'hook', 'issue', '--agent', 'claude-code'], ...init })
172 case 'chat': return $.process.spawn({ argv: ['node', 'hooks/run-checker.mjs', 'hook', 'chat', '--agent', 'claude-code'], ...init })
173 }
174}
175
176/** One CLI run: its output, held to the byte limits, and its exit code. */
177async function runChecker(
178 $: EngineInterface,
179 channel: Channel,
180 payload: Record<string, unknown>,
181 cwd: string,
182 variables: Record<string, string>,
183 signal: AbortSignal,
184): Promise<{ stdout: string; stderr: string }> {
185 // The stream follows the dispatch's cancellation signal. The asynchronous
186 // wrapper relays it to the CLI's group, including a synchronous model child.
187 const init = { cwd: $.plugin.root, env: { ...variables, PLAIN_ENGLISH_CWD: cwd }, input: JSON.stringify(payload) }
188 const stream = spawnHook($, channel, init)
189 const ran = { stdout: '', stderr: '', exitCode: null as number | null }
190 const bytes = { stdout: 0, stderr: 0 }
191 const stop = () => { void stream.return({ code: null, signal: null }).catch(() => {}) }
192 signal.addEventListener('abort', stop, { once: true })
193 try {
194 signal.throwIfAborted()
195 for (;;) {
196 const chunk = await stream.next()
197 if (chunk.done) {
198 ran.exitCode = chunk.value.code
199 if (chunk.value.signal !== null) throw new Error(`check unavailable (signal ${chunk.value.signal}).`)
200 break
201 }
202 bytes[chunk.value.stream] += new TextEncoder().encode(chunk.value.text).length
203 if (bytes[chunk.value.stream] > 4_194_304) throw new Error('check unavailable: checker output exceeded 4 MB.')
204 ran[chunk.value.stream] += chunk.value.text
205 }
206 } finally {
207 signal.removeEventListener('abort', stop)
208 await stream.return({ code: null, signal: null })
209 }
210 if (ran.exitCode !== 0) throw new Error(`check unavailable (exit ${ran.exitCode}). ${ran.stderr.trim()}`)
211 return ran
212}
213
214/**
215 * Checks the deciding run's answer and reports its notices. Only this run's
216 * notices are logged: it replayed every earlier question, so it repeats theirs.
217 */
218function finish($: EngineInterface, channel: Channel, ran: { stdout: string; stderr: string }): string {
219 if (ran.stdout.trim() !== '') {
220 let parsed: unknown
221 try { parsed = JSON.parse(ran.stdout) } catch { throw new Error('check unavailable: the checker returned an unreadable response.') }
222 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('check unavailable: the checker returned an unexpected response.')
223 const record = parsed as Record<string, unknown>
224 if (channel === 'chat') {
225 if (record['decision'] === 'block' && typeof record['reason'] === 'string') {
226 // A refused reply.
227 } else if (record['decision'] === undefined && typeof record['systemMessage'] === 'string') {
228 // An allowed reply with advice.
229 } else throw new Error('check unavailable: the reply checker returned an unknown decision.')
230 } else {
231 const specific = record['hookSpecificOutput'] as Record<string, unknown> | undefined
232 if (specific === undefined || specific === null || typeof specific !== 'object' ||
233 !['deny', 'ask'].includes(String(specific['permissionDecision'])) || typeof specific['permissionDecisionReason'] !== 'string') {
234 throw new Error('check unavailable: the write checker returned an unknown decision.')
235 }
236 }
237 }
238 for (const notice of new Set(ran.stderr.split(/\r?\n/).map(line => line.trim()))) {
239 if (SAFE_CHECK_NOTICES.has(notice)) log($, notice)
240 }
241 return ran.stdout
242}
243
244/**
245 * One transcript row. `$.ui.log` draws a single line: Claude Code 2.1.294
246 * shows a line break inside it as U+FFFD and puts the plugin's name in front
247 * of the row itself, so the text carries neither (issue #80).
248 */
249function log($: EngineInterface, text: string): void {
250 $.ui.log(oneLine(text).replace(/^plain-english: /, ''))
251}
252
253async function session($: EngineInterface): Promise<{ id: string; cwd: string }> {
254 const [id, cwd] = await Promise.all([$.session.id(), $.session.cwd()])
255 return { id, cwd }
256}
257
258/** Which write channel a tool call belongs to, or none. */
259function channelOf(e: Readonly<Record<string, unknown>>): 'docs' | 'github' | 'issue' | undefined {
260 const tool = String(e['tool'])
261 if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') {
262 return MARKDOWN.test(String(e['file_path'] ?? '')) ? 'docs' : undefined
263 }
264 if (tool === 'Bash') {
265 return classifyShellCommand(String(e['command'] ?? ''))
266 }
267 return ISSUE_TOOLS.test(tool) ? 'issue' : undefined
268}
269
270/**
271 * The chat gate, shared by the two stop events: the reply as the event
272 * carries it goes to the chat adapter. A block holds the turn with the
273 * reason, as the settings hook's flat JSON does, and never runs the settings
274 * hooks beneath, so a project that also installed them is judged once. A
275 * pass hands on to them.
276 *
277 * Verified live on 2.1.293: the block holds the turn in an interactive
278 * session and under `claude -p`, where a settings `Stop` block is ignored.
279 */
280async function judgeReply<E extends { last_assistant_message?: string }, R extends { block?: string }>(
281 $: EngineInterface,
282 e: E,
283 next: ((e: E) => Promise<R>) & { readonly signal: AbortSignal },
284 record: (finding: ReviewFinding) => void,
285): Promise<R | { block: string }> {
286 const cwd = await $.session.cwd()
287 const payload = e as unknown as Record<string, unknown>
288 const stdout = await adapter($, 'chat', payload, cwd, CHAT_TIMEOUT_MS, next.signal)
289 const verdict = readChatVerdict(stdout)
290 if (verdict.notice !== undefined) log($, noticeLine(verdict.notice, verdict.kind === 'block'))
291 if (verdict.kind === 'block') {
292 record({ channel: 'chat', reason: verdict.reason, strict: true, event: payload, cwd, key: '' })
293 return { block: verdict.reason }
294 }
295 if (verdict.notice !== undefined) record({ channel: 'chat', reason: verdict.notice, strict: false, event: payload, cwd, key: '' })
296 return next(e)
297}
298
299export const register: Register = on => {
300 let repair = false
301 const repairAttempts = new Set<string>()
302 const keepOnce = new Set<string>()
303 let actionNotice = ''
304 let exceptionReason = ''
305 let current: ReviewFinding | undefined
306 /**
307 * One tool.call hook serves the three write channels, picked by tool name.
308 * Fail-open: a `.catch` that calls `next(e)` lets the write through when the
309 * adapter itself fails, which is the contract every plain-english hook has.
310 */
311 on('tool.call', async ($, e, next) => {
312 const channel = channelOf(e)
313 if (channel === undefined) return next(e)
314
315 const here = await session($)
316 const payload = toolPayload(e, here)
317 const key = JSON.stringify([here.id, channel, e['agentId'] ?? '', payload['tool_name'], payload['tool_input']])
318 const stdout = await adapter($, channel, payload, here.cwd, TOOL_TIMEOUT_MS, next.signal)
319 const verdict = readToolVerdict(stdout)
320 if (verdict.kind !== 'allow') {
321 current = { channel, reason: verdict.reason, strict: verdict.kind === 'deny', event: e, cwd: here.cwd, key }
322 $.ui.invalidate('ui.render')
323 }
324
325 const repairKey = `${channel}:${String(e['agentId'] ?? '')}:${String(e['file_path'] ?? e['tool'])}`
326 if (verdict.kind === 'allow') repairAttempts.delete(repairKey)
327
328 if (verdict.kind === 'deny') return { deny: verdict.reason }
329 if (verdict.kind === 'ask') {
330 if (keepOnce.delete(key)) {
331 repairAttempts.delete(repairKey)
332 return next(e)
333 }
334 if (repair && !repairAttempts.has(repairKey)) {
335 repairAttempts.add(repairKey)
336 return { deny: `Rewrite attempt 1 of 1. Correct the quoted passages and retry this write. If findings remain, the user decides.\n\n${verdict.reason}` }
337 }
338 // The settings hook's `ask` hands the decision to the person. So does
339 // this, in the engine's own dialog, with a question written for them;
340 // the model's guidance travels in the deny. Nobody to ask (a -p run)
341 // refuses.
342 const ask = askFor(channel, e, here.cwd, verdict.reason)
343 let answer: string | undefined
344 try {
345 answer = await $.ui.ask(ask.question, {
346 header: ask.header,
347 options: [ask.allow, ask.refuse],
348 })
349 } catch {
350 // dismissed, or no one to ask
351 }
352 if (answer !== ask.allow) {
353 const decision = answer === ask.refuse ? 'The user was asked and refused this write.'
354 : answer ? `The user answered instead of choosing: ${JSON.stringify(answer)}`
355 : 'No approval was received. The dialog was unavailable, dismissed, or unanswered.'
356 return { deny: `${decision}\n\n${verdict.reason}` }
357 }
358 repairAttempts.delete(repairKey)
359 }
360 return next(e)
361 }).catch(($, e, next) => {
362 log($, `check unavailable; the write was allowed. ${next.error.message ?? 'The checker did not finish.'}`)
363 return next(e)
364 })
365
366 // Two registrations, not a loop: the validator reads each event name off the
367 // source as a string literal, and a name held in a variable does not load.
368 on('classic.Stop', ($, e, next) => judgeReply($, e, next, finding => {
369 current = finding
370 $.ui.invalidate('ui.render')
371 })).catch(($, e, next) => {
372 log($, `reply check unavailable; the reply was allowed. ${next.error.message ?? 'The checker did not finish.'}`)
373 return next(e)
374 })
375 on('classic.SubagentStop', ($, e, next) => judgeReply($, e, next, finding => {
376 current = finding
377 $.ui.invalidate('ui.render')
378 })).catch(($, e, next) => {
379 log($, `reply check unavailable; the reply was allowed. ${next.error.message ?? 'The checker did not finish.'}`)
380 return next(e)
381 })
382
383 on('session.start', async ($, e, next) => {
384 await $.command.register({
385 name: COMMAND,
386 description: 'Lint the working tree with plain-english and show the findings',
387 argumentHint: '[paths | review | status | repair on/off]',
388 })
389 return next(e)
390 })
391
392 on('prompt.context', async ($, e, next) => {
393 const base = await next(e)
394 try {
395 const cwd = await $.session.cwd()
396 const ran = await $.process.run(['node', 'hooks/run-checker.mjs', 'guidance'], { cwd: $.plugin.root, env: projectEnv(cwd, 5_000), timeoutMs: 6_000 })
397 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `exit ${ran.exitCode}`)
398 const text = ran.stdout.trim()
399 if (new TextEncoder().encode(text).length > 32_768) throw new Error('Project writing guidance is too large (over 32 KB). Reduce project vocabulary or writing observations before loading it.')
400 if (text === '') return base
401 return { ...base, blocks: [...base.blocks.filter(block => block.name !== 'plainEnglishProject'), { name: 'plainEnglishProject', text }] }
402 } catch (error) {
403 log($, `project writing guidance unavailable. ${String(error)}`)
404 return base
405 }
406 })
407
408 on('command.run', { command: COMMAND }, async ($, e) => {
409 const cwd = await $.session.cwd()
410 if (e.args.trim() === 'review') {
411 await $.ui.open({ id: PANE, title: 'Plain English findings', focus: true, closeOnEscape: true })
412 return {}
413 }
414 if (e.args.trim() === 'status') {
415 return { text: `repair ${repair ? 'on' : 'off'} for this session. ${current === undefined ? 'No recent findings.' : `${readPassages(current.reason).length} quoted findings in the most recent check.`} Extra model checks follow project configuration; this command does not change them.` }
416 }
417 if (e.args.trim() === 'repair on' || e.args.trim() === 'repair off') {
418 repair = e.args.trim() === 'repair on'
419 repairAttempts.clear()
420 return { text: `repair ${repair ? 'on' : 'off'} for this session. ${repair ? 'Advisory writes get one correction attempt before asking you. Required checks still refuse.' : 'Advisory writes ask you immediately.'}` }
421 }
422 let paths: string[]
423 try {
424 paths = readPaths(e.args)
425 } catch (error) {
426 return { text: String(error) }
427 }
428 try {
429 const typed = paths.map(path => path.startsWith('-') ? `./${path}` : path)
430 const ran = await $.process.run(['node', 'hooks/run-checker.mjs', 'lint'], {
431 cwd: $.plugin.root,
432 env: { ...projectEnv(cwd, 120_000), PLAIN_ENGLISH_LINT_PATHS: JSON.stringify(typed) },
433 timeoutMs: 125_000,
434 })
435 const text = (ran.stdout + ran.stderr).trim()
436 if (ran.exitCode !== 0 && (ran.exitCode !== 1 || text === '')) {
437 return { text: `check unavailable (exit ${ran.exitCode}).${text === '' ? '' : '\n' + text}` }
438 }
439 return { text: text === '' ? 'no findings.' : text }
440 } catch (error) {
441 return { text: `check unavailable. ${String(error)}` }
442 }
443 })
444
445 on('ui.render', { component: 'Pane' }, ($, e, next) => {
446 if (e.requestId !== PANE) return next(e)
447 const { Box, Text, Button, Input } = $.ui.resolve(e)
448 const displayed = current
449 const passages = current === undefined ? [] : readPassages(current.reason)
450 return Box({ flexDirection: 'column', children: [
451 Text({ bold: true, children: [passages.length === 0 && current !== undefined ? 'Latest finding' : `${passages.length} findings`] }),
452 Text({ children: [`Repair ${repair ? 'on' : 'off'} for this session.`] }),
453 Text({ children: [current === undefined ? 'No recent finding.' : `Most recent finding in ${String(current.event['file_path'] ?? current.channel)}.`] }),
454 ...passages.map(passage => Text({ children: [`line ${passage.line}: ${JSON.stringify(passage.match)} (${passage.ruleId})${passage.hint === undefined ? '' : ' ' + passage.hint}`] })),
455 ...(displayed === undefined ? [] : [Input({ key: 'exception-reason', label: 'Reason for an exception', value: exceptionReason, placeholder: 'Explain why this wording belongs here', onSubmit: text => {
456 exceptionReason = text.trim()
457 $.ui.invalidate('ui.render')
458 } })]),
459 ...passages.flatMap((passage, index) => {
460 if (displayed === undefined || !/^[A-Za-z][\w .-]{0,79}$/.test(passage.match) || /(?:length|count|paragraph|sentence|structure)/.test(passage.ruleId)) return []
461 return [Button({ key: `approve-term-${index}`, label: `Approve ${JSON.stringify(passage.match)} for this rule`, onPress: async () => {
462 if (exceptionReason === '') actionNotice = 'Enter a reason and press Enter before approving a project term.'
463 else actionNotice = await approveProjectTerm($, displayed, passage.match, passage.ruleId, exceptionReason)
464 $.ui.invalidate('ui.render')
465 } })]
466 }),
467 ...(displayed?.channel !== 'docs' ? [] : passages.map((passage, index) => Button({
468 key: `copy-exception-${index}`, label: `Copy next-line exception for ${passage.ruleId}`, onPress: async () => {
469 if (exceptionReason === '' || /[\r\n\x00-\x1f]|-->/.test(exceptionReason)) {
470 actionNotice = 'Enter a one-line reason without comment markup, then press Enter.'
471 } else {
472 const comment = `<!-- plain-english-disable-next-line ${passage.ruleId}: ${exceptionReason} -->`
473 const copied = await $.ui.copy({ text: comment })
474 actionNotice = copied.isCopied
475 ? `Copied ${comment}. Paste it immediately above the intended passage. This exempts one rule on the next line; no file was changed.`
476 : `Clipboard unavailable. Paste ${comment} immediately above the intended passage.`
477 }
478 $.ui.invalidate('ui.render')
479 },
480 }))),
481 Text({ children: [current?.channel === 'chat'
482 ? current.strict ? 'Required reply checks refuse this reply. Correct it or add a justified project exception.' : 'The reply was allowed with advice. No temporary reply waiver was created.'
483 : current?.strict ? 'Required checks refuse this write. Correct it or add a justified project exception.' : 'Keep this write once in the advisory decision dialog, or refuse so Claude rewrites.'] }),
484 ...(displayed === undefined || displayed.strict || displayed.channel === 'chat' ? [] : [Button({ key: 'keep-once', label: 'Keep this write once', onPress: () => {
485 keepOnce.add(displayed.key)
486 actionNotice = 'Approved the identical write once for this session. Ask Claude to retry it. Changed text still gets checked.'
487 $.ui.invalidate('ui.render')
488 } })]),
489 ...(actionNotice === '' ? [] : [Text({ children: [actionNotice] })]),
490 ...(passages.length === 0 && current !== undefined ? [Text({ children: [current.reason] })] : []),
491 ] })
492 })
493}
494hooks/shell.mjs 380 lines1// GENERATED by scripts/build-plugin.mjs from src/shell.ts. Do not edit.
2
3// src/shell.ts
4function windowsPathPrefix(text) {
5 return /^(?:-[FC]|--(?:file|body-file|notes-file)=)?(?:[A-Za-z]:|\\\\)/.test(text);
6}
7var SEPARATORS = /* @__PURE__ */ new Set([";", "\n", "&"]);
8function parseCommands(input) {
9 const commands = [];
10 let current = { words: [], redirects: [], heredocs: [], unterminated: false };
11 let word = "";
12 let quoted = false;
13 let expands = false;
14 let pendingRedirect = null;
15 const pendingHeredocs = [];
16 const endWord = () => {
17 if (!word && !quoted) return;
18 const w = { text: word, expands };
19 if (pendingRedirect) {
20 current.redirects.push({ target: w, exotic: pendingRedirect.exotic });
21 pendingRedirect = null;
22 } else {
23 current.words.push(w);
24 }
25 word = "";
26 quoted = false;
27 expands = false;
28 };
29 const endCommand = () => {
30 endWord();
31 if (current.words.length || current.redirects.length || current.heredocs.length) {
32 commands.push(current);
33 }
34 current = { words: [], redirects: [], heredocs: [], unterminated: false };
35 };
36 let i = 0;
37 while (i < input.length) {
38 const c = input[i];
39 if (c === "\n" && pendingHeredocs.length) {
40 endWord();
41 i++;
42 for (const h of pendingHeredocs.splice(0)) {
43 const body = [];
44 let closed = false;
45 while (i < input.length) {
46 let nl = input.indexOf("\n", i);
47 if (nl === -1) nl = input.length;
48 const line = input.slice(i, nl);
49 i = nl + 1;
50 if ((h.raw ? line.replace(/^\t+/, "") : line.trim()) === h.tag) {
51 closed = true;
52 break;
53 }
54 body.push(line);
55 }
56 if (!closed) current.unterminated = true;
57 current.heredocs.push(body.join("\n"));
58 }
59 continue;
60 }
61 if (c === "\\") {
62 if (/^(?:-[FC]|--(?:file|body-file|notes-file)=)?$/.test(word) && input[i + 1] === "\\" && /[A-Za-z0-9]/.test(input[i + 2] ?? "")) {
63 word += "\\\\";
64 i += 2;
65 continue;
66 }
67 if (windowsPathPrefix(word)) {
68 word += c;
69 i++;
70 continue;
71 }
72 if (input[i + 1] === "\n") {
73 i += 2;
74 continue;
75 }
76 if (i + 1 < input.length) {
77 word += input[i + 1];
78 quoted = true;
79 i += 2;
80 continue;
81 }
82 i++;
83 continue;
84 }
85 if (c === "'") {
86 const close = input.indexOf("'", i + 1);
87 if (close === -1) {
88 current.unterminated = true;
89 break;
90 }
91 word += input.slice(i + 1, close);
92 quoted = true;
93 i = close + 1;
94 continue;
95 }
96 if (c === '"') {
97 let j = i + 1;
98 let out = "";
99 let closed = false;
100 while (j < input.length) {
101 if (input[j] === "\\" && j + 1 < input.length) {
102 if (!out && input[j + 1] === "\\" && /[A-Za-z0-9]/.test(input[j + 2] ?? "")) {
103 out = "\\\\";
104 j += 2;
105 } else if (windowsPathPrefix(out)) {
106 out += "\\";
107 j++;
108 } else if ('\\"$`'.includes(input[j + 1])) {
109 out += input[j + 1];
110 j += 2;
111 } else if (input[j + 1] === "\n") {
112 j += 2;
113 } else {
114 out += "\\";
115 j++;
116 }
117 continue;
118 }
119 if (input[j] === '"') {
120 closed = true;
121 break;
122 }
123 if (input[j] === "$" || input[j] === "`") expands = true;
124 out += input[j];
125 j++;
126 }
127 if (!closed) {
128 current.unterminated = true;
129 break;
130 }
131 word += out;
132 quoted = true;
133 i = j + 1;
134 continue;
135 }
136 if (c === "`") {
137 expands = true;
138 let j = i + 1;
139 while (j < input.length && input[j] !== "`") {
140 if (input[j] === "\\") j++;
141 j++;
142 }
143 if (j >= input.length) {
144 current.unterminated = true;
145 break;
146 }
147 word += input.slice(i, j + 1);
148 i = j + 1;
149 continue;
150 }
151 if (c === "$") {
152 expands = true;
153 word += c;
154 i++;
155 continue;
156 }
157 if (c === "<" && input[i + 1] === "<") {
158 if (input[i + 2] === "<") {
159 endWord();
160 i += 3;
161 continue;
162 }
163 endWord();
164 let j = i + 2;
165 const raw = input[j] === "-";
166 if (raw) j++;
167 while (input[j] === " " || input[j] === " ") j++;
168 let tag = "";
169 let q = "";
170 if (input[j] === "'" || input[j] === '"') {
171 q = input[j];
172 j++;
173 }
174 while (j < input.length && /[A-Za-z0-9_]/.test(input[j])) tag += input[j++];
175 if (q && input[j] === q) j++;
176 if (tag) pendingHeredocs.push({ tag, raw });
177 i = j;
178 continue;
179 }
180 if (c === ">") {
181 endWord();
182 let j = i + 1;
183 let exotic = false;
184 if (input[j] === ">") j++;
185 if (input[j] === "&" || input[j] === "(") {
186 exotic = true;
187 j++;
188 }
189 const prev = current.words[current.words.length - 1];
190 if (prev && /^\d$/.test(prev.text) && !prev.expands) {
191 current.words.pop();
192 exotic = true;
193 }
194 pendingRedirect = { exotic };
195 i = j;
196 continue;
197 }
198 if (c === "<") {
199 endWord();
200 i++;
201 while (i < input.length && /\s/.test(input[i])) i++;
202 while (i < input.length && !/[\s;&|<>]/.test(input[i])) i++;
203 continue;
204 }
205 if (c === "|") {
206 endCommand();
207 i += input[i + 1] === "|" ? 2 : 1;
208 continue;
209 }
210 if (SEPARATORS.has(c)) {
211 endCommand();
212 i += input[i + 1] === c && c === "&" ? 2 : 1;
213 continue;
214 }
215 if (c === " " || c === " " || c === "\r") {
216 endWord();
217 i++;
218 continue;
219 }
220 if (c === "(" || c === ")") {
221 endCommand();
222 i++;
223 continue;
224 }
225 word += c;
226 i++;
227 }
228 endCommand();
229 return commands;
230}
231var ECHO_FLAGS = /* @__PURE__ */ new Set(["-e", "-n", "-E"]);
232function printfContent(words) {
233 let args = words;
234 if (args[0]?.text === "--") args = args.slice(1);
235 else if (args[0]?.text.startsWith("-")) return "";
236 if (!args.length || args.some((word) => word.expands)) return "";
237 const format = args[0].text;
238 const tokens = [];
239 let literal = "";
240 let conversions = 0;
241 const escapes = {
242 n: "\n",
243 t: " ",
244 r: "\r",
245 a: "\x07",
246 b: "\b",
247 f: "\f",
248 v: "\v",
249 "\\": "\\"
250 };
251 for (let i = 0; i < format.length; i++) {
252 const char = format[i];
253 if (char === "%") {
254 const next = format[++i];
255 if (next === "%") literal += "%";
256 else if (next === "s") {
257 tokens.push(literal, null);
258 literal = "";
259 conversions++;
260 } else return "";
261 } else if (char === "\\") {
262 const escaped = escapes[format[++i] ?? ""];
263 if (escaped === void 0) return "";
264 literal += escaped;
265 } else literal += char;
266 }
267 tokens.push(literal);
268 let argument = 1;
269 let size = 0;
270 const result = [];
271 do {
272 for (const token of tokens) {
273 const text = token === null ? args[argument++]?.text ?? "" : token;
274 size += text.length;
275 if (size > 256 * 1024) return "";
276 result.push(text);
277 }
278 } while (conversions > 0 && argument < args.length);
279 return result.join("");
280}
281function contentOf(cmd) {
282 if (cmd.heredocs.length) return cmd.heredocs.join("\n");
283 const name = cmd.words[0]?.text ?? "";
284 if (name !== "printf" && name !== "echo") return "";
285 if (name === "printf") return printfContent(cmd.words.slice(1));
286 const args = cmd.words.slice(1).filter((w) => !ECHO_FLAGS.has(w.text));
287 if (args.some((a) => a.expands)) return "";
288 const texts = args.map((a) => a.text);
289 return texts.join(" ");
290}
291function shellFileWrites(input, baseDir) {
292 const out = [];
293 let cwd = baseDir;
294 for (const cmd of parseCommands(input)) {
295 if (cmd.unterminated) continue;
296 if (cmd.words[0]?.text === "cd" && cwd !== void 0) {
297 const target = cmd.words[1];
298 if (target && !target.expands && cmd.words.length === 2) cwd = resolveDirectory(cwd, target.text);
299 continue;
300 }
301 const targetPath = (path) => cwd === void 0 ? path : resolveDirectory(cwd, path);
302 const text = contentOf(cmd);
303 if (!text.trim()) continue;
304 const plain = cmd.redirects.filter((r) => !r.exotic && !r.target.expands && r.target.text);
305 if (plain.length === 1) {
306 out.push({ path: targetPath(plain[0].target.text), text });
307 continue;
308 }
309 if (plain.length > 1) continue;
310 if (cmd.redirects.length) continue;
311 if ((cmd.words[0]?.text ?? "") !== "tee") continue;
312 const targets = cmd.words.slice(1).filter((w) => !w.text.startsWith("-"));
313 if (targets.length !== 1 || targets[0].expands) continue;
314 out.push({ path: targetPath(targets[0].text), text });
315 }
316 return out;
317}
318function resolveDirectory(base, path) {
319 const windows = /^[A-Za-z]:[\\/]|^\\\\/.test(base) || /^[A-Za-z]:[\\/]|^\\\\/.test(path);
320 const slash = windows ? "\\" : "/";
321 const absolute = /^[A-Za-z]:[\\/]|^[\\/]/.test(path);
322 const joined = absolute ? path : base + slash + path;
323 const network = windows && joined.startsWith("\\\\");
324 const prefix = network ? slash + slash : windows ? (/^[A-Za-z]:/.exec(joined)?.[0] ?? "") + slash : joined.startsWith("/") ? "/" : "";
325 const parts = [];
326 for (const part of joined.replace(/^[A-Za-z]:/, "").split(/[\\/]+/)) {
327 if (!part || part === ".") continue;
328 if (part === "..") {
329 if (parts.length > (network ? 2 : 0)) parts.pop();
330 } else parts.push(part);
331 }
332 return prefix + parts.join(slash);
333}
334function gitCommand(cmd, cwd) {
335 let i = 1;
336 while (i < cmd.words.length) {
337 const option = cmd.words[i].text;
338 if (!option.startsWith("-")) return { subcommand: option, index: i, cwd };
339 if (option === "-C" || option === "-c" || option === "--git-dir" || option === "--work-tree") {
340 const value = cmd.words[++i];
341 if (!value || value.expands) return { subcommand: "", index: i, cwd };
342 if (option === "-C") cwd = resolveDirectory(cwd, value.text);
343 } else if (option.startsWith("-C") && option.length > 2) cwd = resolveDirectory(cwd, option.slice(2));
344 i++;
345 }
346 return { subcommand: "", index: i, cwd };
347}
348function publishingCommands(input, baseDir = process.cwd()) {
349 const out = [];
350 let cwd = baseDir;
351 for (const cmd of parseCommands(input)) {
352 if (cmd.unterminated || cmd.words[0]?.expands) continue;
353 const name = cmd.words[0]?.text;
354 if (name === "cd") {
355 const target = cmd.words[1];
356 if (target && !target.expands && cmd.words.length === 2) cwd = resolveDirectory(cwd, target.text);
357 continue;
358 }
359 if (name === "git") {
360 const git = gitCommand(cmd, cwd);
361 if (git.subcommand === "commit") out.push({ args: cmd.words.slice(git.index + 1), cwd: git.cwd, heredocs: cmd.heredocs });
362 } else if (name === "gh") {
363 const kind = cmd.words[1]?.text;
364 const action = cmd.words[2]?.text;
365 const actions = kind === "pr" ? ["create", "edit", "comment", "review"] : kind === "issue" ? ["create", "edit", "comment"] : kind === "release" ? ["create", "edit"] : [];
366 if (action && actions.includes(action)) out.push({ args: cmd.words.slice(3), cwd, heredocs: cmd.heredocs });
367 }
368 }
369 return out;
370}
371function classifyShellCommand(input) {
372 if (input.length > 256 * 1024) return void 0;
373 if (publishingCommands(input, "").length) return "github";
374 if (input.includes("*** Begin Patch") || shellFileWrites(input).some((f) => /\.(md|markdown|mdx)$/i.test(f.path))) return "docs";
375 return void 0;
376}
377export {
378 classifyShellCommand
379};
380hooks/issue-tools.mjs 9 lines1// GENERATED by scripts/build-plugin.mjs from src/agents/issue-tools.ts. Do not edit.
2
3// src/agents/issue-tools.ts
4var ISSUE_TOOL_PATTERN = "^(?:.*[_:]|MCP:)?(?:save_(?:issue|comment)|createJiraIssue|editJiraIssue|addOrEditJiraIssueComment|createConfluenceContent|updateConfluenceContent|createConfluenceComment|updateConfluenceComment)$";
5var ISSUE_TOOLS = new RegExp(ISSUE_TOOL_PATTERN);
6export {
7 ISSUE_TOOLS
8};
9hooks/wire.ts 257 lines1/**
2 * The wire between the mod and the bundled CLI.
3 *
4 * The CLI's `hook <channel> --agent claude-code` reads one settings-hook
5 * payload on stdin and prints Claude Code's own hook JSON: nothing when the
6 * write may go ahead, `hookSpecificOutput.permissionDecision` for a tool
7 * call, a flat `{ decision: "block", reason }` for a stop event. This file
8 * builds the payload and reads the answer back, so `register.ts` holds the
9 * hooks alone. Every reader here fails towards allowing: a linter is never
10 * the reason a write cannot happen.
11 */
12
13/** What a pre-tool answer comes to once read. */
14export type ToolVerdict =
15 | { kind: 'allow' }
16 | { kind: 'ask'; reason: string }
17 | { kind: 'deny'; reason: string }
18
19/** What a stop answer comes to once read. */
20export type ChatVerdict =
21 | { kind: 'pass'; notice?: string }
22 | { kind: 'block'; reason: string; notice?: string }
23
24/** Tokenize user paths without evaluating shell expressions or expanding variables. */
25export function readPaths(input: string): string[] {
26 const paths: string[] = []
27 let word = ''
28 let quote = ''
29 let active = false
30 for (let i = 0; i < input.length; i++) {
31 const char = input[i]!
32 if (char === '\\' && quote !== "'") {
33 if (++i >= input.length) throw new Error('A path ends with an unfinished escape.')
34 word += input[i]
35 active = true
36 } else if (quote !== '') {
37 if (char === quote) quote = ''
38 else word += char
39 } else if (char === '"' || char === "'") {
40 quote = char
41 active = true
42 } else if (/\s/.test(char)) {
43 if (active) paths.push(word)
44 word = ''
45 active = false
46 } else {
47 word += char
48 active = true
49 }
50 }
51 if (quote !== '') throw new Error('A quoted path is missing its closing quote.')
52 if (active) paths.push(word)
53 if (paths.some(path => path === '')) throw new Error('A path cannot be empty.')
54 return paths.length === 0 ? ['.'] : paths
55}
56
57/** The three keys `tool.call` carries beside the tool's own arguments. */
58const RESERVED = new Set(['tool', 'tool_use_id', 'agentId'])
59
60/**
61 * A PreToolUse payload as a settings hook would read it on stdin, built from
62 * a `tool.call` event: the tool's arguments are every field but the reserved.
63 */
64export function toolPayload(
65 e: Readonly<Record<string, unknown>>,
66 session: { id: string; cwd: string },
67): Record<string, unknown> {
68 const input: Record<string, unknown> = {}
69 for (const [key, value] of Object.entries(e)) {
70 if (!RESERVED.has(key)) input[key] = value
71 }
72 return {
73 hook_event_name: 'PreToolUse',
74 session_id: session.id,
75 cwd: session.cwd,
76 tool_name: String(e['tool']),
77 tool_use_id: e['tool_use_id'],
78 tool_input: input,
79 }
80}
81
82function parse(stdout: string): Record<string, unknown> | undefined {
83 const text = stdout.trim()
84 if (text === '') return undefined
85 try {
86 const value: unknown = JSON.parse(text)
87 return value !== null && typeof value === 'object'
88 ? (value as Record<string, unknown>)
89 : undefined
90 } catch {
91 return undefined
92 }
93}
94
95function asString(value: unknown): string | undefined {
96 return typeof value === 'string' && value.length > 0 ? value : undefined
97}
98
99/** Reads a pre-tool answer. Anything unreadable allows. */
100export function readToolVerdict(stdout: string): ToolVerdict {
101 const out = parse(stdout)
102 const specific = out?.['hookSpecificOutput']
103 if (specific === null || typeof specific !== 'object') return { kind: 'allow' }
104 const record = specific as Record<string, unknown>
105 const decision = asString(record['permissionDecision'])
106 const reason =
107 asString(record['permissionDecisionReason']) ?? 'plain-english refused this write.'
108 if (decision === 'deny') return { kind: 'deny', reason }
109 if (decision === 'ask') return { kind: 'ask', reason }
110 return { kind: 'allow' }
111}
112
113/** Reads a stop answer. Anything unreadable passes. */
114export function readChatVerdict(stdout: string): ChatVerdict {
115 const out = parse(stdout)
116 if (out === undefined) return { kind: 'pass' }
117 const notice = asString(out['systemMessage'])
118 if (out['decision'] === 'block') {
119 const reason = asString(out['reason']) ?? 'plain-english held this reply.'
120 return notice === undefined ? { kind: 'block', reason } : { kind: 'block', reason, notice }
121 }
122 return notice === undefined ? { kind: 'pass' } : { kind: 'pass', notice }
123}
124
125/** One quoted passage, as the CLI's reason lists them. */
126export interface Passage {
127 line: number
128 match: string
129 ruleId: string
130 hint?: string
131}
132
133/**
134 * A finding line as `formatReason` in the CLI prints it:
135 * ` line 3: "Furthermore" (furthermore) Start the sentence with its own point.`
136 * The CLI and this module ship in one bundle, so the shape cannot drift
137 * between them unseen; a line that does not match is simply not a passage.
138 */
139const PASSAGE = /^\s+line (\d+): ("(?:[^"\\]|\\.)*") \(([\w-]+)\)(?: (.*))?$/
140
141/** The passages quoted in a reason, in the order the CLI gave them. */
142export function readPassages(reason: string): Passage[] {
143 const passages: Passage[] = []
144 for (const line of reason.split('\n')) {
145 const m = PASSAGE.exec(line)
146 if (m === null) continue
147 let match = m[2] ?? '""'
148 try {
149 match = String(JSON.parse(match))
150 } catch {
151 // the quoted form is still readable
152 }
153 const passage: Passage = { line: Number(m[1]), match, ruleId: m[3] ?? '' }
154 if (m[4] !== undefined && m[4] !== '') passage.hint = m[4]
155 passages.push(passage)
156 }
157 return passages
158}
159
160/**
161 * Text fit for `$.ui.log`, which draws one transcript row: line breaks and
162 * other control characters become spaces. Claude Code 2.1.294 draws a line
163 * break inside a row as U+FFFD (issue #80).
164 */
165export function oneLine(text: string): string {
166 return text.replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/ {2,}/g, ' ').trim()
167}
168
169/**
170 * The transcript row for a reply check's notice. The engine hands a mod's
171 * block reason to the model and draws none of it, so this row is what the
172 * person sees of a held reply. It names up to three quoted passages, or the
173 * notice's first line when it quotes none, and points at the pane that holds
174 * the whole finding.
175 */
176export function noticeLine(notice: string, held: boolean): string {
177 const passages = readPassages(notice)
178 const shown = passages.slice(0, 3).map(passage => `${JSON.stringify(passage.match)} (${passage.ruleId})`)
179 const more = passages.length > shown.length ? ` and ${passages.length - shown.length} more` : ''
180 const what = shown.length > 0
181 ? `${shown.join(', ')}${more}.`
182 : notice.split('\n').find(line => line.trim() !== '') ?? notice
183 return oneLine(`${held ? 'held this reply for a rewrite' : 'advice on this reply'}: ${what} /plain-english review shows the full finding.`)
184}
185
186/** The dialog an advisory finding opens: its text, its chip and its two answers. */
187export interface Ask {
188 question: string
189 header: string
190 allow: string
191 refuse: string
192}
193
194/** The chip beside the question; the engine allows twelve characters. */
195const HEADER = 'Prose check'
196const REFUSE = 'Refuse so Claude rewrites'
197
198/** What was about to happen, and the thing it would have happened to. */
199function subjectOf(
200 channel: 'docs' | 'github' | 'issue',
201 e: Readonly<Record<string, unknown>>,
202 cwd: string,
203): { subject: string; verb: string; allow: string } {
204 if (channel === 'docs') {
205 let path = String(e['file_path'] ?? 'a file')
206 if (path.startsWith(`${cwd}/`)) path = path.slice(cwd.length + 1)
207 return { subject: path, verb: 'Save the file as it is?', allow: 'Save it as it is' }
208 }
209 if (channel === 'github') {
210 const command = String(e['command'] ?? '')
211 let subject = 'the commit message'
212 if (/\bgh\s+pr\b/i.test(command)) subject = 'the pull request text'
213 else if (/\bgh\s+issue\b/i.test(command)) subject = 'the issue text'
214 else if (/\bgh\s+release\b/i.test(command)) subject = 'the release notes'
215 return { subject, verb: 'Run the command as it is?', allow: 'Run it as it is' }
216 }
217 return { subject: 'the Linear issue', verb: 'Send it as it is?', allow: 'Send it as it is' }
218}
219
220/**
221 * The question a person sees when a finding is advisory.
222 *
223 * The CLI's reason is written for the model: how to rewrite, and the narrower
224 * ways to get past the check. Shown to a person it reads as instructions to
225 * somebody else, with the actual question tacked on at the end. This names the
226 * plugin, the file or command, one quoted passage with its rule, and asks a
227 * question the person can answer. The model's text stays in the deny reason.
228 */
229export function askFor(
230 channel: 'docs' | 'github' | 'issue',
231 e: Readonly<Record<string, unknown>>,
232 cwd: string,
233 reason: string,
234): Ask {
235 const { subject, verb, allow } = subjectOf(channel, e, cwd)
236 const passages = readPassages(reason)
237 const first = passages[0]
238 const lines: string[] = []
239
240 if (first === undefined) {
241 lines.push(`plain-english found writing in ${subject} that breaks its rules.`)
242 } else if (passages.length === 1) {
243 lines.push(`plain-english found one passage in ${subject} that breaks its rules:`)
244 } else {
245 lines.push(
246 `plain-english found ${passages.length} passages in ${subject} that break its rules. The first:`,
247 )
248 }
249 if (first !== undefined) {
250 const hint = first.hint === undefined ? '' : ` ${first.hint}`
251 lines.push('', `line ${first.line}: "${first.match}" (${first.ruleId})${hint}`)
252 }
253 lines.push('', `Refusing hands Claude the findings and a way to fix each. ${verb}`)
254
255 return { question: lines.join('\n'), header: HEADER, allow, refuse: REFUSE }
256}
257