SLOPSHOPPER

intent-ledger

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

newpaneguardcommandtoaststatus
★ 1v0.5.1MITupdated 2026-10-12mattbv/intent-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · intent-ledger
│ ┃ Intent ✕ › fix the failing auth test and add an audit log call │ ┃ GOAL │ ┃ No goal logged yet. The agent logs one when ⏺ Read(src/auth.ts) │ ┃ it starts a multi-step task. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ASSUMPTIONS ⎿ Added 2 lines, removed 1 line │ ┃ None logged yet. The agent adds them as it ⏺ Bash(bun test) │ ┃ works. ⎿ 3 pass, 1 fail │ ┃ │ ┃ TIMELINE ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ T1 main changed 3 files with nothing logged │ ┃ (auth.ts, audit.ts, cache.ts) ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ 3 files touched · unlogged changes in T1 › /ledger │ ⎿ intent-ledger: Intent Ledger opened. Focus it with ctrl+x tab; p │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Intent
GOAL No goal logged yet. The agent logs one when it starts a multi-step task. ASSUMPTIONS None logged yet. The agent adds them as it works. TIMELINE T1 main changed 3 files with nothing logged (auth.ts, audit.ts, cache.ts) 3 files touched · unlogged changes in T1
README

<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">

Install

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

Why

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.

What you see

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
SectionShows
Goal, NOW and the health stripthe 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)
Assumptionsstatus (? 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 & riskswhat the agent is deferring or guessing on (? question, ! risk), the latest 5
Planthe 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, Findingschoices between alternatives with the reason; facts learned that settle no assumption. The latest 4 of each
Structurethe 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
Historyentries against turns: ○ logged, ─── still open, ✓ ✗ × settled. Shown once there are two turns and two entries; up to 12 turns and 14 rows
Timelineyour 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):

Steering from the pane

Focus the pane (ctrl+x tab) and press an assumption to expand it: its reasoning, the note that resolved it, and three buttons.

  • ✓ confirm marks it confirmed by you.
  • ✗ wrong (drafts a reply) marks it invalidated and drafts a reply in your prompt box (Assumption A2 ("…") is wrong: ). You finish and send it; nothing is sent for you.
  • reopen sets it back to open.

The agent hears about each of your marks once, in its next ledger result, so it can reconcile its plan.

How it works

The ledger is built only from what the agent produces openly:

  1. Its ledger tool (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.
  2. Visible plans: TodoWrite, TaskCreate / TaskUpdate, and the plan returned by ExitPlanMode.
  3. Edits (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:

  • Each kind has its own statuses. Goals: 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.
  • The same text logged again while the first is unsettled, by the same writer, is one entry. A repeat of finished work is new work.
  • Entries logged by a subagent carry a sub·xxxxx label.
  • The ledger keeps up to 300 entries (settled ones are dropped first, and never the current goal), each line up to 200 characters.
  • When the agent invalidates an assumption, you get a toast.

Privacy and reach

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.

Commands

CommandDoes
/ledger or /ledger openopen the pane (any unrecognised argument does this too)
/ledger closeclose it
/ledger resetclear the ledger
/ledger statusprint the loaded version and the settings that shape the pane (palette, brandAccent, instructAgent, ...)
/ledger copycopy 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

Configure

In /config, or under pluginConfigs["intent-ledger"].options in settings.json:

OptionDefaultMeaning
instructAgenttrueadd the system-prompt section asking the agent to keep the ledger (also needed for the "nothing logged" warning)
autoAllowtruethe ledger tool skips the permission prompt
observePlanstruefold todo lists, tasks and approved plans into the plan
staleAfterTurns3flag an open assumption stale after this many turns (0 disables)
openOnStarttrueopen the pane when a session starts (the host decides whether the pane fits)
palettedefaultdefault 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
brandAccentfalsewith the default palette, colour the pane headers Claude orange instead of your terminal's text colour (named palettes use their own accent)
statusLinetrueshow the summary in the status line
motiontrueshow a small spinner on the active step while the agent works (terminal only; it stops when the turn ends)

Troubleshooting

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.

Limits

  • It shows declared intent, not the model's actual reasoning.
  • Nothing is saved after the session ends; use /ledger copy to keep a Markdown copy.
  • I have run it in the terminal. The desktop, VS Code and mobile layouts are covered by mock-engine tests and renders of the SVGs, not by use.

Develop

claude --plugin-dir .      # edits hot-reload
claude plugin validate .
claude plugin test .
FileHolds
hooks/register.tsxthe hooks: tool registration, system-prompt section, plan observation, turn bookkeeping, the pane
hooks/core.tspure reducers, input validation, the structure and history builders, SVG, summaries and the Markdown export
hooks/palettes.tsthe colour palettes
types/index.d.tsthe state contract
tests/ledger.test.tspure behaviour, plus the tool and pane exercised through the engine on terminal and desktop

See CONTRIBUTING.md, the code of conduct and the changelog.

License

MIT © Matheus Boni Vicari

Source 4 files
hooks/register.tsx 650 lines
1import { 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}
650
hooks/core.ts 1066 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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')
1066
hooks/palettes.ts 99 lines
1/**
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}
99
types/index.d.ts 76 lines
1/** 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