SLOPSHOPPER

rea-casebook

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…

newpaneguardcommandtooltimer
★ 2v1.0.4MITupdated 2026-10-13pablodiazjorge/rea-casebook
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rea-casebook
│ ┃ Casebook ✕ › fix the failing auth test and add an audit log call │ ┃ No binary open yet. The pane fills in as re… │ ┃ ⏺ Read(src/auth.ts) │ ┃ Findings (0) ⎿ Read 6 lines │ ┃ No findings recorded yet. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Evidence (0) ⏺ Bash(bun test) │ ┃ No Evidence yet. ⎿ 3 pass, 1 fail │ ┃ │ ┃ rea-casebook v1.0.4 ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /casebook │ ⎿ rea-casebook: Casebook pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Casebook
No binary open yet. The pane fills in as rea delivers Evide… Findings (0) No findings recorded yet. Evidence (0) No Evidence yet. rea-casebook v1.0.4
README

<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">

A Claude Code session in a dark theme: on the left, the agent analyses a challenge binary with rea and records findings with record_finding; on the right, the Casebook pane lists the target, a Ghidra cold start of 18 seconds, seven findings, two open unknowns and eighteen Evidence records

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.

What the pane shows

SectionWhat it holds
TargetThe binary open_binary opened: file name, format and architecture
WaitWhile 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 startHow long that first query took, kept for the session
FindingsWhat 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 unknownsThe questions rea holds as residual unknowns (record_unknown, update_unknown, list_unknowns) until they are resolved
EvidenceThe 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 in a light theme at the end of the session: findings F1 to F7 marked observed or derived with their Evidence count, two open unknowns, the Evidence list newest first and the legend of the marks The same pane in a dark theme

The pane while the first Ghidra query runs: waiting for binary_overview for 7 seconds, with a note that the first query imports and analyses the target

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.

Findings that cite real Evidence

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.

How it works

  1. Every call to a rea tool passes through a 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.
  2. When a result is too large for the client and is saved to a file, the mod reads the Evidence from that file.
  3. The mod never calls rea on its own. Everything it shows is what the agent already received.
  4. The state lives in the session ($.state) and is drawn by the pane. A second open_binary keeps the Evidence of the first, so comparing two builds keeps both.
  5. Each change rewrites the session's journal, ~/.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.

The command

CommandWhat it does
/casebookOpens the pane
/casebook exportWrites the casebook as Markdown beside the journal, to ~/.rea-casebook/<date>/<session>.md
/casebook artifactWrites 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.

Language

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.

Permissions

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.

What it runs, writes and sends

Everything the mod does on its own, for anyone who reviews it before installing.

What the hooks do

HookWhat 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 ArtifactWhen 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_findingStands 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 /casebookOpens the pane or writes one of the two exports
ui.render on the paneDraws the casebook
session.startRegisters record_finding, the command and a one-second timer that only redraws the waiting counter

What the mod writes

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:

FileWhen
<session>.jsonl, the journalAfter each change to the casebook
<session>.md, the Markdown exportWhen you run /casebook export
<session>.html, the artifact pageWhen 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.

What it reads, calls and sends

  • What it reads. The results of the rea calls the agent already made, and a rea result that the client saved to a file because it was too large. It also reads the environment variable 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.
  • Tools it calls by itself. None. It never calls rea, Artifact or any other tool; record_finding is called by the model.
  • Prompts it submits. None. /casebook artifact shows you a request to give Claude; it does not send it.
  • Network. None. The only data that leaves your machine is the artifact page you ask Claude to publish, through Claude Code's Artifact tool.

PRIVACY.md sums this up as a privacy notice.

Install

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

Development

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 .

Status

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

License

MIT

Source 8 files
hooks/register.tsx 359 lines
1// 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}
359
hooks/casebook.ts 103 lines
1// 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
103
hooks/evidence.ts 108 lines
1// 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
108
hooks/html.ts 117 lines
1// 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, '&amp;').replace(/</gu, '&lt;').replace(/>/gu, '&gt;').replace(/"/gu, '&quot;').replace(/'/gu, '&#39;')
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}
117
hooks/journal.ts 58 lines
1// 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}
58
hooks/markdown.ts 46 lines
1// 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}
46
hooks/messages.ts 171 lines
1// 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]
171
types/index.d.ts 48 lines
1// 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