A live HTML page that shows every change Claude makes, with diffs, the reason for each edit, checks, and before/after behavior flows.

A live page in your browser that shows every change Claude makes while it works: the diff, the reason for each edit, the checks it ran, and how the code behaved before and after each turn.

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install change-journal@claude-code-mods
Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.
The page opens in your browser on the first edit of a session and updates itself every second and a half. It needs no server.
The page follows new changes as they happen. When you select something, it stops following until you press "Follow new changes".
Changes made outside Edit and Write (a sed command, a formatter, or your own edits) show up after each turn as outside changes. Test commands appear with a pass or fail mark.
✎ 3 files +40 −12./changes to see the latest turns in the terminal. The pane opens with the keys.o to open the page.Commands:
/changes: open the pane with the latest four turns and their edits./changes open: open the page in your browser./changes path: print where the page lives.
The hint row under the title always names the keys that work now.
| Key | What it does |
|---|---|
1 to 9 | Show that edit under the list: its reason and its first changed lines. Press the number again to hide it. |
o | Open the page. With an edit shown, the page opens on that edit's turn. |
c | Copy the page path. |
x | Close the pane. |
| Tab, Enter | Move to the next row or button, and press it. |
| Esc | Give the keys back to the prompt. The pane stays open. |
| ctrl+x tab | Give the keys to the pane again. |
On the page, click a ring or a dot on the rail to jump to a turn or an edit. Press "Follow new changes" to go back to the newest change.
Change these in /plugin:
autoOpen (on): open the page on the first edit of a session.liveSummaries (on): after each turn with edits, ask the helper model for a title, an explanation, the behavior flows, and missing reasons. This uses extra tokens.helperModel (claude-sonnet-5-5): the model for those summaries.helperEffort (medium): how hard the helper model thinks (low, medium, or high).The page and its data stay on your machine in ~/.claude/change-journal/<project>/<session>/. Files that look like secrets (.env, keys, credentials) are listed without their content, and tokens in diffs are redacted. When summaries are on, a short excerpt of each diff goes to your configured Claude provider.
See the marketplace README for configuration, privacy, updates, and removal.
hooks/register.ts 162 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { installEdits } from './edits'
4import { helperFrom } from './journal/helper'
5import { statusLine, takeWrites } from './journal/output'
6import { hunksFrom, restoreFrom } from './journal/restore'
7import { configureJournal, journalSettings } from './journal/settings'
8import { adoptJournal, claimAutoOpen, finishSummary, journalView, nextSummaryTurn, resetJournal } from './journal/state'
9import { parseSummary, SUMMARY_SYSTEM, summaryPrompt } from './journal/summary'
10import type { JournalSettings } from './journal/types'
11import { installPane, PANE_ID } from './pane'
12import { installReasons } from './reasons'
13import { noop } from './shared/noop'
14import { installTurns } from './turns'
15
16const PAGE_FILES = ['index.html', 'style.css', 'core.js', 'timeline.js', 'changes.js', 'behavior.js', 'files.js']
17const FLUSH_MS = 1000
18const SUMMARY_POLL_MS = 1500
19
20let isSummarizing = false
21let isInteractive = false
22
23function settingsFrom(options: PluginOptions): JournalSettings {
24 return {
25 ...helperFrom(options),
26 liveSummaries: options.liveSummaries !== false,
27 autoOpen: options.autoOpen !== false,
28 }
29}
30
31function projectSlug(cwd: string): string {
32 return cwd.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
33}
34
35async function openInBrowser($: EngineInterface, options: { path: string }): Promise<void> {
36 const opened = await $.process.run(['open', options.path], { timeoutMs: 5000 }).catch(() => undefined)
37 if (opened?.exitCode !== 0) {
38 await $.process.run(['xdg-open', options.path], { timeoutMs: 5000 }).catch(() => undefined)
39 }
40}
41
42async function startJournal($: EngineInterface, options: { cwd: string }): Promise<void> {
43 const home = (await $.env.get('HOME')) ?? '/tmp'
44 const sessionId = await $.session.id()
45 const folder = `${home}/.claude/change-journal/${projectSlug(options.cwd)}/${sessionId}`
46 resetJournal({
47 sessionId,
48 project: options.cwd.split('/').filter(Boolean).at(-1) ?? options.cwd,
49 root: options.cwd,
50 folder,
51 startedAt: await $.clock.now(),
52 })
53 const saved = restoreFrom({ text: await $.fs.read(`${folder}/data.js`).catch(() => ''), sessionId })
54 if (saved) {
55 const edits = []
56 for (const edit of saved.edits) {
57 const script = edit.hunkCount > 0 ? await $.fs.read(`${folder}/edit-${edit.id}.js`).catch(() => '') : ''
58 edits.push({ ...edit, hunks: hunksFrom(script) })
59 }
60 adoptJournal({ ...saved, edits })
61 }
62 for (const name of PAGE_FILES) {
63 await $.fs.write(`${folder}/${name}`, await $.fs.read(`${$.plugin.root}/page/${name}`))
64 }
65}
66
67async function flush($: EngineInterface): Promise<void> {
68 const folder = journalView().folder
69 if (folder === '') {
70 return
71 }
72 const writes = takeWrites(await $.clock.now())
73 for (const edit of writes.edits) {
74 await $.fs.write(`${folder}/edit-${edit.id}.js`, edit.text)
75 }
76 if (writes.data === undefined) {
77 return
78 }
79 await $.fs.write(`${folder}/data.js`, writes.data)
80 $.ui.status(statusLine())
81 $.ui.invalidate('ui.render')
82 if (isInteractive && claimAutoOpen()) {
83 await openInBrowser($, { path: `${folder}/index.html` })
84 }
85}
86
87async function summarizeNext($: EngineInterface): Promise<void> {
88 if (isSummarizing) {
89 return
90 }
91 const turn = nextSummaryTurn()
92 if (!turn) {
93 return
94 }
95 isSummarizing = true
96 const journal = journalView()
97 const prompt = summaryPrompt({
98 turn,
99 edits: journal.edits.filter(edit => edit.turn === turn.index && edit.status === 'applied'),
100 tests: journal.tests.filter(test => test.turn === turn.index),
101 })
102 const result = await $.model
103 .complete({
104 model: journalSettings().helperModel,
105 effort: journalSettings().helperEffort,
106 system: SUMMARY_SYSTEM,
107 prompt,
108 maxTokens: 1500,
109 timeoutMs: 45000,
110 })
111 .catch(() => undefined)
112 finishSummary({ index: turn.index, summary: result?.isAnswered ? parseSummary(result.text) : null })
113 isSummarizing = false
114}
115
116export const register: Register = (on, options) => {
117 configureJournal(settingsFrom(options))
118 installEdits(on)
119 installReasons(on)
120 installTurns(on)
121 installPane(on)
122
123 on('session.start', async ($, e, next) => {
124 isInteractive = e.isInteractive
125 await $.command.register({
126 name: 'changes',
127 description: 'Change Journal: no argument opens the pane, "open" opens the live page, "path" prints its path',
128 argumentHint: '[open|path]',
129 immediate: true,
130 })
131 await startJournal($, { cwd: e.cwd })
132 $.clock.every(FLUSH_MS, () => {
133 flush($).catch(noop)
134 })
135 $.clock.every(SUMMARY_POLL_MS, () => {
136 summarizeNext($).catch(noop)
137 })
138 return next(e)
139 })
140
141 on('classic.SessionStart', async ($, e, next) => {
142 if (e.source === 'clear') {
143 await startJournal($, { cwd: e.cwd })
144 }
145 return next(e)
146 })
147
148 on('command.run', { command: 'changes' }, async ($, e) => {
149 const action = e.args.trim().toLowerCase()
150 const path = `${journalView().folder}/index.html`
151 if (action === 'path') {
152 return { text: `Change Journal page: ${path}` }
153 }
154 if (action === 'open') {
155 await openInBrowser($, { path })
156 return { text: `Opened the Change Journal page: ${path}` }
157 }
158 await $.ui.open({ id: PANE_ID, title: 'Change Journal', focus: true, rows: 24 })
159 return { text: `Change Journal pane opened with the keys; Esc gives them back. Live page: ${path}` }
160 })
161}
162hooks/edits.ts 96 lines1import type { On } from 'claude-code'
2
3import { capHunks, creationHunk, relativePath, replacementHunk } from './journal/diff'
4import { addEdit, addTest, journalView } from './journal/state'
5import { isTestCommand, shortCommand } from './journal/tests'
6import type { EditSource, EditStatus, Hunk } from './journal/types'
7import { isSecretPath, sanitize } from './shared/privacy'
8
9type Outcome = { deny?: string; isError?: boolean }
10
11function statusOf(ran: Outcome): EditStatus {
12 if (ran.deny !== undefined) {
13 return 'denied'
14 }
15 return ran.isError === true ? 'failed' : 'applied'
16}
17
18function record(options: {
19 toolUseId: string
20 source: EditSource
21 filePath: string
22 status: EditStatus
23 hunks: readonly Hunk[]
24 at: number
25}): void {
26 const path = relativePath({ path: options.filePath, root: journalView().root })
27 const isHidden = isSecretPath(path)
28 const capped = capHunks(isHidden ? [] : options.hunks)
29 const notes = [
30 isHidden ? 'content hidden: this looks like a secrets file' : '',
31 options.status === 'applied' && !isHidden && options.hunks.length === 0 ? 'changed, but no diff was available' : '',
32 capped.isTruncated ? 'diff truncated' : '',
33 ]
34 addEdit({
35 toolUseId: options.toolUseId,
36 source: options.source,
37 path,
38 status: options.status,
39 hunks: capped.hunks.map(hunk => ({ ...hunk, lines: hunk.lines.map(sanitize) })),
40 note: notes.filter(Boolean).join(' · '),
41 at: options.at,
42 })
43}
44
45export function installEdits(on: On): void {
46 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
47 const ran = await next(e)
48 const base = { toolUseId: e.tool_use_id, source: 'Edit', filePath: e.file_path, at: await $.clock.now() } as const
49 if (ran.deny !== undefined || ran.isError === true) {
50 record({ ...base, status: statusOf(ran), hunks: [] })
51 return ran
52 }
53 if (ran.result.staged !== true) {
54 record({ ...base, status: 'applied', hunks: ran.result.structuredPatch })
55 }
56 return ran
57 })
58
59 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
60 const ran = await next(e)
61 const base = { toolUseId: e.tool_use_id, source: 'Write', filePath: e.file_path, at: await $.clock.now() } as const
62 if (ran.deny !== undefined || ran.isError === true) {
63 record({ ...base, status: statusOf(ran), hunks: [] })
64 return ran
65 }
66 const { structuredPatch, type, staged } = ran.result
67 const hunks = type === 'create' && structuredPatch.length === 0 ? [creationHunk(e.content)] : structuredPatch
68 if (staged !== true) {
69 record({ ...base, status: 'applied', hunks })
70 }
71 return ran
72 })
73
74 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
75 const ran = await next(e)
76 const base = { toolUseId: e.tool_use_id, source: 'NotebookEdit', filePath: e.notebook_path, at: await $.clock.now() } as const
77 if (ran.deny !== undefined || ran.isError === true) {
78 record({ ...base, status: statusOf(ran), hunks: [] })
79 return ran
80 }
81 const hunk = replacementHunk({ before: ran.result.old_source ?? '', after: ran.result.new_source })
82 record({ ...base, status: 'applied', hunks: [hunk] })
83 return ran
84 })
85
86 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
87 const ran = await next(e)
88 if (!isTestCommand(e.command) || ran.deny !== undefined) {
89 return ran
90 }
91 const isPassing = ran.isError !== true && !ran.result.interrupted
92 addTest({ command: shortCommand(e.command), isPassing, at: await $.clock.now() })
93 return ran
94 })
95}
96hooks/journal/helper.ts 16 lines1import type { ModelEffort, PluginOptions } from 'claude-code'
2
3export const DEFAULT_HELPER_MODEL = 'claude-sonnet-5-5'
4
5const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high']
6
7export type Helper = { helperModel: string; helperEffort: ModelEffort }
8
9export function helperFrom(options: PluginOptions): Helper {
10 const model = options.helperModel
11 return {
12 helperModel: typeof model === 'string' && model.trim() !== '' ? model.trim() : DEFAULT_HELPER_MODEL,
13 helperEffort: EFFORTS.find(effort => effort === options.helperEffort) ?? 'medium',
14 }
15}
16hooks/journal/output.ts 58 lines1import { journalSettings } from './settings'
2import { drainWrites, journalView } from './state'
3import type { EditEntry, PendingWrites } from './types'
4
5export type Totals = { files: number; added: number; removed: number; passing: number; failing: number }
6
7export function totals(): Totals {
8 const journal = journalView()
9 const applied = journal.edits.filter(edit => edit.status === 'applied')
10 return {
11 files: new Set(applied.map(edit => edit.path)).size,
12 added: applied.reduce((sum, edit) => sum + edit.added, 0),
13 removed: applied.reduce((sum, edit) => sum + edit.removed, 0),
14 passing: journal.tests.filter(test => test.isPassing).length,
15 failing: journal.tests.filter(test => !test.isPassing).length,
16 }
17}
18
19export function statusLine(): string | undefined {
20 const { files, added, removed, passing, failing } = totals()
21 if (files === 0) {
22 return undefined
23 }
24 const checks = passing + failing > 0 ? ` · ✓${passing} ✗${failing}` : ''
25 return `✎ ${files} file${files === 1 ? '' : 's'} +${added} −${removed}${checks}`
26}
27
28function editScript(edit: EditEntry): string {
29 const payload = JSON.stringify({ hunks: edit.hunks })
30 return `(window.__changeJournalEdits = window.__changeJournalEdits || {})[${edit.id}] = ${payload};\n`
31}
32
33function dataScript(updatedAt: number): string {
34 const journal = journalView()
35 const payload = {
36 version: 1,
37 project: journal.project,
38 sessionId: journal.sessionId,
39 startedAt: journal.startedAt,
40 updatedAt,
41 liveSummaries: journalSettings().liveSummaries,
42 totals: totals(),
43 turns: journal.turns,
44 edits: journal.edits.map(({ hunks, ...edit }) => edit),
45 tests: journal.tests,
46 }
47 return `window.__changeJournal = ${JSON.stringify(payload)};\n`
48}
49
50export function takeWrites(updatedAt: number): PendingWrites {
51 const { editIds, isDataDirty } = drainWrites()
52 const edits = journalView().edits.filter(edit => editIds.includes(edit.id))
53 return {
54 edits: edits.map(edit => ({ id: edit.id, text: editScript(edit) })),
55 data: isDataDirty || editIds.length > 0 ? dataScript(updatedAt) : undefined,
56 }
57}
58hooks/journal/restore.ts 121 lines1import type { EditEntry, EditSource, EditStatus, FlowStep, Hunk, SummaryStatus, TestRun, TurnEntry, TurnSummary } from './types'
2
3const PREFIX = 'window.__changeJournal = '
4const SOURCES: readonly EditSource[] = ['Edit', 'Write', 'NotebookEdit', 'Command']
5const STATUSES: readonly EditStatus[] = ['applied', 'failed', 'denied']
6const SUMMARY_STATUSES: readonly SummaryStatus[] = ['off', 'waiting', 'running', 'ready', 'failed']
7
8type Saved = { turns: TurnEntry[]; edits: EditEntry[]; tests: TestRun[] }
9type Fields = Record<string, unknown>
10
11function isRecord(value: unknown): value is Fields {
12 return typeof value === 'object' && value !== null && !Array.isArray(value)
13}
14
15function text(value: unknown): string {
16 return typeof value === 'string' ? value : ''
17}
18
19function count(value: unknown): number {
20 return typeof value === 'number' && Number.isFinite(value) ? value : 0
21}
22
23function records(value: unknown): Fields[] {
24 return Array.isArray(value) ? value.filter(isRecord) : []
25}
26
27function steps(value: unknown): FlowStep[] {
28 return records(value).map(step => ({
29 text: text(step.text),
30 isChanged: step.isChanged === true,
31 editIds: Array.isArray(step.editIds) ? step.editIds.filter((id): id is number => typeof id === 'number') : [],
32 }))
33}
34
35function summaryOf(value: unknown): TurnSummary | null {
36 if (!isRecord(value) || typeof value.title !== 'string') {
37 return null
38 }
39 return { title: value.title, explanation: text(value.explanation), before: steps(value.before), after: steps(value.after), editReasons: [] }
40}
41
42function turnOf(turn: Fields): TurnEntry {
43 const status = SUMMARY_STATUSES.find(candidate => candidate === turn.summaryStatus) ?? 'off'
44 return {
45 index: count(turn.index),
46 prompt: text(turn.prompt),
47 startedAt: count(turn.startedAt),
48 endedAt: typeof turn.endedAt === 'number' ? turn.endedAt : null,
49 summaryStatus: status === 'running' || status === 'waiting' ? 'failed' : status,
50 summary: summaryOf(turn.summary),
51 }
52}
53
54function editOf(edit: Fields): EditEntry | undefined {
55 const source = SOURCES.find(candidate => candidate === edit.source)
56 const status = STATUSES.find(candidate => candidate === edit.status)
57 if (!source || !status || typeof edit.id !== 'number') {
58 return undefined
59 }
60 return {
61 id: edit.id,
62 turn: count(edit.turn),
63 source,
64 path: text(edit.path),
65 status,
66 added: count(edit.added),
67 removed: count(edit.removed),
68 reason: text(edit.reason),
69 isReasonInferred: edit.isReasonInferred === true,
70 note: text(edit.note),
71 at: count(edit.at),
72 hunks: [],
73 hunkCount: count(edit.hunkCount),
74 }
75}
76
77export function restoreFrom(options: { text: string; sessionId: string }): Saved | undefined {
78 if (!options.text.startsWith(PREFIX)) {
79 return undefined
80 }
81 let parsed: unknown
82 try {
83 parsed = JSON.parse(options.text.slice(PREFIX.length).trim().replace(/;$/, ''))
84 } catch {
85 return undefined
86 }
87 if (!isRecord(parsed) || parsed.sessionId !== options.sessionId) {
88 return undefined
89 }
90 return {
91 turns: records(parsed.turns).map(turnOf),
92 edits: records(parsed.edits).flatMap(edit => editOf(edit) ?? []),
93 tests: records(parsed.tests).map(test => ({
94 id: count(test.id),
95 turn: count(test.turn),
96 command: text(test.command),
97 isPassing: test.isPassing === true,
98 at: count(test.at),
99 })),
100 }
101}
102
103export function hunksFrom(text: string): Hunk[] {
104 const marker = '] = '
105 let parsed: unknown
106 try {
107 parsed = JSON.parse(text.slice(text.indexOf(marker) + marker.length).trim().replace(/;$/, ''))
108 } catch {
109 return []
110 }
111 return isRecord(parsed)
112 ? records(parsed.hunks).map(hunk => ({
113 oldStart: count(hunk.oldStart),
114 oldLines: count(hunk.oldLines),
115 newStart: count(hunk.newStart),
116 newLines: count(hunk.newLines),
117 lines: Array.isArray(hunk.lines) ? hunk.lines.filter((line): line is string => typeof line === 'string') : [],
118 }))
119 : []
120}
121hooks/journal/settings.ts 13 lines1import { DEFAULT_HELPER_MODEL } from './helper'
2import type { JournalSettings } from './types'
3
4let settings: JournalSettings = { liveSummaries: true, helperModel: DEFAULT_HELPER_MODEL, helperEffort: 'medium', autoOpen: true }
5
6export function configureJournal(next: JournalSettings): void {
7 settings = next
8}
9
10export function journalSettings(): JournalSettings {
11 return settings
12}
13hooks/journal/state.ts 187 lines1import { countLines } from './diff'
2import { journalSettings } from './settings'
3import type { EditEntry, EditSource, EditStatus, Hunk, TestRun, TurnEntry, TurnSummary } from './types'
4
5export type JournalIdentity = {
6 sessionId: string
7 project: string
8 root: string
9 folder: string
10 startedAt: number
11}
12
13export type Journal = JournalIdentity & {
14 turns: TurnEntry[]
15 edits: EditEntry[]
16 tests: TestRun[]
17 reasons: Map<string, string>
18 unwrittenEdits: number[]
19 isDataDirty: boolean
20 hasOpenedPage: boolean
21 baseline: Map<string, string>
22 touchedThisTurn: Set<string>
23}
24
25let journal: Journal = emptyJournal({ sessionId: '', project: '', root: '', folder: '', startedAt: 0 })
26
27function emptyJournal(options: JournalIdentity): Journal {
28 return {
29 ...options,
30 turns: [],
31 edits: [],
32 tests: [],
33 reasons: new Map(),
34 unwrittenEdits: [],
35 isDataDirty: true,
36 hasOpenedPage: false,
37 baseline: new Map(),
38 touchedThisTurn: new Set(),
39 }
40}
41
42export function resetJournal(options: JournalIdentity): void {
43 journal = emptyJournal(options)
44}
45
46export function adoptJournal(saved: { turns: TurnEntry[]; edits: EditEntry[]; tests: TestRun[] }): void {
47 journal = { ...journal, ...saved, hasOpenedPage: true, isDataDirty: false }
48}
49
50export function journalView(): Readonly<Journal> {
51 return journal
52}
53
54function currentTurn(at: number): TurnEntry {
55 const last = journal.turns.at(-1)
56 if (last) {
57 return last
58 }
59 const opening: TurnEntry = {
60 index: 0,
61 prompt: 'Before the first prompt',
62 startedAt: at,
63 endedAt: null,
64 summaryStatus: 'off',
65 summary: null,
66 }
67 journal.turns.push(opening)
68 return opening
69}
70
71export function beginTurn(options: { at: number; prompt: string }): void {
72 journal.turns.push({
73 index: (journal.turns.at(-1)?.index ?? 0) + 1,
74 prompt: options.prompt.trim() || 'A prompt with no text',
75 startedAt: options.at,
76 endedAt: null,
77 summaryStatus: 'off',
78 summary: null,
79 })
80 journal.touchedThisTurn = new Set()
81 journal.isDataDirty = true
82}
83
84export function endTurn(at: number): void {
85 const turn = currentTurn(at)
86 turn.endedAt = at
87 const hasEdits = journal.edits.some(edit => edit.turn === turn.index && edit.status === 'applied')
88 turn.summaryStatus = journalSettings().liveSummaries && hasEdits ? 'waiting' : 'off'
89 journal.isDataDirty = true
90}
91
92export function recordReason(options: { toolUseId: string; reason: string }): void {
93 journal.reasons.set(options.toolUseId, options.reason)
94}
95
96export function addEdit(options: {
97 toolUseId: string
98 source: EditSource
99 path: string
100 status: EditStatus
101 hunks: Hunk[]
102 note: string
103 at: number
104}): EditEntry {
105 const { added, removed } = countLines(options.hunks)
106 const edit: EditEntry = {
107 id: journal.edits.length + 1,
108 turn: currentTurn(options.at).index,
109 source: options.source,
110 path: options.path,
111 status: options.status,
112 added,
113 removed,
114 reason: journal.reasons.get(options.toolUseId) ?? '',
115 isReasonInferred: false,
116 note: options.note,
117 at: options.at,
118 hunks: options.hunks,
119 hunkCount: options.hunks.length,
120 }
121 journal.edits.push(edit)
122 journal.unwrittenEdits.push(edit.id)
123 journal.touchedThisTurn.add(edit.path)
124 journal.isDataDirty = true
125 return edit
126}
127
128export function addTest(options: { command: string; isPassing: boolean; at: number }): void {
129 journal.tests.push({ id: journal.tests.length + 1, turn: currentTurn(options.at).index, ...options })
130 journal.isDataDirty = true
131}
132
133export function setBaseline(counts: Map<string, string>): void {
134 journal.baseline = counts
135}
136
137export function changedOutsideEdits(counts: Map<string, string>): string[] {
138 const changed = [...counts.entries()]
139 .filter(([path, count]) => journal.baseline.get(path) !== count)
140 .map(([path]) => path)
141 .filter(path => !journal.touchedThisTurn.has(path))
142 journal.baseline = counts
143 return changed
144}
145
146export function nextSummaryTurn(): TurnEntry | undefined {
147 const turn = journal.turns.find(candidate => candidate.summaryStatus === 'waiting')
148 if (turn) {
149 turn.summaryStatus = 'running'
150 journal.isDataDirty = true
151 }
152 return turn
153}
154
155export function finishSummary(options: { index: number; summary: TurnSummary | null }): void {
156 const turn = journal.turns.find(candidate => candidate.index === options.index)
157 if (!turn) {
158 return
159 }
160 turn.summary = options.summary
161 turn.summaryStatus = options.summary ? 'ready' : 'failed'
162 options.summary?.editReasons.forEach(({ id, why }) => {
163 const edit = journal.edits.find(candidate => candidate.id === id && candidate.turn === turn.index)
164 if (edit && edit.reason === '' && why !== '') {
165 edit.reason = why
166 edit.isReasonInferred = true
167 }
168 })
169 journal.isDataDirty = true
170}
171
172export function claimAutoOpen(): boolean {
173 const hasApplied = journal.edits.some(edit => edit.status === 'applied')
174 if (!journalSettings().autoOpen || journal.hasOpenedPage || !hasApplied) {
175 return false
176 }
177 journal.hasOpenedPage = true
178 return true
179}
180
181export function drainWrites(): { editIds: number[]; isDataDirty: boolean } {
182 const drained = { editIds: journal.unwrittenEdits, isDataDirty: journal.isDataDirty }
183 journal.unwrittenEdits = []
184 journal.isDataDirty = false
185 return drained
186}
187hooks/journal/summary.ts 106 lines1import { sanitize } from '../shared/privacy'
2import type { EditEntry, FlowStep, TestRun, TurnEntry, TurnSummary } from './types'
3
4const DIFF_LINES_PER_EDIT = 60
5const PROMPT_BUDGET = 12000
6
7export const SUMMARY_SYSTEM =
8 'You explain code changes to a developer who watches an AI coding agent work. Be concrete and plain. Reply with one JSON object and nothing else.'
9
10function diffExcerpt(edit: EditEntry): string {
11 const lines = edit.hunks.flatMap(hunk => [
12 `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
13 ...hunk.lines,
14 ])
15 return sanitize(lines.slice(0, DIFF_LINES_PER_EDIT).join('\n'))
16}
17
18export function summaryPrompt(options: {
19 turn: TurnEntry
20 edits: readonly EditEntry[]
21 tests: readonly TestRun[]
22}): string {
23 const edits = options.edits
24 .map(edit =>
25 [
26 `### Edit ${edit.id}: ${edit.path} (${edit.source}, +${edit.added} -${edit.removed})`,
27 edit.reason ? `Agent's stated reason: ${sanitize(edit.reason)}` : '',
28 diffExcerpt(edit),
29 ]
30 .filter(Boolean)
31 .join('\n'),
32 )
33 .join('\n\n')
34 const tests = options.tests
35 .map(test => `- ${test.isPassing ? 'PASS' : 'FAIL'}: ${sanitize(test.command)}`)
36 .join('\n')
37 return [
38 `The developer asked: ${sanitize(options.turn.prompt).slice(0, 1500)}`,
39 '',
40 'The agent made these edits:',
41 edits.slice(0, PROMPT_BUDGET),
42 tests ? `\nChecks run:\n${tests}` : '',
43 '',
44 'Return JSON with this shape:',
45 '{"title": "what changed, at most 8 words",',
46 ' "explanation": "2 to 4 short sentences: what changed, why, and what a reviewer should check",',
47 ' "before": [{"text": "one step of how the code behaved before", "editIds": [1]}],',
48 ' "after": [{"text": "one step of how it behaves now", "changed": true, "editIds": [1]}],',
49 ' "edits": [{"id": 1, "why": "the most likely reason for this edit, one short sentence"}]}',
50 'Use 3 to 7 steps in each flow. Mark an "after" step changed only when its behavior differs.',
51 'If the behavior did not change (a refactor or docs), say so in the explanation and keep the flows identical.',
52 ].join('\n')
53}
54
55function isRecord(value: unknown): value is Record<string, unknown> {
56 return typeof value === 'object' && value !== null && !Array.isArray(value)
57}
58
59function numbersOf(value: unknown): number[] {
60 return Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number') : []
61}
62
63function stepsOf(options: { value: unknown; isAfter: boolean }): FlowStep[] {
64 if (!Array.isArray(options.value)) {
65 return []
66 }
67 return options.value
68 .filter(isRecord)
69 .filter(step => typeof step.text === 'string')
70 .slice(0, 9)
71 .map(step => ({
72 text: String(step.text).slice(0, 200),
73 isChanged: options.isAfter && step.changed === true,
74 editIds: numbersOf(step.editIds),
75 }))
76}
77
78export function parseSummary(text: string): TurnSummary | null {
79 const start = text.indexOf('{')
80 const end = text.lastIndexOf('}')
81 if (start < 0 || end <= start) {
82 return null
83 }
84 let parsed: unknown
85 try {
86 parsed = JSON.parse(text.slice(start, end + 1))
87 } catch {
88 return null
89 }
90 if (!isRecord(parsed) || typeof parsed.title !== 'string' || typeof parsed.explanation !== 'string') {
91 return null
92 }
93 return {
94 title: parsed.title.slice(0, 80),
95 explanation: parsed.explanation.slice(0, 900),
96 before: stepsOf({ value: parsed.before, isAfter: false }),
97 after: stepsOf({ value: parsed.after, isAfter: true }),
98 editReasons: Array.isArray(parsed.edits)
99 ? parsed.edits
100 .filter(isRecord)
101 .filter(edit => typeof edit.id === 'number' && typeof edit.why === 'string')
102 .map(edit => ({ id: Number(edit.id), why: String(edit.why).slice(0, 300) }))
103 : [],
104 }
105}
106hooks/journal/types.ts 71 lines1import type { Helper } from './helper'
2
3export type EditStatus = 'applied' | 'failed' | 'denied'
4
5export type EditSource = 'Edit' | 'Write' | 'NotebookEdit' | 'Command'
6
7export type Hunk = {
8 oldStart: number
9 oldLines: number
10 newStart: number
11 newLines: number
12 lines: string[]
13}
14
15export type EditEntry = {
16 id: number
17 turn: number
18 source: EditSource
19 path: string
20 status: EditStatus
21 added: number
22 removed: number
23 reason: string
24 isReasonInferred: boolean
25 note: string
26 at: number
27 hunks: Hunk[]
28 hunkCount: number
29}
30
31export type TestRun = {
32 id: number
33 turn: number
34 command: string
35 isPassing: boolean
36 at: number
37}
38
39export type FlowStep = { text: string; isChanged: boolean; editIds: number[] }
40
41export type EditReason = { id: number; why: string }
42
43export type TurnSummary = {
44 title: string
45 explanation: string
46 before: FlowStep[]
47 after: FlowStep[]
48 editReasons: EditReason[]
49}
50
51export type SummaryStatus = 'off' | 'waiting' | 'running' | 'ready' | 'failed'
52
53export type TurnEntry = {
54 index: number
55 prompt: string
56 startedAt: number
57 endedAt: number | null
58 summaryStatus: SummaryStatus
59 summary: TurnSummary | null
60}
61
62export type JournalSettings = Helper & {
63 liveSummaries: boolean
64 autoOpen: boolean
65}
66
67export type PendingWrites = {
68 edits: { id: number; text: string }[]
69 data: string | undefined
70}
71hooks/pane.tsx 105 lines1import type { EngineInterface, On, RenderSurface } from 'claude-code'
2
3import { totals } from './journal/output'
4import { actionsTree, COLORS, detailTree, editRow, hintText, testRow } from './journal/panel'
5import type { PaneAction } from './journal/panel'
6import { journalView } from './journal/state'
7import type { TurnEntry } from './journal/types'
8import { noop } from './shared/noop'
9import { truncate } from './shared/text/meter'
10
11export const PANE_ID = 'change-journal'
12const TURNS_SHOWN = 4
13
14let selectedEditId: number | undefined
15
16function turnTitle(turn: TurnEntry): string {
17 return turn.summary?.title ?? turn.prompt
18}
19
20function pagePath(): string {
21 return `${journalView().folder}/index.html`
22}
23
24function pageTargets(): string[] {
25 const selected = journalView().edits.find(edit => edit.id === selectedEditId)
26 return selected ? [`file://${pagePath()}#turn=${selected.turn}&tab=changes`, pagePath()] : [pagePath()]
27}
28
29async function openInBrowser($: EngineInterface, options: { targets: string[] }): Promise<void> {
30 for (const target of options.targets) {
31 for (const opener of ['open', 'xdg-open']) {
32 const opened = await $.process.run([opener, target], { timeoutMs: 5000 }).catch(() => undefined)
33 if (opened?.exitCode === 0) {
34 return
35 }
36 }
37 }
38}
39
40async function perform($: EngineInterface, options: { action: PaneAction; surface: RenderSurface }): Promise<void> {
41 const { action } = options
42 if (action.kind === 'select') {
43 selectedEditId = selectedEditId === action.editId ? undefined : action.editId
44 $.ui.invalidate('ui.render')
45 } else if (action.kind === 'open') {
46 await openInBrowser($, { targets: pageTargets() })
47 } else if (action.kind === 'copy') {
48 const copied = await $.ui.copy({ text: pagePath(), surface: options.surface })
49 $.ui.toast(copied.isCopied ? 'Copied the page path' : 'Could not copy here. Type /changes path to print it.')
50 } else {
51 await $.ui.close({ id: PANE_ID })
52 }
53}
54
55export function installPane(on: On): void {
56 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
57 const elements = $.ui.resolve(e)
58 const { Box, Text } = elements
59 const journal = journalView()
60 const width = Math.max(30, e.props.bodyColumns)
61 const sum = totals()
62 const act = (action: PaneAction) => {
63 perform($, { action, surface: e.surface }).catch(noop)
64 }
65 const maxChurn = Math.max(1, ...journal.edits.map(edit => edit.added + edit.removed))
66 const turns = journal.turns
67 .filter(turn => journal.edits.some(edit => edit.turn === turn.index) || journal.tests.some(test => test.turn === turn.index))
68 .slice(-TURNS_SHOWN)
69 .reverse()
70 const numbered = turns.flatMap(turn => journal.edits.filter(edit => edit.turn === turn.index))
71 const numberOf = (id: number) => numbered.findIndex(edit => edit.id === id) + 1
72 const selected = numbered.find(edit => edit.id === selectedEditId)
73 const latestReason = [...journal.edits].reverse().find(edit => edit.reason !== '')?.reason
74
75 return (
76 <Box flexDirection="column">
77 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
78 <Text bold color={COLORS.turn}>Change Journal</Text>
79 <Text dimColor>{`${sum.files} files +${sum.added} −${sum.removed} ✓${sum.passing} ✗${sum.failing}`}</Text>
80 </Box>
81 <Text color={COLORS.muted}>{hintText(e.props.isFocused)}</Text>
82 <Text dimColor>{'─'.repeat(width)}</Text>
83 {turns.length === 0 && <Text dimColor>No changes yet. Edits appear here and on the page as Claude makes them.</Text>}
84 {turns.map(turn => (
85 <Box key={`turn-${turn.index}`} flexDirection="column" marginBottom={1}>
86 <Text>
87 <Text color={COLORS.turn}>{`Turn ${turn.index} `}</Text>
88 <Text bold>{truncate({ text: turnTitle(turn), width: width - 10 })}</Text>
89 </Text>
90 {journal.edits
91 .filter(edit => edit.turn === turn.index)
92 .map(edit => editRow({ elements, act, edit, number: numberOf(edit.id), maxChurn, isSelected: edit.id === selectedEditId, width }))}
93 {journal.tests.filter(test => test.turn === turn.index).map(test => testRow({ elements, test, width }))}
94 </Box>
95 ))}
96 {selected && detailTree({ elements, edit: selected, number: numberOf(selected.id), width })}
97 {!selected && latestReason !== undefined && (
98 <Text italic dimColor>{`Why: ${truncate({ text: latestReason, width: width * 2 - 6 })}`}</Text>
99 )}
100 {actionsTree({ elements, act, hasSelection: selected !== undefined })}
101 </Box>
102 )
103 })
104}
105hooks/reasons.ts 32 lines1import type { On, TurnStepChunk } from 'claude-code'
2
3import { reasonFrom } from './journal/reason'
4import type { StepNarration } from './journal/reason'
5import { recordReason } from './journal/state'
6
7function noteChunk(options: { chunk: TurnStepChunk; narration: StepNarration }): StepNarration {
8 const { chunk, narration } = options
9 if (chunk.kind === 'text') {
10 return { ...narration, text: narration.text + chunk.text }
11 }
12 if (chunk.kind === 'thinking') {
13 return { ...narration, thinking: narration.thinking + chunk.text }
14 }
15 if (chunk.kind === 'tool') {
16 recordReason({ toolUseId: chunk.id, reason: reasonFrom(narration) })
17 }
18 return narration
19}
20
21export function installReasons(on: On): void {
22 on('turn.step', async function* ($, e, next) {
23 const stream = next(e)
24 let narration: StepNarration = { text: '', thinking: '' }
25 for await (const chunk of stream) {
26 narration = noteChunk({ chunk, narration })
27 yield chunk
28 }
29 return await stream.result
30 })
31}
32hooks/shared/noop.ts 4 lines1export function noop(): undefined {
2 return undefined
3}
4