SLOPSHOPPER

puredori

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

newguard
v0.3.0MITupdated 2026-10-26gabe4coding/puredori
A shopper browsing a rack in a slop shop
README

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

  • Plan first: the agent confirms the intent with you. Then it splits the work into a stack of small PRs. Each step names the run output that shows that it works.
  • Story in the first PR: the first PR holds the plan as a table. While that PR is open, the agent keeps the link and the status of each step current.
  • Two-layer PR body: the top layer has the intent, the drift from it, the decisions and real run output. The reading map below it lists the files to sample, from mechanical signals in the diff.
  • Small stays small: a small diff gets only ## Intent and ## Evidence.
  • PR gate: a hook stops gh pr create, and gh pr edit with a new body, when the body has no ## Intent or no ## Evidence section.

Quick start

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.

Example

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.

Documentation

Page
InstallationRequirements, install, local folder, update
UsageThe rules, the plan, the PR body, stacks, the PR gate
DevelopmentLayout, verification loop, checks, release

Related Claude Code plugins

The same author maintains these plugins. Each plugin installs and works alone.

  • claude-code-mode: runs MCP tools as one sandboxed JavaScript program, not as many tool calls.
  • plain: end-to-end tests in plain English. Playwright does the actions and the Jev model decides.
  • loadout: hides the tools, skills and MCP instructions that the task does not need.
  • shall: engineering standards (RFC 2119) that agents apply, with checks that post to PRs.
  • retroagent: syncs your Claude Code and Codex sessions to a private repo and searches them.

License

MIT. See LICENSE.

Source 2 files
hooks/register.ts 50 lines
1import 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}
50
hooks/gate.ts 216 lines
1// 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