SLOPSHOPPER

repro

Holds a Claude Code research session to its records: refuses an edit to a frozen plan, says when a manuscript or a run has no ledger behind it, and shows what…

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repro
│ ┃ Research projects ✕ › fix the failing auth test and add an audit log call │ ┃ 1–0 of 0 · Enter to pick · Esc to close │ ⏺ 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-status │ ⎿ repro: 0 research projects. ↑ and ↓ to move, Enter to pick, Esc │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Research projects
1–0 of 0 · Enter to pick · Esc to close
README

repro

A Claude Code mod for research sessions. The reproducible-science plugin's hooks speak when a number enters a manuscript unbound or a frozen plan changes, and they speak only in a project that already keeps a ledger or a plan. This closes the other half: it works in a project that keeps neither yet, and it can refuse.

It is a plugin of function hooks. That API is early access and changes between Claude Code releases, so this ships beside the reproducible-science plugin and not inside it: a build that cannot load it still loads the plugin.

What it does

Refuses an edit to a frozen fileAn Edit or Write to a file with a freeze record (.prereg/<name>.json beside it: a plan or an amendment), or to a PREREG.md carrying a freeze digest, is denied, with the instruction to record a note with prereg log or a change with prereg amend. An amendment still in draft has no record and is edited freely.
Notes a manuscript with no ledgerThe first edit to a .tex, .Rmd, .qmd or .typ with no .results/ ledger above it goes through, and the model is told no number in the file is bound to a run.
Notes an analysis with no ledgerThe same, once per project, when a command runs an analysis.
Notes a downloaded sourceAfter curl or wget saves a PDF, the model is told the address to record as url: in the claims file, so citations fetch can retrieve the same bytes for a reader.
Puts the gates in the system promptSeal before a run, claim before a number, freeze before a confirmatory analysis, with the state of the project's records as of the last turn.
Shows the stateOne dim row above the prompt, refreshed at the end of each turn, with one field per tool.
WarnsThe pinned line under the prompt appears only when something is wrong, a few words per problem.
/repro-statusThe same readout on demand, with the sealed files hashed. Instant, and no model turn.
/repro-verifyChecks every pinned quotation of the working project against its source, which takes minutes on a large project, and keeps the count for the readout.
/repro-hide, /repro-showHides the readout above the prompt, and shows it again. A warning still appears while it is hidden.

The status row

study · prereg: 1/1 frozen · results: 3/3 runs sealed, 4 claims bound · citations: 120/120 quotes found · repro: 34/34 checks verified

Above the prompt each field is drawn on a row of its own. prereg is frozen plans over all plans, or none drafted.

results is the runs recorded after inputs were sealed, over all runs, then the claims bound to a run with results claim. A run recorded before anything was sealed lowers the first number: 2/3 runs sealed. A run whose id begins smoke_, prefreeze_, test_ or dryrun_ is a test run and is left out of the row, because it is recorded before a plan is frozen and nothing may be claimed from it.

citations is the last full check by /repro-verify, and before one has run it is the count pinned: citations: 120 pinned, not verified. Checking quotations takes minutes on a large project, so the row never runs it. A check that does not finish in a minute marks its own field not read (timed out) and leaves the others standing.

repro is the checks repro verify verified over all the manifest declares, one per assertion. The manifest is the repro.yaml at the project's top or above it, which is the one repro verify reads without being given a path. A manifest under another name or in a subfolder (paper/repro.yaml) is not found, and a project with none reads repro: no manifest. Claims are counted on the results row, where results claim makes them:

repro: 56/56 checks verified repro: 55/56 checks verified repro: 6/6 checks verified, 1 broken pin

A pinned file that changed is named because the assertions read from it still verify, against a file that is not the declared one. Where repro verify does not print a line for every assertion and some did not verify, the field gives the tool's own words: repro: 1 mismatch, 33 verified. The check takes under a second on a manifest of 56 assertions, so it runs with the others at the end of each turn.

A row whose tool has a next step still to take names the command that takes it, directly after the row:

citations: none pinned → citations pin repro: no manifest → repro manifest init

prereg: none drafted points at prereg new, an unfrozen draft at prereg freeze, a missing ledger at results init, a ledger with no runs at results seal, and a manifest with no claims at adding some. A project with none of the four set up shows one hint, repro init, which creates all four. A row with something to report carries no hint.

The warnings, each on the pinned line as repro: ...:

1 plan edited after freezea frozen PREREG.md no longer matches its digest
ledger truncated, edited, corruptthe chain does not verify
ledger rewritten after timestampa timestamp proof contradicts the chain
2 sealed files changedafter /repro-status, which hashes the sealed files
16 runs, nothing sealedruns are recorded and no input was sealed
3 runs, no plan frozenruns are recorded and no plan is frozen
1 number mismatcheda number in the manuscript disagrees with the artifact repro.yaml binds it to
1 quotation mismatchedthe same, for a quote assertion
2 claims mismatchedthe same, where the kind could not be read off repro verify's lines
1 quotation not foundafter /repro-verify: a pinned quotation is not in its source
1 claim on test runsa manuscript number is bound to a run named as a test run

An assertion or a quotation that is unchecked, and a claim offering no evidence, are counted in the row and draw no warning.

The working project

A session started in one folder often edits files in another, so the project is read off the paths the tools touch: the nearest directory above a touched file that holds a .results/ ledger, a claims/ directory, a PREREG.md or a paper/ directory.

/repro-status the working project's state /repro-status <part of a name> set the working project, among the folders beside the session's /repro-status pick choose one from a list /repro-status list print them /repro-status auto go back to following the files

The model can set it too, through the set_project tool the mod registers.

Install

/plugin marketplace add elliottower/reproducible-science /plugin install repro@reproducible-science

It needs the results, prereg and citations commands on PATH, and repro for the fourth field.

Develop

claude plugin validate packages/repro/mod claude plugin test packages/repro/mod

The tests stand in for the engine and need no network. They do not run in this repository's CI, which has no Claude Code.

Source 2 files
hooks/register.ts 968 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4/** What `prereg freeze` once wrote into a plan. Its presence makes a plan frozen in place. */
5const FROZEN = /^\*\*Plan sha256:\*\*[ \t]*`[0-9a-f]{64}`/m
6
7/**
8 * Where `prereg freeze` records a file frozen whole: `.prereg/<name>.json` beside it. A plan and
9 * each of its amendments has one, and a file with one is never written to again.
10 */
11const recordOf = (path: string) => `${parent(path)}/.prereg/${path.replace(/^.*\//, '')}.json`
12
13const PLAN = 'PREREG.md'
14const LEDGER = '.results/ledger.jsonl'
15const MANIFEST = 'repro.yaml'
16
17/**
18 * What a row reads while its tool has a next step still to take, and the command that takes it.
19 * A row matching none of these carries no hint.
20 */
21const HINTS: [state: RegExp, next: string][] = [
22  [/prereg: none drafted$/, 'prereg new'],
23  [/prereg: 0\/\d+ frozen$/, 'prereg freeze'],
24  [/^results: no ledger$/, 'results init'],
25  [/^results: 0 runs, 0 claims bound$/, 'results seal'],
26  [/^citations: none pinned$/, 'citations pin'],
27  [/^repro: no manifest$/, 'repro manifest init'],
28  [/^repro: no claims declared$/, 'add claims to repro.yaml'],
29]
30/** The rows of a project with nothing set up, which `repro init` starts in one command. */
31const NOTHING = [/prereg: none drafted$/, /^results: no ledger$/, /^citations: none pinned$/, /^repro: no manifest$/]
32const MANUSCRIPT = /\.(tex|rmd|qmd|typ)$/i
33
34/** Programs that run an analysis, matched on a command's first three words. */
35const RUNNERS = new Set([
36  'python', 'python3', 'uv', 'Rscript', 'julia', 'make', 'snakemake', 'nextflow', 'papermill',
37  'jupyter', 'quarto', 'modal', 'sbatch', 'srun',
38])
39
40const GATES = `# Research records (reproducible-science)
41
42This project keeps machine-checkable records, and three steps are not taken without one:
43
441. Before a run whose output a paper will report: \`results seal\` the inputs, then \`results run\`. With no ledger in the repository, \`results init\` comes first.
452. Before a number goes into a manuscript: \`results claim\` binds the sentence to the run that produced it.
463. Before a confirmatory analysis runs: \`prereg freeze\` the plan. A frozen plan or amendment is never edited; a note is recorded with \`prereg log\` and a change to the plan with \`prereg amend\`.
47
48A quotation is pinned with \`citations pin\` before the sentence quoting it is written.
49A gate is skipped only with the user's approval or a reason written into the notebook or the commit message, and the user is told in the same turn.`
50
51const parent = (path: string) => path.replace(/\/[^/]*$/, '')
52const dirOf = (path: string) => (path.includes('/') ? parent(path) : '.')
53
54/** The nearest `name` at or above `dir`, or undefined. Stops at the filesystem root. */
55async function above($: EngineInterface, dir: string, name: string) {
56  for (let at = dir; ; at = parent(at)) {
57    if (await $.fs.exists(`${at}/${name}`)) {
58      return `${at}/${name}`
59    }
60    if (at === '' || at === '.' || !at.includes('/')) {
61      return undefined
62    }
63  }
64}
65
66async function absolute($: EngineInterface, path: string) {
67  const cwd = await $.session.cwd()
68  if (path.startsWith('~/')) {
69    return `${/^\/(?:Users|home)\/[^/]+/.exec(cwd)?.[0] ?? ''}${path.slice(1)}`
70  }
71
72  return path.startsWith('/') ? path : `${cwd}/${path}`
73}
74
75/** What marks a directory as a research project: a ledger, pinned quotations, a plan or a manuscript folder. */
76const MARKERS = ['.results', 'claims', PLAN, 'paper']
77
78/** The nearest research project at or above `dir`, or undefined. */
79async function projectOf($: EngineInterface, dir: string) {
80  for (let at = dir; at.includes('/') && at !== ''; at = parent(at)) {
81    for (const name of MARKERS) {
82      if (await $.fs.exists(`${at}/${name}`)) {
83        return at
84      }
85    }
86  }
87
88  return undefined
89}
90
91/** The directory a shell command works in when it opens with `cd <dir>`, or undefined. */
92const cdTarget = (command: string) => /(?:^|[;&]\s*)cd\s+["']?([^\s"';&]+)/.exec(command)?.[1]
93
94/** A command that saves a PDF from a URL: `[url, path]`, or undefined. curl and wget only. */
95function download(command: string): [string, string] | undefined {
96  if (!/\b(curl|wget)\b/.test(command)) {
97    return undefined
98  }
99  const url = /https?:\/\/[^\s"'<>|;&)]+/.exec(command)
100  const saved = /(?:-o|-O|--output|--output-document|>)\s*["']?([^\s"'|;&]+\.pdf)\b/i.exec(command)
101
102  return url && saved ? [url[0], saved[1] as string] : undefined
103}
104
105/** Whether a command runs an analysis. An inline script (`python3 - <<EOF`, `python -c`) is not one. */
106const runsAnAnalysis = (command: string) =>
107  !/\s-(c\s|\s*<<)/.test(command) &&
108  command.trim().split(/\s+/).slice(0, 3).some(word => RUNNERS.has(word.replace(/^.*\//, '')))
109
110/**
111 * The last full quotation check of each project, as `found/pinned`. Checking thousands of
112 * quotations takes minutes, so the status line never runs it: `/repro-verify` does, and the
113 * result is kept here, in the session's state, until the next one.
114 */
115const quotations = atom({ plugin: 'repro', key: 'quotations' } as const, {} as Record<string, string>)
116
117/**
118 * How many quotations that check reported as `not found`, by project. Kept apart from the
119 * fraction because pinned less found also counts the quotations the check could not read.
120 */
121const quotationsNotFound = atom(
122  { plugin: 'repro', key: 'quotationsNotFound' } as const,
123  {} as Record<string, number>,
124)
125
126const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
127
128/**
129 * A command's output, or undefined when it did not finish in time or could not start.
130 *
131 * `$.process.run` rejects on a timeout, and a rejection here used to end the whole turn's hook:
132 * one slow `prereg check` on a busy machine left no status line at all. A check that did not
133 * finish now marks its own field and leaves the others standing.
134 */
135async function output($: EngineInterface, argv: string[], cwd: string, timeoutMs: number) {
136  try {
137    return (await $.process.run(argv, { cwd, timeoutMs })).stdout
138  } catch {
139    return undefined
140  }
141}
142
143/** The outcomes `repro verify` counts, the most serious first, which is the order the row gives them in. */
144const OUTCOMES = ['mismatch', 'not_found', 'error', 'unchecked', 'not_offered', 'verified']
145
146/** The kinds of evidence `repro verify` prints beside an assertion. Only `quote` is not a number. */
147const KINDS = new Set(['quote', 'metric', 'table', 'value', 'correspondence'])
148
149/**
150 * The `repro` field and its warnings, from what `repro verify` printed:
151 *
152 *       MISS  effect-size  correspondence effect-delta: manuscript 0.055, run 0.0453
153 *
154 *       1 mismatch, 2 verified
155 *       policy publication: FAILED  (1 errors, 0 warnings)
156 *
157 * The counts are the tool's own summary line, under its own words. A pinned file that changed
158 * is counted too: the assertions read from it still say `verified`, of a file that is not the
159 * declared one, and `3/3 verified` would be the row saying so.
160 */
161function assertions(printed: string | undefined) {
162  if (printed === undefined) {
163    return { field: 'repro: not read (timed out)', wrong: [] }
164  }
165  // Without the policy line nothing was verified: the manifest did not load.
166  if (!/^\s+policy \S+: (passed|FAILED)/m.test(printed)) {
167    return { field: 'repro: manifest unreadable', wrong: [] }
168  }
169  const summary = /^\s+(\d+ \w+(?:, \d+ \w+)*)$/m.exec(printed)?.[1] ?? ''
170  const counts = new Map([...summary.matchAll(/(\d+) (\w+)/g)].map(([, n, word]) => [word as string, Number(n)]))
171  const total = [...counts.values()].reduce((a, b) => a + b, 0)
172  const said = (n: number) => n.toLocaleString('en-US')
173  const brokenPins = (printed.match(/^\s+BROKEN PIN\s/gm) ?? []).length
174  const parts = [
175    ...(brokenPins ? [`${said(brokenPins)} broken ${brokenPins === 1 ? 'pin' : 'pins'}`] : []),
176    ...OUTCOMES.filter(word => counts.has(word)).map(word => `${said(counts.get(word) ?? 0)} ${word.replace(/_/g, ' ')}`),
177  ]
178  const verified = counts.get('verified') ?? 0
179  // The row counts checks, one per assertion. Claims are counted on the results row, where
180  // `results claim` makes them. Where every assertion is printed the row is a fraction even
181  // when some failed; otherwise it is a fraction only when all verified.
182  const lines = [...printed.matchAll(/^\s+(ok|MISS|GONE|--|ERR|none)\s+(\S+)\s/gm)]
183  const pins = brokenPins ? `, ${said(brokenPins)} broken ${brokenPins === 1 ? 'pin' : 'pins'}` : ''
184
185  const wrong: string[] = []
186  const mismatched = counts.get('mismatch') ?? 0
187  if (mismatched) {
188    // The kind is the word after the claim's id. An id with a space in it moves that word, and
189    // a line cut short hides one, so the kinds are believed only when every one is a known kind.
190    const kinds = [...printed.matchAll(/^\s+MISS\s+\S+\s+(\S+)/gm)].map(match => match[1] as string)
191    const quotes = kinds.filter(kind => kind === 'quote').length
192    if (kinds.length !== mismatched || !kinds.every(kind => KINDS.has(kind))) {
193      wrong.push(`${plural(mismatched, 'claim')} mismatched`)
194    } else {
195      if (mismatched > quotes) {
196        wrong.push(`${plural(mismatched - quotes, 'number')} mismatched`)
197      }
198      if (quotes) {
199        wrong.push(`${plural(quotes, 'quotation')} mismatched`)
200      }
201    }
202  }
203
204  return {
205    field:
206      total === 0
207        ? 'repro: no claims declared'
208        : lines.length === total
209          ? `repro: ${said(verified)}/${said(total)} checks verified${pins}`
210          : verified === total && !brokenPins
211            ? `repro: ${said(verified)}/${said(total)} checks verified`
212            : `repro: ${parts.join(', ')}`,
213    wrong,
214  }
215}
216
217/**
218 * Every `claims` folder in the project, relative to its root. A paper keeps its pinned quotations
219 * beside the manuscript as often as at the top (`paper/prior_art/claims`), and looking only at
220 * the top reported 63 pinned quotations as none.
221 */
222async function claimsFolders($: EngineInterface, root: string) {
223  const found = await output(
224    $,
225    ['find', '.', '-maxdepth', '4', '-type', 'd', '-name', 'claims', '-not', '-path', '*/node_modules/*', '-not', '-path', '*/.git/*'],
226    root,
227    20_000,
228  )
229
230  return (found ?? '')
231    .split('\n')
232    .map(line => line.replace(/^\.\//, '').trim())
233    .filter(line => line.length > 0)
234    .sort()
235}
236
237/**
238 * Folders below the project that hold a `PREREG.md` of their own. `prereg check` reads the plan
239 * nearest the folder it runs in, so a study kept in a subfolder was never listed: a repository
240 * with a second frozen plan under `artifact_survey/` read `1/1 frozen`.
241 */
242async function planFolders($: EngineInterface, root: string) {
243  const found = await output(
244    $,
245    ['find', '.', '-mindepth', '2', '-maxdepth', '4', '-type', 'f', '-name', 'PREREG.md', '-not', '-path', '*/node_modules/*', '-not', '-path', '*/.git/*'],
246    root,
247    20_000,
248  )
249
250  return (found ?? '')
251    .split('\n')
252    .map(line => line.replace(/^\.\//, '').trim())
253    .filter(line => line.endsWith('/PREREG.md'))
254    .map(line => line.slice(0, -'/PREREG.md'.length))
255    .sort()
256}
257
258/**
259 * What the last `status` found wrong, each in a few words. Drawn on the engine's pinned line
260 * under the prompt, which is one row and spends about 28 columns on this plugin's name, so a
261 * warning names the problem and `/repro-status` says what to do. The line is kept for these
262 * because of its warning mark: a line that is there every turn stops being read as a warning.
263 */
264let warnings: string[] = []
265
266/**
267 * One line, one field per tool, in a fixed order so each is found in the same place every turn:
268 *
269 *     study · prereg: 1/1 frozen · results: 2/3 runs sealed, 4 claims bound · citations: 120/120 quotes found · repro: 34/34 checks verified
270 *
271 * The `repro` field is there only in a project with a manifest. Fractions only where there is a real total. The project's name stays, because the project
272 * followed is the one whose files the session touches, which need not be where it started.
273 *
274 * `withFiles` also hashes every sealed file, which is the check that finds a changed input and
275 * the one that costs: it runs for `/repro-status`, never at the end of every turn.
276 */
277async function status($: EngineInterface, root: string, withFiles: boolean) {
278  const fields = [root.replace(/^.*\//, '')]
279  const wrong: string[] = []
280
281  // `prereg check` reads every plan at, above and below the directory it runs in.
282  const plans = await output($, ['prereg', 'check'], root, 60_000)
283  // One line per plan, whichever folder reported it: a check run in a subfolder also lists the
284  // plan above it.
285  const listed = new Set((plans ?? '').split('\n'))
286  if (plans !== undefined) {
287    for (const folder of await planFolders($, root)) {
288      const below = await output($, ['prereg', 'check'], `${root}/${folder}`, 60_000)
289      for (const line of (below ?? '').split('\n')) {
290        listed.add(line)
291      }
292    }
293  }
294  const counts = new Map<string, number>()
295  for (const line of listed) {
296    // A plan frozen with `prereg freeze` is listed by its absolute path. A registration frozen by a
297    // commit line in the document is listed by its path in the repository, under its own words:
298    // text added after the frozen text leaves the plan intact, and a commit that is pending or not
299    // in the repository could not be checked, which is not a change.
300    const pinned = /^(unchanged|appended|CHANGED|pending|unknown commit)\s{2,}[^/\s]/.exec(line)?.[1]
301    const label =
302      /^([A-Za-z][A-Za-z ]*?)\s{2,}\//.exec(line)?.[1]?.toLowerCase() ??
303      (pinned && { unchanged: 'unchanged', appended: 'unchanged', CHANGED: 'changed' }[pinned]) ??
304      (pinned ? 'not frozen' : undefined)
305    // `log  <path>  4 entries, chain intact` reports the log kept beside a plan. It is not a
306    // plan, and counted as one it read as a plan that was neither frozen nor a draft: changed.
307    if (label && label !== 'log') {
308      counts.set(label, (counts.get(label) ?? 0) + 1)
309    }
310  }
311  const total = [...counts.values()].reduce((a, b) => a + b, 0)
312  const frozen = counts.get('unchanged') ?? 0
313  const broken = total - frozen - (counts.get('not frozen') ?? 0)
314  fields.push(
315    plans === undefined
316      ? 'prereg: not read (timed out)'
317      : total === 0
318        ? 'prereg: none drafted'
319        : broken
320          ? `prereg: ${broken} changed`
321          : `prereg: ${frozen}/${total} frozen`,
322  )
323  if (broken) {
324    wrong.push(`${plural(broken, 'plan')} edited after freeze`)
325  }
326
327  let runs = 0
328  if (await $.fs.exists(`${root}/${LEDGER}`)) {
329    const verified = await output(
330      $,
331      ['results', 'verify', ...(withFiles ? ['--files'] : [])],
332      root,
333      withFiles ? 300_000 : 60_000,
334    )
335    const head = verified?.split('\n')[0] ?? ''
336    if (verified === undefined) {
337      fields.push('results: not read (timed out)')
338    } else if (/chain intact: \d+ events/.test(head)) {
339      const ledger = await $.fs.read(`${root}/${LEDGER}`)
340      // A run counts as sealed when inputs were sealed before it was recorded. A run named as a
341      // test (`smoke_…`, `prefreeze_…`, `test_…`, `dryrun_…`) is left out of the row: it is
342      // recorded before a plan is frozen, and nothing may be claimed from it.
343      const events = ledger.split('\n').flatMap(line => {
344        try {
345          return line.trim() ? [JSON.parse(line) as { event?: string; run_id?: string }] : []
346        } catch {
347          return []
348        }
349      })
350      const isTest = (id: string | undefined) => /^(smoke|prefreeze|test|dryrun)[_-]/.test(id ?? '')
351      const claims = events.filter(event => event.event === 'claim')
352      let hasSeal = false
353      let sealed = 0
354      for (const event of events) {
355        if (event.event === 'seal') {
356          hasSeal = true
357        } else if (event.event === 'run' && !isTest(event.run_id)) {
358          runs += 1
359          sealed += hasSeal ? 1 : 0
360        }
361      }
362      const changed = (verified.match(/^\s+(CHANGED|MISSING)\s/gm) ?? []).length
363      const onTests = claims.filter(event => isTest(event.run_id)).length
364      fields.push(
365        `results: ${changed ? `${changed} changed, ` : ''}` +
366          `${runs ? `${sealed}/${runs} runs sealed` : '0 runs'}, ${plural(claims.length, 'claim')} bound`,
367      )
368      if (changed) {
369        wrong.push(`${plural(changed, 'sealed file')} changed`)
370      }
371      if (onTests) {
372        wrong.push(`${plural(onTests, 'claim')} on test runs`)
373      }
374      if (/^TIMESTAMP CONTRADICTS/m.test(verified)) {
375        wrong.push('ledger rewritten after timestamp')
376      }
377      if (runs > 0 && !hasSeal) {
378        wrong.push(`${plural(runs, 'run')}, nothing sealed`)
379      }
380    } else {
381      // `CHAIN TRUNCATED — events are missing from the end`: the word after CHAIN.
382      const fault = /^(?:CHAIN|NO)\s+(\w+)/.exec(head)?.[1]?.toLowerCase() ?? 'unreadable'
383      fields.push(`results: ${fault}`)
384      wrong.push(`ledger ${fault}`)
385    }
386  } else {
387    fields.push('results: no ledger')
388  }
389  if (runs > 0 && total > 0 && frozen === 0 && !broken) {
390    wrong.push(`${plural(runs, 'run')}, no plan frozen`)
391  }
392
393  const folders = await claimsFolders($, root)
394  if (folders.length > 0) {
395    const last = (await read($, quotations))[root]
396    if (last) {
397      fields.push(`citations: ${last}`)
398      const notFound = (await read($, quotationsNotFound))[root] ?? 0
399      if (notFound) {
400        wrong.push(`${plural(notFound, 'quotation')} not found`)
401      }
402    } else {
403      // Counting what is pinned is one grep; checking it is minutes, and `/repro-verify` does that.
404      const counted = await output(
405        $,
406        ['grep', '-rhcE', '^[[:space:]]*-?[[:space:]]*exact:', ...folders],
407        root,
408        20_000,
409      )
410      const pinned = (counted ?? '').split('\n').reduce((sum, n) => sum + (Number(n) || 0), 0)
411      fields.push(`citations: ${pinned.toLocaleString('en-US')} pinned, not verified`)
412    }
413  } else {
414    // Said, so the row always holds the same three fields and a missing one is not read as fine.
415    fields.push('citations: none pinned')
416  }
417
418  // `repro verify` reads the `repro.yaml` at or above the directory it runs in and no other, so
419  // that is the only manifest looked for. It takes under a second on 34 assertions, and runs here.
420  // With no manifest the field says so, as the others do for a missing ledger or plan: a row
421  // left out reads the same as nothing to check.
422  if (await above($, root, MANIFEST)) {
423    const checked = assertions(await output($, ['repro', 'verify'], root, 60_000))
424    fields.push(checked.field)
425    wrong.push(...checked.wrong)
426  } else {
427    fields.push('repro: no manifest')
428  }
429
430  warnings = wrong
431
432  return fields.join(' · ')
433}
434
435/** The pane that lists the research projects, one button each, to pick one from. */
436const PICKER = 'repro-projects'
437
438/** Each project's button is addressed by this prefix and its folder name. */
439const ROW = 'project:'
440
441// In a pane the arrows walk the buttons only while the whole tree fits: once there are rows to
442// scroll, an arrow scrolls the pane and the focus ring stays where it was. So the picker never
443// draws more rows than fit. It draws a window onto the list and slides the window as the ring
444// reaches either edge of it.
445
446// Both live in the session's state, which the pane reads while it draws: a write then redraws
447// the pane by itself. Kept in module variables, a slide changed the number and drew nothing.
448
449/** How many projects the window shows. Corrected from the pane's real height on a first scroll. */
450const windowRows = atom({ plugin: 'repro', key: 'windowRows' } as const, 8)
451
452/** The index in `found` of the window's first row. */
453const top = atom({ plugin: 'repro', key: 'top' } as const, 0)
454
455const lastTop = (rows: number) => Math.max(0, found.length - rows)
456
457/** The line of the pane the focus ring is on, from 0, to tell a wrap from an ordinary move. */
458let lastLine = 0
459
460/** The research projects last found beside the session's folder, by folder name. */
461let found: string[] = []
462
463/**
464 * The research projects in the folder that holds the session's own: each folder there with a
465 * ledger, pinned quotations or a plan at its top level, the ones with a ledger first.
466 */
467async function discover($: EngineInterface) {
468  const home = parent(await $.session.cwd())
469  const withLedger: string[] = []
470  const others: string[] = []
471  const papersOnly: string[] = []
472  for (const entry of await $.fs.list(home)) {
473    if (entry.kind !== 'dir' || entry.name.startsWith('.')) {
474      continue
475    }
476    if (await $.fs.exists(`${home}/${entry.name}/${LEDGER}`)) {
477      withLedger.push(entry.name)
478    } else if (
479      (await $.fs.exists(`${home}/${entry.name}/claims`)) ||
480      (await $.fs.exists(`${home}/${entry.name}/${PLAN}`))
481    ) {
482      others.push(entry.name)
483    } else if (await $.fs.exists(`${home}/${entry.name}/paper`)) {
484      papersOnly.push(entry.name)
485    }
486  }
487  found = [...withLedger.sort(), ...others.sort(), ...papersOnly.sort()]
488
489  return found
490}
491
492/** Paths and projects already told once this session, so a note is not repeated. */
493const told = new Set<string>()
494
495/**
496 * The research project this session last worked in, wherever the session was started.
497 *
498 * A session opened in one folder edits files in another, so the project is read off the paths
499 * the tools touch: the nearest directory above a touched file that holds a ledger, pinned
500 * quotations, a plan or a manuscript folder.
501 */
502let project: string | undefined
503
504/** Set when the person named the project themselves; the tools' paths then stop moving it. */
505let isPinned = false
506
507// The session keeps both, so a reload of this module does not forget which project is open.
508const savedProject = atom({ plugin: 'repro', key: 'project' } as const, null)
509const savedPin = atom({ plugin: 'repro', key: 'isPinned' } as const, false)
510/** Whether the person hid the readout above the prompt with `/repro-hide`. Warnings still show. */
511const hidden = atom({ plugin: 'repro', key: 'isHidden' } as const, false)
512
513async function save($: EngineInterface) {
514  await update($, savedProject, () => project ?? null)
515  await update($, savedPin, () => isPinned)
516}
517
518/** What the checks last said, shown on the status line and given to the model with the gates. */
519let lastStatus = ''
520
521/**
522 * The same line, drawn in the band above the prompt. Kept in the session's state
523 * so a change redraws it: a `$.ui.status` line carried this plugin's name and a warning mark ahead
524 * of the text, and in a narrow terminal that left room for the project name and little else.
525 */
526const shownStatus = atom({ plugin: 'repro', key: 'statusLine' } as const, '')
527
528async function show($: EngineInterface) {
529  // The pinned line under the prompt carries a warning mark and this plugin's name, so it holds
530  // only what is wrong, and nothing at all when nothing is.
531  $.ui.status(warnings.length > 0 ? warnings.join(' · ') : undefined)
532  await update($, shownStatus, () => lastStatus)
533}
534
535/** Makes the project above `path` the active one, when there is one. */
536async function track($: EngineInterface, path: string) {
537  if (isPinned) {
538    return
539  }
540  const before = project
541  project = (await projectOf($, await absolute($, path))) ?? project
542  if (project !== before) {
543    await save($)
544  }
545}
546
547/**
548 * Names the working project: a path, part of the name of a folder beside the one the session
549 * started in, or its number in the list. `auto` hands it back to the paths the tools touch.
550 * Answers what it did, for the transcript.
551 */
552async function choose($: EngineInterface, wanted: string) {
553  if (wanted === 'auto') {
554    isPinned = false
555    await save($)
556
557    return 'Following the files this session touches.'
558  }
559  const cwd = await $.session.cwd()
560  let name = wanted
561  if (!/^[~/.]/.test(wanted)) {
562    const names = found.length > 0 ? found : await discover($)
563    const hits = /^\d+$/.test(wanted)
564      ? names.slice(Number(wanted) - 1, Number(wanted))
565      : names.includes(wanted)
566        ? [wanted]
567        : names.filter(one => one.toLowerCase().includes(wanted.toLowerCase()))
568    if (hits.length !== 1) {
569      return hits.length === 0
570        ? `No research project matches "${wanted}". /repro-status list shows them all.`
571        : `"${wanted}" matches ${hits.length} projects. Specify the name further:\n  ${hits.join('\n  ')}`
572    }
573    name = hits[0] as string
574  }
575  const path = /^[~/.]/.test(name) ? await absolute($, name) : `${parent(cwd)}/${name}`
576  if (!(await $.fs.exists(path))) {
577    return `No such folder: ${path}`
578  }
579  project = (await projectOf($, path)) ?? path
580  isPinned = true
581  await save($)
582
583  return `Working project set to ${project}`
584}
585
586async function refresh($: EngineInterface) {
587  lastStatus = project ? await status($, project, false) : ''
588  await show($)
589}
590
591export const register: Register = on => {
592  on('session.start', async ($, e, next) => {
593    await $.command.register({
594      name: 'repro-status',
595      description: "(reproducible-science) Instant status of a project's ledger, plans and quotations",
596      argumentHint: '[part of a project name | pick | list | auto]',
597    })
598    await $.command.register({
599      name: 'repro-hide',
600      description: '(reproducible-science) Hide the readout above the prompt',
601    })
602    await $.command.register({
603      name: 'repro-show',
604      description: '(reproducible-science) Show the readout above the prompt again',
605    })
606    await $.command.register({
607      name: 'repro-verify',
608      description: "(reproducible-science) Check every pinned quotation of the working project against its source",
609    })
610    project = (await read($, savedProject)) ?? undefined
611    isPinned = await read($, savedPin)
612    await $.tool.register({
613      name: 'set_project',
614      description:
615        'Declare which research project this session is working on, when it differs from the ' +
616        'folder the session started in. Give the project folder as an absolute path. The ' +
617        'record checks, the status line and the gates then follow that project.',
618      inputSchema: {
619        type: 'object',
620        properties: { path: { type: 'string', description: 'Absolute path of the project folder' } },
621        required: ['path'],
622      },
623    })
624    await track($, await $.session.cwd())
625    await refresh($)
626
627    return next(e)
628  })
629
630  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
631    await track($, e.file_path)
632
633    return next(e)
634  })
635
636  on('turn.complete', async ($, e, next) => {
637    await refresh($)
638
639    return next(e)
640  })
641
642  on('prompt.compose', async ($, e, next) => {
643    const composed = await next(e)
644
645    return project
646      ? {
647          sections: [
648            ...composed.sections,
649            {
650              id: 'repro:gates',
651              text: `${GATES}\n\nState of this project's records as of the last turn: ${lastStatus}.`,
652              scope: 'session',
653            },
654          ],
655        }
656      : composed
657  })
658
659  for (const tool of ['Edit', 'Write'] as const) {
660    on('tool.call', { tool }, async ($, e, next) => {
661      const path = await absolute($, e.file_path)
662      await track($, path)
663
664      if (path.includes('/') && (await $.fs.exists(recordOf(path)))) {
665        return {
666          deny:
667            `${path} has a freeze record (${recordOf(path)}), and a frozen file never changes by ` +
668            `one byte. Record a note with \`prereg log\`, or a change to the plan as an ` +
669            `amendment with \`prereg amend\`, which is a file of its own. Ask the user before ` +
670            `doing either.`,
671        }
672      }
673
674      if (path.endsWith(`/${PLAN}`) && (await $.fs.exists(path)) && FROZEN.test(await $.fs.read(path))) {
675        return {
676          deny:
677            `${path} is a frozen registration, and a frozen plan is not edited in place. ` +
678            `Record the change as an amendment or a deviation with \`prereg log\`, which keeps ` +
679            `the frozen text and its digest. If the plan must be replaced, ask the user first.`,
680        }
681      }
682
683      const ran = await next(e)
684      if (ran.deny !== undefined || !MANUSCRIPT.test(path) || told.has(path)) {
685        return ran
686      }
687      if (await above($, dirOf(path), LEDGER)) {
688        return ran
689      }
690      told.add(path)
691
692      return {
693        ...ran,
694        context: [
695          ...(ran.context ?? []),
696          `reproducible-science: ${path} is a manuscript and no .results/ ledger exists at or above it, ` +
697            `so no number in it is bound to a run. Before reporting a result here, run ` +
698            `\`results init\`, seal the inputs, and bind each sentence with \`results claim\`. ` +
699            `If this file reports no results of this project, say so to the user and continue.`,
700        ],
701      }
702    })
703  }
704
705  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
706    const target = cdTarget(e.command)
707    if (target) {
708      await track($, target)
709    }
710    const ran = await next(e)
711    const cwd = target ? await absolute($, target) : await $.session.cwd()
712    const fetched = download(e.command)
713    if (ran.deny === undefined && !ran.isError && fetched && !told.has(fetched[1])) {
714      told.add(fetched[1])
715
716      return {
717        ...ran,
718        context: [
719          ...(ran.context ?? []),
720          `reproducible-science: ${fetched[1]} was downloaded from ${fetched[0]}. When this source is ` +
721            `pinned, write that address as \`url:\` under \`source:\` in its claims file, beside ` +
722            `its sha256 and its \`doi:\`, so \`citations fetch\` can retrieve the same bytes ` +
723            `for a reader. Record the address the file came from, not a landing page.`,
724        ],
725      }
726    }
727    if (ran.deny !== undefined || !project || told.has(cwd) || !runsAnAnalysis(e.command)) {
728      return ran
729    }
730    if (await above($, cwd, LEDGER)) {
731      return ran
732    }
733    told.add(cwd)
734
735    return {
736      ...ran,
737      context: [
738        ...(ran.context ?? []),
739        `reproducible-science: that command ran an analysis in a project with no .results/ ledger. If a ` +
740          `paper will report its output, \`results init\` and \`results seal\` the inputs before ` +
741          `the run that counts. If it was exploratory, no record is owed.`,
742      ],
743    }
744  })
745
746  on('tool.call', { tool: 'mcp__repro__set_project' }, async ($, e) => {
747    const said = await choose($, String(e.path ?? ''))
748    await refresh($)
749
750    return { result: `${said}\n${lastStatus}` }
751  })
752
753  // One dim row directly above the prompt, drawn by this mod. The hint line under the prompt
754  // takes a `tail`, and the terminal leaves the tail out where the row has no room beside its
755  // own mode labels, which on a session with several of them is always.
756  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
757    const line = await read($, shownStatus)
758    if (!line || e.props.hasSurvey || (await read($, hidden))) {
759      return next(e)
760    }
761    const { Box, Text } = $.ui.resolve(e)
762
763    // The engine draws its collapse mark, `[-]`, over the last columns of the band's first row.
764    // Unpadded, a line wrapping there lost the characters under it: `4 sealed, 0 claims` showed
765    // as `4 sealed,` then `claims`.
766    // One field per row, always: the project's name with the plans, then the runs, then the
767    // quotations, then the manifest's assertions where there is a manifest. Laid side by side they read differently at every window width.
768    const [name, first, ...rest] = line.split(' · ')
769    const fields = first === undefined ? [name ?? ''] : [`${name} · ${first}`, ...rest]
770
771    // A row with a next step still to take names the command that takes it.
772    const isNew = fields.length === NOTHING.length && NOTHING.every((state, at) => state.test(fields[at] ?? ''))
773    const hints = fields.map((field, at) =>
774      isNew ? (at === 0 ? 'repro init' : undefined) : HINTS.find(([state]) => state.test(field))?.[1],
775    )
776    // The hint follows its row directly. Set in a column, it sat as far right as the longest row,
777    // and a long project name pushed it off the edge of the window.
778    const rows = fields.map((field, at) => (hints[at] ? `${field}  →  ${hints[at]}` : field))
779
780    return h(
781      Box,
782      { paddingRight: 5, flexDirection: 'column' },
783      ...rows.map((row, at) => h(Text, { key: `field:${at}`, dimColor: true }, row)),
784    )
785  })
786
787  on('ui.render', { component: 'Pane', requestId: PICKER }, async ($, e) => {
788    const { Box, Button, Text } = $.ui.resolve(e)
789    const rows = await read($, windowRows)
790    const first = Math.min(await read($, top), lastTop(rows))
791    const shown = found.slice(first, first + rows)
792    const current = project?.replace(/^.*\//, '')
793    const start = shown.includes(current ?? '') ? current : shown[0]
794
795    return h(
796      Box,
797      { flexDirection: 'column' },
798      ...shown.map(name =>
799        h(Button, {
800          key: `${ROW}${name}`,
801          label: name,
802          plain: true,
803          dimColor: true,
804          ...(name === start ? { autoFocus: true } : {}),
805          // Closed first: the checks behind the status take seconds, and a pane that waits
806          // for them looks like a pick that did nothing.
807          onPress: async () => {
808            await $.ui.close({ id: PICKER })
809            await choose($, name)
810            await refresh($)
811          },
812        }),
813      ),
814      h(
815        Text,
816        { dimColor: true },
817        `${first + 1}–${Math.min(first + rows, found.length)} of ${found.length} · Enter to pick · Esc to close`,
818      ),
819    )
820  })
821
822  // The ring keeps its position in the pane, not its row: after the window slides, the ring is
823  // on the same line and that line shows the next project. So the window slides exactly when an
824  // arrow on the last line asks the ring to wrap to the first (or the reverse): the wrap is
825  // refused, the window moves by one, and the ring stays on its line, now showing the next
826  // project. The ring wraps for real only at the two ends of the whole list.
827  on('ui.focus', async ($, e, next) => {
828    const landed = e.element ?? (e as { key?: string }).key
829    if (e.requestId !== PICKER || !landed?.startsWith(ROW)) {
830      return next(e)
831    }
832    const rows = Math.min(await read($, windowRows), found.length)
833    const first = Math.min(await read($, top), lastTop(rows))
834    const line = found.indexOf(landed.slice(ROW.length)) - first
835
836    const isWrapDown = lastLine === rows - 1 && line === 0
837    const isWrapUp = lastLine === 0 && line === rows - 1
838    if (e.origin?.kind !== 'person' || rows < 2 || !(isWrapDown || isWrapUp)) {
839      lastLine = line
840
841      return next(e)
842    }
843    if (isWrapDown && first < lastTop(rows)) {
844      await update($, top, () => first + 1)
845    } else if (isWrapUp && first > 0) {
846      await update($, top, () => first - 1)
847    } else {
848      // An end of the whole list: go round to the other end.
849      await update($, top, () => (isWrapDown ? 0 : lastTop(rows)))
850      lastLine = isWrapDown ? 0 : rows - 1
851      void $.ui.focus({ requestId: PICKER, key: `${ROW}${found[isWrapDown ? 0 : found.length - 1]}` })
852    }
853
854    return { deny: 'the list moved to the next project' }
855  })
856
857  // A scroll in the picker means the tree did not fit the pane the layout granted. The window
858  // shrinks to the pane's real height, and the pane is held at its top.
859  on('ui.scroll', async ($, e, next) => {
860    if (e.requestId !== PICKER || e.origin.kind !== 'person') {
861      return next(e)
862    }
863    if (e.contentRows > e.bodyRows) {
864      await update($, windowRows, () => Math.max(2, e.bodyRows - 1))
865    }
866
867    return next({ ...e, offset: 0 })
868  })
869
870  on('command.run', { command: 'repro-status' }, async ($, e) => {
871    const wanted = (e.args ?? '').trim()
872    if (wanted === 'list') {
873      const names = await discover($)
874
875      return {
876        text:
877          `${names.length} research projects. Set one with /repro-status <number or ` +
878          `part of its name>:\n${names.map((name, at) => `  ${String(at + 1).padStart(2)}  ${name}`).join('\n')}`,
879      }
880    }
881    if (wanted === 'pick' || (!wanted && !project)) {
882      const names = await discover($)
883      const here = names.indexOf(project?.replace(/^.*\//, '') ?? '')
884      const rows = await read($, windowRows)
885      await update($, top, () => Math.min(Math.max(0, here - 1), lastTop(rows)))
886      lastLine = 0
887      await $.ui.open({
888        id: PICKER,
889        title: 'Research projects',
890        focus: true,
891        closeOnEscape: true,
892        holdToasts: true,
893        rows: rows + 1,
894      })
895
896      return {
897        text:
898          `${names.length} research projects. ↑ and ↓ to move, Enter to pick, Esc to close. ` +
899          `Or run /repro-status <part of a name>.`,
900      }
901    }
902    const said = wanted ? [await choose($, wanted)] : []
903    if (!project || said.some(line => !line.startsWith('Working project') && !line.startsWith('Following'))) {
904      return { text: said.join('\n') }
905    }
906    const lines = [...said, await status($, project, true)]
907    lastStatus = lines[said.length] ?? lastStatus
908    await show($)
909
910    return { text: lines.join('\n') }
911  })
912
913  // The readout is drawn every turn, and in a short window it takes rows the person may want back.
914  // A warning is not part of it: that line stays, because it only appears when something is wrong.
915  on('command.run', { command: 'repro-hide' }, async $ => {
916    await update($, hidden, () => true)
917
918    return { text: 'Readout hidden. /repro-show brings it back.' }
919  })
920
921  on('command.run', { command: 'repro-show' }, async $ => {
922    await update($, hidden, () => false)
923
924    return { text: 'Readout shown.' }
925  })
926
927  // The full quotation check. It reads every pinned source, which takes minutes on a large
928  // project, so it has its own command and `/repro-status` stays instant.
929  on('command.run', { command: 'repro-verify' }, async $ => {
930    if (!project) {
931      return { text: 'No working project. Run /repro-status <part of a name> first.' }
932    }
933    const checked: string[] = []
934    const folders = await claimsFolders($, project)
935    const count = (text: string | undefined) => Number((text ?? '0').replace(/,/g, ''))
936    let pinned = 0
937    let found = 0
938    let notFound = 0
939    for (const folder of folders) {
940      const ran = await $.process.run(['citations', 'verify', '--claims', folder], {
941        cwd: project,
942        timeoutMs: 300_000,
943      })
944      checked.push(
945        folder,
946        ...ran.stdout.split('\n').filter(line => /^\s+(found|not found|unchecked|ambiguous)\s/.test(line)),
947      )
948      pinned += count(/^([\d,]+) quotes?$/m.exec(ran.stdout)?.[1])
949      found += count(/^\s+found\s+([\d,]+)/m.exec(ran.stdout)?.[1])
950      notFound += count(/^\s+not found\s+([\d,]+)/m.exec(ran.stdout)?.[1])
951    }
952    if (pinned > 0) {
953      const root = project
954      const line = `${found.toLocaleString('en-US')}/${pinned.toLocaleString('en-US')} quotes found`
955      await update($, quotations, saved => ({ ...saved, [root]: line }))
956      await update($, quotationsNotFound, saved => ({ ...saved, [root]: notFound }))
957    }
958    if (folders.length === 0) {
959      checked.push('no claims folder, so no quotations are pinned')
960    }
961    const lines = [await status($, project, true), ...checked]
962    lastStatus = lines[0] ?? lastStatus
963    await show($)
964
965    return { text: lines.join('\n') }
966  })
967}
968
types/index.d.ts 25 lines
1/** The research project a session is working in: its folder, or null before one is touched. */
2export type WorkingProject = string | null
3
4declare module 'claude-code' {
5  interface PluginState {
6    'repro': {
7      project: WorkingProject
8      /** Whether the person named that project, so the tools' paths no longer move it. */
9      isPinned: boolean
10      /** Whether the person hid the readout above the prompt. */
11      isHidden: boolean
12      /** The index of the first project the picker's window shows. */
13      top: number
14      /** How many projects the picker's window shows. */
15      windowRows: number
16      /** The readout drawn in the band above the prompt. */
17      statusLine: string
18      /** The last full quotation check of each project, as `found/pinned found`, by folder. */
19      quotations: Record<string, string>
20      /** How many quotations that check reported as not found, by folder. */
21      quotationsNotFound: Record<string, number>
22    }
23  }
24}
25