A per-session ledger of topics, decisions, open questions, to-dos and board state, kept by Claude through a tool and shown in a pane.

Two Claude Code mods.
In a terminal:
claude plugin marketplace add gabedeluna/claude-mods
claude plugin install ledger@claude-mods
claude plugin install sidechat@claude-mods
Then restart Claude Code.
A running record of the session, kept by Claude as you work: topics, the decisions you made (quoted in your own words), open questions with a recommended answer and buttons to answer them, and to-dos for you and for Claude, ticked off as they get done. /ledger opens the panel.
A side panel for questions about the session that never reach the main conversation. /sidechat <question> opens it and asks; recap, now and more (explains the text you last copied) are buttons at the top. Answers can look up files and code, follow-up buttons go deeper, Draw it turns an answer into a diagram, and Send to main writes a note into your prompt. Click the box at the bottom to type; Enter sends, Shift+Enter adds a line.
On Windows Terminal, Shift+Enter needs a key setting: add an action that sends \u001b\r on shift+enter in the terminal's settings.json.
hooks/register.tsx 998 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LedgerBoard, LedgerCard, LedgerDoc, LedgerFilter, LedgerItem, LedgerTopic } from '../types'
5import {
6 applyOps,
7 boardDigest,
8 boardView,
9 cardChanges,
10 cleanName,
11 digest,
12 emptyDoc,
13 gapsFor,
14 isLogged,
15 addSaid,
16 isOpenFor,
17 openTodoLines,
18 agentBrief,
19 stepText,
20 compareTasks,
21 newBoardToolCards,
22 parseBoardCards,
23} from './model'
24import type { Gap, LedgerOp } from './model'
25
26const PANE = 'ledger'
27const TOOL = 'mcp__ledger__ledger'
28const BOARD_TOOL = 'mcp__board__board'
29
30const ACC = '#e0905f'
31const OK = '#7ee0a1'
32const TEAL = '#5fd4c4'
33const BLUE = '#79b8ff'
34const VIO = '#c3a6ff'
35const AMB = '#e8c06a'
36const GREY = '#8b929c'
37const INK = '#14161a'
38
39const docA = atom({ plugin: 'ledger', key: 'doc' } as const, emptyDoc())
40const boardA = atom({ plugin: 'ledger', key: 'board' } as const, null)
41const openA = atom({ plugin: 'ledger', key: 'open' } as const, {})
42const filterA = atom({ plugin: 'ledger', key: 'filter' } as const, 'all')
43const turnsA = atom({ plugin: 'ledger', key: 'turns' } as const, 0)
44// Names of dispatched agents that have finished (completed, failed or killed): a step still
45// open under such a name is flagged wherever steps are listed.
46const finishedA = atom({ plugin: 'ledger', key: 'finished' } as const, [] as string[])
47
48// Agent types that only read: no steps required to dispatch them.
49const READ_ONLY_AGENTS = new Set(['Explore', 'Plan', 'claude-code-guide', 'test-context-researcher', 'statusline-setup'])
50
51const DESCRIPTION = [
52 "The session ledger: the running record of this conversation, shown in the user's Ledger pane and attached to the conversation when it matters.",
53 'Update it (several ops in one call) as things happen, not at the end:',
54 '- a topic opens or its status changes: op topic (status now|exploring|shaped|chosen|hold|rejected|review|done; only one topic is "now").',
55 "- the user rules on something: op decide, with quote = the user's exact words copied from their message (use ... to skip words). It is rejected unless the quote is in a user message. Never record your own recommendation as decided.",
56 "- you ask the user something, or recommend something they have not confirmed: op ask, owner you, rec = your recommended answer written as the user would say it ('Yes, build it next.'), detail = why.",
57 "- the user answers a question: op answer, item = its id, text = the decision, quote = the user's exact words. It moves to Decided. A question that stops mattering is answered too, never closed: text says what was decided (for example \"not doing this\"), quote = the user's words that settled it. Dropping a question is a decision.",
58 '- either of you commits to doing something: op todo, owner you|claude.',
59 '- you start building something: before writing code, add each major step of the build as a to-do (owner claude), in the order you will do it, worded as the action in plain words so the user can tell what is happening: "Build the panel the side chat lives in", "Build the file and board lookups", "Write the tests for asking a question", "Run the tests", "Install it for every session". Not a feature label like "Lookups, read-only". Close each with evidence the moment it is done; add new steps the same way as they come up.',
60 '- you dispatch an agent to do work: first give it steps. For an OpenSpec change: op tasks (topic, change=<change name>, agent=<agent name>, tasks="3.1,3.2" to pick some) loads the unticked tasks of openspec/changes/<change>/tasks.md as numbered to-dos. For any other work: op todo with agent=<agent name> per step, in order; the tool numbers them 1, 2, 3. Dispatching a working agent by name with no open steps under that name is refused; when steps exist, the agent\'s prompt and every follow-up message to it carry its step ids and the close instruction automatically. Reassign with op assign (agent, and item or topic+tasks).',
61 '- a fact changes the options: op learn.',
62 "- something is finished: op close with item id (or topic + task number); Claude's to-dos need evidence (what ran and what it showed). A step whose agent has finished while it is still open is flagged FINISHED; close it with evidence you verified or reopen it as work still owed.",
63 'Link a topic to its board card with op topic card=<card-id>, or set exempt=true when the board rules exempt the work (Claude meta-tooling). The ledger only reads the board: you write it with the board tool, logging each decision with the user\'s exact words in quotes; the ledger sees the quote on the card and marks it logged by itself. Use op logged item=<id> only when the card log cannot carry the quote.',
64 'Other ops: edit (item, text, detail, owner), reopen, remove, hold (text, or empty to clear), title (text). An empty ops list returns the current ledger.',
65].join('\n')
66
67const SCHEMA = {
68 type: 'object',
69 properties: {
70 ops: {
71 type: 'array',
72 items: {
73 type: 'object',
74 properties: {
75 op: {
76 type: 'string',
77 enum: ['topic', 'decide', 'ask', 'answer', 'todo', 'learn', 'close', 'reopen', 'edit', 'remove', 'logged', 'hold', 'title', 'tasks', 'assign'],
78 },
79 agent: { type: 'string', description: 'todo, tasks, assign: the name of the agent responsible for the step(s).' },
80 task: { type: 'string', description: 'A step number such as 3.4; with topic, names an item instead of its id.' },
81 change: { type: 'string', description: 'tasks: the OpenSpec change whose tasks.md is loaded.' },
82 tasks: { type: 'string', description: 'tasks, assign: comma-separated step numbers to load or reassign.' },
83 topic: { type: 'string', description: 'Topic name or id; a new name opens the topic.' },
84 text: { type: 'string' },
85 quote: { type: 'string', description: "decide: the user's exact words." },
86 owner: { type: 'string', enum: ['you', 'claude'], description: 'you = the user.' },
87 detail: { type: 'string', description: 'ask: why you recommend it. todo: what it involves.' },
88 rec: { type: 'string', description: "ask: your recommended answer, in the user's voice." },
89 item: { type: 'string', description: 'An item id such as ledger.3.' },
90 status: { type: 'string' },
91 card: { type: 'string', description: 'Board card id; empty string unlinks.' },
92 exempt: { type: 'boolean' },
93 evidence: { type: 'string' },
94 },
95 required: ['op'],
96 },
97 },
98 },
99 required: ['ops'],
100}
101
102type Env = EngineInterface
103
104let sessionId = ''
105let boardPath = ''
106let allCards: Record<string, LedgerCard> = {}
107let lastMtime = 0
108let ownMtime = -1
109let needFull = false
110let lastExtras = ''
111const seenBoardUses = new Set<string>()
112
113async function save($: Env, doc: LedgerDoc) {
114 if (!sessionId) return
115 await $.store.set(`doc:${sessionId}`, doc)
116 const ids = ((await $.store.get('sessions')) as string[] | undefined) ?? []
117 const next = [...ids.filter((i) => i !== sessionId), sessionId]
118 for (const old of next.slice(0, Math.max(0, next.length - 40))) {
119 await $.store.delete(`doc:${old}`)
120 await $.store.delete(`said:${old}`)
121 }
122 await $.store.set('sessions', next.slice(-40))
123}
124
125async function findBoard($: Env): Promise<string> {
126 let dir = (await $.session.root()).replace(/\\/g, '/')
127 for (let i = 0; i < 5; i += 1) {
128 const p = `${dir}/../Telify-board/docs/board/index.html`
129 if (await $.fs.exists(p)) return (await $.fs.stat(p, { resolve: true })).realPath ?? p
130 dir = `${dir}/..`
131 }
132 return ''
133}
134
135function sameFile(a: string, b: string) {
136 return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
137}
138
139// Re-derive what the pane shows from the cached board, for a doc that just changed.
140async function refreshView($: Env) {
141 const prev = await read($, boardA)
142 if (!prev?.found) return
143 // Just reloaded: the parsed cards are gone until the board is read again.
144 if (!Object.keys(allCards).length) return refreshBoard($, true)
145 const doc = await read($, docA)
146 const view = boardView(allCards, doc)
147 await update($, boardA, () => ({ ...prev, cards: view.cards, rulings: view.rulings }))
148}
149
150// The tasks.md of an OpenSpec change under the session root; undefined when there is none.
151async function readTasks($: Env, change: string): Promise<string | undefined> {
152 const root = (await $.session.root()).replace(/\\/g, '/')
153 const p = `${root}/openspec/changes/${change.trim()}/tasks.md`
154 if (!(await $.fs.exists(p))) return undefined
155 return (await $.fs.read(p)) as string
156}
157
158// Names of dispatched agents no longer running, for the FINISHED flag on their open steps.
159async function refreshAgents($: Env) {
160 const agents = await $.agent.list()
161 const finished = agents.filter((a) => a.name && a.status !== 'running').map((a) => a.name as string)
162 const prev = await read($, finishedA)
163 if (prev.length === finished.length && prev.every((n, i) => n === finished[i])) return
164 await update($, finishedA, () => finished)
165}
166
167async function refreshBoard($: Env, force = false) {
168 if (!boardPath) {
169 await update($, boardA, () => ({ found: false, path: '', cards: {}, rulings: [], changes: [] }))
170 return
171 }
172 const st = await $.fs.stat(boardPath)
173 if (!force && st.mtimeMs === lastMtime) return
174 const own = await ownBoardCards($)
175 const html = (await $.fs.read(boardPath)) as string
176 allCards = parseBoardCards(html)
177 const doc = await read($, docA)
178 const view = boardView(allCards, doc)
179 const prev = await read($, boardA)
180 const now = await $.clock.now()
181 const changes = [...(prev?.changes ?? [])]
182 if (prev?.found && lastMtime) {
183 for (const card of Object.values(view.cards)) {
184 const by: 'claude' | 'other' = st.mtimeMs === ownMtime || own.has(card.id) ? 'claude' : 'other'
185 for (const text of cardChanges(prev.cards[card.id], card)) changes.push({ card: card.id, text, by, at: now })
186 }
187 }
188 lastMtime = st.mtimeMs
189 const board: LedgerBoard = { found: true, path: boardPath, cards: view.cards, rulings: view.rulings, changes: changes.slice(-20) }
190 await update($, boardA, () => board)
191}
192
193// Cards this session changed through the board tool since the last look. The board mod answers
194// that tool itself, so the ledger never sits in its call; the session's own tool list says it ran.
195async function ownBoardCards($: Env): Promise<Set<string>> {
196 return newBoardToolCards((await $.session.messages()).flatMap((m) => m.toolUses), seenBoardUses)
197}
198
199// The user's words: their messages, and what they picked or typed in a question box.
200// Every message the person sends, saved as it is sent: one sent mid-turn is stored as a queued note,
201// never as a user message, so the transcript alone would miss it.
202async function ensureSession($: Env) {
203 if (!sessionId) sessionId = await $.session.id().catch(() => '')
204 return sessionId
205}
206
207async function rememberSaid($: Env, text: string) {
208 if (!(await ensureSession($))) return
209 const said = ((await $.store.get(`said:${sessionId}`)) as string[] | undefined) ?? []
210 const next = addSaid(said, text)
211 if (next !== said) await $.store.set(`said:${sessionId}`, next)
212}
213
214async function userTexts($: Env) {
215 const said = (await ensureSession($)) ? (((await $.store.get(`said:${sessionId}`)) as string[] | undefined) ?? []) : []
216 const messages = await $.session.messages()
217 const typed = messages.filter((m) => m.role === 'user').map((m) => m.text)
218 const picked = messages
219 .flatMap((m) => m.toolUses)
220 .filter((u) => u.tool === 'AskUserQuestion' && !u.isError && typeof u.text === 'string')
221 .map((u) => u.text as string)
222 return [...typed, ...picked, ...said]
223}
224
225// Your own to-do, checked off or back on from the panel; Claude's close only through the tool, with evidence.
226async function checkTodo($: Env, item: string, done: boolean) {
227 const doc = await read($, docA)
228 const r = applyOps(doc, [{ op: done ? 'close' : 'reopen', item }], { userTexts: [], turn: await $.session.turns() })
229 if (r.changed) await commit($, r.doc)
230}
231
232async function commit($: Env, doc: LedgerDoc) {
233 await update($, docA, () => doc)
234 await save($, doc)
235 await refreshView($)
236}
237
238// Board edits Claude makes: the next board read attributes them to Claude, not another session.
239async function markOwnBoardWrite($: Env, path: string | undefined, before: number) {
240 if (!boardPath || !path) return
241 const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath ?? path
242 if (!sameFile(real, boardPath)) return
243 const st = await $.fs.stat(boardPath)
244 if (st.mtimeMs !== before) {
245 ownMtime = st.mtimeMs
246 await refreshBoard($)
247 }
248}
249
250// Puts an answer in the prompt box, after anything already typed there.
251async function fillPrompt($: Env, text: string) {
252 const box = await $.prompt.read()
253 const draft = box.text.trimEnd()
254 await $.prompt.fill({ text: draft ? `${draft}\n${text}` : text })
255}
256
257export const register: Register = (on) => {
258 on('session.start', async ($, e, next) => {
259 sessionId = await $.session.id()
260 const stored = (await $.store.get(`doc:${sessionId}`)) as LedgerDoc | undefined
261 const held = await read($, docA)
262 const doc = held.topics.length ? held : stored ?? emptyDoc()
263 await update($, docA, () => doc)
264 await update($, turnsA, () => 0)
265 // A hot reload keeps the state, so only a fresh start or a resume re-sends the whole ledger.
266 needFull = !held.topics.length && doc.topics.length > 0
267 await $.tool.register({ name: 'ledger', description: DESCRIPTION, inputSchema: SCHEMA })
268 await $.command.register({
269 name: 'ledger',
270 description: 'Open the session ledger (close · catch-up)',
271 argumentHint: '[close|catch-up]',
272 })
273 boardPath = await findBoard($)
274 await refreshBoard($, true)
275 $.clock.every(20000, () => {
276 void refreshBoard($)
277 void refreshAgents($)
278 })
279 return next(e)
280 })
281
282 // The dispatch door: the Agent tool call itself (the engine's agent.spawn event did not reach a
283 // hook of this plugin in the live session on 2026-10-03). A working agent dispatched by name
284 // needs open steps under that name; with them, its prompt ends with the brief (ids, the tool,
285 // close as you go).
286 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
287 const input = e as unknown as { prompt?: unknown; name?: unknown; subagent_type?: unknown }
288 const type = typeof input.subagent_type === 'string' ? input.subagent_type : 'general-purpose'
289 if (READ_ONLY_AGENTS.has(type) || typeof input.prompt !== 'string') return next(e)
290 const name = typeof input.name === 'string' ? input.name.trim() : ''
291 const doc = await read($, docA)
292 const brief = name ? agentBrief(doc, name) : null
293 if (!brief) {
294 return {
295 deny: name
296 ? `Ledger: no open steps are assigned to @${name}. Before dispatching, add its steps with the ledger tool (op tasks with change+agent, or op todo with agent per step), then dispatch again.`
297 : 'Ledger: a working agent needs a name and steps. Dispatch it with name=<agent> after adding its steps with the ledger tool (op tasks or op todo with agent).',
298 }
299 }
300 return next({ ...e, prompt: `${input.prompt}\n\n${brief}` } as typeof e)
301 })
302
303 // A follow-up message to a named agent carries whatever it still has open.
304 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
305 const input = e as unknown as { to?: unknown; message?: unknown }
306 if (typeof input.to !== 'string' || typeof input.message !== 'string') return next(e)
307 const brief = agentBrief(await read($, docA), input.to.trim())
308 if (!brief) return next(e)
309 return next({ ...e, message: `${input.message}\n\n${brief}` } as typeof e)
310 })
311
312 on('tool.call', { tool: TOOL }, async ($, e) => {
313 const ops = (Array.isArray(e.ops) ? e.ops : []) as LedgerOp[]
314 const doc = await read($, docA)
315 const board = await read($, boardA)
316 if (!ops.length) return { result: doc.topics.length ? digest(doc, board, gapsFor(doc, board)) : 'The ledger is empty.' }
317 const turn = await $.session.turns()
318 const withContent = await Promise.all(ops.map(async (o) => (o.op === 'tasks' && o.change ? { ...o, content: await readTasks($, o.change) } : o)))
319 const r = applyOps(doc, withContent, { userTexts: await userTexts($), turn })
320 if (r.changed) await commit($, r.doc)
321 const gaps = gapsFor(r.doc, await read($, boardA))
322 const lines = [...r.done.map((d) => `ok: ${d}`), ...r.failed.map((f) => `FAILED: ${f}`)]
323 if (gaps.length) lines.push('Board gaps now:', ...gaps.map((g) => ` - ${g.text}`))
324 const open = openTodoLines(r.doc, turn, 'claude', 8, new Set(await read($, finishedA)))
325 if (open.length) lines.push('Open to-dos (claude), close each the moment its evidence exists:', ...open)
326 if (!r.done.length) return { deny: lines.join('\n') }
327 return { result: lines.join('\n') }
328 })
329
330 on('tool.call', async ($, e, next) => {
331 const tool = String(e.tool)
332 const isEdit = tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit'
333 const isBash = tool === 'Bash' || tool === 'PowerShell'
334 const input = e as unknown as { file_path?: string; command?: string }
335 const touchesBoard = tool === BOARD_TOOL || (isBash && typeof input.command === 'string' && input.command.includes('Telify-board'))
336 if (!boardPath || (!isEdit && !touchesBoard)) return next(e)
337 const before = (await $.fs.stat(boardPath)).mtimeMs
338 const ran = await next(e)
339 await markOwnBoardWrite($, isEdit ? input.file_path : boardPath, before)
340 return ran
341 })
342
343 on('prompt.submit', async ($, e, next) => {
344 await rememberSaid($, e.text)
345 const doc = await read($, docA)
346 const board = await read($, boardA)
347 const context: string[] = []
348 if (doc.topics.length || doc.hold) {
349 const gaps = gapsFor(doc, board)
350 if (needFull) {
351 context.push(digest(doc, board, gaps))
352 needFull = false
353 lastExtras = boardDigest(board, [])
354 } else {
355 const extras = boardDigest(board, [])
356 const parts: string[] = []
357 if (gaps.length) parts.push(boardDigest(null, gaps))
358 if (extras && extras !== lastExtras) parts.push(extras)
359 lastExtras = extras
360 if (parts.length) context.push(`Session ledger, board state:\n${parts.join('\n')}`)
361 const open = openTodoLines(doc, await $.session.turns(), 'claude', 8, new Set(await read($, finishedA)))
362 if (open.length)
363 context.push(`Session ledger, open to-dos (claude). Close each with evidence the moment it is done, before replying:\n${open.join('\n')}`)
364 }
365 }
366 const ran = await next(context.length ? { ...e, context: [...(e.context ?? []), ...context] } : e)
367 const turns = await $.session.turns()
368 await update($, turnsA, () => turns)
369 return ran
370 })
371
372 on('session.compact', async ($, e, next) => {
373 const ran = await next(e)
374 needFull = true
375 return ran
376 })
377
378 on('turn.complete', async ($, e, next) => {
379 const ran = await next(e)
380 if (e.agentId === undefined) {
381 const turns = await $.session.turns()
382 await update($, turnsA, () => turns)
383 }
384 return ran
385 })
386
387 on('command.run', { command: 'ledger' }, async ($, e) => {
388 const arg = e.args.trim()
389 if (arg === 'close') {
390 await $.ui.close({ id: PANE })
391 return {}
392 }
393 if (arg === 'catch-up') {
394 const doc = await read($, docA)
395 const prompt = [
396 'Update the session ledger kept beside this conversation. The ledger now, as JSON:',
397 JSON.stringify(doc),
398 '',
399 'Return ONLY a JSON array of ops, no prose, for what this conversation holds that the ledger is missing or has wrong. Op shapes:',
400 '{"op":"topic","topic":"<name>","status":"now|exploring|shaped|chosen|hold|rejected|review|done","card":"<board card id>","exempt":true}',
401 '{"op":"decide","topic":"<name>","text":"<the decision>","quote":"<the user\'s exact words, copied verbatim>"}',
402 '{"op":"ask","topic":"<name>","text":"<question>","owner":"you|claude","rec":"<recommended answer>","detail":"<why>"}',
403 '{"op":"answer","item":"<question id>","text":"<the decision>","quote":"<the user\'s exact words>"}',
404 '{"op":"todo","topic":"<name>","text":"<task>","owner":"you|claude"}',
405 '{"op":"learn","topic":"<name>","text":"<fact>"}',
406 '{"op":"close","item":"<item id>","evidence":"<what ran and what it showed>"}',
407 'A recommendation the user has not confirmed is an ask, never a decide. Return [] when nothing is missing.',
408 ].join('\n')
409 const reply = await $.model.fork({ prompt })
410 if (!reply.isAnswered) return { text: `Ledger catch-up failed: ${reply.reason}` }
411 const start = reply.text.indexOf('[')
412 const end = reply.text.lastIndexOf(']')
413 let ops: LedgerOp[] = []
414 try {
415 ops = JSON.parse(reply.text.slice(start, end + 1)) as LedgerOp[]
416 } catch {
417 return { text: 'Ledger catch-up: the reply was not a list of ops; nothing changed.' }
418 }
419 const r = applyOps(doc, ops, { userTexts: await userTexts($), turn: await $.session.turns() })
420 if (r.changed) {
421 await commit($, r.doc)
422 needFull = true
423 }
424 return {
425 text: `Ledger catch-up: ${r.done.length} recorded, ${r.failed.length} rejected.${r.failed.length ? `\n${r.failed.join('\n')}` : ''}`,
426 }
427 }
428 await $.ui.open({ id: PANE, title: 'Ledger' })
429 return {}
430 })
431
432 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
433 if (e.props.hasSurvey) return next(e)
434 const doc = await read($, docA)
435 if (!doc.topics.length && !doc.hold) return next(e)
436 const board = await read($, boardA)
437 const turns = await read($, turnsA)
438 const view = buildView(doc, board, {}, 'all')
439 const { Box, Text, Button } = $.ui.resolve(e)
440 const ago = turns - doc.updatedTurn
441 return (
442 <Box flexDirection="row" width={e.props.bodyColumns}>
443 <Text color={ACC} bold>
444 {'▎ledger '}
445 </Text>
446 <Text color={ACC}>{`${view.you} for you`}</Text>
447 <Text dimColor>{' · '}</Text>
448 <Text color={GREY}>{`${view.claude} on Claude`}</Text>
449 <Text dimColor>{' · '}</Text>
450 <Text color={view.gaps ? AMB : GREY}>{view.gaps ? `${view.gaps} board gap${view.gaps > 1 ? 's' : ''}` : 'board in sync'}</Text>
451 <Text dimColor>{ago <= 0 ? ' · updated this turn' : ` · updated ${ago} turn${ago > 1 ? 's' : ''} ago`}</Text>
452 <Box flexGrow={1} />
453 <Button key="open-ledger" plain label="open ledger" hotkey="l" onPress={() => void $.ui.open({ id: PANE, title: 'Ledger' })} />
454 </Box>
455 )
456 })
457
458 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
459 const doc = await read($, docA)
460 const board = await read($, boardA)
461 const open = await read($, openA)
462 const filter = await read($, filterA)
463 const turns = await read($, turnsA)
464 // Two cells of margin on the right, so nothing runs into the frame.
465 const W = Math.max(30, e.props.bodyColumns - 2)
466 const view = buildView(doc, board, open, filter, new Set(await read($, finishedA)))
467 const legend = legendFor(doc)
468 const { Box, Text, Button } = $.ui.resolve(e)
469 const toggle = (key: string, isOpen: boolean) => () => void update($, openA, (o) => ({ ...o, [key]: !isOpen }))
470
471 const filters: { id: LedgerFilter; label: string }[] = [
472 { id: 'all', label: 'All topics' },
473 { id: 'you', label: `For you ${view.you}` },
474 { id: 'claude', label: `Claude ${view.claude}` },
475 { id: 'board', label: view.gaps ? `Board ${view.gaps} gap${view.gaps > 1 ? 's' : ''}` : 'Board ✓' },
476 ]
477
478 return (
479 <Box flexDirection="column" width={W}>
480 <Box flexDirection="row">
481 <Text color={ACC} bold>
482 {'LEDGER '}
483 </Text>
484 <Text bold wrap="truncate-end">
485 {doc.title || 'This session'}
486 </Text>
487 <Box flexGrow={1} />
488 <Text dimColor>{`this conversation · ${turns} turn${turns === 1 ? '' : 's'}`}</Text>
489 <Text>{' '}</Text>
490 </Box>
491 <Text dimColor>{'─'.repeat(W)}</Text>
492 <Box flexDirection="row" gap={1}>
493 {filters.map((f, i) =>
494 f.id === filter ? (
495 <Button key={`f-${f.id}`} variant="primary" label={f.label} hotkey={String(i + 1)} onPress={() => void update($, filterA, () => f.id)} />
496 ) : (
497 <Button key={`f-${f.id}`} plain dimColor label={f.label} hotkey={String(i + 1)} onPress={() => void update($, filterA, () => f.id)} />
498 ),
499 )}
500 </Box>
501 {doc.hold ? (
502 <Box flexDirection="row" marginTop={1}>
503 <Text backgroundColor={VIO} color={INK} bold>
504 {' HOLD '}
505 </Text>
506 <Text color={GREY}>{` ${doc.hold}`}</Text>
507 </Box>
508 ) : null}
509 {view.topics.length === 0 ? (
510 <Box marginTop={1}>
511 <Text dimColor>
512 {filter === 'all'
513 ? 'Nothing recorded yet. Topics, decisions and to-dos land here as they come up.'
514 : 'Nothing here right now.'}
515 </Text>
516 </Box>
517 ) : null}
518 {view.topics.map((t) => (
519 <Box key={`tb-${t.id}`} flexDirection="column" marginTop={1}>
520 <Box flexDirection="row">
521 <Text dimColor>{t.isOpen ? '▾ ' : '▸ '}</Text>
522 <Text color={t.glyphColor}>{`${t.glyph} `}</Text>
523 <Button key={`t-${t.id}`} plain label={t.isOpen ? split(t.name, W - 22)[0] : clip(t.name, W - 22)} onPress={toggle(`t:${t.id}`, t.isOpen)} />
524 <Box flexGrow={1} />
525 {t.chip ? (
526 <Text backgroundColor={t.chipColor} color={INK} bold>
527 {` ${t.chip} `}
528 </Text>
529 ) : (
530 <Text dimColor>{t.chipText}</Text>
531 )}
532 </Box>
533 {t.isOpen && split(t.name, W - 22)[1] ? (
534 <Box paddingLeft={4}>
535 <Text>{split(t.name, W - 22)[1]}</Text>
536 </Box>
537 ) : null}
538 <Box flexDirection="row" paddingLeft={4}>
539 <Text color={GREY} wrap={t.isOpen ? 'wrap' : 'truncate-end'}>
540 {t.sub}
541 </Text>
542 <Box flexGrow={1} />
543 <Text color={t.countColor}>{` ${t.counts}`}</Text>
544 </Box>
545 {t.isOpen ? (
546 <Box flexDirection="column" marginLeft={3}>
547 {t.sections.map((s) => (
548 <Box key={`s-${t.id}-${s.label}`} flexDirection="column" marginTop={1}>
549 <Box flexDirection="row">
550 <Text dimColor>{s.isOpen ? '▾ ' : '▸ '}</Text>
551 <Button key={`sh-${t.id}-${s.label}`} plain dimColor label={`${s.label} ${s.rows.length}`} onPress={toggle(`s:${t.id}:${s.label}`, s.isOpen)} />
552 </Box>
553 {(s.isOpen ? s.rows : []).map((r) => {
554 const isOpen = open[`i:${r.key}`] === true
555 if (r.header) {
556 return (
557 <Box key={`r-${r.key}`} flexDirection="row" marginTop={1}>
558 <Text color={r.headerColor ?? GREY} wrap="truncate-end">{r.text}</Text>
559 </Box>
560 )
561 }
562 // The tag's column is reserved first, so a long title is clipped and the tag never wraps.
563 const tagW = r.tag === 'YOU' ? 6 : r.tag === 'CLAUDE' ? 9 : r.tag ? r.tag.length + 1 : 0
564 const titleW = Math.max(12, W - 18 - tagW)
565 return (
566 <Box key={`r-${r.key}`} flexDirection="column">
567 <Box flexDirection="row">
568 {r.check ? (
569 <Button key={`chk-${r.key}`} plain label={`${r.glyph} `} onPress={() => void checkTodo($, r.check!.item, !r.check!.done)} />
570 ) : (
571 <Text color={r.glyphColor}>{`${r.glyph} `}</Text>
572 )}
573 <Button key={`i-${r.key}`} plain dimColor={r.muted} label={isOpen ? split(r.text, titleW)[0] : clip(r.text, titleW)} onPress={toggle(`i:${r.key}`, isOpen)} />
574 <Box flexGrow={1} />
575 {r.tag === 'YOU' ? (
576 <Text backgroundColor={ACC} color={INK} bold>
577 {' YOU '}
578 </Text>
579 ) : r.tag === 'CLAUDE' ? (
580 <Text backgroundColor="#3a3f4a" color="#e6e8ec" bold>
581 {' CLAUDE '}
582 </Text>
583 ) : (
584 <Text color={r.tagColor}>{r.tag}</Text>
585 )}
586 </Box>
587 {isOpen && split(r.text, titleW)[1] ? (
588 <Box paddingLeft={2}>
589 <Text dimColor={r.muted}>{split(r.text, titleW)[1]}</Text>
590 </Box>
591 ) : null}
592 {isOpen ? (
593 <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
594 {r.details.map((d, i) => (
595 <Box key={`d-${r.key}-${i}`}>
596 <Text italic={d.italic} color={d.color ?? GREY}>
597 {d.t}
598 </Text>
599 </Box>
600 ))}
601 {r.fill ? (
602 <Box flexDirection="row" gap={2} marginTop={1}>
603 {r.fill.rec ? (
604 <Button
605 key={`rec-${r.key}`}
606 variant="primary"
607 label="Use recommended"
608 onPress={() => void fillPrompt($, `${cleanName(r.fill!.topic)}: ${r.fill!.question} ${r.fill!.rec}`)}
609 />
610 ) : null}
611 <Button
612 key={`own-${r.key}`}
613 label="Different answer"
614 onPress={() => void fillPrompt($, `${cleanName(r.fill!.topic)}: ${r.fill!.question} `)}
615 />
616 </Box>
617 ) : null}
618 </Box>
619 ) : null}
620 </Box>
621 )
622 })}
623 </Box>
624 ))}
625 </Box>
626 ) : null}
627 </Box>
628 ))}
629 <Box flexDirection="column" marginTop={1}>
630 <Text dimColor>{'─'.repeat(W)}</Text>
631 {legend.map((row) => (
632 <Box key={`lg-${row.name}`} flexDirection="row" flexWrap="wrap" columnGap={2}>
633 <Text dimColor>{row.name}</Text>
634 {row.keys.map((k) => (
635 <Box key={`lg-${row.name}-${k.label}`} flexDirection="row">
636 <Text color={k.color}>{`${k.glyph} `}</Text>
637 <Text color={GREY}>{k.label}</Text>
638 </Box>
639 ))}
640 </Box>
641 ))}
642 <Text dimColor>{'press a line to open it · 1-4 filters · esc back to prompt'}</Text>
643 </Box>
644 </Box>
645 )
646 })
647}
648
649// ---- view model: what the pane and the band draw ----
650
651type Detail = { t: string; italic?: boolean; color?: string }
652type Row = {
653 key: string
654 /** A group line (an agent's name and count), drawn without glyph, button or tag. */
655 header?: boolean
656 headerColor?: string
657 glyph: string
658 glyphColor: string
659 text: string
660 muted: boolean
661 tag: string
662 tagColor?: string
663 details: Detail[]
664 fill?: { topic: string; question: string; rec?: string }
665 /** Your own to-do: its box is a button that checks it off, or back on. */
666 check?: { item: string; done: boolean }
667 matches: LedgerFilter[]
668}
669type TopicView = {
670 id: string
671 name: string
672 glyph: string
673 glyphColor: string
674 chip?: string
675 chipColor?: string
676 chipText: string
677 sub: string
678 counts: string
679 countColor: string
680 isOpen: boolean
681 sections: { label: string; rows: Row[]; isOpen: boolean }[]
682}
683
684// Where a line breaks: the last space that fits, so the second part continues the sentence.
685const split = (s: string, n: number): [string, string] => {
686 if (s.length <= n) return [s, '']
687 const space = s.lastIndexOf(' ', n)
688 const at = space > n / 2 ? space : n
689 return [s.slice(0, at).trimEnd(), s.slice(at).trimStart()]
690}
691
692const clip = (s: string, n: number) => (s.length > n ? `${split(s, n - 1)[0]}…` : s)
693
694const STATUS_GLYPH: Record<string, [string, string]> = {
695 now: ['●', ACC],
696 exploring: ['○', BLUE],
697 shaped: ['◇', TEAL],
698 chosen: ['◆', ACC],
699 hold: ['‖', VIO],
700 rejected: ['×', GREY],
701 review: ['◐', AMB],
702 done: ['✓', OK],
703}
704const COLUMN_COLOR: Record<string, string> = {
705 backlog: GREY,
706 planning: BLUE,
707 ready: TEAL,
708 building: ACC,
709 review: AMB,
710 done: OK,
711}
712const ORDER: Record<string, number> = { now: 0, rejected: 2, done: 2 }
713
714const STATUS_LABEL: Record<string, string> = {
715 now: 'now',
716 exploring: 'exploring',
717 shaped: 'shaped',
718 chosen: 'chosen',
719 hold: 'on hold',
720 rejected: 'rejected',
721 review: 'in review',
722 done: 'done',
723}
724
725type LegendKey = { glyph: string; color: string; label: string }
726
727// The key for the topic statuses in use right now.
728export const legendFor = (doc: LedgerDoc): { name: string; keys: LegendKey[] }[] => {
729 const statuses = Object.keys(STATUS_LABEL).filter((s) => doc.topics.some((t) => t.status === s))
730 const keys = statuses.map((s) => ({ glyph: STATUS_GLYPH[s]![0], color: STATUS_GLYPH[s]![1], label: STATUS_LABEL[s]! }))
731 return keys.length ? [{ name: 'topics', keys }] : []
732}
733
734const itemRow = (t: LedgerTopic, i: LedgerItem, card: LedgerCard | undefined): Row => {
735 const owner = i.owner === 'you' ? 'YOU' : 'CLAUDE'
736 if (i.kind === 'decided') {
737 const logged = t.card ? (isLogged(i, card) ? `Logged on ${t.card}` : `Not on ${t.card} yet`) : undefined
738 return {
739 // Its own key, so a question opened to answer it lands in Decided folded.
740 key: `${i.id}:d`,
741 glyph: '✓',
742 glyphColor: OK,
743 text: i.text,
744 muted: false,
745 tag: '',
746 details: [
747 { t: `"${i.quote}"`, italic: true, color: '#d9dce1' },
748 ...(i.detail ? [{ t: i.detail }] : []),
749 ...(logged ? [{ t: logged }] : []),
750 ],
751 matches: ['all'],
752 }
753 }
754 if (i.kind === 'question') {
755 return {
756 key: i.id,
757 glyph: '?',
758 glyphColor: ACC,
759 text: i.text,
760 muted: !!i.done,
761 tag: i.done ? 'dropped' : owner,
762 tagColor: GREY,
763 details: i.done
764 ? [{ t: i.detail ?? 'No longer matters' }]
765 : [
766 { t: 'Not answered yet' },
767 ...(i.rec ? [{ t: `Recommended: ${i.rec}`, color: '#c9ccd2' }] : []),
768 ...(i.detail ? [{ t: i.detail }] : []),
769 ],
770 fill: !i.done && i.owner === 'you' ? { topic: t.name, question: i.text, rec: i.rec } : undefined,
771 matches: i.done ? ['all'] : ['all', i.owner === 'you' ? 'you' : 'claude'],
772 }
773 }
774 if (i.kind === 'todo') {
775 return {
776 key: i.id,
777 glyph: i.done ? '☑' : '☐',
778 glyphColor: i.done ? OK : i.owner === 'you' ? ACC : GREY,
779 text: stepText(i),
780 muted: !!i.done,
781 tag: i.done ? 'done' : i.agent ? '' : owner,
782 tagColor: OK,
783 details: [
784 ...(i.full && i.full !== i.text ? [{ t: i.full, color: '#c9ccd2' }] : []),
785 ...(i.detail ? [{ t: i.detail }] : [{ t: i.done ? 'Done' : 'Open' }]),
786 ],
787 matches: i.done ? ['all'] : ['all', i.owner === 'you' ? 'you' : 'claude'],
788 ...(i.owner === 'you' ? { check: { item: i.id, done: !!i.done } } : {}),
789 }
790 }
791 return { key: i.id, glyph: '·', glyphColor: GREY, text: i.text, muted: true, tag: '', details: [], matches: ['all'] }
792}
793
794const boardRows = (t: LedgerTopic, board: LedgerBoard | null, gaps: Gap[]): Row[] => {
795 const rows: Row[] = []
796 const mine = gaps.filter((g) => g.topicId === t.id)
797 const card = t.card ? board?.cards[t.card] : undefined
798 if (card) {
799 const waiting = card.awaiting ?? card.nextAction ?? card.trigger
800 rows.push({
801 key: `${t.id}:card`,
802 glyph: '▣',
803 glyphColor: COLUMN_COLOR[card.status] ?? GREY,
804 text: card.id,
805 muted: false,
806 tag: card.status,
807 tagColor: COLUMN_COLOR[card.status] ?? GREY,
808 details: [
809 { t: card.title, color: '#c9ccd2' },
810 ...(waiting ? [{ t: `${card.awaiting ? 'Awaiting' : card.nextAction ? 'Next' : 'Trigger'}: ${waiting}` }] : []),
811 ...(card.lastLog ? [{ t: `Last log: ${card.lastLog}` }] : []),
812 ],
813 matches: ['all', 'board'],
814 })
815 }
816 mine.forEach((g, n) =>
817 rows.push({
818 key: `${t.id}:gap${n}`,
819 glyph: '⚠',
820 glyphColor: AMB,
821 text: g.text,
822 muted: false,
823 tag: 'gap',
824 tagColor: AMB,
825 details: [{ t: 'Claude sees this gap too and closes it in the work.' }],
826 matches: ['all', 'board'],
827 }),
828 )
829 if (card) {
830 board!.changes
831 .filter((c) => c.card === card.id)
832 .slice(-3)
833 .forEach((c, n) =>
834 rows.push({
835 key: `${t.id}:chg${n}`,
836 glyph: '↻',
837 glyphColor: BLUE,
838 text: `${c.by === 'claude' ? 'Claude' : 'Another session'} ${c.text}`,
839 muted: c.by === 'claude',
840 tag: c.by === 'claude' ? 'Claude' : 'other session',
841 tagColor: BLUE,
842 details: [{ t: new Date(c.at).toLocaleTimeString() }],
843 matches: ['all', 'board'],
844 }),
845 )
846 if (!mine.length) rows.push({ key: `${t.id}:sync`, glyph: '✓', glyphColor: GREY, text: 'Card in sync with this session', muted: true, tag: '', details: [], matches: ['all', 'board'] })
847 } else if (t.exempt) {
848 rows.push({ key: `${t.id}:exempt`, glyph: '✓', glyphColor: GREY, text: 'No board card: exempt', muted: true, tag: '', details: [], matches: ['all', 'board'] })
849 }
850 return rows
851}
852
853// TO-DO rows: steps sorted by number and grouped under one header per agent (name, done of
854// total, FINISHED when the agent stopped with steps open); steps with no agent follow, untagged
855// by agent but still tagged by owner.
856const todoRows = (t: LedgerTopic, card: LedgerCard | undefined, finished: ReadonlySet<string>): Row[] => {
857 const todos = t.items.filter((i) => i.kind === 'todo').slice().sort((a, b) => compareTasks(a.task, b.task))
858 const agents: string[] = []
859 for (const i of todos) if (i.agent && !agents.includes(i.agent)) agents.push(i.agent)
860 const out: Row[] = []
861 for (const a of agents) {
862 const mine = todos.filter((i) => i.agent === a)
863 const done = mine.filter((i) => i.done).length
864 const stopped = finished.has(a) && done < mine.length
865 out.push({
866 key: `${t.id}:agent:${a}`,
867 header: true,
868 headerColor: stopped ? AMB : done === mine.length ? OK : TEAL,
869 glyph: '',
870 glyphColor: GREY,
871 text: `@${a} · ${done} of ${mine.length}${stopped ? ' · FINISHED, steps still open' : done === mine.length ? ' · done' : ''}`,
872 muted: false,
873 tag: '',
874 details: [],
875 matches: ['all', 'claude'],
876 })
877 out.push(...mine.map((i) => itemRow(t, i, card)))
878 }
879 const mine = todos.filter((i) => !i.agent)
880 // Steps with no agent are the lead's; under agent groups they get their own header so they
881 // never read as the last agent's.
882 if (mine.length && agents.length) {
883 const done = mine.filter((i) => i.done).length
884 out.push({
885 key: `${t.id}:agent:lead`,
886 header: true,
887 headerColor: done === mine.length ? OK : TEAL,
888 glyph: '',
889 glyphColor: GREY,
890 text: `lead · ${done} of ${mine.length}${done === mine.length ? ' · done' : ''}`,
891 muted: false,
892 tag: '',
893 details: [],
894 matches: ['all', 'claude'],
895 })
896 }
897 out.push(...mine.map((i) => itemRow(t, i, card)))
898 return out
899}
900
901export const buildView = (
902 doc: LedgerDoc,
903 board: LedgerBoard | null,
904 open: Record<string, boolean>,
905 filter: LedgerFilter,
906 finished: ReadonlySet<string> = new Set(),
907): { topics: TopicView[]; you: number; claude: number; gaps: number } => {
908 const gaps = gapsFor(doc, board)
909 let you = 0
910 let claude = 0
911 const topics: TopicView[] = []
912 const ordered = [...doc.topics].sort((a, b) => (ORDER[a.status] ?? 1) - (ORDER[b.status] ?? 1))
913
914 const pushTopic = (base: Omit<TopicView, 'isOpen' | 'sections' | 'counts' | 'countColor'>, groups: [string, Row[]][], defaultOpen: boolean) => {
915 const all = groups.flatMap(([, rows]) => rows)
916 const y = all.filter((r) => r.matches.includes('you')).length
917 const c = all.filter((r) => r.matches.includes('claude')).length
918 const g = all.filter((r) => r.tag === 'gap').length
919 you += y
920 claude += c
921 const hits = all.filter((r) => r.matches.includes(filter))
922 if (filter !== 'all' && hits.length === 0) return
923 const isOpen = filter !== 'all' ? true : open[`t:${base.id}`] ?? defaultOpen
924 const parts = [y ? `${y} for you` : '', c ? `${c} Claude` : '', g ? `${g} gap` : ''].filter(Boolean)
925 topics.push({
926 ...base,
927 counts: parts.length ? parts.join(' · ') : 'nothing open',
928 countColor: g ? AMB : y ? ACC : GREY,
929 isOpen,
930 sections: isOpen
931 ? groups
932 // Decided starts folded: a topic's open work shows first, its history one press away.
933 .map(([label, rows]) => ({ label, rows: rows.filter((r) => r.matches.includes(filter)), isOpen: open[`s:${base.id}:${label}`] ?? label !== 'DECIDED' }))
934 .filter((s) => s.rows.length)
935 : [],
936 })
937 }
938
939 for (const t of ordered) {
940 const card = t.card ? board?.cards[t.card] : undefined
941 const rows = (kind: LedgerItem['kind']) => t.items.filter((i) => i.kind === kind).map((i) => itemRow(t, i, card))
942 const [glyph, glyphColor] = STATUS_GLYPH[t.status] ?? ['○', BLUE]
943 const missing = t.card && board?.found && !card
944 pushTopic(
945 {
946 id: t.id,
947 name: t.name,
948 glyph,
949 glyphColor,
950 chip: card ? card.status.toUpperCase() : undefined,
951 chipColor: card ? COLUMN_COLOR[card.status] ?? GREY : undefined,
952 chipText: missing ? 'card missing' : t.card ? '' : t.exempt ? 'exempt' : 'no card',
953 sub: [t.status, t.card ?? (t.exempt ? 'no card, exempt' : 'no card'), card?.campaign].filter(Boolean).join(' · '),
954 },
955 [
956 ['DECIDED', rows('decided')],
957 ['OPEN QUESTIONS', rows('question')],
958 ['TO-DO', todoRows(t, card, finished)],
959 ['BOARD', boardRows(t, board, gaps)],
960 ['LEARNED', rows('learned')],
961 ],
962 t.status === 'now',
963 )
964 }
965
966 if (board?.rulings.length) {
967 pushTopic(
968 {
969 id: '_rulings',
970 name: 'Board rulings waiting on you',
971 glyph: '⚖',
972 glyphColor: ACC,
973 chipText: '',
974 sub: 'cards in the same campaigns, outside this session',
975 },
976 [
977 [
978 'BOARD',
979 board.rulings.map((r) => ({
980 key: `ruling:${r.id}`,
981 glyph: '⚖',
982 glyphColor: ACC,
983 text: r.id,
984 muted: false,
985 tag: 'YOU',
986 details: [{ t: r.title, color: '#c9ccd2' }, ...(r.nextAction ? [{ t: r.nextAction }] : [])],
987 fill: { topic: r.id, question: r.nextAction ?? r.title },
988 matches: ['all', 'you', 'board'] as LedgerFilter[],
989 })),
990 ],
991 ],
992 false,
993 )
994 }
995
996 return { topics, you, claude, gaps: gaps.length }
997}
998hooks/model.ts 653 lines1import type {
2 LedgerBoard,
3 LedgerCard,
4 LedgerDoc,
5 LedgerItem,
6 LedgerOwner,
7 LedgerRuling,
8 LedgerStatus,
9 LedgerTopic,
10} from '../types'
11
12export const STATUSES: LedgerStatus[] = [
13 'now',
14 'exploring',
15 'shaped',
16 'chosen',
17 'hold',
18 'rejected',
19 'review',
20 'done',
21]
22
23export type LedgerOp = {
24 op: string
25 topic?: string
26 text?: string
27 quote?: string
28 owner?: string
29 detail?: string
30 rec?: string
31 item?: string
32 status?: string
33 card?: string
34 exempt?: boolean
35 evidence?: string
36 agent?: string
37 task?: string
38 change?: string
39 content?: string
40 tasks?: string
41}
42
43export type Gap = { topicId: string; itemId?: string; text: string }
44
45export const emptyDoc = (): LedgerDoc => ({ v: 1, title: '', topics: [], updatedTurn: 0 })
46
47export const slug = (name: string): string =>
48 name
49 .toLowerCase()
50 .replace(/[^a-z0-9]+/g, '-')
51 .replace(/^-+|-+$/g, '')
52 .slice(0, 32) || 'topic'
53
54// Lowercase, straight quotes gone, whitespace collapsed: what a quote check compares.
55export const norm = (s: string): string =>
56 s
57 .toLowerCase()
58 .replace(/[“”‘’"'`]/g, '')
59 .replace(/\s+/g, ' ')
60 .trim()
61
62// A quote may skip words with "..." between fragments; every fragment must be in one user message.
63export const quoteFragments = (quote: string): string[] =>
64 quote
65 .split(/\.\.\.|…/)
66 .map(norm)
67 .filter((f) => f.length > 0)
68
69export const quoteIsVerbatim = (quote: string, userTexts: string[]): boolean => {
70 const frags = quoteFragments(quote)
71 if (frags.length === 0) return false
72 const texts = userTexts.map(norm)
73 return texts.some((t) => frags.every((f) => t.includes(f)))
74}
75
76const clone = (doc: LedgerDoc): LedgerDoc => JSON.parse(JSON.stringify(doc)) as LedgerDoc
77
78export const findTopic = (doc: LedgerDoc, ref: string): LedgerTopic | undefined => {
79 const r = ref.trim().toLowerCase()
80 return doc.topics.find((t) => t.id === r || t.name.toLowerCase() === r || t.id === slug(ref))
81}
82
83export const findItem = (
84 doc: LedgerDoc,
85 id: string,
86): { topic: LedgerTopic; item: LedgerItem } | undefined => {
87 for (const topic of doc.topics) {
88 const item = topic.items.find((i) => i.id === id.trim())
89 if (item) return { topic, item }
90 }
91 return undefined
92}
93
94// Topic names start with a letter: the name leads text put in the prompt box, where a leading / reads as a command.
95export const cleanName = (s: string): string => s.trim().replace(/^[^\p{L}]+/u, '')
96
97// An item named by its task number inside a topic ("3.4" in the thread topic).
98export const findByTask = (
99 doc: LedgerDoc,
100 topicRef: string,
101 task: string,
102): { topic: LedgerTopic; item: LedgerItem } | undefined => {
103 const topic = findTopic(doc, topicRef)
104 const item = topic?.items.find((i) => i.kind === 'todo' && i.task === task)
105 return topic && item ? { topic, item } : undefined
106}
107
108// "3.4 Notice step ..." starts with a task number; "Build the panel" does not.
109export const leadingTaskNumber = (text: string): string | undefined => /^(\d+(?:\.\d+)?[a-z]?)\s/.exec(text)?.[1]
110
111// The next ordinal under an agent's name in a topic: 1, 2, 3 in the order the steps were added.
112const nextOrdinal = (topic: LedgerTopic, agent: string): string =>
113 String(topic.items.filter((i) => i.kind === 'todo' && i.agent === agent).length + 1)
114
115// A row title from a tasks.md line: backticks gone, paths cut to their last segment, the first
116// clause only (up to ". ", "; " or " ("), capped at a word boundary. The full text stays in `full`.
117export const shortTitle = (text: string, max = 64): string => {
118 let t = text
119 .replace(/`/g, '')
120 .replace(/\S*\/\S*/g, (m) => m.replace(/\/+$/, '').split('/').pop() ?? m)
121 .replace(/\s{2,}/g, ' ')
122 .trim()
123 const cut = t.search(/\. |; | \(/)
124 if (cut > 8) t = t.slice(0, cut)
125 t = t.replace(/[:,;]\s*$/, '').trim()
126 if (t.length <= max) return t
127 const head = t.slice(0, max - 1)
128 const space = head.lastIndexOf(' ')
129 return `${(space > max / 2 ? head.slice(0, space) : head).trim()}…`
130}
131
132// Natural order of step numbers: 0.1, 0.2, 2.1, 3.1, 3.1a, 3.2, 10.1; a step without a number sorts last.
133export const compareTasks = (a: string | undefined, b: string | undefined): number => {
134 if (a === b) return 0
135 if (a === undefined) return 1
136 if (b === undefined) return -1
137 const pa = a.split('.')
138 const pb = b.split('.')
139 for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
140 const [na, sa] = /^(\d+)([a-z]*)$/.exec(pa[i] ?? '')?.slice(1) ?? ['-1', '']
141 const [nb, sb] = /^(\d+)([a-z]*)$/.exec(pb[i] ?? '')?.slice(1) ?? ['-1', '']
142 if (Number(na) !== Number(nb)) return Number(na) - Number(nb)
143 if (sa !== sb) return (sa ?? '') < (sb ?? '') ? -1 : 1
144 }
145 return 0
146}
147
148export type ParsedTask = { number: string; text: string; done: boolean }
149
150// The checkbox lines of an OpenSpec tasks.md: "- [ ] 3.4 text" and "- [x] 1.1 text".
151export const parseTasks = (content: string): ParsedTask[] => {
152 const out: ParsedTask[] = []
153 for (const raw of content.split(/\r?\n/)) {
154 const m = /^\s*-\s*\[( |x|X)\]\s*(\d+(?:\.\d+)?[a-z]?)\s+(.*)$/.exec(raw)
155 if (!m) continue
156 out.push({ number: m[2]!, text: m[3]!.trim(), done: m[1] !== ' ' })
157 }
158 return out
159}
160
161// What a dispatched agent reads at the end of its prompt: its open steps and how to close them.
162export const agentBrief = (doc: LedgerDoc, agent: string): string | null => {
163 const rows: string[] = []
164 for (const t of doc.topics) {
165 if (t.status === 'done' || t.status === 'rejected') continue
166 for (const i of t.items) if (i.kind === 'todo' && i.agent === agent && !i.done) rows.push(` - ${i.id}: ${i.task ? `${i.task} ` : ''}${i.full ?? i.text}`)
167 }
168 if (!rows.length) return null
169 return [
170 `Session ledger steps assigned to you (@${agent}). The user follows your progress through them.`,
171 'Close each one the moment it lands, before starting the next, with the tool mcp__ledger__ledger:',
172 ' {"ops":[{"op":"close","item":"<id>","evidence":"<what ran and what it showed>"}]}',
173 'Never batch the closes at the end. A step you will not do stays open: say so in your report.',
174 ...rows,
175 ].join('\n')
176}
177
178export const stepText = (i: LedgerItem): string => (i.task ? `${i.task} ${i.text}` : i.text)
179
180const ensureTopic = (doc: LedgerDoc, ref: string): LedgerTopic => {
181 const found = findTopic(doc, ref)
182 if (found) return found
183 let id = slug(ref)
184 while (doc.topics.some((t) => t.id === id)) id = `${id}-2`
185 const topic: LedgerTopic = { id, name: cleanName(ref), status: 'exploring', seq: 0, items: [] }
186 doc.topics.push(topic)
187 return topic
188}
189
190const addItem = (topic: LedgerTopic, item: Omit<LedgerItem, 'id'>, turn?: number): LedgerItem => {
191 topic.seq += 1
192 const made = { id: `${topic.id}.${topic.seq}`, ...item, ...(turn === undefined ? {} : { turn }) } as LedgerItem
193 topic.items.push(made)
194 return made
195}
196
197const owner = (o: string | undefined): LedgerOwner | undefined =>
198 o === 'you' || o === 'claude' ? o : undefined
199
200export type ApplyContext = { userTexts: string[]; turn: number }
201export type ApplyResult = { doc: LedgerDoc; done: string[]; failed: string[]; changed: boolean }
202
203export const applyOps = (start: LedgerDoc, ops: LedgerOp[], ctx: ApplyContext): ApplyResult => {
204 const doc = clone(start)
205 const done: string[] = []
206 const failed: string[] = []
207 const need = (o: LedgerOp, ...fields: (keyof LedgerOp)[]): string | undefined => {
208 const missing = fields.filter((f) => o[f] === undefined || o[f] === '')
209 return missing.length ? `${o.op}: needs ${missing.join(', ')}` : undefined
210 }
211
212 for (const o of ops) {
213 const miss = (() => {
214 switch (o.op) {
215 case 'topic':
216 return need(o, 'topic')
217 case 'decide':
218 return need(o, 'topic', 'text', 'quote')
219 case 'answer':
220 return need(o, 'item', 'text', 'quote')
221 case 'ask':
222 case 'todo':
223 return need(o, 'topic', 'text', 'owner')
224 case 'learn':
225 return need(o, 'topic', 'text')
226 case 'close':
227 case 'reopen':
228 case 'remove':
229 case 'logged':
230 case 'edit':
231 return o.topic && o.task ? undefined : need(o, 'item')
232 case 'tasks':
233 return need(o, 'topic', 'change')
234 case 'assign':
235 return need(o, 'agent')
236 case 'hold':
237 case 'title':
238 return undefined
239 default:
240 return `unknown op "${o.op}"`
241 }
242 })()
243 if (miss) {
244 failed.push(miss)
245 continue
246 }
247 if (o.topic !== undefined && !findTopic(doc, o.topic) && !cleanName(o.topic)) {
248 failed.push(`${o.op}: topic names must start with a letter ("${o.topic}")`)
249 continue
250 }
251
252 if (o.op === 'title') {
253 doc.title = (o.text ?? '').trim()
254 done.push(`title set`)
255 continue
256 }
257 if (o.op === 'hold') {
258 doc.hold = o.text?.trim() || undefined
259 done.push(doc.hold ? 'hold set' : 'hold cleared')
260 continue
261 }
262
263 if (o.op === 'topic') {
264 const topic = ensureTopic(doc, o.topic!)
265 if (o.status) {
266 if (!STATUSES.includes(o.status as LedgerStatus)) {
267 failed.push(`topic: status must be one of ${STATUSES.join(', ')}`)
268 continue
269 }
270 // Only one topic is "now" at a time.
271 if (o.status === 'now') {
272 for (const t of doc.topics) if (t !== topic && t.status === 'now') t.status = 'shaped'
273 }
274 topic.status = o.status as LedgerStatus
275 }
276 if (o.text) {
277 if (!cleanName(o.text)) {
278 failed.push(`topic: the name "${o.text}" has no letters to start with`)
279 continue
280 }
281 topic.name = cleanName(o.text)
282 }
283 if (o.card !== undefined) topic.card = o.card.trim() || undefined
284 if (o.exempt !== undefined) topic.exempt = o.exempt
285 done.push(`topic ${topic.id}`)
286 continue
287 }
288
289 if (o.op === 'decide') {
290 if (!quoteIsVerbatim(o.quote!, ctx.userTexts)) {
291 failed.push(
292 `decide "${o.text}": the quote is not in any user message. Record it with op ask (owner you) as a recommendation until the user rules in their own words.`,
293 )
294 continue
295 }
296 const topic = ensureTopic(doc, o.topic!)
297 const made = addItem(topic, { kind: 'decided', text: o.text!.trim(), quote: o.quote!.trim() })
298 done.push(`${made.id} decided (quote verified)`)
299 continue
300 }
301
302 if (o.op === 'ask' || o.op === 'todo') {
303 const who = owner(o.owner)
304 if (!who) {
305 failed.push(`${o.op}: owner must be you or claude`)
306 continue
307 }
308 const topic = ensureTopic(doc, o.topic!)
309 const agent = o.op === 'todo' ? o.agent?.trim() || undefined : undefined
310 const text = o.text!.trim()
311 // A step under an agent's name carries its number: the task number the text starts
312 // with, or the next ordinal under that agent in this topic.
313 const task = agent ? o.task?.trim() || leadingTaskNumber(text) || nextOrdinal(topic, agent) : o.task?.trim() || undefined
314 const made = addItem(topic, {
315 kind: o.op === 'ask' ? 'question' : 'todo',
316 text: task && text.startsWith(`${task} `) ? text.slice(task.length + 1).trim() : text,
317 owner: who,
318 detail: o.detail?.trim() || undefined,
319 rec: o.op === 'ask' ? o.rec?.trim() || undefined : undefined,
320 ...(agent ? { agent } : {}),
321 ...(task ? { task } : {}),
322 }, ctx.turn)
323 done.push(`${made.id} ${made.kind} for ${who}${agent ? ` (${task} @${agent})` : ''}`)
324 continue
325 }
326
327 if (o.op === 'tasks') {
328 const miss = need(o, 'topic', 'change')
329 if (miss) {
330 failed.push(miss)
331 continue
332 }
333 if (o.content === undefined) {
334 failed.push(`tasks: could not read tasks.md for change ${o.change}`)
335 continue
336 }
337 const topic = ensureTopic(doc, o.topic!)
338 topic.change = o.change!.trim()
339 const only = o.tasks ? new Set(o.tasks.split(',').map((t) => t.trim()).filter(Boolean)) : null
340 const agent = o.agent?.trim() || undefined
341 let loaded = 0
342 let skipped = 0
343 for (const t of parseTasks(o.content)) {
344 if (only && !only.has(t.number)) continue
345 if (t.done || topic.items.some((i) => i.kind === 'todo' && i.task === t.number)) {
346 skipped += 1
347 continue
348 }
349 addItem(topic, { kind: 'todo', text: shortTitle(t.text), full: t.text, owner: 'claude', task: t.number, ...(agent ? { agent } : {}) }, ctx.turn)
350 loaded += 1
351 }
352 done.push(`${topic.id}: loaded ${loaded} step${loaded === 1 ? '' : 's'} from ${o.change}/tasks.md${agent ? ` for @${agent}` : ''}${skipped ? ` (${skipped} already done or present)` : ''}`)
353 continue
354 }
355
356 if (o.op === 'assign') {
357 const miss = need(o, 'agent')
358 if (miss) {
359 failed.push(miss)
360 continue
361 }
362 const hits = o.item
363 ? [findItem(doc, o.item)]
364 : o.topic && o.tasks
365 ? o.tasks.split(',').map((t) => findByTask(doc, o.topic!, t.trim()))
366 : []
367 const found = hits.filter((h): h is NonNullable<typeof h> => !!h)
368 if (!found.length) {
369 failed.push(`assign: no item ${o.item ?? o.tasks ?? ''}`)
370 continue
371 }
372 for (const h of found) {
373 h.item.agent = o.agent!.trim()
374 if (!h.item.task) h.item.task = nextOrdinal(h.topic, h.item.agent)
375 }
376 done.push(`${found.map((h) => h.item.id).join(', ')} assigned to @${o.agent!.trim()}`)
377 continue
378 }
379
380 if (o.op === 'learn') {
381 const topic = ensureTopic(doc, o.topic!)
382 const made = addItem(topic, { kind: 'learned', text: o.text!.trim() })
383 done.push(`${made.id} learned`)
384 continue
385 }
386
387 const hit = o.item ? findItem(doc, o.item) : o.topic && o.task ? findByTask(doc, o.topic, o.task) : undefined
388 if (!hit) {
389 failed.push(`${o.op}: no item ${o.item}`)
390 continue
391 }
392 const { topic, item } = hit
393 if (o.op === 'answer') {
394 if (item.kind !== 'question') {
395 failed.push(`answer ${item.id}: only an open question can be answered`)
396 continue
397 }
398 if (!quoteIsVerbatim(o.quote!, ctx.userTexts)) {
399 failed.push(`answer ${item.id}: the quote is not in any user message, so the question stays open.`)
400 continue
401 }
402 // The answered question becomes a decision, with the question kept beside it.
403 const asked = item.text
404 item.kind = 'decided'
405 item.text = o.text!.trim()
406 item.quote = o.quote!.trim()
407 item.detail = `Answers: ${asked}`
408 delete item.owner
409 delete item.done
410 delete item.rec
411 done.push(`${item.id} answered, now decided (quote verified)`)
412 continue
413 }
414 if (o.op === 'close') {
415 if (item.kind === 'question') {
416 failed.push(`close ${item.id}: a question ends as a decision, even a dropped one. Use op answer with what was decided (for example "not doing this") and the user's words that settled it.`)
417 continue
418 }
419 if (item.kind === 'todo' && item.owner === 'claude' && !o.evidence) {
420 failed.push(`close ${item.id}: Claude's to-dos close with evidence (what ran and what it showed)`)
421 continue
422 }
423 item.done = true
424 if (o.evidence) item.detail = `Evidence: ${o.evidence.trim()}`
425 else if (o.text) item.detail = o.text.trim()
426 done.push(`${item.id} closed`)
427 } else if (o.op === 'reopen') {
428 item.done = false
429 done.push(`${item.id} reopened`)
430 } else if (o.op === 'remove') {
431 topic.items = topic.items.filter((i) => i !== item)
432 done.push(`${item.id} removed`)
433 } else if (o.op === 'logged') {
434 item.logged = true
435 done.push(`${item.id} marked logged on the card`)
436 } else if (o.op === 'edit') {
437 if (o.text) item.text = o.text.trim()
438 if (o.detail !== undefined) item.detail = o.detail.trim() || undefined
439 if (o.rec !== undefined) item.rec = o.rec.trim() || undefined
440 const who = owner(o.owner)
441 if (who) item.owner = who
442 done.push(`${item.id} edited`)
443 }
444 }
445
446 const changed = done.length > 0
447 if (changed) doc.updatedTurn = ctx.turn
448 return { doc, done, failed, changed }
449}
450
451export const isOpenFor = (item: LedgerItem, who: LedgerOwner): boolean =>
452 (item.kind === 'question' || item.kind === 'todo') && item.owner === who && !item.done
453
454// Claude's open to-dos, oldest first, each with how many turns it has been open:
455// what the tool reply and every prompt show so a finished step is closed at the
456// step, not remembered later. Topics that are done or rejected are skipped.
457export const openTodoLines = (
458 doc: LedgerDoc,
459 turn: number,
460 who: LedgerOwner = 'claude',
461 max = 8,
462 finished: ReadonlySet<string> = new Set(),
463): string[] => {
464 const rows: { topic: LedgerTopic; item: LedgerItem; age: number }[] = []
465 for (const t of doc.topics) {
466 if (t.status === 'done' || t.status === 'rejected') continue
467 for (const i of t.items) {
468 if (i.kind !== 'todo' || !isOpenFor(i, who)) continue
469 rows.push({ topic: t, item: i, age: i.turn === undefined ? Number.MAX_SAFE_INTEGER : Math.max(0, turn - i.turn) })
470 }
471 }
472 rows.sort((a, b) => b.age - a.age)
473 const topics = new Set(rows.map((r) => r.topic.id))
474 const out = rows.slice(0, max).map((r) => {
475 const where = topics.size > 1 ? ` [${r.topic.name}]` : ''
476 const age = r.age === Number.MAX_SAFE_INTEGER ? 'open since before turn stamps' : r.age === 0 ? 'this turn' : `${r.age} turn${r.age === 1 ? '' : 's'} open`
477 const agent = r.item.agent ? ` @${r.item.agent}${finished.has(r.item.agent) ? ' FINISHED, step still open' : ''}` : ''
478 return ` - ${r.item.id} (${age})${where}${agent}: ${line(stepText(r.item), 120)}`
479 })
480 if (rows.length > max) out.push(` … ${rows.length - max} more`)
481 return out
482}
483
484// A decision counts as logged when marked so, or when the card's log carries its quote.
485export const isLogged = (item: LedgerItem, card: LedgerCard | undefined): boolean => {
486 if (item.logged) return true
487 if (!card || !item.quote) return false
488 const frag = quoteFragments(item.quote)[0]?.slice(0, 60)
489 if (!frag) return false
490 return card.logTexts.some((t) => norm(t).includes(frag))
491}
492
493export const gapsFor = (doc: LedgerDoc, board: LedgerBoard | null): Gap[] => {
494 const gaps: Gap[] = []
495 // Build steps are judged per card, not per topic: a card is covered when ANY
496 // topic linked to it holds an open Claude to-do, so a finished plan-review
497 // topic still linked to a building card never reports the gap on its own.
498 const cardsWithBuildSteps = new Set(
499 doc.topics
500 .filter((t) => t.card && t.items.some((i) => i.kind === 'todo' && i.owner === 'claude' && !i.done))
501 .map((t) => t.card as string),
502 )
503 const buildGapReported = new Set<string>()
504 for (const t of doc.topics) {
505 if (t.exempt || t.status === 'rejected') continue
506 const decided = t.items.filter((i) => i.kind === 'decided')
507 // No board beside this project (someone else's machine, or not read yet): nothing to keep in sync.
508 if (!board?.found) continue
509 if (!t.card) {
510 if (t.status === 'hold') gaps.push({ topicId: t.id, text: 'On hold: needs a board card with a trigger' })
511 else if (decided.length) gaps.push({ topicId: t.id, text: 'Decisions here have no board card' })
512 continue
513 }
514 const card = board.cards[t.card]
515 if (!card) {
516 gaps.push({ topicId: t.id, text: `Card ${t.card} is not on the board` })
517 continue
518 }
519 for (const d of decided) {
520 if (!isLogged(d, card)) gaps.push({ topicId: t.id, itemId: d.id, text: `Decision not logged on ${card.id}: ${d.text}` })
521 }
522 if (card.status === 'building' && !cardsWithBuildSteps.has(card.id) && !buildGapReported.has(card.id)) {
523 buildGapReported.add(card.id)
524 gaps.push({ topicId: t.id, text: `${card.id} is building but has no open build steps here: add them as Claude to-dos and tick them off as they land` })
525 }
526 if (t.status === 'hold' && card.status === 'backlog' && !card.trigger) {
527 gaps.push({ topicId: t.id, text: `On hold: ${card.id} has no trigger` })
528 }
529 }
530 return gaps
531}
532
533// Every card object on the board, wherever the tree holds it.
534export const parseBoardCards = (html: string): Record<string, LedgerCard> => {
535 const m = html.match(/<script type="application\/json" id="board-data">([\s\S]*?)<\/script>/)
536 if (!m || !m[1]) throw new Error('board-data block not found')
537 const data = JSON.parse(m[1]) as { root?: unknown }
538 const out: Record<string, LedgerCard> = {}
539 const walk = (n: unknown): void => {
540 if (!n || typeof n !== 'object') return
541 if (Array.isArray(n)) {
542 for (const x of n) walk(x)
543 return
544 }
545 const o = n as Record<string, unknown>
546 if (typeof o.id === 'string' && typeof o.title === 'string' && typeof o.status === 'string') {
547 const log = Array.isArray(o.log) ? (o.log as { text?: string }[]) : []
548 const texts = log.map((l) => (typeof l?.text === 'string' ? l.text : '')).filter(Boolean)
549 out[o.id] = {
550 id: o.id,
551 title: o.title,
552 status: o.status,
553 campaign: typeof o.campaign === 'string' ? o.campaign : undefined,
554 awaiting: typeof o.awaiting === 'string' ? o.awaiting : undefined,
555 nextAction: typeof o.nextAction === 'string' ? o.nextAction : undefined,
556 trigger: typeof o.trigger === 'string' ? o.trigger : undefined,
557 lastLog: texts[texts.length - 1],
558 logTexts: texts.slice(-40),
559 logCount: texts.length,
560 decision: o.decision === true,
561 }
562 }
563 for (const [k, v] of Object.entries(o)) if (k !== 'log' && typeof v === 'object') walk(v)
564 }
565 walk(data.root ?? data)
566 return out
567}
568
569// The linked cards, plus every card waiting on a ruling in a campaign a linked card belongs to.
570export const boardView = (
571 all: Record<string, LedgerCard>,
572 doc: LedgerDoc,
573): { cards: Record<string, LedgerCard>; rulings: LedgerRuling[] } => {
574 const cards: Record<string, LedgerCard> = {}
575 const campaigns = new Set<string>()
576 for (const t of doc.topics) {
577 const c = t.card ? all[t.card] : undefined
578 if (c) {
579 cards[c.id] = c
580 if (c.campaign) campaigns.add(c.campaign)
581 }
582 }
583 const rulings = Object.values(all)
584 .filter((c) => c.decision && c.status !== 'done' && c.campaign && campaigns.has(c.campaign))
585 .map((c) => ({ id: c.id, title: c.title, campaign: c.campaign, nextAction: c.nextAction }))
586 return { cards, rulings }
587}
588
589// What changed on a linked card between two reads of the board.
590export const cardChanges = (before: LedgerCard | undefined, after: LedgerCard): string[] => {
591 if (!before) return []
592 const out: string[] = []
593 if (before.status !== after.status) out.push(`moved to ${after.status}`)
594 if (after.logCount > before.logCount) {
595 const tag = (after.lastLog ?? '').split(':')[0]?.slice(0, 24) || 'an entry'
596 out.push(`logged ${tag}:`)
597 }
598 return out
599}
600
601const line = (s: string, max = 160): string => (s.length > max ? `${s.slice(0, max - 1)}…` : s)
602
603// What the model reads: the whole ledger, compact.
604export const digest = (doc: LedgerDoc, board: LedgerBoard | null, gaps: Gap[]): string => {
605 const out: string[] = ['Session ledger (the ledger mod; update it with the ledger tool).']
606 if (doc.hold) out.push(`HOLD: ${doc.hold}`)
607 for (const t of doc.topics) {
608 const card = t.card ? ` card ${t.card}${board?.cards[t.card] ? ` (${board.cards[t.card]!.status})` : ''}` : t.exempt ? ' exempt' : ''
609 out.push(`## ${t.name} [${t.status}]${card}`)
610 for (const i of t.items) {
611 if (i.kind === 'decided') out.push(` ${i.id} decided: ${line(i.text)}`)
612 else if (i.kind === 'learned') out.push(` ${i.id} learned: ${line(i.text)}`)
613 else if (!i.done) out.push(` ${i.id} ${i.kind === 'question' ? 'open question' : 'to-do'} (${i.agent ? `@${i.agent}` : i.owner}): ${line(stepText(i))}`)
614 }
615 }
616 const tail = boardDigest(board, gaps)
617 if (tail) out.push(tail)
618 return out.join('\n')
619}
620
621// The board part alone, sent each turn while anything in it is open.
622export const boardDigest = (board: LedgerBoard | null, gaps: Gap[]): string => {
623 const out: string[] = []
624 if (gaps.length) out.push('Board gaps (close them in the work):', ...gaps.map((g) => ` - ${g.text}`))
625 if (board?.rulings.length)
626 out.push('Board rulings waiting on the user:', ...board.rulings.map((r) => ` - ${r.id}: ${line(r.nextAction ?? r.title, 120)}`))
627 const others = board?.changes.filter((c) => c.by === 'other') ?? []
628 if (others.length) out.push('Changed by another session:', ...others.slice(-5).map((c) => ` - ${c.card} ${c.text}`))
629 return out.join('\n')
630}
631
632// Cards named by board-tool calls not seen before (marked seen now). A failed or unanswered call changed nothing.
633export const newBoardToolCards = (
634 uses: { tool_use_id: string; tool: string; input: Record<string, unknown>; text?: string; isError?: true }[],
635 seen: Set<string>,
636): Set<string> => {
637 const ids = new Set<string>()
638 for (const u of uses) {
639 if (u.tool !== 'mcp__board__board' || u.isError || u.text === undefined || seen.has(u.tool_use_id)) continue
640 seen.add(u.tool_use_id)
641 const ops = Array.isArray(u.input.ops) ? (u.input.ops as { id?: unknown }[]) : []
642 for (const op of ops) if (typeof op?.id === 'string') ids.add(op.id)
643 }
644 return ids
645}
646
647// The person's saved messages: newest kept, each capped, a repeat of the last one skipped.
648export const addSaid = (said: string[], text: string, max = 300, cap = 6000): string[] => {
649 const t = text.trim().slice(0, cap)
650 if (!t || said.at(-1) === t) return said
651 return [...said, t].slice(-max)
652}
653types/index.d.ts 88 lines1export type LedgerOwner = 'you' | 'claude'
2export type LedgerItemKind = 'decided' | 'question' | 'todo' | 'learned'
3export type LedgerStatus =
4 | 'now'
5 | 'exploring'
6 | 'shaped'
7 | 'chosen'
8 | 'hold'
9 | 'rejected'
10 | 'review'
11 | 'done'
12
13export type LedgerItem = {
14 id: string
15 kind: LedgerItemKind
16 text: string
17 quote?: string
18 owner?: LedgerOwner
19 done?: boolean
20 detail?: string
21 rec?: string
22 logged?: boolean
23 turn?: number
24 agent?: string
25 task?: string
26 full?: string
27}
28
29export type LedgerTopic = {
30 id: string
31 name: string
32 status: LedgerStatus
33 card?: string
34 exempt?: boolean
35 change?: string
36 seq: number
37 items: LedgerItem[]
38}
39
40export type LedgerDoc = {
41 v: 1
42 title: string
43 hold?: string
44 topics: LedgerTopic[]
45 updatedTurn: number
46}
47
48export type LedgerCard = {
49 id: string
50 title: string
51 status: string
52 campaign?: string
53 awaiting?: string
54 nextAction?: string
55 trigger?: string
56 lastLog?: string
57 logTexts: string[]
58 logCount: number
59 decision: boolean
60}
61
62export type LedgerChange = { card: string; text: string; by: 'claude' | 'other'; at: number }
63
64export type LedgerRuling = { id: string; title: string; campaign?: string; nextAction?: string }
65
66export type LedgerBoard = {
67 found: boolean
68 path: string
69 cards: Record<string, LedgerCard>
70 rulings: LedgerRuling[]
71 changes: LedgerChange[]
72}
73
74export type LedgerFilter = 'all' | 'you' | 'claude' | 'board'
75
76declare module 'claude-code' {
77 interface PluginState {
78 ledger: {
79 doc: LedgerDoc
80 board: LedgerBoard | null
81 open: Record<string, boolean>
82 filter: LedgerFilter
83 turns: number
84 finished: string[]
85 }
86 }
87}
88