A live casebook of the Evidence rea gives the agent while it reverse engineers a binary: Ghidra's cold start, findings that must cite real evidence_id values…

<h1 align="center">rea-casebook</h1>
A Claude Code mod that keeps a live casebook of what <a href="https://github.com/morluto/rea">rea</a> gives the agent while it reverse engineers a binary
<img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.296-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.296 or newer"> <img src="https://img.shields.io/badge/version-1.0.4-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">

rea answers each analysis call with an Evidence record: an evidence_id, what was asked, how sure it is (observed, derived or inferred) and where it comes from. Those records live in rea's memory for as long as the MCP connection lasts, and in the transcript they scroll away among the model's text. rea-casebook collects them in a pane as they arrive, makes the wait for Ghidra visible, and gives the model a tool to record findings that must cite Evidence rea actually delivered.
It needs rea registered as an MCP server named rea (its tools appear as mcp__rea__*). It works with any binary rea opens; it was built on Windows with rea 6.4.0 and Ghidra 12.1.
| Section | What it holds |
|---|---|
| Target | The binary open_binary opened: file name, format and architecture |
| Wait | While a rea call runs, how long it has been waiting. The first Ghidra-backed query imports and analyses the target and can take minutes; the pane says so |
| Cold start | How long that first query took, kept for the session |
| Findings | What the model recorded with record_finding, each with a mark for its confidence and the number of Evidence records it cites, plus how many were refused |
| Open unknowns | The questions rea holds as residual unknowns (record_unknown, update_unknown, list_unknowns) until they are resolved |
| Evidence | The latest records, newest first: a mark for the confidence, the operation and its main parameters, and the authority when it is not the binary itself |
Confidence is drawn as a mark that differs in shape as well as color: ● observed, ◐ derived, ○ inferred (a legend sits at the bottom of the pane). Long findings and questions take two lines at most; the export keeps them whole. The words stay in the theme's text color, and color goes to the marks.


The pane opens by itself the first time the agent opens a binary, and /casebook opens it again. A footer names the mod's version, so you know which code is drawing.
The mod adds one tool for the model, record_finding, with a claim, the evidence_id values that support it and a confidence (observed, derived or inferred). It records the finding only when every cited id came from a rea result in this session. An invented id, or one copied from another session, is refused, and the model reads which ids rea never delivered, so it can go back and get real Evidence. The refusals are counted in the pane.
The ids of the mutation Evidence that record_unknown and update_unknown return count as delivered too: "this is still unknown" is a finding that can cite them.
tool.call hook. The mod notes when it started, lets it run and reads the result the agent gets: an Evidence record, the target of open_binary, the unknowns of the session tools, or an error. The agent's result goes back untouched, and a failure inside the mod never refuses or repeats a rea call.$.state) and is drawn by the pane. A second open_binary keeps the Evidence of the first, so comparing two builds keeps both.~/.rea-casebook/<date>/<session>.jsonl: a line for the session and its counters, one for the target with its file name and SHA-256 (never its local path), and one per Evidence record, unknown and finding, in the order they happened. The journal and both exports stay in that folder; the casebook only leaves it when you ask Claude to publish the artifact page.| Command | What it does |
|---|---|
/casebook | Opens the pane |
/casebook export | Writes the casebook as Markdown beside the journal, to ~/.rea-casebook/<date>/<session>.md |
/casebook artifact | Writes the casebook as a page and gives you the request to ask Claude to publish it as a private claude.ai artifact; once it is published, the request names the same artifact |
The export has the target (name, format, architecture and SHA-256), the cold start, the findings with their Evidence, confidence and authority, the open unknowns and the Evidence list. It names the target by its file name, never by its local path. The command takes no file name: the export always goes to that one place.
The artifact is a page with the same sections, in light and dark themes, with every evidence_id whole. The mod writes it next to the journal and answers with a request for you to give Claude, such as Publish <page> with the Artifact tool as a new private artifact …. Claude then publishes it in a visible turn, where you decide as for any other call. Once Claude has published it, the next request names that artifact, so it is updated in place. Publishing needs a session that offers the Artifact tool, such as the Claude desktop app.
In /config, under rea-casebook, Language is English (the default) or Español. It sets the pane, the export and what Claude reads when a finding is refused. The tool's description stays in English, the language the model reads best.
The pane needs no rules: the mod reads the results of rea calls that your own rules already allowed, and calls nothing by itself.
record_finding is called by the model, so it goes through your permissions like any other tool: Claude Code may ask you each time, or the classifier may decide in auto mode. It only writes to the pane, so it is worth allowing in .claude/settings.json (the project's) or ~/.claude/settings.json.
When Claude publishes the artifact for you, it first reads the page the mod wrote under ~/.rea-casebook/, outside your project, and Claude Code asks about that read. The second rule below allows reads of that folder only, so the publish does not stop there:
{
"permissions": {
"allow": ["mcp__rea-casebook__record_finding", "Read(~/.rea-casebook/**)"]
}
}
The mod writes files through the engine, not through your rules: the journal under ~/.rea-casebook/ after each change, and the Markdown export and the artifact page beside it when you ask for them. /casebook artifact never publishes by itself: Claude makes the Artifact call when you ask it to, and your rules apply.
Everything the mod does on its own, for anyone who reviews it before installing.
| Hook | What it does |
|---|---|
tool.call on rea's tools (mcp__rea__*) | Reads each result to update the casebook and returns what next gives back, unchanged. It never refuses, repeats or rewrites a rea call |
tool.call on Artifact | When a call publishes the casebook page, reads the artifact URL from its result. It returns what next gives back, unchanged, for every Artifact call |
tool.call on mcp__rea-casebook__record_finding | Stands in for one tool only, the mod's own record_finding, on every call: this hook is that tool's implementation, so it answers without next. It records the finding or refuses it, as described above. It stands in for no other tool |
command.run on /casebook | Opens the pane or writes one of the two exports |
ui.render on the pane | Draws the casebook |
session.start | Registers record_finding, the command and a one-second timer that only redraws the waiting counter |
It writes exactly three files, all in one folder under your home folder, ~/.rea-casebook/<date>/, named after the session and with a fixed extension:
| File | When |
|---|---|
<session>.jsonl, the journal | After each change to the casebook |
<session>.md, the Markdown export | When you run /casebook export |
<session>.html, the artifact page | When you run /casebook artifact |
No command takes a path, and the session id in the name is cut to letters, digits, _ and -, so a file never lands outside that folder. The mod writes nothing in your project, and it never writes or edits a build, start-up, settings or instructions file.
USERPROFILE (Windows) or HOME (macOS and Linux), only to find your home folder for the three files above, because the engine gives a mod no other way to find it. Neither variable is a credential, and the mod reads no token, key or password.record_finding is called by the model./casebook artifact shows you a request to give Claude; it does not send it.PRIVACY.md sums this up as a privacy notice.
Inside a Claude Code session:
/plugin install rea-casebook --marketplace pablodiazjorge/rea-casebook
Or in a terminal:
claude plugin marketplace add pablodiazjorge/rea-casebook
claude plugin install rea-casebook@rea-casebook
Reading rea's results, the casebook's state, the journal, both exports and the words are plain modules tested directly. The hooks are tested against a simulated engine whose rea answers with real rea 6.4.0 results, with local paths replaced (tests/fixtures/rea.ts).
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
CI runs both on Ubuntu, macOS and Windows with Claude Code 2.1.296.
To try a change in a real session, start Claude Code with the folder as a plugin directory; it reloads the mod when its files change:
claude --plugin-dir .
Version 1.0: the pane, findings that must cite real Evidence, the session journal and the Markdown and artifact exports, tested on Ubuntu, macOS and Windows and used in real sessions with rea 6.4.0. Since 1.0.3 the mod calls no tool and submits no prompt by itself: /casebook artifact writes the page and you ask Claude to publish it. Since 1.0.4 every file it writes goes to ~/.rea-casebook/, and /casebook export takes no file name. The design is recorded in rea-lab (ADRs 0015 to 0017).
hooks/register.tsx 359 lines1// rea-casebook: a pane with what rea delivered to the agent in this session: the target, the wait for
2// Ghidra's cold start, the Evidence as it arrives, the open unknowns and the findings the model
3// records with record_finding, a tool that refuses any evidence_id rea never delivered.
4//
5// It reads rea's results as they pass through `tool.call` (ADR 0015 of rea-lab) and never calls rea
6// on its own, so it needs no permission beyond the person's own rules for rea. It calls no tool and
7// submits no prompt by itself either. Each change rewrites the session's journal under
8// ~/.rea-casebook/ (ADR 0016); `/casebook export` writes the casebook beside it as Markdown, and
9// `/casebook artifact` writes a page beside it that the person asks Claude to publish (ADR 0017).
10// Those three files are all it writes: nothing in the person's project, and no path anyone names.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register, RenderInput, ToolCallResult } from 'claude-code'
14
15import type { CaseState } from '../types'
16import { callEnded, callStarted, EMPTY_CASE, type FindingInput, type FindingOutcome, findingRefused, isQuick, oldestPending, recordFinding } from './casebook'
17import { baseName, readReaResult, type ReaOutcome, savedText } from './evidence'
18import { caseToHtml } from './html'
19import { casebookBase, hasCase, journalLines } from './journal'
20import { caseToMarkdown } from './markdown'
21import { clip, formatSeconds, type Lang, langOf, words } from './messages'
22
23const PANE = 'rea-casebook'
24const VERSION = '1.0.4'
25const REA = /^mcp__rea__/u
26const FINDING_TOOL = 'mcp__rea-casebook__record_finding'
27const TIMELINE_MAX = 40
28
29const casebook = atom({ plugin: 'rea-casebook', key: 'case' } as const, EMPTY_CASE)
30const now = atom({ plugin: 'rea-casebook', key: 'now' } as const, 0)
31
32let lang: Lang = 'en'
33// Opens the pane by itself once, at the first binary; reset on a reload, which is harmless.
34let isAutoOpened = false
35// Journal writes run one after another, so an older casebook never overwrites a newer one.
36let journalQueue: Promise<void> = Promise.resolve()
37// The page `/casebook artifact` last wrote: an Artifact publish of this file is the casebook's.
38let artifactPage: string | null = null
39
40// ---------------------------------------------------------------------------
41// Engine helpers
42
43/** A result too large for the client lands in a file; the Evidence is read from there. */
44async function readSaved($: EngineInterface, tool: string, path: string, at: number): Promise<ReaOutcome> {
45 try {
46 const raw = await $.fs.read(path)
47 return typeof raw === 'string' ? readReaResult(tool, savedText(raw), false, at) : { kind: 'other' }
48 } catch {
49 return { kind: 'other' }
50 }
51}
52
53async function openPane($: EngineInterface): Promise<void> {
54 await $.ui.open({ id: PANE, title: words(lang).name })
55}
56
57/** The home folder, read only from USERPROFILE (Windows) or HOME (macOS and Linux). */
58async function homeOf($: EngineInterface): Promise<string> {
59 return (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
60}
61
62/** This session's files under ~/.rea-casebook/, without extension, or null before a rea call. */
63async function sessionBase($: EngineInterface, state: CaseState): Promise<string | null> {
64 const home = await homeOf($)
65 if (!home || state.startedAt === null) return null
66 return casebookBase(home, await $.session.id(), state.startedAt)
67}
68
69async function writeJournal($: EngineInterface): Promise<void> {
70 const state = await read($, casebook)
71 if (!hasCase(state)) return
72 const base = await sessionBase($, state)
73 if (base) await $.fs.write(`${base}.jsonl`, journalLines(state, await $.session.id(), VERSION))
74}
75
76/** Queues a rewrite of the journal; a failed write never reaches rea or the model. */
77function saveJournal($: EngineInterface): void {
78 journalQueue = journalQueue.then(() => writeJournal($)).catch(() => undefined)
79}
80
81const failed = (result: ToolCallResult) => result.deny !== undefined || result.isError === true
82
83/** The artifact URL in an Artifact result: its own field, or the first claude.ai link in its text. */
84function artifactUrlOf(result: ToolCallResult): string | null {
85 const own = (result.result as { url?: unknown } | undefined)?.url
86 if (typeof own === 'string' && own.startsWith('https://')) return own
87 return /https:\/\/claude\.ai\/[^\s"'<>)]+/u.exec(result.text ?? '')?.[0] ?? null
88}
89
90const samePath = (left: string, right: string) => left.replace(/\\/gu, '/').toLowerCase() === right.replace(/\\/gu, '/').toLowerCase()
91
92async function keepArtifactUrl($: EngineInterface, url: string): Promise<void> {
93 await update($, casebook, (state) => ({ ...state, artifactUrl: url }))
94 saveJournal($)
95}
96
97/**
98 * Writes the casebook page and answers with the request to give Claude. The mod never publishes it
99 * itself: the person asks Claude, in a turn where their permissions decide as for any other call.
100 * Once Claude has published the page, the request names that artifact so it is updated in place.
101 */
102async function writeArtifactPage($: EngineInterface): Promise<string> {
103 const t = words(lang)
104 const state = await read($, casebook)
105 if (!hasCase(state)) return t.caseEmpty
106 const base = await sessionBase($, state)
107 if (!base) return t.exportFailed('~/.rea-casebook')
108 const page = `${base}.html`
109 try {
110 await $.fs.write(page, caseToHtml(state, lang, VERSION))
111 } catch {
112 return t.exportFailed(page)
113 }
114 artifactPage = page
115 const target = state.target ? baseName(state.target.path) : '-'
116 return t.artifactWritten(page, t.artifactRequest(page, state.artifactUrl, t.artifactDescription(target)))
117}
118
119/** Writes the Markdown export beside the journal, `~/.rea-casebook/<day>/<session>.md`, and answers. */
120async function exportCase($: EngineInterface): Promise<string> {
121 const t = words(lang)
122 const state = await read($, casebook)
123 if (!hasCase(state)) return t.caseEmpty
124 const base = await sessionBase($, state)
125 if (!base) return t.exportFailed('~/.rea-casebook')
126 try {
127 await $.fs.write(`${base}.md`, caseToMarkdown(state, lang))
128 return t.exported(`${base}.md`)
129 } catch {
130 return t.exportFailed(`${base}.md`)
131 }
132}
133
134// ---------------------------------------------------------------------------
135// Drawing
136
137// Every word is drawn in the theme's `text` color, named on each Text: the only color with a 7:1
138// contrast in every theme. Text left uncolored, or dimmed, does not follow the theme in the terminal
139// (light gray on a light theme), so neither is used. Hierarchy comes from bold and spacing.
140const WORDS = 'text'
141// One mark per confidence, told apart by shape as well as color. A mutation record (no confidence)
142// gets a hollow diamond in the text color.
143const MARKS = {
144 observed: { icon: '●', color: 'success' },
145 derived: { icon: '◐', color: 'suggestion' },
146 inferred: { icon: '○', color: 'warning' },
147} as const
148const markOf = (confidence: string) => MARKS[confidence as keyof typeof MARKS] ?? { icon: '◇', color: WORDS }
149// Static analysis reads the binary itself; only another authority is worth a word on each line.
150const USUAL_AUTHORITY = 'shipped-artifact'
151
152async function drawPane($: EngineInterface, e: RenderInput, columns: number) {
153 const { Box, Text } = $.ui.resolve(e)
154 const t = words(lang)
155 const state: CaseState = await read($, casebook)
156 const at = Math.max(await read($, now), await $.clock.now())
157 const waiting = oldestPending(state)
158 const open = state.unknowns.filter((unknown) => unknown.status !== 'resolved')
159 const timeline = state.evidence.slice(-TIMELINE_MAX).reverse()
160 // Findings and questions take two lines at most, so the Evidence stays in view; the export has them whole.
161 const twoLines = Math.max(40, columns * 2 - 12)
162
163 return (
164 <Box flexDirection="column">
165 <Text color={WORDS} bold wrap="truncate-end">
166 {state.target ? t.target(baseName(state.target.path), state.target.format, state.target.architecture) : t.noTarget}
167 </Text>
168 {waiting ? (
169 <Text color={WORDS} wrap="wrap">
170 <Text color="warning">{'◷ '}</Text>
171 {(state.coldStartMs === null && !isQuick(waiting.tool) ? t.ghidraWaiting : t.waiting)(waiting.tool, formatSeconds(at - waiting.since))}
172 </Text>
173 ) : null}
174 {state.coldStartMs !== null ? <Text color={WORDS}>{t.coldStart(formatSeconds(state.coldStartMs))}</Text> : null}
175
176 <Box flexDirection="column" marginTop={1}>
177 <Text color={WORDS} bold>{t.findings(state.findings.length)}</Text>
178 {state.findings.length === 0 ? <Text color={WORDS}>{t.noFindings}</Text> : null}
179 {state.findings.map((finding) => {
180 const mark = markOf(finding.confidence)
181 const tail = ` (${finding.evidenceIds.length} ev)`
182 return (
183 <Text key={finding.id} color={WORDS} wrap="wrap">
184 <Text color={WORDS} bold>{`${finding.id} `}</Text>
185 <Text color={mark.color}>{`${mark.icon} `}</Text>
186 {`${clip(finding.claim, twoLines - finding.id.length - 3 - tail.length)}${tail}`}
187 </Text>
188 )
189 })}
190 {state.refused > 0 ? (
191 <Text color={WORDS} wrap="wrap">
192 <Text color="error">{'✕ '}</Text>
193 {t.refused(state.refused)}
194 </Text>
195 ) : null}
196 </Box>
197
198 {open.length > 0 ? (
199 <Box flexDirection="column" marginTop={1}>
200 <Text color={WORDS} bold>{t.unknowns(open.length)}</Text>
201 {open.map((unknown) => (
202 <Text key={unknown.id} color={WORDS} wrap="wrap">
203 <Text color="warning">{'? '}</Text>
204 {`${clip(unknown.question, twoLines - 2)}${unknown.status && unknown.status !== 'open' ? ` [${unknown.status}]` : ''}`}
205 </Text>
206 ))}
207 </Box>
208 ) : null}
209
210 <Box flexDirection="column" marginTop={1}>
211 <Text color={WORDS} bold>{t.timeline(state.evidence.length)}</Text>
212 {timeline.length === 0 ? <Text color={WORDS}>{t.noEvidence}</Text> : null}
213 {timeline.map((item) => {
214 const mark = markOf(item.confidence)
215 const authority = item.authority && item.authority !== USUAL_AUTHORITY ? ` - ${item.authority}` : ''
216 return (
217 <Text key={item.id} color={WORDS} wrap="truncate-end">
218 <Text color={mark.color}>{`${mark.icon} `}</Text>
219 {`${`${item.operation} ${item.detail}`.trim()}${authority}`}
220 </Text>
221 )
222 })}
223 </Box>
224
225 {/* The marks' legend, once there is something to read it against, and the version drawing the pane. */}
226 <Box flexDirection="column" marginTop={1}>
227 {state.evidence.length > 0 ? (
228 <Text color={WORDS}>
229 <Text color={MARKS.observed.color}>{`${MARKS.observed.icon} `}</Text>
230 {`${t.confidence.observed} `}
231 <Text color={MARKS.derived.color}>{`${MARKS.derived.icon} `}</Text>
232 {`${t.confidence.derived} `}
233 <Text color={MARKS.inferred.color}>{`${MARKS.inferred.icon} `}</Text>
234 {t.confidence.inferred}
235 </Text>
236 ) : null}
237 <Text color={WORDS}>{`rea-casebook v${VERSION}`}</Text>
238 </Box>
239 </Box>
240 )
241}
242
243// ---------------------------------------------------------------------------
244// Hooks
245
246export const register: Register = (on, options) => {
247 lang = langOf(options?.language)
248
249 on('session.start', async ($, e, next) => {
250 const started = await next(e)
251 const t = words(lang)
252 await $.tool.register({
253 name: 'record_finding',
254 description: t.toolDescription,
255 inputSchema: {
256 type: 'object',
257 properties: {
258 claim: { type: 'string', description: t.claimField },
259 evidence_ids: { type: 'array', minItems: 1, items: { type: 'string', pattern: '^ev_[0-9a-f]{64}$' }, description: t.evidenceField },
260 confidence: { type: 'string', enum: ['observed', 'derived', 'inferred'], description: t.confidenceField },
261 },
262 required: ['claim', 'evidence_ids'],
263 },
264 isDeferred: false,
265 })
266 await $.command.register({ name: 'casebook', description: t.command })
267
268 // Ticks the Ghidra counter while a rea call runs; quiet otherwise.
269 $.clock.every(1000, () => {
270 void (async () => {
271 if ((await read($, casebook)).pending.length === 0) return
272 const at = await $.clock.now()
273 await update($, now, () => at)
274 })()
275 })
276 return started
277 })
278
279 // Every rea call: note the start, let it run, read what came back. The result goes back untouched.
280 on('tool.call', { tool: REA }, async ($, e, next) => {
281 const tool = e.tool.replace(REA, '')
282 const id = e.tool_use_id
283 const since = await $.clock.now()
284 await update($, casebook, (state) => callStarted(state, id, tool, since))
285 let ran: Awaited<ReturnType<typeof next>>
286 try {
287 ran = await next(e)
288 } catch (error) {
289 const at = await $.clock.now()
290 await update($, casebook, (state) => callEnded(state, id, tool, { kind: 'error', message: String(error) }, at))
291 throw error
292 }
293 try {
294 const at = await $.clock.now()
295 const text = ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
296 let outcome = readReaResult(tool, text, ran.isError === true, at)
297 if (outcome.kind === 'saved') outcome = await readSaved($, tool, outcome.path, at)
298 await update($, casebook, (state) => callEnded(state, id, tool, outcome, at))
299 saveJournal($)
300 if (outcome.kind === 'opened' && !isAutoOpened) {
301 isAutoOpened = true
302 void openPane($)
303 }
304 } catch {
305 // The casebook never stands between rea and the agent.
306 }
307 return ran
308 }).catch(($, e, next) => next(e)) // a failure here never refuses a rea call, nor runs it twice
309
310 // The model's findings. When the hook fails, the .catch refuses with a reason instead of no answer.
311 on('tool.call', { tool: FINDING_TOOL }, async ($, e) => {
312 const t = words(lang)
313 const at = await $.clock.now()
314 const input = e as unknown as FindingInput
315 let outcome: FindingOutcome = { ok: false, reason: 'invalid', problems: [] }
316 await update($, casebook, (state) => {
317 outcome = recordFinding(state, input, at)
318 if (outcome.ok) return outcome.state
319 return outcome.reason === 'unknown_evidence' ? findingRefused(state) : state
320 })
321 const result = outcome as FindingOutcome
322 if (result.ok || result.reason === 'unknown_evidence') saveJournal($)
323 if (result.ok) return { result: t.findingRecorded(result.finding.id) }
324 return { deny: result.reason === 'unknown_evidence' ? t.unknownEvidence(result.missing) : t.invalidFinding(result.problems) }
325 }).catch(() => ({ deny: words(lang).findingFailed }))
326
327 on('command.run', { command: 'casebook' }, async ($, e) => {
328 const t = words(lang)
329 const [verb = '', ...rest] = e.args.trim().split(/\s+/u)
330 if (verb === '') {
331 await openPane($)
332 return { text: t.opened }
333 }
334 if (verb === 'export' && rest.length === 0) return { text: await exportCase($) }
335 if (verb === 'artifact' && rest.length === 0) return { text: await writeArtifactPage($) }
336 return { text: t.usage }
337 })
338
339 // When Claude publishes the casebook's page (because the person asked it to, with the request
340 // `/casebook artifact` gave), keep the URL so the next request names the same artifact. This only
341 // reads the result: any Artifact call, this one included, passes through untouched.
342 on('tool.call', { tool: 'Artifact' }, async ($, e, next) => {
343 const ran = await next(e)
344 try {
345 const input = e as unknown as { action?: unknown; asset?: unknown; file_path?: unknown }
346 const isPagePublish = (input.action === undefined || input.action === 'publish') && input.asset !== true
347 if (isPagePublish && typeof input.file_path === 'string' && artifactPage && samePath(input.file_path, artifactPage) && !failed(ran)) {
348 const url = artifactUrlOf(ran)
349 if (url) await keepArtifactUrl($, url)
350 }
351 } catch {
352 // Watching never changes what the Artifact call returns.
353 }
354 return ran
355 }).catch(($, e, next) => next(e))
356
357 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => drawPane($, e, e.props.bodyColumns))
358}
359hooks/casebook.ts 103 lines1// The casebook's state changes, as pure functions over CaseState. No `$`: tested directly.
2
3import type { CaseState, EvidenceItem, Finding, Target, Unknown } from '../types'
4import type { ReaOutcome } from './evidence'
5import { clip } from './messages'
6
7export const EMPTY_CASE: CaseState = {
8 target: null,
9 pending: [],
10 coldStartMs: null,
11 evidence: [],
12 unknowns: [],
13 findings: [],
14 refused: 0,
15 startedAt: null,
16 artifactUrl: null,
17}
18
19// A finding may cite only what is kept, so the cap is far above a long session's few hundred calls.
20const MAX_EVIDENCE = 2000
21const CONFIDENCE = new Set(['observed', 'derived', 'inferred'])
22const EVIDENCE_ID = /^ev_[0-9a-f]{64}$/u
23
24/** A rea call starts. */
25export const callStarted = (state: CaseState, id: string, tool: string, at: number): CaseState => ({
26 ...state,
27 pending: [...state.pending.filter((call) => call.id !== id), { id, tool, since: at }],
28 startedAt: state.startedAt ?? at,
29})
30
31/** Tools that never wait for Ghidra: their duration says nothing about the cold start. */
32const QUICK = new Set(['open_binary', 'close_binary', 'binary_session', 'get_evidence_bundle'])
33export const isQuick = (tool: string) => QUICK.has(tool)
34
35/** A rea call ends; its outcome updates the target, the evidence, the unknowns and the cold start. */
36export function callEnded(state: CaseState, id: string, tool: string, outcome: ReaOutcome, at: number): CaseState {
37 const started = state.pending.find((call) => call.id === id)
38 const next: CaseState = { ...state, pending: state.pending.filter((call) => call.id !== id) }
39 // A second open_binary keeps what came before: comparing two builds needs the Evidence of both.
40 if (outcome.kind === 'opened') return { ...next, target: outcome.target }
41 if (outcome.kind === 'closed') return { ...next, pending: [] }
42 if (outcome.kind === 'unknowns') {
43 // The mutation Evidence reached the model too, so a finding may cite it.
44 const added = outcome.mutations.map(
45 (mutation): EvidenceItem => ({ id: mutation.id, operation: tool, confidence: '', authority: '', detail: `question=${clip(mutation.question, 40)}`, limitations: 0, at }),
46 )
47 return { ...next, evidence: addEvidence(next.evidence, added), unknowns: outcome.unknowns.reduce(upsertUnknown, next.unknowns) }
48 }
49 if (outcome.kind !== 'evidence') return next
50 const isFirstAnalysis = next.coldStartMs === null && !QUICK.has(tool) && started !== undefined
51 return {
52 ...next,
53 evidence: addEvidence(next.evidence, [outcome.item]),
54 coldStartMs: isFirstAnalysis ? at - started.since : next.coldStartMs,
55 }
56}
57
58/** Adds the records not yet kept, oldest dropped past the cap. */
59function addEvidence(list: EvidenceItem[], items: EvidenceItem[]): EvidenceItem[] {
60 const kept = new Set(list.map((item) => item.id))
61 const added = items.filter((item) => !kept.has(item.id) && kept.add(item.id))
62 return added.length === 0 ? list : [...list, ...added].slice(-MAX_EVIDENCE)
63}
64
65const upsertUnknown = (list: Unknown[], unknown: Unknown) => [...list.filter((item) => item.id !== unknown.id), unknown]
66
67/** The input of the record_finding tool, as the model sends it. */
68export type FindingInput = { claim?: unknown; evidence_ids?: unknown; confidence?: unknown }
69
70export type FindingOutcome =
71 | { ok: true; state: CaseState; finding: Finding }
72 | { ok: false; reason: 'invalid'; problems: string[] }
73 | { ok: false; reason: 'unknown_evidence'; missing: string[] }
74
75/**
76 * Records a finding only when every evidence_id it cites was delivered by rea in this session, so the
77 * model cannot back a claim with an invented or foreign id.
78 */
79export function recordFinding(state: CaseState, input: FindingInput, at: number): FindingOutcome {
80 const problems: string[] = []
81 const claim = typeof input.claim === 'string' ? input.claim.trim() : ''
82 if (!claim) problems.push('claim')
83 const ids = Array.isArray(input.evidence_ids) ? input.evidence_ids.filter((id): id is string => typeof id === 'string') : []
84 if (ids.length === 0 || ids.some((id) => !EVIDENCE_ID.test(id))) problems.push('evidence_ids')
85 const confidence = typeof input.confidence === 'string' ? input.confidence : 'derived'
86 if (!CONFIDENCE.has(confidence)) problems.push('confidence')
87 if (problems.length > 0) return { ok: false, reason: 'invalid', problems }
88 const delivered = new Set(state.evidence.map((item) => item.id))
89 const missing = [...new Set(ids)].filter((id) => !delivered.has(id))
90 if (missing.length > 0) return { ok: false, reason: 'unknown_evidence', missing }
91 const finding: Finding = { id: `F${state.findings.length + 1}`, claim, evidenceIds: [...new Set(ids)], confidence, at }
92 return { ok: true, state: { ...state, findings: [...state.findings, finding] }, finding }
93}
94
95/** Counts a refused finding, for the pane. */
96export const findingRefused = (state: CaseState): CaseState => ({ ...state, refused: state.refused + 1 })
97
98/** The longest-running pending call, for the pane's Ghidra counter. */
99export const oldestPending = (state: CaseState) =>
100 state.pending.reduce<CaseState['pending'][number] | null>((oldest, call) => (oldest === null || call.since < oldest.since ? call : oldest), null)
101
102export const targetLabel = (target: Target | null) => target?.path.split(/[\\/]/u).pop() ?? null
103hooks/evidence.ts 108 lines1// Reads what a rea MCP call returned into something the casebook can keep (ADR 0015 of rea-lab).
2// No `$`: tested directly.
3
4import type { EvidenceItem, Target, Unknown } from '../types'
5
6/** What one rea result means for the casebook. */
7export type ReaOutcome =
8 | { kind: 'evidence'; item: EvidenceItem }
9 // record_unknown, update_unknown and list_unknowns answer with the unknowns themselves, not an
10 // Evidence record; each revision names the mutation Evidence rea stored for it.
11 | { kind: 'unknowns'; unknowns: Unknown[]; mutations: Mutation[] }
12 | { kind: 'opened'; target: Target }
13 | { kind: 'closed' }
14 | { kind: 'saved'; path: string }
15 | { kind: 'error'; message: string }
16 | { kind: 'other' }
17
18/** A mutation Evidence id and the question of the unknown it belongs to. */
19export type Mutation = { id: string; question: string }
20
21const EVIDENCE_ID = /^ev_[0-9a-f]{64}$/u
22// Claude Code may append a reminder block to tool results; it is not part of what rea returned.
23const REMINDER = /\s*<system-reminder>[\s\S]*<\/system-reminder>\s*$/u
24// A large result is replaced by a note that names the file where the client saved it.
25const SAVED = /exceeds maximum allowed tokens\. Output has been saved to (.+?\.txt)\./u
26
27type Json = Record<string, unknown>
28const isObject = (value: unknown): value is Json => typeof value === 'object' && value !== null && !Array.isArray(value)
29const text = (value: unknown): string => (typeof value === 'string' ? value : '')
30
31/** Parses a result text as one closed JSON value, or returns undefined. */
32export function closedJson(raw: string): unknown {
33 try {
34 return JSON.parse(raw.replace(REMINDER, ''))
35 } catch {
36 return undefined
37 }
38}
39
40/** The call's scalar parameters, short, for one line of the timeline. */
41export function detailOf(parameters: unknown): string {
42 if (!isObject(parameters)) return ''
43 return Object.entries(parameters)
44 .filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value))
45 .slice(0, 2)
46 .map(([key, value]) => `${key}=${String(value).slice(0, 40)}`)
47 .join(' ')
48}
49
50const isUnknown = (value: unknown): value is Json => isObject(value) && typeof value.unknown_id === 'string' && typeof value.question === 'string'
51
52/** The residual unknowns in a session tool's `result`: one record, or a page of them under `items`. */
53function unknownsOf(result: unknown): Json[] {
54 if (isUnknown(result)) return [result]
55 if (isObject(result) && Array.isArray(result.items)) return result.items.filter(isUnknown)
56 return []
57}
58
59/**
60 * The text of a result the client saved to a file: the content blocks' text joined when the file
61 * holds them as JSON, the file's text as it is otherwise.
62 */
63export function savedText(raw: string): string {
64 const parsed = closedJson(raw)
65 if (!Array.isArray(parsed) || !parsed.every((block) => isObject(block) && typeof block.text === 'string')) return raw
66 return parsed.map((block) => String((block as Json).text)).join('')
67}
68
69/** Reads one rea result. `tool` is the rea tool name without the mcp__rea__ prefix. */
70export function readReaResult(tool: string, resultText: string, isError: boolean, at: number): ReaOutcome {
71 if (isError) return { kind: 'error', message: resultText.slice(0, 200) }
72 const saved = SAVED.exec(resultText)?.[1]
73 if (saved) return { kind: 'saved', path: saved }
74 const payload = closedJson(resultText)
75 if (!isObject(payload)) return { kind: 'other' }
76 if (typeof payload.evidence_id === 'string' && EVIDENCE_ID.test(payload.evidence_id) && typeof payload.operation === 'string') {
77 const item: EvidenceItem = {
78 id: payload.evidence_id,
79 operation: payload.operation,
80 confidence: text(payload.confidence),
81 authority: text(payload.authority),
82 detail: detailOf(payload.parameters),
83 limitations: Array.isArray(payload.limitations) ? payload.limitations.length : 0,
84 at,
85 }
86 return { kind: 'evidence', item }
87 }
88 if (tool === 'open_binary' && isObject(payload.result) && typeof payload.result.path === 'string') {
89 const result = payload.result
90 return { kind: 'opened', target: { path: text(result.path), sha256: text(result.sha256), format: text(result.format), architecture: text(result.architecture) } }
91 }
92 const records = unknownsOf(payload.result)
93 if (records.length > 0) {
94 const unknowns = records.map((record) => ({ id: text(record.unknown_id), question: text(record.question), status: text(record.status), severity: text(record.severity), at }))
95 const mutations = records.flatMap((record) =>
96 (Array.isArray(record.mutation_evidence_ids) ? record.mutation_evidence_ids : [])
97 .filter((id): id is string => typeof id === 'string' && EVIDENCE_ID.test(id))
98 .map((id) => ({ id, question: text(record.question) })),
99 )
100 return { kind: 'unknowns', unknowns, mutations }
101 }
102 if (tool === 'close_binary') return { kind: 'closed' }
103 return { kind: 'other' }
104}
105
106/** The file name of a path, for display; the full path stays in the state. */
107export const baseName = (path: string) => path.split(/[\\/]/u).pop() ?? path
108hooks/html.ts 117 lines1// The casebook as one self-contained HTML page, for `/casebook artifact` (ADR 0017 of rea-lab): the
2// same sections as the Markdown export, with light and dark themes. Every color keeps a 7:1 contrast
3// on its background, the marks included, and no local path is written. The artifact service wraps
4// the page in its own document skeleton, so the page starts at its <title> and <style>, with no
5// doctype, html, head or body of its own. No `$`: tested directly.
6
7import type { CaseState } from '../types'
8import { formatSeconds, words, type Lang } from './messages'
9
10const escape = (value: string) =>
11 value.replace(/&/gu, '&').replace(/</gu, '<').replace(/>/gu, '>').replace(/"/gu, '"').replace(/'/gu, ''')
12
13const shortId = (id: string) => `<code title="${escape(id)}">${escape(id.slice(0, 19))}</code>`
14
15const MARKS: Record<string, string> = { observed: '●', derived: '◐', inferred: '○' }
16
17const STYLE = `
18:root { --bg: #ffffff; --surface: #f6f8fa; --text: #111111; --muted: #3d3d3d; --line: #d0d7de; --code: #eef1f4;
19 --observed: #116329; --derived: #0a3d91; --inferred: #7a4b00; color-scheme: light; }
20@media (prefers-color-scheme: dark) {
21 :root:not([data-theme="light"]) { --bg: #0d1117; --surface: #161b22; --text: #f0f3f6; --muted: #c9d1d9; --line: #30363d;
22 --code: #1f2630; --observed: #56d364; --derived: #79c0ff; --inferred: #e3b341; color-scheme: dark; }
23}
24:root[data-theme="dark"] { --bg: #0d1117; --surface: #161b22; --text: #f0f3f6; --muted: #c9d1d9; --line: #30363d;
25 --code: #1f2630; --observed: #56d364; --derived: #79c0ff; --inferred: #e3b341; color-scheme: dark; }
26* { box-sizing: border-box; }
27body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
28main { max-width: 1040px; margin: 0 auto; padding-inline: 16px; padding-block: 24px 48px; }
29h1 { font-size: 1.6rem; margin: 0 0 6px; }
30h2 { font-size: 1.15rem; margin: 32px 0 10px; }
31p { margin: 0 0 6px; }
32.muted { color: var(--muted); }
33.table { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; }
34table { border-collapse: collapse; width: 100%; min-width: 680px; font-size: 0.95rem; font-variant-numeric: tabular-nums; }
35th, td { text-align: left; vertical-align: top; padding: 8px 10px; border-bottom: 1px solid var(--line); }
36th { background: var(--surface); font-weight: 600; white-space: nowrap; }
37tr:last-child td { border-bottom: 0; }
38code { font: 0.85rem/1.4 ui-monospace, "Cascadia Mono", Consolas, monospace; background: var(--code); padding: 1px 4px; border-radius: 4px; overflow-wrap: anywhere; }
39.mark { font-weight: 700; }
40.observed { color: var(--observed); } .derived { color: var(--derived); } .inferred { color: var(--inferred); }
41.legend { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 12px; color: var(--muted); }
42footer { margin-top: 32px; color: var(--muted); font-size: 0.9rem; }
43`
44
45export function caseToHtml(state: CaseState, lang: Lang, version: string): string {
46 const w = words(lang)
47 const c = w.markdownColumns
48 const target = state.target?.path.split(/[\\/]/u).pop() ?? '-'
49 const confidence = (value: string) => {
50 const name = w.confidence[value as keyof typeof w.confidence]
51 return name ? `<span class="mark ${value}" aria-hidden="true">${MARKS[value]}</span> ${escape(name)}` : '-'
52 }
53 const authorityOf = new Map(state.evidence.map((item) => [item.id, item.authority]))
54 const none = `<p class="muted">${escape(w.markdownNone)}</p>`
55 const table = (head: string[], rows: string[][]) =>
56 `<div class="table"><table><thead><tr>${head.map((cell) => `<th>${escape(cell)}</th>`).join('')}</tr></thead><tbody>${rows
57 .map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`)
58 .join('')}</tbody></table></div>`
59
60 const sections: string[] = [`<h1>${escape(w.markdownTitle(target))}</h1>`, `<p class="muted">${escape(w.htmlIntro)}</p>`]
61 if (state.target) {
62 sections.push(
63 `<p>${escape(`${state.target.format} ${state.target.architecture}`)} - SHA-256 <code>${escape(state.target.sha256)}</code></p>`,
64 state.coldStartMs !== null ? `<p class="muted">${escape(w.coldStart(formatSeconds(state.coldStartMs)))}</p>` : '',
65 )
66 }
67 sections.push(`<h2>${escape(w.markdownFindings)}</h2>`)
68 sections.push(
69 state.findings.length === 0
70 ? none
71 : table(
72 ['#', c.confidence, c.finding, c.evidence, c.authority],
73 state.findings.map((finding) => [
74 `<strong>${escape(finding.id)}</strong>`,
75 confidence(finding.confidence),
76 escape(finding.claim),
77 finding.evidenceIds.map(shortId).join(' '),
78 escape([...new Set(finding.evidenceIds.map((id) => authorityOf.get(id) || '-'))].join(', ')),
79 ]),
80 ),
81 )
82 sections.push(`<h2>${escape(w.markdownUnknowns)}</h2>`)
83 sections.push(
84 state.unknowns.length === 0
85 ? none
86 : table(
87 [c.question, c.severity, c.status],
88 state.unknowns.map((unknown) => [escape(unknown.question), escape(unknown.severity || '-'), escape(unknown.status)]),
89 ),
90 )
91 sections.push(`<h2>${escape(w.markdownEvidence)}</h2>`)
92 sections.push(
93 state.evidence.length === 0
94 ? none
95 : table(
96 [c.confidence, c.operation, c.authority, 'evidence_id'],
97 state.evidence.map((item) => [
98 confidence(item.confidence),
99 escape(`${item.operation} ${item.detail}`.trim()),
100 escape(item.authority || '-'),
101 `<code>${escape(item.id)}</code>`,
102 ]),
103 ),
104 )
105 sections.push(
106 `<div class="legend">${(['observed', 'derived', 'inferred'] as const).map((value) => `<span>${confidence(value)}</span>`).join('')}</div>`,
107 `<footer>${escape(w.htmlFooter(version))}</footer>`,
108 )
109
110 return [
111 `<title>${escape(w.htmlTitle(target))}</title>`,
112 `<style>${STYLE}</style>`,
113 `<main lang="${lang}">${sections.filter(Boolean).join('\n')}</main>`,
114 '',
115 ].join('\n')
116}
117hooks/journal.ts 58 lines1// The session's journal (ADR 0016 of rea-lab): one JSONL file per session under ~/.rea-casebook/,
2// rebuilt from the casebook on every change, so it survives the session and never lands in the
3// person's project. No `$`: tested directly.
4
5import type { CaseState } from '../types'
6
7export const JOURNAL_FOLDER = '.rea-casebook'
8
9const twoDigits = (value: number) => String(value).padStart(2, '0')
10
11/** The local day of a time, as the journal names its folders: 2026-10-12. */
12export function dayOf(at: number): string {
13 const date = new Date(at)
14 return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
15}
16
17/**
18 * `<home>/.rea-casebook/<day>/<session>`, with the home's own separator: every file the mod writes is
19 * this path plus an extension the caller adds as fixed text (`.jsonl`, `.html`, `.md`), so what kind
20 * of file it writes is visible where it writes it. The session id is cut to letters, digits, `_` and
21 * `-`, so the path never leaves its day folder.
22 */
23export function casebookBase(home: string, sessionId: string, startedAt: number): string {
24 const separator = home.includes('\\') ? '\\' : '/'
25 const session = sessionId.replace(/[^\w-]/gu, '').slice(0, 64) || 'session'
26 return [home.replace(/[\\/]+$/u, ''), JOURNAL_FOLDER, dayOf(startedAt), session].join(separator)
27}
28
29/** Whether the session has anything worth a journal: a binary opened or Evidence delivered. */
30export const hasCase = (state: CaseState) => state.target !== null || state.evidence.length > 0
31
32/**
33 * The journal's lines: a header with the session and its counters, the target, then the Evidence,
34 * unknowns and findings in the order they happened. The target is named by its file name and
35 * SHA-256, never by its local path, which can hold the person's user name.
36 */
37export function journalLines(state: CaseState, sessionId: string, version: string): string {
38 const header = {
39 type: 'casebook',
40 version,
41 session: sessionId,
42 startedAt: state.startedAt,
43 coldStartMs: state.coldStartMs,
44 refused: state.refused,
45 artifactUrl: state.artifactUrl,
46 }
47 const events = [
48 ...state.evidence.map((item) => ({ type: 'evidence', ...item })),
49 ...state.unknowns.map((unknown) => ({ type: 'unknown', ...unknown })),
50 ...state.findings.map((finding) => ({ type: 'finding', ...finding })),
51 ].sort((left, right) => left.at - right.at)
52 const target = state.target
53 ? [{ type: 'target', name: state.target.path.split(/[\\/]/u).pop() ?? '', sha256: state.target.sha256, format: state.target.format, architecture: state.target.architecture }]
54 : []
55 const lines = [header, ...target, ...events]
56 return `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`
57}
58hooks/markdown.ts 46 lines1// The casebook as a Markdown report, in the shape of rea-lab's writeups (ADR 0012): findings with
2// their evidence_id values, confidence and authority, then the Evidence and the open unknowns.
3// No `$`: tested directly.
4
5import type { CaseState } from '../types'
6import { formatSeconds, words, type Lang } from './messages'
7
8const cell = (value: string) => value.replace(/\|/gu, '\\|').replace(/\r?\n/gu, ' ')
9const shortId = (id: string) => `\`${id.slice(0, 19)}\``
10
11export function caseToMarkdown(state: CaseState, lang: Lang): string {
12 const w = words(lang)
13 const c = w.markdownColumns
14 const target = state.target?.path.split(/[\\/]/u).pop() ?? '-'
15 const lines: string[] = [`# ${w.markdownTitle(target)}`, '']
16 if (state.target) {
17 lines.push(`## ${w.markdownTarget}`, '', `- ${state.target.format} ${state.target.architecture}`, `- SHA-256 \`${state.target.sha256}\``)
18 if (state.coldStartMs !== null) lines.push(`- ${w.coldStart(formatSeconds(state.coldStartMs))}`)
19 lines.push('')
20 }
21 const authorityOf = new Map(state.evidence.map((item) => [item.id, item.authority]))
22 lines.push(`## ${w.markdownFindings}`, '')
23 if (state.findings.length === 0) lines.push(w.markdownNone)
24 else {
25 lines.push(`| # | ${c.finding} | ${c.evidence} | ${c.confidence} | ${c.authority} |`, '|---|---|---|---|---|')
26 for (const finding of state.findings) {
27 const authorities = [...new Set(finding.evidenceIds.map((id) => authorityOf.get(id) || '-'))].join(', ')
28 lines.push(`| ${finding.id} | ${cell(finding.claim)} | ${finding.evidenceIds.map(shortId).join(', ')} | ${finding.confidence} | ${authorities} |`)
29 }
30 }
31 lines.push('', `## ${w.markdownUnknowns}`, '')
32 if (state.unknowns.length === 0) lines.push(w.markdownNone)
33 else {
34 lines.push(`| ${c.question} | ${c.severity} | ${c.status} |`, '|---|---|---|')
35 for (const unknown of state.unknowns) lines.push(`| ${cell(unknown.question)} | ${unknown.severity || '-'} | ${unknown.status} |`)
36 }
37 // The findings cite short ids; here they are whole, so each one can be checked against rea.
38 lines.push('', `## ${w.markdownEvidence}`, '')
39 if (state.evidence.length === 0) lines.push(w.markdownNone)
40 else {
41 lines.push(`| ${c.operation} | ${c.confidence} | ${c.authority} | evidence_id |`, '|---|---|---|---|')
42 for (const item of state.evidence) lines.push(`| ${cell(`${item.operation} ${item.detail}`.trim())} | ${item.confidence || '-'} | ${item.authority || '-'} | \`${item.id}\` |`)
43 }
44 return `${lines.join('\n')}\n`
45}
46hooks/messages.ts 171 lines1// Every word the person and Claude read from the casebook, in English and Spanish.
2// No `$`: tested directly.
3
4export type Lang = 'en' | 'es'
5
6/** The userConfig value, or anything else, as a language: Spanish only when it says so. */
7export const langOf = (option: unknown): Lang =>
8 typeof option === 'string' && /^(es|español|espanol|spanish)\b/iu.test(option.trim()) ? 'es' : 'en'
9
10/** Seconds as the pane shows them: "42 s" or "3 min 05 s". */
11export function formatSeconds(ms: number): string {
12 const seconds = Math.max(0, Math.round(ms / 1000))
13 if (seconds < 60) return `${seconds} s`
14 const minutes = Math.floor(seconds / 60)
15 return `${minutes} min ${String(seconds % 60).padStart(2, '0')} s`
16}
17
18/** A text cut to `max` characters, at a space when one is near, ending in an ellipsis when it was cut. */
19export function clip(value: string, max: number): string {
20 if (value.length <= max) return value
21 const cut = value.slice(0, Math.max(1, max - 1))
22 const space = cut.lastIndexOf(' ')
23 return `${(space > cut.length / 2 ? cut.slice(0, space) : cut).trimEnd()}…`
24}
25
26type Words = {
27 name: string
28 command: string
29 noTarget: string
30 target: (name: string, format: string, architecture: string) => string
31 ghidraWaiting: (tool: string, elapsed: string) => string
32 waiting: (tool: string, elapsed: string) => string
33 coldStart: (elapsed: string) => string
34 timeline: (count: number) => string
35 noEvidence: string
36 unknowns: (count: number) => string
37 findings: (count: number) => string
38 noFindings: string
39 refused: (count: number) => string
40 toolDescription: string
41 claimField: string
42 evidenceField: string
43 confidenceField: string
44 findingRecorded: (id: string) => string
45 invalidFinding: (problems: string[]) => string
46 unknownEvidence: (ids: string[]) => string
47 findingFailed: string
48 opened: string
49 exported: (path: string) => string
50 exportFailed: (path: string) => string
51 /** Before rea opens a binary: nothing to export or publish yet. */
52 caseEmpty: string
53 /** The page is written; the person gives Claude the request to publish it. */
54 artifactWritten: (page: string, request: string) => string
55 /** What to ask Claude: a new private artifact, or an update of the one already published. */
56 artifactRequest: (page: string, url: string | null, description: string) => string
57 artifactDescription: (target: string) => string
58 usage: string
59 htmlTitle: (target: string) => string
60 htmlIntro: string
61 htmlFooter: (version: string) => string
62 markdownTitle: (target: string) => string
63 markdownTarget: string
64 markdownFindings: string
65 markdownEvidence: string
66 markdownUnknowns: string
67 markdownNone: string
68 confidence: { observed: string; derived: string; inferred: string }
69 markdownColumns: { finding: string; evidence: string; confidence: string; operation: string; authority: string; question: string; severity: string; status: string }
70}
71
72const EN = {
73 name: 'Casebook',
74 command: 'Show the rea casebook pane; "/casebook export" writes it as Markdown and "/casebook artifact" writes a page to publish as an artifact, both under ~/.rea-casebook/',
75 noTarget: 'No binary open yet. The pane fills in as rea delivers Evidence.',
76 target: (name, format, architecture) => `${name} - ${format} ${architecture}`,
77 ghidraWaiting: (tool, elapsed) => `Waiting for ${tool}: ${elapsed} (the first Ghidra query imports and analyses the target)`,
78 waiting: (tool, elapsed) => `Waiting for ${tool}: ${elapsed}`,
79 coldStart: (elapsed) => `Ghidra cold start: ${elapsed}`,
80 timeline: (count) => `Evidence (${count})`,
81 noEvidence: 'No Evidence yet.',
82 unknowns: (count) => `Open unknowns (${count})`,
83 findings: (count) => `Findings (${count})`,
84 noFindings: 'No findings recorded yet.',
85 refused: (count) => `${count} finding(s) refused for citing evidence rea never delivered`,
86 toolDescription:
87 'Record one finding in the rea casebook pane. Cite only evidence_id values that rea returned in this session; a finding that cites any other id is refused. Use it for conclusions you would put in a report, not for every observation.',
88 claimField: 'The finding, in one or two sentences.',
89 evidenceField: 'The evidence_id values from rea results that support the finding.',
90 confidenceField: 'observed (seen directly), derived (computed from observations) or inferred (your interpretation).',
91 findingRecorded: (id) => `Recorded as ${id} in the casebook.`,
92 invalidFinding: (problems) => `Not recorded: check ${problems.join(', ')}.`,
93 unknownEvidence: (ids) =>
94 `Not recorded: rea did not deliver ${ids.length === 1 ? 'this evidence_id' : 'these evidence_id values'} in this session: ${ids.join(', ')}. Cite only ids from rea results you received.`,
95 findingFailed: 'Not recorded: the casebook could not save the finding. Try again.',
96 opened: 'Casebook pane opened.',
97 exported: (path) => `Casebook exported to ${path}.`,
98 exportFailed: (path) => `Could not write ${path}.`,
99 caseEmpty: 'Nothing to write yet: the casebook fills in once rea opens a binary.',
100 artifactWritten: (page, request) => `Casebook page written to ${page}. To publish it as an artifact, ask Claude:\n${request}`,
101 artifactRequest: (page, url, description) =>
102 url
103 ? `Publish ${page} with the Artifact tool to my existing artifact ${url}, without changing its content.`
104 : `Publish ${page} with the Artifact tool as a new private artifact with the icon "search" and the description "${description}", without changing its content.`,
105 artifactDescription: (target) => `The rea casebook of ${target}: findings with the Evidence they cite, open unknowns and every Evidence record.`,
106 usage: 'Usage: /casebook, /casebook export or /casebook artifact',
107 htmlTitle: (target) => `Casebook ${target}`,
108 htmlIntro: 'What rea delivered to the agent in one Claude Code session. Every finding cites evidence_id values that rea returned in that session; any of them can be checked with rea.',
109 htmlFooter: (version) => `Exported by rea-casebook ${version}.`,
110 markdownTitle: (target) => `Casebook: ${target}`,
111 markdownTarget: 'Target',
112 markdownFindings: 'Findings',
113 markdownEvidence: 'Evidence',
114 markdownUnknowns: 'Open unknowns',
115 markdownNone: 'None.',
116 confidence: { observed: 'observed', derived: 'derived', inferred: 'inferred' },
117 markdownColumns: { finding: 'Finding', evidence: 'Evidence', confidence: 'Confidence', operation: 'Operation', authority: 'Authority', question: 'Question', severity: 'Severity', status: 'Status' },
118} satisfies Words
119
120const ES = {
121 name: 'Casebook',
122 command: 'Muestra el panel del casebook de rea; "/casebook export" lo escribe en Markdown y "/casebook artifact" escribe una página para publicarlo como artifact, los dos en ~/.rea-casebook/',
123 noTarget: 'Todavía no hay ningún binario abierto. El panel se rellena a medida que rea entrega evidencia.',
124 target: (name, format, architecture) => `${name} - ${format} ${architecture}`,
125 ghidraWaiting: (tool, elapsed) => `Esperando a ${tool}: ${elapsed} (la primera consulta a Ghidra importa y analiza el objetivo)`,
126 waiting: (tool, elapsed) => `Esperando a ${tool}: ${elapsed}`,
127 coldStart: (elapsed) => `Arranque en frío de Ghidra: ${elapsed}`,
128 timeline: (count) => `Evidencia (${count})`,
129 noEvidence: 'Todavía no hay evidencia.',
130 unknowns: (count) => `Preguntas abiertas (${count})`,
131 findings: (count) => `Hallazgos (${count})`,
132 noFindings: 'Todavía no hay hallazgos registrados.',
133 refused: (count) => `${count} hallazgo(s) rechazado(s) por citar evidencia que rea no entregó`,
134 toolDescription:
135 'Record one finding in the rea casebook pane. Cite only evidence_id values that rea returned in this session; a finding that cites any other id is refused. Use it for conclusions you would put in a report, not for every observation.',
136 claimField: 'The finding, in one or two sentences.',
137 evidenceField: 'The evidence_id values from rea results that support the finding.',
138 confidenceField: 'observed (seen directly), derived (computed from observations) or inferred (your interpretation).',
139 findingRecorded: (id) => `Registrado como ${id} en el casebook.`,
140 invalidFinding: (problems) => `No registrado: revisa ${problems.join(', ')}.`,
141 unknownEvidence: (ids) =>
142 `No registrado: rea no ha entregado ${ids.length === 1 ? 'este evidence_id' : 'estos evidence_id'} en esta sesión: ${ids.join(', ')}. Cita solo identificadores de resultados de rea que hayas recibido.`,
143 findingFailed: 'No registrado: el casebook no ha podido guardar el hallazgo. Inténtalo de nuevo.',
144 opened: 'Panel del casebook abierto.',
145 exported: (path) => `Casebook exportado a ${path}.`,
146 exportFailed: (path) => `No se ha podido escribir ${path}.`,
147 caseEmpty: 'Todavía no hay nada que escribir: el casebook se rellena cuando rea abre un binario.',
148 artifactWritten: (page, request) => `Página del casebook escrita en ${page}. Para publicarla como artifact, pídele a Claude:\n${request}`,
149 artifactRequest: (page, url, description) =>
150 url
151 ? `Publica ${page} con la herramienta Artifact en mi artifact ${url}, sin cambiar su contenido.`
152 : `Publica ${page} con la herramienta Artifact como artifact privado nuevo, con el icono "search" y la descripción "${description}", sin cambiar su contenido.`,
153 artifactDescription: (target) => `El casebook de rea de ${target}: hallazgos con la evidencia que citan, preguntas abiertas y todos los registros de evidencia.`,
154 usage: 'Uso: /casebook, /casebook export o /casebook artifact',
155 htmlTitle: (target) => `Casebook ${target}`,
156 htmlIntro: 'Lo que rea entregó al agente en una sesión de Claude Code. Cada hallazgo cita evidence_id que rea devolvió en esa sesión; cualquiera de ellos se puede comprobar con rea.',
157 htmlFooter: (version) => `Exportado por rea-casebook ${version}.`,
158 markdownTitle: (target) => `Casebook: ${target}`,
159 markdownTarget: 'Objetivo',
160 markdownFindings: 'Hallazgos',
161 markdownEvidence: 'Evidencia',
162 markdownUnknowns: 'Preguntas abiertas',
163 markdownNone: 'Ninguno.',
164 confidence: { observed: 'observado', derived: 'derivado', inferred: 'inferido' },
165 markdownColumns: { finding: 'Hallazgo', evidence: 'Evidencia', confidence: 'Confianza', operation: 'Operación', authority: 'Autoridad', question: 'Pregunta', severity: 'Gravedad', status: 'Estado' },
166} satisfies Words
167
168const WORDS: Record<Lang, Words> = { en: EN, es: ES }
169
170export const words = (lang: Lang): Words => WORDS[lang]
171types/index.d.ts 48 lines1// The casebook's state: what rea delivered to the agent in this session, as the pane draws it.
2
3/** One Evidence record rea delivered, reduced to what the pane shows. */
4export type EvidenceItem = {
5 id: string
6 operation: string
7 confidence: string
8 authority: string
9 /** The call's main parameters, for example "procedure=0x140001470". */
10 detail: string
11 limitations: number
12 at: number
13}
14
15/** The binary the agent opened with open_binary. */
16export type Target = { path: string; sha256: string; format: string; architecture: string }
17
18/** A residual unknown rea recorded with record_unknown or update_unknown. */
19export type Unknown = { id: string; question: string; status: string; severity: string; at: number }
20
21/** A finding the model recorded with record_finding; it cites only delivered Evidence. */
22export type Finding = { id: string; claim: string; evidenceIds: string[]; confidence: string; at: number }
23
24/** A rea call still running, so the pane can count Ghidra's cold start. */
25export type Pending = { id: string; tool: string; since: number }
26
27export type CaseState = {
28 target: Target | null
29 pending: Pending[]
30 /** Duration of the first call that had to wait for Ghidra to import and analyse the target. */
31 coldStartMs: number | null
32 evidence: EvidenceItem[]
33 unknowns: Unknown[]
34 findings: Finding[]
35 /** record_finding calls refused because they cited evidence rea never delivered. */
36 refused: number
37 /** When the first rea call of the session started: it names the day folder of the journal. */
38 startedAt: number | null
39 /** The artifact the casebook was published to, so later publishes update it in place. */
40 artifactUrl: string | null
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 'rea-casebook': { case: CaseState; now: number }
46 }
47}
48