SLOPSHOPPER

ledger

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.

newpanebandguardcommandprompt
v0.1.1no licenseupdated 2026-10-04gabedeluna/claude-mods/plugins/ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ledger
│ ┃ Ledger ✕ › fix the failing auth test and add an audit log call │ ┃ LEDGER This sessionthis conversation · 1 tu │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ [ All topics ] 2: For you 0 3: Claude 0 4: B ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Nothing recorded yet. Topics, decisions and ⎿ Added 2 lines, removed 1 line │ ┃ land here as they come up. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ──────────────────────────────────────────── │ ┃ press a line to open it · 1-4 filters · esc ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ prompt │ ✻ Worked for 42s · done 4:20 PM │ │ › /ledger │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ledger
LEDGER This sessionthis conversation · 1 turn ────────────────────────────────────────────────────── [ All topics ] 2: For you 0 3: Claude 0 4: Board ✓ Nothing recorded yet. Topics, decisions and to-dos land here as they come up. ────────────────────────────────────────────────────── press a line to open it · 1-4 filters · esc back to prompt
README

claude-mods

Two Claude Code mods.

Install

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.

ledger

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.

sidechat

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.

Source 3 files
hooks/register.tsx 998 lines
1import { 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}
998
hooks/model.ts 653 lines
1import 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}
653
types/index.d.ts 88 lines
1export 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