Checks goal-checklist strips, and optionally the closing status line and the conventional-commit title rule.

Small Claude Code mods from Stylus Nexus: a progress row above the prompt, a usage meter, a Render deploy light, and a checker for team conventions. Each mod is a few hundred lines of TypeScript with tests, so you can read one in a sitting before you install it.
A mod is a Claude Code plugin with a hooks module: it can add status lines, rows above the prompt, toasts, tools and commands.
Read before you install. A mod runs inside your Claude Code session and sees your prompts, tool calls and answers. Review a mod's source first. The table says which mods touch the network or can block a command.
| Mod | What it does | Network | Can block a command? |
|---|---|---|---|
task-progress | One progress row per plan the model reports, plus a row per running subagent. | No | Only its own tool, on bad input |
limits-meter | 5-hour and weekly usage in the status line, plus running subagents. Toasts at 90% and 95%. | No | No |
deploy-watch | A light per Render service in the status line, with a toast when a deploy starts, goes live, fails or is canceled. | Render API, read-only | No |
house-rules | Warns when a goal-checklist strip breaks its rules. Optional: status-line check and a commit and PR title rule. | No | Only the title rule, and only if you turn it on |
/plugin marketplace add stylusnexus/claude-mods
/plugin install task-progress@stylusnexus-mods
Replace task-progress with any mod name above. Run /plugin to enable, disable or configure it later.
Requires Claude Code 2.1.286 or newer, the first build that loads hooks modules (a similar mod, zycck/claude-mods, documents the same minimum). These mods were built and tested on 2.1.296; older builds have not been tried. Check yours with claude --version.
A progress row above the prompt for each plan the model reports:
Ship login · Build · 2/6 steps · ▓▓▓░░░░░░░ 33% · running
↳ explorer · sonnet · Grep · 0:42
{id, next:true}, {id, done:[…], active:…}, {id, failed:…, note}, {id, state:…}. An unknown step title is refused with the list of valid ones.doneBarSeconds (default 30)./progress [show|hide] and /progress-clear.The bar shows what the model reports. A full bar is not proof the work is done; check the work.
Not yet verified: it has not run in a long live session, and a subagent's row leaves on that subagent's first finished turn, so a subagent with several turns may drop off early.
Status line such as 5h 42% · wk 18% · 2 agents. Toasts once when a window passes 90% and again at 95%, with the time until it resets. No options, no network.
Status line such as deploy: api 🟢 · web 🟡. A toast when a service's newest deploy starts, goes live, fails or is canceled.
| Option | Meaning |
|---|---|
renderApiKey | A Render API key. The mod only lists deploys. Stored as a sensitive option. |
serviceIds | Comma-separated Render service ids; label one with name=id, for example api=srv-abc123,web=srv-def456. |
pollSeconds | How often to check, 10 to 600. Default 30. |
The only network calls are GET https://api.render.com/v1/services/<id>/deploys?limit=1 with your key.
Built for one team's goal-checklist convention: rows of ⬜ 🔄 🟦 🚀 ✅ with a tally line, directly above a 🟢/🟡/🔴 status line. By default it only shows a toast when a printed strip breaks the rules (tally mismatch, skipped stage, merged item with no SHA, strip with nothing changed, strip when no checklist is active). Read strip.ts before relying on it.
| Option | Default | Meaning |
|---|---|---|
checkStatusLine | off | Warn when a reply does not end on a 🟢/🟡/🔴 line of 100 characters or fewer. |
enforceCommitRule | off | Blocks git commit and gh pr create unless the subject is type(scope): summary, 50 characters or fewer, lowercase after the colon, no trailing period. |
Each mod lives in plugins/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json and hooks/*.ts. Pure logic sits in its own file with unit tests beside it; register.ts holds the hooks.
cd plugins/<name>
claude plugin test .
claude plugin validate .
There is no CI yet: the checks above need the Claude Code CLI and have not been wired into GitHub Actions.
Issues and pull requests are welcome. Keep a mod small, keep its tests passing, and say in the README table whether it uses the network or can block a command. Use type(scope): summary commit titles of 50 characters or fewer.
MIT © 2026 Stylus Nexus
hooks/register.ts 77 lines1import type { Register } from 'claude-code'
2import { MCP_ISSUE, MCP_MOVES, activatesChecklist, commitFile, commitSubject, fileLocation, firstLine, moves, prTitle, statusProblem, stdinSubject, subjectProblem, writtenSubject, type Title } from './rules'
3import { checkStrip, looksLikeStrip, type Item } from './strip'
4
5const ASKED = /goal-?checklist|where are we|status on/i
6
7export const register: Register = (on, options) => {
8 const checkStatusLine = options.checkStatusLine === true
9 const enforceCommitRule = options.enforceCommitRule === true
10 let prev: Item[] | undefined
11 let prompt = ''
12 let moved = false
13
14 on('turn.start', async ($, e, next) => {
15 prompt = e.text
16 moved = false
17 if (activatesChecklist(e.text)) await $.state.set({ plugin: 'house-rules', key: 'isChecklistActive' }, true)
18 return next(e)
19 })
20
21 on('tool.call', { tool: MCP_MOVES }, ($, e, next) => {
22 moved = true
23 return next(e)
24 })
25
26 on('tool.call', { tool: MCP_ISSUE }, ($, e, next) => {
27 if ((e as { state?: string }).state === 'closed') moved = true
28 return next(e)
29 })
30
31 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
32 if (moves(e.command)) moved = true
33 if (!enforceCommitRule) return next(e)
34 const commit = commitSubject(e.command)
35 let t: Title | undefined = commit ?? prTitle(e.command)
36 const file = commitFile(e.command)
37 if (file !== undefined) {
38 // A file the same command writes first doesn't exist yet: take its title from the command.
39 const written = file === '-' || /[$`]/.test(file) ? undefined : writtenSubject(e.command, file)
40 if (written) t = written
41 else {
42 const path = file === '-' ? undefined : fileLocation(e.command, file)
43 const text =
44 file === '-'
45 ? stdinSubject(e.command)
46 : path === undefined
47 ? undefined
48 : await $.fs.read(path).then(firstLine, () => undefined)
49 if (text === undefined || /[$`]/.test(file))
50 return { deny: `${$.plugin.name}: commit message ${file === '-' ? 'from stdin' : `file ${file}`} can't be read — pass the subject with -m.` }
51 t = { text, dynamic: false }
52 }
53 }
54 const problem = t === undefined ? undefined : subjectProblem(t.text, t.dynamic)
55 if (t !== undefined && problem !== undefined)
56 return { deny: `${$.plugin.name}: ${commit || file !== undefined ? 'commit' : 'PR title'} "${t.text}" — ${problem}.` }
57 return next(e)
58 }).catch(($, e, next) => (next.called ? next(e) : { deny: `${$.plugin.name}: its guard failed.` }))
59
60 on('turn.complete', async ($, e, next) => {
61 const result = await next(e)
62 if (e.agentId === undefined && e.reason === 'answer') {
63 const { value: isActive } = await $.state.get({ plugin: 'house-rules', key: 'isChecklistActive' })
64 const stray = !isActive && looksLikeStrip(e.answer)
65 const strip = stray ? { problems: [] as string[], items: undefined } : checkStrip(e.answer, prev, ASKED.test(prompt))
66 const problems = [checkStatusLine ? statusProblem(e.answer) : undefined, ...strip.problems]
67 if (stray) problems.push('a strip was printed but no goal-checklist is active: leave it out')
68 if (strip.items) prev = strip.items
69 else if (prev && moved) problems.push('a PR or deploy ran but no strip was printed')
70 const found = problems.filter(Boolean)
71 if (found.length) $.ui.toast(`${$.plugin.name}: ${found.join(' · ')}`)
72 }
73 moved = false
74 return result
75 })
76}
77hooks/rules.ts 143 lines1const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert'
2const SUBJECT = new RegExp(`^(${TYPES})(\\([a-z0-9._/-]+\\))?!?: [^A-Z\\s].*[^.\\s]$`)
3const STATUS = /^[🟢🟡🔴] \S/u
4const AT_START = String.raw`(?:^|[;&|(]\s*|\n\s*)`
5const ARG = String.raw`(?:"[^"]+"|'[^']+'|[^\s"';&|)]+)`
6const GIT_OPTS = String.raw`(?:\s+(?:-[Cc]\s+${ARG}|--(?:git-dir|work-tree|namespace|super-prefix|config-env)\s+${ARG}|--[a-z-]+(?:=${ARG})?|-p))*`
7const GIT_COMMIT = new RegExp(`${AT_START}git${GIT_OPTS}\\s+commit(?![\\w-])`)
8const GH_PR_CREATE = new RegExp(`${AT_START}gh\\s+pr\\s+create\\b`)
9const MOVES = new RegExp(
10 `${AT_START}(?:gh\\s+(?:pr\\s+(?:merge|create)|issue\\s+close)\\b|(?:vercel|render|fly|wrangler)\\b[^\\n]*\\bdeploy\\b|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?deploy\\b)`,
11)
12
13export const subjectProblem = (subject: string, dynamic = false): string | undefined => {
14 if (dynamic) return 'built from a shell variable or substitution — write the subject out so it can be checked'
15 if (subject.length > 50) return `subject is ${subject.length} chars, limit is 50`
16 if (!SUBJECT.test(subject))
17 return 'use "type(scope): imperative summary" — lowercase after the colon, no trailing period'
18 return undefined
19}
20
21// Drops the body of any heredoc that is not feeding git commit or gh pr, so text a command
22// merely writes into a file is never read as a command.
23export const scrub = (cmd: string): string =>
24 cmd.replace(
25 /^([^\n]*)<<-?\s*(['"]?)(\w+)\2[^\n]*\n[\s\S]*?\n[ \t]*\3[ \t]*$/gm,
26 (all, head: string) => (/\bgit\s+commit(?![\w-])|\bgh\s+pr\b/.test(head) ? all : `${head}<<`),
27 )
28
29const first = (m: RegExpMatchArray | null) => m?.slice(1).find(g => g !== undefined)
30
31export type Title = { text: string; dynamic: boolean }
32
33// A title is dynamic when the shell will expand it before git or gh sees it.
34const title = (m: RegExpMatchArray | null): Title | undefined => {
35 const text = first(m)?.trim()
36 if (text === undefined) return undefined
37 const single = m![0].trimEnd().includes("'" + text)
38 return { text, dynamic: !single && /[$`]/.test(text) }
39}
40
41export const commitSubject = (raw: string): Title | undefined => {
42 const cmd = scrub(raw)
43 if (!GIT_COMMIT.test(cmd)) return undefined
44 const heredoc = cmd.match(/(?:\s-[a-zA-Z]*m|\s--message[= ])\s*"\$\(cat <<-?\\?'?"?EOF"?'?\n([^\n]*)/)
45 if (heredoc) return { text: heredoc[1]!.trim(), dynamic: false }
46 return title(cmd.match(/(?:\s-[a-zA-Z]*m|\s--message[= ])\s*(?:"([^"\n]*)|'([^'\n]*)|([^\s"';&|)]+))/))
47}
48
49export const prTitle = (raw: string): Title | undefined => {
50 const cmd = scrub(raw)
51 if (!GH_PR_CREATE.test(cmd)) return undefined
52 return title(cmd.match(/(?:\s-t|\s--title[= ])\s*(?:"([^"\n]*)|'([^'\n]*)|([^\s"';&|)]+))/))
53}
54
55// `git commit -F path` or `--file path`, with no -m beside it. `-` means stdin.
56export const commitFile = (raw: string): string | undefined => {
57 const cmd = scrub(raw)
58 if (!GIT_COMMIT.test(cmd) || commitSubject(cmd)) return undefined
59 return first(cmd.match(/\s(?:-[a-zA-Z]*F|--file[= ])\s*(?:"([^"]+)"|'([^']+)'|([^\s"';&|)]+))/))
60}
61
62// Where `file` is once the `cd`s before git commit and any `git -C dir` have applied;
63// undefined when one goes somewhere the plugin can't resolve (a variable, `~`, `cd -`).
64export const fileLocation = (raw: string, file: string): string | undefined => {
65 const cmd = scrub(raw)
66 const hit = GIT_COMMIT.exec(cmd)
67 const steps: { to: string; tree: boolean }[] = []
68 for (const m of cmd.slice(0, hit?.index ?? 0).matchAll(
69 new RegExp(`${AT_START}cd\\s+(?:"([^"]+)"|'([^']+)'|([^\\s;&|)]+))`, 'g'),
70 ))
71 steps.push({ to: (m[1] ?? m[2] ?? m[3])!, tree: false })
72 // git reads a relative -F path from the work tree when --work-tree names one.
73 for (const m of (hit?.[0] ?? '').matchAll(
74 /\s(-C\s+|--work-tree(?:=|\s+))(?:"([^"]+)"|'([^']+)'|([^\s"';&|)]+))/g,
75 ))
76 steps.push({ to: (m[2] ?? m[3] ?? m[4])!, tree: m[1]!.startsWith('--') })
77
78 let dir = ''
79 let tree: string | undefined
80 for (const { to, tree: isTree } of steps) {
81 if (to === '-' || /[$`~]/.test(to)) return undefined
82 const at = to.startsWith('/') || dir === '' ? to : `${dir}/${to}`
83 if (isTree) tree = at
84 else dir = at
85 }
86 const base = tree ?? dir
87 return file.startsWith('/') || base === '' ? file : `${base}/${file}`
88}
89
90const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
91
92// The title of a message file the same command writes before it commits, from
93// `echo`/`printf` text or a `cat` heredoc redirected into it. `>>` is not a write the
94// plugin can follow, as the file may already hold text.
95export const writtenSubject = (raw: string, file: string): Title | undefined => {
96 const at = GIT_COMMIT.exec(raw)?.index ?? raw.length
97 const head = raw.slice(0, at)
98 const f = `["']?${esc(file)}["']?(?![\\w./-])`
99 const line = (text: string) => text.split(/\\n|\n/).find(l => l.trim() !== '')?.trim()
100
101 const echo = head.match(
102 new RegExp(`${AT_START}(echo|printf)\\s+(?:-[a-zA-Z]+\\s+)*(?:"([^"\\n]*)"|'([^'\\n]*)'|([^\\s"';&|<>]+))\\s*>(?!>)\\s*${f}`),
103 )
104 if (echo) {
105 const text = echo[2] ?? echo[3] ?? echo[4]!
106 if (echo[1] === 'printf' && text.includes('%')) return undefined
107 const title = line(text)
108 return title === undefined ? undefined : { text: title, dynamic: echo[3] === undefined && /[$`]/.test(text) }
109 }
110
111 const doc =
112 head.match(new RegExp(`\\bcat\\s*>\\s*${f}\\s*<<-?\\s*(['"]?)(\\w+)\\1[ \\t]*\\n[ \\t]*([^\\n]*)`)) ??
113 head.match(new RegExp(`\\bcat\\s*<<-?\\s*(['"]?)(\\w+)\\1\\s*>\\s*${f}[ \\t]*\\n[ \\t]*([^\\n]*)`))
114 if (doc) return { text: doc[3]!.trim(), dynamic: doc[1] === '' && /[$`]/.test(doc[3]!) }
115 return undefined
116}
117
118// The first line of a heredoc fed to `git commit -F -`.
119export const stdinSubject = (cmd: string): string | undefined =>
120 cmd.match(/<<-?\s*['"]?(\w+)['"]?[ \t]*\n[ \t]*([^\n]*)/)?.[2]?.trim()
121
122export const firstLine = (text: string): string | undefined =>
123 text.split('\n').find(l => l.trim() !== '')?.trim()
124
125export const moves = (cmd: string): boolean => MOVES.test(scrub(cmd))
126
127export const MCP_MOVES = /^mcp__.+__(merge_pull_request|create_pull_request)$/
128export const MCP_ISSUE = /^mcp__.+__issue_write$/
129
130export const statusProblem = (answer: string): string | undefined => {
131 const lines = answer.trimEnd().split('\n')
132 const last = lines[lines.length - 1] ?? ''
133 if (!STATUS.test(last)) return 'reply does not end on a 🟢/🟡/🔴 status line'
134 if ([...last].length > 100) return 'status line is over 100 characters'
135 return undefined
136}
137
138// A prompt that seeds, adds to, drops from or restores a goal-checklist: the skill invoked with
139// issue numbers or symbols, or a pasted strip. A bare /goal-checklist or --help starts nothing.
140export const activatesChecklist = (prompt: string): boolean =>
141 /\/goal-checklist\s+[^\n]*(#?\d{2,}|[⬜🔄🟦🚀✅🔍⏸])/u.test(prompt) ||
142 /^\s*(⬜|🔄|🟦|🚀|✅|🔍|⏸)\uFE0F?\s+#\d+/mu.test(prompt)
143hooks/strip.ts 87 lines1export type Item = { num: number; sym: string; frac?: string; note?: string }
2export type Checked = { problems: string[]; items?: Item[] }
3
4const BANDS = '⬜|🔄|🟦|🚀|✅|🔍|⏸'
5const STAGE: Record<string, number> = { '⬜': 0, '🔄': 1, '🟦': 2, '🚀': 3, '✅': 4 }
6const TALLY = /^·\s.*\b\d+ total\s*$/u
7const HEAD = new RegExp(`^(\\s|${BANDS})`, 'u')
8const ORPHAN = new RegExp(`^(${BANDS})\\uFE0F?\\s+#\\d+`, 'mu')
9const SHA = /^[0-9a-f]{7,}\b/
10const STATUS_EMOJI = /^[🟢🟡🔴] /u
11
12const tokens = () =>
13 new RegExp(`(${BANDS})\\uFE0F?|(on\\s+#\\d+:)|( · )|#(\\d+)(?:\\s+(\\d+\\/\\d+))?(?:\\s*\\(((?:[^()]|\\([^()]*\\))*)\\))?`, 'gu')
14
15const key = (items: Item[]) =>
16 items.map(i => `${i.num}${i.sym}${i.frac ?? ''}`).sort().join(',')
17
18// True when the reply holds a goal-checklist strip, well formed or not.
19export const looksLikeStrip = (answer: string): boolean =>
20 ORPHAN.test(answer) || answer.split('\n').some(l => TALLY.test(l.trim()))
21
22// Reads the goal-checklist strip printed in `answer` and returns what breaks the skill's rules.
23// `prev` is the last strip seen; `requested` means the user asked for the strip, so repeating it is fine.
24export const checkStrip = (answer: string, prev: Item[] | undefined, requested: boolean): Checked => {
25 const lines = answer.trimEnd().split('\n')
26 let t = -1
27 for (let i = lines.length - 1; i >= 0; i--) if (TALLY.test(lines[i]!.trim())) { t = i; break }
28 if (t < 0) return ORPHAN.test(answer) ? { problems: ['strip has no tally line'] } : { problems: [] }
29
30 let a = t
31 while (a > 0 && lines[a - 1]!.trim() !== '' && HEAD.test(lines[a - 1]!)) a--
32
33 const problems: string[] = []
34 const items: Item[] = []
35 const lastNum: Record<string, number> = {}
36 let cur = ''
37 let named = false
38 for (const line of lines.slice(a, t)) {
39 for (const m of line.matchAll(tokens())) {
40 if (m[1]) { cur = m[1]; named = false }
41 else if (m[2]) named = true
42 else if (m[3]) named = false
43 else if (m[4]) {
44 const item: Item = { num: Number(m[4]), sym: cur, frac: m[5], note: m[6] }
45 items.push(item)
46 const tag = `#${item.num}`
47 if ((cur === '🟦' || cur === '🚀') && !SHA.test(item.note ?? ''))
48 problems.push(`${tag} is ${cur} without its merge SHA`)
49 if (cur === '⏸' && !named && !(item.note ?? '').trim())
50 problems.push(`${tag} is ⏸️ with no blocker named`)
51 if (cur !== '⏸') {
52 if (item.num < (lastNum[cur] ?? 0)) problems.push(`${tag} is out of order in its row`)
53 lastNum[cur] = item.num
54 }
55 }
56 }
57 }
58
59 const counts: Record<string, number> = {}
60 for (const i of items) counts[i.sym] = (counts[i.sym] ?? 0) + 1
61 const tally = lines[t]!
62 const said: Record<string, number> = {}
63 for (const m of tally.matchAll(new RegExp(`(\\d+)(${BANDS})\\uFE0F?`, 'gu'))) said[m[2]!] = Number(m[1])
64 const total = Number(/(\d+) total/.exec(tally)?.[1])
65 const wrong =
66 total !== items.length ||
67 Object.keys({ ...counts, ...said }).some(s => (counts[s] ?? 0) !== (said[s] ?? 0))
68 if (wrong) problems.push(`tally does not match the ${items.length} items in the strip`)
69
70 const after = lines.slice(t + 1).filter(l => l.trim() !== '')
71 if (after.length > 1 || (after.length === 1 && !STATUS_EMOJI.test(after[0]!)))
72 problems.push('strip is not directly above the status line')
73
74 if (prev) {
75 const before = new Map(prev.map(i => [i.num, i.sym]))
76 for (const i of items) {
77 const from = STAGE[before.get(i.num) ?? '']
78 const to = STAGE[i.sym]
79 if (from !== undefined && to !== undefined && to - from > 1)
80 problems.push(`#${i.num} jumped ${before.get(i.num)}→${i.sym}, skipping a stage`)
81 }
82 if (!requested && key(prev) === key(items))
83 problems.push('strip unchanged since the last one — skip it when nothing moved')
84 }
85 return { problems, items }
86}
87types/index.d.ts 8 lines1export type ChecklistActive = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'house-rules': { isChecklistActive: ChecklistActive }
6 }
7}
8