Blocks commits without [TICKET] when the branch has a ticket, and commits that only fix linting or formatting; suggests the correct message.

Four small Claude Code mods. Two of them check the Bash commands Claude runs and block commits without a ticket and CI reruns. One tells Claude when a git worktree can be removed after it opens a pull request. One warns you when memory or disk is low.
![A terminal where commit-ticket blocks git commit -m "fix stuff" on the branch feature/ABC-12-login-redirect and allows "[ABC-12] Fix login redirect", and guard-actions blocks gh run rerun and allows gh run view.](docs/demo.gif)
The demo runs the real decision code of two mods with demo/run-hook.sh, so the commands are only checked and never run. Inside Claude Code the same code runs on every Bash command that Claude tries to run.
Claude Code loads every plugin in ~/.claude/skills/<name> in the next session. Clone this repository, then link the mods you want from its root:
for m in commit-ticket guard-actions close-worktree live-resources; do
ln -s "$PWD/$m" ~/.claude/skills/$m
done
To disable a mod, remove its link. To check a mod before you link it, run claude plugin validate commit-ticket and claude plugin test commit-ticket. The requirements are below.
You can write rules like "always put the ticket in the commit message" in CLAUDE.md, but Claude can forget them in a long session. commit-ticket and guard-actions check every Bash command before it runs, block the ones that break a rule, and tell Claude the reason and what to do instead. The logic of each mod is plain TypeScript in hooks/lib.ts, with unit tests next to it.
The rules are fixed in the code. There is no config file. If your ticket format or your CI rules are different, fork the repository and change hooks/lib.ts.
These are personal tools that I use every day. There are no releases and no stability promise, and the behavior can change.
claude plugin test --help works, your version has them. I use Claude Code 2.1.296.live-resources works only on macOS, because it reads memory with vm_stat.guard-actions and close-worktree are about the GitHub CLI (gh). commit-ticket and close-worktree need git.This mod reads the message of every git commit that Claude runs. When the branch name has a ticket, like feature/ABC-5-something, the message must start with a ticket in brackets, like [ABC-5] Fix overflow. If it does not, the mod blocks the commit and suggests the message with the ticket of the branch. It also blocks commits that only fix linting or formatting, like [ABC-5] Fix linting, because the result of an autofix (eslint --fix, prettier --write) should go inside the commit of the change that caused it.
The mod does not check branches without a ticket, merge commits, or messages that it cannot read with confidence, like -F file or --amend --no-edit. To skip the check once, add the comment # ok-commit at the end of the command.
This mod blocks gh run watch, gh run rerun and gh pr checks --watch, because every rerun or wait for CI uses GitHub Actions minutes, and many organizations pay for them. Read-only commands like gh run view or gh run list are allowed, also inside a sleep loop. When Claude opens a second pull request in the same session, the mod shows a message that suggests one integration pull request instead.
To run a blocked command once, add the comment # ok-actions at the end of the command.
After Claude runs gh pr create from a git worktree, this mod checks the worktree. If it has no uncommitted files and no unpushed commits, it tells Claude that the worktree can be removed and gives the exact git worktree remove command. If something is still pending, it tells Claude what is missing and asks it not to remove the worktree. The mod only gives advice. It never deletes anything.
Worktrees that nobody removes stay on disk, and many of them can fill it. The local and remote branches stay after the worktree is removed, so you can create it again with git worktree add if the review asks for changes.
In interactive sessions, this mod checks free memory and free disk space every 30 seconds. When memory goes under 2.5 GB or disk goes under 10 GB, it shows a status line and a short message, and the status line also counts the running test runners (jest, vitest, rspec, playwright, pytest and others). Under 1 GB of memory or 5 GB of disk, the status line is marked as critical. When everything is fine, it shows nothing.
It exists because test runners that run in parallel can use all the memory of a laptop and freeze it.
demo/run-hook.sh builds the Bash tool call that Claude Code would pass to a mod, pipes it as JSON into demo/run-hook.ts, and prints the decision. It supports commit-ticket and guard-actions, and it never runs the command:
demo/run-hook.sh guard-actions 'gh run watch 123'
To record the GIF again, install VHS and run vhs docs/demo.tape from the root of the repository. The tape works in a new temporary git repository.
The logic of each mod is in hooks/lib.ts, with its tests in hooks/lib.test.ts. The file hooks/register.ts only connects that logic to the hook events of Claude Code.
claude plugin validate <mod> # checks the manifest and the hooks
claude plugin test <mod> # runs the *.test.ts files of the mod
MIT. See LICENSE.
hooks/register.ts 40 lines1import type { Register } from 'claude-code'
2
3import { checkCommit, extractMessage } from './lib'
4
5// Directory where the command runs: the first `cd X` or the `git -C X`.
6function commandDir(command: string, home: string): string | null {
7 const raw =
8 /^\s*cd\s+("[^"]+"|'[^']+'|[^\s&;]+)/m.exec(command)?.[1] ??
9 /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/.exec(command)?.[1]
10 if (!raw) return null
11 const dir = raw.replace(/^["']|["']$/g, '')
12 return dir.startsWith('~') ? home + dir.slice(1) : dir
13}
14
15export const register: Register = on => {
16 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
17 if (extractMessage(e.command) === null) {
18 return next(e)
19 }
20
21 try {
22 const home = (await $.env.get('HOME')) ?? ''
23 const cwd = commandDir(e.command, home) ?? (await $.session.cwd())
24 const branch = await $.process.run(['git', 'branch', '--show-current'], {
25 cwd,
26 timeoutMs: 10_000,
27 })
28 const reason = checkCommit(e.command, branch.stdout.trim())
29
30 if (reason !== null) {
31 return { deny: reason }
32 }
33 } catch {
34 // If the branch could not be read, it is better to let the commit pass than to block it.
35 }
36
37 return next(e)
38 })
39}
40hooks/lib.ts 108 lines1// Explicit escape hatch: a command with this comment passes without a check.
2export const OVERRIDE = '# ok-commit'
3
4export const IS_COMMIT = /\bgit\s+(?:-[Cc]\s+\S+\s+)*commit\b/
5
6const HEREDOC_START = /<<-?\s*\\?['"]?(\w+)['"]?/
7const INLINE = /\s(?:-[a-zA-Z]*m|--message)[ =]\s*(?:"([^"]*)"|'([^']*)')/
8// Characters that end the `git commit` segment in a compound command
9// (`&&`, `||`, `;`, `|`, `&` and newline).
10const SEGMENT_END = ';&|\n'
11
12// Separates the heredoc bodies from the rest of the command. Without this, a
13// `cat >> plan.md <<'EOF'` or a `python3 - <<'EOF'` that mention `git commit`
14// in their text were read as the commit, and their first line as the message.
15function splitHeredocs(command: string): { outside: string; bodies: string[] } {
16 const lines = command.split('\n')
17 const outside: string[] = []
18 const bodies: string[] = []
19 for (let i = 0; i < lines.length; i++) {
20 outside.push(lines[i])
21 const start = HEREDOC_START.exec(lines[i])
22 if (!start) continue
23 const body: string[] = []
24 for (i++; i < lines.length && lines[i].trim() !== start[1]; i++) body.push(lines[i])
25 bodies.push(body.join('\n'))
26 outside.push(`<<HEREDOC_${bodies.length - 1}`)
27 }
28 return { outside: outside.join('\n'), bodies }
29}
30
31// First line of the message of a `git commit`, or null when it cannot be read
32// with confidence (`-F`, `--amend --no-edit`, `$(git log ...)`, variables).
33export function extractMessage(command: string): string | null {
34 const { outside, bodies } = splitHeredocs(command)
35 const commit = IS_COMMIT.exec(outside)
36 if (!commit) return null
37
38 // Only what goes from `git commit` to the end of its segment counts,
39 // plus a heredoc that starts there (`-F - <<EOF` or `-m "$(cat <<EOF`).
40 const rest = outside.slice(commit.index + commit[0].length)
41 const heredocRef = /^[^\n]*?<<-?\s*\\?['"]?\w+['"]?[^\n]*\n<<HEREDOC_(\d+)/.exec(rest)
42 if (heredocRef) {
43 const first = bodies[Number(heredocRef[1])].split('\n').find((l) => l.trim() !== '')
44 return clean(first)
45 }
46
47 const inline = INLINE.exec(firstSegment(rest))
48 return clean(inline?.[1] ?? inline?.[2])
49}
50
51// Cuts at the first `&&`, `||`, `;`, `|` or newline outside quotes.
52function firstSegment(text: string): string {
53 let quote: string | null = null
54 for (let i = 0; i < text.length; i++) {
55 const c = text[i]
56 if (quote) {
57 if (c === quote) quote = null
58 } else if (c === '"' || c === "'") {
59 quote = c
60 } else if (SEGMENT_END.includes(c)) {
61 return text.slice(0, i)
62 }
63 }
64 return text
65}
66
67function clean(raw: string | undefined): string | null {
68 const message = raw?.trim()
69 if (!message || message.includes('$(') || message.startsWith('$')) return null
70 return message
71}
72
73// Ticket of a branch like `feature/ABC-5-something`, `fix/XYZ-981-x` or `ABC-6-editor`.
74export function ticketOf(branch: string): string | null {
75 return /(?:^|\/)([A-Z][A-Z0-9]*-\d+)(?:-|$)/.exec(branch)?.[1] ?? null
76}
77
78const HAS_TICKET = /^\[[A-Z][A-Z0-9]*-\d+\] \S/
79// Recognizes English and Spanish verbs and nouns.
80const LINT_ONLY =
81 /^\[[A-Z][A-Z0-9]*-\d+\]\s+(corregir|aplicar|arreglar|fix|apply|run)\s+(el\s+|los\s+|the\s+)?(linting|lint|formato|format|prettier|eslint)\b/i
82
83// Returns the reason to block, or null if the commit is fine.
84export function checkCommit(command: string, branch: string): string | null {
85 if (command.includes(OVERRIDE)) return null
86
87 const message = extractMessage(command)
88 if (message === null || /^Merge\b/.test(message)) return null
89
90 const ticket = ticketOf(branch)
91
92 if (ticket !== null && !HAS_TICKET.test(message)) {
93 return (
94 `commit-ticket: the branch ${branch} has the ticket ${ticket}, so the message must be ` +
95 `"[TICKET] description". Use: [${ticket}] ${message}`
96 )
97 }
98
99 if (LINT_ONLY.test(message)) {
100 return (
101 'commit-ticket: no commits that only fix linting or formatting. Run the autofix (eslint --fix, prettier --write) ' +
102 'and keep the result inside the commit of the change that caused it (git commit --amend or a fixup).'
103 )
104 }
105
106 return null
107}
108