Shape AI work into small, reviewable PRs: plan the stack, write two-layer PR bodies, block PRs without intent and evidence.

<img src="docs/assets/banner.svg" alt="PuReDoRi: Pull Request Done Right" width="100%">
A Claude Code plugin that shapes AI work into small pull requests that a person can review. It plans the stack before the code, writes each PR body for the reviewer, and stops a PR without intent and evidence.
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a> <a href="docs/installation.mdx"><img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude%20Code-plugin-D97757?logo=claude&logoColor=white"></a> <a href="https://github.com/gabe4coding/puredori/actions/workflows/test.yml"><img alt="Tests" src="https://github.com/gabe4coding/puredori/actions/workflows/test.yml/badge.svg"></a>
<a href="#quick-start">Quick start</a> · <a href="docs/usage.mdx">Usage</a> · <a href="docs/installation.mdx">Installation</a> · <a href="docs/development.mdx">Development</a>
An agent writes code faster than a person can review it. The reviewer gets a large diff and a long, generic PR description. Then the reviewer reads less, or stops.
PuReDoRi changes the shape of the work, not only the text. The reviewer checks the plan before the code and the behavior after the code. The diff between them is sampled, not read.
## Intent and ## Evidence.gh pr create, and gh pr edit with a new body, when the body has no ## Intent or no ## Evidence section.You need Claude Code 2.1.296 or later and the GitHub CLI (gh), logged in.
Type these commands at the prompt of a Claude Code session:
/plugin marketplace add gabe4coding/puredori
/plugin install puredori@puredori
Start a new session and give a task that ends in a PR. For example: "Add a --shout flag to the CLI. It will end in a PR." The agent loads the pr-plan skill. It asks you to confirm the intent and the steps before it writes code.
You confirm a plan of four steps. The agent opens step 1 as a draft PR with this table at the end:
| # | Step | Evidence | PR |
|---|---|---|---|
| 1 | Add greet | `greet('Ada')` output | planned |
| 2 | Add a name check | `greet('')` output | planned |
| 3 | Add farewell | `farewell('Ada')` output | planned |
| 4 | Add a CLI | `node cli.js greet Ada` output | planned |
Later, step 2 merges and the agent opens the PR of step 3. It reads the table from the first PR, checks the status of each PR, and changes only the rows that changed:
| 2 | Add a name check | `greet('')` output | #2 (merged) |
| 3 | Add farewell | `farewell('Ada')` output | #3 (open) |
A reviewer who opens the first PR sees the full stack and its status.
| Page | |
|---|---|
| Installation | Requirements, install, local folder, update |
| Usage | The rules, the plan, the PR body, stacks, the PR gate |
| Development | Layout, verification loop, checks, release |
The same author maintains these plugins. Each plugin installs and works alone.
MIT. See LICENSE.
hooks/register.ts 50 lines1import type { Register } from 'claude-code'
2import { bodySources, hasShellVariable, MAX_WORDS, missingSections, proseWords } from './gate.ts'
3
4// The fix for a body file that the hook cannot read. Most often the same
5// command writes the file (a redirect, a heredoc, `tee`) or runs `cd` first.
6export const VARIABLE_FIX =
7 'the path uses a shell variable; write the literal path. If this command also writes the file, write it in a separate step (Write tool) first.'
8export const SEPARATE_STEP_FIX =
9 'the hook reads it before the command runs, from the session directory. Write the body file in a separate step (Write tool), then run gh with its absolute path.'
10
11// The rules are hooks/rules.md, sent by a SessionStart command hook in hooks.json:
12// Claude Code's built-in security module bypasses the prompt.compose and
13// prompt.section handlers of a plugin module (debug log: "prompt.section
14// bypassed by cc-plugin-sec-default").
15export const register: Register = on => {
16 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
17 for (const source of bodySources(e.command)) {
18 let body = source.kind === 'inline' ? source.body : undefined
19 if (source.kind === 'file') {
20 body = await $.fs.read(source.path).catch(() => undefined)
21 if (body === undefined) {
22 const fix = hasShellVariable(source.path) ? VARIABLE_FIX : SEPARATE_STEP_FIX
23 return { deny: `${$.plugin.name}: cannot read the PR body file ${source.path}: ${fix}` }
24 }
25 }
26
27 const missing = missingSections(body)
28 if (missing.length > 0) {
29 return {
30 deny:
31 `${$.plugin.name}: the PR body has no ${missing.map(s => `"## ${s}"`).join(' or ')} section. ` +
32 `Load skill ${$.plugin.name}:pr-write, write the body to a file with its template, and run the command again with --body-file.`,
33 }
34 }
35
36 const words = proseWords(body)
37 if (words > MAX_WORDS) {
38 return {
39 deny:
40 `${$.plugin.name}: the PR body has ${words} words outside code blocks (max ${MAX_WORDS}). ` +
41 `Load skill ${$.plugin.name}:pr-write, delete the sentences the reviewer would skip, and run the command again with --body-file.`,
42 }
43 }
44 }
45 return next(e)
46 }).catch(($, e, next) =>
47 next.called ? next(e) : { deny: `${$.plugin.name}: the PR gate failed; check the body by hand with skill ${$.plugin.name}:pr-write.` },
48 )
49}
50hooks/gate.ts 216 lines1// What the PR gate reads from a Bash command: the `gh pr create` and
2// `gh pr edit` commands it runs, where each one's PR body is, and which
3// required sections a body lacks. The same words in a quoted string, a heredoc
4// or a commit message do not count.
5//
6// The gate is a nudge for an agent that follows the rules, not a security
7// boundary: `bash -c '...'`, `eval` or an alias get past it. Do not add lexer
8// cases for those.
9
10export const REQUIRED = ['Intent', 'Evidence'] as const
11const SECTIONS = REQUIRED.map(name => ({ name, pattern: new RegExp(`^#{2,3}\\s*${name}\\b`, 'mi') }))
12
13export type BodySource = { kind: 'inline'; body: string } | { kind: 'file'; path: string }
14
15// One simple command: its words without quotes (a `$(...)` stays as written),
16// and the heredoc bodies that feed its stdin.
17type Segment = { words: string[]; stdin: string[] }
18type Heredoc = { delim: string; strip: boolean }
19
20const OPERATORS = ';&|()'
21const isSpace = (c: string | undefined) => c === ' ' || c === '\t'
22
23// The text between the quote at `i` and its closing quote (or the end), and
24// the index after it.
25const readQuoted = (src: string, i: number) => {
26 const close = src.indexOf(src[i], i + 1)
27 const end = close === -1 ? src.length : close
28 return { text: src.slice(i + 1, end), next: end + 1 }
29}
30
31// Reads the heredoc bodies that start at `i` (the char after a newline), in
32// order. Returns the bodies and the index after the last delimiter line.
33const readHeredocs = (src: string, i: number, pending: Heredoc[]) => {
34 const bodies: string[] = []
35 for (const { delim, strip } of pending) {
36 const lines: string[] = []
37 while (i < src.length) {
38 let end = src.indexOf('\n', i)
39 if (end === -1) end = src.length
40 const line = src.slice(i, end)
41 i = end + 1
42 if ((strip ? line.replace(/^\t+/, '') : line) === delim) break
43 lines.push(line)
44 }
45 bodies.push(lines.join('\n'))
46 }
47 return { bodies, next: Math.min(i, src.length) }
48}
49
50// A shell lexer, as far as the gate needs one. It splits `src` from `start`
51// into simple commands at unquoted `;`, `&`, `&&`, `|`, `||`, `(`, `)` and
52// newlines. The commands inside a `$(...)` are in the result too. Inside a
53// `$(...)` (`nested`) it stops at the `)` that closes it and returns its index.
54const lex = (src: string, start: number, nested: boolean): { segments: Segment[]; end: number } => {
55 const segments: Segment[] = []
56 let seg: Segment = { words: [], stdin: [] }
57 let word: string | undefined
58 let pending: Heredoc[] = []
59 const add = (text: string) => {
60 word = (word ?? '') + text
61 }
62 const endWord = () => {
63 if (word !== undefined) seg.words.push(word)
64 word = undefined
65 }
66 const endSegment = () => {
67 endWord()
68 if (seg.words.length > 0) segments.push(seg)
69 seg = { words: [], stdin: [] }
70 }
71 // Reads a `$(...)` that starts at `i`: its text as written, and its commands.
72 const substitution = (i: number) => {
73 const inner = lex(src, i + 2, true)
74 segments.push(...inner.segments)
75 return src.slice(i, inner.end + 1)
76 }
77
78 let i = start
79 while (i < src.length) {
80 const c = src[i]
81 const next = src[i + 1]
82 if (c === '\n') {
83 endWord()
84 if (pending.length > 0) {
85 const heredocs = readHeredocs(src, i + 1, pending)
86 seg.stdin.push(...heredocs.bodies)
87 pending = []
88 i = heredocs.next
89 } else i++
90 endSegment()
91 } else if (isSpace(c)) {
92 endWord()
93 i++
94 } else if (c === '\\') {
95 // A backslash before a newline joins the lines.
96 if (next !== '\n') add(next ?? '')
97 i += 2
98 } else if (c === "'") {
99 const quoted = readQuoted(src, i)
100 add(quoted.text)
101 i = quoted.next
102 } else if (c === '`') {
103 const quoted = readQuoted(src, i)
104 add(`\`${quoted.text}\``)
105 i = quoted.next
106 } else if (c === '"') {
107 i++
108 add('')
109 while (i < src.length && src[i] !== '"') {
110 // In double quotes a backslash escapes only $ ` " \ and a newline.
111 if (src[i] === '\\' && '$`"\\\n'.includes(src[i + 1] ?? 'x')) {
112 if (src[i + 1] !== '\n') add(src[i + 1])
113 i += 2
114 } else if (src[i] === '$' && src[i + 1] === '(') {
115 const sub = substitution(i)
116 add(sub)
117 i += sub.length
118 } else add(src[i++])
119 }
120 i++
121 } else if (c === '$' && next === '(') {
122 const sub = substitution(i)
123 add(sub)
124 i += sub.length
125 } else if (c === '<' && next === '<' && src[i + 2] === '<') {
126 // A here-string: its text is the next word.
127 endWord()
128 i += 3
129 } else if (c === '<' && next === '<') {
130 // A heredoc: its body starts after the next newline.
131 endWord()
132 i += 2
133 const strip = src[i] === '-'
134 if (strip) i++
135 while (isSpace(src[i])) i++
136 let delim = ''
137 while (i < src.length && !isSpace(src[i]) && !`${OPERATORS}<>\n`.includes(src[i])) {
138 if (src[i] === "'" || src[i] === '"') {
139 const quoted = readQuoted(src, i)
140 delim += quoted.text
141 i = quoted.next
142 } else delim += src[i++]
143 }
144 pending.push({ delim, strip })
145 } else if (c === '&' && (next === '>' || word?.endsWith('>'))) {
146 // `2>&1` and `&>file` are redirects, not a background `&`.
147 add(c)
148 i++
149 } else if (c === ')' && nested) {
150 endSegment()
151 return { segments, end: i }
152 } else if (OPERATORS.includes(c)) {
153 endSegment()
154 i += (c === '&' || c === '|') && next === c ? 2 : 1
155 } else {
156 add(c)
157 i++
158 }
159 }
160 endSegment()
161 return { segments, end: src.length }
162}
163
164const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
165const GH = /(?:^|\/)gh$/
166
167// The value of a flag in `args`: `--name value`, `--name=value`, `-x value`
168// or `-xvalue`. `names` holds the long name, then the short one.
169const flagValue = (args: string[], [long, short]: [string, string]) => {
170 for (let i = 0; i < args.length; i++) {
171 const arg = args[i]
172 if (arg === long || arg === short) return args[i + 1] ?? ''
173 if (arg.startsWith(`${long}=`)) return arg.slice(long.length + 1)
174 if (!arg.startsWith('--') && arg.startsWith(short)) return arg.slice(short.length)
175 }
176 return undefined
177}
178
179const bodySource = ({ words, stdin }: Segment): BodySource[] => {
180 const [gh, group, cmd, ...args] = words.slice(words.findIndex(w => !ASSIGNMENT.test(w)))
181 if (!GH.test(gh ?? '') || group !== 'pr' || (cmd !== 'create' && cmd !== 'edit')) return []
182 // --web hands the body to the person in the browser.
183 if (args.includes('--web') || args.includes('-w')) return []
184 const file = flagValue(args, ['--body-file', '-F'])
185 const body = flagValue(args, ['--body', '-b'])
186 if (file === '-') return [{ kind: 'inline', body: stdin.join('\n') }]
187 if (file !== undefined) return [{ kind: 'file', path: file }]
188 if (body !== undefined) return [{ kind: 'inline', body }]
189 // Without a body flag, gh pr create writes an empty body (--fill too).
190 return cmd === 'create' ? [{ kind: 'inline', body: '' }] : []
191}
192
193// The PR bodies that a command writes, one for each `gh pr create` and each
194// `gh pr edit` with a body flag. Empty: the command writes no PR body, so the
195// gate lets it run. Most commands have no `gh` at all: they skip the lexer.
196export const bodySources = (command: string): BodySource[] =>
197 command.includes('gh') ? lex(command, 0, false).segments.flatMap(bodySource) : []
198
199// A path with a `$` or a backtick: the hook reads it with no shell expansion.
200export const hasShellVariable = (path: string) => /[$`]/.test(path)
201
202export const missingSections = (body: string): string[] =>
203 SECTIONS.filter(({ pattern }) => !pattern.test(body)).map(({ name }) => name)
204
205// The reviewer reads the prose. Run output and diagrams go in fenced code
206// blocks, and a long log must not push a good body over the cap.
207export const MAX_WORDS = 1000
208// A fenced block, also one indented in a list or in a `>` quote, ends at a
209// fence at least as long, or at the end of the body. A backtick fence has no
210// backtick after it: "```npm test``` fails" is inline code.
211const FENCE = /^[ \t>]*(`{3,}(?=[^`\n]*$)|~{3,})[^\n]*$[\s\S]*?(?:^[ \t>]*\1[`~]*[ \t]*$|(?![\s\S]))/gm
212
213// A word has a letter or a digit: table pipes, `---`, `##` and bullets are not words.
214export const proseWords = (body: string): number =>
215 body.replace(FENCE, '').split(/\s+/).filter(w => /[\p{L}\p{N}]/u.test(w)).length
216