SLOPSHOPPER

flujo-work

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…

guardcommandtoaststatusprompttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flujo-work
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Edit(/work/app/src/auth.ts) ⎿ Denied by flujo-work: flujo-work: todavía no hay un plan aprobado. Entrá en modo plan (EnterPlanMode), inv ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /sin-protocolo ⎿ flujo-work: Flujo de fases apagado en esta sesión (los commits con Co-Authored-By siguen bloqueados). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mods de Claude Code

Tres mods para Claude Code que uso a diario. Están en español rioplatense, igual que el flujo de trabajo que imponen.

ModQué hace
flujo-workFlujo 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-finSonido 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-devoHUD sobre el prompt: modelo, rama y estado de git, contexto usado, límites de uso, costo y actividad de herramientas y subagentes, con alertas.

Instalación

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.

Paths a ajustar

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.

Comandos de flujo-work

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

Tests

claude plugin test flujo-work
claude plugin test aviso-fin
claude plugin test hud-devo

Licencia

MIT

Source 1 files
hooks/register.ts 346 lines
1import 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