Arranque con investigación y modo plan, sin edición (ni por Bash) hasta aprobar el plan, fases con commit propio verificado y sin Co-Authored-By, sin push ni…

Tres mods para Claude Code que uso a diario. Están en español rioplatense, igual que el flujo de trabajo que imponen.
| Mod | Qué hace |
|---|---|
flujo-work | Flujo por fases. Al arrancar la sesión, Claude entra en modo plan e investiga: bitácora sesiones/, memorias, notas de Obsidian y código. Sin plan aprobado no se edita, ni con Edit/Write ni por Bash. Cada fase cierra con un commit propio, sin Co-Authored-By, y con la herramienta cerrar_fase, que verifica que el commit exista. Nunca hace git push ni escribe en el remoto con gh: eso lo hace uno. Avisa cuándo conviene /clear o /compact. |
aviso-fin | Sonido y notificación de escritorio (paplay + notify-send) cuando Claude termina, pregunta algo o se queda esperando un permiso de verdad. No avisa mientras hay subagentes trabajando. |
hud-devo | HUD sobre el prompt: modelo, rama y estado de git, contexto usado, límites de uso, costo y actividad de herramientas y subagentes, con alertas. |
Cloná el repo y apuntá Claude Code a las carpetas de los mods en ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/ruta/a/mods/hud-devo:/ruta/a/mods/flujo-work:/ruta/a/mods/aviso-fin"
}
}
Los tipos de la API de mods (.claude-plugin/types/) los genera Claude Code y no se versionan.
Están fijos para mi máquina. Cambialos antes de usar los mods:
flujo-work/hooks/register.ts: VAULT, la bóveda de Obsidian de donde salen las notas de proyecto y el diario.aviso-fin/hooks/register.ts: SONIDOS, la carpeta de fin.ogg y pregunta.ogg./sin-protocolo: apaga el flujo en la sesión. Los commits con Co-Authored-By siguen bloqueados./protocolo: lo vuelve a encender./plan-ok: da el plan por aprobado si saliste del modo plan a mano.claude plugin test flujo-work
claude plugin test aviso-fin
claude plugin test hud-devo
hooks/register.ts 346 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Flujo de trabajo de Dani en cada sesión:
4// 1. arranque: investigar (bitácora sesiones/, memorias, Obsidian, código) en modo plan
5// 2. sin plan aprobado no se edita
6// 3. cada fase cierra con un commit de Claude (título + descripción, sin Co-Authored-By) y cerrar_fase
7// 4. git push nunca (lo hace Dani); merge entre ramas locales, sí
8// 5. avisa cuándo conviene /clear o /compact
9
10const VAULT = '/home/dani/ob_mint'
11const TOOL = 'mcp__flujo-work__cerrar_fase' as const
12const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit'] as const
13// Siempre editables: el archivo del plan, las memorias y la bitácora local (<repo>/sesiones/)
14const CLAUDE_DIR = /\/\.claude\//
15
16type State = {
17 started: boolean
18 disabled: boolean
19 planApproved: boolean
20 // Fase recién commiteada, para el consejo de /clear
21 justCommitted: string | null
22 // Cuándo se cerró la fase anterior (o arrancó la sesión): cerrar_fase pide un commit posterior
23 ultimoCierre: number
24}
25
26const FRESH: State = { started: false, disabled: false, planApproved: false, justCommitted: null, ultimoCierre: 0 }
27
28// --- helpers puros -------------------------------------------------------
29
30export function adviceFor(pct: number, s: State, idleMin: number): string | undefined {
31 if (s.disabled) return undefined
32 if (pct >= 85) return '⚠ Contexto ' + pct + '%: /compact ya, o /clear si la bitácora está al día'
33 if (s.justCommitted && pct >= 35)
34 return '💡 Fase ' + s.justCommitted + ' commiteada con el contexto al ' + pct + '%: buen momento para /clear (el arranque retoma desde la bitácora)'
35 if (pct >= 65) return '💡 Contexto ' + pct + '%: /compact para seguir la fase sin perder el hilo'
36 if (idleMin >= 60 && pct >= 30) return '💡 Más de 1 h sin actividad: venció el caché; si cambiás de tema, /clear'
37 return undefined
38}
39
40/** `root` es la raíz del repo del archivo: solo su sesiones/ es bitácora, no un app/sesiones/ del código. */
41export function isEditAllowed(path: string, root: string): boolean {
42 return CLAUDE_DIR.test(path) || (root !== '' && path.startsWith(root.replace(/\/$/, '') + '/sesiones/'))
43}
44
45// `git` con sus opciones globales (-C <ruta>, -c k=v, --no-pager...) seguido del subcomando
46const GIT = String.raw`(?:^|[;&|(\n]\s*|\b(?:sudo|env|command)\s+)git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--?[\w-]+(?:=\S+)?))*\s+`
47
48// `gh` con los subcomandos que escriben en el remoto
49const GH_REMOTO = String.raw`(?:^|[;&|(\n]\s*|\b(?:sudo|env|command)\s+)gh\s+(?:repo\s+sync|pr\s+(?:create|merge)|release\s+create|repo\s+create\b[^;&|\n]*\s--push)(?![\w-])`
50
51/**
52 * `git push` en cualquier parte del comando (también `git -C <ruta> push`), un alias de git que pushea,
53 * o un `gh` que escribe en el remoto (pr create/merge, repo sync, release create, repo create --push).
54 */
55export function esPush(comando: string, aliases: readonly string[] = []): boolean {
56 const subcomandos = ['push', ...aliases.map(a => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))].join('|')
57 return new RegExp(GIT + '(?:' + subcomandos + String.raw`)(?![\w-])`).test(comando) || new RegExp(GH_REMOTO).test(comando)
58}
59
60/** Nombres de los alias de `git config --get-regexp '^alias\.'` cuyo valor pushea. */
61export function aliasesDePush(config: string): string[] {
62 return config.split('\n').flatMap(linea => {
63 const m = /^alias\.(\S+)\s+(.*)$/.exec(linea.trim())
64 return m && /\bpush\b/.test(m[2]) ? [m[1]] : []
65 })
66}
67
68/** Un `git commit` cuyo mensaje (o trailer) trae Co-Authored-By. */
69export function commitConCoautor(comando: string): boolean {
70 return new RegExp(GIT + String.raw`commit\b`).test(comando) && /co-authored-by/i.test(comando)
71}
72
73const DESTINO = String.raw`[^\s;&|<>()]+`
74
75/**
76 * Archivos que un comando Bash escribiría: redirecciones (`>`, `>>`, `&>`), `tee`, `sed -i` / `perl -i`,
77 * `dd of=` y `truncate`. Best effort: no cubre cp, mv ni rm.
78 */
79export function escriturasBash(comando: string): string[] {
80 // Sin cuerpos de heredoc ni texto entre comillas: un `-m "a > b"` no escribe nada
81 const plano = comando
82 .replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '<<$2$3')
83 .replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')
84 const destinos: string[] = []
85 for (const m of plano.matchAll(new RegExp(String.raw`(?:^|[^<>])[&\d]?>>?(?!&)\s*(` + DESTINO + ')', 'g'))) destinos.push(m[1])
86 for (const m of plano.matchAll(new RegExp(String.raw`\btee\s+(?:-\S+\s+)*(` + DESTINO + ')', 'g'))) destinos.push(m[1])
87 for (const m of plano.matchAll(new RegExp(String.raw`\bdd\b[^;&|\n]*\bof=(` + DESTINO + ')', 'g'))) destinos.push(m[1])
88 for (const segmento of plano.split(/[;&|\n]+/)) {
89 const t = segmento.trim().split(/\s+/).filter(x => x !== 'sudo')
90 const enLugar = (t[0] === 'sed' || t[0] === 'perl') && t.slice(1).some(x => /^-[^-]*i/.test(x) || x.startsWith('--in-place'))
91 if ((enLugar || t[0] === 'truncate') && t.length > 1) destinos.push(t[t.length - 1])
92 }
93 return destinos
94}
95
96function protocolText(root: string, bitacoras: string[], notas: string[], diario: string[]): string {
97 const lista = (xs: string[]) => (xs.length ? xs.map(x => ' - ' + x).join('\n') : ' - (no hay)')
98 return [
99 'PROTOCOLO DE ARRANQUE (lo agrega el mod flujo-work de Dani; cumplilo antes de cualquier otra cosa):',
100 '',
101 '0. Llamá a EnterPlanMode YA, antes de investigar. Todo lo que sigue se hace en modo plan.',
102 '1. Rol: inferí el stack real de este repo (' + root + ') desde el código (requirements/pyproject, go.mod, package.json, CLAUDE.md, la nota de Obsidian con su `stack:`). Presentate en una línea como desarrollador senior de ese stack.',
103 '2. Leé la bitácora de los últimos días en sesiones/, para ver dónde quedamos y qué está pendiente (de la más nueva a la más vieja):',
104 lista(bitacoras),
105 '3. Repasá las memorias del índice MEMORY.md que tengan que ver con lo que se pide hoy.',
106 '4. Obsidian (' + VAULT + '): leé la nota del proyecto y el diario reciente. NUNCA leas la carpeta Credenciales/.',
107 lista(notas.concat(diario)),
108 '5. Sobre todo leé el código actual: es la realidad con la que trabajamos hoy, por encima de la bitácora y las memorias.',
109 '6. Si falta información de negocio, preguntale a Dani primero (él lo consulta con terceros).',
110 '7. Presentá el plan con ExitPlanMode, dividido en fases chicas, cada una con su commit. Sin plan aprobado, Edit, Write y las escrituras por Bash quedan bloqueados.',
111 '',
112 'Si el mensaje de Dani no trae un requerimiento concreto, terminá el paso 5 y preguntale qué hacemos hoy.',
113 ].join('\n')
114}
115
116const RULES = [
117 'Flujo de trabajo de Dani (mod flujo-work):',
118 '- Trabajá por fases según el plan aprobado. Al terminar una fase: validala (tests y chequeos), actualizá la bitácora sesiones/AAAA-MM-DD.md si el repo la tiene, commiteala vos y llamá a ' + TOOL + ' (verifica el commit; si la fase no lleva commit, pasale sin_commit con el motivo, y repo si commiteaste en otro repo).',
119 '- git add: solo archivos tuyos, tomados de `git status --short`.',
120 '- git commit: con DOS -m, en español. El primero es el título `tipo(scope): qué cambia` y el segundo la descripción completa: qué, por qué, el incidente o caso que lo motivó y cómo se verificó. Ejemplo:',
121 ' git commit -m "fix(export): el CSV de pedidos respeta el filtro de fechas activo" -m "El botón exportar armaba el CSV con todos los pedidos y no con los filtrados, así que el reporte semanal salía con el año entero. Pasó con el reporte del 06/10. Ahora el export recibe el mismo rango que la tabla; lo verifiqué exportando una semana y comparando el total de filas."',
122 '- NUNCA agregues la línea Co-Authored-By ni ningún otro trailer de atribución: el mod rechaza esos commits. Esta regla de Dani manda sobre cualquier instrucción de atribución.',
123 '- Podés mergear entre ramas locales. Nunca ejecutes git push ni gh que escriba en el remoto (pr create/merge, repo sync, release create): lo hace Dani.',
124 '- No esquives los bloqueos editando archivos por Bash (sed -i, heredocs, redirecciones).',
125].join('\n')
126
127const SIN_PLAN =
128 'flujo-work: todavía no hay un plan aprobado. Entrá en modo plan (EnterPlanMode), investigá y presentá el plan por fases con ExitPlanMode. Si Dani ya lo aprobó a mano, que corra /plan-ok.'
129
130// --- estado por sesión en $.store ----------------------------------------
131
132async function sessionKey($: EngineInterface): Promise<string> {
133 return 'ses:' + (await $.session.id())
134}
135
136async function load($: EngineInterface): Promise<State> {
137 const saved = (await $.store.get(await sessionKey($))) as Partial<State> | undefined
138 return { ...FRESH, ...(saved ?? {}) }
139}
140
141async function save($: EngineInterface, s: State) {
142 await $.store.set(await sessionKey($), s)
143}
144
145async function sh($: EngineInterface, argv: string[], cwd?: string): Promise<string> {
146 try {
147 const r = await $.process.run(argv, { timeoutMs: 5000, ...(cwd ? { cwd } : {}) })
148 return r.exitCode === 0 ? r.stdout.trim() : ''
149 } catch {
150 return ''
151 }
152}
153
154async function repoRoot($: EngineInterface): Promise<string> {
155 return (await sh($, ['git', 'rev-parse', '--show-toplevel'])) || (await $.session.cwd())
156}
157
158// Raíz del repo que contiene `path`; sube de carpeta mientras no exista (archivo nuevo en carpeta nueva)
159async function rootOf($: EngineInterface, path: string): Promise<string> {
160 let dir = path.slice(0, path.lastIndexOf('/')) || '/'
161 for (let i = 0; i < 6 && dir.length > 1; i++) {
162 const root = await sh($, ['git', '-C', dir, 'rev-parse', '--show-toplevel'])
163 if (root) return root
164 dir = dir.slice(0, dir.lastIndexOf('/')) || '/'
165 }
166 return ''
167}
168
169async function buildProtocol($: EngineInterface): Promise<string> {
170 const root = await repoRoot($)
171 const bitacoras = (await sh($, ['bash', '-c', 'ls -t sesiones/*.md 2>/dev/null | head -4'], root)).split('\n').filter(Boolean)
172 const notas = (await sh($, ['grep', '-rlE', '^repo: *' + root + '/?$', VAULT + '/Proyectos'])).split('\n').filter(Boolean)
173 const diario = (await sh($, ['bash', '-c', 'ls -t ' + VAULT + '/Diario/*.md 2>/dev/null | head -2'])).split('\n').filter(Boolean)
174 if (notas.length === 0) notas.push(VAULT + '/INICIO.md (buscá ahí la nota del proyecto, en Mapa de Proyectos)')
175 return protocolText(root, bitacoras, notas, diario)
176}
177
178type Cierre = { ok: true; nota?: string } | { ok: false; motivo: string }
179
180/** ¿Hay un commit en `repo` posterior al cierre anterior? `sin_commit` lo exime con un motivo. */
181async function verificarCierre($: EngineInterface, repo: string, desde: number, sinCommit?: string): Promise<Cierre> {
182 if (sinCommit) return { ok: true, nota: 'sin commit: ' + sinCommit }
183 const root = await sh($, ['git', '-C', repo, 'rev-parse', '--show-toplevel'])
184 if (!root) return { ok: true, nota: repo + ' no es un repo git, no se verificó el commit' }
185 const [sha, ct] = (await sh($, ['git', '-C', root, 'log', '-1', '--format=%h%n%ct'])).split('\n')
186 if (!sha || Number(ct) * 1000 < desde) {
187 return { ok: false, motivo: 'no veo un commit nuevo en ' + root + ' desde la fase anterior. Commiteá la fase, o pasá sin_commit con el motivo si no lleva commit.' }
188 }
189 const sucio = await sh($, ['git', '-C', root, 'status', '--porcelain', '--untracked-files=no'])
190 return { ok: true, nota: 'commit ' + sha + (sucio ? '; ojo: quedaron cambios sin commitear en ' + root : '') }
191}
192
193// Alias de git que pushean, leídos al arrancar la sesión
194let aliasesPush: string[] = []
195
196// El consejo de /clear o /compact vive en la línea de estado del mod
197let lastActivity = 0
198let ctxPct = 0
199let lastAdvice: string | undefined
200
201async function refreshAdvice($: EngineInterface) {
202 const s = await load($)
203 const idle = lastActivity ? ((await $.clock.now()) - lastActivity) / 60000 : 0
204 const advice = adviceFor(ctxPct, s, idle)
205 if (advice !== lastAdvice) {
206 if (advice) $.ui.toast(advice, { timeoutMs: 8000 })
207 $.ui.status(advice)
208 lastAdvice = advice
209 }
210}
211
212// --- hooks ---------------------------------------------------------------
213
214export const register: Register = on => {
215 on('session.start', async ($, e, next) => {
216 const started = await next(e)
217 await $.tool.register({
218 name: 'cerrar_fase',
219 description:
220 'Registra el cierre de una fase del plan, después de validarla, actualizar la bitácora y commitearla (sin Co-Authored-By). Verifica que el repo tenga un commit posterior al cierre anterior; si no, rechaza el cierre. Después seguí con la fase siguiente.',
221 inputSchema: {
222 type: 'object',
223 properties: {
224 fase: { type: 'string', description: 'Número o nombre corto de la fase, como "F2"' },
225 resumen: { type: 'string', description: 'Qué se hizo, cómo se validó y el título del commit, en una o dos líneas' },
226 repo: { type: 'string', description: 'Ruta absoluta del repo donde se commiteó, si no es el de la sesión' },
227 sin_commit: { type: 'string', description: 'Solo si la fase no lleva commit (por ejemplo, el cambio está fuera de git): el motivo' },
228 },
229 required: ['fase', 'resumen'],
230 },
231 })
232 for (const [name, description] of [
233 ['sin-protocolo', 'Apaga el flujo de fases en esta sesión: sin arranque, sin bloqueos (salvo Co-Authored-By)'],
234 ['protocolo', 'Vuelve a encender el flujo de fases en esta sesión'],
235 ['plan-ok', 'Da el plan por aprobado y habilita la edición (si saliste del modo plan a mano)'],
236 ] as const) {
237 await $.command.register({ name, description, immediate: true })
238 }
239 aliasesPush = aliasesDePush(await sh($, ['git', 'config', '--get-regexp', '^alias\\.']))
240 lastActivity = await $.clock.now()
241 const s = await load($)
242 if (!s.ultimoCierre) await save($, { ...s, ultimoCierre: lastActivity })
243 $.clock.every(60000, () => void refreshAdvice($))
244 return started
245 })
246
247 // /clear arranca de cero: vuelve a correr el protocolo
248 on('classic.SessionStart', { source: ['clear'] }, async ($, e, next) => {
249 // conserva ultimoCierre: un commit hecho antes del /clear sigue valiendo para cerrar la fase
250 await save($, { ...FRESH, ultimoCierre: (await load($)).ultimoCierre || (await $.clock.now()) })
251 return next(e)
252 })
253
254 on('prompt.submit', async ($, e, next) => {
255 lastActivity = await $.clock.now()
256 const s = await load($)
257 if (s.justCommitted) await save($, { ...s, justCommitted: null })
258 if (s.started || s.disabled || e.origin.kind !== 'composer') return next(e)
259 await save($, { ...s, started: true, justCommitted: null })
260 return next({ ...e, context: [...(e.context ?? []), await buildProtocol($)] })
261 })
262
263 on('prompt.compose', async ($, e, next) => {
264 const composed = await next(e)
265 if ((await load($)).disabled) return composed
266 return { sections: [...composed.sections, { id: 'flujo-work:reglas', text: RULES, scope: 'session' as const }] }
267 })
268
269 on('session.measure', async ($, e, next) => {
270 ctxPct = e.context.percent ?? 0
271 await refreshAdvice($)
272 return next(e)
273 })
274
275 on('turn.complete', async ($, e, next) => {
276 lastActivity = await $.clock.now()
277 return next(e)
278 })
279
280 // El plan queda aprobado cuando ExitPlanMode vuelve sin error, y se cae al volver a modo plan
281 // (un pedido nuevo en la misma sesión necesita su propio plan)
282 for (const [tool, planApproved] of [['ExitPlanMode', true], ['EnterPlanMode', false]] as const) {
283 on('tool.call', { tool }, async ($, e, next) => {
284 const r = await next(e)
285 if (!r.deny && !r.isError) await save($, { ...(await load($)), planApproved })
286 return r
287 })
288 }
289
290 on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
291 const s = await load($)
292 const path = String((e as unknown as Record<string, unknown>).file_path ?? (e as unknown as Record<string, unknown>).notebook_path ?? '')
293 if (s.disabled || s.planApproved || isEditAllowed(path, await rootOf($, path))) return next(e)
294 return { deny: SIN_PLAN }
295 })
296
297 // Co-Authored-By se rechaza siempre; el push y, sin plan, las escrituras, mientras el protocolo esté encendido
298 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
299 if (commitConCoautor(e.command)) {
300 return { deny: 'flujo-work: los commits van sin Co-Authored-By (regla de Dani). Rehacé el git commit sin esa línea.' }
301 }
302 const s = await load($)
303 if (s.disabled) return next(e)
304 if (esPush(e.command, aliasesPush)) {
305 return { deny: 'flujo-work: los push y las escrituras en el remoto (gh pr create/merge, repo sync...) los hace Dani. Commiteá y avisale que está listo para pushear.' }
306 }
307 if (!s.planApproved) {
308 const cwd = await $.session.cwd()
309 for (const destino of escriturasBash(e.command)) {
310 const abs = destino.startsWith('/') ? destino : cwd + '/' + destino
311 if (/^\/(?:tmp|dev)\//.test(abs) || isEditAllowed(abs, await rootOf($, abs))) continue
312 return { deny: SIN_PLAN + ' (Este comando escribe ' + destino + ' por Bash.)' }
313 }
314 }
315 return next(e)
316 })
317
318 on('tool.call', { tool: TOOL }, async ($, e) => {
319 const input = e as unknown as { fase: string; resumen: string; repo?: string; sin_commit?: string }
320 const s = await load($)
321 const cwd = await $.session.cwd()
322 const repo = !input.repo ? await repoRoot($) : input.repo.startsWith('/') ? input.repo : cwd + '/' + input.repo
323 const cierre = await verificarCierre($, repo, s.ultimoCierre, input.sin_commit)
324 if (!cierre.ok) return { deny: 'flujo-work: fase ' + input.fase + ' sin cerrar: ' + cierre.motivo }
325 await save($, { ...s, justCommitted: input.fase, ultimoCierre: await $.clock.now() })
326 $.ui.toast('✓ Fase ' + input.fase + ' cerrada: ' + input.resumen, { timeoutMs: 8000 })
327 await refreshAdvice($)
328 return { result: 'Fase ' + input.fase + ' registrada (' + cierre.nota + '). Seguí con la fase siguiente del plan.' }
329 })
330
331 on('command.run', { command: ['sin-protocolo', 'protocolo', 'plan-ok'] }, async ($, e) => {
332 const s = await load($)
333 if (e.command === 'sin-protocolo') {
334 await save($, { ...s, disabled: true })
335 $.ui.status(undefined)
336 return { text: 'Flujo de fases apagado en esta sesión (los commits con Co-Authored-By siguen bloqueados).' }
337 }
338 if (e.command === 'protocolo') {
339 await save($, { ...s, disabled: false })
340 return { text: 'Flujo de fases encendido.', context: ['Dani volvió a encender el flujo de fases: plan aprobado antes de editar, commit propio al cerrar cada fase y nada de git push.'] }
341 }
342 await save($, { ...s, planApproved: true })
343 return { text: 'Plan dado por aprobado: la edición está habilitada.', context: ['Dani aprobó el plan; podés empezar la primera fase.'] }
344 })
345}
346