SLOPSHOPPER

repro-log

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…

newguardcommandprocess
★ 1v0.1.0MITupdated 2026-10-14samuelbfernandes/moddeck/mods/repro-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repro-log
› 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 ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /repro ⎿ repro-log: Repro · saved .moddeck/repro/2025-10-09-0853.md ⎿ repro-log: 0 commands · 3 changed files ⎿ repro-log: Git a1b2c3d · dirty ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

repro-log

Install (Claude Code 2.1.295 or later): /plugin install repro-log --marketplace samuelbfernandes/moddeck

What it does

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.

Use

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

Secrets

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.

Settings

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

Limits

  • Only commands Claude runs through the Bash tool are seen, not what you run yourself or what a script runs inside.
  • The command list is what was run, in order, not a cleaned pipeline; steps that depended on files outside the project or on the environment may not replay as is. Environment variables and working directory changes are not recorded.
  • Edit and Write give file names only, no content. Files changed by a shell command are not listed.
  • Not verified live: real git, R and shasum output, and the side bar reading isOff. Tested with a mocked engine.

Related

edit-replay shows the diffs of the session's edits; repro-log records how to re-run the analysis.

Source 3 files
hooks/register.tsx 197 lines
1import { 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}
197
hooks/lib.ts 153 lines
1import 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}
153
types/index.d.ts 20 lines
1/** 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