SLOPSHOPPER

house-rules

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

guardtoast
A shopper browsing a rack in a slop shop
README

Claude Code mods

License: MIT Claude Code mods Tested on Claude Code 2.1.296 GitHub stars

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.

Mods

ModWhat it doesNetworkCan block a command?
task-progressOne progress row per plan the model reports, plus a row per running subagent.NoOnly its own tool, on bad input
limits-meter5-hour and weekly usage in the status line, plus running subagents. Toasts at 90% and 95%.NoNo
deploy-watchA light per Render service in the status line, with a toast when a deploy starts, goes live, fails or is canceled.Render API, read-onlyNo
house-rulesWarns when a goal-checklist strip breaks its rules. Optional: status-line check and a commit and PR title rule.NoOnly the title rule, and only if you turn it on

Install

/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.

task-progress

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
  • Plans: the model creates a plan for any task of three or more steps, then updates it with short calls: {id, next:true}, {id, done:[…], active:…}, {id, failed:…, note}, {id, state:…}. An unknown step title is refused with the list of valid ones.
  • Subagents: one row each, with name, model, current tool and elapsed time. They come from engine events and cost no tokens.
  • States: running, needs input, error, done. A finished plan leaves after doneBarSeconds (default 30).
  • Commands: /progress [show|hide] and /progress-clear.
  • Adds one short section to the system prompt telling the model when to create and update a plan.
  • Does not: make network calls, run shell commands, read or write files, block edits, or hold a turn open.

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.

limits-meter

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.

deploy-watch

Status line such as deploy: api 🟢 · web 🟡. A toast when a service's newest deploy starts, goes live, fails or is canceled.

OptionMeaning
renderApiKeyA Render API key. The mod only lists deploys. Stored as a sensitive option.
serviceIdsComma-separated Render service ids; label one with name=id, for example api=srv-abc123,web=srv-def456.
pollSecondsHow 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.

house-rules

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.

OptionDefaultMeaning
checkStatusLineoffWarn when a reply does not end on a 🟢/🟡/🔴 line of 100 characters or fewer.
enforceCommitRuleoffBlocks git commit and gh pr create unless the subject is type(scope): summary, 50 characters or fewer, lowercase after the colon, no trailing period.

Development

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.

Contributing

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.

License

MIT © 2026 Stylus Nexus

Source 4 files
hooks/register.ts 77 lines
1import 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}
77
hooks/rules.ts 143 lines
1const 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)
143
hooks/strip.ts 87 lines
1export 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}
87
types/index.d.ts 8 lines
1export type ChecklistActive = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'house-rules': { isChecklistActive: ChecklistActive }
6  }
7}
8