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…

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.
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.
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.[T3] add the retry) is recorded against that task, which is marked running.task with done, or gate-failed and a note.finding with a path, a line, a severity and a one-line summary. fixed closes them./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
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.
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.
### 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.[ ] becomes [x], only under a done task.Two settings in /config, the first off by default:
| Setting | Does |
|---|---|
| Follow superpowers plans | a plan saved under docs/superpowers/plans starts a run by itself |
| Tick finished tasks in the plan | marking 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.
/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.
| Key | Does |
|---|---|
1–9, Enter | open that run |
d | close the focused run by hand, or reopen one closed that way |
x | clear the focused run for good, after asking |
a | list 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.
| Key | Does |
|---|---|
e | details: what each unit and task is for, what was done, tier, agents and tokens, and fixed findings |
1–9 | add that finding to the prompt, never sending it |
b | back to the list |
Esc | close |
A timer runs only while a subagent does, to turn that mark.
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.
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.hooks/register.tsx 1379 lines1import { 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 lines1// 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}
214hooks/kit/icons.ts 60 lines1// 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+)?$/, ''))
60hooks/book.ts 190 lines1// 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}
190hooks/kit/layout.ts 24 lines1// 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}
24hooks/run.ts 467 lines1// 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}
467types/index.d.ts 115 lines1// 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