Blocks git commits that mix top-level directories, break Conventional Commits, or lack Co-Authored-By, explaining the fix in plain PT-BR

Function-hook mods for Claude Code: shell safety, secret guarding, session telemetry, context panels, and workflow tools. Each mod is an isolated plugin with its own tests, and you enable only the ones you want.
| Mod | Description |
|---|---|
guarda | Intercepts risky Bash commands (rm -rf, git reset --hard, force push, new dependencies) and asks for confirmation explaining the impact. |
lixeira | Sends rm / Remove-Item targets to the Windows Recycle Bin instead of deleting them permanently. |
cofre | Keeps API keys out of the conversation: /chave stores them in .env, secret file reads display only variable names, outputs are masked, and pasted keys are quarantined. |
atomic-commit-guard | Blocks commits that mix top-level directories, violate Conventional Commits, or lack a Co-Authored-By trailer. |
universal-audit-log | A hook on * that logs every event (tool calls, prompts, turns, fs/http/process calls from other plugins) with secret redaction. |
| Mod | Description |
|---|---|
context-monitor | Context weather and token usage above the prompt, replay and activity panels, plain-language explanation before each edit, and ai-memory notes. |
cache-frio | Confirms before submitting a prompt to a large conversation whose prompt cache has gone cold. |
session-telemetry | Timestamp on each response, TTFT and tok/s per step, and visual notice whenever the responding model switches. |
session-time-machine | /timemachine: renders the session as a timeline of prompts, tool calls, and turn completions. |
turn-inspector | Side pane displaying the session turn-by-turn: every model request and what it carried. |
clean-view | Collapses each successful tool call into a single concise line (Edit file +12 −3); errors and in-flight calls retain the full block. |
webfetch-cache | Serves repeated WebFetch requests (identical URL and prompt) directly from the session cache. |
reflexo | Detects corrections and constraints in prompts, offering to save them as rules in CLAUDE.md. |
| Mod | Description |
|---|---|
agent-flow | Live diagram of agent flow: main loop context, subagent tree, and context passed/returned by each agent. |
agent-radar | One live line per running subagent above the prompt; /radar opens a pane with messages from each. |
delegation-dock | Status line indicator showing in-flight delegations (agy/codex/ollama) and alerting when one returns empty. |
phase-tracker | Tracks and displays the current development phase (Spec → Red Test → Surgical Code → Verification). |
next-prompts | Band with 3 next-step suggestions generated by a free Ollama cloud model; click one to draft. |
git-sidebar | Lazygit-style side pane: worktrees, local branches, branch switching, and worktree checkout. |
wait-quiz | Quizzes a flashcard in a band above the prompt while Claude is working. |
meus-mods | Central hub: quick-access bar above the prompt and /mods command displaying the status of each mod. |
Requires Claude Code with function-hook mods support.
git clone https://github.com/Covre81/claude-code-mods.git ~/.claude/mods
Enable the mods by listing their absolute paths in CLAUDE_CODE_PLUGIN_DIRS inside ~/.claude/settings.json. On Windows, paths are separated by ;:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:/Users/me/.claude/mods/guarda;C:/Users/me/.claude/mods/cofre;C:/Users/me/.claude/mods/session-telemetry"
}
}
Order matters: the last plugin in the list sits closest to the engine. For instance, lixeira should come after guarda so guarda inspects the original rm -rf first.
Several mods include a command to toggle them on or off without restarting (/webfetch-cache on|off, /radar-toggle, /telemetria…); /mods displays the status of all mods.
Each mod follows the same structure:
<mod>/
.claude-plugin/plugin.json manifest
.claude-plugin/types/ engine types (git-ignored, see below)
hooks/hooks.json hooks registered by the mod
hooks/register.ts(x) code
hooks/*.test.ts(x) tests
Engine types are not committed to the repository. To typecheck and test a mod, copy .claude-plugin/types from an existing mod or generate them with your Claude Code instance.
Tests run directly within Claude Code, not via vitest/jest:
claude plugin test guarda # single mod
for d in */hooks/hooks.json; do claude plugin test "${d%%/*}"; done # all mods
Commits follow Conventional Commits, scoped to one mod per commit (enforced by atomic-commit-guard).
Six mods (agent-flow, git-sidebar, session-time-machine, turn-inspector, universal-audit-log, webfetch-cache) originate from davila7/claude-code-templates, and agent-radar originates from hamzafer/claude-code-mods, both under the MIT License and adapted here. Copyright notices are preserved in THIRD_PARTY_NOTICES.md.
hooks/register.tsx 202 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const isEnabledRef = atom({ plugin: 'atomic-commit-guard', key: 'isEnabled' } as const, true)
5
6// ponytail: copy of guarda/hooks/register.tsx; plugins load in isolation
7type Dir = string | null
8
9function tokenize(text: string): string[] {
10 const words: string[] = []
11 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
12 for (let m = re.exec(text); m; m = re.exec(text)) words.push(m[1] ?? m[2] ?? m[3] ?? '')
13 return words
14}
15
16const isAbs = (p: string) => /^([A-Za-z]:[\\/]|[\\/]|~)/.test(p)
17/** Git Bash spells `C:\Dev` as `/c/Dev`; the engine's fs wants `C:/Dev`. */
18const toWin = (p: string) => p.replace(/^\/([a-zA-Z])(?=\/|$)/, (_, d: string) => `${d.toUpperCase()}:`)
19const joinDir = (dir: Dir, arg: string | undefined): string => (!arg || arg === '~' ? '~' : isAbs(arg) || !dir ? arg : `${dir}/${arg}`)
20
21const PREFIXES = new Set(['sudo', 'command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!'])
22
23/** Each simple command of a shell line with the folder it runs in (after any cd). */
24function* commands(command: string): Generator<{ cmd: string; args: string[]; dir: Dir }> {
25 let dir: Dir = null
26 for (const seg of command.split(/&&|\|\||;|\||\n/)) {
27 const words = tokenize(seg.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, ''))
28 while (words.length && (PREFIXES.has(words[0]!) || /^[A-Za-z_]\w*=/.test(words[0]!))) words.shift()
29 const [first, ...args] = words
30 if (!first) continue
31 const cmd = first.replace(/^\\/, '').replace(/\.exe$/i, '')
32 if (cmd === 'cd') {
33 dir = joinDir(dir, args[0])
34 continue
35 }
36 yield { cmd, args, dir }
37 }
38}
39
40function gitParts(args: string[], dir: Dir) {
41 let i = 0
42 let gitDir = dir
43 while (i < args.length && args[i]!.startsWith('-')) {
44 if (args[i] === '-C') gitDir = joinDir(gitDir, args[i + 1])
45 i += args[i] === '-C' || args[i] === '-c' ? 2 : 1
46 }
47 return { sub: args[i], rest: args.slice(i + 1), gitDir }
48}
49
50async function resolveDir($: EngineInterface, base: string, dir: Dir): Promise<string | null> {
51 if (dir === null) return base
52 if (dir.startsWith('~')) return null
53 const path = isAbs(dir) ? toWin(dir) : `${base}/${dir}`
54 const st = await $.fs.stat(path).catch(() => undefined)
55 return st?.kind === 'dir' ? path : null
56}
57
58async function git($: EngineInterface, cwd: string, args: string[]) {
59 return $.process.run(['git', ...args], { cwd, timeoutMs: 15000 })
60}
61
62export type CommitTarget = { args: string[]; dir: Dir }
63
64export function commitTargets(command: string): CommitTarget[] {
65 const found: CommitTarget[] = []
66 for (const { cmd, args, dir } of commands(command)) {
67 if (cmd !== 'git') continue
68 const { sub, rest, gitDir } = gitParts(args, dir)
69 if (sub === 'commit') found.push({ args: rest, dir: gitDir })
70 }
71 return found
72}
73
74export function commitMessage(command: string): string | null {
75 const heredoc = /<<-?\s*['"]?(\w+)['"]?\n([\s\S]*?)\n\s*\1\s*(\n|$)/.exec(command)
76 if (heredoc?.[2] !== undefined) return heredoc[2]
77 const words = tokenize(command)
78 const start = words.indexOf('commit')
79 const parts: string[] = []
80 for (let i = start >= 0 ? start + 1 : 0; i < words.length; i++) {
81 const w = words[i]!
82 if (w === '-m' || w === '--message') {
83 const next = words[++i]
84 if (next !== undefined) parts.push(next)
85 } else if (w.startsWith('--message=')) {
86 parts.push(w.slice('--message='.length))
87 } else if (/^-[^-FctuC]*m/.test(w)) {
88 const rest = w.slice(w.indexOf('m') + 1)
89 if (rest) parts.push(rest)
90 else {
91 const next = words[++i]
92 if (next !== undefined) parts.push(next)
93 }
94 }
95 }
96 return parts.length ? parts.join('\n\n') : null
97}
98
99const CONVENTIONAL_RE = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([\w.\-\/]+\))?!?: \S/
100
101export function messageProblems(msg: string): string[] {
102 const problems: string[] = []
103 const lines = msg.trim().split(/\r?\n/)
104 const first = lines[0] ?? ''
105 if (!CONVENTIONAL_RE.test(first)) {
106 problems.push('a 1ª linha da mensagem deve seguir Conventional Commits (ex.: feat(escopo): descrição)')
107 }
108 if (!lines.some(l => /^co-authored-by:/i.test(l.trim()))) {
109 problems.push('a mensagem deve conter uma linha iniciada por "Co-Authored-By:"')
110 }
111 return problems
112}
113
114// ponytail: domain = top-level dir; monorepos with src/+tests/ split will be flagged, refine per repo if it gets noisy
115export function scopeDirs(files: string[]): string[] {
116 const dirs = new Set<string>()
117 for (const f of files) {
118 const clean = f.trim().replace(/\\/g, '/').replace(/^\.\//, '')
119 const slash = clean.indexOf('/')
120 if (slash > 0) dirs.add(clean.slice(0, slash))
121 }
122 return [...dirs].sort()
123}
124
125async function stagedDirs($: EngineInterface, target: CommitTarget, cwd: string): Promise<string[]> {
126 // a root commit (an import) spans every directory by nature
127 const head = await git($, cwd, ['rev-parse', '--verify', '-q', 'HEAD'])
128 if (head.exitCode !== 0) return []
129 const staged = await git($, cwd, ['diff', '--cached', '--name-only'])
130 if (staged.exitCode !== 0) return []
131 const files = staged.stdout.split('\n')
132 const hasAll = target.args.includes('--all') || target.args.some(a => /^-[^-mFctuC]*a/.test(a))
133 if (hasAll) {
134 const unstaged = await git($, cwd, ['diff', '--name-only'])
135 if (unstaged.exitCode !== 0) return []
136 files.push(...unstaged.stdout.split('\n'))
137 }
138 return scopeDirs(files)
139}
140
141export const register: Register = on => {
142 on('session.start', async ($, e, next) => {
143 // Published so the meus-mods panel reads the state, never a copied default (the toggle is per session).
144 await update($, isEnabledRef, () => isEnabledRef.initial)
145 await $.command.register({
146 name: 'commit-guard',
147 description: 'Liga/desliga a validação de escopo único, Conventional Commits e Co-Authored-By.',
148 argumentHint: '<on|off>',
149 })
150 return next(e)
151 })
152
153 on('command.run', { command: 'commit-guard' }, async ($, e) => {
154 const arg = e.args.trim().toLowerCase()
155 if (arg !== 'on' && arg !== 'off') return { text: 'Uso: /commit-guard on|off' }
156 await update($, isEnabledRef, () => arg === 'on')
157 return { text: `atomic-commit-guard ${arg === 'on' ? 'ativado' : 'desativado'}.` }
158 })
159
160 on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
161 if (!(await read($, isEnabledRef).catch(() => true))) return next(e)
162 const command = String(e.command ?? '')
163 let targets: CommitTarget[] = []
164 let msg: string | null = null
165 try {
166 targets = commitTargets(command)
167 if (!targets.length) return next(e)
168 msg = commitMessage(command)
169 } catch {
170 return next(e)
171 }
172
173 const problems: string[] = []
174 const base = toWin(await $.session.cwd().catch(() => ''))
175 if (base) {
176 for (const target of targets) {
177 const cwd = await resolveDir($, base, target.dir).catch(() => null)
178 const dirs = cwd === null ? [] : await stagedDirs($, target, cwd).catch(() => [])
179 if (dirs.length > 1) {
180 problems.push(
181 `o escopo do commit cruza mais de um diretório de primeiro nível (${dirs.join(', ')}) — faça um commit separado por diretório`,
182 )
183 }
184 }
185 }
186
187 if (msg !== null) {
188 try {
189 problems.push(...messageProblems(msg))
190 } catch {
191 // never block shell calls on a classifier bug
192 }
193 }
194
195 if (problems.length) {
196 return { deny: `Commit bloqueado: ${problems.join('; ')}. Corrija e rode de novo.` }
197 }
198 return next(e)
199 })
200}
201
202types/index.d.ts 7 lines1declare module 'claude-code' {
2 interface PluginState {
3 'atomic-commit-guard': { isEnabled: boolean }
4 }
5}
6
7