Warns (status line and toast) only when RAM or disk is low: free RAM, free disk and running test runners. When there is no problem, it draws nothing.

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 51 lines1import type { Register } from 'claude-code'
2
3import { countRunners, formatStatus, levelOf, parseDfAvailable, parseVmStat } from './lib'
4import type { Level } from './lib'
5
6const EVERY_MS = 30_000
7
8export const register: Register = on => {
9 let lastLevel: Level = 'ok'
10
11 on('session.start', async ($, e, next) => {
12 const started = await next(e)
13
14 if (e.isInteractive) {
15 const tick = async () => {
16 try {
17 const [vm, df, ps] = await Promise.all([
18 $.process.run(['vm_stat']),
19 $.process.run(['df', '-k', '/']),
20 $.process.run(['ps', '-axo', 'command']),
21 ])
22 const ram = parseVmStat(vm.stdout)
23 const disk = parseDfAvailable(df.stdout)
24
25 const level = levelOf(ram, disk)
26
27 // The status line only shows up when there is a problem: the engine
28 // draws it as a warning, and showing it all the time would be noise.
29 $.ui.status(level === 'ok' ? undefined : formatStatus(ram, disk, countRunners(ps.stdout)))
30
31 if (level !== 'ok' && level !== lastLevel) {
32 $.ui.toast(
33 level === 'critical'
34 ? 'Memory or disk almost full: do not start more tests in parallel'
35 : 'Low memory or disk: be careful with tests in parallel',
36 )
37 }
38 lastLevel = level
39 } catch {
40 // A failed tick must not break the session; the next one tries again.
41 }
42 }
43
44 void tick()
45 $.clock.every(EVERY_MS, tick)
46 }
47
48 return started
49 })
50}
51hooks/lib.ts 62 lines1const GB = 1024 ** 3
2
3// "Available" memory the macOS way: free + inactive + speculative + purgeable.
4// `vm_stat` reports pages, and the page size is on its first line.
5export function parseVmStat(text: string): number | null {
6 const pageSize = Number(/page size of (\d+) bytes/.exec(text)?.[1])
7 if (!pageSize) return null
8
9 const pages = (label: string): number =>
10 Number(new RegExp(`^${label}:\\s+(\\d+)\\.`, 'm').exec(text)?.[1] ?? 0)
11
12 const available =
13 pages('Pages free') +
14 pages('Pages inactive') +
15 pages('Pages speculative') +
16 pages('Pages purgeable')
17
18 return available * pageSize
19}
20
21// `df -k /`: column 4 of the second line is the available space, in KB.
22export function parseDfAvailable(text: string): number | null {
23 const row = text.trim().split('\n')[1]?.trim().split(/\s+/)
24 const kb = Number(row?.[3])
25 return Number.isFinite(kb) && row?.[3] !== undefined ? kb * 1024 : null
26}
27
28const RUNNER =
29 /\b(jest|vitest|rspec|parallel_rspec|playwright test|tsx --test|node --test|bun test|pytest)\b/
30
31// Test processes in `ps -axo command`, without counting `ps` itself or the
32// `grep` commands that someone started to look for them.
33export function countRunners(psText: string): number {
34 return psText
35 .split('\n')
36 .filter(line => RUNNER.test(line) && !/\b(grep|pgrep|ps -axo)\b/.test(line))
37 .length
38}
39
40export type Level = 'ok' | 'warn' | 'critical'
41
42export function levelOf(ramBytes: number | null, diskBytes: number | null): Level {
43 const ram = ramBytes ?? Infinity
44 const disk = diskBytes ?? Infinity
45 if (ram < 1 * GB || disk < 5 * GB) return 'critical'
46 if (ram < 2.5 * GB || disk < 10 * GB) return 'warn'
47 return 'ok'
48}
49
50const gb = (bytes: number | null): string =>
51 bytes === null ? '?' : `${(bytes / GB).toFixed(1)}G`
52
53export function formatStatus(
54 ramBytes: number | null,
55 diskBytes: number | null,
56 runners: number,
57): string {
58 const mark = { ok: '', warn: '! ', critical: '!! ' }[levelOf(ramBytes, diskBytes)]
59 const tests = runners > 0 ? ` · tests ${runners}` : ''
60 return `${mark}RAM ${gb(ramBytes)} free · disk ${gb(diskBytes)}${tests}`
61}
62