SLOPSHOPPER

litter

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

new
★ 8v0.1.0MITupdated 2026-10-15Und3rf10w/claude-litter/plugins/litter
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (56 | deps as viewDeps, ^ error: No matching export in "slop-empty:/home/runner/work/slopshopper/slopshopper/.cache/src/und3rf10w-claude-litter-litter/hooks/rules/p
README

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

Install

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

  • Claude Code 2.1.288 or later;
  • git;
  • jq on PATH (for the command hooks);
  • a POSIX 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.

What it does

CommandWhat 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:wikiRebuilds .claude/litter/WIKI.md, an index of every litter run and swarm in the repository and the decisions they produced.

How runs are verified and landed

  • **Workflows (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.
  • The Mod (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.
  • **Command Hook Guard (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.
  • When a run fails (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.

The /litter pane

/litter [section] [arg] opens litter's in-session UI pane with eight sections:

  1. Home — overview of active runs, live swarms, open questions, and pending rule proposals
  2. Runs — the board of runs, gates, attempts, verdicts, and summaries
  3. Swarms — the interactive swarm builder and live swarm tree browser
  4. Rules — review, accept, dismiss, retire, or import user, project, and local rules
  5. Tasks — view and edit a team's tasks
  6. Agents — inspect agents, write to an agent's inbox, broadcast to a team, and stop an agent
  7. Stats — session and per-swarm spend, token/cache breakdown, context, and limits
  8. Doctor — environment, hook, and policy diagnostics

ctrl+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.

Options

Configure litter via /plugin or under pluginConfigs.litter.options in a Claude Code settings file (userConfig in plugins/litter/.claude-plugin/plugin.json):

OptionDefaultDescription
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.
verifyTimeoutMs1800000Maximum duration (ms) for a verify, goalCheck, or setup command before litter terminates it and records a timeout.
maxAttempts3Maximum executor attempts per gate (1–10) before an execute run halts.
maxRounds4Maximum 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).
summaryEverySec60Minimum 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.
repairRounds3Maximum verified and independently judged repairs (1–10) a repair swarm may run before asking for more budget.
repairAgents6Maximum 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.
bandEndedMinutes10Minutes (0–1440) an ended run remains visible in the status band above the prompt (0 removes it immediately).

Development

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.

Acknowledgements

Source 50 files
hooks/register.tsx 639 lines
1// 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}
639
hooks/ports.ts 294 lines
1// 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}
294
hooks/ui/pane.tsx 143 lines
1// 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}
143
hooks/hooktypes.ts 133 lines
1// 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>
133
hooks/session.ts 45 lines
1// 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}
45
hooks/registry.ts 125 lines
1// 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]
125
hooks/ledger.ts 363 lines
1// 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}
363
hooks/ledgerowner.ts 42 lines
1// 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-')))
42
hooks/policy.ts 507 lines
1// 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}
507
hooks/layout.ts 193 lines
1// 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}$/
193
hooks/roles.ts 106 lines
1// 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}
106
hooks/litterdir.ts 285 lines
1// 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