Design, execute and fanout runs as Claude Code workflows, with a mod that records every step, runs verify itself and lands only verified trees

<img src="./assets/logo.png" alt="Claude Litter" width="320" /> <em>Verified multi-agent runs for Claude Code</em>
<a href="https://github.com/Und3rf10w/claude-litter/releases"><img src="https://img.shields.io/badge/version-4.0.0-22c55e?style=flat-square" alt="Version 4.0.0" /></a> <a href="https://code.claude.com/"><img src="https://img.shields.io/badge/Claude_Code-2.1.288%2B-orange?style=flat-square" alt="Claude Code 2.1.288+" /></a>
Claude Litter is a plugin marketplace built around litter. litter runs multi-agent work as Claude Code workflows: adversarial designs, gate-by-gate plan executions, parallel task fan-outs, and hierarchical agent swarms led from your session. A hooks module (a mod) runs beside them: it records every step to an append-only ledger, runs your verify command itself, holds each agent to its role's files and tools, and lands only trees that passed verification and independent review.
litter replaces this repository's earlier plugins (swarm-loop, deepwork, and team-overlord) and its standalone Python TUI. The last release containing them is tagged legacy-3.8.
/plugin install litter --marketplace Und3rf10w/claude-litter
Or add the marketplace first, then install from it:
/plugin marketplace add Und3rf10w/claude-litter
/plugin install litter@claude-litter
litter requires:
2.1.288 or later;git;jq on PATH (for the command hooks);sh.Runs start from a clean, committed working tree in auto or default permission mode. The mod refuses to launch under bypass-permissions or accept-edits.
| Command | What runs | ||||
|---|---|---|---|---|---|
/litter:design <problem> | Independent investigators (falsifier, coverage, mechanism), an isolated prober, a reframer that challenges the goal, an author, and a design-critic with the only veto iterate against a written bar agreed first. An approved plan comes back for review in plan mode. | ||||
| `/litter:execute <plan.md \ | goal>` | Implements an approved plan gate by gate (or turns a goal into gates first via litter:plan-run). An independent gate-critic must pass each gate, and the mod commits it only after its own run of the verify command passes on that exact tree. | |||
/litter:fanout <tasks> | Runs independent tasks in parallel, with each writer task in its own git worktree. A verifier checks each task, and the mod merges the branches and verifies the combined tree before anything lands. | ||||
/litter:amend <gate-id> <change> | Reworks one gate of an execute plan with a stated change in a single-gate background run, judged by the same independent critic and verified by the mod. | ||||
/litter:swarm <description> | Drafts, starts, leads, and ends a tree of agents where orchestrators each own a scoped part of the work and delegate to their own workers. The swarm is drafted into the swarm builder and starts when you press Launch there. | ||||
| `/litter:rules list \ | add \ | show \ | retire \ | import` | Manages standing rules and bans that litter agents receive wherever they apply (user, project, and local scopes). Rules are accepted in the Rules pane, can be imported from deepwork guardrails, and export as Claude Code rule files. |
/litter:wiki | Rebuilds .claude/litter/WIKI.md, an index of every litter run and swarm in the repository and the decisions they produced. |
workflows/*.js)**: design-run, plan-run, execute-run, and fanout-run execute as deterministic Claude Code Workflow scripts using structured output schemas and fresh context per agent call.hooks/register.tsx): The single writer for .claude/litter/. It validates workflow launches against recorded run.start specs, runs verify itself as an argument vector (never a shell string), snapshots the git tree before and after verification, overwrites test claims with observed exit codes, and commits only the exact verified tree on land.hooks/*.sh)**: POSIX sh hooks enforce per-role and per-swarm-node file ownership, read-only vs. read-write Bash policies, and worktree setup or directory linking.repair): With repair set to auto (the default), a failed fanout, or an execute run that halted on a plan gap or ran out of attempts, continues as a bounded repair swarm and lands only verified, independently approved work. With off, the run stops, preserves all state and worktrees, and offers continuation routes./litter pane/litter [section] [arg] opens litter's in-session UI pane with eight sections:
user, project, and local rulesctrl+x tab focuses the pane, 1–8 switch sections, and esc returns focus to the prompt.
litter keeps runtime state under .claude/litter/ in your repository's main checkout (each run's plan, ledger, and summary, swarm state, and hook state). Project rules live in .litter/rules/ so they can be committed and shared.
Configure litter via /plugin or under pluginConfigs.litter.options in a Claude Code settings file (userConfig in plugins/litter/.claude-plugin/plugin.json):
| Option | Default | Description |
|---|---|---|
verify | — | Command that decides whether a gate is done, as an argument list with no shell string (e.g. ["uv", "run", "pytest", "tests/", "-q"]). litter runs it directly and records the exit code. |
goalCheck | — | Optional argument list that checks a goal-mode run's goal at the end. Usually set per run at launch. |
setup | — | Optional argument list run in the root of each fresh worktree to install dependencies (e.g. ["npm", "ci"]). Must be idempotent. |
linkDirs | — | Optional ignored directories (e.g. ["node_modules"]) symlinked from your checkout into each fresh worktree instead of running setup. |
verifyTimeoutMs | 1800000 | Maximum duration (ms) for a verify, goalCheck, or setup command before litter terminates it and records a timeout. |
maxAttempts | 3 | Maximum executor attempts per gate (1–10) before an execute run halts. |
maxRounds | 4 | Maximum rounds (1–10) a design run executes before stopping for you. |
summaryModel | "haiku" | Model used for the swarm browser's generated summary ("off" disables it). |
summaryEverySec | 60 | Minimum interval in seconds (>= 10) between generated swarm summaries. |
repair | "auto" | "auto" continues a failed fanout or halted execute run as a bounded repair swarm; "off" stops, keeps all state, and offers routes. |
repairRounds | 3 | Maximum verified and independently judged repairs (1–10) a repair swarm may run before asking for more budget. |
repairAgents | 6 | Maximum total workers and helpers (1–50) a repair swarm's nodes may spawn before asking for more budget. |
widen | "ask" | Who may add files outside a gate's Files list during an execute run: "ask" (only you, via a dialog) or "critic" (the gate's critic may also accept required files, listed in the run summary). |
trailers | — | Optional commit trailers (Key: value) appended to every commit litter creates. |
bandEndedMinutes | 10 | Minutes (0–1440) an ended run remains visible in the status band above the prompt (0 removes it immediately). |
cd plugins/litter
claude plugin validate --strict .
tests/unit/node_modules/.bin/tsc -p . # type-check the mod (after `bun install` in tests/unit)
tests/unit/node_modules/.bin/tsc -p tests/unit # type-check the unit specs
claude plugin test . # tier 1: the engine's plugin tests
(cd tests/unit && bun install && bun run test) # tier 2: bun specs
sh tests/hooks/run.sh # tier 3: hook scripts (also SHELL_UNDER_TEST=dash)
sh tests/headless/run.sh # tier 4: live sessions; spends tokens, run by hand
To run Claude Code with your working copy of litter loaded, use claude --plugin-dir plugins/litter.
litter runs on.hooks/register.tsx 639 lines1// litter's hooks module: wiring only. Every `on(...)` the mod makes is here, so
2// `claude plugin validate` and a reader see each event and matcher in one file.
3//
4// Rules for this file (plan sections 4 and 12):
5// - every tool.call hook has a literal `tool` matcher from the allowlist:
6// Workflow, TaskStop and litter's own mcp__litter__* tools. Never a broad
7// or RegExp tool.call hook: one would break every isolated agent it matches;
8// - every hook has a .catch. A policy hook's catch refuses what's litter's
9// and passes the rest on; any other hook's catch passes on;
10// - this is the only file that touches `$`. Validate follows `$` into a
11// function declared here but never across an import, so each hook is an
12// inline literal that builds the ports and calls its module's handler;
13// - no logic: each handler lives in its own module;
14// - every hook but session.start, session.end and the ui.render hooks
15// awaits O's `rebound` first, so the first hook after a /clear rebinds the
16// session (plan 15.4). A render can't: a rebind writes $.state. One
17// exception within agent.spawn: a teammate litter has no stake in passes
18// before it (spawn.ts, outsideTeammate).
19//
20// Contract owner: phase 1a; the swarm wiring is phase 2b-1's (package C), the
21// repair wiring phase 2b-2's (package V), the UI wiring phase 5's (WP-CA),
22// the one pane's phase 5b's (WP-DA).
23
24import { atom, read, update } from 'claude-code'
25import type { EngineInterface, Register, ToolCallArgs } from 'claude-code'
26import type {
27 AgentsPaneView,
28 BandView,
29 BoardView,
30 CardsView,
31 DoctorPaneView,
32 HubView,
33 Ports,
34 RulesPaneView,
35 SpawnStream,
36 Stat,
37 SwarmPaneView,
38 StatsPaneView,
39 TasksPaneView,
40} from './ports'
41import type { HubSection } from '../types/index'
42import type { SectionView } from './ui/pane'
43import type { BandRenderCatch, CardRenderCatch } from './hooktypes'
44import { getOptions, session, setOptions, updateSession } from './session'
45import { SPOOL_CONSUMERS } from './registry'
46import { recordFailed, recordTool, registerRecordTool } from './ledger'
47import { claudeLedgerOwner } from './ledgerowner'
48import { drainAllSpools, drainOwnSpools, startDrainTimer, stopInterrupted } from './policy'
49import { activePath } from './layout'
50import { registerRoles } from './roles'
51import { resolveLitterDir, writeGitignore } from './litterdir'
52import { scheduleSync } from './rules/export'
53import { onSessionEnd, onTurnComplete, reconcileRuns, settleNotices } from './lifecycle'
54import { repairNote, repairTurnComplete } from './repair'
55import { cleanupLeftovers, startWorktreePolicy } from './worktrees'
56import { outsideWorkflow, roleSpawnRefusal, taskStop, taskStopFailed, workflowCheck, workflowCheckFailed } from './launch'
57import { holdClear, noteSessionEnd, rebound, startSwarmTimers } from './clear'
58import { addLeadTokens, attach, binding, notePromptOrigin, setInteractive, setRecordedSession, withGateClosed } from './swarm/state'
59import { measureSpend, noteUsage } from './stats/spend'
60import { statsMeasured } from './stats/pane'
61import { registerSwarmTools } from './swarm/tools'
62import { clearPending } from './swarm/guard'
63import { recoverSwarms } from './swarm/recover'
64import { swarmReadTool } from './swarm/launch'
65import { swarmProposeTool } from './swarm/propose'
66import { outsideTeammate, swarmSpawn } from './swarm/spawn'
67import { admitSend, admitStep, leadTurnStart, resumeSpawn, settleNode, swarmContext, swarmSendTool } from './swarm/admit'
68import { observeNodeTurn } from './swarm/observe'
69import { sweepNodePolicies } from './swarm/end'
70import { startPaneTimer } from './swarm/panes'
71import {
72 agentOfferFailed,
73 agentSpawnFailed,
74 clearHoldFailed,
75 litterCommandFailed,
76 offerSwarmType,
77 paneRenderFailed,
78 sessionSendFailed,
79 swarmProposeFailed,
80 swarmReadFailed,
81 swarmSendFailed,
82 turnStartFailed,
83 turnStepFailed,
84 uiFocusFailed,
85 uiInputFailed,
86 uiMessageFailed,
87 uiPressFailed,
88 uiScrollFailed,
89 uiSelectFailed,
90} from './swarm/wiring'
91import { litterAutocomplete, litterSuggestions, registerLitterCommand } from './uicommands'
92import { litterCommand, onLitterUi } from './ui/hub'
93import { renderLitterPane, sectionShown } from './ui/pane'
94import { startUiTimer } from './uitick'
95import { restoreAsks } from './asks'
96import { bandShows, onBandUi } from './band/view'
97import { renderBand } from './band/render'
98import { cardRow, noteCards, onCardUi, restoreCards } from './card/view'
99import { cardOf, renderCard } from './card/render'
100
101/**
102 * The views (types/index.d.ts), written through `p.setView`: the `litter`
103 * pane's sections' (package N's swarm and doctor, phase 3's board and rules
104 * (AF), phase 5's tasks (CE) and agents (CG)), and phase 5's band (CB) and
105 * cards (CC).
106 */
107const swarmPane = atom({ plugin: 'litter', key: 'swarmPane' } as const, null)
108const doctorPane = atom({ plugin: 'litter', key: 'doctorPane' } as const, null)
109const board = atom({ plugin: 'litter', key: 'board' } as const, null)
110const rulesPane = atom({ plugin: 'litter', key: 'rulesPane' } as const, null)
111const band = atom({ plugin: 'litter', key: 'band' } as const, null)
112const cards = atom({ plugin: 'litter', key: 'cards' } as const, null)
113const tasksPane = atom({ plugin: 'litter', key: 'tasksPane' } as const, null)
114const agentsPane = atom({ plugin: 'litter', key: 'agentsPane' } as const, null)
115/** Phase 5b's (WP-DA): the `litter` pane's own view, its section and strip (ui/hub.ts). */
116const hub = atom({ plugin: 'litter', key: 'hub' } as const, null)
117/** Phase 5b's (WP-DH): the Stats section. */
118const statsPane = atom({ plugin: 'litter', key: 'statsPane' } as const, null)
119
120const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
121
122/** The shown section's view, from that section's atom alone (so a write to another doesn't redraw the pane); Home has none. */
123async function sectionView($: EngineInterface, section: HubSection): Promise<SectionView | null> {
124 if (section === 'runs') return read($, board)
125 if (section === 'swarms') return read($, swarmPane)
126 if (section === 'rules') return read($, rulesPane)
127 if (section === 'tasks') return read($, tasksPane)
128 if (section === 'agents') return read($, agentsPane)
129 if (section === 'stats') return read($, statsPane)
130 if (section === 'doctor') return read($, doctorPane)
131 return null
132}
133
134/**
135 * The band's and the card's render catches: next(e), as the pane's
136 * paneRenderFailed, which is typed for `Pane` alone and so can't be theirs.
137 */
138const bandRenderFailed: BandRenderCatch = async ($, e, next) => next(e)
139const cardRenderFailed: CardRenderCatch = async ($, e, next) => next(e)
140
141/** The ports over this dispatch's `$` (ports.ts). */
142function enginePorts($: EngineInterface): Ports {
143 return {
144 fs: {
145 read: path => $.fs.read(path),
146 write: (path, text) => $.fs.write(path, text),
147 list: async path => (await $.fs.list(path)).map(e => ({ name: e.name, kind: e.kind, isLink: e.isLink })),
148 exists: path => $.fs.exists(path),
149 stat: async (path, options) => {
150 const s = await $.fs.stat(path, options?.resolve ? { resolve: true } : undefined)
151 const out: Stat = { kind: s.kind, size: s.size, mtimeMs: s.mtimeMs, isLink: s.isLink }
152 if (s.realPath !== undefined) out.realPath = s.realPath
153 return out
154 },
155 },
156 run: async (argv, init) => {
157 const r = await $.process.run(argv, init)
158 return { code: r.exitCode, stdout: r.stdout, stderr: r.stderr, truncated: r.isStdoutTruncated || r.isStderrTruncated }
159 },
160 spawn: request => $.process.spawn(request) as SpawnStream,
161 now: () => $.clock.now(),
162 store: {
163 get: key => $.store.get(key),
164 set: (key, value) => $.store.set(key, value),
165 delete: key => $.store.delete(key),
166 },
167 ledgerOwner: claudeLedgerOwner,
168 sessionId: () => $.session.id(),
169 cwd: () => $.session.cwd(),
170 pluginRoot: $.plugin.root,
171 settings: () => $.settings.read(),
172 // The engine writes `[litter] $.ui.log (to debug): <text>` (2.1.290), so no prefix of litter's own.
173 log: text => $.ui.log(text, { to: 'debug' }),
174 registerTool: async spec => {
175 await $.tool.register(spec)
176 },
177 registerAgent: async spec => {
178 await $.agent.register(spec)
179 },
180 every: (ms, fn) => $.clock.every(ms, fn),
181 ask: (question, options) => $.ui.ask(question, options),
182
183 // Phase 2b, for swarms.
184 listAgents: () => $.agent.list(),
185 send: (to, text) => $.session.send({ to, text }),
186 appendUserRow: async text => {
187 const r = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
188 if (r.deny !== undefined) throw new Error(`the row wasn't appended: ${r.deny}`)
189 },
190 submitPrompt: async text => {
191 const r = await $.prompt.submit({ text })
192 if (r.drop !== undefined) throw new Error(`the prompt was dropped: ${r.drop}`)
193 },
194 fillPrompt: async text => (await $.prompt.fill({ text })).isFilled,
195 messages: async agentId => {
196 if (agentId === undefined) return $.session.messages()
197 const r = await $.session.messages({ agentId })
198 return Array.isArray(r) ? r : null
199 },
200 complete: request => $.model.complete(request),
201 callTool: (tool, input) => $.tool.call({ ...input, tool } as unknown as ToolCallArgs),
202 sleep: ms => $.clock.sleep(ms),
203 after: (ms, fn) => $.clock.after(ms, fn),
204 limits: async () => ({
205 CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH: await $.env.get('CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH'),
206 CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: await $.env.get('CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS'),
207 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY: await $.env.get('CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY'),
208 CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION: await $.env.get('CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION'),
209 }),
210 settingsFrom: async source => {
211 const s = await $.settings.read({ source })
212 return Object.keys(s).length === 0 ? null : s
213 },
214 configDir: async () => {
215 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
216 if (dir !== undefined && dir !== '') {
217 if (!dir.startsWith('/')) throw new Error(`CLAUDE_CONFIG_DIR isn't an absolute path: ${dir}`)
218 return dir.replace(/\/+$/, '')
219 }
220 const home = await $.env.get('HOME')
221 if (home === undefined || !home.startsWith('/')) throw new Error('neither CLAUDE_CONFIG_DIR nor an absolute HOME is set')
222 return `${home.replace(/\/+$/, '')}/.claude`
223 },
224 registerCommand: async spec => {
225 await $.command.register(spec)
226 },
227 openPane: async (id, title, focus) => (await $.ui.open({ id, title, ...(focus === true ? { focus: true } : {}) })).isPlaced,
228 paneOpen: async id => (await $.ui.panes()).some(pane => pane.id === id),
229 closePane: id => $.ui.close({ id }),
230 toast: text => $.ui.toast(text),
231 // `to` left out: the transcript (the d.ts's default).
232 note: text => $.ui.log(text),
233 copy: async text => (await $.ui.copy({ text })).isCopied,
234 setView: async (pane, view) => {
235 if (pane === 'swarm') await update($, swarmPane, () => view as SwarmPaneView | null)
236 else if (pane === 'doctor') await update($, doctorPane, () => view as DoctorPaneView | null)
237 else if (pane === 'board') await update($, board, () => view as BoardView | null)
238 else if (pane === 'rules') await update($, rulesPane, () => view as RulesPaneView | null)
239 else if (pane === 'band') await update($, band, () => view as BandView | null)
240 else if (pane === 'cards') await update($, cards, () => view as CardsView | null)
241 else if (pane === 'tasks') await update($, tasksPane, () => view as TasksPaneView | null)
242 else if (pane === 'hub') await update($, hub, () => view as HubView | null)
243 else if (pane === 'stats') await update($, statsPane, () => view as StatsPaneView | null)
244 else await update($, agentsPane, () => view as AgentsPaneView | null)
245 },
246
247 // Phase 5 (WP-CA).
248 notice: (toolUseId, text) => {
249 // Fire-and-forget in 2.1.289: a refusal is a debug-log warning, never a throw (ports.ts).
250 try {
251 $.ui.notice(toolUseId, text)
252 return true
253 } catch {
254 return false
255 }
256 },
257 toolNames: async () => (await $.tool.list()).map(t => t.name),
258 envVar: async name => (name === 'CLAUDE_CODE_TASK_LIST_ID' ? $.env.get('CLAUDE_CODE_TASK_LIST_ID') : undefined),
259
260 // Phase 5b (WP-DH).
261 usage: () => $.session.usage(),
262 }
263}
264
265/**
266 * Drains this session's spools before a hook that reads what they carry (the
267 * launch check, the task notification), while a run is active. It lives here,
268 * not in launch.ts or lifecycle.ts, because the registry imports those two.
269 */
270async function drainFirst(p: Ports): Promise<void> {
271 const litterDir = session().litterDir
272 if (!litterDir) return
273 try {
274 if (await p.fs.exists(activePath(litterDir))) await drainOwnSpools(p, litterDir, SPOOL_CONSUMERS)
275 } catch (err) {
276 p.log(`drain before the hook failed: ${errText(err)}`)
277 }
278}
279
280/**
281 * A subagent's interrupted turn: its stop, while a run is active, since no
282 * SubagentStop reports it (policy.ts, stopInterrupted). Here for the same
283 * reason as drainFirst.
284 */
285async function interrupted(p: Ports, agentId: string): Promise<void> {
286 const litterDir = session().litterDir
287 if (!litterDir) return
288 try {
289 if (await p.fs.exists(activePath(litterDir))) await stopInterrupted(p, litterDir, agentId, SPOOL_CONSUMERS)
290 } catch (err) {
291 p.log(`interrupted agent ${agentId}: its stop wasn't counted: ${errText(err)}`)
292 }
293}
294
295/** One session.start step: a failure is logged and the next step still runs. */
296async function step(p: Ports, name: string, work: () => Promise<void>): Promise<void> {
297 try {
298 await work()
299 } catch (err) {
300 p.log(`session.start ${name} failed: ${errText(err)}`)
301 }
302}
303
304/** One part of a hook that mustn't stop the parts after it: a failure is logged. */
305async function apart(p: Ports, name: string, work: () => Promise<void>): Promise<void> {
306 try {
307 await work()
308 } catch (err) {
309 p.log(`${name} failed: ${errText(err)}`)
310 }
311}
312
313export const register: Register = (on, options) => {
314 setOptions(options as Readonly<Record<string, unknown>>)
315
316 on('session.start', async ($, e, next) => {
317 const p = enginePorts($)
318 updateSession({ ready: false })
319 // Whether anyone is at the prompt to watch a repair (2b-2, decision 15).
320 if (typeof e.isInteractive === 'boolean') setInteractive(e.isInteractive)
321 // Recovery re-registers the swarms' types while the gate holds sends and
322 // node turns (15.4); it opens right after, not after the slower steps.
323 // withGateClosed opens it once: at openGateNow, or when the steps end if
324 // one threw before that (the gate is a counter).
325 await withGateClosed(async openGateNow => {
326 await step(p, 'tools', async () => {
327 updateSession({ toolsRefused: await registerRecordTool(p) })
328 })
329 await step(p, 'swarm tools', async () => {
330 updateSession({ swarmToolsRefused: await registerSwarmTools(p) })
331 })
332 await step(p, 'roles', async () => {
333 const { failed } = await registerRoles(p)
334 for (const f of failed) p.log(`role ${f.role} not registered: ${f.reason}`)
335 })
336 // Phase 5b (WP-DA): /litter, the one command (it replaced the six pane commands).
337 await step(p, 'litter command', () => registerLitterCommand(p))
338 await step(p, 'litterDir', async () => {
339 const r = await resolveLitterDir(p, e.cwd)
340 updateSession(r.ok ? { litterDir: r.litterDir, litterDirError: null } : { litterDir: null, litterDirError: r.error })
341 })
342 const litterDir = session().litterDir
343 await step(p, 'swarm state', async () => {
344 if (litterDir) attach(p, litterDir)
345 setRecordedSession(await p.sessionId())
346 })
347 if (litterDir) {
348 await step(p, 'swarm pending', () => clearPending(p, litterDir))
349 await step(p, 'swarms', () => recoverSwarms(p, litterDir))
350 }
351 openGateNow()
352 if (litterDir) {
353 // First after the gate, before the session spends more: a restart whose cost ledger began again is told
354 // apart from one that went on (WP-DH).
355 await step(p, 'swarm spend', async () => void (await measureSpend(p)))
356 await step(p, 'worktree.json', () => startWorktreePolicy(p, litterDir))
357 await step(p, '.gitignore', () => writeGitignore(p, litterDir))
358 // Phase 6 (16.7): the rules in force as Claude Code rule files, on a timer, so the start never waits on git.
359 await step(p, 'rule exports', async () => scheduleSync(p, litterDir))
360 await step(p, 'drain', () => drainAllSpools(p, litterDir, SPOOL_CONSUMERS))
361 await step(p, 'reconcile', () => reconcileRuns(p, litterDir))
362 await step(p, 'leftover worktrees', () => cleanupLeftovers(p, litterDir))
363 // After recovery, so policy.json's entries say which swarms are live (FIX-U, U1).
364 await step(p, 'leftover node policies', () => sweepNodePolicies(p, litterDir))
365 }
366 await step(p, 'options', async () => {
367 // The trailers option's lines that aren't `Key: value`, once per session (decision 4).
368 const dropped = getOptions().trailersDropped
369 if (dropped.length > 0) p.log(`the trailers option's lines that aren't one Key: value each were dropped: ${dropped.map(l => JSON.stringify(l.slice(0, 80))).join(', ')}`)
370 })
371 await step(p, 'timers', async () => startDrainTimer(p, SPOOL_CONSUMERS))
372 await step(p, 'swarm timers', async () => startSwarmTimers(p))
373 await step(p, 'pane timer', async () => startPaneTimer(p))
374 // Phase 5 (WP-CA): the open asks and the cards back, and the ui tick (which refreshes the `litter` pane, WP-DA).
375 await step(p, 'asks', () => restoreAsks(p))
376 await step(p, 'cards', () => restoreCards(p))
377 await step(p, 'ui timer', async () => startUiTimer(p))
378 })
379 updateSession({ ready: true })
380 return next(e)
381 }).catch(($, e, next) => next(e))
382
383 // The rebind flag at a /clear (O), then the run lease as before.
384 on('session.end', async ($, e, next) => {
385 const p = enginePorts($)
386 await apart(p, 'session.end swarm', () => noteSessionEnd(p, e))
387 return onSessionEnd(p, e, next)
388 }).catch(($, e, next) => next(e))
389
390 // The record tool, answered in the hook, never through next(e).
391 on('tool.call', { tool: 'mcp__litter__record' }, async ($, e, next) => {
392 const p = enginePorts($)
393 await rebound(p)
394 return recordTool(p, e, next)
395 }).catch(recordFailed)
396
397 // The three swarm tools, answered in their hooks, never through next(e) (plan 15.4).
398 on('tool.call', { tool: 'mcp__litter__swarm_propose' }, async ($, e, next) => {
399 const p = enginePorts($)
400 await rebound(p)
401 return swarmProposeTool(p, e, next)
402 }).catch(swarmProposeFailed)
403
404 on('tool.call', { tool: 'mcp__litter__swarm_read' }, async ($, e, next) => {
405 const p = enginePorts($)
406 await rebound(p)
407 await drainFirst(p)
408 return swarmReadTool(p, e, next)
409 }).catch(swarmReadFailed)
410
411 on('tool.call', { tool: 'mcp__litter__swarm_send' }, async ($, e, next) => {
412 const p = enginePorts($)
413 await rebound(p)
414 await drainFirst(p)
415 return swarmSendTool(p, e, next)
416 }).catch(swarmSendFailed)
417
418 // The launch check (main thread only: Workflow isn't a subagent tool).
419 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
420 const p = enginePorts($)
421 await rebound(p)
422 await drainFirst(p)
423 return workflowCheck(p, e, next)
424 }).catch(workflowCheckFailed)
425
426 // Observes a stop of a run's task: run.stopping, then run.end once settled.
427 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
428 const p = enginePorts($)
429 await rebound(p)
430 return taskStop(p, e, next)
431 }).catch(taskStopFailed)
432
433 // The main thread can't spawn litter roles; then the swarm's spawn check, with
434 // the engine's own `next`. A rendezvous, so it keeps the spawner's context.
435 // A teammate litter has no stake in passes first, waiting on no rebind, and so
436 // does a workflow's agent of no swarm type (2.1.292 raises one for each agent()).
437 // 2.1.299 raises it for a resumed run too (`resume`): never a spawn to plan; admit.ts's resumeSpawn.
438 on('agent.spawn', async ($, e, next) => {
439 if (outsideTeammate(e) || outsideWorkflow(e)) return next(e)
440 const p = enginePorts($)
441 await rebound(p)
442 if (e.resume !== undefined) return resumeSpawn(p, e, next)
443 const refused = roleSpawnRefusal(e)
444 if (refused !== null) return { deny: refused }
445 return swarmSpawn(p, e, next)
446 }).catch(agentSpawnFailed)
447
448 // Admission for sends to and from swarm nodes (plan 15.4, "Messages").
449 on('session.send', async ($, e, next) => {
450 const p = enginePorts($)
451 await rebound(p)
452 return admitSend(p, e, next)
453 }).catch(sessionSendFailed)
454
455 // Every model request of every loop: a node's wake is admitted at its first step (15.9, item 15).
456 on('turn.step', async function* ($, e, next) {
457 const p = enginePorts($)
458 await rebound(p)
459 return yield* admitStep(p, e, next)
460 }).catch(turnStepFailed)
461
462 // The main loop's turn: the lead is busy. Since 2.1.299 a subagent's or teammate's raises it too, with its agentId.
463 on('turn.start', async ($, e, next) => {
464 if (e.agentId !== undefined) return next(e)
465 const p = enginePorts($)
466 await rebound(p)
467 await leadTurnStart(p, e)
468 return next(e)
469 }).catch(turnStartFailed)
470
471 // A run's completion notification: run.end and summary.md before the launcher's
472 // turn. While a swarm is live, the lead's <swarm> block rides in `context`.
473 // In this order (2b-2): the notice settles first, since a halted fanout's
474 // may start a repair swarm; then its run's card (phase 5, CC); then the
475 // block, which shows the repair swarm; then X's note.
476 on('prompt.submit', async ($, e, next) => {
477 const p = enginePorts($)
478 await rebound(p)
479 await drainFirst(p)
480 notePromptOrigin(e.origin)
481 await settleNotices(p, e)
482 await apart(p, 'prompt.submit cards', () => noteCards(p, e))
483 const block = await swarmContext(p).catch((err: unknown) => {
484 p.log(`prompt.submit swarm context failed: ${errText(err)}`)
485 return null
486 })
487 let note: string | null = null
488 try {
489 note = repairNote(p)
490 } catch (err) {
491 p.log(`prompt.submit repair note failed: ${errText(err)}`)
492 }
493 const extra = [block, note].filter((x): x is string => typeof x === 'string' && x !== '')
494 return next(extra.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...extra] })
495 }).catch(($, e, next) => next(e))
496
497 // A main turn that ends with a launch pending and no Workflow call releases the lock.
498 // A subagent's interrupted turn is its stop, which settles a stopping run.
499 // A swarm node's turn settles its place (L), then P observes it.
500 on('turn.complete', async ($, e, next) => {
501 const p = enginePorts($)
502 await rebound(p)
503 if (e.agentId !== undefined && e.isAborted) await interrupted(p, e.agentId)
504 await apart(p, 'turn.complete settle', () => settleNode(p, e))
505 if (e.agentId !== undefined && binding(e.agentId) !== null) await apart(p, 'turn.complete observe', () => observeNodeTurn(p, e))
506 // The lead's turn counts for each live swarm's Stats (WP-DH).
507 if (e.agentId === undefined) addLeadTokens(e.usage)
508 // The repair swarm's start, scheduled once the notification turn that started it ends (2b-2, X).
509 await apart(p, 'turn.complete repair', () => repairTurnComplete(p, e))
510 return onTurnComplete(p, e, next)
511 }).catch(($, e, next) => next(e))
512
513 // The session's figures moved (as a turn ends, the lead's or an agent's): each live swarm's spend follows its total,
514 // and Stats redraws when it's shown (WP-DH).
515 on('session.measure', async ($, e, next) => {
516 const p = enginePorts($)
517 await rebound(p)
518 if (e.changed.includes('cost')) await apart(p, 'session.measure spend', () => noteUsage(p, e))
519 await apart(p, 'session.measure stats', () => statsMeasured(p, e))
520 return next(e)
521 }).catch(($, e, next) => next(e))
522
523 // Hides the types of a swarm that isn't live and owned here.
524 on('agent.offer', async ($, e, next) => {
525 const p = enginePorts($)
526 await rebound(p)
527 return offerSwarmType(p, e, next)
528 }).catch(agentOfferFailed)
529
530 // /clear is held while a swarm's or a run's agents are running (O).
531 on('command.run', { command: 'clear' }, async ($, e, next) => {
532 const p = enginePorts($)
533 await rebound(p)
534 await drainFirst(p)
535 return holdClear(p, e, next)
536 }).catch(clearHoldFailed)
537
538 // Phase 5b (WP-DA): /litter [section] [arg], the one command, registered at
539 // session.start and after a /clear (it replaced the six pane commands).
540 on('command.run', { command: 'litter' }, async ($, e, next) => {
541 const p = enginePorts($)
542 await rebound(p)
543 return litterCommand(p, e, next)
544 }).catch(litterCommandFailed)
545
546 // WP-EC: /litter's sections in the prompt box's typeahead, as its first
547 // argument is typed. Every other prompt passes on at once, before the ports.
548 on('prompt.autocomplete', async ($, e, next) => {
549 if (litterSuggestions(e).length === 0) return next(e)
550 const p = enginePorts($)
551 await rebound(p)
552 return litterAutocomplete(p, e, next)
553 }).catch(($, e, next) => next(e))
554
555 // The one pane, `litter`: the hub's chrome over the shown section's body
556 // (ui/pane.tsx), from the hub's atom and that section's. A render doesn't
557 // await `rebound`: $.state.set is refused while a render draws (d.ts), and
558 // a rebind's refreshes write the atoms. The next hook of any other kind
559 // rebinds.
560 on('ui.render', { component: 'Pane', requestId: 'litter' }, async ($, e, next) => {
561 const view = await read($, hub)
562 return renderLitterPane(view, await sectionView($, sectionShown(view)), $.ui.resolve(e), e)
563 }).catch(paneRenderFailed)
564
565 // The pane's presses, fields, picks and Client posts: the hub answers its
566 // strip and routes the rest to the shown section (ui/hub.ts's key rule).
567 on('ui.message', { requestId: 'litter' }, async ($, e, next) => {
568 const p = enginePorts($)
569 await rebound(p)
570 return onLitterUi.message(p, e, next)
571 }).catch(uiMessageFailed)
572
573 on('ui.press', { requestId: 'litter' }, async ($, e, next) => {
574 const p = enginePorts($)
575 await rebound(p)
576 return onLitterUi.press(p, e, next)
577 }).catch(uiPressFailed)
578
579 on('ui.input', { requestId: 'litter' }, async ($, e, next) => {
580 const p = enginePorts($)
581 await rebound(p)
582 return onLitterUi.input(p, e, next)
583 }).catch(uiInputFailed)
584
585 on('ui.select', { requestId: 'litter' }, async ($, e, next) => {
586 const p = enginePorts($)
587 await rebound(p)
588 return onLitterUi.select(p, e, next)
589 }).catch(uiSelectFailed)
590
591 // WP-DA2: the pane's ring and its window. The hub keeps the element the
592 // ring lands on (a list draws its description) and routes a wheel tick to
593 // the shown section, which moves its in-pane list or passes it on.
594 on('ui.focus', { requestId: 'litter' }, async ($, e, next) => {
595 const p = enginePorts($)
596 await rebound(p)
597 return onLitterUi.focus(p, e, next)
598 }).catch(uiFocusFailed)
599
600 on('ui.scroll', { requestId: 'litter' }, async ($, e, next) => {
601 const p = enginePorts($)
602 await rebound(p)
603 return onLitterUi.scroll(p, e, next)
604 }).catch(uiScrollFailed)
605
606 // Phase 5 (WP-CA). The band above the prompt (CB): litter's rows over the
607 // tree the plugins beneath draw, which it awaits and keeps below its own;
608 // with nothing of litter's to show, theirs alone. A render: no `rebound`.
609 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
610 const view = await read($, band)
611 if (!bandShows(view, e)) return next(e)
612 return renderBand(view, $.ui.resolve(e), e, await next(e))
613 }).catch(bandRenderFailed)
614
615 // The finished-run card (CC). It runs for every message row, so its first
616 // check is synchronous and cheap; a row with no card is the engine's. An
617 // expanded row (ctrl+o, or `verbose`) draws the card above the engine's
618 // own, so both show (decision 8, revised by WP-CK).
619 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
620 if (!cardRow(e)) return next(e)
621 const card = cardOf(await read($, cards), e)
622 if (card === null) return next(e)
623 return renderCard(card, $.ui.resolve(e), e.props.isExpanded ? await next(e) : null)
624 }).catch(cardRenderFailed)
625
626 // Their presses: any plugin's button in the band or a row; keys not `band:` or `card:` pass on.
627 on('ui.press', { component: 'AbovePrompt' }, async ($, e, next) => {
628 const p = enginePorts($)
629 await rebound(p)
630 return onBandUi.press(p, e, next)
631 }).catch(uiPressFailed)
632
633 on('ui.press', { component: 'UserMessage' }, async ($, e, next) => {
634 const p = enginePorts($)
635 await rebound(p)
636 return onCardUi.press(p, e, next)
637 }).catch(uiPressFailed)
638}
639hooks/ports.ts 294 lines1// The engine as the pure modules see it. Every module that touches files,
2// processes, time or the store takes a `Ports` argument instead of `$`, so
3// the same code runs inside the mod (enginePorts in register.tsx) and under
4// bun in tier 2 (tests/unit/support/node-ports.ts), where git runs for real
5// in scratch repos.
6//
7// Only register.tsx touches `$`: `claude plugin validate` follows `$` into a
8// function declared in the same file, never across an import, so a module
9// that took `$` would fail validation.
10//
11// Contract owner: phase 1a. Implementers use these; they don't change them.
12// A port that's missing something is a contract change: say so in your report.
13
14import type {
15 AgentInfo,
16 AgentStatus,
17 CommandSpec,
18 EngineInterface,
19 ModelCompleteRequest,
20 ModelCompleteResult,
21 SessionMessage,
22 SessionSendResult,
23 SessionUsage,
24 ToolCallResult,
25 ToolSpec,
26} from 'claude-code'
27import type { AgentsPaneView, BandView, BoardView, CardsView, DoctorPaneView, HubView, RulesPaneView, StatsPaneView, SwarmPaneView, TasksPaneView } from '../types/index'
28import type { LedgerRead, Owner } from './ledgercore'
29
30/** What `$.agent.register` takes (the engine doesn't export the type's name). */
31export type AgentSpec = Parameters<EngineInterface['agent']['register']>[0]
32
33/** One row of `$.agent.list()` (the d.ts's AgentInfo; policy.ts has an AgentInfo of its own). */
34export type ListedAgent = AgentInfo
35export type { AgentStatus, CommandSpec, ModelCompleteRequest, ModelCompleteResult, SessionMessage, SessionSendResult, SessionUsage, ToolCallResult }
36
37/**
38 * Whether a listed agent is still alive, so its place, its policy file and
39 * its worktree stay: `running` a turn (or finalizing one), `waiting` (2.1.289:
40 * mid-turn on foreground Agent calls alone, or ended but still holding a
41 * background shell, monitor, child agent or workflow, which wakes it), or
42 * `pending`, not started yet. `idle` is between turns (a teammate, or an ended
43 * named agent; a litter node is never named), and the rest have ended.
44 */
45export const agentLive = (status: AgentStatus): boolean => status === 'running' || status === 'waiting' || status === 'pending'
46
47/** The four subagent limits the health check reads (plan 15.7), each read with its name as a literal. */
48export const SUBAGENT_LIMIT_NAMES = [
49 'CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH',
50 'CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS',
51 'CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY',
52 'CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION',
53] as const
54export type SubagentLimitName = (typeof SUBAGENT_LIMIT_NAMES)[number]
55/** This process's values, raw: `undefined` when unset, never parsed here. */
56export type SubagentLimits = Readonly<Record<SubagentLimitName, string | undefined>>
57
58/** The settings sources the health check reads one by one (`$.settings.read({ source })`; `flag` isn't a file you edit). */
59export type SettingsSourceName = 'user' | 'project' | 'local' | 'policy'
60
61/**
62 * The views in `$.state` (types/index.d.ts), each in its own atom: N's swarm
63 * and doctor panes, phase 3's board and rules pane, and phase 5's band, cards,
64 * tasks pane and agents pane (WP-CA). The band and the cards aren't panes,
65 * but setView writes them the same way.
66 */
67export type PaneName = 'swarm' | 'doctor' | 'board' | 'rules' | 'band' | 'cards' | 'tasks' | 'agents' | 'stats' | 'hub'
68export type { AgentsPaneView, BandView, BoardView, CardsView, DoctorPaneView, HubView, RulesPaneView, StatsPaneView, SwarmPaneView, TasksPaneView }
69/**
70 * Any of those views; each view's `view` field names it, and setView writes it
71 * to its atom. Phase 5b (WP-DA): the pane views are the `litter` pane's
72 * sections (Stats is WP-DH's), and `hub` is the pane's own (ui/hub.ts).
73 */
74export type PaneView = SwarmPaneView | DoctorPaneView | BoardView | RulesPaneView | BandView | CardsView | TasksPaneView | AgentsPaneView | StatsPaneView | HubView
75
76/**
77 * Whom `send` addresses (the d.ts's SessionSendAddress, less `{ sessionId }`):
78 * SendMessage's spelling (a name, an agent id, a received `from` address), or
79 * `{ agentId }`, a subagent or teammate of this session as `$.agent.list()`
80 * lists it (phase 5, COMMON decision 5).
81 */
82export type SendTo = string | { agentId: string }
83
84export type RunInit = {
85 cwd?: string
86 env?: Record<string, string>
87 stdin?: string
88 /** Default 30 s, at most 10 minutes (the engine's limits). */
89 timeoutMs?: number
90}
91
92export type RunResult = {
93 /** The exit code. A process that can't start rejects instead. */
94 code: number
95 stdout: string
96 stderr: string
97 /** Either stream was cut at 4 MiB. Streamed work uses `spawn`. */
98 truncated: boolean
99}
100
101export type SpawnRequest = {
102 argv: readonly string[]
103 cwd?: string
104 env?: Record<string, string>
105 input?: string
106}
107
108export type SpawnChunk = { stream: 'stdout' | 'stderr'; text: string }
109
110/** `code` is null when a signal ended the child, and `signal` then names it. */
111export type SpawnEnd = { code: number | null; signal: string | null }
112
113/** Leaving the loop (break, return, throw) kills the child. */
114export type SpawnStream = AsyncGenerator<SpawnChunk, SpawnEnd>
115
116export type Stat = {
117 kind: 'file' | 'dir' | 'other'
118 size: number
119 mtimeMs: number
120 isLink: boolean
121 /** Only with `{ resolve: true }`, and absent when the path leads nowhere. */
122 realPath?: string
123}
124
125export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; isLink: boolean }
126
127export interface Ports {
128 fs: {
129 /** Rejects when missing or over 4 MiB. */
130 read(path: string): Promise<string>
131 /** Creates the file and its parent folders. Rejects over 4 MiB. Not atomic: see fsx.atomicWrite. */
132 write(path: string, text: string): Promise<void>
133 /** Rejects when the folder is missing. */
134 list(path: string): Promise<Entry[]>
135 exists(path: string): Promise<boolean>
136 /** Rejects when missing. */
137 stat(path: string, options?: { resolve?: boolean }): Promise<Stat>
138 }
139 /** Runs argv with no shell. Git runs with repo hooks off inside the mod. */
140 run(argv: readonly string[], init?: RunInit): Promise<RunResult>
141 spawn(request: SpawnRequest): SpawnStream
142 /** Milliseconds since the epoch. Scripts can't read the clock; the mod can. */
143 now(): Promise<number>
144 /** Kept across sessions (the engine's `$.store`, 4 MiB in all). */
145 store: {
146 get(key: string): Promise<unknown>
147 set(key: string, value: unknown): Promise<void>
148 delete(key: string): Promise<void>
149 }
150 /**
151 * The host's answer to ledgercore's `owned`: whether this store owns the
152 * ledger at `runDir`, as read. Without it, `owned` calls a v2 head `mine`
153 * and anything less `other`.
154 */
155 ledgerOwner?(runDir: string, read: LedgerRead): Promise<Owner>
156 /** The current session id. It changes at /clear and when a session is backgrounded. */
157 sessionId(): Promise<string>
158 /** The session's working directory, absolute (`$.session.cwd()`). It moves with EnterWorktree. */
159 cwd(): Promise<string>
160 /** The plugin's own folder, absolute. */
161 pluginRoot: string
162 /**
163 * `$.settings.read()`: the settings merged over every source, `--settings`
164 * and managed policy included, as the engine runs under them. A snapshot.
165 */
166 settings(): Promise<Readonly<Record<string, unknown>>>
167 /** One line in the debug log (`claude --debug`), which the engine prefixes `[litter] $.ui.log (to debug): `. */
168 log(text: string): void
169 /** `$.tool.register`: served as `mcp__litter__<name>`. Rejects when the engine refuses it. */
170 registerTool(spec: ToolSpec): Promise<void>
171 /** `$.agent.register`: served as `litter:<name>`. Rejects when the engine refuses it. */
172 registerAgent(spec: AgentSpec): Promise<void>
173 /** `$.clock.every`: `fn` runs every `ms` until `cancel()` or the module unloads. */
174 every(ms: number, fn: () => void): { cancel(): void }
175 /**
176 * `$.ui.ask`: your answer, as the label chosen or the text typed under
177 * "Other", so compare it with the labels exactly. Rejects when dismissed and
178 * under `-p` or in an eval (no one to ask); a handler that needs an answer
179 * refuses then.
180 */
181 ask(question: string, options: { options: readonly string[]; header?: string }): Promise<string>
182
183 // Phase 2b, for swarms (plan 15.4; WP-C's port table).
184
185 /** `$.agent.list()`: the session's agents so far, subagents and teammates, the model's and the plugins' alike. Liveness is `agentLive`. */
186 listAgents(): Promise<ListedAgent[]>
187 /**
188 * `$.session.send({ to, text })`: `to` as SendMessage spells it (an agent id,
189 * a name, a received `from` address), or `{ agentId }` (phase 5), passed on
190 * as is. It goes through every litter hook but the calling one, so the mod
191 * sends only from a `swarm_send` or `ui.*` dispatch (COMMON, decision 2).
192 * Resolves once queued at the recipient.
193 */
194 send(to: SendTo, text: string): Promise<SessionSendResult>
195 /**
196 * `$.session.append`: a user-role row of one text block in the lead's
197 * conversation, which a busy lead reads in its current turn (spike 10).
198 * Rejects with the reason when a hook refuses it (`{ deny }`).
199 */
200 appendUserRow(text: string): Promise<void>
201 /**
202 * `$.prompt.submit({ text })`: a lead turn of its own once the session is
203 * idle. Resolves once that turn starts, so never await it inside a hook:
204 * call it from a `p.after` or `p.every` callback, or a turn.complete (15.9,
205 * item 19). Rejects with the host's text on a refusal (from a tool.call
206 * hook) and on `{ drop }`.
207 */
208 submitPrompt(text: string): Promise<void>
209 /** `$.prompt.fill({ text })`: the prompt box takes the text as your draft. False under a dialog or headless. */
210 fillPrompt(text: string): Promise<boolean>
211 /** `$.session.messages()`, or an agent's with its id. Null when the engine can't read that agent's (`{ deny }`). */
212 messages(agentId?: string): Promise<SessionMessage[] | null>
213 /** `$.model.complete`: one tool-less completion. Resolves the reply or why there's none; rejects only on a request the engine won't send. */
214 complete(request: ModelCompleteRequest): Promise<ModelCompleteResult>
215 /**
216 * `$.tool.call({ tool, ...input })`: TaskStop for a hard stop. It runs
217 * through every litter hook but the calling one. Rejects when no tool has
218 * that name; refuses Agent, AskUserQuestion and Workflow (spike 7).
219 */
220 callTool(tool: string, input: Record<string, unknown>): Promise<ToolCallResult>
221 /**
222 * `$.clock.sleep(ms)`. The d.ts (2.1.288): unlike every other `$` call, the
223 * wait is the hook's own time, so a hook that polls with it spends its 10 s
224 * budget (WP-C's report). Poll in short steps under a cap well inside it.
225 */
226 sleep(ms: number): Promise<void>
227 /** `$.clock.after`: `fn` runs once after `ms`, outside any hook's dispatch, unless cancelled first. */
228 after(ms: number, fn: () => void): { cancel(): void }
229 /** The four subagent limits from this process's environment, each read with its name as a literal. */
230 limits(): Promise<SubagentLimits>
231 /** `$.settings.read({ source })`: that one file as loaded. Null when it has nothing (the engine answers `{}` for a missing file). */
232 settingsFrom(source: SettingsSourceName): Promise<Readonly<Record<string, unknown>> | null>
233 /** `CLAUDE_CONFIG_DIR`, else `$HOME/.claude`: absolute. Rejects when neither is set, or the value isn't absolute. */
234 configDir(): Promise<string>
235 /** `$.command.register`: `/<name>`, served by a `command.run` hook on `{ command: name }`. Rejects when refused. */
236 registerCommand(spec: CommandSpec): Promise<void>
237 /**
238 * `$.ui.open({ id, title, focus })`: whether the pane was placed (drawn). An
239 * open answering your command is placed at any width. `focus` (phase 5b,
240 * WP-DA) asks for the keyboard when the person asked for the pane (the
241 * command, a button): a request the surface refuses while a band or another
242 * pane holds the keys or the composer has text (d.ts `:7166`).
243 */
244 openPane(id: string, title: string, focus?: boolean): Promise<boolean>
245 /**
246 * Whether this plugin's pane `id` is open now (`$.ui.panes()`), drawn or
247 * waiting undrawn (phase 5b, WP-DA): the ticks refresh the `litter` pane's
248 * section only while it is.
249 */
250 paneOpen(id: string): Promise<boolean>
251 /** `$.ui.close({ id })`; an id that isn't open is left alone. */
252 closePane(id: string): Promise<void>
253 /** `$.ui.toast(text)`: a few seconds over the transcript's corner, after the `litter: ` the engine puts first (so the text has none). */
254 toast(text: string): void
255 /**
256 * `$.ui.log(text)` to the transcript (WP-CK): one dim row of its own, drawn
257 * like a system notice after the `litter: ` the engine puts first (so the
258 * text has none), and never sent to the model. The transcript stays drawn
259 * above a permission dialog, where the band isn't (2.1.290).
260 */
261 note(text: string): void
262 /** `$.ui.copy({ text })`: whether the clipboard took it. */
263 copy(text: string): Promise<boolean>
264 /** Writes a view into `$.state` (the atoms in register.tsx); the write redraws what reads it. */
265 setView(pane: PaneName, view: PaneView | null): Promise<void>
266
267 // Phase 5 (WP-CA).
268
269 /**
270 * `$.ui.notice(tool_use_id, text)`: a line under the dialog open for that
271 * call, or removed with `text` undefined (CF; COMMON decision 1). In 2.1.289
272 * the call is fire-and-forget: it returns before the engine checks the call
273 * is open, and a refusal ("no call with that tool_use_id is open", or
274 * another plugin's `$.tool.call` run) shows only as a debug-log warning,
275 * `[litter] $.ui.notice dropped: …`, never a throw. So true means the call
276 * was made, not that it was accepted; false, it threw.
277 */
278 notice(toolUseId: string, text: string | undefined): boolean
279 /** `$.tool.list()`'s names: the tools the model can call now, built-in and MCP alike (CE checks for TaskUpdate). */
280 toolNames(): Promise<string[]>
281 /** `$.env.get` for the one name the tasks pane reads (COMMON decision 15); undefined when unset. */
282 envVar(name: 'CLAUDE_CODE_TASK_LIST_ID'): Promise<string | undefined>
283
284 // Phase 5b (WP-DH).
285
286 /**
287 * `$.session.usage()` with no breakdown (so it sends nothing): the status
288 * line's figures. `cost` is the session's whole spend, the process's cost
289 * ledger, every agent's responses included; absent where the host keeps
290 * none. Figures the engine hasn't got are left out, never zeroed.
291 */
292 usage(): Promise<SessionUsage>
293}
294hooks/ui/pane.tsx 143 lines1// The `litter` pane's draw (phase 5b, WP-DA): the header, the section strip,
2// the shown section's body through its own renderer, and the keys line on
3// the terminal. A pure function of the hub's view and the shown section's
4// view, which register.tsx reads from their atoms.
5//
6// Every body gets the themed table (chrome.tsx: a dim Text is muted) and a
7// BodyContext. Home (home.tsx, WP-DB) fits its rows, so the keys line sits at
8// the pane's foot; each other section says in its own module (its
9// SectionMeta) whether its body fits `ctx.rows` yet, and which keys it adds
10// to the keys line. One that doesn't fit is drawn whole (the keys line
11// follows it, reached by scrolling).
12//
13// `h` and `Fragment` are the environment's JSX factory, so no name here takes them.
14
15import type { RenderElement } from 'claude-code'
16import type { PaneElements, PaneRender } from '../hooktypes'
17import type { AgentsPaneView, BoardView, DoctorPaneView, HubSection, HubTab, HubView, RulesPaneView, StatsPaneView, SwarmPaneView, TasksPaneView } from '../../types/index'
18import { tableFor } from '../swarm/builder'
19import { renderBoardPane, runsSection } from '../board/pane'
20import { renderSwarmPane, swarmsSection } from '../swarm/browser'
21import { doctorSection, renderDoctorPane } from '../swarm/doctorpane'
22import { renderRulesPane, rulesSection } from '../rules/pane'
23import { renderTasksPane, tasksSection } from '../tasks/pane'
24import { agentsSection, renderAgentsPane } from '../agents/pane'
25import { renderStatsPane, statsSection } from '../stats/pane'
26import { header, keysLine, plan, strip, stripWidths, themed, type BodyContext, type KeyHint, type SectionMeta } from './chrome'
27import { cells } from '../band/view'
28import { noteDrawn } from './drawn'
29import { renderHome } from './home'
30import { LABEL, SECTIONS, tabsOf } from './hub'
31
32/** The shown section's view, whichever it is: register.tsx reads the one atom the hub's section names. */
33export type SectionView = BoardView | SwarmPaneView | RulesPaneView | TasksPaneView | AgentsPaneView | StatsPaneView | DoctorPaneView
34
35/** Which section the hub's view shows; Home before the first write. */
36export const sectionShown = (hub: HubView | null): HubSection => (hub && (SECTIONS as readonly string[]).includes(hub.section) ? hub.section : 'home')
37
38/** The keys line while the pane has the keyboard. */
39export const HUB_KEYS: readonly KeyHint[] = [
40 { key: '1–8', word: 'section' },
41 { key: 'tab', word: 'next' },
42 { key: 'enter', word: 'press' },
43 { key: 'esc', word: 'back to the prompt' },
44]
45
46/** Each section but Home, as its own module declares it (chrome.tsx's SectionMeta). Home fits and has no keys of its own. */
47export type MetaTable = Record<Exclude<HubSection, 'home'>, SectionMeta<never>>
48const META = {
49 runs: runsSection,
50 swarms: swarmsSection,
51 rules: rulesSection,
52 tasks: tasksSection,
53 agents: agentsSection,
54 stats: statsSection,
55 doctor: doctorSection,
56} satisfies MetaTable
57
58/** Whether the shown section's body fits `ctx.rows` (the hub clips it there in the dock). The table is a seam for tier 2. */
59export const fitsOf = (shown: HubSection, table: MetaTable = META): boolean => shown === 'home' || table[shown].fits
60
61/** The shown section's own keys, drawn before the hub's. */
62export const keysOf = (shown: HubSection, view: SectionView | null, table: MetaTable = META): readonly KeyHint[] => (shown === 'home' ? [] : table[shown].keys(view as never))
63
64/** The keys line's keys: the shown section's, then the hub's. */
65export const keysLineOf = (shown: HubSection, view: SectionView | null, table: MetaTable = META): KeyHint[] => [...keysOf(shown, view, table), ...HUB_KEYS]
66
67/** The body's Box props: a section that fits is clipped to `rows` in the dock (so the keys line sits at the pane's foot); else drawn whole. */
68export function bodyBox(shown: HubSection, placement: 'dock' | 'inline', rows: number, table: MetaTable = META): { height?: number; overflow?: 'hidden' } {
69 return fitsOf(shown, table) && placement === 'dock' && Number.isFinite(rows) ? { height: rows, overflow: 'hidden' } : {}
70}
71
72/** The tabs before the hub's first write: every section, nothing counted. */
73const blankTabs = (): HubTab[] => tabsOf({ runs: 0, swarms: 0, swarmsNeedYou: false, rules: null, tasks: null, agents: null })
74
75/**
76 * The rows the keys line wraps to past its first at `columns` (WP-DB): chrome's
77 * plan counts it one row, but keysLine is a wrapping row of keys, each after
78 * the first with ` · ` before it, and at 60 columns the hub's own four
79 * already take two. Unfocused it's one line. Pure.
80 */
81export function keysOverflow(keys: readonly KeyHint[], isFocused: boolean, columns: number): number {
82 if (!isFocused || !Number.isFinite(columns)) return 0
83 let rows = 1
84 let used = 0
85 keys.forEach((k, i) => {
86 const w = (i > 0 ? 3 : 0) + cells(k.key) + 1 + cells(k.word)
87 if (used > 0 && used + w > columns) {
88 rows++
89 used = w
90 } else used += w
91 })
92 return rows - 1
93}
94
95/** register.tsx's ui.render hook for `{ component: 'Pane', requestId: 'litter' }`. Pure but for its note of what the terminal drew (ui/drawn.ts). */
96export function renderLitterPane(hub: HubView | null, view: SectionView | null, resolved: PaneElements, e: PaneRender): RenderElement {
97 const el = themed(tableFor(resolved, e.surface))
98 const { Box } = el
99 const shown = sectionShown(hub)
100 const tabs = hub?.tabs && hub.tabs.length === SECTIONS.length ? hub.tabs : blankTabs()
101 const { bodyColumns, isFocused, placement, scroll } = e.props
102 const keys = e.surface === 'terminal'
103 const keyHints = keysLineOf(shown, view)
104 // plan counts the keys line one row: the rows it wraps to past that come off the body's.
105 const over = keys ? keysOverflow(keyHints, isFocused, bodyColumns) : 0
106 const layout = plan({ bodyColumns, bodyRows: scroll.bodyRows - over, context: false, strip: stripWidths(tabs, shown, isFocused), keys })
107 const ctx: BodyContext = { surface: e.surface, bodyColumns, isFocused, placement, scroll, rows: layout.bodyRows, focused: hub?.focus ?? null, lists: hub?.lists ?? {} }
108 // The header's rows, the strip's, and the gap above the body.
109 if (e.surface === 'terminal') noteDrawn({ shown, view, ctx, bodyTop: layout.headerRows + layout.stripRows + 1, scrollOffset: scroll.offset })
110 return (
111 <Box flexDirection="column">
112 {header(el, { section: LABEL[shown], live: hub?.live ?? [] }, bodyColumns)}
113 {strip(el, tabs, shown, isFocused)}
114 <Box flexDirection="column" marginTop={1} {...bodyBox(shown, placement, layout.bodyRows)}>
115 {body(shown, hub, view, el, ctx)}
116 </Box>
117 {keys ? keysLine(el, keyHints, isFocused) : null}
118 </Box>
119 )
120}
121
122/** The shown section's body, through its renderer, over the view register.tsx read from that section's atom (Home's is the hub's own). */
123function body(shown: HubSection, hub: HubView | null, view: SectionView | null, el: PaneElements, ctx: BodyContext): RenderElement {
124 switch (shown) {
125 case 'home':
126 return renderHome(hub?.home, el, ctx)
127 case 'runs':
128 return renderBoardPane(view as BoardView | null, el, ctx)
129 case 'swarms':
130 return renderSwarmPane(view as SwarmPaneView | null, el, ctx)
131 case 'rules':
132 return renderRulesPane(view as RulesPaneView | null, el, ctx)
133 case 'tasks':
134 return renderTasksPane(view as TasksPaneView | null, el, ctx)
135 case 'agents':
136 return renderAgentsPane(view as AgentsPaneView | null, el, ctx)
137 case 'stats':
138 return renderStatsPane(view as StatsPaneView | null, el, ctx)
139 case 'doctor':
140 return renderDoctorPane(view as DoctorPaneView | null, el, ctx)
141 }
142}
143hooks/hooktypes.ts 133 lines1// The hook types for each registration in register.tsx. register.tsx holds
2// every hook as an inline literal that builds the ports and calls the module's
3// handler, typed here as a PortHook: the engine hook's input, `next` and result,
4// with the ports in place of `$` (ports.ts says why). A catch never uses `$`,
5// so a module exports it with the engine's own catch type.
6//
7// Contract owner: phase 1a; the swarm hooks below are phase 2b-1's (package C),
8// the band's and the card's phase 5's (WP-CA).
9
10import type { CatchHandler, Elements, Hook, MatchedHook, RenderSurface } from 'claude-code'
11import type { Ports } from './ports'
12
13type AnyHook = (...args: any[]) => any
14
15/** An engine hook with the ports in place of `$`. */
16export type PortHook<H extends AnyHook> = (p: Ports, e: Parameters<H>[1], next: Parameters<H>[2]) => ReturnType<H>
17
18/**
19 * The same for a streaming hook (`turn.step`): the handler is an
20 * `async function* (p, e, next)` yielding the event's chunks and returning its
21 * result (or nothing), which register.tsx runs with `return yield* handler(p, e, next)`.
22 */
23export type PortStreamHook<H extends AnyHook> = (p: Ports, e: Parameters<H>[1], next: Parameters<H>[2]) => ReturnType<H>
24
25export type RecordHook = MatchedHook<'tool.call', { readonly tool: 'mcp__litter__record' }>
26export type RecordCatch = CatchHandler<RecordHook>
27export type WorkflowHook = MatchedHook<'tool.call', { readonly tool: 'Workflow' }>
28export type WorkflowCatch = CatchHandler<WorkflowHook>
29export type TaskStopHook = MatchedHook<'tool.call', { readonly tool: 'TaskStop' }>
30export type TaskStopCatch = CatchHandler<TaskStopHook>
31export type SpawnHook = Hook<'agent.spawn'>
32export type SpawnCatch = CatchHandler<SpawnHook>
33export type PromptSubmitHook = Hook<'prompt.submit'>
34export type PromptSubmitCatch = CatchHandler<PromptSubmitHook>
35export type TurnCompleteHook = Hook<'turn.complete'>
36export type TurnCompleteCatch = CatchHandler<TurnCompleteHook>
37export type SessionEndHook = Hook<'session.end'>
38export type SessionEndCatch = CatchHandler<SessionEndHook>
39
40// Phase 2b: the swarm's hooks (plan 15.4; COMMON, "Wiring").
41export type SessionStartHook = Hook<'session.start'>
42export type SessionStartCatch = CatchHandler<SessionStartHook>
43/** Every SendMessage and every plugin's `$.session.send`; `e.agentId` is the sending loop, absent for the lead. */
44export type SessionSendHook = Hook<'session.send'>
45export type SessionSendCatch = CatchHandler<SessionSendHook>
46/** The main loop only: a subagent's run raises no turn.start (d.ts, TurnCompleteInput.turnId), and its input has no agentId. */
47export type TurnStartHook = Hook<'turn.start'>
48export type TurnStartCatch = CatchHandler<TurnStartHook>
49/** Every model request of every loop; `e.agentId` names a subagent's. A stream: its catch is a generator too. */
50export type TurnStepHook = Hook<'turn.step'>
51export type TurnStepCatch = CatchHandler<TurnStepHook>
52/** Matched on `command` in register.tsx (`clear`, `litter`). */
53export type CommandRunHook = Hook<'command.run'>
54export type CommandRunCatch = CatchHandler<CommandRunHook>
55/** `e.agent` is the type as the engine serves it, `litter:sw-<swarm>-<node>` for a node's. */
56export type AgentOfferHook = Hook<'agent.offer'>
57export type AgentOfferCatch = CatchHandler<AgentOfferHook>
58/** A pane's draw, matched on `{ component: 'Pane', requestId }` in register.tsx. */
59export type UiRenderHook = MatchedHook<'ui.render', { readonly component: 'Pane' }>
60export type UiRenderCatch = CatchHandler<UiRenderHook>
61/** A `Client`'s `surface.post` (the tree, swarm/tree.tsx); matched on the pane's `requestId`. */
62export type UiMessageHook = Hook<'ui.message'>
63export type UiMessageCatch = CatchHandler<UiMessageHook>
64/** A `Button` press in a pane; matched on the pane's `requestId`, `e.element` the button's key. */
65export type UiPressHook = Hook<'ui.press'>
66export type UiPressCatch = CatchHandler<UiPressHook>
67export type UiInputHook = Hook<'ui.input'>
68export type UiInputCatch = CatchHandler<UiInputHook>
69export type UiSelectHook = Hook<'ui.select'>
70export type UiSelectCatch = CatchHandler<UiSelectHook>
71/** The pane's focus ring moving (WP-DA2): Tab, the arrows, a click, an autoFocus; `e.element` the key taking it, absent for an engine stop. */
72export type UiFocusHook = Hook<'ui.focus'>
73export type UiFocusCatch = CatchHandler<UiFocusHook>
74/** The pane's window asked to move (WP-DA2): the wheel, the scroll keys; `e.pointer` the body cell under the wheel. */
75export type UiScrollHook = Hook<'ui.scroll'>
76export type UiScrollCatch = CatchHandler<UiScrollHook>
77/**
78 * One section's `ui.*` handlers, as the board and the rules pane export them
79 * (phase 3, AF). Phase 5b (WP-DA): register.tsx matches the hub's
80 * (ui/hub.ts, onLitterUi) on `requestId: 'litter'`, and the hub routes each
81 * key to its section's. WP-DA2: `focus` and `scroll`, which a section that
82 * draws an in-pane list answers (ui/list.tsx); one without passes them on.
83 */
84export type PaneUiHandlers = {
85 message: PortHook<UiMessageHook>
86 press: PortHook<UiPressHook>
87 input: PortHook<UiInputHook>
88 select: PortHook<UiSelectHook>
89 focus?: PortHook<UiFocusHook>
90 scroll?: PortHook<UiScrollHook>
91}
92/** The three swarm tools, answered in their hooks, never passed on. */
93export type SwarmProposeHook = MatchedHook<'tool.call', { readonly tool: 'mcp__litter__swarm_propose' }>
94export type SwarmProposeCatch = CatchHandler<SwarmProposeHook>
95export type SwarmReadHook = MatchedHook<'tool.call', { readonly tool: 'mcp__litter__swarm_read' }>
96export type SwarmReadCatch = CatchHandler<SwarmReadHook>
97export type SwarmSendHook = MatchedHook<'tool.call', { readonly tool: 'mcp__litter__swarm_send' }>
98export type SwarmSendCatch = CatchHandler<SwarmSendHook>
99
100/** The `litter` pane's `ui.render` input (ui/pane.tsx); its section bodies get a BodyContext from it (ui/chrome.tsx). */
101export type PaneRender = Parameters<UiRenderHook>[1]
102/** What `$.ui.resolve(e)` answers for a pane's render: the surface's element table (`Box`, `Text`, `Button`, …). */
103export type PaneElements = Elements[RenderSurface]
104
105// Phase 5 (WP-CA): the band above the prompt, the finished-run card, and their presses.
106
107/**
108 * The band's draw, matched on `{ component: 'AbovePrompt' }`: one instance
109 * (the engine's `requestId` is `above-prompt`), raised on the terminal and
110 * desktop only. `e.props`: hasSurvey, isWorking, maxRows, bodyColumns, scroll, view.
111 */
112export type BandRenderHook = MatchedHook<'ui.render', { readonly component: 'AbovePrompt' }>
113export type BandRenderCatch = CatchHandler<BandRenderHook>
114export type BandRender = Parameters<BandRenderHook>[1]
115/** What `$.ui.resolve(e)` answers for the band's render. */
116export type BandElements = Elements[BandRender['surface']]
117/**
118 * A transcript row's draw, matched on `{ component: 'UserMessage' }`: every
119 * user-role row, on every surface. `e.props`: text, origin, isExpanded, task
120 * (a notification's), from, onScreen; all but `text` read-only.
121 */
122export type CardRenderHook = MatchedHook<'ui.render', { readonly component: 'UserMessage' }>
123export type CardRenderCatch = CatchHandler<CardRenderHook>
124export type CardRender = Parameters<CardRenderHook>[1]
125/** What `$.ui.resolve(e)` answers for a row's render. */
126export type CardElements = Elements[CardRender['surface']]
127/** A press on a Button in the band, any plugin's: matched on `{ component: 'AbovePrompt' }`; litter's keys start `band:`. */
128export type BandPressHook = MatchedHook<'ui.press', { readonly component: 'AbovePrompt' }>
129export type BandPressCatch = CatchHandler<BandPressHook>
130/** A press on a Button in a transcript row, any plugin's: matched on `{ component: 'UserMessage' }`; litter's keys start `card:`. */
131export type CardPressHook = MatchedHook<'ui.press', { readonly component: 'UserMessage' }>
132export type CardPressCatch = CatchHandler<CardPressHook>
133hooks/session.ts 45 lines1// What the mod knows about the current session, kept in module memory.
2// register() runs again on every reload and session.start fires after it, so
3// this is rebuilt then. /clear fires no session.start but keeps the module, so
4// this survives it; $.state doesn't (plan section 15.4, "After /clear").
5// Anything that must outlive the process goes to $.store or the ledger.
6//
7// Contract owner: phase 1a.
8
9import type { LitterOptions } from './options'
10import { normalizeOptions } from './options'
11
12export type SessionInfo = {
13 /** null until session.start resolved it, or when it can't be (litterDirError says why). */
14 litterDir: string | null
15 litterDirError: string | null
16 /** Set when $.tool.register was refused (e.g. managed allowedMcpServers): every litter launch is denied, naming it. */
17 toolsRefused: string | null
18 /**
19 * Phase 2b: set when a swarm tool's registration was refused. Proposing and
20 * launching a swarm are refused while it's set (J, K); runs don't depend on it.
21 */
22 swarmToolsRefused: string | null
23 /** True once session.start has finished its steps. */
24 ready: boolean
25 /**
26 * Which host runs litter: Claude Code (the mod), or Codex (litterd, design
27 * D2). On Codex litter's agents are sessions litterd starts with their own
28 * sandbox, which the lead's permission mode doesn't reach (D4).
29 */
30 host: 'claude-code' | 'codex'
31}
32
33let options: LitterOptions = normalizeOptions(undefined)
34let info: SessionInfo = { litterDir: null, litterDirError: null, toolsRefused: null, swarmToolsRefused: null, ready: false, host: 'claude-code' }
35
36export const getOptions = (): LitterOptions => options
37export const setOptions = (raw: Readonly<Record<string, unknown>> | undefined): void => {
38 options = normalizeOptions(raw)
39}
40
41export const session = (): Readonly<SessionInfo> => info
42export const updateSession = (patch: Partial<SessionInfo>): void => {
43 info = { ...info, ...patch }
44}
45hooks/registry.ts 125 lines1// Which module answers each record kind, and who consumes the spool. A static
2// table, so `claude plugin validate` and the reader both see the whole wiring.
3//
4// Contract owner: phase 1a. A package adds its handler to its own module and,
5// if a new kind needs a row here, says so in its report.
6
7import type { RecordHandler } from './records/core'
8import type { KnownKind } from './records/design'
9import type { SpoolConsumer } from './hookfiles'
10import { recordGeneric } from './records/design'
11import { recordRunFind } from './litterdir'
12import { recordVerify } from './verify'
13import { recordLand } from './land'
14import { consumeLifecycle, recordRunEnd, recordRunStart } from './lifecycle'
15import { consumeAgents } from './policy'
16import { consumeOutput } from './output'
17import { recordIntegrate } from './integrate'
18import { consumeWorktrees } from './worktrees'
19import { recordSwarmBind } from './swarm/admit'
20import { recordSwarmEnd } from './swarm/end'
21import { consumeSwarm } from './swarm/observe'
22import { recordRepairBudget, recordRepairStart } from './repair'
23import { recordGateAmend, recordGateWiden, recordPlanWrite } from './plan'
24import { consumeInflight } from './inflight'
25import { consumeAsks } from './asks'
26import { consumeExecutorGate } from './widen'
27import { recordRuleFind, recordRuleImport, recordRuleOpen, recordRuleProposal } from './rules/records'
28import { recordProbe, recordProposal, recordVerdict } from './design/records'
29
30export const KIND_HANDLERS: Readonly<Record<KnownKind, RecordHandler>> = {
31 finding: recordGeneric,
32 coverage: recordGeneric,
33 mechanism: recordGeneric,
34 // Design (phase 4): the prober's, the author's and the design critic's, design/records.ts's (WP-BC).
35 probe: recordProbe,
36 reframe: recordGeneric,
37 proposal: recordProposal,
38 verdict: recordVerdict,
39 'design.feedback': recordGeneric,
40 verify: recordVerify,
41 'land.intent': recordGeneric,
42 land: recordLand,
43 'land.abandoned': recordGeneric,
44 'land.blocked': recordGeneric,
45 'rule.propose': recordRuleProposal,
46 'task.bind': recordGeneric,
47 'task.result': recordGeneric,
48 integrate: recordIntegrate,
49 'integrate.merge': recordGeneric,
50 'integrate.conflict': recordGeneric,
51 'integrate.skip': recordGeneric,
52 'integrate.done': recordGeneric,
53 'lock.recovered': recordGeneric,
54 'lock.forced': recordGeneric,
55 'run.start': recordRunStart,
56 'run.launch': recordGeneric,
57 'run.resume': recordGeneric,
58 'run.stopping': recordGeneric,
59 'run.end': recordRunEnd,
60 'worktree.intent': recordGeneric,
61 'worktree.create': recordGeneric,
62 'worktree.setup_failed': recordGeneric,
63 'worktree.removed': recordGeneric,
64 'worktree.kept': recordGeneric,
65 'plan.write': recordPlanWrite,
66 'gate.amend': recordGateAmend,
67 'gate.widen': recordGateWiden,
68 'wiki.write': recordGeneric,
69 // Rules (phase 3): the rules ledger's, rules/records.ts.
70 'rule.open': recordRuleOpen,
71 'rule.import': recordRuleImport,
72 'run.find': recordRunFind,
73 'rule.find': recordRuleFind,
74 // Swarms (phase 2b): the two kinds the tool takes have handlers; the mod's own are refused before this table.
75 'swarm.revision': recordGeneric,
76 'swarm.proposal': recordGeneric,
77 'swarm.proposal.decided': recordGeneric,
78 'swarm.start': recordGeneric,
79 'swarm.worktree.intent': recordGeneric,
80 'swarm.worktree': recordGeneric,
81 'swarm.worktree.failed': recordGeneric,
82 'swarm.worktree.removed': recordGeneric,
83 'swarm.worktree.kept': recordGeneric,
84 'swarm.instance': recordGeneric,
85 'swarm.node': recordGeneric,
86 'swarm.bind': recordSwarmBind,
87 'swarm.event': recordGeneric,
88 'swarm.summary': recordGeneric,
89 'swarm.stop': recordGeneric,
90 'swarm.stopping': recordGeneric,
91 'swarm.handover': recordGeneric,
92 'swarm.end': recordSwarmEnd,
93 'session.rebind': recordGeneric,
94 // Repair (phase 2b-2): the launcher's two; the mod's own are refused before this table.
95 // A bound swarm node's verify, verdict and land never reach it (ledger.ts routes them to swarm/gate.ts).
96 'run.repair': recordGeneric,
97 'repair.declined': recordGeneric,
98 'repair.start': recordRepairStart,
99 'repair.budget': recordRepairBudget,
100 // Design (phase 4): the mod's own, refused before this table.
101 'design.result': recordGeneric,
102 'design.deliver': recordGeneric,
103}
104
105/**
106 * In order: the agent map first, so the others can look an agent up; an
107 * execute run's in-flight flag next (phase 3, decision 24), so a gate-critic's
108 * start or stop is seen before its output; the permission asks next (phase
109 * 5, decision 1), which read the agent map and pair an ask with a call start;
110 * the swarm's consumer next, so its in-flight table sees a call before
111 * anything acts on it; the worktree records before the outputs, so a
112 * writer's result finds its worktree. An execute executor's gate (A8) needs
113 * only the agent map.
114 */
115export const SPOOL_CONSUMERS: readonly SpoolConsumer[] = [
116 consumeAgents,
117 consumeInflight,
118 consumeAsks,
119 consumeExecutorGate,
120 consumeSwarm,
121 consumeWorktrees,
122 consumeOutput,
123 consumeLifecycle,
124]
125hooks/ledger.ts 363 lines1// mcp__litter__record: the record tool (plan section 9, "Ledger"). Owner:
2// phase 1b, work package B.
3//
4// The tool is answered in its tool.call hook, never through next(e). This file
5// owns the tool's spec and the dispatcher (kind matched case-insensitively,
6// caller identity, mod-only kinds, bounds, the error shape). Each kind is
7// dispatched to its owner through registry.ts; the kinds the tool validates
8// itself are in records/design.ts. Storage is ledgercore.ts.
9
10import type { ToolSpec } from 'claude-code'
11import type { PortHook, RecordCatch, RecordHook } from './hooktypes'
12import type { Ports } from './ports'
13import type { LitterOptions } from './options'
14import type { Caller, RecordCtx, RecordError, RecordInput, RecordReply } from './records/core'
15import type { KindRule, KnownKind } from './records/design'
16import type { AgentInfo } from './policy'
17import type { SwarmGateCall } from './swarm/gate'
18import { fail, RECORD_MAX_BYTES } from './records/core'
19import { KIND_RULES, KNOWN_KINDS, MOD_ONLY_KINDS, USER_ONLY_KINDS } from './records/design'
20import { KIND_HANDLERS, SPOOL_CONSUMERS } from './registry'
21import { elsewhere, LedgerNotOwned, LedgerTampered, owned, readLedger, RecordTooLarge } from './ledgercore'
22import { NotOnCodex } from './ledgerowner'
23import { agentInfo, drainOwnSpools } from './policy'
24import { RUN_ID, runDir, runJsonPath } from './layout'
25import { getOptions, session } from './session'
26import { binding, ready as swarmReady } from './swarm/state'
27import { isSwarmGateKind, swarmGateRecord } from './swarm/gate'
28
29type FieldType = 'strings' | 'objects' | 'texts or objects' | 'object' | 'boolean' | 'integer'
30
31/**
32 * The fields of any kind whose value isn't a string. A model sends a field the
33 * schema doesn't declare as a string, so a list arrives JSON-encoded (tier 4,
34 * live). Each is declared in the schema, and a string holding the JSON of its
35 * type is decoded before the checks.
36 */
37const STRUCTURED: Readonly<Record<string, FieldType>> = {
38 authorized: 'strings',
39 verify: 'strings',
40 goalCheck: 'strings',
41 setup: 'strings',
42 runRules: 'strings',
43 linkDirs: 'strings',
44 mergeOrder: 'strings',
45 // Phase 4 (decision 3): each { question, smallest_test, kill_result }, or a bare question.
46 empiricalUnknowns: 'texts or objects',
47 anchors: 'strings',
48 tasks: 'objects',
49 bar: 'objects',
50 lenses: 'objects',
51 gates: 'objects',
52 criteria: 'objects',
53 amendOf: 'object',
54 adversary: 'boolean',
55 isolate: 'boolean',
56 carryOver: 'boolean',
57 maxAttempts: 'integer',
58 maxRounds: 'integer',
59 attempt: 'integer',
60 // 2b-2: run.start's repair override, a swarm verdict's proposals, repair.budget's request.
61 repairRounds: 'integer',
62 repairAgents: 'integer',
63 proposed_rules: 'strings',
64 rounds: 'integer',
65 agents: 'integer',
66 // Phase 3: a plan-template critic's verdict (execute's VERDICT, through the
67 // tool), rule.propose's paths, rule.open's proposal, rule.find's applicable.
68 // rule.find's `status` list isn't declared: run.end's `status` is a string,
69 // so its handler decodes it.
70 plan_adherence: 'object',
71 tests_green: 'object',
72 no_regressions: 'object',
73 rules: 'object',
74 paths: 'strings',
75 proposal: 'object',
76 applicable: 'object',
77 // WP-AO: gate.widen's files, run.start's widenings, a plan-template critic's widened.
78 files: 'strings',
79 widenings: 'objects',
80 widened: 'objects',
81 // Design D9: run.find's opt-in for the configured options.
82 options: 'boolean',
83}
84
85const SCHEMA_OF: Readonly<Record<FieldType, Record<string, unknown>>> = {
86 strings: { type: 'array', items: { type: 'string' } },
87 objects: { type: 'array', items: { type: 'object' } },
88 'texts or objects': { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'object' }] } },
89 object: { type: 'object' },
90 boolean: { type: 'boolean' },
91 integer: { type: 'integer' },
92}
93
94const hasType = (v: unknown, t: FieldType): boolean =>
95 t === 'strings' || t === 'objects' || t === 'texts or objects'
96 ? Array.isArray(v)
97 : t === 'object'
98 ? typeof v === 'object' && v !== null && !Array.isArray(v)
99 : t === 'boolean'
100 ? typeof v === 'boolean'
101 : Number.isInteger(v)
102
103/** A structured field sent as a string of its JSON, decoded; anything else unchanged, for the handler to judge. */
104function decodeField(name: string, v: unknown): unknown {
105 const t = STRUCTURED[name]
106 if (t === undefined || typeof v !== 'string') return v
107 try {
108 const parsed: unknown = JSON.parse(v)
109 return hasType(parsed, t) ? parsed : v
110 } catch {
111 return v
112 }
113}
114
115/** Registered at session.start as `mcp__litter__record`. */
116export const RECORD_TOOL: ToolSpec = {
117 name: 'record',
118 description:
119 'litter: record a step of a litter run. Only litter workflows and the litter skills call this; ' +
120 'the kind and its fields are named in your instructions. A rejected record names the field to fix ' +
121 'and, where there is a fixed set, the allowed values: fix exactly that and call again.',
122 inputSchema: {
123 type: 'object',
124 properties: {
125 kind: { type: 'string', description: 'The record kind your instructions name, such as finding, verify or run.start.' },
126 run: {
127 type: 'string',
128 description: "The run id (<yyyymmdd>-<slug>). A run's agents leave it out: litter knows their run.",
129 },
130 ...Object.fromEntries(Object.entries(STRUCTURED).map(([name, t]) => [name, SCHEMA_OF[t]])),
131 },
132 required: ['kind'],
133 additionalProperties: true,
134 },
135}
136
137/**
138 * Registers the record tool. Resolves null, or the refusal's text when the
139 * engine refused it (managed allowedMcpServers: plan section 9, "Fail closed").
140 */
141export async function registerRecordTool(p: Ports): Promise<string | null> {
142 try {
143 await p.registerTool(RECORD_TOOL)
144 return null
145 } catch (err) {
146 return err instanceof Error ? err.message : String(err)
147 }
148}
149
150/** What the dispatcher needs from the session (session.ts); tier 2 passes its own. */
151export type DispatchEnv = {
152 litterDir: string | null
153 litterDirError: string | null
154 ready: boolean
155 options: LitterOptions
156}
157
158/** tool.call's own keys beside the tool's arguments (d.ts ToolCallReserved, AgentLoop): never part of a record. */
159const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
160
161const bytes = (text: string) => new TextEncoder().encode(text).length
162
163const callerMay = (rule: KindRule, agent: boolean) => rule.by === 'any' || rule.by === (agent ? 'agent' : 'launcher')
164
165/** An executor of a fanout run with no task.bind of its own yet: it may record only task.bind. */
166async function unboundWriter(p: Ports, litterDir: string, run: string, agentId: string): Promise<boolean> {
167 const dir = runDir(litterDir, run)
168 const { records } = await readLedger(p, dir, run)
169 // The workflow from run.start, chained in the ledger: run.json may have been changed since.
170 if (records.find(r => r.kind === 'run.start')?.body.workflow !== 'fanout') return false
171 return !records.some(r => r.kind === 'task.bind' && r.by.source === 'agent' && r.by.agentId === agentId)
172}
173
174/** The kinds this caller may record, for an error's `allowed`. */
175const allowedFor = (agent: boolean): KnownKind[] => KNOWN_KINDS.filter(k => callerMay(KIND_RULES[k], agent))
176
177/** The top-level field with the longest JSON, to name in a too-large error. */
178function largestField(input: Record<string, unknown>): string | undefined {
179 let best: string | undefined
180 let size = -1
181 for (const [k, v] of Object.entries(input)) {
182 const n = JSON.stringify(v ?? null).length
183 if (n > size) {
184 size = n
185 best = k
186 }
187 }
188 return best
189}
190
191/**
192 * The record tool's checks, in order, then the kind's handler (section 9,
193 * "Ledger"): the kind, who may record it, the run, the bounds. Every refusal
194 * names the field and, where there's a fixed set, the allowed values
195 * ("Errors are recoverable"). A handler's other failures throw, so the hook's
196 * catch denies the call.
197 */
198export async function dispatchRecord(
199 p: Ports,
200 agentId: string | undefined,
201 raw: Readonly<Record<string, unknown>>,
202 env: DispatchEnv,
203): Promise<RecordReply> {
204 const fields: Record<string, unknown> = {}
205 for (const [k, v] of Object.entries(raw)) if (!RESERVED.has(k)) fields[k] = decodeField(k, v)
206 const isAgent = typeof agentId === 'string' && agentId !== ''
207
208 // The kind, matched case-insensitively ("some models misname parameters").
209 const given = fields.kind
210 if (typeof given !== 'string' || given.trim() === '') {
211 return fail("litter: 'kind' is required: the record kind your instructions name", 'kind', allowedFor(isAgent))
212 }
213 const lower = given.trim().toLowerCase()
214 if ((USER_ONLY_KINDS as readonly string[]).includes(lower)) {
215 return fail(
216 `litter: '${lower}' is the user's alone, in the rules pane (/litter rules); a tool call can only propose a rule or open one in the pane`,
217 'kind',
218 ['rule.propose', 'rule.open'],
219 )
220 }
221 // A rules-ledger kind only the mod writes (16.7): no run's kind, so it isn't in KIND_RULES.
222 if (lower === 'rule.export') return fail("litter: 'rule.export' is recorded by litter itself, never through this tool", 'kind', allowedFor(isAgent))
223 const kind = KNOWN_KINDS.find(k => k.toLowerCase() === lower)
224 if (!kind) return fail(`litter: '${given}' isn't a record kind`, 'kind', allowedFor(isAgent))
225 const rule = KIND_RULES[kind]
226 if (rule.by === 'mod' || MOD_ONLY_KINDS.includes(kind)) {
227 return fail(`litter: '${kind}' is recorded by litter itself, never through this tool`, 'kind', allowedFor(isAgent))
228 }
229
230 // The spool first, at every record call (section 9, "Draining"): an agent the
231 // map doesn't know yet may only be waiting there, and a mode switch or a
232 // stop spooled before this call is acted on before it.
233 if (env.litterDir) await drainOwnSpools(p, env.litterDir, SPOOL_CONSUMERS)
234 // Who's calling.
235 const info: AgentInfo | undefined = isAgent ? await agentInfo(p, agentId) : undefined
236 const caller: Caller = isAgent ? { agentId, agentType: info?.agentType ?? null } : { agentId: null, agentType: null }
237 if (!callerMay(rule, isAgent)) {
238 return fail(
239 isAgent
240 ? `litter: '${kind}' is recorded from the main thread by a litter skill, not by an agent`
241 : `litter: '${kind}' is recorded by a litter run's agents, not from the main thread`,
242 'kind',
243 allowedFor(isAgent),
244 )
245 }
246 if (!env.litterDir) {
247 return fail(
248 env.ready
249 ? (env.litterDirError ?? "litter: this session has no litter directory, so nothing can be recorded")
250 : 'litter: the session is still starting; call again in a moment',
251 )
252 }
253 const litterDir = env.litterDir
254 const input: RecordInput = { ...fields, kind }
255
256 // The run: an agent's from the agent map, never from its input; a launcher's from its input.
257 const agentRun = info?.role && typeof info.run === 'string' ? info.run : null
258 // A fanout writer records task.bind before anything else (section 7, fanout.js step 2).
259 if (isAgent && kind !== 'task.bind' && info?.role === 'executor' && agentRun !== null && (await unboundWriter(p, litterDir, agentRun, agentId))) {
260 return fail('litter: bind first: record kind task.bind with the bind value from your <task>', 'kind', ['task.bind'])
261 }
262 // A bound swarm node's verify, verdict and land are its swarm's gates (2b-2):
263 // swarm/gate.ts records them in the run the swarm repairs, never the run
264 // the input names. A run agent's go on below, by the agent map. The
265 // bindings are read once recovery has put them back (the state's gate).
266 if (isAgent && agentRun === null && isSwarmGateKind(kind)) {
267 await swarmReady(p)
268 const node = binding(agentId)
269 if (node) {
270 const big = overBound(input)
271 if (big) return big
272 const call: SwarmGateCall = { kind, input, caller: { agentId, agentType: caller.agentType }, binding: node, litterDir, options: env.options }
273 return answer(kind, input.run, () => swarmGateRecord(p, call))
274 }
275 }
276 if (rule.run !== 'none') {
277 if (agentRun !== null) {
278 if (input.run !== undefined && input.run !== agentRun) {
279 return fail(`litter: 'run' is your run, ${agentRun}; leave it out`, 'run', [agentRun])
280 }
281 input.run = agentRun
282 } else if (isAgent && rule.by === 'agent') {
283 return fail(
284 `litter: '${kind}' is recorded by a litter run's agents, and this agent (${caller.agentType ?? 'not started through a litter run'}) isn't one`,
285 )
286 } else if (input.run !== undefined || rule.run === 'required') {
287 if (typeof input.run !== 'string' || !RUN_ID.test(input.run)) {
288 return fail("litter: 'run' is required: the run id, <yyyymmdd>-<slug>, as run.start or run.find returned it", 'run')
289 }
290 if (!(await p.fs.exists(runJsonPath(runDir(litterDir, input.run))))) {
291 return fail(`litter: there's no run ${input.run} in ${litterDir}; run.find lists the runs`, 'run')
292 }
293 }
294 // A broken chain stops the run's records before the handler does any work (section 9, "Chained"), and so
295 // does another store's run (plan.write would write its file first). run.end's handler asks about a run with
296 // no head here; a rule.propose only names its run.
297 if (typeof input.run === 'string') {
298 const dir = runDir(litterDir, input.run)
299 const read = await readLedger(p, dir, input.run)
300 if (read.tampered) {
301 return fail(`litter: run ${input.run}'s ledger is tampered at record ${read.tampered.seq} (${read.tampered.reason}); nothing more is recorded for it`, 'run')
302 }
303 if (kind !== 'run.end' && kind !== 'rule.propose' && (await owned(p, dir, read)) !== 'mine') {
304 return fail(`litter: ${elsewhere(input.run)}; nothing is recorded for it here`, 'run')
305 }
306 }
307 }
308
309 const big = overBound(input)
310 if (big) return big
311 const ctx: RecordCtx = { p, caller, litterDir, options: env.options }
312 return answer(kind, input.run, () => KIND_HANDLERS[kind](ctx, input))
313}
314
315/** Bounded before anything is written (section 9, "Every record is bounded"). */
316function overBound(input: RecordInput): RecordError | null {
317 const size = bytes(JSON.stringify(input))
318 if (size <= RECORD_MAX_BYTES) return null
319 const field = largestField(input)
320 return fail(`litter: the record is ${size} bytes, over the ${RECORD_MAX_BYTES}-byte limit; cut '${field}' down and record again`, field)
321}
322
323/**
324 * A handler's reply, with the ledger's own refusals in the error shape. Its
325 * other failures throw, so the hook's catch denies the call; so does a reply
326 * that isn't one.
327 */
328async function answer(kind: KnownKind, run: unknown, handle: () => Promise<RecordReply>): Promise<RecordReply> {
329 let reply: RecordReply
330 try {
331 reply = await handle()
332 } catch (err) {
333 if (err instanceof RecordTooLarge) return fail(`litter: ${err.message}; cut it down and record again`, err.field ?? undefined)
334 if (err instanceof LedgerTampered) {
335 return fail(`litter: ${typeof run === 'string' ? `run ${run}'s ` : 'the run '}${err.message}; nothing more is recorded for it`, 'run')
336 }
337 if (err instanceof LedgerNotOwned || err instanceof NotOnCodex) return fail(`litter: ${err.message}`)
338 throw err
339 }
340 if (!reply || typeof reply !== 'object' || typeof (reply as { ok?: unknown }).ok !== 'boolean') {
341 throw new Error(`the ${kind} handler gave no reply`)
342 }
343 return reply
344}
345
346/** tool.call on `mcp__litter__record`: dispatches the kind to its handler in KIND_HANDLERS. */
347export const recordTool: PortHook<RecordHook> = async (p, e) => {
348 const s = session()
349 const reply = await dispatchRecord(p, e.agentId, e as unknown as Record<string, unknown>, {
350 litterDir: s.litterDir,
351 litterDirError: s.litterDirError,
352 ready: s.ready,
353 options: getOptions(),
354 })
355 return { result: JSON.stringify(reply) }
356}
357
358/** The catch for recordTool: an error result naming the hook (plan section 9, refusal table). */
359export const recordFailed: RecordCatch = async ($, e, next) => {
360 const why = typeof next.error?.message === 'string' ? next.error.message.replace(/\s+/g, ' ').slice(0, 300) : (next.error?.kind ?? 'unknown')
361 return { deny: `litter guard failed: mcp__litter__record (${why}); refusing` }
362}
363hooks/ledgerowner.ts 42 lines1// Claude Code's answer to ledgercore's `owned` (Ports.ledgerOwner): whether
2// `$.store` owns a run's ledger. A v2 head is this store's own append. A
3// legacy head (from before heads had a version, set by an append or a read)
4// is this store's unless the ledger shows Codex's litterd driving the run: a
5// run.launch or run.resume with a `driver`, or a `codex-` task id. With no
6// head here, another host or Codex home drives it, or this store was lost:
7// the mod doesn't act on it on its own. litterd answers for Codex with its own
8// driver records.
9//
10// The shared ledgers (rules, swarm specs and launches) don't ask: Claude Code
11// is their only writer until Codex has one of its own, so on Claude Code
12// their appends adopt a chain whose head `$.store` lost (a lost store mustn't
13// keep you from accepting a rule or a proposal), and on Codex they refuse
14// (rules/ledger.ts, records/swarm.ts).
15
16import type { AppendOptions, LedgerRead, Owner } from './ledgercore'
17import type { StoredRecord } from './records/core'
18import { session } from './session'
19
20/** A shared ledger's append on Codex. */
21export class NotOnCodex extends Error {
22 constructor(what: string) {
23 super(`${what} isn't written on Codex yet; do it from Claude Code`)
24 }
25}
26
27/** The options for an append to a shared ledger (`what`, for the refusal): adopt on Claude Code; on Codex it throws NotOnCodex. */
28export function sharedAppend(what: string): AppendOptions {
29 if (session().host === 'codex') throw new NotOnCodex(what)
30 return { adopt: true }
31}
32
33export async function claudeLedgerOwner(_runDir: string, read: LedgerRead): Promise<Owner> {
34 if (read.anchor === 'head') return 'mine'
35 if (read.anchor === 'none') return 'other'
36 return read.records.some(codexDriven) ? 'other' : 'mine'
37}
38
39const codexDriven = (r: StoredRecord): boolean =>
40 (r.kind === 'run.launch' || r.kind === 'run.resume') &&
41 (r.body.driver !== undefined || (typeof r.body.taskId === 'string' && r.body.taskId.startsWith('codex-')))
42hooks/policy.ts 507 lines1// The mod's side of the files it shares with the command hooks (plan section 9,
2// "Mod-to-hook state"; contract in hookfiles.ts). Owner: phase 1b, work package B.
3//
4// - the `active` marker and policy.json, replaced atomically (fsx.atomicWrite);
5// - the spool drain: rename aside, read in order, hand each line to every
6// consumer in registry.ts, show a line that doesn't parse and skip it;
7// - the agent map built from the spooled starts and stops, kept in $.store
8// with the run (consumeAgents, agentInfo);
9// - the drain timer ($.clock.every, every 2 s while a run is active).
10
11import type { Ports } from './ports'
12import type { Policy, SpoolConsumer, SpoolCtx, SpoolEvent, SpoolLine } from './hookfiles'
13import { activePath, policyPath, spoolBlobDir, spoolDir, spoolPath } from './layout'
14import { atomicWrite, atomicWriteJson, randomId, readJson, remove, rename, serial } from './fsx'
15import { isRoleName } from './roles'
16import { session } from './session'
17
18// writePolicy, readPolicy and setActive were built in phase 1a, so run.start
19// (D) can use them; B owns them from here on.
20
21/** Writes policy.json atomically, before the call that changed it is answered. */
22export async function writePolicy(p: Ports, litterDir: string, policy: Policy): Promise<void> {
23 await atomicWriteJson(p, policyPath(litterDir), policy)
24}
25
26/** Reads policy.json, or undefined when there's none. One of another version reads as undefined. */
27export async function readPolicy(p: Ports, litterDir: string): Promise<Policy | undefined> {
28 const policy = await readJson<Policy>(p, policyPath(litterDir))
29 return policy?.v === 1 ? policy : undefined
30}
31
32/**
33 * The serial key every read-modify-write of policy.json runs under: updatePolicy,
34 * a run's activate and deactivate (lifecycle.ts), and the swarm part's writer
35 * (swarm/guard.ts), which creates the file when there's none. Work under it
36 * never calls updatePolicy: a nested serial on the same key waits on itself.
37 */
38export const policyKey = (litterDir: string) => `policy:${litterDir}`
39
40/**
41 * Read-modify-write of policy.json, serialised in this module instance, so two
42 * changes (a /clear rebind and a stop, say) can't both start from the same
43 * file. Resolves the policy written, or undefined when there was none. `change`
44 * returns null to leave the file alone.
45 */
46export function updatePolicy(
47 p: Ports,
48 litterDir: string,
49 change: (policy: Policy) => Policy | null,
50): Promise<Policy | undefined> {
51 return serial(policyKey(litterDir), async () => {
52 const policy = await readPolicy(p, litterDir)
53 if (!policy) return undefined
54 const next = change(policy)
55 if (next === null) return policy
56 await writePolicy(p, litterDir, next)
57 return next
58 })
59}
60
61/** Creates or removes `hooks/active`. Create it after policy.json is written, and remove it before. */
62export async function setActive(p: Ports, litterDir: string, on: boolean): Promise<void> {
63 if (on) await atomicWrite(p, activePath(litterDir), '')
64 else await remove(p, activePath(litterDir))
65}
66
67// The drain. Every drain of one litterDir runs under one serial key, so the
68// timer, a record call and session.start never take the same spool apart
69// twice; the helpers below it are unserialised (a nested serial on the same
70// key would wait on itself).
71
72const spoolKey = (litterDir: string) => `spool:${litterDir}`
73
74/** A spool file the drain renamed aside: `<session>.jsonl.draining-<id>`. */
75const ASIDE = /^(.+)\.jsonl\.draining-[A-Za-z0-9-]+$/
76const LIVE = /^(.+)\.jsonl$/
77/** A blob name: one plain file name under spool/blobs/, never a path. */
78const BLOB_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/
79
80const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
81
82/** Why a parsed line isn't a spool line, or null when it is one. */
83function lineProblem(line: Record<string, unknown>): string | null {
84 if (line.v !== 1) return `version ${JSON.stringify(line.v)}, not 1`
85 // REQUIRED is keyed by every SpoolEvent, so a new event can't be left out of this check.
86 if (typeof line.ev !== 'string' || !Object.hasOwn(REQUIRED, line.ev)) return `unknown ev ${JSON.stringify(line.ev)}`
87 if (typeof line.session_id !== 'string' || line.session_id === '') return 'no session_id'
88 if (typeof line.ts !== 'number' || !Number.isFinite(line.ts)) return 'no ts'
89 for (const field of REQUIRED[line.ev as SpoolEvent]) {
90 if (typeof line[field] !== 'string' || line[field] === '') return `no ${field}`
91 }
92 return null
93}
94
95/** The string fields each event can't do without (hookfiles.ts, SpoolLine). */
96const REQUIRED: Readonly<Record<SpoolEvent, readonly string[]>> = {
97 start: ['agent_id', 'agent_type', 'cwd'],
98 stop: ['agent_id', 'agent_type'],
99 output: ['agent_id', 'agent_type', 'tool_use_id'],
100 mode: ['mode'],
101 'isolation-lost': ['agent_id', 'agent_type', 'tool'],
102 deny: ['tool', 'reason'],
103 worktree: ['step', 'run', 'name', 'path', 'branch', 'base'],
104 // Phase 2b's swarm lines (consumed by swarm/observe.ts).
105 call: ['agent_id', 'agent_type', 'tool', 'tool_use_id', 'phase'],
106 handback: ['agent_id', 'agent_type', 'tool_use_id'],
107 hold: ['agent_id', 'phase'],
108 // Phase 5's permission ask (consumed by asks.ts).
109 ask: ['agent_id', 'tool'],
110}
111
112/**
113 * The line with its blob merged in, when it names one in `file`. The blob is
114 * the whole line, with `ev` and the ids also kept on the stub; for an `output`
115 * line, a blob without `ev` is taken as the `input` alone.
116 */
117async function withBlob(
118 p: Ports,
119 litterDir: string,
120 stub: Record<string, unknown>,
121): Promise<{ line: Record<string, unknown>; blob: string | null } | { error: string }> {
122 if (stub.file === undefined) return { line: stub, blob: null }
123 const name = stub.file
124 if (typeof name !== 'string' || !BLOB_NAME.test(name)) return { error: `'file' isn't a blob name: ${JSON.stringify(name)}` }
125 const dir = spoolBlobDir(litterDir)
126 const path = (await p.fs.exists(`${dir}/${name}`)) ? `${dir}/${name}` : `${dir}/${name}.json`
127 let blob: unknown
128 try {
129 blob = JSON.parse(await p.fs.read(path))
130 } catch (err) {
131 return { error: `blob ${name}: ${errText(err)}` }
132 }
133 if (blob === null || typeof blob !== 'object' || Array.isArray(blob)) return { error: `blob ${name} isn't a JSON object` }
134 const { file: _file, ...rest } = stub
135 const full = 'ev' in blob ? { ...(blob as Record<string, unknown>), ...rest } : stub.ev === 'output' ? { ...rest, input: blob } : null
136 return full ? { line: full, blob: path } : { error: `blob ${name} isn't a spool line` }
137}
138
139/**
140 * Hands the complete lines of `text` from `from` on to every consumer, in
141 * order. Resolves how far it read (a cut-off last line is left for the next
142 * read unless `final`), and counts what it skipped.
143 */
144async function deliver(
145 ctx: SpoolCtx,
146 consumers: readonly SpoolConsumer[],
147 name: string,
148 text: string,
149 from: number,
150 final: boolean,
151 tally: { lines: number; bad: number; blobs: string[] },
152): Promise<number> {
153 let at = from
154 for (;;) {
155 const nl = text.indexOf('\n', at)
156 if (nl === -1 && !(final && at < text.length)) return at
157 const end = nl === -1 ? text.length : nl
158 const raw = text.slice(at, end).trim()
159 at = nl === -1 ? text.length : nl + 1
160 if (raw === '') continue
161 tally.lines++
162 const skip = (why: string) => {
163 tally.bad++
164 ctx.p.log(`spool ${name}: skipped a line (${why}): ${raw.slice(0, 120)}`)
165 }
166 let parsed: unknown
167 try {
168 parsed = JSON.parse(raw)
169 } catch {
170 skip("it doesn't parse")
171 continue
172 }
173 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
174 skip('not a JSON object')
175 continue
176 }
177 const merged = await withBlob(ctx.p, ctx.litterDir, parsed as Record<string, unknown>)
178 if ('error' in merged) {
179 skip(merged.error)
180 continue
181 }
182 const problem = lineProblem(merged.line)
183 if (problem !== null) {
184 skip(problem)
185 continue
186 }
187 if (merged.blob) tally.blobs.push(merged.blob)
188 const line = merged.line as SpoolLine
189 for (const consumer of consumers) {
190 try {
191 await consumer(ctx, line)
192 } catch (err) {
193 ctx.p.log(`spool ${name}: a consumer failed on ${line.ev} (${'agent_id' in line ? line.agent_id : '-'}): ${errText(err)}`)
194 }
195 }
196 }
197}
198
199/**
200 * Drains one spool file already renamed aside, then removes it and the blobs
201 * it named. A hook that opened the spool just before the rename can still
202 * append to the aside file, so it's read again until it stops growing. A line
203 * can be delivered twice only after a crash between delivery and removal, so
204 * every consumer is idempotent.
205 */
206async function drainAside(
207 p: Ports,
208 litterDir: string,
209 sessionId: string,
210 aside: string,
211 consumers: readonly SpoolConsumer[],
212): Promise<{ lines: number; bad: number }> {
213 const ctx: SpoolCtx = { p, litterDir, sessionId }
214 const name = aside.slice(aside.lastIndexOf('/') + 1)
215 const tally = { lines: 0, bad: 0, blobs: [] as string[] }
216 let text = ''
217 let done = 0
218 for (let pass = 0; pass < 20; pass++) {
219 const now = await p.fs.read(aside)
220 if (pass > 0 && now.length === text.length) break
221 text = now
222 done = await deliver(ctx, consumers, name, text, done, false, tally)
223 }
224 // A last line without its newline is read as it is: nothing appends to it any more.
225 if (done < text.length) await deliver(ctx, consumers, name, text, done, true, tally)
226 await remove(p, aside)
227 for (const blob of tally.blobs) await remove(p, blob).catch(err => p.log(`spool blob not removed: ${errText(err)}`))
228 return { lines: tally.lines, bad: tally.bad }
229}
230
231/** Unserialised: renames one session's live spool aside and drains it. */
232async function drainLive(ctx: SpoolCtx, consumers: readonly SpoolConsumer[]): Promise<{ lines: number; bad: number }> {
233 const live = spoolPath(ctx.litterDir, ctx.sessionId)
234 if (!(await ctx.p.fs.exists(live))) return { lines: 0, bad: 0 }
235 // New appends start a new file from here on (section 9, "Draining").
236 const aside = `${live}.draining-${randomId()}`
237 await rename(ctx.p, live, aside)
238 return drainAside(ctx.p, ctx.litterDir, ctx.sessionId, aside, consumers)
239}
240
241/** Drains one session's spool into the consumers, once, in order. */
242export function drainSpool(ctx: SpoolCtx, consumers: readonly SpoolConsumer[]): Promise<{ lines: number; bad: number }> {
243 return serial(spoolKey(ctx.litterDir), () => drainLive(ctx, consumers))
244}
245
246/**
247 * The session ids this module instance has run under. /clear changes the id
248 * without a session.start, so a policy naming one of these is this session's
249 * own after a /clear, not another live session's.
250 */
251const mySessions = new Set<string>()
252
253/** The current session id, remembered as one of this instance's own. */
254async function currentSession(p: Ports): Promise<string> {
255 const sid = await p.sessionId()
256 mySessions.add(sid)
257 return sid
258}
259
260/** A swarm owner's beat writes `checkedAt` every 10 s; past this (swarm/owner.ts's OWNER_STALE_MS) it's taken as gone. */
261const SWARM_OWNER_FRESH_S = 60
262
263/**
264 * The other live sessions whose spools are theirs to drain: the one that owns
265 * the active run, and each one that owns a live swarm while its beat is fresh
266 * (a swarm's lines are its owner's nodes', for that session's consumers).
267 * Empty when everything active is this session's.
268 */
269async function foreignOwners(p: Ports, litterDir: string, sid: string): Promise<Set<string>> {
270 const owners = new Set<string>()
271 const policy = await readPolicy(p, litterDir).catch(() => undefined)
272 if (!policy) return owners
273 const foreign = (s: unknown): s is string => typeof s === 'string' && s !== '' && s !== sid && !mySessions.has(s)
274 if (policy.run !== null && foreign(policy.session) && (await p.fs.exists(activePath(litterDir)))) owners.add(policy.session)
275 const now = Math.floor((await p.now()) / 1000)
276 for (const entry of Object.values(policy.swarm?.swarms ?? {})) {
277 const owner = entry?.owner
278 if (foreign(owner?.session) && typeof owner.checkedAt === 'number' && now - owner.checkedAt <= SWARM_OWNER_FRESH_S) owners.add(owner.session)
279 }
280 return owners
281}
282
283/**
284 * At session.start: drains every spool under hooks/spool/, earlier session ids
285 * too, before reconcile (section 9, "Draining"): first the files an earlier
286 * drain renamed aside and never finished, then each live spool, oldest first.
287 * The spool of a run or a swarm another live session owns is left to that session.
288 */
289export async function drainAllSpools(p: Ports, litterDir: string, consumers: readonly SpoolConsumer[]): Promise<void> {
290 const sid = await currentSession(p)
291 await serial(spoolKey(litterDir), async () => {
292 const dir = spoolDir(litterDir)
293 if (!(await p.fs.exists(dir))) return
294 const owners = await foreignOwners(p, litterDir, sid)
295 const files: { name: string; session: string; aside: boolean; mtime: number }[] = []
296 for (const e of await p.fs.list(dir)) {
297 if (e.kind !== 'file') continue
298 const m = ASIDE.exec(e.name) ?? LIVE.exec(e.name)
299 if (!m || owners.has(m[1]!)) continue
300 const mtime = (await p.fs.stat(`${dir}/${e.name}`).catch(() => null))?.mtimeMs ?? 0
301 files.push({ name: e.name, session: m[1]!, aside: ASIDE.test(e.name), mtime })
302 }
303 files.sort((a, b) => Number(b.aside) - Number(a.aside) || a.mtime - b.mtime || a.name.localeCompare(b.name))
304 for (const f of files) {
305 try {
306 const r = f.aside
307 ? await drainAside(p, litterDir, f.session, `${dir}/${f.name}`, consumers)
308 : await drainLive({ p, litterDir, sessionId: f.session }, consumers)
309 if (r.bad > 0) p.log(`spool ${f.name}: ${r.bad} of ${r.lines} lines skipped`)
310 } catch (err) {
311 p.log(`spool ${f.name} not drained: ${errText(err)}`)
312 }
313 }
314 })
315}
316
317/**
318 * Drains this session's spools: at a record call, the Workflow hook and the
319 * task notification, and on every timer tick (section 9, "Draining"). After a
320 * /clear the policy still names the session this instance ran under before, so
321 * it's rewritten to name the current one first (section 12: "policy.json names
322 * the owning session again after a /clear"), and both sessions' spools are
323 * drained. A run another live session owns is left alone.
324 */
325export async function drainOwnSpools(p: Ports, litterDir: string, consumers: readonly SpoolConsumer[]): Promise<void> {
326 const sid = await currentSession(p)
327 const policy = await readPolicy(p, litterDir)
328 if (policy && policy.session !== sid && mySessions.has(policy.session)) {
329 await updatePolicy(p, litterDir, cur => (cur.session !== sid && mySessions.has(cur.session) ? { ...cur, session: sid } : null))
330 }
331 await serial(spoolKey(litterDir), async () => {
332 for (const s of mySessions) {
333 try {
334 await drainLive({ p, litterDir, sessionId: s }, consumers)
335 } catch (err) {
336 p.log(`spool ${s} not drained: ${errText(err)}`)
337 }
338 }
339 })
340}
341
342let timer: { cancel(): void } | null = null
343let ticking = false
344
345/**
346 * Starts the drain timer for this module instance (p.every, so it ends when
347 * the module unloads). A session.start after a reload replaces the old timer.
348 * A tick does one `exists` while nothing is active, and skips while the last
349 * tick is still draining.
350 */
351export function startDrainTimer(p: Ports, consumers: readonly SpoolConsumer[]): void {
352 timer?.cancel()
353 timer = p.every(2000, () => {
354 if (ticking) return
355 ticking = true
356 void tick(p, consumers)
357 .catch(err => p.log(`drain timer: ${errText(err)}`))
358 .finally(() => {
359 ticking = false
360 })
361 })
362}
363
364async function tick(p: Ports, consumers: readonly SpoolConsumer[]): Promise<void> {
365 const litterDir = session().litterDir
366 if (!litterDir || !(await p.fs.exists(activePath(litterDir)))) return
367 await drainOwnSpools(p, litterDir, consumers)
368}
369
370// The agent map: who each agent is, from its spooled start and stop, kept in
371// $.store so it outlives a reload. The record tool and the land check read it
372// for the caller's role and run; nothing here trusts bind/ files.
373
374export type AgentInfo = {
375 agentId: string
376 agentType: string
377 /** The `litter:` role, when it's one. */
378 role: string | null
379 run: string | null
380 cwd: string
381 started: number
382 stopped: number | null
383 /** The session whose spool carried the start. */
384 sessionId?: string
385 transcriptPath?: string
386}
387
388const agentKey = (agentId: string) => `litter.agent.${agentId}`
389const runAgentsKey = (run: string) => `litter.agents.${run}`
390
391/** The role an agent type names, when it's a litter role (`litter:<role>`). */
392export function roleOf(agentType: string): string | null {
393 const name = agentType.startsWith('litter:') ? agentType.slice('litter:'.length) : null
394 return name !== null && isRoleName(name) ? name : null
395}
396
397/** The agent map's entry for an agentId, or undefined when no start was spooled for it. */
398export async function agentInfo(p: Ports, agentId: string): Promise<AgentInfo | undefined> {
399 const info = (await p.store.get(agentKey(agentId))) as AgentInfo | undefined
400 return info && typeof info === 'object' && info.agentId === agentId ? info : undefined
401}
402
403/** The agent ids started in a run, in start order. */
404export async function runAgents(p: Ports, run: string): Promise<string[]> {
405 const ids = await p.store.get(runAgentsKey(run))
406 return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string') : []
407}
408
409/** Drops a run's agents from the store, once nothing reads them (after its run.end), so the map doesn't grow without end. */
410export async function forgetRunAgents(p: Ports, run: string): Promise<void> {
411 for (const id of await runAgents(p, run)) await p.store.delete(agentKey(id))
412 await p.store.delete(runAgentsKey(run))
413}
414
415/**
416 * Spool consumer: start and stop lines build the agent map. A start for a
417 * litter role while a run is active belongs to that run (drains run before
418 * reconcile, so the policy still names the run its lines came from). A line
419 * that contradicts the map, or a stop for an agent it doesn't know, changes
420 * nothing and is logged (section 12).
421 */
422export const consumeAgents: SpoolConsumer = async (ctx, line) => {
423 if (line.ev === 'start') {
424 const cwd = line.cwd.normalize('NFC')
425 const known = await agentInfo(ctx.p, line.agent_id)
426 if (known) {
427 if (known.agentType !== line.agent_type || known.cwd !== cwd) {
428 ctx.p.log(`agent ${line.agent_id}: a start as ${line.agent_type} in ${cwd} contradicts ${known.agentType} in ${known.cwd}; ignored`)
429 } else if (known.stopped !== null) {
430 // A stopped agent that's messaged again starts again with the same id.
431 await ctx.p.store.set(agentKey(line.agent_id), { ...known, stopped: null } satisfies AgentInfo)
432 }
433 return
434 }
435 const role = roleOf(line.agent_type)
436 const run = role ? ((await readPolicy(ctx.p, ctx.litterDir))?.run?.run ?? null) : null
437 const info: AgentInfo = {
438 agentId: line.agent_id,
439 agentType: line.agent_type,
440 role,
441 run,
442 cwd,
443 started: Math.round(line.ts * 1000),
444 stopped: null,
445 sessionId: line.session_id,
446 transcriptPath: line.transcript_path,
447 }
448 await ctx.p.store.set(agentKey(line.agent_id), info)
449 if (run) {
450 const ids = await runAgents(ctx.p, run)
451 if (!ids.includes(line.agent_id)) await ctx.p.store.set(runAgentsKey(run), [...ids, line.agent_id])
452 }
453 return
454 }
455 if (line.ev === 'stop') {
456 const known = await agentInfo(ctx.p, line.agent_id)
457 if (!known) {
458 ctx.p.log(`agent ${line.agent_id}: a stop for an agent with no start; ignored`)
459 return
460 }
461 if (known.agentType !== line.agent_type) {
462 ctx.p.log(`agent ${line.agent_id}: a stop as ${line.agent_type} contradicts ${known.agentType}; ignored`)
463 return
464 }
465 if (known.stopped === null) await ctx.p.store.set(agentKey(line.agent_id), { ...known, stopped: Math.round(line.ts * 1000) } satisfies AgentInfo)
466 }
467}
468
469/**
470 * The stop of an agent whose loop was interrupted, which no SubagentStop
471 * reports: the engine marks the loop done before it checks the abort, so
472 * runAgent's SubagentStop for an interrupted query returns at once (2.1.288
473 * `:739409`, `:739446`; 2.1.287 the same). A TaskStop'd workflow's agents end
474 * that way, and a stopping run would never settle. The agent's aborted
475 * turn.complete stands in: the consumers get the stop line identity.sh would
476 * have spooled, after a drain, so its start is in the map. An agent the map
477 * doesn't know, or has seen stop, changes nothing.
478 */
479export async function stopInterrupted(p: Ports, litterDir: string, agentId: string, consumers: readonly SpoolConsumer[]): Promise<boolean> {
480 await drainOwnSpools(p, litterDir, consumers)
481 const sessionId = await currentSession(p)
482 const ts = (await p.now()) / 1000
483 return serial(spoolKey(litterDir), async () => {
484 const known = await agentInfo(p, agentId)
485 if (!known || known.stopped !== null) return false
486 const line: SpoolLine = {
487 v: 1,
488 ev: 'stop',
489 ts,
490 session_id: known.sessionId ?? sessionId,
491 agent_id: agentId,
492 agent_type: known.agentType,
493 transcript_path: known.transcriptPath ?? '',
494 stop_hook_active: false,
495 background_tasks: 0,
496 }
497 for (const consumer of consumers) {
498 try {
499 await consumer({ p, litterDir, sessionId }, line)
500 } catch (err) {
501 p.log(`interrupted agent ${agentId}: a consumer failed on its stop: ${errText(err)}`)
502 }
503 }
504 return true
505 })
506}
507hooks/layout.ts 193 lines1// Every path litter writes or reads, from litterDir down (plan section 10,
2// "On disk"). All absolute: the mod never passes a relative path to $.fs,
3// since EnterWorktree moves the session's working directory mid-session.
4//
5// Contract owner: phase 1a. The command hooks (lib.sh) mirror the hooks/ part.
6
7export const SEP = '/'
8
9const join = (...parts: string[]): string => parts.join(SEP)
10
11/** `<primary checkout>/.claude/litter`, resolved once per session (litterdir.ts). */
12export type LitterDir = string
13
14// hooks/: shared with the command hooks (plan section 9, "Mod-to-hook state").
15export const hooksDir = (litterDir: LitterDir) => join(litterDir, 'hooks')
16/** Exists only while a run is active or a swarm is live. Every script's first test. */
17export const activePath = (litterDir: LitterDir) => join(hooksDir(litterDir), 'active')
18export const policyPath = (litterDir: LitterDir) => join(hooksDir(litterDir), 'policy.json')
19export const worktreePolicyPath = (litterDir: LitterDir) => join(hooksDir(litterDir), 'worktree.json')
20export const agentPolicyPath = (litterDir: LitterDir, agentId: string) => join(hooksDir(litterDir), 'agents', `${agentId}.json`)
21export const outPath = (litterDir: LitterDir, agentId: string) => join(hooksDir(litterDir), 'out', `${agentId}.json`)
22/** worktree.sh's step marker for one worktree name (hookfiles.WtMarker). */
23export const wtMarkerPath = (litterDir: LitterDir, name: string) => join(hooksDir(litterDir), 'wt', name)
24export const bindPath = (litterDir: LitterDir, agentId: string) => join(hooksDir(litterDir), 'bind', agentId)
25export const modePath = (litterDir: LitterDir) => join(hooksDir(litterDir), 'mode')
26export const spoolDir = (litterDir: LitterDir) => join(hooksDir(litterDir), 'spool')
27export const spoolPath = (litterDir: LitterDir, sessionId: string) => join(spoolDir(litterDir), `${sessionId}.jsonl`)
28/** Lines over 4 KiB go to a file here, named by the line's `file` field. */
29export const spoolBlobDir = (litterDir: LitterDir) => join(spoolDir(litterDir), 'blobs')
30/**
31 * In a session started in a linked worktree, `$CLAUDE_PROJECT_DIR/.claude/litter/hooks/pointer`
32 * holds the absolute path of the real hooks/ folder. lib.sh follows it.
33 */
34export const pointerPath = (projectDir: string) => join(projectDir, '.claude', 'litter', 'hooks', 'pointer')
35// Phase 2b, the swarm markers (hookfiles.ts says who writes each).
36/** A spawn the mod has reserved and not yet bound: one file per `tool_use_id`, so the guard waits for its binding. */
37export const pendingDir = (litterDir: LitterDir) => join(hooksDir(litterDir), 'pending')
38export const pendingPath = (litterDir: LitterDir, key: string) => join(pendingDir(litterDir), key)
39/** A node or child that handed back, or whose stop hold.sh let go. */
40export const donePath = (litterDir: LitterDir, agentId: string) => join(hooksDir(litterDir), 'done', agentId)
41/** hold.sh's list of the children a worker was already held for. */
42export const holdPath = (litterDir: LitterDir, agentId: string) => join(hooksDir(litterDir), 'hold', agentId)
43/** The background tasks a bound agent started (watch.sh), so hold.sh counts only its own. */
44export const shellsPath = (litterDir: LitterDir, agentId: string) => join(hooksDir(litterDir), 'shells', agentId)
45
46// Runs.
47export const runDir = (litterDir: LitterDir, run: string) => join(litterDir, run)
48export const runJsonPath = (runDir: string) => join(runDir, 'run.json')
49export const ledgerDir = (runDir: string) => join(runDir, 'ledger')
50export const ledgerFileName = (seq: number, kind: string) => `${String(seq).padStart(6, '0')}-${kind}.json`
51export const ledgerExportDir = (runDir: string) => join(runDir, 'ledger-export')
52/** One part of the ledger's export, from 1: `ledger-export/0001.jsonl` (ledgerexport.ts). */
53export const ledgerExportPart = (runDir: string, part: number) => join(ledgerExportDir(runDir), `${String(part).padStart(4, '0')}.jsonl`)
54export const verifyLogDir = (runDir: string, verifyId: string) => join(runDir, 'verify', verifyId)
55export const verifyLogPart = (runDir: string, verifyId: string, part: number) =>
56 join(verifyLogDir(runDir, verifyId), `${String(part).padStart(4, '0')}.log`)
57export const planPath = (runDir: string) => join(runDir, 'plan.md')
58export const summaryPath = (runDir: string) => join(runDir, 'summary.md')
59export const proposalPath = (runDir: string, version: string) => join(runDir, 'proposals', `${version}.md`)
60export const verdictsDir = (runDir: string) => join(runDir, 'verdicts')
61/**
62 * An execute verdict, rendered from its ledger record (plan section 10): the
63 * gate and attempt of the verify it cites. Both have passed `isPlanGate` (or
64 * are `goal`) and are a whole number, so they join.
65 */
66export const verdictFile = (runDir: string, gate: string, attempt: number) => join(verdictsDir(runDir), `${gate}-${attempt}.md`)
67/**
68 * A design verdict, rendered from its ledger record (plan section 10; phase
69 * 4): the version it judged, which has passed PROPOSAL_VERSION.
70 */
71export const designVerdictPath = (runDir: string, version: string) => join(verdictsDir(runDir), `${version}.md`)
72/** A probe's report on one unknown (plan section 10; decision 18): the id has passed UNKNOWN_ID. */
73export const probePath = (runDir: string, unknownId: string) => join(runDir, 'probes', `${unknownId}.md`)
74/** A version the mod assigns a design's proposal, `v<n>` from 1 (plan 7, step 6): it names `proposals/` and `verdicts/` files. */
75export const PROPOSAL_VERSION = /^v[1-9][0-9]*$/
76/** An empirical unknown's id, `U<n>` from 1 (decisions 3 and 18): it names a `probes/` file. */
77export const UNKNOWN_ID = /^U[1-9][0-9]*$/
78/** An earlier `plan.md`, kept by plan.write and gate.amend (plan section 10), from 1. */
79export const planVersionPath = (runDir: string, n: number) => join(runDir, `plan.${n}.md`)
80/** A worktree's setup log, `verify/setup-<name>/0001.log`, written by worktree.sh or the mod. */
81export const setupLogPart = (runDir: string, name: string) => verifyLogPart(runDir, `setup-${name}`, 1)
82
83// Worktrees (plan section 7, fanout.js; section 9, "Run worktrees").
84/** The integration worktree's name; its path is `<worktreeRoot>/<name>`. */
85export const integrationName = (run: string) => `litter-${run}-integration`
86/** `refs/heads/` is left off: these are branch names. */
87export const integrationBranch = (run: string) => `litter/${run}-integration`
88/** A workflow agent's worktree `<runId>-<n>` gets `litter/<run>-w<n>`. */
89export const writerBranch = (run: string, n: number) => `litter/${run}-w${n}`
90
91/** `<litterDir>/WIKI.md` (plan section 10, "On disk"). */
92export const wikiPath = (litterDir: LitterDir) => join(litterDir, 'WIKI.md')
93
94// Lock lease (plan section 9, "Launch preconditions", step 0).
95export const leasePath = (litterDir: LitterDir, oid: string) => join(litterDir, `lock-lease-${oid}.json`)
96
97// Swarms (plan 15.8). Every name below has passed SWARM_NAME, NODE_ID or
98// LAUNCH_ID before it gets here: these join, they don't check.
99export const swarmsDir = (litterDir: LitterDir) => join(litterDir, 'swarms')
100export const swarmDir = (litterDir: LitterDir, name: string) => join(swarmsDir(litterDir), name)
101/** The spec at its latest saved revision. */
102export const swarmJsonPath = (litterDir: LitterDir, name: string) => join(swarmDir(litterDir, name), 'swarm.json')
103export const swarmBriefPath = (litterDir: LitterDir, name: string, node: string) => join(swarmDir(litterDir, name), 'briefs', `${node}.md`)
104/** Every saved or accepted revision, from 1. */
105export const revisionPath = (litterDir: LitterDir, name: string, n: number) => join(swarmDir(litterDir, name), 'revisions', `${n}.json`)
106export const revisionBriefPath = (litterDir: LitterDir, name: string, n: number, node: string) =>
107 join(swarmDir(litterDir, name), 'revisions', String(n), 'briefs', `${node}.md`)
108/** A lead proposal as staged: `spec.json` and `briefs/<node>.md` inside. */
109export const swarmProposalDir = (litterDir: LitterDir, name: string, n: number) => join(swarmDir(litterDir, name), 'proposals', String(n))
110export const launchesDir = (litterDir: LitterDir, name: string) => join(swarmDir(litterDir, name), 'launches')
111/** One launch: its ledger is `ledgerDir(launchDir(…))`, as the spec's is `ledgerDir(swarmDir(…))`. */
112export const launchDir = (litterDir: LitterDir, name: string, launch: string) => join(launchesDir(litterDir, name), launch)
113/** Written at swarm.end: nodes, last reports, goal history, the branches and worktrees left. */
114export const launchSummaryPath = (litterDir: LitterDir, name: string, launch: string) => join(launchDir(litterDir, name, launch), 'summary.md')
115/** The owner's lease beside its ref's blob (plan 15.4, "One session owns a live swarm"). */
116export const ownerLeasePath = (litterDir: LitterDir, name: string, oid: string) => join(swarmDir(litterDir, name), `owner-lease-${oid}.json`)
117/** This project's templates; repository content, so listing one does nothing else. */
118export const projectTemplatesDir = (litterDir: LitterDir) => join(litterDir, 'swarm-templates')
119/** Yours, for every project: `configDir` is `CLAUDE_CONFIG_DIR` or `~/.claude` (ports.ts, configDir). */
120export const userTemplatesDir = (configDir: string) => join(configDir, 'litter', 'swarm-templates')
121
122// Rules (plan 16.1, 16.4; phase 3). `primary` is the primary checkout
123// (lifecycle.ts, primaryOf); a bare repository has no project or local scope.
124/** Your rules, for every project. */
125export const userRulesDir = (configDir: string) => join(configDir, 'litter', 'rules')
126/** The project's rules, committed. */
127export const projectRulesDir = (primary: string) => join(primary, '.litter', 'rules')
128/** Your rules for this repository, never committed (`/.litter.local/` in `<common dir>/info/exclude`). */
129export const localRulesDir = (primary: string) => join(primary, '.litter.local', 'rules')
130/** A rule folder's earlier and retired versions, `<id>@<v>.md`; never loaded. */
131export const ruleHistoryDir = (rulesDir: string) => join(rulesDir, '.history')
132// Exported rules (16.7, phase 6): Claude Code loads these folders' rule files itself, in sessions with no litter run.
133/** A user rule's export: `<config dir>/rules/litter/<id>.md`. */
134export const userRuleExportDir = (configDir: string) => join(configDir, 'rules', 'litter')
135/** A project rule's export, committed with `.litter/`. */
136export const projectRuleExportDir = (primary: string) => join(primary, '.claude', 'rules', 'litter')
137/** A local rule's export, never committed (`/.claude/rules/litter-local/` in `<common dir>/info/exclude`). */
138export const localRuleExportDir = (primary: string) => join(primary, '.claude', 'rules', 'litter-local')
139/**
140 * Where [Confirm restore] keeps a hand-edited export it replaces,
141 * `<id>@<v>-<stamp>.md`: beside the repository's rules ledger, which the
142 * litter directory's .gitignore keeps out of git, and outside every folder
143 * Claude Code loads rules from.
144 */
145export const ruleExportEditsDir = (litterDir: string) => join(litterDir, 'rules-ledger', 'export-edits')
146/**
147 * The rules ledgers (decision 17), each a ledgercore directory, so the
148 * records are under its `ledger/`: the repository's holds every project and
149 * local rule's records and every proposal made here; yours every user rule's.
150 */
151export const repoRulesLedgerDir = (litterDir: LitterDir) => join(litterDir, 'rules-ledger')
152export const userRulesLedgerDir = (configDir: string) => join(configDir, 'litter', 'rules-ledger')
153/** The rules ledgers' `run` field (ledgercore's records carry one). */
154export const RULES_LEDGER_RUN = 'rules'
155
156/** A swarm's ownership ref: create-only, at a blob naming the owning session (15.4). */
157export const swarmRef = (name: string) => `refs/litter-swarm/${name}`
158/** A node's type as `$.agent.register` takes it. The engine serves it as `litter:sw-<swarm>-<node>`. */
159export const nodeTypeName = (swarm: string, node: string) => `sw-${swarm}-${node}`
160/** The prefix of every swarm type as the engine serves it. Never split a type on it: look it up (state.ts, swarmOfType). */
161export const SWARM_TYPE_PREFIX = 'litter:sw-'
162/** A `new` worktree's folder under the worktree root (15.3). */
163export const swarmNewWorktreeName = (swarm: string, node: string) => `sw-${swarm}-${node}`
164/** An `own` worker's folder: one per instance. */
165export const swarmOwnWorktreeName = (swarm: string, node: string, n: number) => `sw-${swarm}-${node}-${n}`
166/** An `own` worker's branch, without `refs/heads/`. */
167export const swarmOwnBranch = (swarm: string, node: string, n: number) => `sw/${swarm}/${node}-${n}`
168/** The spec ledger's `run` field. */
169export const specRun = (name: string) => `swarm:${name}`
170/** A launch ledger's `run` field. */
171export const launchRun = (name: string, launch: string) => `swarm:${name}/${launch}`
172
173export const SWARM_NAME = /^[a-z0-9][a-z0-9-]{0,23}$/
174export const NODE_ID = /^[a-z0-9][a-z0-9-]{0,31}$/
175/** `<yyyymmdd-hhmmss>` in UTC, `-2`, `-3`… on a collision (records/swarm.ts, newLaunchId). */
176export const LAUNCH_ID = /^[0-9]{8}-[0-9]{6}(-[0-9]+)?$/
177
178/**
179 * Paths the snapshot and the clean-tree check leave out (plan section 9, "Snapshot").
180 * Relative to the checkout root, as git pathspecs.
181 */
182export const SNAPSHOT_EXCLUDES = [
183 '.claude/litter',
184 '.claude/worktrees',
185 '.litter',
186 '.litter.local',
187 '.claude/rules/litter',
188 '.claude/rules/litter-local',
189] as const
190
191/** A run id: `<yyyymmdd>-<slug>`. */
192export const RUN_ID = /^[0-9]{8}-[a-z0-9][a-z0-9-]{0,62}$/
193hooks/roles.ts 106 lines1// The roles (plan section 6), registered with $.agent.register at
2// session.start as `litter:<name>`. Owner: phase 1b, work package B.
3//
4// ROLE_POLICIES is the guard's view of each role; policy.ts writes it into
5// policy.json, so the role table has one source. ROLE_TOOLS is the section 6
6// table's Tools column. No role names a model: section 6 mentions a per-role
7// `model` option, but section 10's option list has none, so every role runs on
8// the session's model until one exists.
9
10import type { AgentSpec, Ports } from './ports'
11import type { RoleName, RolePolicy } from './hookfiles'
12import { ROLE_PROMPTS } from './prompts/index'
13
14export const ROLE_POLICIES: Record<RoleName, RolePolicy> = {
15 falsifier: { write: 'none', bash: 'ro', isolated: false },
16 coverage: { write: 'none', bash: 'ro', isolated: false },
17 mechanism: { write: 'none', bash: 'ro', isolated: false },
18 reframer: { write: 'none', bash: 'ro', isolated: false },
19 prober: { write: 'own-worktree', bash: 'rw', isolated: true },
20 author: { write: 'none', bash: 'ro', isolated: false },
21 'design-critic': { write: 'none', bash: 'ro', isolated: false },
22 'gate-critic': { write: 'none', bash: 'ro', isolated: false },
23 executor: { write: 'checkout', bash: 'rw', isolated: false },
24 adversary: { write: 'none', bash: 'ro', isolated: false },
25 relay: { write: 'none', bash: 'ro', isolated: false },
26 planner: { write: 'none', bash: 'ro', isolated: false },
27 verifier: { write: 'none', bash: 'ro', isolated: false },
28 researcher: { write: 'none', bash: 'ro', isolated: false },
29}
30
31export const ROLE_NAMES = Object.keys(ROLE_POLICIES) as RoleName[]
32
33export const isRoleName = (name: string): name is RoleName => Object.hasOwn(ROLE_POLICIES, name)
34
35const RECORD = 'mcp__litter__record'
36const READ = ['Read', 'Grep', 'Glob']
37
38/**
39 * Section 6's Tools column. "Bash (read-only)" is Bash here: the guard's `ro`
40 * rules (ROLE_POLICIES) and, for the adversary and researcher, the script's
41 * bashCommandClamp make it read-only. Those two never record, because a clamp
42 * drops every mcp__ tool (section 9, "Guard").
43 */
44export const ROLE_TOOLS: Readonly<Record<RoleName, readonly string[]>> = {
45 falsifier: [...READ, 'Bash', RECORD],
46 coverage: [...READ, 'Bash', RECORD],
47 mechanism: [...READ, 'Bash', RECORD],
48 reframer: [...READ, 'Bash', RECORD],
49 prober: [...READ, 'Bash', 'Write', RECORD],
50 author: [...READ, RECORD],
51 'design-critic': [...READ, 'Bash', RECORD],
52 'gate-critic': [...READ, 'Bash', RECORD],
53 executor: [...READ, 'Edit', 'Write', 'Bash', RECORD],
54 adversary: [...READ, 'Bash'],
55 relay: [RECORD],
56 planner: [...READ, RECORD],
57 verifier: [...READ, 'Bash', RECORD],
58 researcher: [...READ, 'Bash'],
59}
60
61/** Section 6's "Used in" column, for the listing line. */
62const USED_IN: Readonly<Record<RoleName, string>> = {
63 falsifier: 'design runs',
64 coverage: 'design runs',
65 mechanism: 'design runs',
66 reframer: 'design runs',
67 prober: 'design runs',
68 author: 'design runs',
69 'design-critic': 'design runs',
70 'gate-critic': 'execute runs',
71 executor: 'execute and fanout runs',
72 adversary: 'execute runs',
73 relay: 'execute and fanout runs',
74 planner: 'plan runs',
75 verifier: 'fanout runs',
76 researcher: 'fanout runs',
77}
78
79/**
80 * The spec for one role. The listing still shows every role (agent.offer must
81 * not hide them, section 6), so the description says who spawns it.
82 */
83export function roleSpec(role: RoleName): AgentSpec {
84 const spec: AgentSpec = {
85 name: role,
86 description: `litter ${role} role (${USED_IN[role]}). Spawned by litter workflows only; don't spawn it yourself.`,
87 prompt: ROLE_PROMPTS[role],
88 tools: ROLE_TOOLS[role],
89 permissionMode: 'auto',
90 }
91 if (ROLE_POLICIES[role].isolated) spec.isolation = 'worktree'
92 return spec
93}
94
95/** Registers every role. Resolves the names it couldn't register, with the reason. */
96export async function registerRoles(p: Ports): Promise<{ failed: { role: RoleName; reason: string }[] }> {
97 const results = await Promise.allSettled(ROLE_NAMES.map(role => p.registerAgent(roleSpec(role))))
98 const failed: { role: RoleName; reason: string }[] = []
99 results.forEach((r, i) => {
100 if (r.status === 'rejected') {
101 failed.push({ role: ROLE_NAMES[i]!, reason: r.reason instanceof Error ? r.reason.message : String(r.reason) })
102 }
103 })
104 return { failed }
105}
106hooks/litterdir.ts 285 lines1// litterDir: `<primary checkout>/.claude/litter`, resolved once per session
2// from git's own answers (plan section 10, "One litter directory"). Also the
3// read-only `run.find` record. Owner: phase 1b, work package B.
4//
5// Every git call here is a fixed argv run with `cwd` set; nothing in it comes
6// from a model or the repository except paths git itself printed, and those
7// only ever go to `cwd` or $.fs (section 9, "Mod safety").
8
9import type { Ports } from './ports'
10import type { RecordHandler } from './records/core'
11import type { RunJson, Workflow } from './records/run'
12import { fail } from './records/core'
13import { configuredOptions } from './options'
14import { getOptions } from './session'
15import { hooksDir, pointerPath, RUN_ID, runDir, runJsonPath } from './layout'
16import { atomicWrite, readJson } from './fsx'
17import { readLedger } from './ledgercore'
18
19export type LitterDirResult = { ok: true; litterDir: string; primary: string | null; commonDir: string } | { ok: false; error: string }
20
21const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
22
23/** The path with every link resolved, or null when it leads nowhere. */
24async function real(p: Ports, path: string): Promise<string | null> {
25 try {
26 return (await p.fs.stat(path, { resolve: true })).realPath ?? null
27 } catch {
28 return null
29 }
30}
31
32/** `git <argv>` in `cwd`: its stdout lines, or null when it exits non-zero or can't start. */
33async function git(p: Ports, cwd: string, argv: readonly string[]): Promise<string[] | null> {
34 try {
35 const r = await p.run(['git', ...argv], { cwd })
36 return r.code === 0 ? r.stdout.split('\n').filter(l => l !== '') : null
37 } catch {
38 return null
39 }
40}
41
42/** One key of the common dir's own config file (not the worktree's), or null when unset. */
43async function commonConfig(p: Ports, commonDir: string, key: string, bool = false): Promise<string | null> {
44 const out = await git(p, commonDir, ['config', '--file', `${commonDir}/config`, ...(bool ? ['--bool'] : []), '--get', key])
45 return out?.[0] ?? null
46}
47
48/** `<dir>/<rel>` with `.` and `..` folded; `rel` absolute is taken as it is. */
49function resolvePath(dir: string, rel: string): string {
50 const parts = rel.startsWith('/') ? [] : dir.split('/').filter(Boolean)
51 for (const part of rel.split('/')) {
52 if (part === '' || part === '.') continue
53 if (part === '..') parts.pop()
54 else parts.push(part)
55 }
56 return `/${parts.join('/')}`
57}
58
59/** `git worktree list --porcelain`, as entries in git's order. */
60async function worktreeList(p: Ports, cwd: string): Promise<{ path: string; branch: string | null; bare: boolean }[] | null> {
61 const out = await git(p, cwd, ['worktree', 'list', '--porcelain'])
62 if (!out) return null
63 const list: { path: string; branch: string | null; bare: boolean }[] = []
64 for (const line of out) {
65 if (line.startsWith('worktree ')) list.push({ path: line.slice('worktree '.length), branch: null, bare: false })
66 else if (list.length > 0 && line.startsWith('branch ')) list[list.length - 1]!.branch = line.slice('branch '.length)
67 else if (list.length > 0 && line === 'bare') list[list.length - 1]!.bare = true
68 }
69 return list
70}
71
72/**
73 * A candidate primary counts only as a directory whose top level is itself
74 * and whose git dir and common dir are both this repository's common dir
75 * (step 3), so a stale or forged litter-primary fails here.
76 */
77async function verifyPrimary(p: Ports, candidate: string, commonDir: string): Promise<string | null> {
78 if (!candidate.startsWith('/')) return null
79 const dir = await real(p, candidate)
80 if (!dir || (await p.fs.stat(dir).catch(() => null))?.kind !== 'dir') return null
81 const out = await git(p, dir, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'])
82 if (!out || out.length !== 3) return null
83 const [top, gitDir, common] = await Promise.all(out.map(o => real(p, o)))
84 return top === dir && gitDir === commonDir && common === commonDir ? dir : null
85}
86
87const primaryRecordPath = (commonDir: string) => `${commonDir}/litter-primary`
88
89/**
90 * The repository's primary checkout and litterDir, for the session's starting
91 * directory (section 10, steps 1 to 6). From a linked worktree it also writes
92 * the pointer the command hooks follow (`<toplevel>/.claude/litter/hooks/pointer`,
93 * layout.pointerPath), and refuses when it can't, since without it the scripts
94 * there would find no `active` and let everything through.
95 */
96export async function resolveLitterDir(p: Ports, cwd: string): Promise<LitterDirResult> {
97 if (!cwd.startsWith('/')) return { ok: false, error: `litter: the session's directory isn't absolute (${cwd})` }
98 const dirs = await git(p, cwd, ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'])
99 if (!dirs || dirs.length !== 2) {
100 return { ok: false, error: `litter: ${cwd} isn't inside a git repository. litter runs need one: start the session in a repository's checkout.` }
101 }
102 const gitDir = await real(p, dirs[0]!)
103 const commonDir = await real(p, dirs[1]!)
104 if (!gitDir || !commonDir) return { ok: false, error: `litter: git named a git directory that doesn't exist (${dirs.join(', ')})` }
105 // A bare repository has no top level, so it's asked for on its own.
106 const topOut = await git(p, cwd, ['rev-parse', '--path-format=absolute', '--show-toplevel'])
107 const toplevel = topOut?.[0] ? await real(p, topOut[0]) : null
108
109 let primary: string | null = null
110 let litterDir: string
111 if (gitDir === commonDir && toplevel) {
112 // Step 1: the primary checkout (an ordinary repo, a submodule, a separate git dir's own checkout).
113 primary = toplevel
114 litterDir = `${primary}/.claude/litter`
115 // Step 4: recorded, so a later session in a separate git dir's linked worktree can find it.
116 try {
117 const recorded = (await p.fs.exists(primaryRecordPath(commonDir))) ? (await p.fs.read(primaryRecordPath(commonDir))).trim() : null
118 if (recorded !== primary) await atomicWrite(p, primaryRecordPath(commonDir), `${primary}\n`)
119 } catch (err) {
120 p.log(`litter-primary not recorded in ${commonDir}: ${errText(err)}`)
121 }
122 } else if ((await commonConfig(p, commonDir, 'core.bare', true)) === 'true') {
123 // Step 5: no primary checkout; the same answer from every worktree.
124 litterDir = `${commonDir}/litter`
125 } else {
126 // Step 2: a linked worktree. The candidates, in order.
127 const candidates: string[] = []
128 const wt = await commonConfig(p, commonDir, 'core.worktree')
129 if (wt) candidates.push(resolvePath(commonDir, wt))
130 const first = (await worktreeList(p, cwd))?.[0]
131 if (first && !first.bare && (await real(p, first.path)) !== commonDir) candidates.push(first.path)
132 try {
133 if (await p.fs.exists(primaryRecordPath(commonDir))) candidates.push((await p.fs.read(primaryRecordPath(commonDir))).trim())
134 } catch (err) {
135 p.log(`litter-primary in ${commonDir} unreadable: ${errText(err)}`)
136 }
137 for (const c of candidates) {
138 primary = await verifyPrimary(p, c, commonDir)
139 if (primary) break
140 }
141 if (!primary) {
142 // Step 6.
143 return {
144 ok: false,
145 error:
146 `litter: can't find the primary checkout of the repository at ${cwd}: it's a linked worktree whose primary ` +
147 `no litter session has opened yet (a separate git dir, or a moved checkout). Open a session in the primary ` +
148 `checkout once, then this worktree resolves too.`,
149 }
150 }
151 litterDir = `${primary}/.claude/litter`
152 }
153
154 if (toplevel && toplevel !== primary) {
155 const res = await writePointer(p, toplevel, litterDir)
156 if (res) return { ok: false, error: res }
157 }
158 return { ok: true, litterDir, primary, commonDir }
159}
160
161/**
162 * Writes `<projectDir>/.claude/litter/hooks/pointer`, the absolute hooks/
163 * folder the scripts follow from a linked worktree (layout.pointerPath), when
164 * it doesn't already say so. Resolves null, or why it couldn't.
165 */
166export async function writePointer(p: Ports, projectDir: string, litterDir: string): Promise<string | null> {
167 const path = pointerPath(projectDir)
168 const want = hooksDir(litterDir)
169 try {
170 if ((await p.fs.exists(path)) && (await p.fs.read(path)).trim() === want) return null
171 await atomicWrite(p, path, `${want}\n`)
172 return null
173 } catch (err) {
174 return `litter: couldn't write ${path}, which the command hooks in this worktree need to find ${want} (${errText(err)})`
175 }
176}
177
178/**
179 * `<litterDir>/.gitignore`: what of litter's folder never goes into git (plan
180 * section 14, "Should .claude/litter/ be committed?"). The rest is yours to
181 * commit.
182 */
183export const LITTER_GITIGNORE = [
184 "# Written by litter. Its runtime files and logs stay out of git: the hooks' state, the locks, the ledgers and",
185 "# the verify logs. The rest is yours to commit: each run's run.json, its copies of the plan, summary.md,",
186 '# verdicts and proposals; each swarm\'s spec, briefs, revisions and summaries; the swarm templates.',
187 '/hooks/',
188 '/lock-lease-*.json',
189 '/rules-ledger/',
190 '/[0-9]*/ledger/',
191 '/[0-9]*/ledger-export/',
192 '/[0-9]*/verify/',
193 '/swarms/*/ledger/',
194 '/swarms/*/owner-lease-*.json',
195 '/swarms/*/launches/*/ledger/',
196 '',
197].join('\n')
198
199/**
200 * Writes `<litterDir>/.gitignore` (LITTER_GITIGNORE) once, when there's none:
201 * one you've changed or removed the lines of is left as it is. Only for a
202 * checkout's `.claude/litter`; a bare repository's lies outside any tree.
203 */
204export async function writeGitignore(p: Ports, litterDir: string): Promise<void> {
205 if (!litterDir.endsWith('/.claude/litter')) return
206 const path = `${litterDir}/.gitignore`
207 if (await p.fs.exists(path)) return
208 await atomicWrite(p, path, LITTER_GITIGNORE)
209}
210
211const WORKFLOWS: readonly Workflow[] = ['execute', 'fanout', 'design', 'plan']
212
213export type FoundRun = {
214 run: string
215 runDir: string
216 checkout: string | null
217 workflow: Workflow
218 status: string
219 started: number | null
220}
221
222/**
223 * A run's status from its ledger: the last of run.launch or run.resume
224 * (active), run.stopping (stopping) and run.end (its status); `pending`
225 * before the first launch; `ledger_tampered` once the chain is broken.
226 */
227async function runStatus(p: Ports, dir: string, run: string): Promise<{ status: string; started: number | null }> {
228 const { records, tampered } = await readLedger(p, dir, run)
229 let status = 'pending'
230 for (const r of records) {
231 if (r.kind === 'run.launch' || r.kind === 'run.resume') status = 'active'
232 else if (r.kind === 'run.stopping') status = 'stopping'
233 else if (r.kind === 'run.end' && typeof r.body.status === 'string') status = r.body.status
234 }
235 const started = records.find(r => r.kind === 'run.start')?.at ?? null
236 return { status: tampered ? 'ledger_tampered' : status, started }
237}
238
239/** The repository litterDir belongs to, for `git worktree list`: the primary checkout, or a bare repo's common dir. */
240const repoOf = (litterDir: string) =>
241 litterDir.endsWith('/.claude/litter') ? litterDir.slice(0, -'/.claude/litter'.length) : litterDir.slice(0, litterDir.lastIndexOf('/'))
242
243/**
244 * `run.find { run?, workflow?, options? }`: absolute paths, newest first,
245 * writes nothing. With `options: true` the reply adds the configured options a
246 * skill shows (design D9); without it the reply is as it always was.
247 */
248export const recordRunFind: RecordHandler = async (ctx, input) => {
249 if (!ctx.litterDir) return fail("litter: this session has no litter directory, so there's no run to find")
250 const want = input.run
251 if (want !== undefined && (typeof want !== 'string' || !RUN_ID.test(want))) {
252 return fail("litter: 'run' must be a run id, <yyyymmdd>-<slug>, or left out to list every run", 'run')
253 }
254 const wf = input.workflow
255 if (wf !== undefined && (typeof wf !== 'string' || !(WORKFLOWS as readonly string[]).includes(wf))) {
256 return fail("litter: 'workflow' must be one of the allowed values, or left out", 'workflow', WORKFLOWS)
257 }
258 if (input.options !== undefined && typeof input.options !== 'boolean') return fail("litter: 'options' must be true, or left out", 'options')
259 const litterDir = ctx.litterDir
260 const names = (await ctx.p.fs.exists(litterDir))
261 ? (await ctx.p.fs.list(litterDir)).filter(e => e.kind === 'dir' && RUN_ID.test(e.name) && (want === undefined || e.name === want)).map(e => e.name)
262 : []
263 // Where each branch is checked out; null for all when git can't say.
264 const byBranch = new Map<string, string>()
265 for (const w of (await worktreeList(ctx.p, repoOf(litterDir))) ?? []) if (w.branch) byBranch.set(w.branch, w.path)
266 const runs: FoundRun[] = []
267 for (const run of names) {
268 const dir = runDir(litterDir, run)
269 let json: RunJson | undefined
270 try {
271 json = await readJson<RunJson>(ctx.p, runJsonPath(dir))
272 } catch (err) {
273 ctx.p.log(`run.find: ${run}/run.json unreadable: ${errText(err)}`)
274 continue
275 }
276 if (!json || json.v !== 1 || json.run !== run) continue
277 if (wf !== undefined && json.workflow !== wf) continue
278 const { status, started } = await runStatus(ctx.p, dir, run)
279 const ref = typeof json.ref === 'string' ? (json.ref.startsWith('refs/') ? json.ref : `refs/heads/${json.ref}`) : null
280 runs.push({ run, runDir: dir, checkout: ref ? (byBranch.get(ref) ?? null) : null, workflow: json.workflow, status, started })
281 }
282 runs.sort((a, b) => (b.started ?? 0) - (a.started ?? 0) || b.run.localeCompare(a.run))
283 return input.options === true ? { ok: true, litterDir, runs, options: configuredOptions(getOptions()) } : { ok: true, litterDir, runs }
284}
285