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.

A static GitHub Pages site visualizing data from an ongoing D&D campaign.
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.
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.
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
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.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>/.build/slices.py) — pure functions of (data, authored) returning (key, slice_data) tuples per category; key matching determines what's missing and needs authoring.build/registry.py) — single source of truth listing every transformer (append + refresh), its slice builder, and its schema.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).
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.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
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.
.venv/bin/pytest tests/
python3 -m http.server 8765 --bind 127.0.0.1 --directory site
Then open <http://127.0.0.1:8765/>.
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.
hooks/register.tsx 233 lines1import { 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}
233types/index.d.ts 14 lines1export 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