SLOPSHOPPER

pr-rules

Blocks gh pr create / gt submit when a repo with an [Unreleased] changelog has no entry on the branch, and (where the repo asks for it) when the PR carries no…

newguardcommandprocess
v0.1.0no licenseupdated 2026-10-04narrowstacks/claude-code-mods/pr-rules
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-rules
› fix the failing auth test and add an audit log call ⏺ 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 › /pr-rules ⎿ pr-rules: PR rules for /work/app (auto): ⎿ pr-rules: changelog: off (no CHANGELOG with [Unreleased]) ⎿ pr-rules: labels: off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Function-hook mods for Claude Code. Each folder is one plugin.

ModWhat it does
context-watchOne-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane
live-spinnerSpinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet
pr-watchTracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs
house-styleNo em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one
handoff/carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop
agent-jobs/jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes
zsh-safeQuotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain
pm-guardBlocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile
remote-guardAsks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh
xcode-statusOnly in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result
agent-modelsPins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on
worktrees/worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once
pr-rulesBlocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules onoffauto` overrides per repo (kept across sessions)

Install

claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods

Or load from a checkout without installing:

claude --plugin-dir ./context-watch --plugin-dir ./live-spinner

Check

claude plugin validate ./context-watch
claude plugin test ./context-watch

tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.

Source 1 files
hooks/register.ts 206 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3export type PrCommand = { tool: 'gh' | 'gt'; labels: string[]; cd?: string }
4
5const SEGMENT = /&&|\|\||;|\n/
6const TOKENS = /"[^"]*"|'[^']*'|\S+/g
7const unquote = (text: string) => text.replace(/^(["'])([\s\S]*)\1$/, '$2')
8
9// The PR-opening command in `command`, its labels and a leading `cd`; null
10// when the command opens no PR.
11export const parsePrCommand = (command: string): PrCommand | null => {
12  const cd = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*(&&|;)/)?.[1]
13  for (const segment of command.split(SEGMENT)) {
14    const tokens = (segment.trim().match(TOKENS) ?? []).map(unquote)
15    const start = tokens.findIndex((token, i) => token === 'gh' && tokens[i + 1] === 'pr' && tokens[i + 2] === 'create')
16    if (start !== -1) {
17      const labels: string[] = []
18      for (let i = start + 3; i < tokens.length; i += 1) {
19        const token = tokens[i] ?? ''
20        const inline = token.match(/^--label=(.*)$/)?.[1]
21        const value = inline ?? (token === '--label' || token === '-l' ? tokens[i + 1] : undefined)
22        if (value !== undefined) {
23          labels.push(...value.split(',').map(label => label.trim()).filter(Boolean))
24          if (inline === undefined) i += 1
25        }
26      }
27
28      return { tool: 'gh', labels, cd: cd === undefined ? undefined : unquote(cd) }
29    }
30    if (tokens[0] === 'gt' && (tokens[1] === 'submit' || tokens[1] === 'ss')) {
31      return { tool: 'gt', labels: [], cd: cd === undefined ? undefined : unquote(cd) }
32    }
33  }
34
35  return null
36}
37
38export type RepoFacts = {
39  changelogs: string[]
40  changed: string[]
41  requireLabels: boolean
42  availableLabels: string[]
43}
44
45// Why the PR should not open yet, or null when it may.
46export const decide = (pr: PrCommand, facts: RepoFacts): string | null => {
47  const problems: string[] = []
48
49  if (facts.changelogs.length > 0) {
50    const touched = facts.changelogs.filter(file => facts.changed.includes(file))
51    const onlyIos = facts.changelogs.every(file => /ios/i.test(file))
52    const skipped = pr.labels.includes('no-changelog') || (onlyIos && pr.labels.includes('no-ios-changelog'))
53    if (touched.length === 0 && !skipped) {
54      const skip = pr.tool === 'gh' ? `pass --label ${onlyIos ? 'no-ios-changelog' : 'no-changelog'}` : 'add the no-changelog label after submitting'
55      problems.push(
56        `No changelog entry on this branch: add one under [Unreleased] in ${facts.changelogs.join(' and/or ')} for the change, or, if it needs none (refactor, tests, CI), ${skip}.`,
57      )
58    }
59  }
60
61  if (pr.tool === 'gh' && facts.requireLabels && pr.labels.length === 0) {
62    const known = facts.availableLabels.length > 0 ? ` Labels in this repo: ${facts.availableLabels.join(', ')}.` : ''
63    problems.push(`This repo labels every PR: pass --label for every label that fits (a kind, each area it touches, any handling flag).${known}`)
64  }
65
66  return problems.length === 0 ? null : `pr-rules: ${problems.join(' ')}`
67}
68
69const LABEL_RULE = /label every (issue and )?pr\b/i
70
71const git = async ($: EngineInterface, cwd: string, args: string[]) => {
72  const run = await $.process.run(['git', ...args], { cwd, timeoutMs: 15_000 })
73  if (run.exitCode !== 0) throw new Error(`git ${args[0]} failed`)
74
75  return run.stdout.trim()
76}
77
78const resolveDir = async ($: EngineInterface, dir: string | undefined) => {
79  const cwd = await $.session.cwd()
80  if (dir === undefined) return cwd
81  if (dir.startsWith('/')) return dir
82  if (dir.startsWith('~')) return `${(await $.env.get('HOME')) ?? ''}${dir.slice(1)}`
83
84  return `${cwd}/${dir}`
85}
86
87const defaultBranch = async ($: EngineInterface, root: string) => {
88  const head = await git($, root, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).catch(() => '')
89  if (head !== '') return head
90  for (const name of ['origin/main', 'origin/master']) {
91    if ((await git($, root, ['rev-parse', '--verify', '--quiet', name]).catch(() => '')) !== '') return name
92  }
93
94  return null
95}
96
97export type Mode = 'auto' | 'on' | 'off'
98
99// A per-repo switch kept across sessions: off skips the repo, on also
100// requires labels there, auto (no entry) goes by what the repo contains.
101const modeKey = (root: string) => `mode:${root}`
102
103const modeOf = async ($: EngineInterface, root: string): Promise<Mode> => {
104  const stored = await $.store.get(modeKey(root)).catch(() => undefined)
105
106  return stored === 'on' || stored === 'off' ? stored : 'auto'
107}
108
109const gatherFacts = async ($: EngineInterface, pr: PrCommand, requireLabelsOption: boolean): Promise<RepoFacts | null> => {
110  const root = await git($, await resolveDir($, pr.cd), ['rev-parse', '--show-toplevel'])
111  const mode = await modeOf($, root)
112  if (mode === 'off') {
113    return null
114  }
115
116  const changelogs: string[] = []
117  for (const entry of await $.fs.list(root)) {
118    if (entry.kind === 'file' && /^CHANGELOG.*\.md$/i.test(entry.name)) {
119      const text = await $.fs.read(`${root}/${entry.name}`)
120      if (/^##\s*\[Unreleased\]/im.test(text)) changelogs.push(entry.name)
121    }
122  }
123
124  const claudeMd = (await $.fs.exists(`${root}/CLAUDE.md`)) ? await $.fs.read(`${root}/CLAUDE.md`) : ''
125  const requireLabels = mode === 'on' || requireLabelsOption || LABEL_RULE.test(claudeMd)
126  if (changelogs.length === 0 && !requireLabels) {
127    return null
128  }
129
130  const base = await defaultBranch($, root)
131  if (base === null) {
132    return null
133  }
134  const mergeBase = await git($, root, ['merge-base', 'HEAD', base])
135  const changed = (await git($, root, ['diff', '--name-only', mergeBase, 'HEAD'])).split('\n').filter(Boolean)
136
137  let availableLabels: string[] = []
138  if (requireLabels && pr.tool === 'gh' && pr.labels.length === 0) {
139    const run = await $.process.run(['gh', 'label', 'list', '--json', 'name', '--limit', '100'], { cwd: root, timeoutMs: 15_000 }).catch(() => undefined)
140    if (run?.exitCode === 0) {
141      availableLabels = (JSON.parse(run.stdout) as { name: string }[]).map(label => label.name)
142    }
143  }
144
145  return { changelogs, changed, requireLabels, availableLabels }
146}
147
148export const register: Register = (on, options) => {
149  const requireLabelsOption = options.requireLabels === true
150
151  on('session.start', async ($, e, next) => {
152    await $.command.register({ name: 'pr-rules', description: 'PR rules for this repo: /pr-rules [on|off|auto]' })
153
154    return next(e)
155  })
156
157  on('command.run', { command: 'pr-rules' }, async ($, e) => {
158    const root = await git($, await $.session.root(), ['rev-parse', '--show-toplevel']).catch(() => null)
159    if (root === null) {
160      return { text: 'Not in a git repo.' }
161    }
162
163    const wanted = e.args.trim()
164    if (wanted === 'on' || wanted === 'off') {
165      await $.store.set(modeKey(root), wanted)
166    } else if (wanted === 'auto') {
167      await $.store.delete(modeKey(root))
168    } else if (wanted !== '') {
169      return { text: 'Usage: /pr-rules [on|off|auto]' }
170    }
171
172    const mode = await modeOf($, root)
173    const changelogs: string[] = []
174    for (const entry of await $.fs.list(root)) {
175      if (entry.kind === 'file' && /^CHANGELOG.*\.md$/i.test(entry.name) && /^##\s*\[Unreleased\]/im.test(await $.fs.read(`${root}/${entry.name}`))) {
176        changelogs.push(entry.name)
177      }
178    }
179    const claudeMd = (await $.fs.exists(`${root}/CLAUDE.md`)) ? await $.fs.read(`${root}/CLAUDE.md`) : ''
180    const labels = mode === 'on' ? 'on (/pr-rules on)' : requireLabelsOption ? 'on (requireLabels setting)' : LABEL_RULE.test(claudeMd) ? 'on (CLAUDE.md asks for labels)' : 'off'
181
182    return {
183      text: mode === 'off'
184        ? `PR rules are off for ${root}. /pr-rules auto turns them back on.`
185        : [
186            `PR rules for ${root} (${mode}):`,
187            `  changelog: ${changelogs.length > 0 ? `on (${changelogs.join(', ')})` : 'off (no CHANGELOG with [Unreleased])'}`,
188            `  labels:    ${labels}`,
189          ].join('\n'),
190    }
191  })
192
193  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
194    const pr = parsePrCommand(e.command)
195    if (pr === null) {
196      return next(e)
197    }
198
199    // Any failure reading the repo lets the command through.
200    const facts = await gatherFacts($, pr, requireLabelsOption).catch(() => null)
201    const reason = facts === null ? null : decide(pr, facts)
202
203    return reason === null ? next(e) : { deny: reason }
204  })
205}
206