SLOPSHOPPER

chronicle-review

A pane that shows a plan or an entry line by line, takes the publisher's notes against lines, and returns Approve, Send back or Stop.

newpaneguardtoasttool
v0.1.0no licenseupdated 2026-10-06abottchen/dnd-data/.claude/skills/chronicle-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chronicle-review
│ ┃ chronicle-review ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing under review. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · chronicle-review
Nothing under review.
README

dnd-data

A static GitHub Pages site visualizing data from an ongoing D&D campaign.

How it works

JSON snapshots from external sources (character sheets, dice roller, Owlbear Rodeo, session log) are dropped into data/ (gitignored). A deterministic Python builder reads those snapshots plus a small store of human-authored prose, validates everything, and renders site/index.html via Jinja2 templates. A GitHub Actions workflow uploads the committed site/ directory to GitHub Pages.

When new source data lands, the authored prose store needs new entries (kill verses, session summaries, NPC epithets, etc.) and existing entries may need a refresh. That work runs locally in a single command: the /build-prose skill inside a Claude Code session, which drives prepare → in-session authoring → apply end-to-end. See Build architecture.

See CLAUDE.md for full architecture detail and validation rules.

Build pipeline

flowchart LR
    subgraph up [source snapshots · dropped into data/]
      direction TB
      P1[data/party.json]
      P2[data/dice/dicex-rolls-*.json]
      P3[data/session-log.json]
      P4[data/inventory/obr-inv-backup-*.json]
    end
    up --> PR["python -m build prepare"]
    A0[build/authored/*.json] --> PR
    PR --> RUN["build/.run/<ts>/pending/*.json"]
    RUN --> BP["/build-prose (in-session)"]
    BP --> RES["build/.run/<ts>/results/*.json"]
    RES --> AP["python -m build apply"]
    AP --> A[build/authored/*.json]
    up --> R
    A --> R
    T[build/templates/*.html] --> R
    R["build/render.py<br/>validate · compute · render"] --> I[site/index.html]
    I --> GH((GitHub Pages))

The source files under data/ are gitignored — they carry real player names that must never reach site/index.html. build/render.py's loaders scrub names at read time using the substring map in build/dice-players.json. Versioned git hooks under .githooks/ reject any commit, message, or pushed change whose content matches a known full-name pattern.

Build architecture

The build package prepares authoring slices, dispatches them to in-session sub-agents via the /build-prose skill, and then applies the returned prose to build/authored/*.json before re-rendering. Each transformer is a single slice file paired with a frozen system prompt and JSON Schema; the sub-agent's only job is to turn one slice into one schema-conformant prose object. The orchestrator itself is deterministic Python.

sequenceDiagram
    autonumber
    participant U as upstream + authored
    participant P as python -m build prepare
    participant RD as build/.run/<ts>/
    participant BP as /build-prose (in-session)
    participant SA as sub-agents (×N slices)
    participant AP as python -m build apply
    participant A as authored/*.json
    participant R as render.py

    P->>U: load data + authored state
    P->>RD: write manifest.json + pending/*.json + frozen prompts
    BP->>RD: read manifest, pending slices, prompts
    BP->>SA: dispatch one sub-agent per slice
    SA-->>RD: results/*.json (schema-validated by apply)
    AP->>RD: validate each result vs frozen schema
    AP->>A: apply prose, bump marker on full refresh success
    AP->>R: run render.py
  • Entry point (build/__main__.py) — prepare / apply subcommand dispatcher. A bare python -m build is the same as prepare and prints the /build-prose command to run next.
  • Prepare (build/prepare.py) — walks the transformer registry, parses frontmatter from .claude/prompts/<name>.md, builds slices, and writes manifest.json + pending/*.json + frozen prompt/schema copies under build/.run/<timestamp>/.
  • Slice builders (build/slices.py) — pure functions of (data, authored) returning (key, slice_data) tuples per category; key matching determines what's missing and needs authoring.
  • Transformer registry (build/registry.py) — single source of truth listing every transformer (append + refresh), its slice builder, and its schema.
  • Apply (build/apply_cli.py, build/apply.py) — validates each results/*.json against the frozen schema, applies the returned prose to the in-memory authored store, persists via build/store.py, bumps site.refreshed_through_session on full refresh-pass success, and then invokes build/render.py.
  • /build-prose skill (.claude/skills/build-prose/) — drives the slice queue inside a Claude Code session, dispatching one sub-agent per pending slice. Each sub-agent receives only the slice + frozen prompt + schema; no Claude Code tools.

Validation gates the render: any MISSING or MALFORMED authored entry causes render.py to exit 1. Fix the authored entry and re-run apply.

The run directory under build/.run/<timestamp>/ is preserved on failure for inspection (and on success with prepare --keep-temp); otherwise it is cleaned up automatically after a clean apply.

Each transformer's preferred model (sonnet or opus) is declared in YAML frontmatter at the top of .claude/prompts/<name>.md. Sonnet handles per-item, short-output transformers (append-kills, append-sessions, append-npcs, refresh-npcs, refresh-intro-epithet); Opus handles slices that aggregate across the campaign and grow with it (append-chapters, append-characters, refresh-chapters, refresh-characters, refresh-road-ahead).

Files

  • site/ — the served artifact directory (uploaded to GitHub Pages by the deploy workflow).
  • site/index.html — committed build artifact.
  • site/styles.css — the design system.
  • site/images/ — character portrait tokens, referenced by each entry's image field in data/party.json.
  • data/ — ingestion directory for source files (contents gitignored).
  • data/party.json, data/session-log.json, data/dice/dicex-rolls-*.json, data/inventory/obr-inv-backup-*.json — source data files, dropped in manually from external exports.
  • build/ — the build orchestrator (Python package). Entry point: python -m build.
  • build/__main__.py — orchestrator entry point; prepare / apply subcommand dispatcher.
  • build/render.py — deterministic Python renderer (validates authored entries, computes derived data, renders via Jinja2).
  • build/paths.py, store.py, slices.py, registry.py, prepare.py, apply.py, apply_cli.py, inventory.py — orchestrator submodules.
  • build/.run/<timestamp>/ — per-run scratch dir (gitignored): manifest.json, pending/, frozen prompts/, and results/ written by /build-prose.
  • build/templates/ — Jinja2 partials for page structure.
  • build/authored/ — JSON prose store (kills, sessions, chapters, npcs, characters, site); the only writable surface for the orchestrator.
  • build/dice-players.json — substring map (first-name or handle → site slug); never records full real names.
  • .claude/prompts/ — paired prompt and schema files, one pair per transformer; each prompt declares its preferred model in YAML frontmatter.
  • .claude/skills/build-prose/ — the /build-prose skill that drives the slice queue in-session.
  • tests/ — pytest suite covering validators, key matching, computation formulas, slice builders, and bestiary lookup.
  • requirements.txt — Python dependencies.
  • .github/workflows/deploy-pages.yml — uploads site/ to GitHub Pages on push to main.
  • .claude/skills/bestiarylookup/ — looks up creatures in 5etools data; consulted by render.py for the "Kinds Slain" trial card.
  • .claude/ext/5etools-src — symlink to a local 5etools-src checkout, gitignored. See .claude/ext/README.md.
  • .githooks/ — versioned pre-commit / commit-msg / pre-push hooks that block forbidden-name leaks.
  • docs/superpowers/specs/, docs/superpowers/plans/ — design specs and implementation plans.

Local setup

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
git config core.hooksPath .githooks
ln -s /path/to/5etools-src .claude/ext/5etools-src

Local build + rebuild

Inside a Claude Code session, the whole build is one command:

/build-prose

The skill runs python -m build prepare, dispatches one sub-agent per pending slice to author prose, then runs python -m build apply to validate, persist, and render. If a slice fails, fix the prompt or slice and re-run /build-prose <run-dir> (the run dir path is printed by the skill) to resume — already-authored slices are skipped.

The two underlying CLIs can still be invoked directly when needed:

.venv/bin/python -m build prepare               # gather pending slices into build/.run/<timestamp>/
.venv/bin/python -m build apply build/.run/<timestamp>/   # validate, apply, render

A bare .venv/bin/python -m build is equivalent to prepare.

To re-render without re-authoring (when build/authored/*.json is already current):

.venv/bin/python build/render.py

Useful flags:

  • prepare --no-refresh — skip the discovery and refresh passes (append-only).
  • prepare --force-refresh — run them even when the marker is current.
  • prepare --keep-temp — preserve the run dir on success.
  • apply --skip-render — apply results but don't rebuild the site.

The render aborts with MISSING / MALFORMED / ORPHAN errors before writing output if any authored entry is missing required fields. Fix the authored entry and re-run apply.

To publish: pull main, run /build-prose, commit site/index.html and build/authored/*.json, push.

Tests

.venv/bin/pytest tests/

Local preview

python3 -m http.server 8765 --bind 127.0.0.1 --directory site

Then open <http://127.0.0.1:8765/>.

GitHub Pages

Configure once: Settings → Pages → Source: GitHub Actions.

The .github/workflows/deploy-pages.yml workflow runs on every push to main, uploads the site/ directory as a Pages artifact, and deploys it. The deploy workflow does not invoke build/render.py — site/index.html is committed and served as-is.

Source 2 files
hooks/register.tsx 233 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ChronicleReview } from '../types'
5
6const PANE = 'chronicle-review'
7const TOOL = 'mcp__chronicle-review__review'
8const HOTKEYS = '123456789abcdefghijklmnopqrstuvwxyz'
9
10const review = atom({ plugin: 'chronicle-review', key: 'review' } as const, null)
11
12type Verdict = 'approve' | 'notes' | 'stop'
13type Outcome = {
14  verdict: Verdict | null
15  notes: { line: number; quote: string; note: string }[]
16}
17
18// What the publisher is typing into the note box, kept here so a redraw of
19// the pane (any state write) draws the box with the same text instead of
20// the saved note. Module-local: a reload of this module loses it, and the
21// mod is not edited while a review is open.
22let draft = ''
23let draftFor: number | null = null
24
25function outcomeOf(state: ChronicleReview, verdict: Verdict | null): Outcome {
26  const notes = Object.entries(state.notes)
27    .map(([k, note]) => ({ line: Number(k), quote: state.lines[Number(k) - 1] ?? '', note }))
28    .sort((a, b) => a.line - b.line)
29  return { verdict, notes }
30}
31
32// Notes already on disk for this review, so that opening the pane again
33// (a second call, a resumed run) never loses what the publisher wrote.
34async function savedNotes($: EngineInterface, notesPath: string): Promise<Record<string, string>> {
35  const notes: Record<string, string> = {}
36  if (!(await $.fs.exists(notesPath))) return notes
37  try {
38    const parsed = JSON.parse(await $.fs.read(notesPath)) as Partial<Outcome>
39    for (const n of parsed.notes ?? []) {
40      if (typeof n.line === 'number' && typeof n.note === 'string' && n.note !== '') {
41        notes[String(n.line)] = n.note
42      }
43    }
44  } catch {
45    // An unreadable file is treated as empty; the next save rewrites it.
46  }
47  return notes
48}
49
50// The publisher's press: write the record, clear the pane's state, and send
51// the verdict back as a prompt so the skill's turn resumes.
52async function finish($: EngineInterface, verdict: Verdict): Promise<void> {
53  const state = await read($, review)
54  if (!state) return
55  const outcome = outcomeOf(state, verdict)
56  await $.fs.write(state.notesPath, JSON.stringify(outcome, null, 2) + '\n')
57  await update($, review, () => null)
58  draft = ''
59  draftFor = null
60  const count = outcome.notes.length
61  void $.prompt.submit({
62    text:
63      `Review of "${state.title}": verdict ${verdict}` +
64      (verdict === 'notes' ? ` with ${count} note${count === 1 ? '' : 's'}` : '') +
65      `. Notes file: ${state.notesPath}`,
66  })
67}
68
69export const register: Register = on => {
70  on('session.start', async ($, e, next) => {
71    await $.tool.register({
72      name: 'review',
73      description:
74        'Open a pane showing numbered lines for the publisher to annotate. Returns at once. ' +
75        'When the publisher presses Approve, Send back or Stop, the plugin writes ' +
76        '{verdict, notes:[{line, quote, note}]} to notes_path and sends a prompt naming the ' +
77        'verdict and that path. After calling this, end the turn and wait for that prompt. ' +
78        'Notes already in notes_path are kept, so calling it again is safe.',
79      inputSchema: {
80        type: 'object',
81        properties: {
82          title: { type: 'string', description: 'The pane title, e.g. "Plan 1: session 28"' },
83          lines: { type: 'array', items: { type: 'string' }, description: 'The lines to show, in order' },
84          notes_path: { type: 'string', description: 'Where to write the notes JSON' },
85        },
86        required: ['title', 'lines', 'notes_path'],
87      },
88    })
89    return next(e)
90  })
91
92  // A hook has a 10s budget, so the tool opens the pane and returns. The
93  // publisher's press comes back as a prompt from this plugin, and the notes
94  // file on disk is the record either way.
95  on('tool.call', { tool: TOOL }, async ($, e) => {
96    const title = String(e.title ?? 'Review')
97    const lines = Array.isArray(e.lines) ? e.lines.map(String) : []
98    const notesPath = String(e.notes_path ?? '')
99    if (lines.length === 0 || notesPath === '') {
100      return { deny: 'chronicle-review: lines and notes_path are required.' }
101    }
102    const current = await read($, review)
103    const notes = {
104      ...(await savedNotes($, notesPath)),
105      ...(current && current.notesPath === notesPath ? current.notes : {}),
106    }
107    const state: ChronicleReview = { title, lines, notesPath, notes, selected: null }
108    await update($, review, () => state)
109    await $.fs.write(notesPath, JSON.stringify(outcomeOf(state, null), null, 2) + '\n')
110    const opened = await $.ui.open({ id: PANE, title, focus: true })
111    if (!opened.isPlaced) {
112      $.ui.toast(`chronicle-review: ${opened.reason ?? 'widen the terminal to see the pane'}`)
113    }
114    const kept = Object.keys(notes).length
115    // A plugin tool's result is text for the model.
116    return {
117      result:
118        `Pane "${title}" ${opened.isPlaced ? 'opened' : 'waiting for room'} with ${lines.length} lines` +
119        (kept ? ` and ${kept} saved note${kept === 1 ? '' : 's'} kept` : '') +
120        `. End the turn. The verdict arrives as a prompt from the chronicle-review plugin, ` +
121        `and the notes file is ${notesPath}.`,
122    }
123  }).catch(($, e, next) =>
124    next.called ? next(e) : { deny: 'chronicle-review: the pane could not be opened.' },
125  )
126
127  on('ui.close', { id: PANE }, async ($, e, next) => {
128    if (e.origin.kind === 'person') {
129      await finish($, 'stop')
130    }
131    return next(e)
132  })
133
134  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
135    const table = $.ui.resolve(e)
136    const { Box, Text, Button } = table
137    // mobile draws no Input: there the pane shows lines and verdicts only.
138    const Input = 'Input' in table ? table.Input : null
139    const state = await read($, review)
140    if (!state) {
141      return <Text dimColor>Nothing under review.</Text>
142    }
143
144    const save = async (next: ChronicleReview) => {
145      await update($, review, () => next)
146      await $.fs.write(next.notesPath, JSON.stringify(outcomeOf(next, null), null, 2) + '\n')
147    }
148
149    const press = async (verdict: Verdict) => {
150      await finish($, verdict)
151      await $.ui.close({ id: PANE })
152    }
153
154    const count = Object.keys(state.notes).length
155    const selected = state.selected
156    const boxValue =
157      selected !== null && draftFor === selected ? draft : (state.notes[String(selected)] ?? '')
158
159    return (
160      <Box flexDirection="column">
161        <Text bold>{state.title}</Text>
162        <Text dimColor>
163          Press a line's key to note it. Enter saves the note, an empty note clears it. Esc returns to the prompt.
164        </Text>
165        <Text> </Text>
166        {state.lines.map((line, i) => {
167          const n = i + 1
168          const hotkey = HOTKEYS[i]
169          const note = state.notes[String(n)]
170          const isSelected = selected === n
171          return (
172            <Box flexDirection="column">
173              <Box>
174                <Button
175                  key={`line:${n}`}
176                  plain
177                  hotkey={hotkey}
178                  label={hotkey ? 'note' : `${n}: note`}
179                  dimColor={!isSelected}
180                  onPress={() => {
181                    draft = state.notes[String(n)] ?? ''
182                    draftFor = n
183                    void update($, review, s => (s ? { ...s, selected: n } : s))
184                  }}
185                />
186                <Text> </Text>
187                <Text inverse={isSelected} wrap="wrap">{line}</Text>
188              </Box>
189              {note !== undefined && (
190                <Box>
191                  <Text>    </Text>
192                  <Text color="warning" wrap="wrap">{'↳ ' + note}</Text>
193                </Box>
194              )}
195            </Box>
196          )
197        })}
198        <Text> </Text>
199        {selected !== null && Input && (
200          <Input
201            key="note"
202            autoFocus
203            label={`Note on line ${selected}:`}
204            value={boxValue}
205            placeholder="what is wrong with it"
206            submitLabel="save"
207            onInput={(value: string) => {
208              draft = value
209              draftFor = selected
210            }}
211            onSubmit={(value: string) => {
212              const n = String(selected)
213              const notes = { ...state.notes }
214              if (value.trim() === '') delete notes[n]
215              else notes[n] = value.trim()
216              draft = ''
217              draftFor = null
218              void save({ ...state, notes, selected: null })
219            }}
220          />
221        )}
222        <Box>
223          <Button key="approve" variant="primary" label="Approve" onPress={() => void press('approve')} />
224          <Text> </Text>
225          <Button key="send-back" label={count ? `Send back (${count} notes)` : 'Send back'} onPress={() => void press('notes')} />
226          <Text> </Text>
227          <Button key="stop" label="Stop" onPress={() => void press('stop')} />
228        </Box>
229      </Box>
230    )
231  })
232}
233
types/index.d.ts 14 lines
1export type ChronicleReview = {
2  title: string
3  lines: string[]
4  notesPath: string
5  notes: Record<string, string>
6  selected: number | null
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'chronicle-review': { review: ChronicleReview | null }
12  }
13}
14