Per-repo commit attribution policy: refuses a git commit, gh pr create/edit or git push that would carry a Co-Authored-By: Claude trailer or the Generated with…

Eleven Claude Code mods (function-hook plugins) built around one workflow: many parallel side sessions, upstream PRs behind CodeRabbit, Codecov and Codex reviews, an Unraid server with about 90 containers, Home Assistant, and a local LLM on an RTX 3070. They are siblings of session-crew and borrow its savvy-progress crabs.
The mod API is early access. These mods were built and tested against Claude Code 2.1.295 (the desktop app's CLI); the declaration file that version ships is the authority.
| Mod | What you get | Commands | |
|---|---|---|---|
| where-am-i | Status line: 🍎 Mac · ha-unraid-management-agent · main · fork of tinkerologie. A note for Claude when, on the Mac, it SSHes to the Unraid host or the container, or runs the tests of a repo that is developed in the container. | /where | |
| roll-call | A pane where each side session's crab holds a sign: one line on where it stands, and a "needs you" lane (questions, approvals, boxes to tick). Lines come from Haiku, the local LLM, or the last message. | `/rollcall [open\ | linked] [text], tool report` |
| fuel-gauge | ⛽ ctx 14% · 5h 26% (34m) · wk 36% in the status line; toasts at 80% and 95% of a plan window and at 85% context; a pane with extra usage and every running side session's context. A band appears only while something runs hot. | /fuel [text] | |
| gpu-crab | While a local-llm tool runs: a crab on a treadmill above the prompt with the model, VRAM, GPU/CPU split (amber when it spills to CPU) and elapsed time; afterwards, a tally of what local models read today instead of the cloud. | /gpu | |
| vitals-strip | Unraid and HA health above the prompt (array and pools, CPU and memory, unhealthy or missing containers, unread alerts, parity, HA repairs). Quiet by default: only what isn't green. | `/vitals [show\ | quiet]` |
| reef | An aquarium pane: each container is a fish coloured by stack, unhealthy ones float belly-up, the GPU is a heater, HA is a lighthouse, side sessions are crabs on the sand. Hover a fish for its name. | /reef [text] | |
| keymaster | Masks API keys, tokens, passwords and private keys in tool output before Claude, the transcript or the screen sees them (‹masked arr-api-key 1a2b3c4d›), with a toast and a count. Withholds output it can't scan. | /keymaster, /keymaster allow <tag> | |
| trailer-guard | Refuses a git commit, gh pr create/edit or git push that would carry Co-Authored-By: Claude or the Claude Code footer into a repo whose remotes include tinkerologie, esphome or home-assistant (configurable). | /trailers | |
| gatekeeper | A pane of your open PRs by lane: needs you (boxes only you tick, green drafts), needs work (red checks, Codecov, CodeRabbit threads, conflicts), waiting. A band when something needs you. | /gates [text], tool gates | |
| codex-corner | A boxing ring above the prompt for Codex adversarial reviews: each run is a round with findings by severity, until the verdict is approve and the bot goes down. Kept per repo and branch. | /codex-card | |
| house-rules | Notes, asks and refusals on tool calls: appdata paths (/mnt/user/appdata vs the nvme8tb pool), CLAUDE.local.md in git, force-push to upstream, HA restarts. Add your own. | /rules, /rules add …, /rules off <id> |
Everything is read-only.
| Mod | Reads |
|---|---|
| roll-call, fuel-gauge, reef | The desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage) |
| vitals-strip | The HA-MCP connector: one ha_eval_template over the Unraid Management Agent integration's entities, and ha_get_overview. Allow both tools in permissions so auto mode doesn't review each read |
| reef | unraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview) |
| gpu-crab, reef | Ollama GET /api/ps at the ollamaUrl option (default http://localhost:11434) |
| gatekeeper | gh api graphql, one search query every 5 minutes and shortly after a push |
| where-am-i, trailer-guard, codex-corner | git in the session's checkout |
Server names, URLs and thresholds are options (userConfig) on each mod. The defaults assume nothing about your network: Ollama on localhost, no Home Assistant connector (vitals-strip and reef's lighthouse stay off until you name one), and no repo or SSH rules for where-am-i. Set your values in ~/.claude/settings.json:
"pluginConfigs": {
"gpu-crab": { "options": { "ollamaUrl": "http://192.168.1.50:11434" } },
"vitals-strip": { "options": { "haServer": "<your HA-MCP connector, as /mcp lists it>" } },
"where-am-i": { "options": { "homes": ["my-integration=container"], "containerTargets": ["my-nas"] } }
}
For one terminal session:
claude --plugin-dir ~/dev/crab-workshop/plugins/where-am-i --plugin-dir ~/dev/crab-workshop/plugins/fuel-gauge
For desktop-app sessions, add the folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, next to session-crew's), then start a new session:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/session-crew:/path/to/crab-workshop/plugins/where-am-i:/path/to/crab-workshop/plugins/keymaster"
}
Or install from this folder as a marketplace, so /reload-plugins picks up edits:
claude plugin marketplace add ~/dev/crab-workshop
Then, in a terminal session, /plugin install keymaster@crab-workshop (and so on). Sessions on the UnRAID SSH connection run Claude Code inside the claude-code container, so the mods need the same setup there.
sh scripts/check.sh # every mod: type-check, claude plugin validate, claude plugin test
sh scripts/check.sh gatekeeper # one mod
shared/ holds the crab art (copied from savvy-progress through session-crew; see THIRD_PARTY_NOTICES.md) and the drawing helpers. scripts/sync-shared.sh copies them into each mod's hooks/shared/, because a mod can only import its own files. Edit shared/, never the copies.
AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead./keymaster allow its tag.MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.
hooks/register.ts 87 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { actionsIn, attributionLines, checkoutPolicy, denial, parsePolicy, policyFor } from './policy'
4import type { Rule } from './policy'
5
6let rules: Rule[] = []
7
8async function git($: EngineInterface, dir: string | undefined, args: string[]): Promise<string> {
9 try {
10 const r = await $.process.run(['git', ...args], { ...(dir ? { cwd: dir } : {}), timeoutMs: 8000 })
11 return r.exitCode === 0 ? r.stdout : ''
12 } catch {
13 return ''
14 }
15}
16
17async function policyOf($: EngineInterface, dir: string | undefined): Promise<{ policy: 'none' | 'allow'; because?: string }> {
18 const remotes = await git($, dir, ['remote', '-v'])
19 const urls = [...new Set(remotes.split('\n').map(l => l.split(/\s+/)[1] ?? '').filter(Boolean))]
20 return checkoutPolicy(urls, rules)
21}
22
23async function readFile($: EngineInterface, dir: string | undefined, file: string): Promise<string> {
24 try {
25 const t = await $.fs.read(file.startsWith('/') || !dir ? file : `${dir}/${file}`)
26 return typeof t === 'string' ? t : ''
27 } catch {
28 return ''
29 }
30}
31
32/** The refusal for a command, or null when it may run. */
33async function judge($: EngineInterface, command: string): Promise<string | null> {
34 const actions = actionsIn(command)
35 if (!actions.length) return null
36 const inline = attributionLines(command)
37 for (const a of actions) {
38 const p = a.kind === 'pr' && a.repo ? { policy: policyFor(a.repo, rules), because: a.repo } : await policyOf($, a.dir)
39 if (p.policy !== 'none') continue
40 const repo = p.because ?? 'this repo'
41 if (a.kind === 'push') {
42 // Commits on no remote yet: what this push would publish.
43 const lines = attributionLines(await git($, a.dir, ['log', '--not', '--remotes', '--format=%B', '-n', '50']))
44 if (lines.length) return denial(repo, lines, 'an unpushed commit')
45 continue
46 }
47 const lines = [...inline]
48 for (const f of a.files) lines.push(...attributionLines(await readFile($, a.dir, f)))
49 if (a.kind === 'commit' && a.isAmendNoMessage) lines.push(...attributionLines(await git($, a.dir, ['log', '-1', '--format=%B'])))
50 const unique = [...new Set(lines)]
51 if (unique.length) return denial(repo, unique, a.kind === 'pr' ? 'this PR text' : 'this commit message')
52 }
53 return null
54}
55
56export const register: Register = (on, options) => {
57 rules = parsePolicy(options.policy as readonly string[] | undefined)
58
59 on('session.start', async ($, e, next) => {
60 const started = await next(e)
61 await $.command.register({ name: 'trailers', description: "This repo's commit attribution policy (trailer-guard)" })
62 return started
63 })
64
65 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66 const why = await judge($, String(e.command ?? ''))
67 return why ? { deny: why } : next(e)
68 }).catch(($, e, next) => next(e))
69
70 // The engine's own attribution text, where the session's repo says none.
71 on('attribution.text', async ($, e, next) => {
72 if (e.kind !== 'commit' && e.kind !== 'pr') return next(e)
73 const p = await policyOf($, await $.session.cwd())
74 return p.policy === 'none' ? { text: '' } : next(e)
75 }).catch(($, e, next) => next(e))
76
77 on('command.run', { command: 'trailers' }, async $ => {
78 const p = await policyOf($, await $.session.cwd())
79 return {
80 text:
81 p.policy === 'none'
82 ? `No AI attribution here: a remote is ${p.because}. trailer-guard refuses commits, PR text and pushes that carry Co-Authored-By: Claude or the Claude Code footer.`
83 : `Attribution allowed here (no remote matches a none rule). Rules: ${rules.map(r => r.join('=')).join(', ')}`,
84 }
85 })
86}
87hooks/policy.ts 79 lines1// Pure logic for trailer-guard.
2
3export type Rule = [pattern: string, policy: 'none' | 'allow']
4
5export function parsePolicy(raw: readonly string[] | string | undefined): Rule[] {
6 const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(/[,\n]/) : []
7 return list
8 .map(s => String(s).trim())
9 .filter(s => s.includes('='))
10 .map(s => [s.slice(0, s.lastIndexOf('=')).trim(), s.slice(s.lastIndexOf('=') + 1).trim().toLowerCase()] as [string, string])
11 .filter((r): r is Rule => !!r[0] && (r[1] === 'none' || r[1] === 'allow'))
12}
13
14export const glob = (pattern: string, s: string): boolean =>
15 new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i').test(s)
16
17/** owner/name from a git remote URL. */
18export function slug(url: string): string | null {
19 const m = url.trim().match(/[:/]([^/:\s]+)\/([^/\s]+?)(?:\.git)?\/?$/)
20 return m && m[1] && m[2] ? `${m[1]}/${m[2]}` : null
21}
22
23export const policyFor = (repo: string, rules: Rule[]): 'none' | 'allow' => rules.find(([p]) => glob(p, repo))?.[1] ?? 'allow'
24
25/** The strictest policy among the repos a checkout's remotes name. */
26export function checkoutPolicy(remoteUrls: string[], rules: Rule[]): { policy: 'none' | 'allow'; because?: string } {
27 for (const u of remoteUrls) {
28 const s = slug(u)
29 if (s && policyFor(s, rules) === 'none') return { policy: 'none', because: s }
30 }
31 return { policy: 'allow' }
32}
33
34// Anywhere in a line, so a trailer passed as its own -m "..." argument counts too.
35const LINES = [/Co-Authored-By:\s*Claude\b[^"'\n]*/gi, /Co-Authored-By:[^"'\n]*noreply@anthropic\.com[^"'\n]*/gi, /(?:🤖\s*)?Generated with \[?Claude Code\]?[^"'\n]*/gi]
36
37/** The attribution lines in a text, as written, once each. */
38export function attributionLines(text: string): string[] {
39 const out = new Set<string>()
40 for (const re of LINES) for (const m of text.matchAll(re)) out.add(m[0].trim())
41 return [...out]
42}
43
44export type GitAction =
45 | { kind: 'commit'; dir?: string; files: string[]; isAmendNoMessage: boolean }
46 | { kind: 'pr'; dir?: string; files: string[]; repo?: string }
47 | { kind: 'push'; dir?: string }
48
49/** The git actions in a shell command (commits, PR creation or edits, pushes), with a leading cd or git -C. */
50export function actionsIn(command: string): GitAction[] {
51 const out: GitAction[] = []
52 let dir: string | undefined
53 const cd = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/)
54 if (cd && cd[1]) dir = cd[1].replace(/^["']|["']$/g, '')
55 const segments = command.split(/&&|;|\|\||\n(?=\s*(?:git|gh|cd)\b)/)
56 for (const seg of segments) {
57 const c = seg.trim()
58 const gitC = c.match(/^git\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
59 const d = gitC && gitC[1] ? gitC[1].replace(/^["']|["']$/g, '') : dir
60 const files = [...c.matchAll(/(?:\s-F|--file|--body-file)[=\s]+("[^"]+"|'[^']+'|[^\s"']+)/g)].map(m => (m[1] ?? '').replace(/^["']|["']$/g, '')).filter(f => f && f !== '-')
61 if (/^git\b(\s+-C\s+\S+)?\s+commit\b/.test(c))
62 out.push({ kind: 'commit', ...(d ? { dir: d } : {}), files, isAmendNoMessage: /--amend\b/.test(c) && !/(\s-m\b|\s-F\b|--message|--file|--no-edit)/.test(c) })
63 else if (/^gh\s+pr\s+(create|edit)\b/.test(c)) {
64 const repo = c.match(/(?:-R|--repo)[=\s]+(\S+)/)?.[1]
65 out.push({ kind: 'pr', ...(d ? { dir: d } : {}), files, ...(repo ? { repo } : {}) })
66 } else if (/^git\b(\s+-C\s+\S+)?\s+push\b/.test(c)) out.push({ kind: 'push', ...(d ? { dir: d } : {}) })
67 }
68 return out
69}
70
71export function denial(repo: string, lines: string[], where: string): string {
72 return (
73 `trailer-guard: ${repo} takes no AI attribution in commits or PRs, and ${where} carries:\n` +
74 lines.map(l => ` ${l}`).join('\n') +
75 `\nRemove ${lines.length === 1 ? 'that line' : 'those lines'} and run it again` +
76 (where === 'an unpushed commit' ? ' (reword the commit with git commit --amend, or git rebase with --exec for older ones).' : '.')
77 )
78}
79