SLOPSHOPPER

plain-english

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

newpaneguardcommandpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plain-english
› fix the failing auth test and add an audit log call ● plain-english: reply check unavailable; the reply was allowed. check unavailable (signal undefined). ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /plain-english ⎿ plain-english: dev ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

plain-english for Claude Code

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.

Install

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.

What you see

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.

  1. A save is questioned. Claude tries to save 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.

  1. A reply is held. Claude ends a turn with a reply over the length limit, or one carrying a stock phrase. The turn does not end. Claude reads the finding and answers again, once.
  2. A commit message is questioned the same way. 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.

What it hooks

EventWhat the hook does
tool.callOn 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.SubagentStopRuns the chat adapter on the reply. A block holds the turn with the reason, in an interactive session and under claude -p alike.
session.startRegisters /plain-english.
prompt.contextAdds 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.

What it reads, writes and sends

  • Reads: proposed prose, its surrounding document when an edit needs context, the project config, configured writing-profile summaries, and files you request. Excluded documents and paths outside the project are skipped before extra model checks. Edits report findings only on changed prose.
  • Writes: temporary reply-control files with turn identifiers and small counters. The review panel retains recent findings in session memory. Approving a project term writes an exception to the project config after your confirmation.
  • Sends: pattern matching stays on this machine. Extra checks ask the session's own model through Claude Code ($.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.
  • Keeps: an extra check sent through the session is one request with no conversation history. The 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.

For reviewers: every program, file and outbound call

This section answers the Claude directory's checks one call at a time. Paths are relative to the plugin folder.

Programs the mod starts

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.tsCommandWhy
spawnHook, through $.process.spawnnode 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 inputRuns 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.runnode hooks/run-checker.mjs approve, with the request as JSON on standard inputChecks 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.runnode hooks/run-checker.mjs guidanceReads the project's declared vocabulary to add to the conversation.
/plain-english, through $.process.runnode hooks/run-checker.mjs lintChecks 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.

What leaves the machine, and where it goes

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.

Files written

The mod itself reads and writes no files. The checker it starts writes two kinds:

  • The project's plain-english config, usually .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.
  • Temporary files in the system's temporary folder, holding turn identifiers and small counters that stop a reply being held twice.

Events that see other content

  • 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.

Bundled code

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.

Configuration

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.

When it misfires

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.

Develop and test

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.

Licence

MIT, as the rest of the repository.

Source 4 files
hooks/register.ts 494 lines
1import 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}
494
hooks/shell.mjs 380 lines
1// 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};
380
hooks/issue-tools.mjs 9 lines
1// 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};
9
hooks/wire.ts 257 lines
1/**
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