SLOPSHOPPER

change-journal

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

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · change-journal
│ ┃ Change Journal ✕ › fix the failing auth test and add an audit log call │ ┃ Change Journal 0 files +0 −0 ✓0 ✗1 │ ┃ ctrl+x tab: use the keys here, or type ⏺ Read(src/auth.ts) │ ┃ /changes open ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ Turn 0 Before the first prompt ⏺ Bash(bun test) │ ┃ ✗ bun test ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ o: Open the page c: Copy the page path x: │ ✻ Worked for 42s · done 4:20 PM │ │ › /changes │ ⎿ change-journal: Change Journal pane opened with the keys; Esc gi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Change Journal
Change Journal 0 files +0 −0 ✓0 ✗1 ctrl+x tab: use the keys here, or type /changes open ──────────────────────────────────────────────────────── Turn 0 Before the first prompt ✗ bun test o: Open the page c: Copy the page path x: Close
README

Change Journal

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.

Change Journal preview

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

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.

  • Rail: each turn is a ring on a line, and each edit is a dot in its status color. Click any of them to jump to it.
  • Changes: the turn's title and explanation, then one card per edit with its diff and line numbers. Each card shows Claude's reason, taken from what Claude wrote just before the edit. When Claude edited without saying why, the turn summary fills in a likely reason, marked as inferred.
  • Behavior: before and after flows for each turn. Changed steps are highlighted and link to the edits that changed them.
  • Files: every changed file, the busiest first. Click one to see its whole history.

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.

How to use

  1. Work with Claude as usual. The page opens in your browser on the first edit, and the status line shows ✎ 3 files +40 −12.
  2. Type /changes to see the latest turns in the terminal. The pane opens with the keys.
  3. Press a number to see that edit's reason and diff, or 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.

How to interact

Change Journal pane with an edit shown

The hint row under the title always names the keys that work now.

KeyWhat it does
1 to 9Show that edit under the list: its reason and its first changed lines. Press the number again to hide it.
oOpen the page. With an edit shown, the page opens on that edit's turn.
cCopy the page path.
xClose the pane.
Tab, EnterMove to the next row or button, and press it.
EscGive the keys back to the prompt. The pane stays open.
ctrl+x tabGive 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.

Settings

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

Privacy

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.

Source 19 files
hooks/register.ts 162 lines
1import 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}
162
hooks/edits.ts 96 lines
1import 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}
96
hooks/journal/helper.ts 16 lines
1import 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}
16
hooks/journal/output.ts 58 lines
1import { 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}
58
hooks/journal/restore.ts 121 lines
1import 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}
121
hooks/journal/settings.ts 13 lines
1import { 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}
13
hooks/journal/state.ts 187 lines
1import { 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}
187
hooks/journal/summary.ts 106 lines
1import { 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}
106
hooks/journal/types.ts 71 lines
1import 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}
71
hooks/pane.tsx 105 lines
1import 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}
105
hooks/reasons.ts 32 lines
1import 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}
32
hooks/shared/noop.ts 4 lines
1export function noop(): undefined {
2  return undefined
3}
4