Intent Ledger: a live pane of what the agent is aiming for, assuming, planning and deciding, so you can watch its intent and correct a wrong assumption early

<h1 align="center">Intent Ledger</h1>
<b>A live pane that shows what your Claude Code agent is aiming for, assuming, planning and deciding,<br>so you can catch a wrong assumption before it becomes wrong code.</b>
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/github/license/mattbv/intent-ledger"></a> <img alt="Built on Claude Code 2.1.296" src="https://img.shields.io/badge/built%20on%20Claude%20Code-2.1.296-orange">
<img src="docs/structure-tree.png" alt="Structure view: a goal, its steps, and an invalidated assumption with a red path to the steps that rest on it" width="440"> <img src="docs/history-grid.png" alt="History view: each entry plotted against the turns it stayed open" width="340">
/plugin install intent-ledger --marketplace mattbv/intent-ledger
Answer y to add the marketplace and pick a scope, then run /ledger. Mods are an early-access Claude Code feature; this was built and tested on Claude Code 2.1.296, and the API can change between releases.
To update later: claude plugin update intent-ledger@intent-ledger, then restart Claude Code. (/plugin install says "Already installed" and does not update.)
To try it for one session without installing: claude --plugin-dir ./intent-ledger.
On a long agent session, the code is the last place a wrong assumption shows up. Intent Ledger asks the agent to state its goal, assumptions (with why and confidence), plan, decisions, questions, risks and findings as it works, and keeps them in a pane next to the transcript. You see which assumptions are still unverified, which went stale, what rests on an assumption that turned out wrong, and where the agent changed files without logging anything. You can confirm an assumption, or flag it wrong and draft the correction.
Where Flightdeck shows what the agent did (cost, tools, permissions, subagents), Intent Ledger shows what it meant.
The sections run attention-first. This is abridged and illustrative, not a capture of one session:
GOAL
Add OAuth login to the API
▸ NOW Add a passport strategy
2 open assumptions · ⚠ 1 stale · ✗ 1 · ✓ 1 · ███░░░░░ 2/5 · 1 question · 1 risk
ASSUMPTIONS 2 open · 1 ✓ · 1 ✗ · 1 stale
press a row to confirm or correct it
▎? A3 Sessions can live in memory for now ▂ low · T4 ▸
? A1 stale Users table has an email column ▆ high · T1 ▸
✗ A2 No auth middleware exists yet T1 · by you ▸
✓ A4 Express 4.x is in use ▄ medium · T2 ▸
OPEN QUESTIONS & RISKS
? Q1 Should refresh tokens be rotated?
! R1 The callback URL differs between staging and prod
PLAN ███░░░░░ 2/5 done
▸ Add a passport strategy
· Wire the callback route
· Write integration tests
STRUCTURE
◎ G1 Add OAuth login to the API
├─ ✓ S1 Read the routes (+2)
└─ ▸ S2 ⚠A2 Add a passport strategy
└─ ✗ A2 No auth middleware exists yet
TIMELINE
T4 you also add logout
T4 main A2 open → invalidated — found middleware/auth.js
T5 main changed 3 files with nothing logged (routes.ts, …)
7 files touched · unlogged changes in T5
| Section | Shows |
|---|---|
| Goal, NOW and the health strip | the latest active goal (with "n earlier" if you have more), the step in progress, and one line of what needs attention: open and stale assumptions, invalidated and confirmed ones, a plan progress bar, open questions and risks (only the non-zero parts) |
| Assumptions | status (? open, ✓ confirmed, ✗ invalidated), confidence, the turn logged, who logged it, and a stale badge before the claim once it has gone unverified for N turns. Ordered open, invalidated, confirmed, dropped (newest first); the pane shows 12 |
| Open questions & risks | what the agent is deferring or guessing on (? question, ! risk), the latest 5 |
| Plan | the agent's logged steps plus its todo list and task list, read automatically, and the plan you approved in plan mode (expandable). When the Structure tree is shown, the plan keeps to what is active and next |
| Decisions, Findings | choices between alternatives with the reason; facts learned that settle no assumption. The latest 4 of each |
| Structure | the goal, its steps and sub-steps, and the assumptions, decisions, findings, questions and risks the agent linked to them with of (up to 30 nodes). ⚠A1 marks every node above or below an invalidated assumption; a bold path runs to the active step; finished branches fold to (+n) once the tree passes 12 nodes; "n unlinked" counts entries the agent did not link. On SVG surfaces the path to an invalidated assumption is also drawn red |
| History | entries against turns: ○ logged, ─── still open, ✓ ✗ × settled. Shown once there are two turns and two entries; up to 12 turns and 14 rows |
| Timeline | your prompts, resolutions, warnings, and (until the tree appears) every entry logged; the latest 10. A turn that changed files with nothing logged raises a warning |
A thin ▎ in the left gutter marks what was added or changed since your last prompt, in every list (and a bar at the edge of the SVG tree), so you can see at a glance what the last turn did. Confidence shows as a small bar (▂ low, ▄ medium, ▆ high), and an open assumption's details turn amber as it nears going stale. In the terminal, the History grid has a changes row with how much happened in each turn.
Inline (main-screen terminal), the pane collapses to a few rows: goal, the active step, assumption counts and the latest moment. The status line reads like ledger · 2 open assumptions · 1 stale · 1 ✗ · plan 2/5 · 1 ?.
On the desktop app, VS Code and mobile, Structure and History are drawn as SVG cards in your chosen palette (dark by default); the terminal draws the same data as text.
Nine colour palettes are available (see Configure):
Focus the pane (ctrl+x tab) and press an assumption to expand it: its reasoning, the note that resolved it, and three buttons.
Assumption A2 ("…") is wrong: ). You finish and send it; nothing is sent for you.The agent hears about each of your marks once, in its next ledger result, so it can reconcile its plan.
The ledger is built only from what the agent produces openly:
mcp__intent-ledger__ledger). The mod registers it and adds a short system-prompt section asking the agent to log its goal, assumptions, steps, decisions, questions, risks and findings on multi-step tasks, and to resolve them as it learns. The tool is always loaded (not deferred). Turn the prompt off with instructAgent: false if you only want observed plans.TodoWrite, TaskCreate / TaskUpdate, and the plan returned by ExitPlanMode.Edit, Write, MultiEdit, NotebookEdit), for the "changed files with nothing logged" warning, which also needs instructAgent on.It does not read hidden reasoning. Treat the ledger as the agent's declared intent; the warning shows where declared intent and actual changes diverge.
Good to know:
active, done, dropped. Assumptions: open, confirmed, invalidated, dropped. Steps: open, active, done, dropped. Questions: open, answered, dropped. Decisions, risks and findings: open, dropped. A close fit is accepted: done on an assumption reads as confirmed.sub·xxxxx label.It makes no network requests, runs no processes, reads and writes no files, and calls no model. State lives in the session: it is kept when you resume the session with claude --resume, and /clear resets it. The things it does outside its own pane: /ledger copy writes the Markdown to your clipboard (or prints it), a "✗ wrong" press drafts text in your prompt box (never sent), and it shows toasts.
With autoAllow on, calls to its own ledger tool skip the permission prompt, including over a permission rule of yours that would ask. The tool only writes to the pane; every other tool's permissions are untouched.
| Command | Does |
|---|---|
/ledger or /ledger open | open the pane (any unrecognised argument does this too) |
/ledger close | close it |
/ledger reset | clear the ledger |
/ledger status | print the loaded version and the settings that shape the pane (palette, brandAccent, instructAgent, ...) |
/ledger copy | copy the entries and the approved plan as Markdown (printed to the transcript if there is no clipboard). It leaves out the timeline and the of links |
In /config, or under pluginConfigs["intent-ledger"].options in settings.json:
| Option | Default | Meaning |
|---|---|---|
instructAgent | true | add the system-prompt section asking the agent to keep the ledger (also needed for the "nothing logged" warning) |
autoAllow | true | the ledger tool skips the permission prompt |
observePlans | true | fold todo lists, tasks and approved plans into the plan |
staleAfterTurns | 3 | flag an open assumption stale after this many turns (0 disables) |
openOnStart | true | open the pane when a session starts (the host decides whether the pane fits) |
palette | default | default follows your Claude Code theme; or catppuccin-mocha, catppuccin-latte, dracula, gruvbox-dark, tokyo-night, nord, solarized-dark, rose-pine. A named palette colours the pane text with fixed hex colours and the SVGs with the palette. Colours are adjusted where needed so text, lines and status colours stay legible on the palette's own background. The pane text is drawn on your terminal's background, so use a named palette with a matching terminal theme |
brandAccent | false | with the default palette, colour the pane headers Claude orange instead of your terminal's text colour (named palettes use their own accent) |
statusLine | true | show the summary in the status line |
motion | true | show a small spinner on the active step while the agent works (terminal only; it stops when the turn ends) |
The pane is empty. The agent fills it by calling the ledger tool, which it does when it starts a multi-step task. Check instructAgent is on; todo lists show up without it if observePlans is on. You can also ask the agent to "log your goal and assumptions in the ledger". It is a prompt, not enforcement: whatever the agent does not log is not shown, apart from plans and the unlogged-changes warning.
The pane does not open. /ledger says why when it cannot place the pane; try a wider window and run it again.
The pane looks like an older version, or ignores a setting. Run /ledger status for the version that is loaded and claude plugin list for the installed one. If they say an old version, run claude plugin update intent-ledger@intent-ledger and restart Claude Code. On one machine the in-app update left the install record on 0.2.0 while newer versions sat in the cache; the command fixed it.
The plugin will not load. Check claude --version. Mods are early access, and this was built on 2.1.296.
Colours look wrong in a named palette. Named palettes use fixed hex colours drawn on your terminal's own background, so they look right with a matching terminal theme and can clash with another. How they degrade on a terminal without true colour is untested; use default to follow your Claude Code theme.
/ledger copy to keep a Markdown copy.claude --plugin-dir . # edits hot-reload
claude plugin validate .
claude plugin test .
| File | Holds |
|---|---|
hooks/register.tsx | the hooks: tool registration, system-prompt section, plan observation, turn bookkeeping, the pane |
hooks/core.ts | pure reducers, input validation, the structure and history builders, SVG, summaries and the Markdown export |
hooks/palettes.ts | the colour palettes |
types/index.d.ts | the state contract |
tests/ledger.test.ts | pure behaviour, plus the tool and pane exercised through the engine on terminal and desktop |
See CONTRIBUTING.md, the code of conduct and the changelog.
MIT © Matheus Boni Vicari
hooks/register.tsx 650 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ApprovedPlan, Entry, Footprint, Meta, Moment, Status, Turn, View } from '../types'
5import {
6 DEFAULT_FOOTPRINT,
7 DEFAULT_META,
8 DEFAULT_TURN,
9 DEFAULT_VIEW,
10 HISTORY_CELL,
11 INSTRUCTIONS,
12 PLUGIN,
13 SCHEMA_VERSION,
14 TOOL,
15 TOOL_DESCRIPTION,
16 TOOL_NAME,
17 TOOL_SCHEMA,
18 activeStep,
19 addMoments,
20 addTask,
21 applyCall,
22 baseName,
23 buildGraph,
24 buildHistory,
25 SVG_WIDTH,
26 activityByTurn,
27 ageLevel,
28 graphAlt,
29 healthParts,
30 isFresh,
31 progressBar,
32 unlinkedCount,
33 graphSvg,
34 historyAlt,
35 historySvg,
36 clean,
37 countsOf,
38 currentGoal,
39 fitText,
40 graphLines,
41 compactTree,
42 promptLabel,
43 spinnerFrame,
44 isStale,
45 listOf,
46 markByYou,
47 moment,
48 momentsFor,
49 normalize,
50 normalizeEntries,
51 normalizeFootprint,
52 mergeMeta,
53 normalizeMeta,
54 normalizeTimeline,
55 normalizeTurn,
56 parseCall,
57 parseConfig,
58 shorten,
59 statusText,
60 stripUnsafe,
61 sortedAssumptions,
62 statusLine,
63 syncTodos,
64 toMarkdown,
65 toolReply,
66 termColors,
67 updateTask,
68 withClosedTurns,
69} from './core'
70import { paletteOf } from './palettes'
71import type { Config, Stamp, Todo } from './core'
72
73const PANE = 'intent-ledger'
74const TITLE = 'Intent'
75const PANE_COLUMNS = 60
76const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
77
78// ---------------------------------------------------------------- state
79
80const meta = atom({ plugin: 'intent-ledger', key: 'meta' } as const, DEFAULT_META)
81const entries = atom({ plugin: 'intent-ledger', key: 'entries' } as const, [])
82const timeline = atom({ plugin: 'intent-ledger', key: 'timeline' } as const, [])
83const turn = atom({ plugin: 'intent-ledger', key: 'turn' } as const, DEFAULT_TURN)
84const plan = atom({ plugin: 'intent-ledger', key: 'plan' } as const, null)
85const footprint = atom({ plugin: 'intent-ledger', key: 'footprint' } as const, DEFAULT_FOOTPRINT)
86const view = atom({ plugin: 'intent-ledger', key: 'view' } as const, DEFAULT_VIEW)
87const beat = atom({ plugin: 'intent-ledger', key: 'beat' } as const, 0)
88
89// The spinner counter moves only while a turn runs, so an idle session redraws nothing.
90let ticker: { cancel: () => void } | null = null
91async function stopTicker($: EngineInterface): Promise<void> {
92 ticker?.cancel()
93 ticker = null
94 await update($, beat, () => 0)
95}
96
97const getMeta = async ($: EngineInterface): Promise<Meta> => normalizeMeta(await read($, meta))
98const getEntries = async ($: EngineInterface): Promise<Entry[]> => normalizeEntries(await read($, entries))
99/**
100 * Apply one change to the entries inside a single `update`, so it reads the latest list and a
101 * concurrent writer's change is not overwritten. Counters merge by maximum afterwards.
102 */
103async function mutate<T>($: EngineInterface, turnNo: number, fn: (list: Entry[], m: Meta) => { entries: Entry[]; meta?: Meta; out: T }): Promise<T> {
104 const m = await getMeta($)
105 let result: { entries: Entry[]; meta?: Meta; out: T } | undefined
106 await update($, entries, cur => {
107 const list = normalizeEntries(cur)
108 result = fn(list, m)
109 return withClosedTurns(list, result.entries, turnNo)
110 })
111 const done = result as { entries: Entry[]; meta?: Meta; out: T } | undefined
112 if (!done) throw new Error('intent-ledger: the entries update did not run')
113 if (done.meta) {
114 const next = done.meta
115 await update($, meta, cur => mergeMeta(normalizeMeta(cur), next))
116 }
117 return done.out
118}
119
120const getTimeline = async ($: EngineInterface): Promise<Moment[]> => normalizeTimeline(await read($, timeline))
121const getTurn = async ($: EngineInterface): Promise<Turn> => normalizeTurn(await read($, turn))
122const getFootprint = async ($: EngineInterface): Promise<Footprint> => normalizeFootprint(await read($, footprint))
123const getView = async ($: EngineInterface): Promise<View> => normalize(DEFAULT_VIEW, await read($, view))
124async function getPlan($: EngineInterface): Promise<ApprovedPlan | null> {
125 const p = await read($, plan)
126 return p && typeof p.text === 'string' ? p : null
127}
128
129async function migrate($: EngineInterface) {
130 const m = await getMeta($)
131 if (m.schemaVersion >= SCHEMA_VERSION) return
132 await update($, entries, list => normalizeEntries(list))
133 await update($, timeline, list => normalizeTimeline(list))
134 await update($, meta, () => ({ ...m, schemaVersion: SCHEMA_VERSION }))
135}
136
137async function resetAll($: EngineInterface) {
138 await update($, meta, () => DEFAULT_META)
139 await update($, entries, () => [])
140 await update($, timeline, () => [])
141 await update($, turn, () => DEFAULT_TURN)
142 await update($, plan, () => null)
143 await update($, footprint, () => DEFAULT_FOOTPRINT)
144 await update($, view, () => DEFAULT_VIEW)
145}
146
147function whoOf(agentId: string | undefined): string {
148 return agentId ? `sub·${agentId.slice(0, 5)}` : 'main'
149}
150
151async function stampFor($: EngineInterface, agentId: string | undefined, source: Stamp['source']): Promise<Stamp> {
152 const [at, t] = await Promise.all([$.clock.now(), getTurn($)])
153 return { at, turn: t.n, who: whoOf(agentId), source }
154}
155
156async function note($: EngineInterface, list: Moment[]) {
157 if (list.length > 0) await update($, timeline, x => addMoments(normalizeTimeline(x), list))
158}
159
160async function refreshStatus($: EngineInterface, cfg: Config) {
161 if (!cfg.statusLine) return $.ui.status(undefined)
162 const [list, t] = await Promise.all([getEntries($), getTurn($)])
163 $.ui.status(statusLine(list, t.n, cfg.staleAfterTurns))
164}
165
166function openPane($: EngineInterface) {
167 return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 7 })
168}
169
170// ---------------------------------------------------------------- hooks
171
172export const register: Register = (on, options) => {
173 const cfg = parseConfig(options as Record<string, unknown> | undefined)
174
175 on('session.start', async ($, e, next) => {
176 await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA as unknown as Record<string, unknown>, isDeferred: false })
177 await $.command.register({
178 name: 'ledger',
179 description: 'Intent Ledger: open the pane, close it, reset it, or copy the ledger as Markdown',
180 argumentHint: '[open|close|reset|copy|status]',
181 })
182 await migrate($)
183 if (cfg.openOnStart) void openPane($).catch(() => undefined)
184 await refreshStatus($, cfg)
185 return next(e)
186 })
187
188 on('session.end', async ($, e, next) => {
189 await stopTicker($)
190 if (e.reason === 'clear') {
191 await resetAll($)
192 await refreshStatus($, cfg)
193 }
194 return next(e)
195 })
196
197 on('command.run', { command: 'ledger' }, async ($, e) => {
198 const verb = e.args.trim().split(/\s+/)[0] || 'open'
199 if (verb === 'close') {
200 await $.ui.close({ id: PANE })
201 return { text: 'Intent Ledger closed.' }
202 }
203 if (verb === 'reset') {
204 await resetAll($)
205 await refreshStatus($, cfg)
206 return { text: 'Intent Ledger cleared.' }
207 }
208 if (verb === 'copy') {
209 const md = toMarkdown(await getEntries($), await getPlan($))
210 const copied = await $.ui.copy({ text: md }).catch(() => ({ isCopied: false as const }))
211 return { text: copied.isCopied ? 'Ledger copied as Markdown.' : md }
212 }
213 if (verb === 'status') return { text: statusText(cfg) }
214 const opened = await openPane($)
215 return { text: opened.isPlaced ? 'Intent Ledger opened. Focus it with ctrl+x tab; press a row to expand it.' : `Intent Ledger is not shown yet: ${opened.reason}` }
216 })
217
218 // The instructions ride in the system prompt only where the ledger tool is offered.
219 on('prompt.compose', async ($, e, next) => {
220 const composed = await next(e)
221 if (!cfg.instructAgent || !e.tools.includes(TOOL)) return composed
222 return { sections: [...composed.sections, { id: `${PLUGIN}:instructions`, text: INSTRUCTIONS, scope: 'session' as const }] }
223 })
224
225 // The ledger tool writes to this pane and nothing else, so it need not interrupt the person.
226 on('tool.check', { tool: 'mcp__intent-ledger__ledger' }, async ($, e, next) => {
227 const verdict = await next(e)
228 if (!cfg.autoAllow || verdict.decision === 'deny') return verdict
229 return { ...verdict, decision: 'allow' as const, reason: `${PLUGIN}: the ledger tool writes only to the ledger pane` }
230 }).catch(($, e, next) => next(e))
231
232 // The ledger tool itself.
233 on('tool.call', { tool: 'mcp__intent-ledger__ledger' }, async ($, e) => {
234 const call = parseCall(e as unknown)
235 const stamp = await stampFor($, e.agentId, 'agent')
236 const since = (await getMeta($)).reportedAt
237 const applied = await mutate($, stamp.turn, (list, m) => {
238 const a = applyCall(m, list, call, stamp)
239 return { entries: a.entries, meta: a.meta, out: a }
240 })
241 await note($, momentsFor(applied, stamp))
242 if (applied.added.length + applied.changed.length > 0) {
243 await update($, turn, t => ({ ...normalizeTurn(t), logged: normalizeTurn(t).logged + 1 }))
244 }
245 if (applied.changed.some(c => c.entry.kind === 'assumption' && c.entry.status === 'invalidated')) {
246 $.ui.toast(`${PLUGIN}: an assumption was invalidated`)
247 }
248 await refreshStatus($, cfg)
249 await update($, meta, cur => mergeMeta(normalizeMeta(cur), { ...DEFAULT_META, reportedAt: stamp.at }))
250 return { result: toolReply(applied, applied.entries, since) }
251 })
252
253 // Visible actions: edits for the footprint, and todo lists, tasks and approved plans for the plan.
254 on('tool.call', async ($, e, next) => {
255 const name = String(e.tool)
256 const ran = await next(e)
257 if (ran.deny !== undefined || ran.isError === true) return ran
258 const input = e as unknown as Record<string, unknown>
259
260 if (EDIT_TOOLS.has(name)) {
261 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : null
262 if (path) {
263 await update($, turn, t => {
264 const x = normalizeTurn(t)
265 return x.edits.includes(path) ? x : { ...x, edits: [...x.edits, path] }
266 })
267 await update($, footprint, f => {
268 const x = normalizeFootprint(f)
269 return x.files.includes(path) ? x : { ...x, files: [...x.files, path].slice(-200) }
270 })
271 }
272 return ran
273 }
274
275 if (!cfg.observePlans) return ran
276
277 if (name === 'TodoWrite') {
278 const stamp = await stampFor($, e.agentId, 'observed')
279 const synced = await mutate($, stamp.turn, (list, m) => {
280 const r = syncTodos(m, list, listOf<Todo>(input.todos), stamp)
281 return { entries: r.entries, meta: r.meta, out: r }
282 })
283 if (synced.summary) await note($, [moment(stamp, synced.summary, 'info')])
284 await refreshStatus($, cfg)
285 } else if (name === 'TaskCreate') {
286 const task = (ran.result as { task?: { id?: unknown; subject?: unknown } } | undefined)?.task
287 if (task && typeof task.id === 'string') {
288 const stamp = await stampFor($, e.agentId, 'observed')
289 await mutate($, stamp.turn, (list, m) => {
290 const r = addTask(m, list, task.id as string, String(task.subject ?? input.subject ?? ''), stamp)
291 return { entries: r.entries, meta: r.meta, out: null }
292 })
293 await refreshStatus($, cfg)
294 }
295 } else if (name === 'TaskUpdate' && typeof input.taskId === 'string') {
296 const at = await $.clock.now()
297 const patch = { status: typeof input.status === 'string' ? input.status : undefined, subject: typeof input.subject === 'string' ? input.subject : undefined }
298 const turnNow = (await getTurn($)).n
299 await update($, entries, list => withClosedTurns(normalizeEntries(list), updateTask(normalizeEntries(list), input.taskId as string, patch, at), turnNow))
300 await refreshStatus($, cfg)
301 } else if (name === 'ExitPlanMode') {
302 const text = (ran.result as { plan?: unknown } | undefined)?.plan
303 if (typeof text === 'string' && text.trim()) {
304 const stamp = await stampFor($, e.agentId, 'observed')
305 await update($, plan, () => ({ text: text.trim(), at: stamp.at, turn: stamp.turn }))
306 await note($, [moment(stamp, `plan approved · ${clean((text.split('\n').find(l => l.trim()) ?? '').replace(/^\s*#{1,6}\s+/, ''), 80)}`, 'info')])
307 }
308 }
309 return ran
310 })
311
312 on('turn.start', async ($, e, next) => {
313 const now = await $.clock.now()
314 await update($, turn, t => ({ ...DEFAULT_TURN, n: normalizeTurn(t).n + 1, startedAt: now, isRunning: true }))
315 if (e.text.trim()) {
316 const t = await getTurn($)
317 await note($, [moment({ at: now, turn: t.n, who: 'you' }, shorten(promptLabel(e.text), 90), 'you')])
318 }
319 await refreshStatus($, cfg)
320 if (cfg.motion && !e.agentId && !ticker) ticker = $.clock.every(400, () => void update($, beat, n => (typeof n === 'number' ? n : 0) + 1))
321 return next(e)
322 })
323
324 on('turn.complete', async ($, e, next) => {
325 const done = await next(e)
326 if (e.agentId) return done
327 const t = await getTurn($)
328 await stopTicker($)
329 await update($, turn, x => ({ ...normalizeTurn(x), isRunning: false }))
330 // Changes made with nothing logged are the moments a monitor most wants to see.
331 if (cfg.instructAgent && t.edits.length > 0 && t.logged === 0) {
332 const now = await $.clock.now()
333 const files = t.edits.map(baseName)
334 await note($, [moment({ at: now, turn: t.n, who: 'main' }, `changed ${files.length} file${files.length === 1 ? '' : 's'} with nothing logged (${shorten(files.join(', '), 60)})`, 'warn')])
335 await update($, footprint, f => {
336 const x = normalizeFootprint(f)
337 return { ...x, unloggedTurns: [...x.unloggedTurns, t.n].slice(-20) }
338 })
339 }
340 await refreshStatus($, cfg)
341 return done
342 })
343
344 // ---------------------------------------------------------------- the pane
345
346 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
347 const els = $.ui.resolve(e)
348 const { Box, Text, Button } = els
349 // Only the remote surfaces (desktop, VS Code, mobile) draw Svg; the terminal keeps the text layouts.
350 const Svg = e.surface === 'terminal' ? undefined : (els as unknown as { Svg?: (props: { source: string; alt: string }) => unknown }).Svg
351 const [list, moments, t, fp, v, approved, pulse] = await Promise.all([getEntries($), getTimeline($), getTurn($), getFootprint($), getView($), getPlan($), cfg.motion ? read($, beat) : Promise.resolve(0)])
352 const width = Math.max(24, e.props.bodyColumns)
353 // The active step shows a spinner while the agent works; otherwise the plain marker.
354 const busy = cfg.motion && t.isRunning && e.surface === 'terminal'
355 const live = busy ? spinnerFrame(typeof pulse === 'number' ? pulse : 0) : '▸'
356 const c = countsOf(list, t.n, cfg.staleAfterTurns)
357 const goal = currentGoal(list)
358 const now = activeStep(list)
359
360 const toggle = (id: string) => update($, view, x => ({ expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
361 const mark = async (id: string, status: Status) => {
362 const at = await $.clock.now()
363 const turnNow = (await getTurn($)).n
364 const found = await mutate($, turnNow, cur => {
365 const res = markByYou(cur, id, status, at)
366 return { entries: res.entries, out: res.entry }
367 })
368 if (!found) return
369 const hit: Entry = found
370 await note($, [moment({ at, turn: turnNow, who: 'you' }, `marked ${hit.id} ${hit.status}`, 'you')])
371 await refreshStatus($, cfg)
372 if (hit.status === 'invalidated') {
373 // A draft in the person's own prompt box: they decide whether and how to tell the agent.
374 await $.prompt.fill({ text: `Assumption ${hit.id} ("${shorten(hit.text, 80)}") is wrong: `, mode: 'append' }).catch(() => undefined)
375 $.ui.toast('A reply is drafted in your prompt: finish it and send.')
376 }
377 }
378
379 // Headers take the terminal's own foreground; the brand orange is opt-in (`brandAccent`).
380 const pal = paletteOf(cfg.palette)
381 const K = termColors(pal, cfg.brandAccent)
382 const accent = K.accent
383 // A one-column gutter marks what changed since your last prompt.
384 const freshIds = new Set(list.filter(x => isFresh(x, t)).map(x => x.id))
385 const gut = (id: string) => <Text bold color={accent}>{freshIds.has(id) ? '▎' : ' '}</Text>
386 const header = (label: string, extra: string) => (
387 <Box flexDirection="row">
388 <Text bold color={accent}>{label}</Text>
389 <Text dimColor wrap="truncate-end">{extra ? ` ${extra}` : ''}</Text>
390 </Box>
391 )
392
393 // ------------------------------------------------ inline: a short summary
394 if (e.props.placement === 'inline') {
395 const rows = [
396 <Text wrap="truncate-end">
397 <Text bold color={accent}>GOAL </Text>
398 {goal ? goal.text : <Text dimColor>none logged yet</Text>}
399 </Text>,
400 now ? <Text wrap="truncate-end"><Text color={K.suggestion} bold>{`${live} NOW `}</Text>{now.text}</Text> : null,
401 <Text wrap="truncate-end">
402 <Text color={K.warning}>{`${c.open} open`}</Text>
403 <Text dimColor>{` · ${c.confirmed} ✓ · `}</Text>
404 <Text color={c.invalidated > 0 ? K.error : K.subtle}>{`${c.invalidated} ✗`}</Text>
405 <Text color={K.warning}>{c.stale > 0 ? ` · ${c.stale} stale` : ''}</Text>
406 <Text dimColor>{c.steps > 0 ? ` · plan ${c.stepsDone}/${c.steps}` : ''}{c.questions > 0 ? ` · ${c.questions} questions` : ''}</Text>
407 </Text>,
408 ...moments.slice(-1).map(m => <Text dimColor wrap="truncate-end">{`T${m.turn} ${m.who}: ${m.text}`}</Text>),
409 ].filter(Boolean)
410 return <Box flexDirection="column">{rows}</Box>
411 }
412
413 // ------------------------------------------------ docked: the full ledger
414 const assumptionRow = (a: Entry) => {
415 const glyph = a.status === 'open' ? '?' : a.status === 'confirmed' ? '✓' : a.status === 'invalidated' ? '✗' : '–'
416 const color = a.status === 'open' ? K.warning : a.status === 'confirmed' ? K.success : a.status === 'invalidated' ? K.error : K.inactive
417 const stale = isStale(a, t.n, cfg.staleAfterTurns)
418 const confGlyph = a.confidence === 'high' ? '▆' : a.confidence === 'medium' ? '▄' : a.confidence === 'low' ? '▂' : ''
419 const aging = a.status === 'open' && ageLevel(t.n - a.turn, cfg.staleAfterTurns) > 0
420 const metaBits = [a.confidence ? `${confGlyph} ${a.confidence}` : '', `T${a.turn}`, a.who !== 'main' ? a.who : '', a.resolvedBy === 'you' ? 'by you' : ''].filter(Boolean).join(' · ')
421 // `stale` leads the claim as a badge so a narrow pane never cuts it off; the chevron says the row opens.
422 const room = Math.max(8, width - metaBits.length - a.id.length - (stale ? 14 : 6) - 3)
423 const isOpen = v.expanded === a.id
424 return (
425 <Box flexDirection="column">
426 <Button key={`row:${a.id}`} plain onPress={() => toggle(a.id)}>
427 {gut(a.id)}
428 <Text color={color}>{glyph} </Text>
429 <Text dimColor>{a.id} </Text>
430 {stale && <Text color={K.warning} bold>{'stale '}</Text>}
431 <Text strikethrough={a.status === 'dropped'}>{shorten(a.text, room)}</Text>
432 <Text color={aging ? K.warning : K.subtle}>{metaBits ? ` ${metaBits}` : ''}</Text>
433 <Text color={K.subtle}>{isOpen ? ' ▾' : ' ▸'}</Text>
434 </Button>
435 {isOpen && (
436 <Box flexDirection="column" paddingLeft={4}>
437 {a.text.length > room && <Text>{a.text}</Text>}
438 {a.why && <Text dimColor>why: {a.why}</Text>}
439 {a.note && <Text dimColor>note: {a.note}</Text>}
440 <Box flexDirection="row" gap={1}>
441 {a.status !== 'confirmed' && <Button key={`ok:${a.id}`} label="✓ confirm" onPress={() => mark(a.id, 'confirmed')} />}
442 {a.status !== 'invalidated' && <Button key={`no:${a.id}`} label="✗ wrong (drafts a reply)" onPress={() => mark(a.id, 'invalidated')} />}
443 {a.status !== 'open' && <Button key={`re:${a.id}`} label="reopen" onPress={() => mark(a.id, 'open')} />}
444 </Box>
445 </Box>
446 )}
447 </Box>
448 )
449 }
450
451 // Rows for everything but assumptions: one line when closed, the whole entry (text, reason, note) when pressed.
452 const textRow = (x: Entry, glyph: string, glyphColor: string | undefined, opts: { why?: boolean; dim?: boolean } = {}) => {
453 const isOpen = v.expanded === x.id
454 const reason = opts.why && x.why ? ` — ${x.why}` : ''
455 const room = Math.max(8, width - x.id.length - 8)
456 const line = shorten(x.text + reason, room)
457 const cut = line.length < (x.text + reason).length
458 const textCut = x.text.length > room
459 const head = line.slice(0, x.text.length)
460 const tail = line.slice(x.text.length)
461 const metaBits = [`logged T${x.turn}`, x.who !== 'main' ? `by ${x.who}` : '', x.source === 'observed' ? 'from your todo list' : ''].filter(Boolean).join(' · ')
462 return (
463 <Box flexDirection="column">
464 <Button key={`row:${x.id}`} plain onPress={() => toggle(x.id)}>
465 {gut(x.id)}
466 <Text color={glyphColor}>{`${glyph} `}</Text>
467 <Text dimColor>{`${x.id} `}</Text>
468 <Text dimColor={opts.dim}>{head}</Text>
469 <Text dimColor>{tail}</Text>
470 <Text color={K.subtle}>{isOpen ? ' ▾' : cut ? ' ▸' : ''}</Text>
471 </Button>
472 {isOpen && (
473 <Box flexDirection="column" paddingLeft={4}>
474 {textCut && <Text>{x.text}</Text>}
475 {x.why && <Text dimColor>why: {x.why}</Text>}
476 {x.note && <Text dimColor>note: {x.note}</Text>}
477 <Text color={K.subtle}>{metaBits}</Text>
478 </Box>
479 )}
480 </Box>
481 )
482 }
483
484 const assumptions = sortedAssumptions(list)
485 const shownAssumptions = assumptions.filter(a => a.status !== 'dropped').slice(0, 12)
486 const steps = list.filter(x => x.kind === 'step' && x.status !== 'dropped')
487 const tree = buildGraph(list)
488 const graph = graphLines(tree)
489 const compact = compactTree(tree)
490 const textGraph = graphLines(compact.roots)
491 const unlinked = unlinkedCount(list)
492 // With the structure tree below, the plan keeps to what is active and next; the tree holds the rest.
493 const stepsShown = graph.length > 1 ? steps.filter(s => s.status !== 'done').slice(0, 3) : [...steps.filter(s => s.status === 'done').slice(-2), ...steps.filter(s => s.status !== 'done')].slice(0, 10)
494 const stepsMore = steps.filter(x => x.status !== 'done').length - stepsShown.filter(x => x.status !== 'done').length
495 const timelineShown = graph.length > 1 ? moments.filter(m => m.tone !== 'add') : moments
496 const labelWidth = Math.min(24, Math.max(12, width - 12 * HISTORY_CELL - 1))
497 const history = buildHistory(list, t.n, Math.max(2, Math.min(12, Math.floor((width - labelWidth - 1) / HISTORY_CELL))))
498 const health = healthParts(c)
499 const decisions = list.filter(x => x.kind === 'decision' && x.status !== 'dropped').slice(-4)
500 const findings = list.filter(x => x.kind === 'finding' && x.status !== 'dropped').slice(-4)
501 const openQs = list.filter(x => (x.kind === 'question' || x.kind === 'risk') && x.status === 'open').slice(-5)
502 const otherGoals = list.filter(x => x.kind === 'goal' && x.id !== goal?.id && x.status !== 'dropped').length
503
504 return (
505 <Box flexDirection="column" gap={1}>
506 <Box flexDirection="column">
507 {header('GOAL', otherGoals > 0 ? `${otherGoals} earlier` : '')}
508 {goal ? <Text>{goal.text}</Text> : <Text dimColor>No goal logged yet. The agent logs one when it starts a multi-step task.</Text>}
509 {now && (
510 <Text>
511 <Text color={K.suggestion} bold>{`${live} NOW `}</Text>
512 {now.text}
513 </Text>
514 )}
515 {health.length > 0 && (
516 <Text wrap="truncate-end">
517 {/* The loop variable is not `h`: the module's JSX factory is the global `h`. */}
518 {health.map((part, i) => (
519 <Text color={part.tone === 'plain' ? undefined : part.tone === 'warning' ? K.warning : part.tone === 'error' ? K.error : K.success} bold={part.text.startsWith('⚠')}>{`${i > 0 ? ' · ' : ''}${part.text}`}</Text>
520 ))}
521 </Text>
522 )}
523 </Box>
524
525 <Box flexDirection="column">
526 {header('ASSUMPTIONS', assumptions.length > 0 ? `${c.open} open · ${c.confirmed} ✓ · ${c.invalidated} ✗${c.stale > 0 ? ` · ${c.stale} stale` : ''}` : '')}
527 {shownAssumptions.length === 0 && <Text dimColor>None logged yet. The agent adds them as it works.</Text>}
528 {c.open > 0 && v.expanded === null && <Text dimColor>press a row to read it in full, confirm or correct it</Text>}
529 {shownAssumptions.map(assumptionRow)}
530 {assumptions.length > shownAssumptions.length && <Text dimColor>{`+${assumptions.length - shownAssumptions.length} more (/ledger copy for all)`}</Text>}
531 </Box>
532
533 {openQs.length > 0 && (
534 <Box flexDirection="column">
535 {header('OPEN QUESTIONS & RISKS', '')}
536 {openQs.map(q => textRow(q, q.kind === 'risk' ? '!' : '?', q.kind === 'risk' ? K.error : K.warning))}
537 </Box>
538 )}
539
540 {(stepsShown.length > 0 || approved) && (
541 <Box flexDirection="column">
542 {header('PLAN', steps.length > 0 ? `${progressBar(c.stepsDone, c.steps)} ${c.stepsDone}/${c.steps} done` : '')}
543 {stepsShown.map(s => textRow(s, s.status === 'active' ? live : s.status === 'done' ? '✓' : '·', s.status === 'active' ? K.suggestion : s.status === 'done' ? K.success : K.subtle, { dim: s.status === 'done' }))}
544 {stepsMore > 0 && <Text dimColor>{`+${stepsMore} more step${stepsMore === 1 ? '' : 's'} to do`}</Text>}
545 {approved && (
546 <Button key="row:plan" plain onPress={() => toggle('plan')}>
547 <Text color={K.planMode}>◆ </Text>
548 <Text dimColor>{`approved plan · T${approved.turn} · ${v.expanded === 'plan' ? 'hide' : 'show'}`}</Text>
549 </Button>
550 )}
551 {approved && v.expanded === 'plan' && (
552 <Box paddingLeft={2} flexDirection="column">
553 {approved.text.split('\n').slice(0, 40).map(line => {
554 const heading = /^#{1,6}\s+/.test(line)
555 return <Text dimColor={!heading} bold={heading}>{shorten(stripUnsafe(line).replace(/^#{1,6}\s+/, ''), 400)}</Text>
556 })}
557 </Box>
558 )}
559 </Box>
560 )}
561
562 {decisions.length > 0 && (
563 <Box flexDirection="column">
564 {header('DECISIONS', '')}
565 {decisions.map(d => textRow(d, '◆', K.subtle, { why: true }))}
566 </Box>
567 )}
568
569 {findings.length > 0 && (
570 <Box flexDirection="column">
571 {header('FINDINGS', '')}
572 {findings.map(f => textRow(f, '•', K.subtle, { why: true }))}
573 </Box>
574 )}
575
576 {graph.length > 1 && (
577 <Box flexDirection="column">
578 {header('STRUCTURE', unlinked > 0 ? `${unlinked} unlinked` : '')}
579 {Svg && <Svg source={graphSvg(tree, SVG_WIDTH, pal, unlinked, freshIds)} alt={graphAlt(buildGraph(list, false), unlinked, freshIds)} />}
580 {!Svg && textGraph.map(l => (
581 <Text wrap="truncate-end" dimColor={(l.entry.status === 'done' || l.entry.status === 'confirmed') && !l.onPath} bold={l.onPath}>
582 {gut(l.entry.id)}
583 <Text color={K.subtle}>{l.prefix}</Text>
584 <Text color={l.entry.status === 'invalidated' ? K.error : l.entry.status === 'active' ? K.suggestion : l.entry.kind === 'goal' ? accent : undefined}>{`${l.glyph} `}</Text>
585 <Text dimColor>{`${l.entry.id} `}</Text>
586 {l.tag && <Text color={K.error}>{`${l.tag} `}</Text>}
587 {l.entry.text}
588 {l.hidden > 0 && <Text dimColor>{` (+${l.hidden})`}</Text>}
589 </Text>
590 ))}
591 {!Svg && compact.omitted > 0 && <Text dimColor>{`+${compact.omitted} more listed in the sections above`}</Text>}
592 </Box>
593 )}
594
595 {history.turns.length > 1 && history.rows.length > 1 && (
596 <Box flexDirection="column">
597 {header('HISTORY', `T${history.turns[0]}–T${history.turns[history.turns.length - 1]}${history.turns[0] > 1 ? ` · last ${history.turns.length}` : ''}`)}
598 {Svg && <Svg source={historySvg(history, pal)} alt={historyAlt(history)} />}
599 {!Svg && (
600 <Text wrap="truncate-end">
601 <Text dimColor>{fitText('', labelWidth + 1)}</Text>
602 {history.turns.map((n, i) => (
603 <Text dimColor={i < history.turns.length - 1} bold={i === history.turns.length - 1}>{String(n % 100).padStart(2).padEnd(HISTORY_CELL)}</Text>
604 ))}
605 </Text>
606 )}
607 {!Svg && (
608 <Text wrap="truncate-end">
609 <Text dimColor>{fitText('changes', labelWidth + 1)}</Text>
610 {activityByTurn(list, history.turns).map(n => (
611 <Text dimColor>{(n > 0 ? String(Math.min(n, 99)) : '·').padStart(2).padEnd(HISTORY_CELL)}</Text>
612 ))}
613 </Text>
614 )}
615 {!Svg && history.rows.map(r => (
616 <Text wrap="truncate-end">
617 <Text>{`${r.entry.id} `}</Text>
618 <Text dimColor={r.entry.status !== 'open' && r.entry.status !== 'active'}>{fitText(r.entry.text, Math.max(0, labelWidth - r.entry.id.length))}</Text>
619 {r.cells.map(c => (
620 <Text color={c.includes('✗') ? K.error : c.includes('✓') ? K.success : K.subtle}>{c}</Text>
621 ))}
622 </Text>
623 ))}
624 {!Svg && <Text dimColor wrap="truncate-end">○ logged ─ open ✓ done ✗ wrong × dropped</Text>}
625 </Box>
626 )}
627
628 <Box flexDirection="column">
629 {header('TIMELINE', '')}
630 {moments.length === 0 && <Text dimColor>Nothing yet: your prompts and ledger changes appear here.</Text>}
631 {timelineShown.slice(-10).map(m => (
632 <Text color={m.tone === 'warn' ? K.warning : undefined} dimColor={m.tone === 'info'}>
633 <Text dimColor>{`T${m.turn} `}</Text>
634 <Text color={m.who === 'you' ? K.suggestion : K.subtle}>{`${m.who} `}</Text>
635 {m.text}
636 </Text>
637 ))}
638 </Box>
639
640 {(fp.files.length > 0 || fp.unloggedTurns.length > 0) && (
641 <Text wrap="truncate-end" dimColor>
642 {`${fp.files.length} file${fp.files.length === 1 ? '' : 's'} touched`}
643 {fp.unloggedTurns.length > 0 ? <Text color={K.warning}>{` · unlogged changes in T${fp.unloggedTurns.slice(-4).join(', T')}`}</Text> : ''}
644 </Text>
645 )}
646 </Box>
647 )
648 })
649}
650hooks/core.ts 1066 lines1import { DEFAULT_PALETTE_ID, PALETTE_IDS, markOn, paletteOf } from './palettes'
2import type { Palette } from './palettes'
3import type { Confidence, Entry, Footprint, Kind, Meta, Moment, Source, Status, Tone, Turn, View } from '../types'
4
5export const SCHEMA_VERSION = 1
6/** The plugin's version; keep it equal to `version` in `.claude-plugin/plugin.json` (CI checks). */
7export const VERSION = '0.5.1'
8export const PLUGIN = 'intent-ledger'
9export const TOOL_NAME = 'ledger'
10export const TOOL = 'mcp__intent-ledger__ledger'
11
12export const KINDS: readonly Kind[] = ['goal', 'assumption', 'step', 'decision', 'question', 'risk', 'finding']
13export const STATUSES: readonly Status[] = ['open', 'active', 'done', 'confirmed', 'invalidated', 'answered', 'dropped']
14export const CONFIDENCES: readonly Confidence[] = ['low', 'medium', 'high']
15
16const PREFIX: Record<Kind, string> = { goal: 'G', assumption: 'A', step: 'S', decision: 'D', question: 'Q', risk: 'R', finding: 'F' }
17
18/** The statuses each kind can take; the first is where a new entry starts. */
19export const ALLOWED: Record<Kind, readonly Status[]> = {
20 goal: ['active', 'done', 'dropped'],
21 assumption: ['open', 'confirmed', 'invalidated', 'dropped'],
22 step: ['open', 'active', 'done', 'dropped'],
23 decision: ['open', 'dropped'],
24 question: ['open', 'answered', 'dropped'],
25 risk: ['open', 'dropped'],
26 finding: ['open', 'dropped'],
27}
28
29/** Where a new entry of a kind starts. */
30export function startOf(kind: Kind): Status {
31 return ALLOWED[kind][0] ?? 'open'
32}
33
34export const DEFAULT_META: Meta = { schemaVersion: SCHEMA_VERSION, seq: { goal: 0, assumption: 0, step: 0, decision: 0, question: 0, risk: 0, finding: 0 }, reportedAt: 0 }
35export const DEFAULT_TURN: Turn = { n: 0, startedAt: 0, logged: 0, edits: [], isRunning: false }
36export const DEFAULT_FOOTPRINT: Footprint = { files: [], unloggedTurns: [] }
37export const DEFAULT_VIEW: View = { expanded: null }
38
39export const MAX_TEXT = 200
40export const MAX_ENTRIES = 300
41export const MAX_TIMELINE = 120
42
43// ---------------------------------------------------------------- config
44
45export type Config = {
46 instructAgent: boolean
47 autoAllow: boolean
48 observePlans: boolean
49 staleAfterTurns: number
50 openOnStart: boolean
51 statusLine: boolean
52 motion: boolean
53 brandAccent: boolean
54 palette: string
55}
56
57/** What `/ledger status` prints: the version and the settings that decide how the pane looks and behaves. */
58export function statusText(cfg: Config): string {
59 const on = (b: boolean) => (b ? 'on' : 'off')
60 return [
61 `Intent Ledger ${VERSION}`,
62 `palette: ${cfg.palette}${cfg.palette === DEFAULT_PALETTE_ID ? ` (follows your Claude Code theme; brandAccent ${on(cfg.brandAccent)})` : ''}`,
63 `instructAgent ${on(cfg.instructAgent)} · observePlans ${on(cfg.observePlans)} · autoAllow ${on(cfg.autoAllow)} · statusLine ${on(cfg.statusLine)} · motion ${on(cfg.motion)} · openOnStart ${on(cfg.openOnStart)} · stale after ${cfg.staleAfterTurns} turns`,
64 ].join('\n')
65}
66
67export function parseConfig(options: Record<string, unknown> | undefined): Config {
68 const o = options ?? {}
69 const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
70 const stale = typeof o.staleAfterTurns === 'number' && Number.isFinite(o.staleAfterTurns) ? Math.max(0, Math.round(o.staleAfterTurns)) : 3
71 return {
72 instructAgent: bool('instructAgent', true),
73 autoAllow: bool('autoAllow', true),
74 observePlans: bool('observePlans', true),
75 staleAfterTurns: stale,
76 openOnStart: bool('openOnStart', true),
77 statusLine: bool('statusLine', true),
78 motion: bool('motion', true),
79 brandAccent: bool('brandAccent', false),
80 palette: typeof o.palette === 'string' && PALETTE_IDS.includes(o.palette.trim().toLowerCase()) ? o.palette.trim().toLowerCase() : DEFAULT_PALETTE_ID,
81 }
82}
83
84// ---------------------------------------------------------------- reading stored values
85
86export function listOf<T>(x: unknown): T[] {
87 return Array.isArray(x) ? (x as T[]) : []
88}
89
90/** A stored value merged over its defaults, so a shape saved by an older build still reads. */
91export function normalize<T extends object>(defaults: T, x: unknown): T {
92 return x && typeof x === 'object' && !Array.isArray(x) ? { ...defaults, ...(x as Partial<T>) } : defaults
93}
94
95export function normalizeMeta(x: unknown): Meta {
96 const m = normalize(DEFAULT_META, x)
97 return { schemaVersion: m.schemaVersion, seq: normalize(DEFAULT_META.seq, m.seq), reportedAt: typeof m.reportedAt === 'number' ? m.reportedAt : 0 }
98}
99
100/** Two counters, whichever is further along per kind: writers that each held an older copy still agree. */
101export function mergeMeta(a: Meta, b: Meta): Meta {
102 const seq = { ...a.seq }
103 for (const k of KINDS) seq[k] = Math.max(a.seq[k] ?? 0, b.seq[k] ?? 0)
104 return { schemaVersion: Math.max(a.schemaVersion, b.schemaVersion), seq, reportedAt: Math.max(a.reportedAt, b.reportedAt) }
105}
106
107export function normalizeEntry(x: unknown): Entry | null {
108 if (!x || typeof x !== 'object') return null
109 const e = x as Partial<Entry>
110 if (typeof e.id !== 'string' || !KINDS.includes(e.kind as Kind) || typeof e.text !== 'string') return null
111 const kind = e.kind as Kind
112 return {
113 id: e.id,
114 kind,
115 text: e.text,
116 why: typeof e.why === 'string' ? e.why : '',
117 confidence: CONFIDENCES.includes(e.confidence as Confidence) ? (e.confidence as Confidence) : null,
118 status: ALLOWED[kind].includes(e.status as Status) ? (e.status as Status) : startOf(kind),
119 source: e.source === 'observed' || e.source === 'you' ? e.source : 'agent',
120 who: typeof e.who === 'string' ? e.who : 'main',
121 turn: typeof e.turn === 'number' ? e.turn : 0,
122 at: typeof e.at === 'number' ? e.at : 0,
123 updatedAt: typeof e.updatedAt === 'number' ? e.updatedAt : 0,
124 note: typeof e.note === 'string' ? e.note : '',
125 resolvedBy: e.resolvedBy === 'agent' || e.resolvedBy === 'you' || e.resolvedBy === 'observed' ? e.resolvedBy : null,
126 ref: typeof e.ref === 'string' ? e.ref : null,
127 of: typeof e.of === 'string' && e.of !== e.id ? e.of : null,
128 closedTurn: typeof e.closedTurn === 'number' ? e.closedTurn : null,
129 }
130}
131
132export function normalizeEntries(x: unknown): Entry[] {
133 return listOf<unknown>(x)
134 .map(normalizeEntry)
135 .filter((e): e is Entry => e !== null)
136}
137
138export function normalizeTimeline(x: unknown): Moment[] {
139 return listOf<Moment>(x).filter(m => m && typeof m.text === 'string' && typeof m.at === 'number')
140}
141
142export function normalizeTurn(x: unknown): Turn {
143 const t = normalize(DEFAULT_TURN, x)
144 return { ...t, edits: listOf<string>(t.edits) }
145}
146
147export function normalizeFootprint(x: unknown): Footprint {
148 const f = normalize(DEFAULT_FOOTPRINT, x)
149 return { files: listOf<string>(f.files), unloggedTurns: listOf<number>(f.unloggedTurns) }
150}
151
152// ---------------------------------------------------------------- text
153
154// Control characters, bidi overrides and zero-width marks, lone surrogates, and the two non-characters.
155// Agent text can come from untrusted files, and it reaches a terminal and SVG markup.
156const UNSAFE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff\ufffe\uffff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g
157
158export function stripUnsafe(s: string): string {
159 return s.replace(UNSAFE, '')
160}
161
162/** `s` cut to `n` code points (never mid-pair), ending in an ellipsis when cut. */
163export function clip(s: string, n: number): string {
164 const cps = Array.from(s)
165 if (cps.length <= n) return s
166 return n <= 1 ? (cps.length > 0 ? '…' : '') : `${cps.slice(0, n - 1).join('')}…`
167}
168
169export function clean(s: unknown, max = MAX_TEXT): string {
170 if (typeof s !== 'string') return ''
171 return clip(stripUnsafe(s.replace(/\s+/g, ' ')).trim(), max)
172}
173
174/** A prompt as the timeline shows it: the tags that wrap pasted text are dropped, the text stays. */
175export function promptLabel(text: unknown): string {
176 if (typeof text !== 'string') return ''
177 return clean(text.replace(/<[\\/]*pasted_content\b[^>]*>/gi, ' '))
178}
179
180export function shorten(s: string, n: number): string {
181 return clip(s, n)
182}
183
184export function ago(ms: number): string {
185 const s = Math.max(0, Math.round(ms / 1000))
186 if (s < 60) return `${s}s`
187 const m = Math.round(s / 60)
188 if (m < 60) return `${m}m`
189 const h = Math.floor(m / 60)
190 return `${h}h${String(m % 60).padStart(2, '0')}`
191}
192
193export function baseName(path: string): string {
194 const parts = path.split(/[\\/]/)
195 return parts[parts.length - 1] || path
196}
197
198// ---------------------------------------------------------------- the ledger tool's input
199
200export type AddItem = { kind: Kind; text: string; why: string; confidence: Confidence | null; of: string | null; pos: number }
201export type ResolveItem = { id: string; status: Status; note: string }
202export type ParsedCall = { add: AddItem[]; resolve: ResolveItem[]; problems: string[] }
203
204export const TOOL_DESCRIPTION = [
205 'Record your intent in the ledger the user watches in a side pane: your goal, the assumptions you are acting on,',
206 'your planned steps, decisions between alternatives, open questions, risks and findings (facts you learned). One short line per entry; batch several in one call.',
207 'Use `resolve` to update an entry by id as you learn: confirm or invalidate an assumption (say what you found in `note`),',
208 'mark a step active or done, answer a question, or drop an entry that no longer applies.',
209 'The result lists what is still open, including any assumption the user marked in the pane.',
210].join(' ')
211
212export const TOOL_SCHEMA = {
213 type: 'object',
214 properties: {
215 add: {
216 type: 'array',
217 description: 'New entries.',
218 items: {
219 type: 'object',
220 properties: {
221 kind: { type: 'string', enum: [...KINDS] },
222 text: { type: 'string', description: 'One line, under 200 characters.' },
223 why: { type: 'string', description: 'For assumptions, decisions and findings: the evidence or reason, briefly.' },
224 confidence: { type: 'string', enum: [...CONFIDENCES], description: 'For assumptions: how sure you are.' },
225 of: { type: 'string', description: 'What this entry serves or rests on: the id of an existing entry (a step of S2, an assumption of S3), or "@N" for the Nth entry of this same `add` list, counting from 1 ("@1" is the first). Steps default to the goal.' },
226 },
227 required: ['kind', 'text'],
228 },
229 },
230 resolve: {
231 type: 'array',
232 description: 'Status changes for existing entries, by id (A1, S3, ...).',
233 items: {
234 type: 'object',
235 properties: {
236 id: { type: 'string' },
237 status: { type: 'string', enum: [...STATUSES] },
238 note: { type: 'string', description: 'What you found or why it changed.' },
239 },
240 required: ['id', 'status'],
241 },
242 },
243 },
244} as const
245
246export function parseCall(input: unknown): ParsedCall {
247 const x = (input && typeof input === 'object' ? input : {}) as { add?: unknown; resolve?: unknown }
248 const problems: string[] = []
249 const add: AddItem[] = []
250 listOf<Record<string, unknown>>(x.add).forEach((raw, index) => {
251 const kind = raw?.kind as Kind
252 const text = clean(raw?.text)
253 if (!KINDS.includes(kind)) {
254 problems.push(`unknown kind "${String(raw?.kind)}"`)
255 return
256 }
257 if (!text) {
258 problems.push(`a ${kind} with no text`)
259 return
260 }
261 const confidence = CONFIDENCES.includes(raw?.confidence as Confidence) ? (raw.confidence as Confidence) : null
262 const of = clean(raw?.of, 12).toUpperCase()
263 add.push({ kind, text, why: clean(raw?.why, 300), confidence, of: of || null, pos: index + 1 })
264 })
265 const resolve: ResolveItem[] = []
266 for (const raw of listOf<Record<string, unknown>>(x.resolve)) {
267 const id = clean(raw?.id, 12).toUpperCase()
268 const status = raw?.status as Status
269 if (!id || !STATUSES.includes(status)) {
270 problems.push(`resolve needs an id and a status (${STATUSES.join(', ')})`)
271 continue
272 }
273 resolve.push({ id, status, note: clean(raw?.note, 300) })
274 }
275 if (add.length === 0 && resolve.length === 0 && problems.length === 0) problems.push('nothing to record: pass `add` or `resolve`')
276 return { add, resolve, problems }
277}
278
279// ---------------------------------------------------------------- reducers
280
281export type Stamp = { at: number; turn: number; who: string; source: Source }
282
283/** The next id of a kind. Ids already in `taken` are skipped, so two writers holding the same counter never mint one id twice. */
284export function nextId(meta: Meta, kind: Kind, taken: readonly Entry[] = []): { id: string; meta: Meta } {
285 const used = new Set(taken.map(e => e.id))
286 let n = (meta.seq[kind] ?? 0) + 1
287 while (used.has(`${PREFIX[kind]}${n}`)) n += 1
288 return { id: `${PREFIX[kind]}${n}`, meta: { ...meta, seq: { ...meta.seq, [kind]: n } } }
289}
290
291/** Status a resolve asks for, fitted to the kind: `done` on an assumption reads as confirmed, and so on. */
292export function fitStatus(kind: Kind, status: Status): Status | null {
293 if (ALLOWED[kind].includes(status)) return status
294 const alias: Partial<Record<Status, Status>> = {
295 done: kind === 'assumption' ? 'confirmed' : kind === 'question' ? 'answered' : undefined,
296 confirmed: kind === 'step' || kind === 'goal' ? 'done' : kind === 'question' ? 'answered' : undefined,
297 answered: kind === 'assumption' ? 'confirmed' : kind === 'step' ? 'done' : undefined,
298 open: kind === 'goal' ? 'active' : undefined,
299 active: kind === 'assumption' || kind === 'question' ? 'open' : undefined,
300 invalidated: kind !== 'assumption' ? 'dropped' : undefined,
301 }
302 return alias[status] ?? null
303}
304
305export type Applied = {
306 meta: Meta
307 entries: Entry[]
308 added: Entry[]
309 changed: { entry: Entry; from: Status }[]
310 problems: string[]
311}
312
313export function applyCall(meta: Meta, entries: Entry[], call: ParsedCall, stamp: Stamp): Applied {
314 let m = meta
315 let list = [...entries]
316 const added: Entry[] = []
317 const changed: { entry: Entry; from: Status }[] = []
318 const problems = [...call.problems]
319
320 // `of: "@N"` names the Nth entry of this call's `add` list, so a tree can be built in one call.
321 const byPos = new Map<number, string>()
322 for (const item of call.add) {
323 const target = (): string | undefined => {
324 const pos = item.of ? /^@(\d+)$/.exec(item.of) : null
325 return pos ? byPos.get(Number(pos[1])) : list.find(e => e.id === item.of)?.id
326 }
327 // The same text logged again while the first is still unsettled, by the same writer, is one entry;
328 // a repeat of something already finished is new work. A repeat may still say what it rests on.
329 const dup = list.find(e => e.kind === item.kind && e.text === item.text && e.who === stamp.who && !isSettled(e.status))
330 if (dup) {
331 byPos.set(item.pos, dup.id)
332 const to = item.of ? target() : undefined
333 if (to && !dup.of && to !== dup.id) list = list.map(e => (e.id === dup.id ? { ...e, of: to } : e))
334 continue
335 }
336 const next = nextId(m, item.kind, list)
337 m = next.meta
338 const entry: Entry = {
339 id: next.id,
340 kind: item.kind,
341 text: item.text,
342 why: item.why,
343 confidence: item.kind === 'assumption' ? item.confidence : null,
344 status: startOf(item.kind),
345 source: stamp.source,
346 who: stamp.who,
347 turn: stamp.turn,
348 at: stamp.at,
349 updatedAt: stamp.at,
350 note: '',
351 resolvedBy: null,
352 ref: null,
353 of: null,
354 closedTurn: null,
355 }
356 if (item.of) {
357 const to = target()
358 if (to && to !== entry.id) entry.of = to
359 else problems.push(`no entry ${item.of} to link ${next.id} to`)
360 }
361 byPos.set(item.pos, entry.id)
362 list.push(entry)
363 added.push(entry)
364 }
365
366 for (const r of call.resolve) {
367 const i = list.findIndex(e => e.id === r.id)
368 if (i < 0) {
369 problems.push(`no entry ${r.id}`)
370 continue
371 }
372 const old = list[i]
373 if (!old) continue
374 const status = fitStatus(old.kind, r.status)
375 if (!status) {
376 problems.push(`${r.id} is ${/^[aeiou]/.test(old.kind) ? 'an' : 'a'} ${old.kind}; it takes ${ALLOWED[old.kind].join(', ')}`)
377 continue
378 }
379 if (status === old.status && !r.note) continue
380 const entry: Entry = { ...old, status, note: r.note || old.note, updatedAt: stamp.at, resolvedBy: stamp.source }
381 list[i] = entry
382 changed.push({ entry, from: old.status })
383 }
384
385 // Only one step can be active at a time per agent: activating one sets the others back to open.
386 for (const c of changed) {
387 if (c.entry.kind === 'step' && c.entry.status === 'active') {
388 list = list.map(e => (e.kind === 'step' && e.status === 'active' && e.id !== c.entry.id && e.who === c.entry.who && e.source === c.entry.source ? { ...e, status: 'open' as Status } : e))
389 }
390 }
391
392 return { meta: m, entries: trimEntries(list), added, changed, problems }
393}
394
395/** Hard ceiling: past this, even open entries go, oldest first. */
396export const HARD_MAX_ENTRIES = MAX_ENTRIES + 100
397
398/**
399 * Keep the list bounded. Settled entries go first, oldest first, those nothing links to before those
400 * something does (their children are re-parented, not orphaned); the current goal is never dropped.
401 * Open entries only go past the hard ceiling, so the list cannot grow without bound.
402 */
403export function trimEntries(list: Entry[]): Entry[] {
404 if (list.length <= MAX_ENTRIES) return list
405 let out = list
406 const goal = currentGoal(list)
407 const drop = (victim: Entry) => {
408 out = out.filter(e => e.id !== victim.id).map(e => (e.of === victim.id ? { ...e, of: victim.of } : e))
409 }
410 const linked = () => new Set(out.map(e => e.of).filter((x): x is string => x !== null))
411 for (const pass of ['leaf', 'any'] as const) {
412 for (const e of [...out]) {
413 if (out.length <= MAX_ENTRIES) return out
414 if (!isSettled(e.status) || e.id === goal?.id) continue
415 if (pass === 'leaf' && linked().has(e.id)) continue
416 drop(e)
417 }
418 }
419 for (const e of [...out]) {
420 if (out.length <= HARD_MAX_ENTRIES) break
421 if (e.id !== goal?.id) drop(e)
422 }
423 return out
424}
425
426/** The user's mark from the pane. */
427export function markByYou(entries: Entry[], id: string, status: Status, at: number): { entries: Entry[]; entry: Entry | null } {
428 let hit: Entry | null = null
429 const list = entries.map(e => {
430 if (e.id !== id) return e
431 const s = fitStatus(e.kind, status)
432 if (!s) return e
433 hit = { ...e, status: s, updatedAt: at, resolvedBy: 'you' as Source, note: e.note }
434 return hit
435 })
436 return { entries: list, entry: hit }
437}
438
439// ---------------------------------------------------------------- observed plans
440
441export type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed' }
442
443const TODO_STATUS: Record<Todo['status'], Status> = { pending: 'open', in_progress: 'active', completed: 'done' }
444
445/** A TodoWrite replaces the whole list: mirror it as observed steps, dropping todos it no longer holds. */
446export function syncTodos(meta: Meta, entries: Entry[], todos: Todo[], stamp: Stamp): { meta: Meta; entries: Entry[]; summary: string | null } {
447 let m = meta
448 const seen = new Set<string>()
449 let list = [...entries]
450 let moved = 0
451 for (const t of todos) {
452 const text = clean(t?.content)
453 if (!text) continue
454 const ref = `todo:${text}`
455 seen.add(ref)
456 const status = TODO_STATUS[t.status] ?? 'open'
457 const i = list.findIndex(e => e.ref === ref && e.who === stamp.who)
458 const cur = i >= 0 ? list[i] : undefined
459 if (cur) {
460 if (cur.status !== status) {
461 list[i] = { ...cur, status, updatedAt: stamp.at, resolvedBy: 'observed' }
462 moved += 1
463 }
464 continue
465 }
466 const next = nextId(m, 'step', list)
467 m = next.meta
468 list.push({ id: next.id, kind: 'step', text, why: '', confidence: null, status, source: 'observed', who: stamp.who, turn: stamp.turn, at: stamp.at, updatedAt: stamp.at, note: '', resolvedBy: null, ref, of: null, closedTurn: null })
469 moved += 1
470 }
471 list = list.map(e =>
472 e.ref?.startsWith('todo:') && !seen.has(e.ref) && e.status !== 'dropped' && e.status !== 'done' && e.who === stamp.who
473 ? ((moved += 1), { ...e, status: 'dropped' as Status, updatedAt: stamp.at, resolvedBy: 'observed' as Source })
474 : e,
475 )
476 if (moved === 0) return { meta: m, entries: list, summary: null }
477 const steps = list.filter(e => e.ref?.startsWith('todo:') && e.who === stamp.who && e.status !== 'dropped')
478 const done = steps.filter(e => e.status === 'done').length
479 return { meta: m, entries: list, summary: `todo list updated · ${done}/${steps.length} done` }
480}
481
482export function addTask(meta: Meta, entries: Entry[], taskId: string, subject: string, stamp: Stamp): { meta: Meta; entries: Entry[] } {
483 const ref = `task:${taskId}`
484 if (entries.some(e => e.ref === ref)) return { meta, entries }
485 const next = nextId(meta, 'step', entries)
486 const entry: Entry = { id: next.id, kind: 'step', text: clean(subject), why: '', confidence: null, status: 'open', source: 'observed', who: stamp.who, turn: stamp.turn, at: stamp.at, updatedAt: stamp.at, note: '', resolvedBy: null, ref, of: null, closedTurn: null }
487 return { meta: next.meta, entries: [...entries, entry] }
488}
489
490export function updateTask(entries: Entry[], taskId: string, patch: { status?: string; subject?: string }, at: number): Entry[] {
491 const ref = `task:${taskId}`
492 const status: Status | undefined =
493 patch.status === 'pending' ? 'open' : patch.status === 'in_progress' ? 'active' : patch.status === 'completed' ? 'done' : patch.status === 'deleted' ? 'dropped' : undefined
494 return entries.map(e =>
495 e.ref === ref ? { ...e, status: status ?? e.status, text: patch.subject ? clean(patch.subject) : e.text, updatedAt: at, resolvedBy: status ? ('observed' as Source) : e.resolvedBy } : e,
496 )
497}
498
499// ---------------------------------------------------------------- reading the ledger
500
501export type Counts = { open: number; confirmed: number; invalidated: number; stale: number; stepsDone: number; steps: number; questions: number; risks: number }
502
503// ---------------------------------------------------------------- small visuals
504
505/** `██░░░`: `done` of `total`, never full until it is, never empty once something is done. */
506export function progressBar(done: number, total: number, width = 8): string {
507 if (total <= 0) return ''
508 const raw = Math.round((Math.max(0, done) / total) * width)
509 const filled = done >= total ? width : Math.max(done > 0 ? 1 : 0, Math.min(width - 1, raw))
510 return '█'.repeat(filled) + '░'.repeat(width - filled)
511}
512
513const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
514
515/** One frame of the working spinner; `beat` is any counter. */
516export function spinnerFrame(beat: number): string {
517 return SPINNER[((Math.floor(beat) % SPINNER.length) + SPINNER.length) % SPINNER.length]!
518}
519
520/** How much happened per turn: entries logged plus entries settled in it. */
521export function activityByTurn(entries: Entry[], turns: number[]): number[] {
522 return turns.map(t => entries.filter(e => e.turn === t).length + entries.filter(e => e.closedTurn === t).length)
523}
524
525/** 0 young, 1 close to going stale, 2 stale (or `after` is 0 and nothing ever goes stale: 0). */
526export function ageLevel(age: number, after: number): 0 | 1 | 2 {
527 if (after <= 0) return 0
528 if (age >= after) return 2
529 return age >= Math.ceil((after * 2) / 3) ? 1 : 0
530}
531
532/** Touched since the current turn started: added or changed since your last prompt. */
533export function isFresh(e: Entry, turn: Turn): boolean {
534 return turn.n > 0 && turn.startedAt > 0 && e.updatedAt >= turn.startedAt
535}
536
537export type HealthPart = { text: string; tone: 'warning' | 'error' | 'success' | 'plain' }
538
539/** The one-line summary at the top of the pane; only what is non-zero. */
540export function healthParts(c: Counts): HealthPart[] {
541 const parts: HealthPart[] = []
542 if (c.open > 0) parts.push({ text: `${c.open} open ${c.open === 1 ? 'assumption' : 'assumptions'}`, tone: 'warning' })
543 if (c.stale > 0) parts.push({ text: `⚠ ${c.stale} stale`, tone: 'warning' })
544 if (c.invalidated > 0) parts.push({ text: `✗ ${c.invalidated}`, tone: 'error' })
545 if (c.confirmed > 0) parts.push({ text: `✓ ${c.confirmed}`, tone: 'success' })
546 if (c.steps > 0) parts.push({ text: `${progressBar(c.stepsDone, c.steps)} ${c.stepsDone}/${c.steps}`, tone: c.stepsDone === c.steps ? 'success' : 'plain' })
547 if (c.questions > 0) parts.push({ text: `${c.questions} ${c.questions === 1 ? 'question' : 'questions'}`, tone: 'warning' })
548 if (c.risks > 0) parts.push({ text: `${c.risks} ${c.risks === 1 ? 'risk' : 'risks'}`, tone: 'error' })
549 return parts
550}
551
552export function isStale(e: Entry, turn: number, after: number): boolean {
553 return after > 0 && e.kind === 'assumption' && e.status === 'open' && turn - e.turn >= after
554}
555
556export function countsOf(entries: Entry[], turn: number, staleAfter: number): Counts {
557 const a = entries.filter(e => e.kind === 'assumption')
558 const steps = entries.filter(e => e.kind === 'step' && e.status !== 'dropped')
559 return {
560 open: a.filter(e => e.status === 'open').length,
561 confirmed: a.filter(e => e.status === 'confirmed').length,
562 invalidated: a.filter(e => e.status === 'invalidated').length,
563 stale: a.filter(e => isStale(e, turn, staleAfter)).length,
564 stepsDone: steps.filter(e => e.status === 'done').length,
565 steps: steps.length,
566 questions: entries.filter(e => e.kind === 'question' && e.status === 'open').length,
567 risks: entries.filter(e => e.kind === 'risk' && e.status === 'open').length,
568 }
569}
570
571export function currentGoal(entries: Entry[]): Entry | null {
572 const goals = entries.filter(e => e.kind === 'goal' && e.status === 'active')
573 return goals[goals.length - 1] ?? null
574}
575
576// ---------------------------------------------------------------- the structure graph
577
578/** Colours for the terminal text: Claude Code's theme keys, or the palette's hex when one is chosen. */
579export type TermColors = { accent: string | undefined; success: string; error: string; warning: string; suggestion: string; subtle: string; inactive: string; planMode: string }
580
581export function termColors(pal: Palette, brandAccent: boolean): TermColors {
582 if (!pal.themed) {
583 return { accent: brandAccent ? 'claude' : undefined, success: 'success', error: 'error', warning: 'warning', suggestion: 'suggestion', subtle: 'subtle', inactive: 'inactive', planMode: 'planMode' }
584 }
585 return { accent: pal.accent, success: pal.ok, error: pal.bad, warning: pal.warn, suggestion: pal.active, subtle: pal.dim, inactive: pal.off, planMode: pal.accent }
586}
587
588/**
589 * A node of the structure tree. `touched` lists the invalidated assumptions it sits under or above
590 * (so a wrong assumption's reach is visible), `onDamage` marks the path to one, `onPath` the path to
591 * an active step, and `hidden` how many finished descendants were folded into it.
592 */
593export type GraphNode = { entry: Entry; children: GraphNode[]; touched: string[]; onDamage: boolean; onPath: boolean; hidden: number }
594
595export const GRAPH_MAX_DEPTH = 4
596
597/** Above this many nodes, fully finished branches fold into one line. */
598export const GRAPH_COLLAPSE_AT = 12
599
600/** The most nodes drawn; past it, the least important leaves fold into their parent's `(+n)`. */
601export const GRAPH_MAX_NODES = 30
602
603/**
604 * The goal's tree: steps hang off the goal (or the step they name in `of`); assumptions, decisions,
605 * findings, questions and risks hang off whatever they name in `of`. Entries that name nothing
606 * appear only as steps under the goal; the rest stay in their own sections. Dropped entries and
607 * everything below them are left out. With no goal, top-level steps are the roots.
608 */
609export function buildGraph(entries: Entry[], collapse = true): GraphNode[] {
610 const goal = currentGoal(entries)
611 const live = entries.filter(e => e.status !== 'dropped' && e.kind !== 'goal')
612 const byId = new Map(live.map(e => [e.id, e]))
613 const parentOf = (e: Entry): string | null => {
614 if (e.of && (byId.has(e.of) || e.of === goal?.id)) return e.of
615 return e.kind === 'step' && goal ? goal.id : null
616 }
617 const kids = new Map<string | null, Entry[]>()
618 for (const e of live) {
619 const p = parentOf(e)
620 if (p === null && e.kind !== 'step') continue
621 kids.set(p, [...(kids.get(p) ?? []), e])
622 }
623 const seen = new Set<string>()
624 const grow = (e: Entry, depth: number): GraphNode => {
625 seen.add(e.id)
626 const children = depth >= GRAPH_MAX_DEPTH ? [] : (kids.get(e.id) ?? []).filter(k => !seen.has(k.id)).map(k => grow(k, depth + 1))
627 return { entry: e, children, touched: [], onDamage: false, onPath: false, hidden: 0 }
628 }
629 const roots = goal ? [grow(goal, 0)] : (kids.get(null) ?? []).map(e => grow(e, 0))
630
631 // Invalidated assumptions: everything under one rests on it, and everything above it holds it.
632 const decorate = (n: GraphNode, above: string[]): string[] => {
633 const here = n.entry.kind === 'assumption' && n.entry.status === 'invalidated' ? [n.entry.id] : []
634 const below: string[] = []
635 for (const c of n.children) below.push(...decorate(c, [...above, ...here]))
636 n.touched = n.entry.kind === 'goal' ? [] : [...new Set([...above, ...below])]
637 n.onDamage = here.length > 0 || below.length > 0
638 return [...here, ...below]
639 }
640 const markPath = (n: GraphNode): boolean => {
641 const hits = n.children.map(markPath)
642 n.onPath = (n.entry.kind === 'step' && n.entry.status === 'active') || hits.some(Boolean)
643 return n.onPath
644 }
645 roots.forEach(r => {
646 decorate(r, [])
647 markPath(r)
648 })
649
650 const count = (n: GraphNode): number => 1 + n.children.reduce((a, c) => a + count(c), 0)
651 const settledBelow = (n: GraphNode): boolean => n.children.every(c => isSettled(c.entry.status) && c.entry.status !== 'invalidated' && settledBelow(c))
652 const fold = (n: GraphNode) => {
653 if (n.entry.kind === 'step' && n.entry.status === 'done' && !n.onPath && !n.onDamage && n.touched.length === 0 && n.children.length > 0 && settledBelow(n)) {
654 n.hidden = count(n) - 1
655 n.children = []
656 return
657 }
658 n.children.forEach(fold)
659 }
660 if (collapse && roots.reduce((a, r) => a + count(r), 0) > GRAPH_COLLAPSE_AT) roots.forEach(fold)
661 if (collapse) limitTree(roots, GRAPH_MAX_NODES)
662 return roots
663}
664
665/**
666 * Trim a tree to `max` nodes by folding leaves into their parent's `hidden` count, last first, leaving
667 * the path to the active step and the path to an invalidated assumption for last.
668 */
669export function limitTree(roots: GraphNode[], max: number): void {
670 const flat = (): { node: GraphNode; parent: GraphNode | null }[] => {
671 const out: { node: GraphNode; parent: GraphNode | null }[] = []
672 const walk = (n: GraphNode, parent: GraphNode | null) => {
673 out.push({ node: n, parent })
674 n.children.forEach(c => walk(c, n))
675 }
676 roots.forEach(r => walk(r, null))
677 return out
678 }
679 for (let all = flat(); all.length > max; all = flat()) {
680 const leaves = all.filter(x => x.parent !== null && x.node.children.length === 0 && !x.node.onPath)
681 const pick = [...leaves].reverse().find(x => !x.node.onDamage) ?? leaves[leaves.length - 1]
682 if (!pick || !pick.parent) return
683 pick.parent.children = pick.parent.children.filter(c => c !== pick.node)
684 pick.parent.hidden += 1 + pick.node.hidden
685 }
686}
687
688/** Live entries with no route up to the goal: the agent did not link them to anything. Depth caps and folds do not count. */
689export function unlinkedCount(entries: Entry[]): number {
690 const goal = currentGoal(entries)
691 const live = entries.filter(e => e.kind !== 'goal' && e.status !== 'dropped')
692 const byId = new Map(live.map(e => [e.id, e]))
693 const linked = (e: Entry, seen: Set<string>): boolean => {
694 if (seen.has(e.id)) return false
695 seen.add(e.id)
696 if (e.of && e.of === goal?.id) return true
697 const parent = e.of ? byId.get(e.of) : undefined
698 return parent ? linked(parent, seen) : e.kind === 'step'
699 }
700 return live.filter(e => !linked(e, new Set())).length
701}
702
703/** `⚠A1,A2` for a node touched by invalidated assumptions (two shown, then `+`), or ''. */
704export function touchedTag(ids: string[]): string {
705 return ids.length === 0 ? '' : `⚠${ids.slice(0, 2).join(',')}${ids.length > 2 ? '+' : ''}`
706}
707
708/** The marker glyph in the SVG: like the text one, but an open step is a ring and a finding an `i`, which read at that size. */
709export function svgGlyph(e: Entry): string {
710 if (e.kind === 'step' && e.status === 'open') return '○'
711 if (e.kind === 'finding') return 'i'
712 return graphGlyph(e)
713}
714
715export type GraphLine = { prefix: string; glyph: string; entry: Entry; tag: string; onPath: boolean; hidden: number }
716
717const GLYPH_BY_KIND: Record<Kind, string> = { goal: '◎', assumption: '?', step: '·', decision: '◇', question: '?', risk: '!', finding: '•' }
718
719export function graphGlyph(e: Entry): string {
720 if (e.kind === 'step') return e.status === 'done' ? '✓' : e.status === 'active' ? '▸' : '·'
721 if (e.kind === 'assumption') return e.status === 'confirmed' ? '✓' : e.status === 'invalidated' ? '✗' : '?'
722 if (e.kind === 'question' && e.status === 'answered') return '✓'
723 return GLYPH_BY_KIND[e.kind]
724}
725
726const COMPACT_KINDS: ReadonlySet<Kind> = new Set(['assumption', 'question', 'risk', 'decision', 'finding'])
727
728/**
729 * A copy of the tree for the text pane, which already lists these entries in their own sections: above
730 * `keep` nodes, leaves that are only an assumption, question, risk, decision or finding drop out, unless they
731 * are wrong, damaged, on the active path or fold something. `omitted` counts what was left out.
732 */
733export function compactTree(roots: GraphNode[], keep = 8): { roots: GraphNode[]; omitted: number } {
734 const count = (n: GraphNode): number => 1 + n.children.reduce((sum, c) => sum + count(c), 0)
735 if (roots.reduce((sum, r) => sum + count(r), 0) <= keep) return { roots, omitted: 0 }
736 let omitted = 0
737 const prune = (n: GraphNode): GraphNode => {
738 const children: GraphNode[] = []
739 for (const c of n.children) {
740 const leaf = c.children.length === 0
741 if (leaf && COMPACT_KINDS.has(c.entry.kind) && c.entry.status !== 'invalidated' && c.touched.length === 0 && !c.onDamage && !c.onPath && c.hidden === 0) omitted++
742 else children.push(prune(c))
743 }
744 return { ...n, children }
745 }
746 return { roots: roots.map(prune), omitted }
747}
748
749/** The tree flattened to lines, each with its box-drawing prefix, capped at `max` lines. */
750export function graphLines(roots: GraphNode[], max = GRAPH_MAX_NODES): GraphLine[] {
751 const out: GraphLine[] = []
752 const walk = (n: GraphNode, lead: string, isLast: boolean, isRoot: boolean) => {
753 out.push({ prefix: isRoot ? '' : lead + (isLast ? '└─ ' : '├─ '), glyph: graphGlyph(n.entry), entry: n.entry, tag: touchedTag(n.touched), onPath: n.onPath, hidden: n.hidden })
754 const next = isRoot ? '' : lead + (isLast ? ' ' : '│ ')
755 n.children.forEach((c, i) => walk(c, next, i === n.children.length - 1, false))
756 }
757 roots.forEach(r => walk(r, '', true, true))
758 return out.slice(0, max)
759}
760
761// ---------------------------------------------------------------- execution over time
762
763const SETTLED: ReadonlySet<Status> = new Set(['done', 'confirmed', 'invalidated', 'answered', 'dropped'])
764
765export function isSettled(status: Status): boolean {
766 return SETTLED.has(status)
767}
768
769/** Stamp the turn an entry settled in, and clear it when one is reopened. `next` is `prev` after a reducer ran. */
770export function withClosedTurns(prev: Entry[], next: Entry[], turn: number): Entry[] {
771 const before = new Map(prev.map(e => [e.id, e]))
772 return next.map(e => {
773 const was = before.get(e.id)
774 if (!isSettled(e.status)) return e.closedTurn === null ? e : { ...e, closedTurn: null }
775 if (was && was.status === e.status) return e
776 return { ...e, closedTurn: turn }
777 })
778}
779
780export type HistoryRow = { entry: Entry; cells: string[] }
781export type History = { turns: number[]; rows: HistoryRow[] }
782
783export const HISTORY_CELL = 3
784
785/** The glyph an entry ends on: settled well, wrong, or dropped. */
786export function endGlyph(status: Status): string {
787 return status === 'invalidated' ? '✗' : status === 'dropped' ? '×' : '✓'
788}
789
790/**
791 * A grid of entries (rows) against turns (columns): `○` the turn it was logged, `───` while it stayed
792 * open, `✓` `✗` `×` the turn it settled. The last `maxTurns` turns and the last `maxRows` entries show.
793 */
794export function buildHistory(entries: Entry[], currentTurn: number, maxTurns = 12, maxRows = 14): History {
795 const rows = entries.filter(e => e.kind !== 'goal')
796 const last = Math.max(currentTurn, ...rows.map(e => e.turn), 1)
797 const first = Math.max(1, last - Math.max(1, maxTurns) + 1)
798 const turns: number[] = []
799 for (let t = first; t <= last; t++) turns.push(t)
800 const pad = (s: string) => ` ${s} `.slice(0, HISTORY_CELL).padEnd(HISTORY_CELL)
801 const cell = (e: Entry, t: number): string => {
802 const born = Math.max(e.turn, 1)
803 const end = isSettled(e.status) ? Math.max(e.closedTurn ?? born, born) : null
804 if (t < born || (end !== null && t > end)) return ' '.repeat(HISTORY_CELL)
805 if (end !== null && t === end) return pad(endGlyph(e.status))
806 if (t === born) return pad('○')
807 return '─'.repeat(HISTORY_CELL)
808 }
809 const shown = rows.filter(e => (isSettled(e.status) ? Math.max(e.closedTurn ?? e.turn, e.turn) : last) >= first).slice(-maxRows)
810 return { turns, rows: shown.map(entry => ({ entry, cells: turns.map(t => cell(entry, t)) })) }
811}
812
813/** `text` cut or padded to exactly `width` cells. */
814export function fitText(text: string, width: number): string {
815 if (width <= 0) return ''
816 const cut = clip(text, width)
817 return cut.padEnd(width + (cut.length - Array.from(cut).length))
818}
819
820// ---------------------------------------------------------------- SVG for the remote surfaces
821
822/** Escape text for SVG/XML. */
823export function esc(text: string): string {
824 return stripUnsafe(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
825}
826
827// Each SVG carries its own dark card, so contrast never depends on how a surface themes or scales it.
828const svgStyle = (p: Palette) =>
829 `<style>.t{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;fill:${p.text}}.d{font:11px ui-monospace,SFMono-Regular,Menlo,monospace;fill:${p.dim}}.e{stroke:${p.edge};fill:none;stroke-width:1.2}.eb{stroke:${p.bad};fill:none;stroke-width:1.8}</style>`
830
831const svgCard = (width: number, height: number, p: Palette) => `<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="8" fill="${p.bg}" stroke="${p.border}"/>`
832
833// Marker glyphs sit on the status colours in the palette's own background colour, so a status never rests on colour alone.
834
835function statusColor(status: Status, p: Palette): string {
836 if (status === 'done' || status === 'confirmed' || status === 'answered') return p.ok
837 if (status === 'invalidated') return p.bad
838 if (status === 'active') return p.active
839 if (status === 'dropped') return p.off
840 return p.warn
841}
842
843export type GraphItem = { entry: Entry; depth: number; row: number; parentRow: number | null; node: GraphNode }
844
845/** The tree flattened to rows with their depth and their parent's row, capped at `max` rows. */
846export function graphLayout(roots: GraphNode[], max = GRAPH_MAX_NODES): GraphItem[] {
847 const out: GraphItem[] = []
848 const walk = (n: GraphNode, depth: number, parentRow: number | null) => {
849 if (out.length >= max) return
850 const row = out.length
851 out.push({ entry: n.entry, depth, row, parentRow, node: n })
852 n.children.forEach(c => walk(c, depth + 1, row))
853 }
854 roots.forEach(r => walk(r, 0, null))
855 return out
856}
857
858/** Alt text for the structure SVG: each entry with its kind, parent and status. */
859export function graphAlt(roots: GraphNode[], unlinked = 0, fresh: ReadonlySet<string> = new Set()): string {
860 const items = graphLayout(roots, 400)
861 const rows = items.map(it => {
862 const parent = it.parentRow === null ? null : items[it.parentRow]?.entry.id
863 const hit = it.node.touched.length > 0 ? `, affected by invalidated ${it.node.touched.join(' and ')}` : ''
864 return `${it.entry.id}, ${it.entry.kind}${parent ? ` under ${parent}` : ''}, ${it.entry.status}${hit}${fresh.has(it.entry.id) ? ', changed this turn' : ''}: ${it.entry.text}`
865 })
866 return `Structure: ${rows.join('; ')}${unlinked > 0 ? `; ${unlinked} more entries are not linked into the tree` : ''}`
867}
868
869/** Alt text for the history SVG. */
870export function historyAlt(history: History): string {
871 const rows = history.rows.map(r => `${r.entry.id} ${r.entry.status} (logged T${r.entry.turn}${r.entry.closedTurn !== null ? `, settled T${r.entry.closedTurn}` : ', still open'})`)
872 return `History, turns ${history.turns[0]} to ${history.turns[history.turns.length - 1]}: ${rows.join('; ')}`
873}
874
875export const SVG_WIDTH = 480
876const ROW = 24
877const INDENT = 22
878
879/** The structure tree as an SVG: a dot per entry coloured by status, elbow edges to its parent. */
880export function graphSvg(roots: GraphNode[], width = SVG_WIDTH, pal: Palette = paletteOf(DEFAULT_PALETTE_ID), unlinked = 0, fresh: ReadonlySet<string> = new Set()): string {
881 const items = graphLayout(roots)
882 const height = items.length * ROW + 16 + (unlinked > 0 ? 20 : 0)
883 const x = (depth: number) => 14 + depth * INDENT
884 const y = (row: number) => 14 + row * ROW
885 const parts: string[] = []
886 for (const it of items) {
887 if (it.parentRow === null) continue
888 const parent = items[it.parentRow]
889 if (!parent) continue
890 parts.push(`<path class="${it.node.onDamage ? 'eb' : 'e'}" d="M${x(parent.depth)} ${y(parent.row) + 6}V${y(it.row)}H${x(it.depth) - 6}"/>`)
891 }
892 for (const it of items) {
893 const e = it.entry
894 const cx = x(it.depth)
895 const cy = y(it.row)
896 const tag = touchedTag(it.node.touched)
897 const extra = it.node.hidden > 0 ? ` (+${it.node.hidden})` : ''
898 const idEnd = cx + 14 + (e.id.length + 1) * 7
899 const labelX = tag ? idEnd + (tag.length + 1) * 7 : idEnd
900 const room = Math.max(6, Math.floor((width - labelX - 12) / 7) - extra.length)
901 const label = clip(e.text, room)
902 const dim = (e.status === 'done' || e.status === 'confirmed' || e.status === 'dropped') && !it.node.onPath
903 const fill = statusColor(e.status, pal)
904 // A ring marks the path from the goal to the step being worked on.
905 const ring = it.node.onPath ? ` stroke="${pal.text}" stroke-width="2"` : ''
906 const shape =
907 e.kind === 'goal'
908 ? `<rect x="${cx - 7}" y="${cy - 7}" width="14" height="14" rx="3" fill="${fill}"${ring}/>`
909 : e.kind === 'decision'
910 ? `<rect x="${cx - 5.5}" y="${cy - 5.5}" width="11" height="11" transform="rotate(45 ${cx} ${cy})" fill="${fill}"${ring}/>`
911 : `<circle cx="${cx}" cy="${cy}" r="7" fill="${fill}"${ring}/>`
912 const mark = e.kind === 'goal' || e.kind === 'decision' ? '' : `<text x="${cx}" y="${cy + 3.5}" text-anchor="middle" font-size="10" font-weight="700" fill="${markOn(fill, pal)}">${esc(svgGlyph(e))}</text>`
913 // A thin bar at the card's left edge marks what changed since your last prompt.
914 if (fresh.has(e.id)) parts.push(`<rect x="3" y="${cy - 7}" width="3" height="14" rx="1.5" fill="${pal.accent}"/>`)
915 parts.push(shape, mark, `<text class="d" x="${cx + 14}" y="${cy + 4}">${esc(e.id)}</text>`)
916 if (tag) parts.push(`<text class="d" x="${idEnd}" y="${cy + 4}" fill="${pal.bad}" style="fill:${pal.bad}">${esc(tag)}</text>`)
917 parts.push(`<text class="${dim ? 'd' : 't'}" x="${labelX}" y="${cy + 4}"${it.node.onPath ? ' font-weight="700"' : ''}>${esc(label)}</text>`)
918 if (extra) parts.push(`<text class="d" x="${labelX + (label.length + 1) * 7}" y="${cy + 4}">${esc(extra.trim())}</text>`)
919 }
920 if (unlinked > 0) parts.push(`<text class="d" x="14" y="${height - 10}">${unlinked} more ${unlinked === 1 ? 'entry is' : 'entries are'} not linked into the tree</text>`)
921 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${svgStyle(pal)}${svgCard(width, height, pal)}${parts.join('')}</svg>`
922}
923
924const CELL = 26
925const LABEL = 190
926
927/** The history grid as an SVG: a bar from the turn an entry was logged to the turn it settled. */
928export function historySvg(history: History, pal: Palette = paletteOf(DEFAULT_PALETTE_ID)): string {
929 const cols = history.turns.length
930 const width = Math.max(LABEL + cols * CELL + 12, 310)
931 const rowsTop = 30
932 const height = rowsTop + history.rows.length * 22 + 30
933 const cx = (i: number) => LABEL + i * CELL + CELL / 2
934 const parts: string[] = []
935 // The current turn's column is lightly shaded, so "still open" reads as "open until now".
936 parts.push(`<rect x="${LABEL + (cols - 1) * CELL}" y="2" width="${CELL}" height="${height - 26}" rx="4" fill="${pal.surface}"/>`)
937 history.turns.forEach((t, i) => parts.push(`<text class="${i === cols - 1 ? 't' : 'd'}" x="${cx(i)}" y="16" text-anchor="middle">${t % 100}</text>`))
938 history.rows.forEach((r, ri) => {
939 const cy = rowsTop + ri * 22 + 8
940 const e = r.entry
941 const settled = isSettled(e.status)
942 const first = r.cells.findIndex(c => c.trim() !== '')
943 let last = -1
944 r.cells.forEach((c, i) => {
945 if (c.trim() !== '') last = i
946 })
947 const room = Math.floor((LABEL - 12) / 7) - e.id.length - 1
948 const text = clip(e.text, room)
949 parts.push(`<text class="t" x="4" y="${cy + 4}">${esc(e.id)}</text><text class="${settled ? 'd' : 't'}" x="${4 + (e.id.length + 1) * 7}" y="${cy + 4}">${esc(text)}</text>`)
950 if (first < 0) return
951 if (last > first) parts.push(`<line class="e" x1="${cx(first)}" y1="${cy}" x2="${cx(last)}" y2="${cy}"/>`)
952 if (!settled || last > first) parts.push(`<circle cx="${cx(first)}" cy="${cy}" r="4" fill="${statusColor('open', pal)}"/>`)
953 if (settled) parts.push(`<circle cx="${cx(last)}" cy="${cy}" r="7" fill="${statusColor(e.status, pal)}"/><text x="${cx(last)}" y="${cy + 3.5}" text-anchor="middle" font-size="10" font-weight="700" fill="${markOn(statusColor(e.status, pal), pal)}">${esc(endGlyph(e.status))}</text>`)
954 })
955 parts.push(`<text class="d" x="4" y="${height - 10}"><tspan fill="${statusColor('open', pal)}">●</tspan> logged ── open ✓ done ✗ wrong × dropped</text>`)
956 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${svgStyle(pal)}${svgCard(width, height, pal)}${parts.join('')}</svg>`
957}
958
959export function activeStep(entries: Entry[]): Entry | null {
960 const s = entries.filter(e => e.kind === 'step' && e.status === 'active')
961 return s[s.length - 1] ?? null
962}
963
964const ASSUMPTION_ORDER: Record<string, number> = { open: 0, invalidated: 1, confirmed: 2, dropped: 3 }
965
966/** Open first (they need attention), then invalidated, confirmed, dropped; newest first within each. */
967export function sortedAssumptions(entries: Entry[]): Entry[] {
968 return entries
969 .filter(e => e.kind === 'assumption')
970 .sort((x, y) => (ASSUMPTION_ORDER[x.status] ?? 9) - (ASSUMPTION_ORDER[y.status] ?? 9) || y.at - x.at)
971}
972
973export function statusLine(entries: Entry[], turn: number, staleAfter: number): string | undefined {
974 if (entries.length === 0) return undefined
975 const c = countsOf(entries, turn, staleAfter)
976 const parts = [
977 c.open > 0 ? `${c.open} open assumption${c.open === 1 ? '' : 's'}` : null,
978 c.stale > 0 ? `${c.stale} stale` : null,
979 c.invalidated > 0 ? `${c.invalidated} ✗` : null,
980 c.steps > 0 ? `plan ${c.stepsDone}/${c.steps}` : null,
981 c.questions > 0 ? `${c.questions} ?` : null,
982 ].filter(Boolean)
983 return parts.length > 0 ? `ledger · ${parts.join(' · ')}` : undefined
984}
985
986/** What the ledger tool answers: what was recorded, and what is still open, so the agent can reconcile. */
987/** `since`: marks the user made before this time were already reported on an earlier call (a mark in the same millisecond as that call is reported once more). */
988export function toolReply(applied: Applied, entries: Entry[], since = 0): string {
989 const lines: string[] = []
990 if (applied.added.length > 0) lines.push(`Logged ${applied.added.map(e => e.id).join(', ')}.`)
991 if (applied.changed.length > 0) lines.push(`Updated ${applied.changed.map(c => `${c.entry.id} → ${c.entry.status}`).join(', ')}.`)
992 if (applied.problems.length > 0) lines.push(`Not recorded: ${applied.problems.join('; ')}.`)
993 const byYou = entries.filter(e => e.resolvedBy === 'you' && e.updatedAt >= since)
994 if (byYou.length > 0) lines.push(`Marked by the user in the pane: ${byYou.map(e => `${e.id} ${e.status} ("${shorten(e.text, 60)}")`).join('; ')}.`)
995 const open = entries.filter(e => e.kind === 'assumption' && e.status === 'open')
996 if (open.length > 0) lines.push(`Open assumptions: ${open.map(e => `${e.id} ${shorten(e.text, 60)}`).join('; ')}.`)
997 const steps = entries.filter(e => e.kind === 'step' && e.source === 'agent' && (e.status === 'open' || e.status === 'active'))
998 if (steps.length > 0) lines.push(`Steps left: ${steps.map(e => `${e.id}${e.status === 'active' ? '*' : ''}`).join(', ')}.`)
999 const qs = entries.filter(e => e.kind === 'question' && e.status === 'open')
1000 if (qs.length > 0) lines.push(`Open questions: ${qs.map(e => e.id).join(', ')}.`)
1001 return lines.join('\n') || 'Nothing changed.'
1002}
1003
1004export function moment(stamp: { at: number; turn: number; who: string }, text: string, tone: Tone): Moment {
1005 return { at: stamp.at, turn: stamp.turn, who: stamp.who, text: clean(text, 160), tone }
1006}
1007
1008export function addMoments(list: Moment[], add: Moment[]): Moment[] {
1009 return [...list, ...add].slice(-MAX_TIMELINE)
1010}
1011
1012export function momentsFor(applied: Applied, stamp: Stamp): Moment[] {
1013 const out: Moment[] = []
1014 for (const e of applied.added) out.push(moment(stamp, `+ ${e.id} ${e.text}`, 'add'))
1015 for (const c of applied.changed) {
1016 const note = c.entry.note ? ` — ${c.entry.note}` : ''
1017 const tone: Tone = c.entry.status === 'invalidated' ? 'warn' : 'resolve'
1018 out.push(moment(stamp, `${c.entry.id} ${c.from} → ${c.entry.status}${note}`, tone))
1019 }
1020 return out
1021}
1022
1023// ---------------------------------------------------------------- export
1024
1025export function toMarkdown(entries: Entry[], plan: { text: string } | null): string {
1026 const sec = (title: string, kind: Kind) => {
1027 const list = entries.filter(e => e.kind === kind)
1028 if (list.length === 0) return ''
1029 const rows = list.map(e => {
1030 const why = e.why ? ` — ${e.why}` : ''
1031 const note = e.note ? ` (${e.note})` : ''
1032 return `- **${e.id}** [${e.status}] ${e.text}${why}${note} _T${e.turn} · ${e.who}${e.source === 'observed' ? ' · observed' : ''}_`
1033 })
1034 return `## ${title}\n\n${rows.join('\n')}\n`
1035 }
1036 const parts = [
1037 '# Intent ledger\n',
1038 sec('Goals', 'goal'),
1039 sec('Assumptions', 'assumption'),
1040 sec('Plan', 'step'),
1041 sec('Decisions', 'decision'),
1042 sec('Questions', 'question'),
1043 sec('Risks', 'risk'),
1044 sec('Findings', 'finding'),
1045 plan ? `## Approved plan\n\n${plan.text}\n` : '',
1046 ]
1047 return parts.filter(Boolean).join('\n')
1048}
1049
1050// ---------------------------------------------------------------- the system prompt section
1051
1052export const INSTRUCTIONS = [
1053 '# Intent ledger',
1054 `The user watches a live ledger of your intent in a side pane. Keep it accurate with the \`${TOOL}\` tool.`,
1055 '- When you start a task that takes more than a couple of steps, log the goal as you understand it, the assumptions you are acting on (about the code, the environment, or what the user wants) and your planned steps, in one call.',
1056 '- Log an assumption whenever you are about to act on something you have not verified. Give a brief `why` and a `confidence`.',
1057 '- Log a decision when you choose between real alternatives, with the reason. Log open questions you are deferring or guessing on, and risks you notice.',
1058 '- Log a finding when you learn a fact that matters to the task and settles no assumption (a limit, a cause, a surprise), with where you saw it as `why`.',
1059 '- Resolve entries as you learn: confirm or invalidate assumptions (say what you found in `note`), mark steps active and done, answer questions. If the plan changes, drop the obsolete steps and add the new ones. When a goal is finished or abandoned, mark it done or dropped.',
1060 '- Steps hang off the goal in the pane\'s structure tree. To show how things relate, pass `of`: an id from an earlier result, or "@N" for the Nth entry of the same call ("@2" is the second), e.g. a sub-step of S2, or an assumption of the step it rests on.',
1061 '- If you keep a todo or task list, the ledger reads it; you need not repeat those steps.',
1062 '- Batch entries; one short line each. Skip the ledger for one-step requests and plain conversation.',
1063 '- The ledger is for transparency, not a substitute for asking: when a wrong guess would be costly, ask the user.',
1064 '- If the user marks an assumption in the pane, the next ledger result tells you once; adjust your plan accordingly.',
1065].join('\n')
1066hooks/palettes.ts 99 lines1/**
2 * Colour palettes for the pane. `default` keeps Claude Code's own theme keys in the terminal and the
3 * built-in dark card in the SVGs; the others are named editor themes drawn as raw hex.
4 * Pure data: no host imports.
5 */
6
7export type Palette = {
8 id: string
9 label: string
10 /** True when the terminal text uses the palette's hex colours; false keeps the host's theme keys. */
11 themed: boolean
12 /** The SVG card and its text. */
13 bg: string
14 border: string
15 text: string
16 dim: string
17 edge: string
18 /** The shaded current-turn column. */
19 surface: string
20 /** Status colours, shared by the terminal text and the SVGs. */
21 ok: string
22 bad: string
23 warn: string
24 active: string
25 off: string
26 accent: string
27}
28
29export const DEFAULT_PALETTE_ID = 'default'
30
31export const PALETTES: readonly Palette[] = [
32 { id: 'default', label: 'Claude Code theme', themed: false, bg: '#0d1117', border: '#30363d', text: '#e6edf3', dim: '#8b949e', edge: '#6e7681', surface: '#161b22', ok: '#2da44e', bad: '#e5534b', warn: '#bf8700', active: '#388bfd', off: '#8c959f', accent: '#d97757' },
33 { id: 'catppuccin-mocha', label: 'Catppuccin Mocha', themed: true, bg: '#1e1e2e', border: '#45475a', text: '#cdd6f4', dim: '#a6adc8', edge: '#6c7086', surface: '#313244', ok: '#a6e3a1', bad: '#f38ba8', warn: '#f9e2af', active: '#89b4fa', off: '#6c7086', accent: '#cba6f7' },
34 { id: 'catppuccin-latte', label: 'Catppuccin Latte', themed: true, bg: '#eff1f5', border: '#bcc0cc', text: '#4c4f69', dim: '#6c6f85', edge: '#9ca0b0', surface: '#ccd0da', ok: '#40a02b', bad: '#d20f39', warn: '#df8e1d', active: '#1e66f5', off: '#9ca0b0', accent: '#8839ef' },
35 { id: 'dracula', label: 'Dracula', themed: true, bg: '#282a36', border: '#44475a', text: '#f8f8f2', dim: '#6272a4', edge: '#6272a4', surface: '#44475a', ok: '#50fa7b', bad: '#ff5555', warn: '#f1fa8c', active: '#8be9fd', off: '#6272a4', accent: '#bd93f9' },
36 { id: 'gruvbox-dark', label: 'Gruvbox Dark', themed: true, bg: '#282828', border: '#504945', text: '#ebdbb2', dim: '#a89984', edge: '#7c6f64', surface: '#3c3836', ok: '#b8bb26', bad: '#fb4934', warn: '#fabd2f', active: '#83a598', off: '#928374', accent: '#fe8019' },
37 { id: 'tokyo-night', label: 'Tokyo Night', themed: true, bg: '#1a1b26', border: '#3b4261', text: '#c0caf5', dim: '#737aa2', edge: '#565f89', surface: '#292e42', ok: '#9ece6a', bad: '#f7768e', warn: '#e0af68', active: '#7aa2f7', off: '#565f89', accent: '#bb9af7' },
38 { id: 'nord', label: 'Nord', themed: true, bg: '#2e3440', border: '#434c5e', text: '#d8dee9', dim: '#9aa5b8', edge: '#616e88', surface: '#3b4252', ok: '#a3be8c', bad: '#bf616a', warn: '#ebcb8b', active: '#88c0d0', off: '#616e88', accent: '#b48ead' },
39 { id: 'solarized-dark', label: 'Solarized Dark', themed: true, bg: '#002b36', border: '#0b4a5a', text: '#93a1a1', dim: '#657b83', edge: '#586e75', surface: '#073642', ok: '#859900', bad: '#dc322f', warn: '#b58900', active: '#268bd2', off: '#586e75', accent: '#d33682' },
40 { id: 'rose-pine', label: 'Rosé Pine', themed: true, bg: '#191724', border: '#403d52', text: '#e0def4', dim: '#908caa', edge: '#6e6a86', surface: '#26233a', ok: '#9ccfd8', bad: '#eb6f92', warn: '#f6c177', active: '#c4a7e7', off: '#6e6a86', accent: '#ebbcba' },
41]
42
43export const PALETTE_IDS: readonly string[] = PALETTES.map(p => p.id)
44
45// ---------------------------------------------------------------- legibility
46
47const channel = (c: number) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
48
49/** WCAG relative luminance of `#rrggbb`. */
50export function luminance(hex: string): number {
51 const n = parseInt(hex.slice(1), 16)
52 return 0.2126 * channel(n >> 16) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255)
53}
54
55/** WCAG contrast ratio between two `#rrggbb` colours, 1 to 21. */
56export function contrast(a: string, b: string): number {
57 const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]
58 return (hi + 0.05) / (lo + 0.05)
59}
60
61const hex2 = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0')
62
63/** `a` moved `t` (0..1) of the way toward `b`. */
64export function mix(a: string, b: string, t: number): string {
65 const x = parseInt(a.slice(1), 16)
66 const y = parseInt(b.slice(1), 16)
67 const ch = (shift: number) => hex2(((x >> shift) & 255) * (1 - t) + ((y >> shift) & 255) * t)
68 return `#${ch(16)}${ch(8)}${ch(0)}`
69}
70
71/** `color`, nudged toward `toward` in 5% steps until it reads against `bg` at `min`; unchanged when it already does. */
72export function legible(color: string, bg: string, min: number, toward: string): string {
73 for (let t = 0; t <= 1.0001; t += 0.05) {
74 const c = t === 0 ? color : mix(color, toward, t)
75 if (contrast(c, bg) >= min) return c
76 }
77 return toward
78}
79
80/** The glyph colour for a marker drawn on `fill`: the palette's own background or text, or black or white, whichever reads best. */
81export function markOn(fill: string, p: Palette): string {
82 const options = [p.bg, p.text, '#000000', '#ffffff']
83 return options.reduce((best, c) => (contrast(c, fill) > contrast(best, fill) ? c : best))
84}
85
86/** A palette with every colour made legible on its own background (text 4.5:1, lines and status colours 3:1). */
87function legibleCopy(p: Palette): Palette {
88 const on = (c: string, min: number) => legible(c, p.bg, min, p.text)
89 return { ...p, dim: on(p.dim, 4.5), edge: on(p.edge, 3), ok: on(p.ok, 3), bad: on(p.bad, 3), warn: on(p.warn, 3), active: on(p.active, 3), off: on(p.off, 3), accent: on(p.accent, 3) }
90}
91
92const RESOLVED: readonly Palette[] = PALETTES.map(legibleCopy)
93
94/** The palette for an id (case and padding ignored); the default for anything unknown. */
95export function paletteOf(id: unknown): Palette {
96 const key = typeof id === 'string' ? id.trim().toLowerCase() : ''
97 return RESOLVED.find(p => p.id === key) ?? RESOLVED[0]!
98}
99types/index.d.ts 76 lines1/** What an entry records. */
2export type Kind = 'goal' | 'assumption' | 'step' | 'decision' | 'question' | 'risk' | 'finding'
3
4/**
5 * Where an entry stands. Assumptions: open, confirmed, invalidated, dropped.
6 * Steps: open, active, done, dropped. Questions: open, answered, dropped.
7 * Goals: active, done, dropped. Decisions, risks and findings: open, dropped.
8 */
9export type Status = 'open' | 'active' | 'done' | 'confirmed' | 'invalidated' | 'answered' | 'dropped'
10
11/** agent: logged through the ledger tool. observed: read off a todo list or task list. you: set from the pane. */
12export type Source = 'agent' | 'observed' | 'you'
13
14export type Confidence = 'low' | 'medium' | 'high'
15
16export type Entry = {
17 id: string
18 kind: Kind
19 text: string
20 why: string
21 confidence: Confidence | null
22 status: Status
23 source: Source
24 /** Who logged it: `main`, or a short subagent label. */
25 who: string
26 /** The main-loop turn it was logged in. */
27 turn: number
28 at: number
29 updatedAt: number
30 /** The latest resolution note, e.g. what invalidated an assumption. */
31 note: string
32 /** Who last changed its status: `agent`, `you` or `observed`. */
33 resolvedBy: Source | null
34 /** For observed steps: `todo:<content>` or `task:<id>`. */
35 ref: string | null
36 /** The id of the entry this one serves or rests on (a step's goal or parent step, an assumption's step), or null. */
37 of: string | null
38 /** The main-loop turn it reached a settled status (done, confirmed, invalidated, answered, dropped); null while open. */
39 closedTurn: number | null
40}
41
42export type Tone = 'info' | 'add' | 'resolve' | 'warn' | 'you'
43
44export type Moment = { at: number; turn: number; who: string; text: string; tone: Tone }
45
46export type Turn = { n: number; startedAt: number; logged: number; edits: string[]; isRunning: boolean }
47
48export type ApprovedPlan = { text: string; at: number; turn: number }
49
50export type Footprint = { files: string[]; unloggedTurns: number[] }
51
52export type View = { expanded: string | null }
53
54export type Meta = {
55 schemaVersion: number
56 seq: Record<Kind, number>
57 /** When the agent was last told about the user's pane marks, so each mark is reported once. */
58 reportedAt: number
59}
60
61declare module 'claude-code' {
62 interface PluginState {
63 'intent-ledger': {
64 meta: Meta
65 entries: Entry[]
66 timeline: Moment[]
67 turn: Turn
68 plan: ApprovedPlan | null
69 footprint: Footprint
70 view: View
71 /** Spinner frame counter; moves only while a turn runs and `motion` is on. */
72 beat: number
73 }
74 }
75}
76