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…

Function-hook mods for Claude Code. Each folder is one plugin.
| Mod | What it does | ||
|---|---|---|---|
context-watch | One-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-spinner | Spinner 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-watch | Tracks 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-style | No 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-safe | Quotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain | ||
pm-guard | Blocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile | ||
remote-guard | Asks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh | ||
xcode-status | Only 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-models | Pins 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-rules | Blocks 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 on | off | auto` overrides per repo (kept across sessions) |
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
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/.
hooks/register.ts 206 lines1import 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