Keeps a reproducibility recipe for data work: the analysis commands that succeeded and the files you changed. /repro writes it to .moddeck/repro/ with git…

Install (Claude Code 2.1.295 or later): /plugin install repro-log --marketplace samuelbfernandes/moddeck
Keeps a reproducibility recipe for data work. It watches Bash commands that succeeded (exit 0) and look like analysis runs (python, Rscript, make, quarto, ...), plus the names of files Claude edits or writes, and keeps them in order (last 200 per project, kept across sessions). /repro turns that into a Markdown file you can keep with your results. It does not call a model and it never changes or delays a tool call.
/repro writes .moddeck/repro/<YYYY-MM-DD-HHMM>.md in the project and replies with the path and three lines. The file has: date, git commit and a dirty flag, versions (python3 --version, R --version, node --version, only for programs that appeared), package and lock files found (name and sha256), random seeds seen (literal numbers only, e.g. --seed 42, set.seed(7)), the commands as a runnable shell block, and the changed files./repro status says how many steps are recorded. /repro clear forgets them./repro on and /repro off switch recording. The choice is remembered across sessions, and the moddeck side bar can read it. The / is required.A command containing KEY, TOKEN, SECRET, PASSWORD=, Bearer, a key-like string (sk-..., ghp_..., AKIA...) or a user:pass@ URL is dropped, not recorded. --password x style arguments are masked as ***. Files named like .env, *.pem, id_rsa or with KEY/TOKEN/SECRET in the name are not listed. This is pattern matching: it can miss an unusual secret, so read the file before you share it.
enabled (default on).programs (comma list): commands that count as analysis runs. Default python,python3,Rscript,R CMD,jupyter nbconvert,quarto,snakemake,make,dvc,pytest,node,cargo run,julia,uv run. Two-word entries match the first two words.maxSteps (default 200): only the last this many steps are kept.R and shasum output, and the side bar reading isOff. Tested with a mocked engine.edit-replay shows the diffs of the session's edits; repro-log records how to re-run the analysis.
hooks/register.tsx 197 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Step } from '../types'
5import {
6 MANIFESTS, addStep, cleanCommand, isAnalysis, isSecretFile, maxStepsOf, programsOf, relPath,
7 renderRecipe, runtimesFor, seedsOf, stamp,
8} from './lib'
9
10type Dollar = EngineInterface
11
12const COMMAND = 'repro'
13const DIR = '.moddeck/repro'
14
15const steps = atom({ plugin: 'repro-log', key: 'steps' } as const, [])
16/** Mirror of the persisted on/off switch (`$.store` 'off'), read by the moddeck side bar. */
17const isOff = atom({ plugin: 'repro-log', key: 'isOff' } as const, false)
18
19type Cfg = { isEnabled: boolean; max: number; programs: string[][] }
20
21const cwdOf = async ($: Dollar): Promise<string> => {
22 try {
23 return await $.session.cwd()
24 } catch {
25 return ''
26 }
27}
28
29const keyOf = (cwd: string) => `steps:${cwd}`
30
31async function save($: Dollar, list: Step[]): Promise<void> {
32 try {
33 await $.store.set(keyOf(await cwdOf($)), list)
34 } catch {
35 // kept in memory only
36 }
37}
38
39async function recordStep($: Dollar, cfg: Cfg, kind: Step['kind'], text: string): Promise<void> {
40 if ((await $.store.get('off')) === true) return
41 const step: Step = { kind, text, at: await $.clock.now() }
42 const next = addStep(await read($, steps), step, cfg.max)
43 await update($, steps, () => next)
44 await save($, next)
45}
46
47/** Looks at a finished tool call. Never throws, never changes the call. */
48async function observe($: Dollar, cfg: Cfg, tool: string, e: Record<string, unknown>, ran: Record<string, unknown>): Promise<void> {
49 if (!cfg.isEnabled || ran.deny !== undefined || ran.isError === true) return
50 if (tool === 'Bash') {
51 const result = ran.result as { interrupted?: boolean } | undefined
52 if (result?.interrupted === true) return
53 const command = e.command
54 if (typeof command !== 'string' || !isAnalysis(command, cfg.programs)) return
55 const clean = cleanCommand(command)
56 if (clean !== undefined) await recordStep($, cfg, 'cmd', clean)
57 return
58 }
59 const raw = tool === 'NotebookEdit' ? e.notebook_path : e.file_path
60 if (typeof raw !== 'string' || raw === '' || isSecretFile(raw)) return
61 await recordStep($, cfg, 'file', relPath(raw, await cwdOf($)))
62}
63
64const first = (s: string) => s.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
65
66async function run($: Dollar, argv: string[]): Promise<string> {
67 try {
68 const r = await $.process.run(argv, { timeoutMs: 10_000 })
69 return r.exitCode === 0 ? r.stdout : ''
70 } catch {
71 return ''
72 }
73}
74
75async function versionLine($: Dollar, argv: string[]): Promise<string> {
76 try {
77 const r = await $.process.run(argv, { timeoutMs: 10_000 })
78 return r.exitCode === 0 ? first(r.stdout !== '' ? r.stdout : r.stderr) : ''
79 } catch {
80 return ''
81 }
82}
83
84async function writeRecipe($: Dollar, list: Step[]): Promise<string> {
85 const now = await $.clock.now()
86 const commands = list.filter(s => s.kind === 'cmd').map(s => s.text)
87 const files = [...new Set(list.filter(s => s.kind === 'file').map(s => s.text))]
88
89 const commit = first(await run($, ['git', 'rev-parse', 'HEAD']))
90 const dirty = commit === '' ? undefined : (await run($, ['git', 'status', '--porcelain'])).trim() !== ''
91
92 const versions: string[] = []
93 for (const r of runtimesFor(commands)) {
94 const argv = r === 'python3' ? ['python3', '--version'] : r === 'R' ? ['R', '--version'] : ['node', '--version']
95 const v = await versionLine($, argv)
96 if (v !== '') versions.push(v)
97 }
98
99 const manifests: { name: string; sha: string }[] = []
100 for (const name of MANIFESTS) {
101 try {
102 if (!(await $.fs.exists(name))) continue
103 } catch {
104 continue
105 }
106 manifests.push({ name, sha: first(await run($, ['shasum', '-a', '256', name])).split(/\s+/)[0] ?? '' })
107 }
108
109 const text = renderRecipe({ at: now, commit, isDirty: dirty, versions, manifests, seeds: seedsOf(commands), commands, files })
110 const path = `${DIR}/${stamp(now)}.md`
111 await $.fs.write(path, text)
112 return [
113 `Repro · saved ${path}`,
114 `${commands.length} command${commands.length === 1 ? '' : 's'} · ${files.length} changed file${files.length === 1 ? '' : 's'}`,
115 commit === '' ? 'No git commit found' : `Git ${commit.slice(0, 7)} · ${dirty ? 'dirty' : 'clean'}`,
116 ].join('\n')
117}
118
119async function flip($: Dollar, off: boolean): Promise<string> {
120 try {
121 if (off) await $.store.set('off', true)
122 else await $.store.delete('off')
123 await update($, isOff, () => off)
124 } catch {
125 return 'Repro · that did not work, nothing changed'
126 }
127 return off ? 'Repro · off · nothing is recorded · /repro on turns it back on' : 'Repro · on'
128}
129
130async function runRepro($: Dollar, cfg: Cfg, args: string): Promise<string> {
131 const word = args.trim().toLowerCase()
132 if (!cfg.isEnabled) return 'Repro · disabled · the enabled setting is false (change it in /config)'
133 if (word === 'on') return flip($, false)
134 if (word === 'off') return flip($, true)
135 const list = await read($, steps)
136 const off = (await read($, isOff)) === true
137 if (word === 'status') return `Repro · ${list.length} step${list.length === 1 ? '' : 's'} recorded · recording is ${off ? 'off' : 'on'}`
138 if (word === 'clear') {
139 await update($, steps, () => [])
140 await save($, [])
141 return 'Repro · cleared'
142 }
143 if (word !== '') return 'Repro · use /repro, /repro status, /repro clear, /repro on or /repro off'
144 if (list.length === 0) return 'Repro · nothing recorded yet · run an analysis command first'
145 try {
146 return await writeRecipe($, list)
147 } catch {
148 return 'Repro · could not write the file, nothing changed'
149 }
150}
151
152export const register: Register = (on, options) => {
153 const cfg: Cfg = {
154 isEnabled: options.enabled !== false,
155 max: maxStepsOf(options.maxSteps),
156 programs: programsOf(options.programs),
157 }
158
159 on('session.start', async ($, e, next) => {
160 try {
161 await update($, isOff, () => false)
162 const stored = (await $.store.get('off')) === true
163 await update($, isOff, () => stored)
164 const kept = await $.store.get(keyOf(await cwdOf($)))
165 if (Array.isArray(kept)) await update($, steps, () => (kept as Step[]).slice(-cfg.max))
166 } catch {
167 // starts empty and on
168 }
169 try {
170 await $.command.register({
171 name: COMMAND,
172 description: 'Write a reproducibility recipe for this project: /repro, /repro status, /repro clear, /repro on, /repro off',
173 argumentHint: '[status|clear|on|off]',
174 immediate: true,
175 })
176 } catch {
177 // fail-open: without the command the mod is quiet
178 }
179 return next(e)
180 })
181
182 // Observation only: the call always runs first and its result is returned as it came.
183 on('tool.call', { tool: /^(Bash|Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
184 const ran = await next(e)
185 try {
186 await observe($, cfg, String(e.tool), e as unknown as Record<string, unknown>, ran as unknown as Record<string, unknown>)
187 } catch {
188 // bookkeeping must never touch the call
189 }
190 return ran
191 }).catch(($, e, next) => next(e))
192
193 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runRepro($, cfg, e.args) })).catch(() => ({
194 text: 'Repro · no answer, nothing changed',
195 }))
196}
197hooks/lib.ts 153 lines1import type { Step } from '../types'
2
3export const DEFAULT_PROGRAMS =
4 'python,python3,Rscript,R CMD,jupyter nbconvert,quarto,snakemake,make,dvc,pytest,node,cargo run,julia,uv run'
5
6/** The programs setting as lists of words, one list per program. */
7export function programsOf(raw: unknown): string[][] {
8 const text = typeof raw === 'string' && raw.trim() !== '' ? raw : DEFAULT_PROGRAMS
9 return text
10 .split(',')
11 .map(p => p.trim().split(/\s+/).filter(Boolean))
12 .filter(p => p.length > 0)
13}
14
15export function maxStepsOf(raw: unknown): number {
16 const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : 200
17 return Math.min(1000, Math.max(10, n))
18}
19
20/** Splits a shell line into the simple commands it runs (by ; && || | and newlines). */
21function segments(command: string): string[] {
22 return command.split(/&&|\|\||[;|\n]/).map(s => s.trim()).filter(Boolean)
23}
24
25/** The words of one segment after env assignments (A=1) and sudo/time/nohup, with the program's path stripped. */
26function words(segment: string): string[] {
27 const w = segment.split(/\s+/).filter(Boolean)
28 while (w.length > 0 && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0]!) || /^(sudo|time|nohup|exec)$/.test(w[0]!))) w.shift()
29 if (w.length > 0) w[0] = w[0]!.replace(/^.*\//, '')
30 return w
31}
32
33/** True when some simple command of the line starts with one of the programs. */
34export function isAnalysis(command: string, programs: string[][]): boolean {
35 return segments(command).some(seg => {
36 const w = words(seg)
37 return programs.some(p => p.every((word, i) => w[i] === word))
38 })
39}
40
41const SECRET = /KEY|TOKEN|SECRET|password\s*=|bearer\s|bearer$|sk-[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{12,}|xox[abp]-[A-Za-z0-9-]{10,}|:\/\/[^\s/:@]+:[^\s/@]+@|-----BEGIN/i
42const SECRET_FILE = /(^|\/)(\.env(\..*)?|id_[a-z0-9]+|.*\.(pem|key|p12|pfx)|credentials(\.json)?|\.netrc|\.npmrc|\.pypirc)$/i
43
44/** True when the text looks like it carries a credential: such a command is never recorded. */
45export function looksSecret(text: string): boolean {
46 return SECRET.test(text)
47}
48
49export function isSecretFile(path: string): boolean {
50 return SECRET_FILE.test(path) || /KEY|TOKEN|SECRET/i.test(path.replace(/^.*\//, ''))
51}
52
53/** `--password x`, `--passwd=x`, `--pwd x`, `--pass x` become `--password ***`. */
54export function maskArgs(command: string): string {
55 return command.replace(/(--(?:password|passwd|pwd|pass))(?:=|\s+)(?:"[^"]*"|'[^']*'|\S+)/gi, '$1 ***')
56}
57
58const MAX_COMMAND = 2000
59
60/** What to keep for a command: the masked text, or undefined when it must not be recorded. */
61export function cleanCommand(command: string): string | undefined {
62 const masked = maskArgs(command.trim())
63 if (masked === '' || looksSecret(masked)) return undefined
64 return masked.length > MAX_COMMAND ? `${masked.slice(0, MAX_COMMAND)} # (cut)` : masked
65}
66
67/** Appends a step and keeps the last `max`. */
68export function addStep(list: readonly Step[], step: Step, max: number): Step[] {
69 return [...list, step].slice(-max)
70}
71
72/** Literal numeric seeds in the recorded commands, in order of first sight. */
73export function seedsOf(commands: readonly string[]): string[] {
74 const rx = [
75 /--seed[=\s]+(\d+)/g,
76 /(?:^|\s)-seed[=\s]+(\d+)/g,
77 /set\.seed\(\s*(\d+)\s*\)/g,
78 /(?:np\.random\.seed|random\.seed|torch\.manual_seed)\(\s*(\d+)\s*\)/g,
79 /random_state\s*=\s*(\d+)/g,
80 ]
81 const seen: string[] = []
82 for (const c of commands) {
83 for (const r of rx) {
84 for (const m of c.matchAll(r)) if (!seen.includes(m[1]!)) seen.push(m[1]!)
85 }
86 }
87 return seen
88}
89
90/** Which version commands are worth running for the commands recorded. */
91export function runtimesFor(commands: readonly string[]): ('python3' | 'R' | 'node')[] {
92 const out: ('python3' | 'R' | 'node')[] = []
93 const add = (r: 'python3' | 'R' | 'node') => out.includes(r) || out.push(r)
94 for (const c of commands) {
95 for (const seg of segments(c)) {
96 const p = words(seg)[0] ?? ''
97 if (/^(python|python3|pytest|jupyter|snakemake|dvc|uv)$/.test(p)) add('python3')
98 else if (/^(R|Rscript|quarto)$/.test(p)) add('R')
99 else if (/^(node|npm|npx)$/.test(p)) add('node')
100 }
101 }
102 return out
103}
104
105export const MANIFESTS = [
106 'requirements.txt', 'pyproject.toml', 'poetry.lock', 'uv.lock', 'Pipfile.lock', 'environment.yml',
107 'renv.lock', 'DESCRIPTION', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
108 'Cargo.toml', 'Cargo.lock', 'Snakefile', 'dvc.lock', 'Makefile', '_quarto.yml',
109]
110
111const pad = (n: number) => String(n).padStart(2, '0')
112
113/** 2026-10-12-1405 (local time). */
114export function stamp(ms: number): string {
115 const d = new Date(ms)
116 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
117}
118
119/** A path made relative to the project directory when it is under it. */
120export function relPath(path: string, cwd: string): string {
121 const base = cwd.endsWith('/') ? cwd : `${cwd}/`
122 return cwd !== '' && path.startsWith(base) ? path.slice(base.length) : path
123}
124
125export type Recipe = {
126 at: number
127 commit: string
128 isDirty: boolean | undefined
129 versions: string[]
130 manifests: { name: string; sha: string }[]
131 seeds: string[]
132 commands: string[]
133 files: string[]
134}
135
136export function renderRecipe(r: Recipe): string {
137 const L: string[] = []
138 L.push('# Reproducibility recipe', '')
139 L.push(`- Date: ${new Date(r.at).toISOString()}`)
140 L.push(`- Git commit: ${r.commit === '' ? 'none found' : r.commit}${r.commit !== '' && r.isDirty !== undefined ? (r.isDirty ? ' (dirty: uncommitted changes)' : ' (clean)') : ''}`)
141 L.push('', '## Versions', '')
142 L.push(...(r.versions.length > 0 ? r.versions.map(v => `- ${v}`) : ['- none of python3, R or node were used']))
143 L.push('', '## Package files', '')
144 L.push(...(r.manifests.length > 0 ? r.manifests.map(m => `- ${m.name}${m.sha !== '' ? ` (sha256 ${m.sha.slice(0, 16)})` : ''}`) : ['- none found']))
145 L.push('', '## Random seeds seen', '')
146 L.push(...(r.seeds.length > 0 ? r.seeds.map(s => `- ${s}`) : ['- none seen in the commands']))
147 L.push('', '## Commands, in order', '', '```sh', ...r.commands, '```')
148 L.push('', '## Changed files', '')
149 L.push(...(r.files.length > 0 ? r.files.map(f => `- ${f}`) : ['- none']))
150 L.push('', '_Commands that looked like they carried a secret were left out, and password arguments are masked._', '')
151 return L.join('\n')
152}
153types/index.d.ts 20 lines1/** One recorded step: a command that succeeded, or a file that was edited or written (name only). */
2export type Step = {
3 kind: 'cmd' | 'file'
4 /** The command (secrets masked) or the file path. */
5 text: string
6 /** When it happened (ms since epoch). */
7 at: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'repro-log': {
13 /** The ordered steps of this project, oldest first, capped by the maxSteps setting. */
14 steps: Step[]
15 /** The persisted on/off switch, mirrored here so the moddeck side bar can read it. */
16 isOff: boolean
17 }
18 }
19}
20