SLOPSHOPPER

ledger

The record of orchestrated work: runs of units and tasks, the subagents spawned for them, each gate's verdict and the findings reviewers report, summed up in…

newpaneguardcommandtoaststatus
v0.2.0MITupdated 2026-10-06BuddyLim/claude-code-mod-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ledger
│ ┃ ledger ✕ › fix the failing auth test and add an audit log call │ ┃ Ledger 0 open · 0 done │ ┃ ──────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ Nothing planned yet. ⏺ Update(src/auth.ts) │ ┃ ──────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ──────────── ⏺ Bash(bun test) │ ┃ a: all ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ledger │ ⎿ ledger: Ledger: nothing is planned. The agent records a plan whe │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ledger
Ledger 0 open · 0 done ──────────────────────────────────────────────────── Nothing planned yet. ──────────────────────────────────────────────────── a: all
README

ledger

A Claude Code mod that keeps the record of an orchestrated run: its units and tasks, the subagents spawned for them, each gate's verdict, and the findings reviewers report.

It draws no band. While a run has something to say, the status line carries one entry, such as unit 1/2 Schema · 1/3 tasks · 1 gate failed · 2 findings (1 error); once every task is done with no agent running and no finding open, the entry is empty. /ledger opens a pane with the whole run.

Install

This mod uses Claude Code's function-hooks plugin API.

Clone it into your personal skills folder, where Claude Code loads it in every session:

git clone https://github.com/BuddyLim/claude-code-mod-ledger ~/.claude/skills/ledger

Or clone it anywhere and load it for one session:

claude --plugin-dir /path/to/claude-code-mod-ledger

It works alone. With the parked mod, a failed gate and each unit's open findings are parked for you; with the lens mod, findings show on their lines and open there.

What it records

  • The plan. The agent calls plan once with the goal and every task: an id, its unit, a title, the tier it is meant for and what its gate checks.
  • What each part is for, and what was done. The plan can say in a line what each unit adds up to and what each task is for; a task marked done can carry its outcome: what changed and where. These are the agent's own account, where gate verdicts and findings are the checked part.
  • Spawns, by themselves. Each subagent the session starts is recorded with its type, model, start, end and tokens. One whose description begins with a task id in brackets ([T3] add the retry) is recorded against that task, which is marked running.
  • Gate verdicts. The agent calls task with done, or gate-failed and a note.
  • Findings. Any agent, a reviewing subagent included, calls finding with a path, a line, a severity and a one-line summary. fixed closes them.

Commands

/ledger          open the pane
/ledger print    print the run in the transcript
/ledger hide     empty the status entry (show brings it back)
/ledger clear    forget the run

Runs

Each plan is a run, and a project keeps its runs: planning again adds one. Nothing picks which run is written to: a task update goes to the run that has the task, a spawn to the run whose task its description names, and a finding to the run of the task it names or of the subagent that reported it. So a task id may not be reused while a run that has it is under way. The status line covers every run under way.

A plan may split a run into lanes: parallel tracks of one feature that can be worked on at once. Each lane then has its own line, with its tasks done of all, a mark for a failed gate, and the turning mark while one of its subagents runs; the status line counts each lane.

A run is done by itself once every task is done with no agent running and no finding open, and opens again if work comes back to it. The last 40 runs are kept; past that the oldest done ones are dropped.

Following a plan document

A run can be read from a plan document instead of being recorded by the agent, so the plan costs no tokens to enter and the document stays the word on it.

  • Tasks and steps. Each ### Task N: name heading is task TN; the checkboxes under it are its steps; a ## section is a unit. A task with every box ticked is done, with some ticked it is under way.
  • In step. The document is read again each time it is written or edited, and when a session starts.
  • Subagents. One described as the document names a task ("Task 3: …") is recorded against it.
  • Ticking. When a task is marked done, the open boxes under it are ticked. Only [ ] becomes [x], only under a done task.
  • Another layout. A document with no such headings is grouped by a small model, from its headings and checkboxes alone and once per document; the words and ticks are still read from the document itself.

Two settings in /config, the first off by default:

SettingDoes
Follow superpowers plansa plan saved under docs/superpowers/plans starts a run by itself
Tick finished tasks in the planmarking a task done ticks its missed boxes
/ledger follow docs/roadmap.md   read this document as a run, wherever it is
/ledger tick                     tick the missed boxes of done tasks now
/ledger tick --dry               say how many it would tick

With no plan document, nothing here applies and the agent records the plan.

The pane

/ledger opens it; nothing opens it unasked. It opens on the current run while that is under way, and on the list of runs otherwise.

The list has a row per run: its goal, tasks done of all, failed gates, open findings, tokens and age. A dot marks the run planned last.

KeyDoes
1–9, Enteropen that run
dclose the focused run by hand, or reopen one closed that way
xclear the focused run for good, after asking
alist done runs too

A run is a line per task: a mark for its state, a failed gate's note beside it, and a running subagent's model and time beside its task, with a mark that turns while it runs. Open findings follow, a line each.

KeyDoes
edetails: what each unit and task is for, what was done, tier, agents and tokens, and fixed findings
1–9add that finding to the prompt, never sending it
bback to the list
Escclose

A timer runs only while a subagent does, to turn that mark.

For other mods

The run is readable state:

const { value: run } = await $.state.get({ plugin: 'ledger', key: 'run' } as const)

types/index.d.ts is the contract (LedgerRun, LedgerTask, LedgerSpawn, LedgerFinding). Only the ledger writes it. One run is kept per project, keyed by the working directory, across sessions.

Development

claude plugin validate .
claude plugin test .
  • hooks/register.tsx: the tools, the spawn and turn hooks, the command.
  • hooks/run.ts: the run's logic, free of the engine.
  • hooks/kit/: a copy of the shared mod-kit; edit the source and sync.
  • types/index.d.ts: the state contract.
Source 7 files
hooks/register.tsx 1379 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LedgerRun } from '../types'
5import { PLAN_PATH, outlineOf, outlineRequest, parsePlan, planFromOutline, runFromDoc, ticked } from './doc'
6import type { PlanDoc } from './doc'
7import { ICON, refIcon } from './kit/icons'
8import type { Book } from './book'
9import {
10  bookOf,
11  busyOf,
12  clash,
13  cleared,
14  closed,
15  currentOf,
16  isBook,
17  listed,
18  nextFinding,
19  ownerOf,
20  put,
21  runMarks,
22  runOfAgent,
23  bookStatus,
24  runOfFinding,
25  settled,
26  underWay,
27} from './book'
28import { PAD, ageOf, cut } from './kit/layout'
29import {
30  SEVERITIES,
31  STATUSES,
32  countsOf,
33  lanesOf,
34  ended,
35  findingText,
36  placeOf,
37  spawnMeta,
38  stepsText,
39  tokensText,
40  unitsOf,
41  fixed,
42  found,
43  orphaned,
44  plan,
45  report,
46  resumed,
47  setTask,
48  spawned,
49  taskOf,
50} from './run'
51import type { NewTask } from './run'
52
53const PLAN = 'mcp__ledger__plan'
54const TASK = 'mcp__ledger__task'
55const FINDING = 'mcp__ledger__finding'
56const FIXED = 'mcp__ledger__fixed'
57const REPORT = 'mcp__ledger__report'
58
59// The run other mods read; null while nothing is planned.
60const run = atom({ plugin: 'ledger', key: 'run' } as const, null)
61const isHidden = atom({ plugin: 'ledger', key: 'isHidden' } as const, false)
62// Whether the pane lists fixed findings and every finished agent too.
63const isAll = atom({ plugin: 'ledger', key: 'isAll' } as const, false)
64// Every run kept for the project, for the pane's list.
65const runs = atom({ plugin: 'ledger', key: 'runs' } as const, [])
66// The pane: the run it shows in full (0 for the list), the list's row the
67// focus is on, whether a run shows its details, and the run a clear is waiting
68// to be confirmed for (0 for none).
69const shown = atom({ plugin: 'ledger', key: 'shown' } as const, 0)
70const cursor = atom({ plugin: 'ledger', key: 'cursor' } as const, 0)
71const isDetailed = atom({ plugin: 'ledger', key: 'isDetailed' } as const, false)
72const confirming = atom({ plugin: 'ledger', key: 'confirming' } as const, 0)
73// The frame of the mark that turns beside a running subagent.
74const spin = atom({ plugin: 'ledger', key: 'spin' } as const, 0)
75// The finding the focus ring is on in a run's view, and the place last asked
76// to be shown: the lens mod, where loaded, opens it.
77const pointed = atom({ plugin: 'ledger', key: 'pointed' } as const, 0)
78const jump = atom({ plugin: 'ledger', key: 'jump' } as const, null)
79
80const PANE = 'ledger'
81const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
82const SPIN_MS = 150
83
84const TASK_ICONS = {
85  todo: ICON.todo,
86  running: ICON.running,
87  'gate-failed': ICON.failed,
88  done: ICON.done,
89} as const
90const TASK_TONES = { todo: 'gray', running: 'cyan', 'gate-failed': 'error', done: 'success' } as const
91const SEVERITY_TONES = { error: 'error', warning: 'warning', note: 'cyan' } as const
92
93// Adds text to the end of the person's draft, on its own line, and never
94// submits it: sending stays theirs.
95const toPrompt = async ($: EngineInterface, text: string) => {
96  const draft = await $.prompt.read().then(
97    box => box.text,
98    () => '',
99  )
100  const lead = draft === '' || draft.endsWith('\n') ? '' : '\n'
101  const filled = await $.prompt.fill({ text: `${lead}${text}\n`, mode: 'append' }).then(
102    answer => answer.isFilled,
103    () => false,
104  )
105
106  $.ui.toast(filled ? 'Ledger: added to the prompt.' : 'Ledger: the prompt could not take it.')
107}
108
109const GUIDE = `# Ledger
110
111The user follows delegated work in a ledger. Keep it true, in few words, with ${PLAN}, ${TASK}, ${FINDING}, ${FIXED} and ${REPORT}.
112
113- Before delegating a settled plan, call ${PLAN} once: the goal, each task (id, lane where the plan has parallel lanes, unit, title, a one-line "about", tier, gate check) and a one-line "about" per unit. It starts a new run and keeps earlier ones, so give its tasks ids no run under way uses.
114- Where a run is already read from a plan document (its report says "Plan:"), do not plan it again: its tasks are T1, T2, ... as the document numbers them.
115- Begin each subagent's description with its task id in brackets: "[T3] add the retry".
116- After gates, call ${TASK} once with every task that changed: "done" with a one-line outcome (what changed, in which files), or "gate-failed" with a one-line note.
117- Tell reviewers to record all their findings in one ${FINDING} call and to answer with the count only, not the findings again. Close findings with ${FIXED}.
118- Make ledger calls in the same step as your other tool calls, never as a step of their own.
119- For a status update, or when resuming, read ${REPORT} and answer from it.`
120
121const SUBAGENT_REFUSAL =
122  'Only the main agent changes the plan and its tasks. Report findings with the finding tool, and the rest in your final answer.'
123
124const keyOf = async ($: EngineInterface) => `runs:${await $.session.cwd()}`
125// Where the one run of a project was kept before runs were kept together.
126const oldKeyOf = async ($: EngineInterface) => `run:${await $.session.cwd()}`
127
128const loadBook = async ($: EngineInterface): Promise<Book> => {
129  const stored = await $.store.get(await keyOf($))
130
131  return isBook(stored) ? stored : bookOf(stored, await $.store.get(await oldKeyOf($)))
132}
133
134// The current run: the one the tools record against.
135const load = async ($: EngineInterface): Promise<LedgerRun | null> => currentOf(await loadBook($))
136
137// The mark that turns beside a running subagent: a timer moves its frame
138// while any subagent runs, and stops when none does.
139let spinTimer: { cancel: () => void } | undefined
140
141const spinWhile = ($: EngineInterface, isBusy: boolean) => {
142  if (isBusy && spinTimer === undefined) {
143    spinTimer = $.clock.every(SPIN_MS, () => {
144      void update($, spin, frame => (frame + 1) % SPINNER.length)
145    })
146  } else if (!isBusy && spinTimer !== undefined) {
147    spinTimer.cancel()
148    spinTimer = undefined
149  }
150}
151
152// Keeps the state other mods and the pane read, the status line's entry and
153// the spinner's timer in step with the book. The entry is the current run's,
154// and is empty while hidden, once that run is done, or with nothing to say.
155const show = async ($: EngineInterface, book: Book) => {
156  const now = currentOf(book)
157
158  await update($, run, () => now)
159  await update($, runs, () => book.runs)
160  $.ui.status((await read($, isHidden)) ? undefined : bookStatus(book))
161  spinWhile($, busyOf(book) > 0)
162}
163
164const refresh = async ($: EngineInterface) => show($, await loadBook($))
165
166// One change at a time: two hooks recording at once would otherwise both read
167// the book before either wrote it, and the second write would drop the first.
168let changes: Promise<unknown> = Promise.resolve()
169
170// Reads the stored book, applies the change and writes it back; answers the
171// reason when the change was refused or the store failed.
172const changeBookNow = async (
173  $: EngineInterface,
174  apply: (book: Book, now: number) => Book | string,
175): Promise<{ book: Book } | { error: string }> => {
176  try {
177    const after = apply(await loadBook($), await $.clock.now())
178
179    if (typeof after === 'string') {
180      return { error: after }
181    }
182
183    await $.store.set(await keyOf($), after)
184    await show($, after)
185
186    return { book: after }
187  } catch (error) {
188    return { error: `The ledger could not be saved: ${error instanceof Error ? error.message : String(error)}` }
189  }
190}
191
192const changeBook = (
193  $: EngineInterface,
194  apply: (book: Book, now: number) => Book | string,
195): Promise<{ book: Book } | { error: string }> => {
196  const next = changes.then(() => changeBookNow($, apply))
197  changes = next.catch(() => undefined)
198
199  return next
200}
201
202// Changes the current run: `apply` answers the run as it should be (a new one
203// to plan it, the same one for no change), a refusal, or null to forget it.
204const change = async (
205  $: EngineInterface,
206  apply: (now: LedgerRun | null) => LedgerRun | string | null,
207): Promise<{ run: LedgerRun | null } | { error: string }> => {
208  const changed = await changeBook($, (book, at) => {
209    const before = currentOf(book)
210    const after = apply(before)
211
212    return typeof after === 'string'
213      ? after
214      : after === null
215        ? before === null
216          ? book
217          : cleared(book, before.id)
218        : put(book, after, at)
219  })
220
221  return 'error' in changed ? changed : { run: currentOf(changed.book) }
222}
223
224// What a small model answered when asked to lay out a document the parser
225// could not read, by the document's outline with its ticks taken out: one
226// call a document, however often its boxes change.
227const laidOut = new Map<string, string>()
228// The plan documents runs are read from: an edit of one reads it again.
229const followed = new Set<string>()
230
231type PlanRead = { text: string; doc: PlanDoc; isLaidOutByModel: boolean }
232
233// Reads a plan document: by its layout where that is the superpowers one, at
234// no cost; else as a small model groups its headings and checkboxes, from a
235// request of those lines alone. Undefined when it has no checkbox to follow.
236const readPlan = async ($: EngineInterface, path: string): Promise<PlanRead | undefined> => {
237  const text = await $.fs.read(path).catch(() => undefined)
238
239  if (text === undefined) {
240    return undefined
241  }
242
243  const direct = parsePlan(text)
244
245  if (direct !== undefined) {
246    return { text, doc: direct, isLaidOutByModel: false }
247  }
248
249  const outline = outlineOf(text)
250
251  if (!/\[( |x|X)\]/.test(outline)) {
252    return undefined
253  }
254
255  const key = outline.replace(/\[(x|X)\]/g, '[ ]')
256  let answer = laidOut.get(key)
257
258  if (answer === undefined) {
259    // A call that fails is no answer: the document is then not followed.
260    answer = await $.model
261      .complete({ model: 'haiku', prompt: outlineRequest(outline), maxTokens: 1500, timeoutMs: 30000 })
262      .then(
263        said => (said.isAnswered ? said.text : ''),
264        () => '',
265      )
266
267    if (answer !== '') {
268      laidOut.set(key, answer)
269    }
270  }
271
272  const doc = planFromOutline(text, answer)
273
274  return doc === undefined ? undefined : { text, doc, isLaidOutByModel: true }
275}
276
277// Makes a run of a plan document, or brings the run it made in step with it.
278// Answers what happened, in a line.
279const followDoc = async ($: EngineInterface, path: string): Promise<string> => {
280  const plan = await readPlan($, path)
281
282  if (plan === undefined) {
283    return `Ledger: ${path} has no tasks with checkboxes to follow.`
284  }
285
286  const sessionId = await $.session.id()
287  const changed = await changeBook($, (book, at) =>
288    put(
289      book,
290      runFromDoc(
291        plan.doc,
292        path,
293        plan.isLaidOutByModel,
294        at,
295        sessionId,
296        book.runs.find(one => one.doc?.path === path),
297      ),
298      at,
299    ),
300  )
301
302  if ('error' in changed) {
303    return changed.error
304  }
305
306  followed.add(path)
307
308  return `Ledger: following ${path}, ${plan.doc.tasks.length} task${plan.doc.tasks.length === 1 ? '' : 's'}${plan.isLaidOutByModel ? ' (laid out by a small model: its layout is not the superpowers one)' : ''}.`
309}
310
311// Ticks, in a run's plan document, the open boxes under each task the run has
312// as done; `isDry` only counts them. Answers how many.
313const tickDoc = async ($: EngineInterface, path: string, isDry: boolean): Promise<number> => {
314  const plan = await readPlan($, path)
315  const owner = (await loadBook($)).runs.find(one => one.doc?.path === path)
316
317  if (plan === undefined || owner === undefined) {
318    return 0
319  }
320
321  const after = ticked(plan.text, plan.doc, owner)
322
323  if (after.count > 0 && !isDry) {
324    await $.fs.write(path, after.text)
325    await followDoc($, path)
326  }
327
328  return after.count
329}
330
331// A file was written: when it is a plan document a run is read from, or a new
332// plan where superpowers saves them and those are followed, the run follows.
333const noteDoc = async ($: EngineInterface, path: string, follows: boolean): Promise<void> => {
334  const isNew = !followed.has(path)
335
336  if (!isNew || (follows && PLAN_PATH.test(path))) {
337    const said = await followDoc($, path).catch(() => '')
338
339    if (isNew && said !== '') {
340      $.ui.toast(said)
341    }
342  }
343}
344
345// A path as typed, made absolute from the session's folder.
346const fullPath = async ($: EngineInterface, typed: string): Promise<string> =>
347  typed.startsWith('/') ? typed : `${await $.session.cwd()}/${typed.replace(/^\.\//, '')}`
348
349// The subagents whose last turn has ended, so a tool call from one is seen as
350// it resuming; and the ends that arrived before their spawn was recorded.
351const over = new Set<string>()
352const earlyEnds = new Map<string, { reason: string; tokens: number | undefined; at: number }>()
353
354const NO_RUN =`Nothing is planned. Call ${PLAN} first.`
355const NOTHING = 'Ledger: nothing is planned. The agent records a plan when it delegates one.'
356
357const text = (value: unknown): string | undefined =>
358  typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
359
360// The most characters kept of what the agent writes: a line each, so the
361// ledger stays cheap to write and quick to read.
362const ABOUT_MOST = 160
363const NOTE_MOST = 160
364const OUTCOME_MOST = 240
365const SUMMARY_MOST = 200
366
367// A line of text: its spaces folded, cut at `most`; undefined when empty.
368const brief = (value: unknown, most: number): string | undefined => {
369  const said = text(value)
370
371  return said === undefined ? undefined : cut(said.replace(/\s+/g, ' '), most)
372}
373
374// What a plan call says each unit is for; an entry that names no unit or says
375// nothing of it is left out.
376const unitNotes = (value: unknown): { name: string; about: string }[] =>
377  (Array.isArray(value) ? (value as unknown[]) : []).flatMap(one => {
378    const entry = (typeof one === 'object' && one !== null ? one : {}) as Record<string, unknown>
379    const name = text(entry.name)
380    const about = brief(entry.about, ABOUT_MOST)
381
382    return name === undefined || about === undefined ? [] : [{ name, about }]
383  })
384
385// The tasks of a plan call, or the reason they were refused.
386const tasksOf = (value: unknown): NewTask[] | string => {
387  if (!Array.isArray(value)) {
388    return 'plan needs tasks: a list of { id, unit, title, tier, check }.'
389  }
390
391  const tasks: NewTask[] = []
392
393  for (const one of value as unknown[]) {
394    const entry = (typeof one === 'object' && one !== null ? one : {}) as Record<string, unknown>
395    const id = text(entry.id)
396    const title = text(entry.title)
397    const lane = text(entry.lane)
398
399    if (id === undefined || title === undefined) {
400      return 'Every task needs an id and a title.'
401    }
402
403    const unit = text(entry.unit)
404    const tier = text(entry.tier)
405    const check = brief(entry.check, NOTE_MOST)
406    const about = brief(entry.about, ABOUT_MOST)
407
408    tasks.push({
409      id,
410      title,
411      ...(lane !== undefined ? { lane } : {}),
412      ...(unit !== undefined ? { unit } : {}),
413      ...(tier !== undefined ? { tier } : {}),
414      ...(check !== undefined ? { check } : {}),
415      ...(about !== undefined ? { about } : {}),
416    })
417  }
418
419  return tasks
420}
421
422export const register: Register = (on, options) => {
423  // Whether a superpowers plan document is read as a run by itself, and
424  // whether finishing a task ticks the boxes under it that were missed.
425  const follows = options.followPlans === true
426  const ticks = options.tickPlans !== false
427
428  on('session.start', async ($, e, next) => {
429    await $.command.register({
430      name: 'ledger',
431      description: 'Show the run: /ledger [hide | show | clear]',
432    })
433    await $.tool.register({
434      name: 'plan',
435      description:
436        'Record the settled plan of an orchestrated run: its goal and every task. It becomes the current run; earlier runs are kept.',
437      inputSchema: {
438        type: 'object',
439        properties: {
440          goal: { type: 'string', description: 'One line naming what the run is for' },
441          units: {
442            type: 'array',
443            description: 'What each unit is for: one line on what its tasks add up to for the user',
444            items: {
445              type: 'object',
446              properties: { name: { type: 'string' }, about: { type: 'string' } },
447              required: ['name', 'about'],
448            },
449          },
450          tasks: {
451            type: 'array',
452            minItems: 1,
453            items: {
454              type: 'object',
455              properties: {
456                id: { type: 'string', description: 'A short id, as in T1' },
457                lane: {
458                  type: 'string',
459                  description: 'Where the plan has parallel lanes (tracks that can be worked on at once): the lane this task is in',
460                },
461                unit: { type: 'string', description: 'The unit the task belongs to; tasks reviewed together share one' },
462                title: { type: 'string' },
463                tier: { type: 'string', description: 'The model the task is meant for: haiku, sonnet or opus' },
464                check: { type: 'string', description: 'What the gate checks to call the task done' },
465                about: {
466                  type: 'string',
467                  description:
468                    'One line on what the task is for: what it changes for the user, or which part of the code it touches',
469                },
470              },
471              required: ['id', 'title'],
472            },
473          },
474        },
475        required: ['goal', 'tasks'],
476      },
477    })
478    await $.tool.register({
479      name: 'task',
480      description: 'Record the state of every task that changed, in one call: done, or gate-failed.',
481      inputSchema: {
482        type: 'object',
483        properties: {
484          tasks: {
485            type: 'array',
486            minItems: 1,
487            items: {
488              type: 'object',
489              properties: {
490                id: { type: 'string' },
491                status: { type: 'string', enum: [...STATUSES] },
492                note: { type: 'string', description: 'With gate-failed: one line on what failed' },
493                outcome: { type: 'string', description: 'With done: one line on what changed, and in which files' },
494              },
495              required: ['id', 'status'],
496            },
497          },
498        },
499        required: ['tasks'],
500      },
501    })
502    await $.tool.register({
503      name: 'finding',
504      description: 'Report every problem a review found, in one call. Subagents call this too.',
505      inputSchema: {
506        type: 'object',
507        properties: {
508          findings: {
509            type: 'array',
510            minItems: 1,
511            items: {
512              type: 'object',
513              properties: {
514                path: { type: 'string', description: 'The file, from the project root' },
515                line: { type: 'integer' },
516                severity: { type: 'string', enum: [...SEVERITIES] },
517                summary: { type: 'string', description: 'One line: what is wrong and what it causes' },
518                task: { type: 'string', description: 'The task id, when known' },
519              },
520              required: ['path', 'severity', 'summary'],
521            },
522          },
523        },
524        required: ['findings'],
525      },
526    })
527    await $.tool.register({
528      name: 'fixed',
529      description: 'Mark findings dealt with, by id.',
530      inputSchema: {
531        type: 'object',
532        properties: { ids: { type: 'array', items: { type: 'integer' }, minItems: 1 } },
533        required: ['ids'],
534      },
535    })
536    await $.tool.register({
537      name: 'report',
538      description: 'Read the run: every task and its state, the agents spawned, and the open findings.',
539      inputSchema: { type: 'object', properties: {} },
540    })
541
542    try {
543      // A run another session planned is kept, but its agents ended with it.
544      const sessionId = await $.session.id()
545      await changeBook($, (book, at) => ({
546        ...book,
547        runs: book.runs.map(one =>
548          one.sessionId !== sessionId ? settled({ ...orphaned(one, at), sessionId }, at) : one,
549        ),
550      }))
551      await refresh($)
552
553      // A plan document may have changed while no session was watching it.
554      for (const one of (await loadBook($)).runs) {
555        if (one.doc !== undefined) {
556          followed.add(one.doc.path)
557
558          if (one.doneAt === undefined) {
559            await followDoc($, one.doc.path).catch(() => '')
560          }
561        }
562      }
563    } catch {
564      $.ui.toast('The ledger could not be loaded.')
565    }
566
567    return next(e)
568  })
569
570  on('prompt.compose', async ($, e, next) => {
571    const composed = await next(e)
572
573    return {
574      sections: [...composed.sections, { id: 'ledger:guide', text: GUIDE, scope: 'session' }],
575    }
576  })
577
578  on('tool.call', { tool: PLAN }, async ($, e) => {
579    if (e.agentId !== undefined) {
580      return { deny: SUBAGENT_REFUSAL }
581    }
582
583    const tasks = tasksOf(e.tasks)
584
585    if (typeof tasks === 'string') {
586      return { deny: tasks }
587    }
588
589    const sessionId = await $.session.id()
590    const goal = text(e.goal) ?? ''
591    const notes = unitNotes(e.units)
592    // A task id names one task among the runs under way: that is how every
593    // later write finds its run.
594    const changed = await changeBook($, (book, at) => {
595      const taken = clash(
596        book,
597        tasks.map(one => one.id),
598      )
599      const made = taken ?? plan(goal, tasks, at, sessionId, notes)
600
601      return typeof made === 'string' ? made : put(book, made, at)
602    })
603
604    if ('error' in changed) {
605      return { deny: changed.error }
606    }
607
608    await update($, isHidden, () => false)
609    await refresh($)
610
611    return { result: `Planned ${tasks.length} task${tasks.length === 1 ? '' : 's'}.` }
612  })
613
614  on('tool.call', { tool: TASK }, async ($, e) => {
615    if (e.agentId !== undefined) {
616      return { deny: SUBAGENT_REFUSAL }
617    }
618
619    // Every task that changed, in one call; one task's fields alone are taken too.
620    const entries = (Array.isArray(e.tasks) ? (e.tasks as unknown[]) : [e]).map(
621      one => (typeof one === 'object' && one !== null ? one : {}) as Record<string, unknown>,
622    )
623    const updates = entries.flatMap(entry => {
624      const id = text(entry.id)
625      const status = STATUSES.find(one => one === entry.status)
626
627      return id === undefined || status === undefined
628        ? []
629        : [{ id, status, note: brief(entry.note, NOTE_MOST), outcome: brief(entry.outcome, OUTCOME_MOST) }]
630    })
631
632    if (updates.length === 0 || updates.length < entries.length) {
633      return { deny: `Each task needs an id and a status, one of ${STATUSES.join(', ')}.` }
634    }
635
636    // Each goes to the run its task is in. All of them or none: one id no run
637    // has refuses the call, and says which.
638    const changed = await changeBook($, (book, at) => {
639      if (book.runs.length === 0) {
640        return NO_RUN
641      }
642
643      let after = book
644
645      for (const one of updates) {
646        const owner = ownerOf(after, one.id)
647        const next: LedgerRun | string =
648          owner === undefined
649            ? `No task ${one.id}. The tasks under way are ${underWay(after)
650                .flatMap(run => run.tasks.map(task => task.id))
651                .join(', ')}.`
652            : setTask(owner, one.id, one.status, one.note, one.outcome)
653
654        if (typeof next === 'string') {
655          return next
656        }
657
658        after = put(after, next, at)
659      }
660
661      return after
662    })
663
664    if ('error' in changed) {
665      return { deny: changed.error }
666    }
667
668    // A task done in a run read from a plan document: the boxes under it that
669    // were missed are ticked there.
670    if (ticks && updates.some(one => one.status === 'done')) {
671      for (const one of changed.book.runs) {
672        if (one.doc !== undefined && updates.some(update => one.tasks.some(task => task.id === update.id))) {
673          await tickDoc($, one.doc.path, false).catch(() => 0)
674        }
675      }
676    }
677
678    return { result: updates.map(one => `${one.id} ${one.status}`).join(', ') }
679  })
680
681  on('tool.call', { tool: FINDING }, async ($, e) => {
682    // Every finding of a review, in one call; one finding's fields alone too.
683    const entries = (Array.isArray(e.findings) ? (e.findings as unknown[]) : [e]).map(
684      one => (typeof one === 'object' && one !== null ? one : {}) as Record<string, unknown>,
685    )
686    const reported = entries.flatMap(entry => {
687      const path = text(entry.path)
688      const summary = brief(entry.summary, SUMMARY_MOST)
689      const severity = SEVERITIES.find(one => one === entry.severity)
690      const line =
691        typeof entry.line === 'number' && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : undefined
692      const task = text(entry.task)
693
694      return path === undefined || summary === undefined || severity === undefined
695        ? []
696        : [
697            {
698              path,
699              severity,
700              summary,
701              ...(line !== undefined ? { line } : {}),
702              ...(task !== undefined ? { task } : {}),
703            },
704          ]
705    })
706
707    if (reported.length === 0 || reported.length < entries.length) {
708      return { deny: `Each finding needs a path, a summary and a severity, one of ${SEVERITIES.join(', ')}.` }
709    }
710
711    const agentId = e.agentId
712    let first = 0
713    // Each goes to the run of the task it names, else to the run the
714    // reporting subagent was spawned in (and to that subagent's task), else to
715    // the current run.
716    const changed = await changeBook($, (book, at) => {
717      let after = book
718      first = nextFinding(book)
719
720      for (const one of reported) {
721        const home =
722          (one.task !== undefined ? ownerOf(after, one.task) : undefined) ??
723          runOfAgent(after, agentId) ??
724          currentOf(after)
725
726        if (home === null || home === undefined) {
727          return NO_RUN
728        }
729
730        const own = home.spawns.find(spawn => spawn.agentId === agentId)?.task
731
732        after = put(
733          after,
734          found(
735            // Findings are numbered across the book, so a number names one.
736            { ...home, nextFinding: nextFinding(after) },
737            {
738              ...one,
739              ...(one.task === undefined && own !== undefined ? { task: own } : {}),
740              ...(agentId !== undefined ? { agentId } : {}),
741            },
742            at,
743          ),
744          at,
745        )
746      }
747
748      return after
749    })
750
751    if ('error' in changed) {
752      return { deny: changed.error }
753    }
754
755    return {
756      result:
757        reported.length === 1
758          ? `Recorded as finding #${first}.`
759          : `Recorded as findings #${first} to #${first + reported.length - 1}.`,
760    }
761  })
762
763  on('tool.call', { tool: FIXED }, async ($, e) => {
764    if (e.agentId !== undefined) {
765      return { deny: SUBAGENT_REFUSAL }
766    }
767
768    const ids = Array.isArray(e.ids)
769      ? (e.ids as unknown[]).filter((one): one is number => typeof one === 'number')
770      : []
771
772    if (ids.length === 0) {
773      return { deny: 'fixed needs ids: the numbers of the findings dealt with.' }
774    }
775
776    // Each finding is closed in the run it is in.
777    const changed = await changeBook($, (book, at) => {
778      if (book.runs.length === 0) {
779        return NO_RUN
780      }
781
782      let after = book
783
784      for (const id of ids) {
785        const home = runOfFinding(after, id)
786        const next = home === undefined ? `No finding #${id}.` : fixed(home, [id])
787
788        if (typeof next === 'string') {
789          return next
790        }
791
792        after = put(after, next, at)
793      }
794
795      return after
796    })
797
798    return 'error' in changed ? { deny: changed.error } : { result: `Marked ${ids.length} fixed.` }
799  })
800
801  // Every run under way, the newest first; with none, the current run as it
802  // ended.
803  on('tool.call', { tool: REPORT }, async $ => {
804    const book = await loadBook($)
805    const live = underWay(book)
806    const last = currentOf(book)
807
808    return {
809      result:
810        live.length > 0
811          ? live.map(one => report(one, true)).join('\n\n')
812          : last === null
813            ? 'Nothing is planned.'
814            : report(last, true),
815    }
816  })
817
818  // A subagent started: recorded in the run whose task its description names,
819  // else in the current run while that is under way. With no run under way
820  // nothing is recorded.
821  on('agent.spawn', async ($, e, next) => {
822    const started = await next(e)
823    const agentId = started.agentId
824
825    if (agentId !== undefined) {
826      const model = started.model
827      await changeBook($, (book, at) => {
828        const live = underWay(book)
829        const named = live.find(one => taskOf(one, e.description, e.prompt) !== undefined)
830        const last = currentOf(book)
831        const home = named ?? (last !== null && last.doneAt === undefined ? last : undefined)
832
833        if (home === undefined) {
834          return book
835        }
836
837        const task = taskOf(home, e.description, e.prompt)
838        const record = spawned(home, {
839          agentId,
840          description: e.description,
841          type: e.subagentType,
842          model,
843          startedAt: at,
844          ...(task !== undefined ? { task } : {}),
845        })
846        // Its turn may have ended before this record was made.
847        const early = earlyEnds.get(agentId)
848        earlyEnds.delete(agentId)
849
850        return put(
851          book,
852          early === undefined ? record : ended(record, agentId, early.reason, early.tokens, early.at),
853          at,
854        )
855      })
856    }
857
858    return started
859  })
860
861  // A subagent's turn is over: when, why, and what it used, cached tokens
862  // included.
863  on('turn.complete', async ($, e, next) => {
864    const agentId = e.agentId
865
866    if (agentId !== undefined) {
867      const usage = e.usage
868      const tokens =
869        usage === undefined
870          ? undefined
871          : usage.input_tokens +
872            usage.output_tokens +
873            usage.cache_read_input_tokens +
874            usage.cache_creation_input_tokens
875      const reason = e.reason
876      await changeBook($, (book, at) => {
877        const home = runOfAgent(book, agentId)
878
879        if (home === undefined) {
880          // Its spawn is not recorded yet, or no run was under way.
881          if (underWay(book).length > 0) {
882            earlyEnds.set(agentId, { reason, tokens, at })
883          }
884
885          return book
886        }
887
888        over.add(agentId)
889
890        return put(book, ended(home, agentId, reason, tokens, at), at)
891      })
892    }
893
894    return next(e)
895  })
896
897  // A plan document was written or edited: the run read from it follows. A
898  // plan saved where superpowers saves them starts a run by itself, where the
899  // setting asks for that.
900  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
901    const ran = await next(e)
902    await noteDoc($, e.file_path, follows)
903
904    return ran
905  })
906
907  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
908    const ran = await next(e)
909    await noteDoc($, e.file_path, follows)
910
911    return ran
912  })
913
914  // A subagent whose turn had ended calls a tool: it was sent a message and is
915  // at work again.
916  on('tool.call', async ($, e, next) => {
917    const agentId = e.agentId
918
919    if (agentId !== undefined && over.has(agentId)) {
920      over.delete(agentId)
921      await changeBook($, (book, at) => {
922        const home = runOfAgent(book, agentId)
923
924        return home === undefined ? book : put(book, resumed(home, agentId), at)
925      })
926    }
927
928    return next(e)
929  })
930
931  on('command.run', { command: 'ledger' }, async ($, e) => {
932    const word = e.args.trim()
933
934    if (word === 'hide' || word === 'show') {
935      await update($, isHidden, () => word === 'hide')
936      await refresh($)
937
938      return { text: `Ledger: the status entry is ${word === 'hide' ? 'hidden' : 'shown'}.` }
939    }
940
941    // A plan document read as a run: any document laid out in tasks with
942    // checkboxes, wherever it is saved.
943    if (word.startsWith('follow')) {
944      const typed = word.slice('follow'.length).trim()
945
946      return {
947        text: typed === '' ? 'Ledger: name the plan document: /ledger follow docs/plan.md' : await followDoc($, await fullPath($, typed)),
948      }
949    }
950
951    if (word === 'tick' || word === 'tick --dry') {
952      const isDry = word.endsWith('--dry')
953      const docs = (await loadBook($)).runs.flatMap(one => (one.doc === undefined ? [] : [one.doc.path]))
954
955      if (docs.length === 0) {
956        return { text: 'Ledger: no run is read from a plan document.' }
957      }
958
959      const counts: string[] = []
960
961      for (const path of docs) {
962        counts.push(`${await tickDoc($, path, isDry).catch(() => 0)} in ${path}`)
963      }
964
965      return { text: `Ledger: ${isDry ? 'would tick' : 'ticked'} ${counts.join(', ')}.` }
966    }
967
968    if (word === 'clear') {
969      const changed = await change($, () => null)
970
971      return { text: 'error' in changed ? changed.error : 'Ledger: the run is cleared.' }
972    }
973
974    const book = await loadBook($)
975    const now = currentOf(book)
976
977    if (word === 'print') {
978      return { text: now === null ? NOTHING : report(now) }
979    }
980
981    if (book.runs.length === 0) {
982      return { text: NOTHING }
983    }
984
985    await show($, book)
986    // On the current run while it is under way; else on the list of runs.
987    await update($, shown, () => (now !== null && now.doneAt === undefined ? now.id : 0))
988    await update($, confirming, () => 0)
989    await $.ui.open({ id: PANE, title: 'Ledger', focus: true, closeOnEscape: true })
990
991    return { text: 'Ledger pane opened.' }
992  })
993
994  // The list's done, clear and use act on the row the focus ring is on.
995  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
996    const moved = await next(e)
997    const id = /^run-(\d+)$/.exec(e.element ?? '')?.[1]
998
999    if (id !== undefined) {
1000      await update($, cursor, () => Number(id))
1001    }
1002
1003    const finding = /^finding-(\d+)$/.exec(e.element ?? '')?.[1]
1004
1005    if (finding !== undefined) {
1006      await update($, pointed, () => Number(finding))
1007    }
1008
1009    return moved
1010  })
1011
1012  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1013    const { Box, Button, Text } = $.ui.resolve(e)
1014    // What is drawn is narrower than the pane by the padding at each side.
1015    const columns = Math.max(30, (e.props.bodyColumns ?? e.viewport?.columns ?? 60) - 2 * PAD)
1016    const rule = '─'.repeat(columns)
1017    const all = await read($, runs)
1018    const openId = await read($, shown)
1019    const now = all.find(one => one.id === openId)
1020    const at = await $.clock.now()
1021    const current = (await read($, run))?.id ?? 0
1022    const isLive = (one: LedgerRun) =>
1023      one.doneAt === undefined && one.spawns.some(spawn => spawn.endedAt === undefined)
1024    // Reading the frame subscribes the drawing only while a subagent runs, so
1025    // an idle pane stays still.
1026    const mark = all.some(isLive) ? ([...SPINNER][(await read($, spin)) % SPINNER.length] ?? '') : ''
1027
1028    // The list: every run of the project, those not done first.
1029    if (now === undefined) {
1030      const withDone = await read($, isAll)
1031      const rows = listed({ nextId: 0, current, runs: all }, withDone)
1032      const doneCount = all.filter(one => one.doneAt !== undefined).length
1033      const focused = await read($, cursor)
1034      const here = rows.some(one => one.id === focused) ? focused : (rows[0]?.id ?? 0)
1035      const picked = rows.find(one => one.id === here)
1036      const asking = await read($, confirming)
1037
1038      return (
1039        <Box flexDirection="column" paddingX={PAD}>
1040          <Box justifyContent="space-between">
1041            <Text bold>
1042              <Text color="cyan">{ICON.tasks} </Text>Ledger
1043            </Text>
1044            <Text dimColor>
1045              {all.length - doneCount} open · {doneCount} done
1046            </Text>
1047          </Box>
1048          <Text dimColor>{rule}</Text>
1049          {rows.length === 0 && (
1050            <Text dimColor>{all.length === 0 ? 'Nothing planned yet.' : 'Every run is done.'}</Text>
1051          )}
1052          {rows.map((one, index) => {
1053            const isOver = one.doneAt !== undefined
1054            const failed = countsOf(one).failed
1055            const tone = isOver ? 'success' : failed > 0 ? 'error' : 'cyan'
1056            const glyph = isOver ? ICON.done : isLive(one) ? mark : failed > 0 ? ICON.failed : ICON.todo
1057
1058            return (
1059              <Box justifyContent="space-between">
1060                <Box>
1061                  <Text color="cyan">{one.id === here ? '▸' : ' '}</Text>
1062                  <Text color={tone} dimColor={isOver}>
1063                    {glyph}{' '}
1064                  </Text>
1065                  <Button
1066                    key={`run-${one.id}`}
1067                    plain
1068                    {...(index < 9 ? { hotkey: String(index + 1) } : {})}
1069                    {...(one.id === here ? { autoFocus: true as const } : {})}
1070                    dimColor={isOver}
1071                    label={cut(one.goal === '' ? `Run ${one.id}` : one.goal, Math.max(10, columns - 34))}
1072                    onPress={async () => {
1073                      await update($, confirming, () => 0)
1074                      await update($, shown, () => one.id)
1075                    }}
1076                  />
1077                  {one.id === current ? <Text color="cyan"> ●</Text> : null}
1078                </Box>
1079                <Text dimColor>
1080                  {' '}
1081                  {runMarks(one)} · {ageOf(one.doneAt ?? one.startedAt, at)}
1082                </Text>
1083              </Box>
1084            )
1085          })}
1086          <Text dimColor>{rule}</Text>
1087          {asking !== 0 ? (
1088            <Box columnGap={2}>
1089              <Text color="warning">Clear run {asking} for good?</Text>
1090              <Button
1091                key="yes"
1092                plain
1093                hotkey="y"
1094                label="yes"
1095                onPress={async () => {
1096                  await changeBook($, book => cleared(book, asking))
1097                  await update($, confirming, () => 0)
1098                }}
1099              />
1100              <Button key="no" plain hotkey="n" label="no" onPress={() => update($, confirming, () => 0)} />
1101            </Box>
1102          ) : picked === undefined ? (
1103            <Button
1104              key="all"
1105              plain
1106              dimColor
1107              hotkey="a"
1108              label={withDone ? 'open only' : 'all'}
1109              onPress={() => update($, isAll, value => !value)}
1110            />
1111          ) : (
1112            <Box columnGap={2}>
1113              <Button
1114                key="done"
1115                plain
1116                dimColor
1117                hotkey="d"
1118                label={picked.isClosed === true ? 'reopen' : 'done'}
1119                onPress={async () => {
1120                  await changeBook($, (book, when) => closed(book, picked.id, picked.isClosed !== true, when))
1121                }}
1122              />
1123              <Button key="clear" plain dimColor hotkey="x" label="clear" onPress={() => update($, confirming, () => picked.id)} />
1124              <Button
1125                key="all"
1126                plain
1127                dimColor
1128                hotkey="a"
1129                label={withDone ? 'open only' : 'all'}
1130                onPress={() => update($, isAll, value => !value)}
1131              />
1132            </Box>
1133          )}
1134        </Box>
1135      )
1136    }
1137
1138    // One run. Each task is a line: marks and numbers say how it stands, and
1139    // words are kept for what is wrong. Details add what each part is for,
1140    // what was done, and what is finished or fixed.
1141    const detailed = await read($, isDetailed)
1142    const counts = countsOf(now)
1143    const openFindings = now.findings.filter(one => one.status === 'open')
1144    const fixedFindings = now.findings.filter(one => one.status === 'fixed')
1145    const findings = [...openFindings, ...(detailed ? fixedFindings : [])]
1146    const lanes = lanesOf(now)
1147    // The finding "open" shows in lens: the one the focus is on, else the first.
1148    const focusedFinding = await read($, pointed)
1149    const target = findings.find(one => one.id === focusedFinding) ?? findings[0]
1150    const loose = now.spawns.filter(one => one.task === undefined && (detailed || one.endedAt === undefined))
1151
1152    return (
1153      <Box flexDirection="column" paddingX={PAD}>
1154        <Box justifyContent="space-between">
1155          <Text bold wrap="truncate-end">
1156            <Text color="cyan">{ICON.tasks} </Text>
1157            {now.goal === '' ? `Run ${now.id}` : now.goal}
1158          </Text>
1159          <Text dimColor>
1160            {' '}
1161            {counts.done}/{counts.tasks}
1162            {counts.tokens > 0 ? ` · ${tokensText(counts.tokens)}` : ''}
1163          </Text>
1164        </Box>
1165        {now.doc === undefined ? null : (
1166          <Text dimColor wrap="truncate-start">
1167            {now.doc.path}
1168          </Text>
1169        )}
1170        <Text dimColor>{rule}</Text>
1171        {unitsOf(now).map((unit, index, units) => (
1172          <Box flexDirection="column" paddingLeft={unit.lane === undefined ? 0 : 2}>
1173            {/* A lane's line comes before its first unit: how the lane stands,
1174                with the mark that turns while one of its subagents runs. */}
1175            {unit.lane !== undefined && units[index - 1]?.lane !== unit.lane
1176              ? lanes
1177                  .filter(lane => lane.name === unit.lane)
1178                  .map(lane => (
1179                    <Box justifyContent="space-between" marginLeft={-2}>
1180                      <Text bold color="cyan" wrap="truncate-end">
1181                        {lane.name}
1182                        {lane.agents > 0 ? ` ${mark}` : ''}
1183                        {lane.failed > 0 ? <Text color="error"> {ICON.failed}</Text> : null}
1184                      </Text>
1185                      <Text dimColor>
1186                        {' '}
1187                        {lane.done}/{lane.tasks}
1188                      </Text>
1189                    </Box>
1190                  ))
1191              : null}
1192            <Box justifyContent="space-between">
1193              <Text bold wrap="truncate-end">
1194                {unit.name}
1195              </Text>
1196              <Text dimColor>
1197                {' '}
1198                {unit.tasks.filter(one => one.status === 'done').length}/{unit.tasks.length}
1199              </Text>
1200            </Box>
hooks/doc.ts 214 lines
1// A plan document as the ledger's input: a superpowers plan (or any document
2// laid out like one) read into tasks and their steps, a run made from it, and
3// its checkboxes ticked. Free of the engine, as run.ts is.
4
5import type { LedgerRun, LedgerStep, LedgerTask } from '../types'
6
7// One task of the document: where its heading is, and where each of its
8// checkbox steps is. Lines are counted from 0.
9export type DocTask = {
10  // The number its heading gives it ("3" of "### Task 3: ..."), or its place.
11  n: string
12  title: string
13  unit: string
14  line: number
15  steps: { line: number; text: string; isDone: boolean }[]
16}
17
18export type PlanDoc = { title: string; tasks: DocTask[] }
19
20// Where the superpowers skills save their plans.
21export const PLAN_PATH = /(^|\/)docs\/superpowers\/plans\/[^/]+\.md$/
22
23const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
24const TASK = /^Task\s+([\w.-]+?)\s*[:.)-]?\s+(.+)$/i
25const BARE_TASK = /^Task\s+([\w.-]+)\s*[:.)-]?$/i
26const BOX = /^\s*[-*+]\s+\[( |x|X)\]\s+(.*)$/
27
28// A step's words without the marks around them.
29const plain = (text: string): string => text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/`/g, '').trim()
30
31// The lines of a document with those inside a code fence blanked: a fenced
32// example may hold headings and checkboxes that are not the plan's.
33const unfenced = (text: string): string[] => {
34  let isFenced = false
35
36  return text.split('\n').map(line => {
37    if (/^\s*(```|~~~)/.test(line)) {
38      isFenced = !isFenced
39
40      return ''
41    }
42
43    return isFenced ? '' : line
44  })
45}
46
47// Reads a document laid out as the superpowers plans are: a `### Task N: name`
48// heading per task, checkbox steps beneath it, `##` sections as units.
49// Undefined when it has no such task: it is laid out some other way.
50export const parsePlan = (text: string): PlanDoc | undefined => {
51  const lines = unfenced(text)
52  const tasks: DocTask[] = []
53  let title = ''
54  let unit = ''
55  let task: DocTask | undefined
56
57  lines.forEach((line, at) => {
58    const heading = HEADING.exec(line)
59
60    if (heading !== null) {
61      const words = plain(heading[2] ?? '')
62      const named = TASK.exec(words) ?? BARE_TASK.exec(words)
63
64      if (named !== null) {
65        task = { n: named[1] ?? '', title: named[2] ?? words, unit, line: at, steps: [] }
66        tasks.push(task)
67      } else {
68        // Any other heading ends the task before it; a `##` one names a unit.
69        task = undefined
70
71        if ((heading[1] ?? '').length === 1 && title === '') {
72          title = words
73        } else if ((heading[1] ?? '').length === 2) {
74          unit = words
75        }
76      }
77
78      return
79    }
80
81    const box = BOX.exec(line)
82
83    if (box !== null && task !== undefined) {
84      task.steps.push({ line: at, text: plain(box[2] ?? ''), isDone: box[1] !== ' ' })
85    }
86  })
87
88  return tasks.length === 0 ? undefined : { title, tasks }
89}
90
91// What a small model is given to lay out a document the parser could not
92// read: only its headings and checkboxes, each with its line number, so the
93// request and the answer are both a few lines.
94export const outlineOf = (text: string): string =>
95  unfenced(text)
96    .flatMap((line, at) => (HEADING.test(line) || BOX.test(line) ? [`${at}: ${line.trim().slice(0, 120)}`] : []))
97    .join('\n')
98
99export const outlineRequest = (outline: string): string =>
100  [
101    'Below are the headings and checkboxes of a plan document, each line led by its line number.',
102    'Group them into tasks: a task is a piece of work with checkbox steps under it.',
103    'Answer with JSON only, no prose: [{"title": "short name", "head": <line of its heading or first line>, "steps": [<line of each checkbox>]}].',
104    'Use only line numbers that appear below. Leave out headings that hold no checkbox.',
105    '',
106    outline,
107  ].join('\n')
108
109// The document as a small model laid it out: its answer names lines, and the
110// words and ticks are read from the document itself, so the model cannot
111// misreport them. Undefined when the answer names no usable task.
112export const planFromOutline = (text: string, answer: string): PlanDoc | undefined => {
113  const lines = unfenced(text)
114  let listed: unknown
115
116  try {
117    listed = JSON.parse(answer.slice(answer.indexOf('['), answer.lastIndexOf(']') + 1))
118  } catch {
119    return undefined
120  }
121
122  const tasks = (Array.isArray(listed) ? (listed as unknown[]) : []).flatMap((one, at): DocTask[] => {
123    const entry = (typeof one === 'object' && one !== null ? one : {}) as Record<string, unknown>
124    const steps = (Array.isArray(entry.steps) ? (entry.steps as unknown[]) : []).flatMap(line => {
125      const box = typeof line === 'number' ? BOX.exec(lines[line] ?? '') : null
126
127      return box === null ? [] : [{ line: line as number, text: plain(box[2] ?? ''), isDone: box[1] !== ' ' }]
128    })
129    const head = typeof entry.head === 'number' ? entry.head : (steps[0]?.line ?? -1)
130    const said = typeof entry.title === 'string' ? entry.title.trim() : ''
131
132    return steps.length === 0
133      ? []
134      : [{ n: String(at + 1), title: said === '' ? `Task ${at + 1}` : said.slice(0, 120), unit: '', line: head, steps }]
135  })
136  const title = plain(HEADING.exec(lines.find(line => /^#\s/.test(line)) ?? '')?.[2] ?? '')
137
138  return tasks.length === 0 ? undefined : { title, tasks }
139}
140
141export const taskIdOf = (task: DocTask): string => `T${task.n}`
142
143const stateOf = (task: DocTask, before: LedgerTask | undefined): LedgerTask['status'] => {
144  const ticked = task.steps.filter(one => one.isDone).length
145
146  // Every box ticked is done. Short of that the ledger's own word stands
147  // where it has one: a task marked done whose boxes were missed stays done
148  // (and its boxes are ticked), and a failed gate stays failed.
149  return task.steps.length > 0 && ticked === task.steps.length
150    ? 'done'
151    : before?.status === 'done' || before?.status === 'gate-failed'
152      ? before.status
153      : ticked > 0 || before?.status === 'running'
154        ? 'running'
155        : 'todo'
156}
157
158// The run a document makes, or the run it already made brought in step with
159// it: tasks and steps are the document's, and what the ledger added (notes,
160// outcomes, tiers, agents, findings) is kept.
161export const runFromDoc = (
162  doc: PlanDoc,
163  path: string,
164  isLaidOutByModel: boolean,
165  now: number,
166  sessionId: string,
167  before?: LedgerRun,
168): LedgerRun => {
169  const fallback = doc.title === '' ? (path.split('/').pop() ?? 'Plan').replace(/\.md$/, '') : doc.title
170  const tasks = doc.tasks.map((task): LedgerTask => {
171    const was = before?.tasks.find(one => one.id === taskIdOf(task))
172    const steps: LedgerStep[] = task.steps.map(one => ({ text: one.text, isDone: one.isDone }))
173
174    return {
175      ...(was ?? {}),
176      id: taskIdOf(task),
177      unit: task.unit === '' ? fallback : task.unit,
178      title: task.title,
179      status: stateOf(task, was),
180      steps,
181    }
182  })
183
184  return {
185    ...(before ?? { id: 0, startedAt: now, spawns: [], findings: [], nextFinding: 1 }),
186    goal: fallback,
187    sessionId,
188    doc: { path, ...(isLaidOutByModel ? { isLaidOutByModel: true as const } : {}) },
189    tasks,
190  }
191}
192
193// The document with the open boxes ticked under each task the run has as
194// done, and how many that was. Only `[ ]` becomes `[x]`, only on a line that
195// is still an open checkbox, and nothing else is changed.
196export const ticked = (text: string, doc: PlanDoc, run: LedgerRun): { text: string; count: number } => {
197  const done = new Set(run.tasks.filter(one => one.status === 'done').map(one => one.id))
198  const lines = text.split('\n')
199  let count = 0
200
201  for (const task of doc.tasks.filter(one => done.has(taskIdOf(one)))) {
202    for (const step of task.steps.filter(one => !one.isDone)) {
203      const line = lines[step.line] ?? ''
204
205      if (/^\s*[-*+]\s+\[ \]\s/.test(line)) {
206        lines[step.line] = line.replace('[ ]', '[x]')
207        count += 1
208      }
209    }
210  }
211
212  return { text: count === 0 ? text : lines.join('\n'), count }
213}
214
hooks/kit/icons.ts 60 lines
1// Shared by the lens, parked and ledger mods. The source is the mod-kit folder;
2// each mod carries a copy under hooks/kit, written there by sync.sh. Edit the
3// source and sync, never a copy.
4
5export type Icon = { glyph: string; color: string }
6
7// Nerd Font glyphs, as a terminal file tree draws them, in each language's
8// usual colour. They need a Nerd Font, or a terminal that ships the symbols.
9export const FILE_ICONS: readonly (readonly [pattern: RegExp, glyph: string, color: string])[] = [
10  [/\.pyi?$/, '\u{e73c}', '#ffd43b'],
11  [/\.[cm]?[tj]sx$/, '\u{e7ba}', '#20c2e3'],
12  [/\.[cm]?ts$/, '\u{e628}', '#519aba'],
13  [/\.[cm]?js$/, '\u{e74e}', '#cbcb41'],
14  [/\.json$/, '\u{e60b}', '#cbcb41'],
15  [/\.(tf|tfvars)$/, '\u{e69a}', '#7b42bc'],
16  [/\.(ya?ml|toml|ini|cfg|env)$/, '\u{e615}', '#6d8086'],
17  [/\.(md|mdx)$/, '\u{e73e}', '#dddddd'],
18  [/\.(sh|bash|zsh)$/, '\u{e795}', '#4d5a5e'],
19  [/\.(css|scss|less)$/, '\u{e749}', '#42a5f5'],
20  [/\.html?$/, '\u{e736}', '#e44d26'],
21  [/\.sql$/, '\u{e706}', '#dad8d8'],
22  [/\.(png|jpe?g|gif|svg|webp|ico)$/, '\u{f1c5}', '#a074c4'],
23  [/(^|\/)Dockerfile$/, '\u{f308}', '#458ee6'],
24  [/(^|\/)\.git(ignore|attributes)$/, '\u{e702}', '#f54d27'],
25  [/\.lock$/, '\u{f023}', '#bbbbbb'],
26]
27
28// The glyphs the mods draw for their own things, one name each.
29export const ICON = {
30  file: '\u{f15b}',
31  folder: '\u{f07b}',
32  folderOpen: '\u{f07c}',
33  needs: '\u{f059}',
34  blocking: '\u{f071}',
35  fyi: '\u{f05a}',
36  done: '\u{f058}',
37  failed: '\u{f057}',
38  running: '\u{f110}',
39  todo: '\u{f10c}',
40  thread: '\u{f075}',
41  options: '\u{f0cb}',
42  preferred: '\u{f005}',
43  tasks: '\u{f0ae}',
44  agents: '\u{f085}',
45  finding: '\u{f188}',
46} as const
47
48export const FILE_COLOR = '#6d8086'
49export const FOLDER_COLOR = '#dcb67a'
50export const STAR_COLOR = '#ffd43b'
51
52export const iconOf = (path: string): Icon => {
53  const hit = FILE_ICONS.find(([pattern]) => pattern.test(path))
54
55  return hit === undefined ? { glyph: ICON.file, color: FILE_COLOR } : { glyph: hit[1], color: hit[2] }
56}
57
58// The icon of a ref's file type; a ref is a path, or path:line.
59export const refIcon = (ref: string): Icon => iconOf(ref.replace(/:\d+(?::\d+)?$/, ''))
60
hooks/book.ts 190 lines
1// The project's runs, kept together: every run planned here, the newest last,
2// and which one is current. Free of the engine, as run.ts is.
3
4import type { LedgerRun } from '../types'
5import { ICON } from './kit/icons'
6import { cut } from './kit/layout'
7import { countsOf, isDone, isRun, statusText, tokensText } from './run'
8
9// `current` is the id of the run the tools record against (0 for none): the
10// one planned last, or the one the user picked.
11export type Book = { nextId: number; current: number; runs: LedgerRun[] }
12
13export const EMPTY: Book = { nextId: 1, current: 0, runs: [] }
14
15// How many runs a project keeps. Past it the oldest done ones are dropped; a
16// run not done is never dropped.
17export const KEPT = 40
18
19export const isBook = (value: unknown): value is Book =>
20  typeof value === 'object' &&
21  value !== null &&
22  typeof (value as Book).nextId === 'number' &&
23  Array.isArray((value as Book).runs) &&
24  (value as Book).runs.every(isRun)
25
26// What the store held before runs were kept together: one run, or nothing.
27export const bookOf = (stored: unknown, old: unknown): Book =>
28  isBook(stored) ? stored : isRun(old) ? { nextId: 2, current: 1, runs: [{ ...old, id: 1 }] } : EMPTY
29
30export const currentOf = (book: Book): LedgerRun | null =>
31  book.runs.find(one => one.id === book.current) ?? null
32
33// Whether a run has nothing left: every task done, no agent running and no
34// finding open.
35export const isSettled = (run: LedgerRun): boolean =>
36  isDone(run) &&
37  run.spawns.every(one => one.endedAt !== undefined) &&
38  run.findings.every(one => one.status === 'fixed')
39
40// A run with its `doneAt` as it should stand: set when it settles or the user
41// closed it, kept while it stays so, and gone when work reopens a run that
42// had only settled.
43export const settled = (run: LedgerRun, now: number): LedgerRun => {
44  const isOver = run.isClosed === true || isSettled(run)
45
46  if (isOver === (run.doneAt !== undefined)) {
47    return run
48  }
49
50  if (isOver) {
51    return { ...run, doneAt: now }
52  }
53
54  const { doneAt: _d, ...rest } = run
55
56  return rest
57}
58
59const pruned = (book: Book): Book => {
60  const extra = book.runs.length - KEPT
61  const drop = new Set(
62    book.runs
63      .filter(one => one.doneAt !== undefined && one.id !== book.current)
64      .slice(0, Math.max(0, extra))
65      .map(one => one.id),
66  )
67
68  return drop.size === 0 ? book : { ...book, runs: book.runs.filter(one => !drop.has(one.id)) }
69}
70
71// Keeps a run: a new one (id 0) is numbered, added and made current; one the
72// book has is replaced. Either way it is settled first.
73export const put = (book: Book, run: LedgerRun, now: number): Book =>
74  run.id === 0
75    ? pruned({
76        nextId: book.nextId + 1,
77        current: book.nextId,
78        runs: [...book.runs, settled({ ...run, id: book.nextId }, now)],
79      })
80    : { ...book, runs: book.runs.map(one => (one.id === run.id ? settled(run, now) : one)) }
81
82// Forgets a run for good. Clearing the current one leaves none current.
83export const cleared = (book: Book, id: number): Book => ({
84  ...book,
85  current: book.current === id ? 0 : book.current,
86  runs: book.runs.filter(one => one.id !== id),
87})
88
89// The user closes a run by hand, or opens it again.
90export const closed = (book: Book, id: number, isClosed: boolean, now: number): Book | string => {
91  const run = book.runs.find(one => one.id === id)
92
93  if (run === undefined) {
94    return `No run ${id}.`
95  }
96
97  const { isClosed: _c, ...rest } = run
98
99  return put(book, isClosed ? { ...rest, isClosed: true } : rest, now)
100}
101
102// The runs as the list shows them: those not done, the newest first, then
103// (when asked) the done ones, the latest done first.
104export const listed = (book: Book, isAll: boolean): LedgerRun[] => [
105  ...book.runs.filter(one => one.doneAt === undefined).reverse(),
106  ...(isAll
107    ? book.runs
108        .filter(one => one.doneAt !== undefined)
109        .sort((a, b) => (b.doneAt ?? 0) - (a.doneAt ?? 0))
110    : []),
111]
112
113// A run in a few marks, for its row in the list: tasks done of all, then
114// only what is wrong or under way.
115export const runMarks = (run: LedgerRun): string => {
116  const counts = countsOf(run)
117
118  return [
119    `${counts.done}/${counts.tasks}`,
120    counts.failed > 0 ? `${ICON.failed} ${counts.failed}` : '',
121    counts.openFindings > 0 ? `${ICON.finding} ${counts.openFindings}` : '',
122    counts.tokens > 0 ? tokensText(counts.tokens) : '',
123  ]
124    .filter(Boolean)
125    .join(' · ')
126}
127
128// How many subagents are running across every run not done.
129export const busyOf = (book: Book): number =>
130  book.runs
131    .filter(one => one.doneAt === undefined)
132    .reduce((sum, one) => sum + one.spawns.filter(spawn => spawn.endedAt === undefined).length, 0)
133
134// The runs still under way, the newest first: where a write looks for its run.
135export const underWay = (book: Book): LedgerRun[] =>
136  book.runs.filter(one => one.doneAt === undefined).reverse()
137
138// The run a task belongs to: one under way before one that is done, and of
139// two the newer.
140export const ownerOf = (book: Book, taskId: string): LedgerRun | undefined =>
141  [...underWay(book), ...book.runs.filter(one => one.doneAt !== undefined).reverse()].find(one =>
142    one.tasks.some(task => task.id === taskId),
143  )
144
145// The run a subagent was recorded in.
146export const runOfAgent = (book: Book, agentId: string | undefined): LedgerRun | undefined =>
147  agentId === undefined ? undefined : book.runs.find(one => one.spawns.some(spawn => spawn.agentId === agentId))
148
149// The run a finding is in.
150export const runOfFinding = (book: Book, id: number): LedgerRun | undefined =>
151  book.runs.find(one => one.findings.some(finding => finding.id === id))
152
153// The ids a new plan may not use: a task id must name one task among the runs
154// under way, since that is how a write finds its run. The answer names them.
155export const clash = (book: Book, ids: readonly string[]): string | undefined => {
156  const taken = ids.filter(id => underWay(book).some(one => one.tasks.some(task => task.id === id)))
157
158  return taken.length === 0
159    ? undefined
160    : `Task ${taken.length === 1 ? 'id' : 'ids'} ${taken.join(', ')} ${taken.length === 1 ? 'is' : 'are'} in use by a run still under way. Give this plan's tasks ids of their own, for example with a prefix.`
161}
162
163// The number the next finding takes: one count for the whole book, so a
164// finding's number names it whichever run it is in.
165export const nextFinding = (book: Book): number =>
166  Math.max(1, ...book.runs.map(one => one.nextFinding))
167
168// The status line's entry for the whole book: the one run under way in full,
169// several as a count of tasks each, and nothing with none under way.
170export const bookStatus = (book: Book): string | undefined => {
171  const live = underWay(book)
172  const only = live[0]
173
174  if (only === undefined) {
175    return undefined
176  }
177
178  if (live.length === 1) {
179    return statusText(only)
180  }
181
182  return `${ICON.tasks} ${live
183    .map(one => {
184      const counts = countsOf(one)
185
186      return `${cut(one.goal === '' ? `run ${one.id}` : one.goal, 16)} ${counts.failed > 0 ? `${ICON.failed} ` : ''}${counts.done}/${counts.tasks}`
187    })
188    .join(' · ')}`
189}
190
hooks/kit/layout.ts 24 lines
1// Shared by the lens, parked and ledger mods. The source is the mod-kit folder;
2// each mod carries a copy under hooks/kit, written there by sync.sh. Edit the
3// source and sync, never a copy.
4
5// The cells a pane leaves clear at each side.
6export const PAD = 2
7
8// Text no longer than `most`, an ellipsis standing for what was cut.
9export const cut = (text: string, most: number): string =>
10  text.length > most ? `${text.slice(0, Math.max(0, most - 1))}…` : text
11
12// How long ago `then` was, as one short word: minutes, hours, then days.
13export const ageOf = (then: number, now: number): string => {
14  const minutes = Math.max(0, Math.round((now - then) / 60000))
15
16  if (minutes < 60) {
17    return `${minutes}m`
18  }
19
20  const hours = Math.round(minutes / 60)
21
22  return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`
23}
24
hooks/run.ts 467 lines
1// A run's logic, free of the engine: every change takes a run and answers the
2// next one, or a string saying why it was refused.
3
4import type {
5  LedgerFinding,
6  LedgerRun,
7  LedgerSeverity,
8  LedgerSpawn,
9  LedgerTask,
10  LedgerTaskStatus,
11  LedgerUnitNote,
12} from '../types'
13import { ICON } from './kit/icons'
14import { cut } from './kit/layout'
15
16export type NewTask = {
17  id: string
18  lane?: string
19  unit?: string
20  title: string
21  about?: string
22  tier?: string
23  check?: string
24}
25
26export type NewFinding = {
27  path: string
28  line?: number
29  severity: LedgerSeverity
30  summary: string
31  task?: string
32  agentId?: string
33}
34
35export const SEVERITIES: readonly LedgerSeverity[] = ['error', 'warning', 'note']
36export const STATUSES: readonly LedgerTaskStatus[] = ['todo', 'running', 'gate-failed', 'done']
37
38export const isRun = (value: unknown): value is LedgerRun =>
39  typeof value === 'object' &&
40  value !== null &&
41  typeof (value as LedgerRun).goal === 'string' &&
42  Array.isArray((value as LedgerRun).tasks) &&
43  Array.isArray((value as LedgerRun).spawns) &&
44  Array.isArray((value as LedgerRun).findings)
45
46// A new run from the settled plan. A task with no unit is in the unit "main".
47export const plan = (
48  goal: string,
49  tasks: readonly NewTask[],
50  now: number,
51  sessionId?: string,
52  units: readonly LedgerUnitNote[] = [],
53): LedgerRun | string => {
54  if (tasks.length === 0) {
55    return 'A plan needs at least one task.'
56  }
57
58  const ids = tasks.map(one => one.id.trim())
59  const twice = ids.find((id, at) => id === '' || ids.indexOf(id) !== at)
60
61  if (twice !== undefined) {
62    return twice === '' ? 'Every task needs an id.' : `Task id ${twice} is used twice.`
63  }
64
65  // What each unit is for, for the units the plan has tasks in.
66  const planned = new Set(tasks.map(one => one.unit?.trim() || 'main'))
67  const described = units
68    .map(one => ({ name: one.name.trim(), about: one.about.trim() }))
69    .filter(one => planned.has(one.name) && one.about !== '')
70
71  return {
72    id: 0,
73    goal: goal.trim(),
74    startedAt: now,
75    ...(sessionId !== undefined ? { sessionId } : {}),
76    ...(described.length > 0 ? { units: described } : {}),
77    tasks: tasks.map(one => ({
78      id: one.id.trim(),
79      ...(one.lane?.trim() ? { lane: one.lane.trim() } : {}),
80      unit: one.unit?.trim() || 'main',
81      title: one.title.trim(),
82      ...(one.tier ? { tier: one.tier.trim() } : {}),
83      ...(one.about?.trim() ? { about: one.about.trim() } : {}),
84      ...(one.check ? { check: one.check.trim() } : {}),
85      status: 'todo' as const,
86    })),
87    spawns: [],
88    findings: [],
89    nextFinding: 1,
90  }
91}
92
93export const setTask = (
94  run: LedgerRun,
95  id: string,
96  status: LedgerTaskStatus,
97  note?: string,
98  outcome?: string,
99): LedgerRun | string =>
100  run.tasks.some(one => one.id === id)
101    ? {
102        ...run,
103        tasks: run.tasks.map(one => {
104          if (one.id !== id) {
105            return one
106          }
107
108          const { note: _n, ...rest } = one
109
110          return {
111            ...rest,
112            status,
113            ...(note?.trim() ? { note: note.trim() } : {}),
114            // What was done stands until it is said again.
115            ...(outcome?.trim() ? { outcome: outcome.trim() } : {}),
116          }
117        }),
118      }
119    : `No task ${id}. The tasks are ${run.tasks.map(one => one.id).join(', ')}.`
120
121const escaped = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
122
123// The task a subagent was spawned for: the one its description names in
124// brackets ("[T3] add the retry"), else the one named there as a word of its
125// own, else the one its prompt names in brackets.
126//
127// Where several are named, the one named first in the text wins, and of two
128// starting at one place the longer id (T10 over T1, 1.2 over 1).
129export const taskOf = (run: LedgerRun, description: string, prompt = ''): string | undefined => {
130  const named = (text: string, isBare: boolean) =>
131    run.tasks
132      .flatMap(one => {
133        const hit = new RegExp(
134          isBare ? `(?<![A-Za-z0-9_])${escaped(one.id)}(?![A-Za-z0-9_])` : `\\[${escaped(one.id)}\\]`,
135        ).exec(text)
136
137        return hit === null ? [] : [{ id: one.id, at: hit.index }]
138      })
139      .sort((a, b) => a.at - b.at || b.id.length - a.id.length)[0]?.id
140
141  // A run read from a plan document numbers its tasks T1, T2, ...: there a
142  // description that says "Task 3", as the document does, names T3.
143  const spoken =
144    run.doc === undefined
145      ? undefined
146      : run.tasks.find(one =>
147          new RegExp(`(?<![A-Za-z0-9_])Task\\s+${escaped(one.id.replace(/^T/, ''))}(?![A-Za-z0-9_.-])`, 'i').test(
148            description,
149          ),
150        )?.id
151
152  return named(description, false) ?? named(description, true) ?? spoken ?? named(prompt, false)
153}
154
155// Records a subagent as started. Its task, when still to do, is now running.
156// One recorded before keeps the tokens it had used.
157export const spawned = (run: LedgerRun, spawn: LedgerSpawn): LedgerRun => {
158  const before = run.spawns.find(one => one.agentId === spawn.agentId)?.tokens
159
160  return {
161    ...run,
162    tasks: run.tasks.map(one =>
163      one.id === spawn.task && one.status === 'todo' ? { ...one, status: 'running' as const } : one,
164    ),
165    spawns: [
166      ...run.spawns.filter(one => one.agentId !== spawn.agentId),
167      { ...spawn, ...(before !== undefined ? { tokens: before + (spawn.tokens ?? 0) } : {}) },
168    ],
169  }
170}
171
172// A subagent that had ended is at work again (it was sent a message): it
173// counts as running until its next turn ends.
174export const resumed = (run: LedgerRun, agentId: string): LedgerRun => ({
175  ...run,
176  spawns: run.spawns.map(one => {
177    if (one.agentId !== agentId) {
178      return one
179    }
180
181    const { endedAt: _e, reason: _r, ...rest } = one
182
183    return rest
184  }),
185})
186
187// A run another session left: the subagents it still shows running ended with
188// that session.
189export const orphaned = (run: LedgerRun, now: number): LedgerRun => ({
190  ...run,
191  spawns: run.spawns.map(one =>
192    one.endedAt === undefined ? { ...one, endedAt: now, reason: 'session ended' } : one,
193  ),
194})
195
196// Records a subagent's turn as over; a run that never saw it start is unchanged.
197export const ended = (
198  run: LedgerRun,
199  agentId: string,
200  reason: string,
201  tokens: number | undefined,
202  now: number,
203): LedgerRun => ({
204  ...run,
205  spawns: run.spawns.map(one =>
206    one.agentId === agentId
207      ? { ...one, endedAt: now, reason, ...(tokens !== undefined ? { tokens: (one.tokens ?? 0) + tokens } : {}) }
208      : one,
209  ),
210})
211
212export const found = (run: LedgerRun, finding: NewFinding, now: number): LedgerRun => ({
213  ...run,
214  nextFinding: run.nextFinding + 1,
215  findings: [
216    ...run.findings,
217    {
218      id: run.nextFinding,
219      path: finding.path.trim(),
220      ...(finding.line !== undefined ? { line: finding.line } : {}),
221      severity: finding.severity,
222      summary: finding.summary.trim(),
223      ...(finding.task !== undefined ? { task: finding.task } : {}),
224      ...(finding.agentId !== undefined ? { agentId: finding.agentId } : {}),
225      at: now,
226      status: 'open',
227    },
228  ],
229})
230
231export const fixed = (run: LedgerRun, ids: readonly number[]): LedgerRun | string => {
232  const missing = ids.find(id => !run.findings.some(one => one.id === id))
233
234  return missing !== undefined
235    ? `No finding #${missing}.`
236    : {
237        ...run,
238        findings: run.findings.map(one => (ids.includes(one.id) ? { ...one, status: 'fixed' as const } : one)),
239      }
240}
241
242export type LedgerUnit = { name: string; lane?: string; about?: string; tasks: LedgerTask[] }
243
244// The units in the order the plan first names them. Two lanes may each have a
245// unit of one name: they are two units.
246export const unitsOf = (run: LedgerRun): LedgerUnit[] =>
247  [...new Set(run.tasks.map(one => `${one.lane ?? ''}\n${one.unit}`))].map(key => {
248    const [lane = '', name = ''] = key.split('\n')
249    const about = run.units?.find(one => one.name === name)?.about
250
251    return {
252      name,
253      ...(lane !== '' ? { lane } : {}),
254      ...(about !== undefined ? { about } : {}),
255      tasks: run.tasks.filter(one => (one.lane ?? '') === lane && one.unit === name),
256    }
257  })
258
259export type LedgerLane = {
260  // '' for the tasks of a run that names no lane for them.
261  name: string
262  units: LedgerUnit[]
263  tasks: number
264  done: number
265  failed: number
266  // The subagents running on this lane's tasks.
267  agents: number
268}
269
270// Whether the plan split the run into lanes: parallel tracks of one feature.
271export const hasLanes = (run: LedgerRun): boolean => run.tasks.some(one => one.lane !== undefined)
272
273// The run's lanes in the order the plan first names them. A run with no lanes
274// is one lane with no name.
275export const lanesOf = (run: LedgerRun): LedgerLane[] => {
276  const units = unitsOf(run)
277
278  return [...new Set(units.map(one => one.lane ?? ''))].map(name => {
279    const mine = units.filter(one => (one.lane ?? '') === name)
280    const tasks = mine.flatMap(one => one.tasks)
281    const ids = new Set(tasks.map(one => one.id))
282
283    return {
284      name,
285      units: mine,
286      tasks: tasks.length,
287      done: tasks.filter(one => one.status === 'done').length,
288      failed: tasks.filter(one => one.status === 'gate-failed').length,
289      agents: run.spawns.filter(
290        one => one.endedAt === undefined && one.task !== undefined && ids.has(one.task),
291      ).length,
292    }
293  })
294}
295
296export type LedgerCounts = {
297  units: number
298  // The unit work is on, counted from 1: the first with a task not done; the
299  // last unit once every task is done.
300  unitAt: number
301  unitName: string
302  tasks: number
303  done: number
304  failed: number
305  running: number
306  agents: number
307  openFindings: number
308  errors: number
309  tokens: number
310}
311
312export const countsOf = (run: LedgerRun): LedgerCounts => {
313  const units = unitsOf(run)
314  const at = units.findIndex(unit => unit.tasks.some(one => one.status !== 'done'))
315  const here = at === -1 ? units.length - 1 : at
316  const open = run.findings.filter(one => one.status === 'open')
317
318  return {
319    units: units.length,
320    unitAt: here + 1,
321    unitName: units[here]?.name ?? '',
322    tasks: run.tasks.length,
323    done: run.tasks.filter(one => one.status === 'done').length,
324    failed: run.tasks.filter(one => one.status === 'gate-failed').length,
325    running: run.tasks.filter(one => one.status === 'running').length,
326    agents: run.spawns.filter(one => one.endedAt === undefined).length,
327    openFindings: open.length,
328    errors: open.filter(one => one.severity === 'error').length,
329    tokens: run.spawns.reduce((sum, one) => sum + (one.tokens ?? 0), 0),
330  }
331}
332
333export const isDone = (run: LedgerRun): boolean => run.tasks.every(one => one.status === 'done')
334
335const few = (count: number, one: string, many = `${one}s`) => `${count} ${count === 1 ? one : many}`
336
337export const tokensText = (tokens: number): string =>
338  tokens >= 999_500
339    ? `${(tokens / 1_000_000).toFixed(1)}M`
340    : tokens >= 1000
341      ? `${Math.round(tokens / 1000)}k`
342      : String(tokens)
343
344// The status line's entry. Undefined once the run has nothing left to say:
345// every task done, no agent running and no finding open.
346export const statusText = (run: LedgerRun): string | undefined => {
347  const counts = countsOf(run)
348
349  if (isDone(run) && counts.agents === 0 && counts.openFindings === 0) {
350    return undefined
351  }
352
353  // A run in lanes says how each lane stands, a failed gate marked on its lane.
354  if (hasLanes(run) && !isDone(run)) {
355    return [
356      `${ICON.tasks} ${lanesOf(run)
357        .map(
358          lane =>
359            `${lane.name === '' ? 'other' : lane.name} ${lane.failed > 0 ? `${ICON.failed} ` : ''}${lane.done}/${lane.tasks}`,
360        )
361        .join(' · ')}`,
362      counts.agents > 0 ? `${ICON.agents} ${counts.agents}` : '',
363      counts.openFindings > 0 ? `${ICON.finding} ${counts.openFindings}` : '',
364    ]
365      .filter(Boolean)
366      .join(' · ')
367  }
368
369  return [
370    `${ICON.tasks} ${
371      isDone(run)
372        ? `all ${few(counts.tasks, 'task')} done`
373        : `unit ${counts.unitAt}/${counts.units} ${counts.unitName} · ${counts.done}/${counts.tasks} tasks`
374    }`,
375    counts.failed > 0 ? `${ICON.failed} ${few(counts.failed, 'gate')} failed` : '',
376    counts.agents > 0 ? `${ICON.agents} ${few(counts.agents, 'agent')} running` : '',
377    counts.openFindings > 0
378      ? `${ICON.finding} ${few(counts.openFindings, 'finding')}${counts.errors > 0 ? ` (${few(counts.errors, 'error')})` : ''}`
379      : '',
380  ]
381    .filter(Boolean)
382    .join(' · ')
383}
384
385const MARKS: Record<LedgerTaskStatus, string> = {
386  todo: ICON.todo,
387  running: ICON.running,
388  'gate-failed': ICON.failed,
389  done: ICON.done,
390}
391
392export const placeOf = (finding: LedgerFinding): string =>
393  finding.line === undefined ? finding.path : `${finding.path}:${finding.line}`
394
395// A finding as the prompt takes it: where it is, then what is wrong.
396export const findingText = (finding: LedgerFinding): string =>
397  `Finding #${finding.id} (${finding.severity}) ${placeOf(finding)}: ${finding.summary}`
398
399const spanText = (ms: number): string => {
400  const seconds = Math.max(0, Math.round(ms / 1000))
401
402  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
403}
404
405// What the pane shows beside a subagent: its model, how long it ran (or has
406// been running), and the tokens it used.
407export const spawnMeta = (spawn: LedgerSpawn, now: number): string =>
408  [
409    spawn.model,
410    spanText((spawn.endedAt ?? now) - spawn.startedAt),
411    spawn.tokens !== undefined ? tokensText(spawn.tokens) : '',
412  ]
413    .filter(Boolean)
414    .join(' · ')
415
416// The run as text: what /ledger print shows and what the model reads back.
417// A task's steps done of all, where its run is read from a plan document: ''
418// for a task with no steps.
419export const stepsText = (task: LedgerTask): string =>
420  task.steps === undefined || task.steps.length === 0
421    ? ''
422    : `${task.steps.filter(one => one.isDone).length}/${task.steps.length}`
423
424const laneDone = (run: LedgerRun, name: string): string => {
425  const lane = lanesOf(run).find(one => one.name === name)
426
427  return `${lane?.done ?? 0}/${lane?.tasks ?? 0} done`
428}
429
430export const report = (run: LedgerRun, isPlain = false): string => {
431  const counts = countsOf(run)
432  const open = run.findings.filter(one => one.status === 'open')
433  const mark = (status: LedgerTaskStatus) => (isPlain ? `[${status}]` : MARKS[status])
434
435  return [
436    `Ledger: ${run.goal === '' ? '(no goal given)' : run.goal}`,
437    ...(run.doc !== undefined ? [`Plan: ${run.doc.path}`] : []),
438    ...unitsOf(run).flatMap((unit, at, units) => [
439      // A lane's heading comes before its first unit.
440      ...(unit.lane !== undefined && units[at - 1]?.lane !== unit.lane
441        ? [`Lane ${unit.lane} (${laneDone(run, unit.lane)})`]
442        : []),
443      `${unit.name} (${unit.tasks.filter(one => one.status === 'done').length}/${unit.tasks.length} done)`,
444      ...(unit.about !== undefined ? [`  ${unit.about}`] : []),
445      ...unit.tasks.flatMap(one => [
446        `  ${mark(one.status)} ${one.id}${one.tier ? ` (${one.tier})` : ''} ${one.title}${stepsText(one) === '' ? '' : ` [${stepsText(one)}]`}${one.note ? `: ${cut(one.note, 160)}` : ''}`,
447        // Once it is done, what was done says more than what it was for.
448        ...(one.outcome !== undefined
449          ? [`      Outcome: ${cut(one.outcome, 300)}`]
450          : one.about !== undefined
451            ? [`      What it does: ${cut(one.about, 300)}`]
452            : []),
453      ]),
454    ]),
455    run.spawns.length > 0
456      ? `Agents: ${counts.agents} running, ${run.spawns.length - counts.agents} finished, ${tokensText(counts.tokens)} tokens`
457      : '',
458    open.length > 0 ? `Findings: ${open.length} open, ${run.findings.length - open.length} fixed` : '',
459    ...open.map(
460      one =>
461        `  #${one.id} ${one.severity} ${placeOf(one)} ${cut(one.summary, 200)}${one.task ? ` (${one.task})` : ''}`,
462    ),
463  ]
464    .filter(Boolean)
465    .join('\n')
466}
467
types/index.d.ts 115 lines
1// The ledger's contract. Another mod reads a run with
2// `$.state.get({ plugin: 'ledger', key: 'run' })`; only the ledger writes it.
3
4export type LedgerTaskStatus = 'todo' | 'running' | 'gate-failed' | 'done'
5
6export type LedgerTask = {
7  id: string
8  // The lane the task is in, where the plan has parallel lanes: tracks of one
9  // feature that can be worked on at once.
10  lane?: string
11  // The unit of work the task belongs to; tasks of one unit are reviewed together.
12  unit: string
13  title: string
14  // The model the task is meant for: haiku, sonnet or opus.
15  tier?: string
16  // What the task is for, in a line: what it changes for the user, or which
17  // part of the code it touches. Written when the run is planned.
18  about?: string
19  // How the task is known to be done: what its gate checks.
20  check?: string
21  status: LedgerTaskStatus
22  // The last gate's verdict, or how the task ended.
23  note?: string
24  // What was done, once it is: what changed and where. The agent's own
25  // account, not a checked one.
26  outcome?: string
27  // The checkbox steps of a task read from a plan document, in its order.
28  steps?: LedgerStep[]
29}
30
31export type LedgerStep = { text: string; isDone: boolean }
32
33// A unit of the plan, where the plan describes it: what its tasks add up to.
34export type LedgerUnitNote = { name: string; about: string }
35
36export type LedgerSpawn = {
37  agentId: string
38  // The task the subagent was spawned for, when its description names one.
39  task?: string
40  description: string
41  // The agent type, and the model it ran on.
42  type: string
43  model: string
44  startedAt: number
45  // Set once its turn completed: when, why it ended, and the tokens it used.
46  endedAt?: number
47  reason?: string
48  tokens?: number
49}
50
51export type LedgerSeverity = 'error' | 'warning' | 'note'
52
53export type LedgerFinding = {
54  id: number
55  path: string
56  line?: number
57  severity: LedgerSeverity
58  summary: string
59  task?: string
60  // The subagent that reported it; absent when the main agent did.
61  agentId?: string
62  at: number
63  status: 'open' | 'fixed'
64}
65
66export type LedgerRun = {
67  // Its number among the project's runs; 0 until the ledger has kept it.
68  id: number
69  goal: string
70  startedAt: number
71  // The session that planned the run; another session finds its agents ended.
72  sessionId?: string
73  // When the run was done: by itself once every task is done with no agent
74  // running and no finding open, or when the user closed it (`isClosed`).
75  doneAt?: number
76  isClosed?: true
77  // The plan document the run is read from, where it is: the document is then
78  // the word on its tasks and steps. `isLaidOutByModel` says a small model
79  // grouped its lines, the document not being laid out as superpowers plans are.
80  doc?: { path: string; isLaidOutByModel?: true }
81  tasks: LedgerTask[]
82  // What each unit is for, for the units the plan described.
83  units?: LedgerUnitNote[]
84  spawns: LedgerSpawn[]
85  findings: LedgerFinding[]
86  nextFinding: number
87}
88
89declare module 'claude-code' {
90  interface PluginState {
91    ledger: {
92      run: LedgerRun | null
93      // Every run kept for the project, the newest last; `run` is the current
94      // one, which the tools record against.
95      runs: LedgerRun[]
96      isHidden: boolean
97      // The pane: the run it shows in full (0 for the list), the list's row the
98      // focus is on, whether done runs are listed, whether a run shows its
99      // details, the run a clear is waiting to be confirmed for, and the frame
100      // of the mark that turns while a subagent runs.
101      shown: number
102      cursor: number
103      isAll: boolean
104      isDetailed: boolean
105      confirming: number
106      spin: number
107      // The finding the focus is on in a run's view, and the place last asked
108      // to be shown, which the lens mod opens where it is loaded: a path from
109      // the session's folder, a line (0 for the file), and a count of the asks.
110      pointed: number
111      jump: { path: string; line: number; n: number } | null
112    }
113  }
114}
115