Tracks audit and code-review findings per branch across rounds and agents, with per-axis score trends, a /findings pane and an /audit command.

Mods para Claude Code: plugins que se enganchan a la sesión con hooks de función para añadir comandos, herramientas y paneles.
| Mod | Qué hace |
|---|---|
| review-ledger | Lleva un registro de los hallazgos de auditorías y code reviews por rama, para que cada nueva auditoría revise lo pendiente en lugar de empezar de cero. |
Dentro de una sesión de claude:
/plugin marketplace add Omargn/claude-mods
/plugin install review-ledger@claude-mods
Los mods son una función en acceso anticipado de Claude Code. Usa una versión reciente (probado en 2.1.288).
Si le pides a Claude que audite una rama varias veces, cada auditoría empieza de cero: vuelve a encontrar los mismos problemas, no sabe cuáles ya corregiste y no hay forma de ver si la calidad mejora entre rondas. Si además combinas revisiones de otros agentes o personas, los hallazgos quedan repartidos en distintas conversaciones.
review-ledger guarda un ledger (libro de registro) por repositorio y rama con:
F1, F2…), eje, severidad, archivo y línea, quién lo reportó y en qué ronda.En la siguiente auditoría Claude lee el ledger primero, verifica cada hallazgo abierto contra el código actual, cierra los que ya están resueltos y solo agrega los problemas nuevos.
1. Audita la rama
/audit
Claude revisa los cambios de la rama en cinco ejes (seguridad, mantenibilidad, SOLID, escalabilidad y otros), registra los hallazgos y da una calificación por eje. Puedes añadir un enfoque extra:
/audit manejo de errores en la capa de pagos
2. Corrige y vuelve a auditar
Corrige lo que quieras y corre /audit otra vez. Claude cierra los hallazgos resueltos, deja abiertos los que siguen, marca como reabiertos (↺) los que regresaron y registra una nueva ronda con su calificación.
3. Consulta el estado
/findings
Abre el panel del ledger para la rama actual.
4. Agrega revisiones de otros (opcional)
Pega en la conversación una revisión escrita por otro agente (Codex, por ejemplo) o por una persona. Claude registra sus hallazgos en el ledger con ese revisor como fuente, sin duplicar los que ya existen.
feature/pagos · 3 abiertos · 1 seguridad · 3 rondas
Seguridad 6 → 8 → 9.5
Mantenibilidad 7 → 7.5 → 8
General 6.5 → 7.8 → 8.6
● F4 [seg high] Token de sesión se registra en los logs
src/auth/session.ts:42 · claude · r2
↺ F2 [mant medium] Lógica de reintentos duplicada
src/payments/client.ts · codex · r1→r3
● F5 [esc low] Consulta sin paginación
src/orders/list.ts:88 · claude · r3
▸ 2 cerrados
● abierto · ↺ reabierto (en amarillo) · ✓ cerrado. Los hallazgos de seguridad abiertos aparecen en rojo.r1→r3 significa que cambió de estado en la ronda 3).c para mostrarlos u ocultarlos.El panel se abre solo al iniciar una sesión si la rama tiene hallazgos abiertos, y se actualiza cada vez que se registra una ronda.
El mod le da a Claude dos herramientas, que usa por su cuenta durante /audit o cuando pegas una revisión:
mcp__review-ledger__read: lee el ledger de la rama actual.mcp__review-ledger__record: registra una ronda (hallazgos nuevos, cambios de estado y calificaciones).También puedes pedírselo directamente, por ejemplo: "cierra F3, ya lo corregí" o "¿qué hallazgos de seguridad siguen abiertos?".
El ledger vive en el almacenamiento local de plugins de Claude Code, en tu máquina, con una entrada por cada combinación de repositorio y rama. No se escribe en el repo ni se comparte con otras personas. Fuera de un repositorio git el mod no hace nada.
hooks/register.tsx 354 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Axis, Finding, FindingStatus, Ledger, Round, ScoreAxis, Severity } from '../types'
5
6const PANE = 'review-ledger'
7const TITLE = 'Review ledger'
8const ledgerAtom = atom({ plugin: 'review-ledger', key: 'ledger' } as const, null)
9const showClosedAtom = atom({ plugin: 'review-ledger', key: 'showClosed' } as const, false)
10
11const AXES: readonly Axis[] = ['security', 'maintainability', 'solid', 'scalability', 'other']
12const SCORE_AXES: readonly ScoreAxis[] = ['security', 'maintainability', 'solid', 'scalability', 'overall']
13const SEVERITIES: readonly Severity[] = ['high', 'medium', 'low']
14
15const AXIS_LABEL: Record<ScoreAxis | 'other', string> = {
16 security: 'Seguridad',
17 maintainability: 'Mantenibilidad',
18 solid: 'SOLID',
19 scalability: 'Escalabilidad',
20 overall: 'General',
21 other: 'Otro',
22}
23const AXIS_TAG: Record<Axis, string> = {
24 security: 'seg',
25 maintainability: 'mant',
26 solid: 'solid',
27 scalability: 'esc',
28 other: 'otro',
29}
30const STATUS_GLYPH: Record<FindingStatus, string> = { open: '●', reopened: '↺', closed: '✓' }
31
32const AUDIT_PROMPT =
33 'Realiza la auditoría de los cambios de esta rama: revisa escalabilidad, mantenibilidad y principios SOLID, ' +
34 'buenas prácticas y seguridad. Al final dame una calificación final de acuerdo a los puntos mencionados.'
35
36const LEDGER_STEPS =
37 'Antes de auditar, lee el ledger con mcp__review-ledger__read. Verifica cada hallazgo abierto contra el código ' +
38 'actual y ciérralo o déjalo abierto; registra los hallazgos nuevos y la calificación (0–10 por eje) con ' +
39 'mcp__review-ledger__record, source "claude".'
40
41const READ_DESCRIPTION =
42 "Read the review ledger for the current repo and branch: every audit/code-review finding with its id, axis, " +
43 'status (open, closed, reopened), who raised it, and the per-axis score of each past round. Call before an audit ' +
44 'or re-audit so you check the open findings instead of starting over.'
45
46const RECORD_DESCRIPTION =
47 'Record one review round in the ledger for the current repo and branch. Call it (1) after you finish any audit or ' +
48 'code review of changes, and (2) whenever the user pastes a review or audit written by another agent or person: ' +
49 "record that review's findings with source set to who wrote it (e.g. \"codex\", \"external\", \"user\"). " +
50 'Put new problems in `findings`, one entry each, never one already in the ledger. Close or reopen existing ' +
51 'findings with `updates` using ids from mcp__review-ledger__read. Give `scores` (0–10) only for axes the review ' +
52 'actually scored.'
53
54const RECORD_SCHEMA = {
55 type: 'object',
56 properties: {
57 source: { type: 'string', description: 'Who wrote this review: "claude", or the other agent/person.' },
58 findings: {
59 type: 'array',
60 description: 'New findings raised in this round.',
61 items: {
62 type: 'object',
63 properties: {
64 title: { type: 'string', description: 'One short line naming the problem.' },
65 axis: { type: 'string', enum: AXES },
66 severity: { type: 'string', enum: SEVERITIES },
67 file: { type: 'string', description: 'Repo-relative path.' },
68 line: { type: 'integer' },
69 },
70 required: ['title', 'axis'],
71 },
72 },
73 updates: {
74 type: 'array',
75 description: 'Status changes for findings already in the ledger.',
76 items: {
77 type: 'object',
78 properties: {
79 id: { type: 'string', description: 'Ledger id, like "F3".' },
80 status: { type: 'string', enum: ['open', 'closed'] },
81 },
82 required: ['id', 'status'],
83 },
84 },
85 scores: {
86 type: 'object',
87 description: 'Scores out of 10 this round gave, per axis.',
88 properties: Object.fromEntries(SCORE_AXES.map(axis => [axis, { type: 'number', minimum: 0, maximum: 10 }])),
89 },
90 },
91 required: ['source'],
92}
93
94type FindingInput = { title?: unknown; axis?: unknown; severity?: unknown; file?: unknown; line?: unknown }
95type UpdateInput = { id?: unknown; status?: unknown }
96
97async function locate($: EngineInterface): Promise<{ key: string; branch: string } | null> {
98 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
99 if (top.exitCode !== 0) return null
100 const head = await $.process.run(['git', 'branch', '--show-current'])
101 const branch = head.stdout.trim() || 'HEAD (detached)'
102
103 return { key: `ledger:${top.stdout.trim()}#${branch}`, branch }
104}
105
106async function load($: EngineInterface): Promise<Ledger | null> {
107 const where = await locate($)
108 if (where === null) return null
109 const stored = (await $.store.get(where.key)) as Ledger | undefined
110 const ledger = stored ?? { key: where.key, branch: where.branch, nextId: 1, findings: [], rounds: [] }
111 await update($, ledgerAtom, () => ledger)
112
113 return ledger
114}
115
116async function save($: EngineInterface, ledger: Ledger): Promise<void> {
117 await $.store.set(ledger.key, ledger)
118 await update($, ledgerAtom, () => ledger)
119}
120
121function oneOf<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
122 return allowed.includes(value as T) ? (value as T) : undefined
123}
124
125function where(finding: Finding): string {
126 if (finding.file === undefined) return ''
127
128 return finding.line === undefined ? finding.file : `${finding.file}:${finding.line}`
129}
130
131function describe(ledger: Ledger): string {
132 if (ledger.findings.length === 0 && ledger.rounds.length === 0) {
133 return `Ledger for ${ledger.branch} is empty.`
134 }
135 const lines = [`Ledger for ${ledger.branch} (${ledger.rounds.length} rounds):`]
136 for (const finding of ledger.findings) {
137 const severity = finding.severity === undefined ? '' : ` ${finding.severity}`
138 const at = where(finding)
139 lines.push(
140 `${finding.id} [${finding.status}] ${finding.axis}${severity}: ${finding.title}` +
141 `${at ? ` (${at})` : ''} — by ${finding.source}, round ${finding.round}`,
142 )
143 }
144 const scored = ledger.rounds.filter(round => Object.keys(round.scores).length > 0)
145 for (const round of scored.slice(-3)) {
146 const parts = SCORE_AXES.flatMap(axis => (round.scores[axis] === undefined ? [] : [`${axis} ${round.scores[axis]}`]))
147 lines.push(`Round ${round.n} (${round.source}) scores: ${parts.join(', ')}`)
148 }
149
150 return lines.join('\n')
151}
152
153function trend(rounds: readonly Round[], axis: ScoreAxis): string {
154 const values = rounds.flatMap(round => (round.scores[axis] === undefined ? [] : [round.scores[axis]]))
155
156 return values.slice(-4).map(value => (Number.isInteger(value) ? `${value}` : value.toFixed(1))).join(' → ')
157}
158
159const isOpen = (finding: Finding) => finding.status !== 'closed'
160
161export const register: Register = on => {
162 on('session.start', async ($, e, next) => {
163 await $.command.register({ name: 'findings', description: 'Show the review findings and score trend for this branch' })
164 await $.command.register({
165 name: 'audit',
166 description: 'Audit this branch (security, maintainability, SOLID, scalability) and update the ledger',
167 argumentHint: '[extra focus]',
168 })
169 await $.tool.register({ name: 'read', description: READ_DESCRIPTION, inputSchema: { type: 'object' } })
170 await $.tool.register({ name: 'record', description: RECORD_DESCRIPTION, inputSchema: RECORD_SCHEMA })
171
172 const ledger = await load($)
173 if (ledger !== null && ledger.findings.some(isOpen)) {
174 void $.ui.open({ id: PANE, title: TITLE })
175 }
176
177 return next(e)
178 })
179
180 on('command.run', { command: 'findings' }, async $ => {
181 const ledger = await load($)
182 if (ledger === null) return { text: 'review-ledger: not inside a git repository.' }
183 await $.ui.open({ id: PANE, title: TITLE })
184
185 return { text: `Findings opened for ${ledger.branch}.` }
186 })
187
188 on('command.run', { command: 'audit' }, async ($, e) => {
189 const ledger = await load($)
190 if (ledger === null) return { text: 'review-ledger: not inside a git repository.' }
191 await $.ui.open({ id: PANE, title: TITLE })
192 const focus = e.args.trim() ? `\n\nEnfoque adicional: ${e.args.trim()}` : ''
193 void $.prompt.submit({ text: `${AUDIT_PROMPT}${focus}\n\n${LEDGER_STEPS}`, asUser: true })
194
195 return { text: `Audit queued for ${ledger.branch}.` }
196 })
197
198 on('tool.call', { tool: 'mcp__review-ledger__read' }, async $ => {
199 const ledger = await load($)
200
201 return { result: ledger === null ? 'Not inside a git repository.' : describe(ledger) }
202 })
203
204 on('tool.call', { tool: 'mcp__review-ledger__record' }, async ($, e) => {
205 const ledger = await load($)
206 if (ledger === null) return { deny: 'Not inside a git repository.' }
207
208 const source = typeof e.source === 'string' && e.source.trim() ? e.source.trim() : 'claude'
209 const n = ledger.rounds.length + 1
210 const findings = [...ledger.findings]
211 const problems: string[] = []
212
213 for (const raw of (Array.isArray(e.findings) ? e.findings : []) as FindingInput[]) {
214 const title = typeof raw.title === 'string' ? raw.title.trim() : ''
215 if (!title) {
216 problems.push('skipped a finding with no title')
217 continue
218 }
219 findings.push({
220 id: `F${ledger.nextId + findings.length - ledger.findings.length}`,
221 title,
222 axis: oneOf(raw.axis, AXES) ?? 'other',
223 severity: oneOf(raw.severity, SEVERITIES),
224 file: typeof raw.file === 'string' && raw.file ? raw.file : undefined,
225 line: typeof raw.line === 'number' ? Math.trunc(raw.line) : undefined,
226 source,
227 status: 'open',
228 round: n,
229 updatedRound: n,
230 })
231 }
232
233 for (const raw of (Array.isArray(e.updates) ? e.updates : []) as UpdateInput[]) {
234 const index = findings.findIndex(finding => finding.id === raw.id)
235 const current = findings[index]
236 const wanted = oneOf(raw.status, ['open', 'closed'] as const)
237 if (current === undefined || wanted === undefined) {
238 problems.push(`skipped update ${String(raw.id)}`)
239 continue
240 }
241 const status: FindingStatus = wanted === 'closed' ? 'closed' : current.status === 'closed' ? 'reopened' : current.status
242 findings[index] = { ...current, status, updatedRound: n }
243 }
244
245 const scores: Round['scores'] = {}
246 const rawScores = typeof e.scores === 'object' && e.scores !== null ? (e.scores as Record<string, unknown>) : {}
247 for (const axis of SCORE_AXES) {
248 const value = rawScores[axis]
249 if (typeof value === 'number' && value >= 0 && value <= 10) scores[axis] = value
250 }
251
252 const added = findings.length - ledger.findings.length
253 const next: Ledger = {
254 ...ledger,
255 nextId: ledger.nextId + added,
256 findings,
257 rounds: [...ledger.rounds, { n, at: await $.clock.now(), source, scores }],
258 }
259 await save($, next)
260 void $.ui.open({ id: PANE, title: TITLE })
261
262 const open = findings.filter(isOpen).length
263 $.ui.toast(`review-ledger: round ${n} · ${added} new · ${open} open`)
264 const note = problems.length > 0 ? `\nNote: ${problems.join('; ')}.` : ''
265
266 return { result: `Recorded round ${n}.${note}\n\n${describe(next)}` }
267 })
268
269 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
270 const { Box, Button, Text } = $.ui.resolve(e)
271 const ledger = await read($, ledgerAtom)
272 if (ledger === null || ledger === undefined) {
273 return <Text dimColor>Not inside a git repository.</Text>
274 }
275
276 const showClosed = (await read($, showClosedAtom)) ?? false
277 const open = ledger.findings.filter(isOpen)
278 const closed = ledger.findings.filter(finding => !isOpen(finding))
279 const openSecurity = open.filter(finding => finding.axis === 'security').length
280 const scored = ledger.rounds.filter(round => Object.keys(round.scores).length > 0)
281 const isClear = open.length === 0 && ledger.rounds.length > 0
282
283 const row = (finding: Finding) => {
284 const isDone = finding.status === 'closed'
285 const color = isDone ? undefined : finding.status === 'reopened' ? 'yellow' : finding.axis === 'security' ? 'red' : undefined
286 const at = where(finding)
287
288 return (
289 <Box flexDirection="column" marginBottom={0}>
290 <Text dimColor={isDone} wrap="truncate-end">
291 <Text color={color}>{STATUS_GLYPH[finding.status]} </Text>
292 <Text bold={!isDone}>{finding.id} </Text>
293 <Text dimColor>[{AXIS_TAG[finding.axis]}{finding.severity ? ` ${finding.severity}` : ''}] </Text>
294 {finding.title}
295 </Text>
296 <Text dimColor wrap="truncate-start">
297 {' '}
298 {at ? `${at} · ` : ''}
299 {finding.source} · r{finding.round}
300 {finding.updatedRound !== finding.round ? `→r${finding.updatedRound}` : ''}
301 </Text>
302 </Box>
303 )
304 }
305
306 return (
307 <Box flexDirection="column">
308 <Text wrap="truncate-end">
309 <Text bold>{ledger.branch}</Text>
310 <Text dimColor> · </Text>
311 <Text color={isClear ? 'green' : undefined}>{open.length} abiertos</Text>
312 {openSecurity > 0 && <Text color="red"> · {openSecurity} seguridad</Text>}
313 <Text dimColor> · {ledger.rounds.length} rondas</Text>
314 </Text>
315
316 {scored.length > 0 && (
317 <Box flexDirection="column" marginTop={1}>
318 {SCORE_AXES.map(axis => {
319 const line = trend(scored, axis)
320
321 return line ? (
322 <Text wrap="truncate-end">
323 <Text dimColor>{AXIS_LABEL[axis].padEnd(15)}</Text>
324 {line}
325 </Text>
326 ) : null
327 })}
328 </Box>
329 )}
330
331 <Box flexDirection="column" marginTop={1}>
332 {ledger.findings.length === 0 && (
333 <Text dimColor>Sin hallazgos todavía. Corre /audit o pega una revisión de otro agente.</Text>
334 )}
335 {open.map(row)}
336 </Box>
337
338 {closed.length > 0 && (
339 <Box flexDirection="column" marginTop={1}>
340 <Button
341 key="toggle-closed"
342 plain
343 hotkey="c"
344 label={`${showClosed ? '▾' : '▸'} ${closed.length} cerrados`}
345 onPress={() => update($, showClosedAtom, value => !value)}
346 />
347 {showClosed && closed.map(row)}
348 </Box>
349 )}
350 </Box>
351 )
352 })
353}
354types/index.d.ts 39 lines1export type Axis = 'security' | 'maintainability' | 'solid' | 'scalability' | 'other'
2export type ScoreAxis = Exclude<Axis, 'other'> | 'overall'
3export type Severity = 'high' | 'medium' | 'low'
4export type FindingStatus = 'open' | 'closed' | 'reopened'
5
6export type Finding = {
7 id: string
8 title: string
9 axis: Axis
10 severity?: Severity
11 file?: string
12 line?: number
13 source: string
14 status: FindingStatus
15 round: number
16 updatedRound: number
17}
18
19export type Round = {
20 n: number
21 at: number
22 source: string
23 scores: Partial<Record<ScoreAxis, number>>
24}
25
26export type Ledger = {
27 key: string
28 branch: string
29 nextId: number
30 findings: Finding[]
31 rounds: Round[]
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'review-ledger': { ledger: Ledger | null; showClosed: boolean }
37 }
38}
39