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…

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.
| Refuses an edit to a frozen file | An 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 ledger | The 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 ledger | The same, once per project, when a command runs an analysis. |
| Notes a downloaded source | After 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 prompt | Seal 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 state | One dim row above the prompt, refreshed at the end of each turn, with one field per tool. |
| Warns | The pinned line under the prompt appears only when something is wrong, a few words per problem. |
/repro-status | The same readout on demand, with the sealed files hashed. Instant, and no model turn. |
/repro-verify | Checks 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-show | Hides the readout above the prompt, and shows it again. A warning still appears while it is hidden. |
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 freeze | a frozen PREREG.md no longer matches its digest |
ledger truncated, edited, corrupt | the chain does not verify |
ledger rewritten after timestamp | a timestamp proof contradicts the chain |
2 sealed files changed | after /repro-status, which hashes the sealed files |
16 runs, nothing sealed | runs are recorded and no input was sealed |
3 runs, no plan frozen | runs are recorded and no plan is frozen |
1 number mismatched | a number in the manuscript disagrees with the artifact repro.yaml binds it to |
1 quotation mismatched | the same, for a quote assertion |
2 claims mismatched | the same, where the kind could not be read off repro verify's lines |
1 quotation not found | after /repro-verify: a pinned quotation is not in its source |
1 claim on test runs | a 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.
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.
/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.
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.
hooks/register.ts 968 lines1import { 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}
968types/index.d.ts 25 lines1/** 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