Pane ledger of files edited, tool outcomes, safety-guard rewrites and directives touched; writes a handoff file.

A function-hooks mod that keeps a ledger of the session in a Pane beside the conversation and writes a workspace handoff file.
session.start: registers /session-ledger and opens the pane ($.ui.open({ id: 'session-ledger' })).command.run (/session-ledger): re-opens the pane.prompt.submit: remembers the first prompt (handoff slug = kebab of its first 4 words, else session).tool.call: records file paths of Edit / Write / MultiEdit / NotebookEdit (file_path / notebook_path, made relative to $.session.root()), Read/Edit/Write of directives/**, and each call's outcome (ok / isError / deny, last 50).turn.complete (main loop only): auto-handoff when >= 5 distinct files were edited and no handoff exists yet, or the edited-file count changed since the last one.ui.render on { component: 'Pane', requestId: 'session-ledger' }.execution/, directives/, deliverables/, projects/, .claude/, tests/, other (count per group; paths truncated to the viewport rows).Writes .claude/handoffs/HANDOFF_<YYYY-MM-DD>_<slug>.md via $.fs.write and toasts the path. Headings mirror .claude/handoffs/2026-06-15-cv-optimizer-and-workspace-hardening.md: # Workspace Handoff — <date>, ## Where we ended (grouped files, hooks summary, safety-guard ledger), then ## What was NOT done today — gap list, ## Lessons captured today (do NOT repeat), ## Live endpoints (for mobile spot-checks), ## Recommended next-session opening (one-line placeholders for the operator).
plugin.json lists safety-guard-mod under dependencies. The mod reads $.state.get({ plugin: 'safety-guard-mod', key: 'ledger' }), typed Array<{ at, kind: 'rewrite'|'block'|'ask', rule, command, rewritten? }>. That contract is mirrored as an ambient PluginState['safety-guard-mod'] declaration in types/index.d.ts (safety-guard-mod's own types were not yet written when this was built); keep the two in sync. If the read fails (plugin not loaded), the pane shows "safety-guard-mod not loaded".
/plugin -> select session-ledger-mod -> Disable (or remove the folder from .claude/mods/).
turn.complete: exactly 1 file written (a second turn does not rewrite), headings correct, in order: yes...._session.md).claude plugin validate passes; tsc clean.hooks/register.tsx 192 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SafetyGuardLedgerEntry, SessionLedgerHook } from '../types'
5
6const PANE = 'session-ledger'
7const TITLE = 'Session ledger'
8const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
9const DIR_TOOLS = ['Read', 'Edit', 'Write']
10export const GROUPS = ['execution/', 'directives/', 'deliverables/', 'projects/', '.claude/', 'tests/', 'other']
11export const HEADINGS = [
12 '## Where we ended',
13 '## What was NOT done today — gap list',
14 '## Lessons captured today (do NOT repeat)',
15 '## Live endpoints (for mobile spot-checks)',
16 '## Recommended next-session opening',
17]
18
19const files = atom({ plugin: 'session-ledger-mod', key: 'files' } as const, [])
20const hooks = atom({ plugin: 'session-ledger-mod', key: 'hooks' } as const, [])
21const directives = atom({ plugin: 'session-ledger-mod', key: 'directives' } as const, [])
22const firstPrompt = atom({ plugin: 'session-ledger-mod', key: 'firstPrompt' } as const, '')
23const handoff = atom({ plugin: 'session-ledger-mod', key: 'handoff' } as const, null)
24const guardLedger = { plugin: 'safety-guard-mod', key: 'ledger' } as const
25
26type $ = EngineInterface
27
28async function rootOf($: $): Promise<string> {
29 try {
30 return (await $.session.root()).replace(/\\/g, '/').replace(/\/+$/, '')
31 } catch {
32 return ''
33 }
34}
35
36export function relative(root: string, path: string): string {
37 const p = path.replace(/\\/g, '/')
38 if (root && p.startsWith(root + '/')) return p.slice(root.length + 1)
39 return p.replace(/^\.\//, '').replace(/^\/+/, '')
40}
41
42export function groupOf(rel: string): string {
43 const top = rel.split('/')[0] + '/'
44 return GROUPS.includes(top) ? top : 'other'
45}
46
47export function grouped(list: readonly string[]): Array<[string, string[]]> {
48 return GROUPS.map(g => [g, list.filter(f => groupOf(f) === g)] as [string, string[]]).filter(
49 ([, l]) => l.length > 0,
50 )
51}
52
53async function readGuard($: $): Promise<SafetyGuardLedgerEntry[] | null> {
54 try {
55 const { value } = await $.state.get(guardLedger)
56 return value ?? []
57 } catch {
58 return null
59 }
60}
61
62function slugOf(prompt: string): string {
63 const s = prompt
64 .split(/\s+/)
65 .filter(Boolean)
66 .slice(0, 4)
67 .join(' ')
68 .toLowerCase()
69 .replace(/[^a-z0-9]+/g, '-')
70 .replace(/^-+|-+$/g, '')
71 return s || 'session'
72}
73
74function summarize(list: readonly SessionLedgerHook[]): string[] {
75 const by = new Map<string, { ok: number; isError: number; deny: number }>()
76 for (const h of list) {
77 const c = by.get(h.tool) ?? { ok: 0, isError: 0, deny: 0 }
78 c[h.outcome]++
79 by.set(h.tool, c)
80 }
81 return [...by].map(([t, c]) => `${t}: ok ${c.ok}, isError ${c.isError}, deny ${c.deny}`)
82}
83
84export async function writeHandoff($: $): Promise<string> {
85 const list = await read($, files)
86 const date = new Date().toISOString().slice(0, 10)
87 const path = `.claude/handoffs/HANDOFF_${date}_${slugOf(await read($, firstPrompt))}.md`
88 const guard = await readGuard($)
89 const lines: string[] = [`# Workspace Handoff — ${date}`, '', HEADINGS[0]!, '', '### Files edited this session', '']
90 for (const [g, l] of grouped(list)) {
91 lines.push(`**${g}** (${l.length})`, ...l.map(f => `- ${f}`), '')
92 }
93 lines.push('### Hooks fired (last 50 tool calls)', '', ...summarize(await read($, hooks)).map(s => `- ${s}`), '')
94 lines.push('### Safety-guard rewrites/blocks', '')
95 if (guard === null) lines.push('- safety-guard-mod not loaded')
96 else if (guard.length === 0) lines.push('- none')
97 else lines.push(...guard.map(g => `- ${g.kind} [${g.rule}] ${g.command}${g.rewritten ? ` -> ${g.rewritten}` : ''}`))
98 lines.push('')
99 for (const h of HEADINGS.slice(1)) lines.push(h, '', '_Operator: fill in._', '')
100 const root = await rootOf($)
101 await $.fs.write(root ? `${root}/${path}` : path, lines.join('\n'))
102 await update($, handoff, () => ({ path, fileCount: list.length }))
103 $.ui.toast(`Handoff written: ${path}`)
104 return path
105}
106
107export const register: Register = on => {
108 on('session.start', async ($, e, next) => {
109 await $.command.register({ name: 'session-ledger', description: 'Open the session ledger pane' })
110 void $.ui.open({ id: PANE, title: TITLE })
111 return next(e)
112 })
113
114 on('command.run', { command: 'session-ledger' }, async $ => {
115 await $.ui.open({ id: PANE, title: TITLE })
116 return { text: 'Session ledger pane opened.' }
117 })
118
119 on('prompt.submit', async ($, e, next) => {
120 if ((await read($, firstPrompt)) === '' && e.text.trim()) await update($, firstPrompt, () => e.text)
121 return next(e)
122 })
123
124 on('tool.call', async ($, e, next) => {
125 const tool = String(e.tool)
126 const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
127 const raw = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
128 const rel = raw === undefined ? undefined : relative(await rootOf($), raw)
129 if (rel !== undefined && EDIT_TOOLS.includes(tool)) {
130 await update($, files, l => (l.includes(rel) ? l : [...l, rel]))
131 }
132 if (rel !== undefined && DIR_TOOLS.includes(tool) && rel.startsWith('directives/')) {
133 await update($, directives, l => (l.includes(rel) ? l : [...l, rel]))
134 }
135 const ran = await next(e)
136 const r = ran as { deny?: unknown; isError?: unknown }
137 const outcome: SessionLedgerHook['outcome'] = typeof r.deny === 'string' ? 'deny' : r.isError ? 'isError' : 'ok'
138 await update($, hooks, l => [...l, { tool, outcome }].slice(-50))
139 return ran
140 })
141
142 on('turn.complete', async ($, e, next) => {
143 if (e.agentId === undefined) {
144 const count = (await read($, files)).length
145 const last = await read($, handoff)
146 if (count >= 5 && (last === null || last.fileCount !== count)) await writeHandoff($)
147 }
148 return next(e)
149 })
150
151 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
152 const { Box, Text, Button } = $.ui.resolve(e)
153 const list = await read($, files)
154 const hookList = await read($, hooks)
155 const dirs = await read($, directives)
156 const guard = await readGuard($)
157 const room = Math.max(1, (e.viewport?.rows ?? 24) - 12)
158 const paths = list.slice(0, room)
159 return (
160 <Box flexDirection="column">
161 <Text bold>Files edited this session ({list.length})</Text>
162 {list.length === 0 && <Text dimColor>None yet.</Text>}
163 {grouped(paths).map(([g, l]) => (
164 <Box flexDirection="column">
165 <Text>{`${g} (${list.filter(f => groupOf(f) === g).length})`}</Text>
166 {l.map(f => (
167 <Text dimColor>{` ${f}`}</Text>
168 ))}
169 </Box>
170 ))}
171 <Text bold>Hooks fired (last {hookList.length})</Text>
172 {summarize(hookList).map(s => (
173 <Text dimColor>{s}</Text>
174 ))}
175 <Text bold>Safety-guard rewrites/blocks</Text>
176 {guard === null ? (
177 <Text dimColor>safety-guard-mod not loaded</Text>
178 ) : guard.length === 0 ? (
179 <Text dimColor>None.</Text>
180 ) : (
181 guard.slice(-room).map(g => <Text dimColor>{`${g.kind} ${g.rule}: ${g.command}`}</Text>)
182 )}
183 <Text bold>Directives touched ({dirs.length})</Text>
184 {dirs.slice(0, room).map(d => (
185 <Text dimColor>{d}</Text>
186 ))}
187 <Button key="write-handoff" label="Write handoff now" onPress={() => void writeHandoff($)} />
188 </Box>
189 )
190 })
191}
192types/index.d.ts 25 lines1// session-ledger-mod contract: its own $.state. safety-guard-mod's `ledger` contract comes
2// from that plugin (listed under `dependencies`); the engine lays it beside this mod's types.
3// SafetyGuardLedgerEntry below only names the row shape this mod reads.
4export type SessionLedgerHook = { tool: string; outcome: 'ok' | 'isError' | 'deny' }
5export type SessionLedgerHandoff = { path: string; fileCount: number }
6export type SafetyGuardLedgerEntry = {
7 at: number | string
8 kind: 'rewrite' | 'block' | 'ask'
9 rule: string
10 command: string
11 rewritten?: string
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'session-ledger-mod': {
17 files: string[]
18 hooks: SessionLedgerHook[]
19 directives: string[]
20 firstPrompt: string
21 handoff: SessionLedgerHandoff | null
22 }
23 }
24}
25