A state-machine graph engine that drives a ticket through claim -> worktree -> coding -> evaluate -> review -> human approval, spawning and monitoring…

Demo source code, not a maintained project. This repository accompanies an article on in-session loop engineering with Claude Code Mods. It is published as-is to show the pattern, and it is not regularly updated. Mods are an early-access Claude Code feature that can change between releases; this code was last checked against Claude Code 2.1.280, and it may need changes to run on later versions. Issues and pull requests are not actively monitored.
A ticket-to-review coding loop that runs inside a Claude Code session, built as a Claude Code Mod (TypeScript event hooks).
It claims tickets and gives each one its own git worktree. It spawns a coding agent, runs checks, then has a separate reviewer agent judge the result, and waits for a person to approve. Up to three tickets run at once, and a dashboard pane inside the session shows every ticket's progress with Pause, Stop, Approve and Reject buttons.
██╗ ██████╗ ██████╗ ██████╗
██║ ██╔═══██╗██╔═══██╗██╔══██╗
██║ ██║ ██║██║ ██║██████╔╝
██║ ██║ ██║██║ ██║██╔═══╝
███████╗╚██████╔╝╚██████╔╝██║
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝
▃▅▆███▆▅▃▁▁▁▃▄▆███▆▅▃▁▁▁▃▄▆███▆▅▃▁
The tickets are demo tickets, and the checks are this repo's own tsc and npm test. To adapt the pattern to a real codebase, replace the ticket source (hooks/ticket.ts) and the checks (hooks/eval.ts) with that repository's own.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Without it, the plugin is skipped silently.npm install
# Start Claude Code with the plugin loaded
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .
Inside the session, type /autonomous-loop to open the dashboard. A pane the plugin opens by itself only appears on terminals at least 144 columns wide; the command opens it at any width.
On startup the loop claims open tickets and starts working. Progress lines also appear in the transcript. The loop's state is kept in the plugin's own store (~/.claude/plugins/store/autonomous-loop_…json), so a new session resumes where the last one stopped.
If nothing happens, run with --debug-file /tmp/loop.log and search the log for autonomous-loop. A line saying the hooks module was "not loaded" means the environment variable is missing.
Every status change goes through one pure function and this table (hooks/state.ts):
claim → prepare worktree → coding → evaluate ──pass──→ review ──pass──→ human approval → done
▲ │ │
└─retry──┘ │
▲ │
└────────── fail ───────────┘
evaluate / coding ── blocker ──→ waiting for human ── answered ──→ coding
| Status | Event | Next |
|---|---|---|
claimed | PREPARED | coding |
coding | AGENT_FINISHED / BLOCKER_FOUND | evaluating / waiting_for_human |
evaluating | PASS / RETRY / BLOCKER_FOUND / EXHAUSTED | reviewing / coding / waiting_for_human / blocked |
waiting_for_human | ANSWERED / CANCELLED | coding / blocked |
reviewing | PASS / FAIL / EXHAUSTED | awaiting_approval / coding / blocked |
awaiting_approval | APPROVED / REJECTED | done / blocked |
Each step does its work and reports an event; the table decides what happens next.
WebFetch, WebSearch and AskUserQuestion are denied). The first attempt uses sonnet; retries escalate to opus.Read, Grep, Glob and Bash, always on opus. The agent that writes a change never grades it.hooks/eval.ts). A failure is classified as retryable or as needing a person (hooks/blocker.ts).Each prompt includes the ticket's title and description. A retry also sees the failed checks' output and the reviewer's reasons from the previous attempt.
host patternHandlers receive the engine interface as $, but Claude Code refuses to load a module that passes $ around as a value: it statically lists every $.noun.method(...) call a plugin makes. So session.start wraps the calls it needs in a plain object of closures (hooks/host.ts), and passes that to the rest of the code:
const host: Host = {
storeGet: (key) => $.store.get(key),
run: (argv, init) => $.process.run(argv, init),
spawnAgent: (args) => $.agent.spawn(args),
// ...
}
void workflow.runWorkflow(host, { cwd: e.cwd })
Everything below index.ts depends only on the Host type, which is also what makes it testable with fakes.
runWorkflow keeps up to maxConcurrent tickets (default 3) in flight and refills a slot as soon as one finishes. The tickets are async functions in one process, sharing one host but each with its own state and worktree. Run state is saved as one whole snapshot after every step. Ticket-store updates go through a promise-chain lock, because each update is a read-modify-write of the whole list.
| Path | What it is |
|---|---|
.claude-plugin/plugin.json | Plugin manifest |
hooks/hooks.json | Mods entry point: { "modules": ["./index.ts"] } |
hooks/index.ts | Registers hooks and agent types, builds host, starts the loop |
hooks/host.ts | The Host type |
hooks/workflow.ts | Pool scheduler and the per-ticket driver loop |
hooks/state.ts | WorkflowState, the transition table, model routing, persistence |
hooks/agent.ts | Spawns agents and waits for their turn.complete |
hooks/ticket.ts | Demo ticket store with claims and leases |
hooks/worktree.ts | Git worktree creation and diff stats |
hooks/eval.ts, hooks/blocker.ts | Checks, and classification of failures |
hooks/approval.ts | Human approval and questions, per ticket |
hooks/heartbeat.ts | Lease renewal for active tickets |
hooks/policy.ts | Agent type names and tool lists |
hooks/dashboard/render.tsx | The dashboard pane |
hooks/animation.ts | The LOOP banner animation |
The plugin is typed against Claude Code's Mods type declarations, which are not included here because they are early access and change between releases. Generate them for your version by running /plugin-types in a Claude Code session opened in this folder. It writes .claude/types/, which tsconfig.json already includes. Then:
npm install
npm run typecheck
claude plugin validate . # checks the plugin the way the engine will load it
-p) runs wait forever at a human gate. There is no dashboard to answer from, and the loop deliberately never approves on a person's behalf.tool.call and turn.step for events the plugin itself caused, so tool restrictions are set when the agent types are registered, not in a tool.call hook.hooks/index.ts 136 lines1// hooks/index.ts — the plugin entry point: registers every hook and starts
2// the ticket loop when a session begins.
3
4import type { Register } from 'claude-code'
5import * as agent from './agent'
6import * as policy from './policy'
7import * as heartbeat from './heartbeat'
8import * as animation from './animation'
9import * as workflow from './workflow'
10import * as approval from './approval'
11import * as dashboard from './dashboard/render'
12import type { Host } from './host'
13
14// A repeat session.start (reconnect, hot reload) must drop the previous
15// subscription, or every state change triggers one extra redraw per reload.
16let unsubscribeWorkflow: (() => void) | undefined
17
18export const register: Register = (on) => {
19 on('session.start', async ($, e, next) => {
20 const result = await next(e)
21 await $.ui.open({ id: dashboard.PANE_ID, title: dashboard.PANE_TITLE })
22
23 // Built inline so every member is a literal `$.noun.method(...)` call;
24 // the object itself is plain data and can be passed anywhere (see host.ts).
25 const host: Host = {
26 now: () => $.clock.now(),
27 sleep: (ms) => $.clock.sleep(ms),
28 every: (ms, fn) => $.clock.every(ms, fn),
29 storeGet: (key) => $.store.get(key),
30 storeSet: (key, value) => $.store.set(key, value),
31 storeDelete: (key) => $.store.delete(key),
32 run: (argv, init) => $.process.run(argv, init),
33 fsExists: (path) => $.fs.exists(path),
34 spawnAgent: (args) => $.agent.spawn(args),
35 listAgents: () => $.agent.list(),
36 uiAsk: (question, options) => $.ui.ask(question, options),
37 uiOpen: (args) => $.ui.open(args),
38 uiInvalidate: (what) => $.ui.invalidate(what),
39 uiLog: (text, opts) => $.ui.log(text, opts),
40 }
41
42 // Registered before the workflow starts, so no spawn can race it. Errors
43 // are left to surface here rather than as unknown-subagent spawn denials.
44 await $.agent.register({
45 name: 'coding',
46 description: 'autonomous-loop: implements a ticket in the current worktree (internal use only)',
47 prompt:
48 'You are an autonomous coding agent. Implement the given ticket in the current worktree ' +
49 'using the available tools.',
50 disallowedTools: policy.CODING_DENIED_TOOLS,
51 })
52 await $.agent.register({
53 name: 'review',
54 description: 'autonomous-loop: reviews the diff in the current worktree (internal use only)',
55 prompt:
56 'You are an autonomous code reviewer. Inspect the diff and evidence in the current ' +
57 'worktree, then respond with PASS or FAIL.',
58 tools: policy.REVIEW_ALLOWED_TOOLS,
59 })
60
61 // A pane the plugin opens on its own waits undrawn below 144 terminal
62 // columns; one opened from a command the person typed is placed at any width.
63 await $.command.register({
64 name: 'autonomous-loop',
65 description: 'Open the autonomous-loop dashboard',
66 })
67
68 heartbeat.startHeartbeat(host, {
69 // Every active ticket needs its lease renewed, not just one.
70 ticketIds: () => workflow.currentStates().map((s) => s.ticketId),
71 onBeat: () => $.ui.invalidate('ui.render'),
72 })
73 animation.startAnimationTicker(
74 host,
75 // One shared animation for the pane: on while ANY ticket is coding/reviewing.
76 () => workflow.currentStates().some((s) => s.status === 'coding' || s.status === 'reviewing'),
77 () => $.ui.invalidate('ui.render'),
78 )
79 unsubscribeWorkflow?.()
80 unsubscribeWorkflow = workflow.subscribe(() => $.ui.invalidate('ui.render'))
81 void workflow.runWorkflow(host, { cwd: e.cwd }).catch((err) =>
82 $.ui.log(`autonomous-loop: ${String(err)}`, { to: 'debug' }))
83 return result
84 })
85
86 on('session.end', async ($, e, next) => {
87 // Stop this process's timers and counters. Worktrees and leases are left
88 // alone on purpose: an unfinished ticket resumes from them next session.
89 heartbeat.stopHeartbeat()
90 animation.stopAnimationTicker()
91 policy.resetStats()
92 return next(e)
93 })
94
95 // Every hook is written as a function literal at its on() call site: the
96 // engine scans the source before loading and refuses a hook passed as a
97 // factory's return value. Factories are called inside the literal instead.
98
99 // The two agent types are for this plugin's own spawns only; hide them
100 // from the main model's agent list.
101 on('agent.offer', { agent: policy.CODING_AGENT_TYPE }, () => ({ isOffered: false }))
102 on('agent.offer', { agent: policy.REVIEW_AGENT_TYPE }, () => ({ isOffered: false }))
103
104 on('turn.complete', ($, e, next) => agent.createTurnCompleteHook()(e, next))
105 // turn.step and tool.call are skipped for events the plugin's own agents
106 // raise (the engine does not re-enter a plugin), so these only observe
107 // other activity. Tool limits are enforced by agent.register above.
108 on('turn.step', async function* ($, e, next) {
109 return yield* agent.createTurnStepHook()(e, next)
110 })
111 on('tool.call', ($, e, next) =>
112 policy.createToolCallHook({
113 agentKindOf: agent.agentKindOf,
114 observe: agent.observeToolCall,
115 })(e, next),
116 )
117 // Opened from a command, so the pane is placed at any width. Re-opening an
118 // already-open pane is a no-op. This hook is the command, so no next().
119 on('command.run', { command: 'autonomous-loop' }, async ($) => {
120 await $.ui.open({ id: dashboard.PANE_ID, title: dashboard.PANE_TITLE })
121 return { text: 'Dashboard opened.' }
122 })
123
124 on('ui.render', { component: 'Pane' }, ($, e, next) => {
125 if (!dashboard.isOwnPane(e)) return next(e)
126 // Resolved here and passed down as elements — `$` itself never leaves this hook.
127 const elements = $.ui.resolve(e)
128 return dashboard.renderDashboard(elements, e, {
129 states: workflow.currentStates(),
130 emit: (ev) => workflow.submitEvent(ev),
131 awaitingApproval: (ticketId) => approval.isAwaitingApproval(ticketId),
132 awaitingHuman: (ticketId) => approval.isAwaitingHuman(ticketId),
133 })
134 })
135}
136hooks/agent.ts 368 lines1// hooks/agent.ts — spawns the coding and review subagents, tracks each by
2// agentId, and resolves its run from the matching turn.complete. Exports hook
3// factories only; index.ts does all the on(...) registration.
4
5import type {
6 EngineInterface,
7 Frozen,
8 Hook,
9 Timer,
10 ToolCallInput,
11 TurnCompleteInput,
12 TurnCompleteReason,
13} from 'claude-code'
14import type { Host } from './host'
15import { pickModel } from './state'
16import type { ModelTier, WorkflowState } from './state'
17import { CODING_AGENT_TYPE, REVIEW_AGENT_TYPE } from './policy'
18
19export type AgentKind = 'coding' | 'review'
20
21export type AgentProgress = {
22 agentId: string
23 kind: AgentKind
24 model: ModelTier
25 steps: number
26 toolCalls: readonly string[]
27 lastText: string
28 startedAtMs: number
29}
30
31export type AgentRunResult = {
32 kind: AgentKind
33 model: ModelTier
34 agentId?: string
35 answer: string
36 isAborted: boolean
37 reason?: TurnCompleteReason
38 steps: number
39 toolCalls: readonly string[]
40 durationMs: number
41 denied?: string
42}
43
44export type SpawnRequest = {
45 kind: AgentKind
46 state: WorkflowState
47 prompt: string
48 description: string
49 cwd?: string
50 subagentType?: string
51}
52
53// Registered by index.ts with policy.ts's tool lists (see policy.ts).
54export const CODING_SUBAGENT_TYPE = CODING_AGENT_TYPE
55export const REVIEW_SUBAGENT_TYPE = REVIEW_AGENT_TYPE
56
57// ---------------------------------------------------------------------------
58// Internal tracking state.
59// ---------------------------------------------------------------------------
60
61type PendingRun = {
62 agentId: string
63 kind: AgentKind
64 model: ModelTier
65 ticketId: string
66 resolve: (result: AgentRunResult) => void
67}
68
69const progress = new Map<string, AgentProgress>()
70const pending = new Map<string, PendingRun>()
71const liveness = new Map<string, Timer>()
72
73// An agent that dies without finishing a turn never sends turn.complete, so
74// runAgent polls $.agent.list() to avoid waiting forever.
75const LIVENESS_POLL_MS = 15_000
76
77function stopLivenessWatch(agentId: string): void {
78 liveness.get(agentId)?.cancel()
79 liveness.delete(agentId)
80}
81
82/**
83 * Settles the pending run as aborted if the engine reports the agent stopped
84 * running before any turn.complete. The agent must be seen `running` once
85 * first, so a listing that lags a fresh spawn is not mistaken for a death.
86 */
87function startLivenessWatch(host: Host, agentId: string): void {
88 let sawRunning = false
89 const timer = host.every(LIVENESS_POLL_MS, () => {
90 void (async () => {
91 const run = pending.get(agentId)
92 if (run === undefined) {
93 stopLivenessWatch(agentId)
94 return
95 }
96 const infos = await host.listAgents()
97 const info = infos.find((i) => i.id === agentId)
98 if (info === undefined) {
99 if (sawRunning) {
100 settleDead(agentId, 'agent is no longer listed by $.agent.list() and no turn.complete arrived')
101 }
102 return
103 }
104 if (info.status === 'running') {
105 sawRunning = true
106 return
107 }
108 settleDead(
109 agentId,
110 `agent ended with status '${info.status}' and no turn.complete arrived`,
111 )
112 })()
113 })
114 liveness.set(agentId, timer)
115}
116
117/** Settles a run the engine reports as no longer running, absent a turn.complete. */
118function settleDead(agentId: string, reason: string): void {
119 const run = pending.get(agentId)
120 if (run === undefined) return
121 stopLivenessWatch(agentId)
122 const p = progress.get(agentId)
123 run.resolve({
124 kind: run.kind,
125 model: run.model,
126 agentId,
127 answer: '',
128 isAborted: true,
129 reason: 'aborted',
130 steps: p?.steps ?? 0,
131 toolCalls: p?.toolCalls ?? [],
132 durationMs: 0,
133 denied: reason,
134 })
135}
136
137// ---------------------------------------------------------------------------
138// Spawning.
139// ---------------------------------------------------------------------------
140
141export async function runAgent(
142 host: Host,
143 request: SpawnRequest,
144): Promise<AgentRunResult> {
145 // Route by `kind`, not by state.status, so a review always gets the review model.
146 const model: ModelTier = pickModel({
147 status: request.kind === 'review' ? 'reviewing' : 'coding',
148 attempt: request.state.attempt,
149 })
150
151 const startedAtMs = await host.now()
152
153 const spawned = await host.spawnAgent({
154 prompt: request.prompt,
155 description: request.description,
156 subagentType:
157 request.subagentType ??
158 (request.kind === 'review' ? REVIEW_SUBAGENT_TYPE : CODING_SUBAGENT_TYPE),
159 model,
160 cwd: request.cwd ?? request.state.worktreePath,
161 })
162
163 if (spawned.deny !== undefined) {
164 return {
165 kind: request.kind,
166 model,
167 answer: '',
168 isAborted: false,
169 steps: 0,
170 toolCalls: [],
171 durationMs: 0,
172 denied: spawned.deny,
173 }
174 }
175
176 const agentId = spawned.agentId
177 if (agentId === undefined) {
178 return {
179 kind: request.kind,
180 model,
181 answer: '',
182 isAborted: false,
183 steps: 0,
184 toolCalls: [],
185 durationMs: 0,
186 denied: 'spawn resolved without an agentId',
187 }
188 }
189
190 // Track before awaiting, so an early turn.complete is not missed.
191 progress.set(agentId, {
192 agentId,
193 kind: request.kind,
194 model,
195 steps: 0,
196 toolCalls: [],
197 lastText: '',
198 startedAtMs,
199 })
200
201 const result = await new Promise<AgentRunResult>((resolve) => {
202 pending.set(agentId, {
203 agentId,
204 kind: request.kind,
205 model,
206 ticketId: request.state.ticketId,
207 resolve: (result) => {
208 pending.delete(agentId)
209 progress.delete(agentId)
210 stopLivenessWatch(agentId)
211 resolve(result)
212 },
213 })
214 startLivenessWatch(host, agentId)
215 })
216 return result
217}
218
219// ---------------------------------------------------------------------------
220// Hook factories.
221// ---------------------------------------------------------------------------
222
223/**
224 * Only `reason === 'answer'` is a real completion. Every other reason becomes
225 * a `denied` string, so workflow.ts routes it to a blocker, not a finish.
226 */
227function deniedReasonFor(e: Frozen<TurnCompleteInput>): string | undefined {
228 switch (e.reason) {
229 case 'answer':
230 return undefined
231 case 'refusal':
232 // Either field may be null.
233 return `model refused: ${e.refusal.category ?? 'unknown'}${
234 e.refusal.explanation ? ` — ${e.refusal.explanation}` : ''
235 }`
236 case 'aborted':
237 return 'turn aborted (interrupted)'
238 case 'error':
239 return 'turn ended in error (retries exhausted or context limit)'
240 }
241}
242
243// Hook bodies take no `$`: the loader refuses any function that receives `$`
244// from outside its own on(...) call site.
245type TurnCompleteHookBody = (
246 e: Parameters<Hook<'turn.complete'>>[1],
247 next: Parameters<Hook<'turn.complete'>>[2],
248) => ReturnType<Hook<'turn.complete'>>
249
250/** on('turn.complete', ...): resolves the deferred for e.agentId, then `return next(e)`. */
251export function createTurnCompleteHook(): TurnCompleteHookBody {
252 return (e, next) => {
253 if (e.agentId !== undefined) {
254 const run = pending.get(e.agentId)
255 if (run !== undefined) {
256 const p = progress.get(e.agentId)
257 run.resolve({
258 kind: run.kind,
259 model: run.model,
260 agentId: e.agentId,
261 answer: e.answer,
262 isAborted: e.isAborted,
263 reason: e.reason,
264 steps: p?.steps ?? 0,
265 toolCalls: p?.toolCalls ?? [],
266 durationMs: e.durationMs,
267 denied: deniedReasonFor(e),
268 })
269 }
270 }
271 return next(e)
272 }
273}
274
275type TurnStepHookBody = (
276 e: Parameters<Hook<'turn.step'>>[1],
277 next: Parameters<Hook<'turn.step'>>[2],
278) => ReturnType<Hook<'turn.step'>>
279
280/** on('turn.step', ...): async generator; `return yield* next(e)` after counting the step for e.agentId. */
281export function createTurnStepHook(): TurnStepHookBody {
282 return async function* (e, next) {
283 const result = yield* next(e)
284 if (e.agentId !== undefined) {
285 const p = progress.get(e.agentId)
286 if (p !== undefined) {
287 p.steps += 1
288 if (result?.answer) p.lastText = result.answer
289 }
290 }
291 return result
292 }
293}
294
295/** Called by policy.ts's tool.call hook (agent.ts registers no tool.call hook of its own). */
296export function observeToolCall(e: Frozen<ToolCallInput>): void {
297 if (e.agentId === undefined) return
298 const p = progress.get(e.agentId)
299 if (p === undefined) return
300 p.toolCalls = [...p.toolCalls, e.tool]
301}
302
303// ---------------------------------------------------------------------------
304// Read-only accessors.
305// ---------------------------------------------------------------------------
306
307export function agentKindOf(agentId: string | undefined): AgentKind | undefined {
308 if (agentId === undefined) return undefined
309 return progress.get(agentId)?.kind
310}
311
312export function isTrackedAgent(agentId: string | undefined): boolean {
313 return agentId !== undefined && progress.has(agentId)
314}
315
316export function trackedAgents(): readonly AgentProgress[] {
317 return [...progress.values()]
318}
319
320export function progressOf(agentId: string): AgentProgress | undefined {
321 return progress.get(agentId)
322}
323
324// ---------------------------------------------------------------------------
325// Teardown.
326// ---------------------------------------------------------------------------
327
328function settleAbandoned(run: PendingRun, reason: string): void {
329 stopLivenessWatch(run.agentId)
330 run.resolve({
331 kind: run.kind,
332 model: run.model,
333 agentId: run.agentId,
334 answer: '',
335 isAborted: true,
336 reason: 'aborted',
337 steps: progress.get(run.agentId)?.steps ?? 0,
338 toolCalls: progress.get(run.agentId)?.toolCalls ?? [],
339 durationMs: 0,
340 denied: reason,
341 })
342 pending.delete(run.agentId)
343 progress.delete(run.agentId)
344}
345
346/** Aborts the agents of one ticket (on its Stop), never another ticket's. */
347export function abandonForTicket(ticketId: string, reason: string): void {
348 for (const run of [...pending.values()]) {
349 if (run.ticketId === ticketId) settleAbandoned(run, reason)
350 }
351}
352
353/** Aborts every pending agent, on full teardown. */
354export function abandonAll(reason: string): void {
355 for (const run of [...pending.values()]) settleAbandoned(run, reason)
356}
357
358/** Convenience over $.agent.list(), filtered to this plugin's tracked ids. */
359export async function liveAgentStatuses(
360 host: Host,
361): Promise<Record<string, string>> {
362 const infos = await host.listAgents()
363 const ids = new Set(progress.keys())
364 return Object.fromEntries(
365 infos.filter((i) => ids.has(i.id)).map((i) => [i.id, i.status]),
366 )
367}
368hooks/policy.ts 96 lines1// hooks/policy.ts — tool permissions for the loop's coding and review agents.
2
3import type { Frozen, Hook, ToolCallInput } from 'claude-code'
4import type { AgentKind } from './agent'
5
6export type PolicyDecision =
7 | { allow: true; reason?: undefined }
8 | { allow: false; reason: string }
9
10export type PolicyStats = {
11 calls: number
12 denied: number
13 byTool: Record<string, number>
14 deniedByTool: Record<string, number>
15}
16
17export type PolicyDeps = {
18 agentKindOf: (agentId: string | undefined) => AgentKind | undefined
19 observe: (e: Frozen<ToolCallInput>) => void
20}
21
22/** Tools a 'coding' subagent may never call. */
23export const CODING_DENIED_TOOLS: readonly string[] = ['WebFetch', 'WebSearch', 'AskUserQuestion']
24
25/** Tools a 'review' subagent may call; anything else is denied for kind === 'review'. */
26export const REVIEW_ALLOWED_TOOLS: readonly string[] = ['Read', 'Grep', 'Glob', 'Bash']
27
28// The real enforcement is at registration: index.ts registers these agent
29// types with the lists above, and the engine applies them itself. A tool.call
30// hook cannot do it -- tool.call and turn.step never fire for agents this
31// plugin spawned. createToolCallHook below still polices every other caller.
32export const PLUGIN_NAME = 'autonomous-loop'
33export const CODING_AGENT_TYPE = `${PLUGIN_NAME}:coding`
34export const REVIEW_AGENT_TYPE = `${PLUGIN_NAME}:review`
35
36function emptyStats(): PolicyStats {
37 return { calls: 0, denied: 0, byTool: {}, deniedByTool: {} }
38}
39
40let current: PolicyStats = emptyStats()
41
42/** Pure: no $, no I/O. Calls outside a tracked agent (agentKind undefined) always allow. */
43export function decide(args: {
44 tool: string
45 agentKind: AgentKind | undefined
46}): PolicyDecision {
47 const { tool, agentKind } = args
48 if (agentKind === undefined) return { allow: true }
49 if (agentKind === 'coding' && CODING_DENIED_TOOLS.includes(tool)) {
50 return { allow: false, reason: `coding agents may not call ${tool}` }
51 }
52 if (agentKind === 'review' && !REVIEW_ALLOWED_TOOLS.includes(tool)) {
53 return {
54 allow: false,
55 reason: `review agents may only use: ${REVIEW_ALLOWED_TOOLS.join(', ')}`,
56 }
57 }
58 return { allow: true }
59}
60
61type ToolCallHookBody = (
62 e: Parameters<Hook<'tool.call'>>[1],
63 next: Parameters<Hook<'tool.call'>>[2],
64) => ReturnType<Hook<'tool.call'>>
65
66/** on('tool.call', ...): deps.observe(e), then `{ deny }` or `return next(e)`. */
67export function createToolCallHook(deps: PolicyDeps): ToolCallHookBody {
68 return (e, next) => {
69 deps.observe(e)
70
71 current.calls += 1
72 current.byTool[e.tool] = (current.byTool[e.tool] ?? 0) + 1
73
74 const decision = decide({ tool: e.tool, agentKind: deps.agentKindOf(e.agentId) })
75 if (!decision.allow) {
76 current.denied += 1
77 current.deniedByTool[e.tool] = (current.deniedByTool[e.tool] ?? 0) + 1
78 return { deny: decision.reason }
79 }
80 return next(e)
81 }
82}
83
84export function stats(): PolicyStats {
85 return {
86 calls: current.calls,
87 denied: current.denied,
88 byTool: { ...current.byTool },
89 deniedByTool: { ...current.deniedByTool },
90 }
91}
92
93export function resetStats(): void {
94 current = emptyStats()
95}
96hooks/heartbeat.ts 107 lines1// hooks/heartbeat.ts — renews the lease on every actively worked ticket.
2
3import type { Timer } from 'claude-code'
4
5import type { Host } from './host'
6import { getTicket, renewLease } from './ticket'
7
8export type HeartbeatHandle = {
9 cancel: () => void
10}
11
12export type HeartbeatOptions = {
13 intervalMs?: number
14 leaseMs?: number
15 /** Every ticket currently being worked, read fresh at each tick. */
16 ticketIds?: () => readonly string[]
17 onBeat?: (nowMs: number) => void
18}
19
20export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000
21export const DEFAULT_LEASE_MS = 120_000
22
23// Heartbeat's own key, written by nothing else, so it has no writer to race.
24const HEARTBEAT_STORE_KEY = 'autonomous-loop/heartbeat'
25
26let timer: Timer | undefined
27let beats = 0
28let lastBeat: number | undefined
29
30async function beat(
31 host: Host,
32 options: HeartbeatOptions,
33 leaseMs: number,
34): Promise<void> {
35 const nowMs = await host.now()
36 const ticketIds = options.ticketIds?.() ?? []
37
38 for (const ticketId of ticketIds) {
39 // Re-check just before renewing: a ticket already done/blocked must never
40 // have its lease extended.
41 const ticket = await getTicket(host, ticketId).catch(() => undefined)
42 if (ticket && ticket.status !== 'done' && ticket.status !== 'blocked') {
43 await renewLease(host, ticketId, leaseMs).catch(() => undefined)
44 }
45 }
46
47 // Never written through saveRuns: $.store has no compare-and-set, so
48 // sharing workflow.ts's runs key would race its persist() writes.
49 if (ticketIds.length > 0) {
50 await host.storeSet(HEARTBEAT_STORE_KEY, {
51 ticketIds,
52 leaseExpiresAtMs: nowMs + leaseMs,
53 })
54 }
55
56 beats += 1
57 lastBeat = nowMs
58 options.onBeat?.(nowMs)
59}
60
61export function startHeartbeat(
62 host: Host,
63 options: HeartbeatOptions = {},
64): HeartbeatHandle {
65 if (timer) {
66 timer.cancel()
67 timer = undefined
68 }
69
70 const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
71 const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS
72
73 timer = host.every(intervalMs, () => {
74 void beat(host, options, leaseMs)
75 })
76
77 return { cancel: () => stopHeartbeat() }
78}
79
80export function stopHeartbeat(): void {
81 timer?.cancel()
82 timer = undefined
83}
84
85/** Full teardown, including the beat counters (for test isolation). */
86export function reset(): void {
87 stopHeartbeat()
88 beats = 0
89 lastBeat = undefined
90}
91
92export function isBeating(): boolean {
93 return timer !== undefined
94}
95
96export function lastBeatMs(): number | undefined {
97 return lastBeat
98}
99
100export function beatCount(): number {
101 return beats
102}
103
104export function currentTimer(): Timer | undefined {
105 return timer
106}
107hooks/animation.ts 79 lines1// hooks/animation.ts — the dashboard's LOOP banner: the word in a block font,
2// with a row of blocks underneath that rolls as a wave while any agent works.
3// Purely cosmetic UI state, kept out of the persisted WorkflowState.
4
5import type { Timer } from 'claude-code'
6import type { Host } from './host'
7
8/** 6 letter rows + 1 block row underneath. */
9export type Frame = readonly string[]
10
11const BANNER: readonly string[] = [
12 '██╗ ██████╗ ██████╗ ██████╗ ',
13 '██║ ██╔═══██╗██╔═══██╗██╔══██╗',
14 '██║ ██║ ██║██║ ██║██████╔╝',
15 '██║ ██║ ██║██║ ██║██╔═══╝ ',
16 '███████╗╚██████╔╝╚██████╔╝██║ ',
17 '╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ',
18]
19/** Display width of every banner row, in cells. */
20export const BANNER_WIDTH = [...BANNER[0]!].length
21/** Block heights, lowest to highest. */
22const LEVELS = '▁▂▃▄▅▆▇█'
23/** Columns per wave crest; the frame cycle length too, so the wave rolls seamlessly. */
24const WAVE_PERIOD = 12
25const TICK_MS = 120
26
27let timer: Timer | undefined
28let frameIndex = 0
29let wasWorking = false
30
31function waveRow(tick: number): string {
32 let row = ''
33 for (let col = 0; col < BANNER_WIDTH; col++) {
34 const phase = ((col - tick) / WAVE_PERIOD) * 2 * Math.PI
35 const level = Math.round(((Math.sin(phase) + 1) / 2) * (LEVELS.length - 1))
36 row += LEVELS[level]
37 }
38 return row
39}
40
41const IDLE_FRAME: Frame = [...BANNER, LEVELS[0]!.repeat(BANNER_WIDTH)]
42
43/** The current frame to draw; a flat block row whenever nothing is actively running. */
44export function currentFrame(): Frame {
45 return wasWorking ? [...BANNER, waveRow(frameIndex)] : IDLE_FRAME
46}
47
48/**
49 * Starts the banner's ticker (a repeat call is a no-op). Each tick reads
50 * `isWorking()` fresh instead of reacting to transitions, so a session that
51 * resumes mid-'coding' still animates. While idle a tick does nothing.
52 */
53export function startAnimationTicker(
54 host: Host,
55 isWorking: () => boolean,
56 invalidate: () => void,
57): void {
58 if (timer !== undefined) return
59 timer = host.every(TICK_MS, () => {
60 const working = isWorking()
61 if (!working) {
62 if (wasWorking) invalidate() // one final redraw back to the still word
63 wasWorking = false
64 frameIndex = 0
65 return
66 }
67 wasWorking = true
68 frameIndex = (frameIndex + 1) % WAVE_PERIOD
69 invalidate()
70 })
71}
72
73export function stopAnimationTicker(): void {
74 timer?.cancel()
75 timer = undefined
76 frameIndex = 0
77 wasWorking = false
78}
79hooks/workflow.ts 655 lines1// hooks/workflow.ts -- the loop itself: drives each ticket through the
2// transition table, and runs up to `maxConcurrent` tickets at once.
3
4import type { Host } from './host'
5import {
6 createInitialState,
7 isTerminal,
8 nextStatus,
9 loadRuns,
10 saveRuns,
11 type WorkflowEvent,
12 type WorkflowState,
13} from './state'
14import { getTicket, claimTicket, updateTicket, type Ticket } from './ticket'
15import { repoRoot, prepareWorktree, worktreeExists, diffStat } from './worktree'
16import { evaluateAttempt, type EvalReport } from './eval'
17import { classifyFailure, toWorkflowEvent } from './blocker'
18import {
19 requestApproval,
20 askHuman,
21 resolveHumanAnswer,
22 resolveApproval,
23 cancelForTicket,
24} from './approval'
25import { runAgent, abandonForTicket } from './agent'
26
27export type ExternalEvent =
28 | { kind: 'HUMAN_ANSWERED'; ticketId: string; answer: string }
29 | { kind: 'APPROVED'; ticketId: string }
30 | { kind: 'REJECTED'; ticketId: string; reason?: string }
31 | { kind: 'PAUSE'; ticketId: string }
32 | { kind: 'RESUME'; ticketId: string }
33 | { kind: 'STOP'; ticketId: string }
34
35export type WorkflowOptions = {
36 cwd: string
37 ticketId?: string
38 maxAttempts?: number
39 resume?: boolean
40 /** How many tickets the pool works at once. Forced to 1 when `ticketId` is set. */
41 maxConcurrent?: number
42}
43
44export const DEFAULT_MAX_CONCURRENT = 3
45
46export type TransitionError = {
47 from: WorkflowState['status']
48 event: WorkflowEvent
49 message: string
50}
51
52// ---------------------------------------------------------------------------
53// Pool state. `current` (ticketId -> WorkflowState) is authoritative; the
54// store is a mirror written by persist() and never read back mid-run.
55// ---------------------------------------------------------------------------
56
57let current = new Map<string, WorkflowState>()
58let running = false
59let listeners = new Set<(state: WorkflowState) => void>()
60let pendingExternal: Array<{ kind: 'PAUSE' | 'RESUME' | 'STOP'; ticketId: string }> = []
61// Last status logged per ticket, so same-status writes (e.g. pause/resume) don't repeat a transcript line.
62let lastLoggedStatusByTicket = new Map<string, string>()
63
64// ---------------------------------------------------------------------------
65// applyTransition -- pure, and the only place state.status changes, so the
66// whole graph lives in one table that can be tested without an engine.
67// ---------------------------------------------------------------------------
68
69export function applyTransition(
70 state: WorkflowState,
71 event: WorkflowEvent,
72 nowMs: number,
73 note?: string,
74): WorkflowState {
75 const to = nextStatus(state.status, event)
76 if (to === undefined) {
77 const message = `autonomous-loop: no transition from ${state.status} on ${event}`
78 const err = new Error(message) as Error & TransitionError
79 err.from = state.status
80 err.event = event
81 err.message = message
82 throw err
83 }
84 // attempt counts coding attempts: every edge that lands on 'coding'
85 // (PREPARED, RETRY, ANSWERED, FAIL) starts a new one.
86 const attempt = to === 'coding' ? state.attempt + 1 : state.attempt
87 const entry = {
88 from: state.status,
89 event,
90 to,
91 attempt,
92 atMs: nowMs,
93 ...(note === undefined ? {} : { note }),
94 }
95 return {
96 ...state,
97 status: to,
98 attempt,
99 history: [...state.history, entry],
100 updatedAtMs: nowMs,
101 }
102}
103
104// ---------------------------------------------------------------------------
105// Persistence + subscriber fan-out.
106// ---------------------------------------------------------------------------
107
108/** One transcript line per status. The switch is exhaustive, so a new status without a case fails to compile. */
109export function describeTransition(state: WorkflowState): string {
110 const ticket = state.ticketId ? `#${state.ticketId}` : '(no ticket)'
111 const attempt = `attempt ${state.attempt}/${state.maxAttempts}`
112 switch (state.status) {
113 case 'claimed':
114 return `autonomous-loop: claimed ${ticket}`
115 case 'coding':
116 return `autonomous-loop: coding ${ticket} (${attempt})`
117 case 'evaluating':
118 return `autonomous-loop: evaluating ${ticket}`
119 case 'waiting_for_human':
120 return `autonomous-loop: ${ticket} needs a human answer -- open the dashboard`
121 case 'reviewing':
122 return `autonomous-loop: reviewing ${ticket}`
123 case 'awaiting_approval':
124 return `autonomous-loop: ${ticket} is awaiting your approval -- open the dashboard`
125 case 'done':
126 return `autonomous-loop: ${ticket} done`
127 case 'blocked':
128 return `autonomous-loop: ${ticket} blocked${state.blockerReason ? ` (${state.blockerReason})` : ''}`
129 }
130}
131
132async function persist(host: Host, state: WorkflowState): Promise<WorkflowState> {
133 current.set(state.ticketId, state)
134 await saveRuns(host, current)
135 for (const listener of listeners) listener(state)
136
137 // No `{ to: 'debug' }`: status changes are the one thing logged to the visible transcript.
138 if (lastLoggedStatusByTicket.get(state.ticketId) !== state.status) {
139 lastLoggedStatusByTicket.set(state.ticketId, state.status)
140 await host.uiLog(describeTransition(state))
141 }
142
143 return state
144}
145
146// ---------------------------------------------------------------------------
147// submitEvent — the synchronous external-event inbox.
148// ---------------------------------------------------------------------------
149
150// Synchronous and engine-free, so the dashboard's buttons can call it directly.
151export function submitEvent(event: ExternalEvent): void {
152 switch (event.kind) {
153 case 'HUMAN_ANSWERED':
154 resolveHumanAnswer(event.ticketId, event.answer)
155 break
156 case 'APPROVED':
157 resolveApproval(event.ticketId, 'approved')
158 break
159 case 'REJECTED':
160 resolveApproval(event.ticketId, 'rejected', event.reason)
161 break
162 case 'PAUSE':
163 pendingExternal.push({ kind: 'PAUSE', ticketId: event.ticketId })
164 break
165 case 'RESUME':
166 pendingExternal.push({ kind: 'RESUME', ticketId: event.ticketId })
167 break
168 case 'STOP':
169 // Scoped to this ticket: never abort another ticket's agent or pending question.
170 pendingExternal.push({ kind: 'STOP', ticketId: event.ticketId })
171 abandonForTicket(event.ticketId, 'stopped by user')
172 cancelForTicket(event.ticketId, 'stopped by user')
173 break
174 }
175}
176
177// ---------------------------------------------------------------------------
178// Driver loop helpers.
179// ---------------------------------------------------------------------------
180
181async function startFresh(host: Host, options: WorkflowOptions): Promise<WorkflowState> {
182 const ticket = await claimTicket(host, options.ticketId !== undefined ? { ticketId: options.ticketId } : {})
183 if (ticket === undefined) {
184 throw new Error('autonomous-loop: no claimable ticket')
185 }
186 const nowMs = await host.now()
187 return createInitialState({
188 ticketId: ticket.id,
189 nowMs,
190 ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),
191 branch: ticket.branch,
192 })
193}
194
195/**
196 * Applies the queued events addressed to this ticket, leaving the rest. The
197 * two filters run with no `await` between them, so other drivers can't interleave.
198 */
199async function drainExternal(host: Host, state: WorkflowState): Promise<WorkflowState> {
200 const mine = pendingExternal.filter((ev) => ev.ticketId === state.ticketId)
201 pendingExternal = pendingExternal.filter((ev) => ev.ticketId !== state.ticketId)
202
203 let next = state
204 for (const ev of mine) {
205 if (ev.kind === 'PAUSE') {
206 next = await persist(host, { ...next, paused: true })
207 } else if (ev.kind === 'RESUME') {
208 next = await persist(host, { ...next, paused: false })
209 } else if (ev.kind === 'STOP') {
210 next = await persist(host, { ...next, stopped: true })
211 }
212 }
213 return next
214}
215
216/**
217 * Re-creates the ticket's worktree if it no longer exists. A resumed run may
218 * point at a directory removed since (crash, cleanup, `git worktree prune`),
219 * and an agent spawned with a missing `cwd` falls back to the session's own
220 * directory -- editing the main checkout instead of the isolated worktree.
221 */
222export async function ensureWorktree(
223 host: Host,
224 state: WorkflowState,
225 options: WorkflowOptions,
226): Promise<WorkflowState> {
227 if (state.worktreePath === undefined) return state
228 if (await worktreeExists(host, state.worktreePath)) return state
229 const root = await repoRoot(host, options.cwd)
230 const wt = await prepareWorktree(host, {
231 repoRoot: root,
232 branch: state.branch ?? `auto/${state.ticketId}`,
233 })
234 return { ...state, worktreePath: wt.path, branch: wt.branch }
235}
236
237/** Per-check cap on carried-forward eval output; the tail is kept, since errors sit at the end of a log. */
238export const FAILURE_DETAIL_CHARS_PER_CHECK = 1500
239/** Cap on the carried-forward reviewer critique; the head is kept, since the verdict comes first. */
240export const REVIEW_FEEDBACK_CHARS = 2000
241
242function keepTail(text: string, max: number): string {
243 return text.length <= max ? text : `…(${text.length - max} earlier chars omitted)\n${text.slice(-max)}`
244}
245
246function keepHead(text: string, max: number): string {
247 return text.length <= max ? text : `${text.slice(0, max)}\n…(${text.length - max} more chars omitted)`
248}
249
250/** The failed checks' output, one block per check, for the next attempt's prompt. */
251export function failureDetail(report: Pick<EvalReport, 'failed'>): string | undefined {
252 if (report.failed.length === 0) return undefined
253 return report.failed
254 .map((c) => `--- ${c.name} (exit ${c.exitCode}) ---\n${keepTail(c.output.trim(), FAILURE_DETAIL_CHARS_PER_CHECK)}`)
255 .join('\n\n')
256}
257
258export function reviewFeedback(answer: string): string | undefined {
259 const trimmed = answer.trim()
260 return trimmed === '' ? undefined : keepHead(trimmed, REVIEW_FEEDBACK_CHARS)
261}
262
263/** The ticket (title and description) comes first, then any retry context from earlier attempts. */
264export function buildCodingPrompt(state: WorkflowState, ticket?: Pick<Ticket, 'title' | 'description'>): string {
265 const lines = [
266 `Ticket: ${state.ticketId}`,
267 `Attempt: ${state.attempt} of ${state.maxAttempts}`,
268 ]
269 if (ticket !== undefined) {
270 lines.push(`Title: ${ticket.title}`, '', ticket.description, '')
271 }
272 lines.push('Implement the ticket in the current worktree.')
273 if (state.humanAnswer !== undefined) {
274 lines.push(`Human answered a previous blocking question: ${state.humanAnswer}`)
275 }
276 if (state.blockerReason !== undefined) {
277 lines.push(`Previous blocker/failure reason: ${state.blockerReason}`)
278 }
279 if (state.lastEvalSummary !== undefined) {
280 lines.push(`Previous evaluation summary: ${state.lastEvalSummary}`)
281 }
282 if (state.lastFailureDetail !== undefined) {
283 lines.push('', 'Output of the checks that failed on the previous attempt:', state.lastFailureDetail)
284 }
285 if (state.lastReviewFeedback !== undefined) {
286 lines.push('', 'The reviewer rejected the previous attempt. Their feedback:', state.lastReviewFeedback)
287 }
288 return lines.join('\n')
289}
290
291export function buildReviewPrompt(state: WorkflowState, ticket?: Pick<Ticket, 'title' | 'description'>): string {
292 const lines = [`Ticket: ${state.ticketId}`]
293 if (ticket !== undefined) {
294 lines.push(`Title: ${ticket.title}`, '', ticket.description, '')
295 }
296 lines.push(
297 'Review the changes in the current worktree against the ticket above.',
298 'Respond with PASS or FAIL, followed by your reasons -- on FAIL, say concretely what must change.',
299 )
300 if (state.lastEvalSummary !== undefined) {
301 lines.push(`Evaluation summary: ${state.lastEvalSummary}`)
302 }
303 return lines.join('\n')
304}
305
306// ---------------------------------------------------------------------------
307// driveOneTicket -- drives one ticket from claim to a terminal status. Each
308// step reads its inputs from `state` and writes its result back into it.
309// ---------------------------------------------------------------------------
310
311async function driveOneTicket(
312 host: Host,
313 options: WorkflowOptions,
314 initialState: WorkflowState,
315): Promise<WorkflowState> {
316 let state = initialState
317 while (!isTerminal(state.status)) {
318 state = await drainExternal(host, state)
319 if (state.stopped) break
320 if (state.paused) {
321 await host.sleep(500)
322 continue
323 }
324
325 // These steps all touch the worktree on disk, so make sure it exists first.
326 if (
327 state.status === 'coding' ||
328 state.status === 'evaluating' ||
329 state.status === 'reviewing' ||
330 state.status === 'awaiting_approval'
331 ) {
332 state = await ensureWorktree(host, state, options)
333 }
334
335 switch (state.status) {
336 case 'claimed': {
337 const ticket = await getTicket(host, state.ticketId)
338 const root = await repoRoot(host, options.cwd)
339 const wt = await prepareWorktree(host, {
340 repoRoot: root,
341 branch: ticket?.branch ?? state.branch ?? `auto/${state.ticketId}`,
342 })
343 state = await persist(
344 host,
345 applyTransition(
346 { ...state, worktreePath: wt.path, branch: wt.branch },
347 'PREPARED',
348 await host.now(),
349 ),
350 )
351 break
352 }
353 case 'coding': {
354 const result = await runAgent(host, {
355 kind: 'coding',
356 state,
357 prompt: buildCodingPrompt(state, await getTicket(host, state.ticketId)),
358 description: `Code ${state.ticketId} attempt ${state.attempt}`,
359 })
360 // Stop aborts the agent; apply the queued STOP before the abort is misread as a coding failure.
361 if (result.isAborted) {
362 state = await drainExternal(host, state)
363 if (state.stopped) break
364 }
365 const withAgent = {
366 ...state,
367 lastModel: result.model,
368 ...(result.agentId !== undefined ? { agentId: result.agentId } : {}),
369 }
370 // Only a completed answer counts as finished; a refusal, error, abort, or denied spawn is a blocker.
371 const codingBlockerReason =
372 result.denied ??
373 (result.isAborted || result.reason !== 'answer'
374 ? `coding agent did not complete (reason: ${result.reason ?? 'aborted'}): ${
375 result.answer || 'no answer produced'
376 }`
377 : undefined)
378 state = codingBlockerReason !== undefined
379 ? await persist(
380 host,
381 applyTransition(
382 {
383 ...withAgent,
384 blockerReason: codingBlockerReason,
385 // Set explicitly so a leftover question from an earlier blocker isn't re-asked.
386 humanQuestion: `The coding agent could not finish: ${codingBlockerReason}. How should it proceed?`,
387 },
388 'BLOCKER_FOUND',
389 await host.now(),
390 ),
391 )
392 : await persist(
393 host,
394 applyTransition(
395 // Retry context has been shown once; clear it so it can't leak into a later attempt.
396 { ...withAgent, humanAnswer: undefined, lastFailureDetail: undefined, lastReviewFeedback: undefined },
397 'AGENT_FINISHED',
398 await host.now(),
399 ),
400 )
401 break
402 }
403 case 'evaluating': {
404 const worktreePath = state.worktreePath
405 if (worktreePath === undefined) {
406 throw new Error('autonomous-loop: evaluating with no worktreePath')
407 }
408 const report = await evaluateAttempt(host, { worktreePath })
409 if (report.outcome === 'pass') {
410 state = await persist(
411 host,
412 applyTransition(
413 { ...state, lastEvalSummary: report.summary },
414 'PASS',
415 await host.now(),
416 ),
417 )
418 } else {
419 const classification = classifyFailure({
420 report,
421 attempt: state.attempt,
422 maxAttempts: state.maxAttempts,
423 })
424 const event = toWorkflowEvent(classification, state.attempt, state.maxAttempts)
425 state = await persist(
426 host,
427 applyTransition(
428 {
429 ...state,
430 lastEvalSummary: report.summary,
431 lastFailureDetail: failureDetail(report),
432 blockerReason: classification.reason,
433 ...(classification.question !== undefined
434 ? { humanQuestion: classification.question }
435 : {}),
436 },
437 event,
438 await host.now(),
439 ),
440 )
441 }
442 break
443 }
444 case 'waiting_for_human': {
445 const answer = await askHuman(host, {
446 ticketId: state.ticketId,
447 question: state.humanQuestion ?? 'Need input to continue.',
448 })
449 state =
450 answer.answer === ''
451 ? await persist(host, applyTransition(state, 'CANCELLED', await host.now()))
452 : await persist(
453 host,
454 applyTransition(
455 // Clear the answered question so a later blocker can't re-ask it.
456 { ...state, humanAnswer: answer.answer, humanQuestion: undefined },
457 'ANSWERED',
458 await host.now(),
459 ),
460 )
461 break
462 }
463 case 'reviewing': {
464 const result = await runAgent(host, {
465 kind: 'review',
466 state,
467 prompt: buildReviewPrompt(state, await getTicket(host, state.ticketId)),
468 description: `Review ${state.ticketId}`,
469 })
470 if (result.isAborted) {
471 state = await drainExternal(host, state) // same STOP-vs-failure ordering as 'coding'
472 if (state.stopped) break
473 }
474 // A review that never completed has no verdict: treat it as FAIL, but record why.
475 const reviewBlockerReason =
476 result.denied ??
477 (result.isAborted || result.reason !== 'answer'
478 ? `review agent did not complete (reason: ${result.reason ?? 'aborted'}): ${
479 result.answer || 'no answer produced'
480 }`
481 : undefined)
482 const passVerdict =
483 reviewBlockerReason === undefined &&
484 /\bpass\b/i.test(result.answer) &&
485 !/\bfail\b/i.test(result.answer)
486 // Cap review failures at maxAttempts, as evaluation does, so the cycle can't run forever.
487 const reviewEvent = passVerdict
488 ? 'PASS'
489 : state.attempt >= state.maxAttempts
490 ? 'EXHAUSTED'
491 : 'FAIL'
492 state = await persist(
493 host,
494 applyTransition(
495 {
496 ...state,
497 lastModel: result.model,
498 lastReviewFeedback: passVerdict ? undefined : reviewFeedback(result.answer),
499 ...(reviewBlockerReason !== undefined ? { blockerReason: reviewBlockerReason } : {}),
500 },
501 reviewEvent,
502 await host.now(),
503 ),
504 )
505 break
506 }
507 case 'awaiting_approval': {
508 const diff = state.worktreePath !== undefined ? await diffStat(host, state.worktreePath) : undefined
509 const answer = await requestApproval(host, {
510 ticketId: state.ticketId,
511 summary: state.lastEvalSummary ?? '',
512 ...(diff !== undefined
513 ? { diffStat: `${diff.filesChanged} files, +${diff.insertions}/-${diff.deletions}` }
514 : {}),
515 })
516 state = await persist(
517 host,
518 applyTransition(
519 state,
520 answer.decision === 'approved' ? 'APPROVED' : 'REJECTED',
521 await host.now(),
522 ),
523 )
524 break
525 }
526 default:
527 break
528 }
529 }
530
531 if (state.stopped && !isTerminal(state.status)) {
532 // STOP is a kill switch, not a graph edge. Force a terminal status so a
533 // later resume doesn't pick the ticket up again -- the one deliberate
534 // exception to applyTransition owning every status change.
535 state = await persist(host, {
536 ...state,
537 status: 'blocked',
538 stopped: false,
539 updatedAtMs: await host.now(),
540 })
541 }
542
543 await updateTicket(host, state.ticketId, {
544 status: state.status === 'done' ? 'done' : 'blocked',
545 }).catch(() => undefined)
546
547 return state
548}
549
550// ---------------------------------------------------------------------------
551// runWorkflow -- the pool scheduler. Resumes unfinished runs, then keeps up
552// to `maxConcurrent` tickets active, refilling a slot as soon as one
553// finishes, until nothing is left to claim.
554// ---------------------------------------------------------------------------
555
556export async function runWorkflow(
557 host: Host,
558 options: WorkflowOptions,
559): Promise<void> {
560 if (running) {
561 throw new Error('autonomous-loop: workflow already running')
562 }
563 running = true
564 try {
565 const maxConcurrent = options.ticketId !== undefined ? 1 : (options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT)
566
567 const loaded = options.resume !== false ? await loadRuns(host) : new Map<string, WorkflowState>()
568 // A single-ticket run resumes only its own saved run.
569 const relevantLoaded = options.ticketId !== undefined
570 ? new Map([...loaded].filter(([ticketId]) => ticketId === options.ticketId))
571 : loaded
572
573 // Finished runs aren't resumable; drop them from the snapshot.
574 let archived = false
575 for (const [ticketId, run] of relevantLoaded) {
576 if (isTerminal(run.status)) {
577 archived = true
578 continue
579 }
580 current.set(ticketId, run)
581 }
582 if (archived) await saveRuns(host, current)
583
584 const active = new Map<string, Promise<void>>()
585 const launch = (state: WorkflowState): void => {
586 const ticketId = state.ticketId
587 active.set(
588 ticketId,
589 driveOneTicket(host, options, state).then(() => {
590 active.delete(ticketId)
591 }),
592 )
593 }
594 for (const state of current.values()) launch(state)
595
596 if (options.ticketId !== undefined) {
597 // Claim at most once and never refill: claiming by id skips the
598 // open-status check, so the refill loop would re-claim this same
599 // finished ticket forever.
600 if (active.size === 0) {
601 const fresh = await startFresh(host, options).catch(() => undefined)
602 if (fresh !== undefined) {
603 current.set(fresh.ticketId, fresh)
604 await saveRuns(host, current)
605 launch(fresh)
606 }
607 }
608 if (active.size > 0) await Promise.race(active.values())
609 return
610 }
611
612 for (;;) {
613 while (active.size < maxConcurrent) {
614 const fresh = await startFresh(host, options).catch(() => undefined)
615 if (fresh === undefined) break // nothing claimable right now
616 current.set(fresh.ticketId, fresh)
617 await saveRuns(host, current)
618 launch(fresh)
619 }
620 if (active.size === 0) break // nothing running and nothing claimable
621 await Promise.race(active.values())
622 }
623 } finally {
624 running = false
625 }
626}
627
628// ---------------------------------------------------------------------------
629// Readers, subscribers, and teardown.
630// ---------------------------------------------------------------------------
631
632/** Every run the pool holds this session, in claim order. */
633export function currentStates(): readonly WorkflowState[] {
634 return [...current.values()]
635}
636
637export function isRunning(): boolean {
638 return running
639}
640
641export function subscribe(listener: (state: WorkflowState) => void): () => void {
642 listeners.add(listener)
643 return () => {
644 listeners.delete(listener)
645 }
646}
647
648export function reset(): void {
649 current = new Map()
650 running = false
651 listeners = new Set()
652 pendingExternal = []
653 lastLoggedStatusByTicket = new Map()
654}
655hooks/approval.ts 166 lines1// hooks/approval.ts — the human gates: approve/reject a finished ticket, and
2// answer a blocking question. Each can be settled from a dialog or the dashboard.
3
4import type { Host } from './host'
5
6export type ApprovalDecision = 'approved' | 'rejected'
7
8export type ApprovalRequest = {
9 ticketId: string
10 summary: string
11 diffStat?: string
12}
13
14export type ApprovalAnswer = {
15 decision: ApprovalDecision
16 via: 'dialog' | 'dashboard'
17 answeredAtMs: number
18 note?: string
19}
20
21export type HumanQuestion = {
22 ticketId: string
23 question: string
24 options?: readonly string[]
25}
26
27export type HumanAnswer = {
28 answer: string
29 via: 'dialog' | 'dashboard'
30 answeredAtMs: number
31}
32
33export const APPROVE_LABEL = 'Approve'
34export const REJECT_LABEL = 'Reject'
35
36// ---------------------------------------------------------------------------
37// Approval requests, keyed by ticketId: several tickets can await approval at
38// once, and one ticket's Approve/Reject must never resolve another's.
39// ---------------------------------------------------------------------------
40
41const approvalReqs = new Map<string, ApprovalRequest>()
42const approvalResolves = new Map<string, (answer: ApprovalAnswer) => void>()
43
44export function requestApproval(
45 host: Host,
46 request: ApprovalRequest,
47): Promise<ApprovalAnswer> {
48 return new Promise<ApprovalAnswer>((resolve) => {
49 const ticketId = request.ticketId
50 approvalReqs.set(ticketId, request)
51 approvalResolves.set(ticketId, (answer: ApprovalAnswer) => {
52 approvalReqs.delete(ticketId)
53 approvalResolves.delete(ticketId)
54 resolve(answer)
55 })
56 host
57 .uiAsk(`Approve "${request.summary}"?`, [APPROVE_LABEL, REJECT_LABEL])
58 .then(async (label) => {
59 const answeredAtMs = await host.now()
60 approvalResolves.get(ticketId)?.({
61 decision: label === APPROVE_LABEL ? 'approved' : 'rejected',
62 via: 'dialog',
63 answeredAtMs,
64 })
65 })
66 .catch(() => {
67 // Dismissed or a -p run: no one to ask. Leave the dashboard-driven
68 // deferred pending; do NOT reject the returned promise.
69 })
70 })
71}
72
73export function resolveApproval(ticketId: string, decision: ApprovalDecision, note?: string): boolean {
74 const resolve = approvalResolves.get(ticketId)
75 if (!resolve) return false
76 resolve({
77 decision,
78 via: 'dashboard',
79 // Called synchronously from a Button onPress, where no engine clock is reachable.
80 answeredAtMs: Date.now(),
81 ...(note !== undefined ? { note } : {}),
82 })
83 return true
84}
85
86export function isAwaitingApproval(ticketId: string): boolean {
87 return approvalReqs.has(ticketId)
88}
89
90export function pendingApproval(ticketId: string): ApprovalRequest | undefined {
91 return approvalReqs.get(ticketId)
92}
93
94// ---------------------------------------------------------------------------
95// Blocking questions (free-text answer), keyed by ticketId for the same reason.
96// ---------------------------------------------------------------------------
97
98const humanQs = new Map<string, HumanQuestion>()
99const humanResolves = new Map<string, (answer: HumanAnswer) => void>()
100
101export function askHuman(host: Host, question: HumanQuestion): Promise<HumanAnswer> {
102 return new Promise<HumanAnswer>((resolve) => {
103 const ticketId = question.ticketId
104 humanQs.set(ticketId, question)
105 humanResolves.set(ticketId, (answer: HumanAnswer) => {
106 humanQs.delete(ticketId)
107 humanResolves.delete(ticketId)
108 resolve(answer)
109 })
110 host
111 .uiAsk(question.question, question.options)
112 .then(async (answer) => {
113 const answeredAtMs = await host.now()
114 humanResolves.get(ticketId)?.({
115 answer,
116 via: 'dialog',
117 answeredAtMs,
118 })
119 })
120 .catch(() => {
121 // Dismissed or a -p run: no one to ask. Leave the dashboard-driven
122 // deferred pending; do NOT reject the returned promise.
123 })
124 })
125}
126
127export function resolveHumanAnswer(ticketId: string, answer: string): boolean {
128 const resolve = humanResolves.get(ticketId)
129 if (!resolve) return false
130 resolve({ answer, via: 'dashboard', answeredAtMs: Date.now() })
131 return true
132}
133
134export function isAwaitingHuman(ticketId: string): boolean {
135 return humanQs.has(ticketId)
136}
137
138export function pendingQuestion(ticketId: string): HumanQuestion | undefined {
139 return humanQs.get(ticketId)
140}
141
142// ---------------------------------------------------------------------------
143// Teardown
144// ---------------------------------------------------------------------------
145
146/**
147 * Settles one ticket's pending request(s) on that ticket's Stop, leaving every
148 * other ticket untouched. A cancelled question settles with answer '', which
149 * workflow.ts treats as CANCELLED.
150 */
151export function cancelForTicket(ticketId: string, reason: string): void {
152 approvalResolves.get(ticketId)?.({ decision: 'rejected', via: 'dashboard', answeredAtMs: Date.now(), note: reason })
153 humanResolves.get(ticketId)?.({ answer: '', via: 'dashboard', answeredAtMs: Date.now() })
154 approvalReqs.delete(ticketId)
155 approvalResolves.delete(ticketId)
156 humanQs.delete(ticketId)
157 humanResolves.delete(ticketId)
158}
159
160/** Settles every ticket's pending request(s), on full teardown. */
161export function cancelAll(reason: string): void {
162 for (const ticketId of new Set([...approvalReqs.keys(), ...humanQs.keys()])) {
163 cancelForTicket(ticketId, reason)
164 }
165}
166hooks/dashboard/render.tsx 175 lines1// hooks/dashboard/render.tsx — draws the dashboard pane: the LOOP banner and
2// one card per active ticket, with its buttons.
3
4import type { EngineInterface, Frozen, RenderElement, RenderInput } from 'claude-code'
5import type { WorkflowState } from '../state'
6import type { ExternalEvent } from '../workflow'
7import * as animation from '../animation'
8
9/** The id this plugin opens its Pane with via `$.ui.open({ id: PANE_ID })`. */
10export const PANE_ID = 'autonomous-loop'
11export const PANE_TITLE = 'Autonomous Loop'
12
13export type DashboardProps = {
14 /** Every active run, in claim order. */
15 states: readonly WorkflowState[]
16 emit: (event: ExternalEvent) => void
17 awaitingApproval: (ticketId: string) => boolean
18 awaitingHuman: (ticketId: string) => boolean
19}
20
21/** True when this render event is for this plugin's own pane, not another plugin's. */
22export function isOwnPane(e: Frozen<RenderInput<'Pane'>>): boolean {
23 return e.component === 'Pane' && e.requestId === PANE_ID
24}
25
26// Below this pane width, buttons stack vertically instead of side by side.
27const NARROW_COLUMNS = 48
28
29/** One dim `label: value` line, truncated rather than wrapped so long values keep the layout intact. */
30function FieldLine(
31 Text: ReturnType<EngineInterface['ui']['resolve']>['Text'],
32 label: string,
33 value: string,
34): RenderElement {
35 return (
36 <Text dimColor wrap="truncate-end">
37 {label}: {value}
38 </Text>
39 )
40}
41
42/**
43 * One ticket's card: fields, blocker or question, buttons, and history.
44 * Every button emits an event carrying this card's own ticketId; the
45 * dashboard never changes WorkflowState directly.
46 */
47function RunCard(
48 elements: ReturnType<EngineInterface['ui']['resolve']>,
49 state: WorkflowState,
50 isNarrow: boolean,
51 props: DashboardProps,
52): RenderElement {
53 const { Box, Text, Button } = elements
54 const Input = 'Input' in elements ? elements.Input : undefined
55 const awaitingApproval = props.awaitingApproval(state.ticketId)
56 const awaitingHuman = props.awaitingHuman(state.ticketId)
57
58 return (
59 <Box flexDirection="column" marginTop={1} paddingX={1}>
60 <Text bold>
61 #{state.ticketId} — {statusLine(state)}
62 </Text>
63
64 <Box flexDirection="column">
65 {state.branch && FieldLine(Text, 'branch', state.branch)}
66 {state.lastModel && FieldLine(Text, 'model', state.lastModel)}
67 {state.agentId && FieldLine(Text, 'agent', state.agentId)}
68 {state.lastEvalSummary && FieldLine(Text, 'eval', state.lastEvalSummary)}
69 </Box>
70
71 {state.blockerReason && (
72 <Box marginTop={1}>
73 <Text wrap="wrap">blocker: {state.blockerReason}</Text>
74 </Box>
75 )}
76 {awaitingHuman && state.humanQuestion && (
77 <Box marginTop={1}>
78 <Text wrap="wrap">question: {state.humanQuestion}</Text>
79 </Box>
80 )}
81
82 <Box
83 flexDirection={isNarrow ? 'column' : 'row'}
84 gap={isNarrow ? 0 : 1}
85 marginTop={1}
86 >
87 <Button
88 label={state.paused ? 'Resume' : 'Pause'}
89 onPress={() => props.emit({ kind: state.paused ? 'RESUME' : 'PAUSE', ticketId: state.ticketId })}
90 />
91 <Button label="Stop" onPress={() => props.emit({ kind: 'STOP', ticketId: state.ticketId })} />
92 {awaitingApproval && (
93 <Button label="Approve" onPress={() => props.emit({ kind: 'APPROVED', ticketId: state.ticketId })} />
94 )}
95 {awaitingApproval && (
96 <Button label="Reject" onPress={() => props.emit({ kind: 'REJECTED', ticketId: state.ticketId })} />
97 )}
98 </Box>
99
100 {awaitingHuman && Input && (
101 <Box marginTop={1}>
102 <Input
103 key={`answer-${state.ticketId}`}
104 label="Answer"
105 placeholder="Type your answer and press Enter"
106 submitLabel="answer"
107 onSubmit={(value: string) => props.emit({ kind: 'HUMAN_ANSWERED', ticketId: state.ticketId, answer: value })}
108 />
109 </Box>
110 )}
111
112 <Box flexDirection="column" marginTop={1}>
113 <Text bold dimColor>history</Text>
114 {historyLines(state).map((line, i) => (
115 <Text key={String(i)} dimColor wrap="truncate-end">
116 {line}
117 </Text>
118 ))}
119 </Box>
120 </Box>
121 )
122}
123
124/**
125 * The pane body: title, LOOP banner, summary, then one card per active ticket.
126 *
127 * `elements` comes from `$.ui.resolve(e)` in index.ts rather than an import,
128 * so the same JSX works on every surface. Every Box sets `flexDirection`
129 * explicitly because the layout engine defaults to `row`.
130 */
131export function renderDashboard(
132 elements: ReturnType<EngineInterface['ui']['resolve']>,
133 e: Frozen<RenderInput<'Pane'>>,
134 props: DashboardProps,
135): RenderElement {
136 const { Box, Text } = elements
137 const isNarrow = e.props.bodyColumns < NARROW_COLUMNS
138
139 return (
140 <Box flexDirection="column" paddingX={1} width={e.props.bodyColumns}>
141 <Text bold>{PANE_TITLE}</Text>
142 <Box flexDirection="column">
143 {animation.currentFrame().map((line, i) => (
144 <Text key={String(i)} color="#CC785C" wrap="truncate">
145 {line}
146 </Text>
147 ))}
148 </Box>
149 <Text dimColor>{summaryLine(props.states)}</Text>
150
151 {props.states.map((state) => (
152 <Box key={state.ticketId} flexDirection="column">
153 {RunCard(elements, state, isNarrow, props)}
154 </Box>
155 ))}
156 </Box>
157 )
158}
159
160export function summaryLine(states: readonly WorkflowState[]): string {
161 if (states.length === 0) return 'no active runs'
162 return `${states.length} active run${states.length === 1 ? '' : 's'}`
163}
164
165export function statusLine(state: WorkflowState): string {
166 return `${state.status} (attempt ${state.attempt}/${state.maxAttempts})`
167}
168
169/** The last `limit` transitions, oldest first. */
170export function historyLines(state: WorkflowState, limit = 10): readonly string[] {
171 return state.history
172 .slice(-limit)
173 .map((h) => `${h.from} --${h.event}--> ${h.to}`)
174}
175hooks/host.ts 35 lines1// hooks/host.ts — the engine capabilities the loop uses, as a plain object.
2//
3// Claude Code statically checks a hooks module before loading it, and refuses
4// one that uses `$` as a value: assigning it, passing it to a function,
5// spreading or returning it. `$` may only appear as a literal
6// `$.noun.method(...)` call. That lets the engine list exactly which
7// capabilities a plugin uses.
8//
9// So index.ts builds a `Host` inline in session.start: an object of small
10// closures, each a literal `$` call (`now: () => $.clock.now()`). The object
11// is not `$`, so it can be passed into workflow.ts and everything it calls,
12// and tests can pass a fake one instead.
13
14import type { EngineInterface } from 'claude-code'
15
16export type Host = {
17 now: EngineInterface['clock']['now']
18 sleep: EngineInterface['clock']['sleep']
19 every: EngineInterface['clock']['every']
20 storeGet: EngineInterface['store']['get']
21 storeSet: EngineInterface['store']['set']
22 storeDelete: EngineInterface['store']['delete']
23 run: EngineInterface['process']['run']
24 fsExists: EngineInterface['fs']['exists']
25 spawnAgent: EngineInterface['agent']['spawn']
26 listAgents: EngineInterface['agent']['list']
27 uiAsk: EngineInterface['ui']['ask']
28 uiOpen: EngineInterface['ui']['open']
29 uiInvalidate: EngineInterface['ui']['invalidate']
30 uiLog: EngineInterface['ui']['log']
31}
32
33// There is deliberately no `hostOf($)` builder: calling it would pass `$` as
34// an argument, the very thing the engine refuses. Only the type lives here.
35hooks/state.ts 159 lines1// hooks/state.ts -- the workflow's types, its transition table (the graph),
2// model routing, and persistence of per-ticket run state.
3
4import type { Host } from './host'
5
6export type WorkflowStatus =
7 | 'claimed'
8 | 'coding'
9 | 'evaluating'
10 | 'waiting_for_human'
11 | 'reviewing'
12 | 'awaiting_approval'
13 | 'done'
14 | 'blocked'
15
16export type WorkflowEvent =
17 | 'PREPARED'
18 | 'AGENT_FINISHED'
19 | 'BLOCKER_FOUND'
20 | 'PASS'
21 | 'RETRY'
22 | 'EXHAUSTED'
23 | 'ANSWERED'
24 | 'CANCELLED'
25 | 'FAIL'
26 | 'APPROVED'
27 | 'REJECTED'
28
29export type ModelTier = 'haiku' | 'sonnet' | 'opus'
30
31export type TransitionTable = Readonly<
32 Record<WorkflowStatus, Readonly<Partial<Record<WorkflowEvent, WorkflowStatus>>>>
33>
34
35export type HistoryEntry = {
36 from: WorkflowStatus
37 event: WorkflowEvent
38 to: WorkflowStatus
39 attempt: number
40 atMs: number
41 note?: string
42}
43
44export type WorkflowState = {
45 ticketId: string
46 status: WorkflowStatus
47 attempt: number
48 maxAttempts: number
49 worktreePath?: string
50 branch?: string
51 agentId?: string
52 lastModel?: ModelTier
53 lastEvalSummary?: string
54 /** Failed checks' output (tail, capped) from the last evaluation; cleared after the next coding attempt. */
55 lastFailureDetail?: string
56 /** The reviewer's critique (capped) from a review that did not pass; cleared after the next coding attempt. */
57 lastReviewFeedback?: string
58 blockerReason?: string
59 humanQuestion?: string
60 humanAnswer?: string
61 approval?: 'pending' | 'approved' | 'rejected'
62 paused: boolean
63 stopped: boolean
64 history: HistoryEntry[]
65 leaseExpiresAtMs: number
66 createdAtMs: number
67 updatedAtMs: number
68}
69
70export type ModelRoutingState = Pick<WorkflowState, 'status' | 'attempt'>
71
72export const RUNS_STORE_KEY = 'autonomous-loop/runs'
73export const DEFAULT_MAX_ATTEMPTS = 3
74export const DEFAULT_LEASE_MS = 120_000
75export const TERMINAL_STATUSES: readonly WorkflowStatus[] = ['done', 'blocked']
76
77export const transitions: TransitionTable = {
78 claimed: { PREPARED: 'coding' },
79 coding: { AGENT_FINISHED: 'evaluating', BLOCKER_FOUND: 'waiting_for_human' },
80 evaluating: {
81 PASS: 'reviewing',
82 RETRY: 'coding',
83 BLOCKER_FOUND: 'waiting_for_human',
84 EXHAUSTED: 'blocked',
85 },
86 waiting_for_human: { ANSWERED: 'coding', CANCELLED: 'blocked' },
87 // EXHAUSTED caps the reviewing -> coding -> evaluating -> reviewing cycle at maxAttempts.
88 reviewing: { PASS: 'awaiting_approval', FAIL: 'coding', EXHAUSTED: 'blocked' },
89 awaiting_approval: { APPROVED: 'done', REJECTED: 'blocked' },
90 done: {},
91 blocked: {},
92}
93
94export function createInitialState(args: {
95 ticketId: string
96 nowMs: number
97 maxAttempts?: number
98 branch?: string
99}): WorkflowState {
100 return {
101 ticketId: args.ticketId,
102 status: 'claimed',
103 attempt: 0,
104 maxAttempts: args.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
105 paused: false,
106 stopped: false,
107 history: [],
108 leaseExpiresAtMs: args.nowMs + DEFAULT_LEASE_MS,
109 createdAtMs: args.nowMs,
110 updatedAtMs: args.nowMs,
111 ...(args.branch !== undefined ? { branch: args.branch } : {}),
112 }
113}
114
115export function nextStatus(
116 status: WorkflowStatus,
117 event: WorkflowEvent,
118): WorkflowStatus | undefined {
119 return transitions[status][event]
120}
121
122export function isTerminal(status: WorkflowStatus): boolean {
123 return TERMINAL_STATUSES.includes(status)
124}
125
126/**
127 * Model routing: reviews always use opus; the first coding attempt uses
128 * sonnet, and a retry escalates to opus, since a failure suggests a harder task.
129 */
130export function pickModel(state: ModelRoutingState): ModelTier {
131 if (state.status === 'reviewing') return 'opus'
132 return state.attempt > 1 ? 'opus' : 'sonnet'
133}
134
135/**
136 * Reads every persisted run, keyed by ticketId. A malformed entry is dropped
137 * rather than thrown, so one corrupt record never blocks the others' resume.
138 */
139export async function loadRuns(host: Host): Promise<Map<string, WorkflowState>> {
140 const raw = await host.storeGet(RUNS_STORE_KEY)
141 if (raw === undefined || raw === null || typeof raw !== 'object') return new Map()
142 const runs = new Map<string, WorkflowState>()
143 for (const [ticketId, value] of Object.entries(raw as Record<string, unknown>)) {
144 if (value !== null && typeof value === 'object' && 'status' in value) {
145 runs.set(ticketId, value as WorkflowState)
146 }
147 }
148 return runs
149}
150
151/**
152 * Writes the whole runs map as one snapshot. The store has no compare-and-set,
153 * so a read-modify-write would race between concurrent tickets; the caller
154 * keeps the authoritative map in memory and every save is a plain overwrite.
155 */
156export async function saveRuns(host: Host, runs: ReadonlyMap<string, WorkflowState>): Promise<void> {
157 await host.storeSet(RUNS_STORE_KEY, Object.fromEntries(runs))
158}
159hooks/ticket.ts 184 lines1// hooks/ticket.ts — the ticket queue: a demo list kept in the plugin's store,
2// with claims, leases, and updates serialized through one lock.
3
4import type { Host } from './host'
5
6export type TicketStatus = 'open' | 'claimed' | 'in_progress' | 'blocked' | 'done'
7
8export type Ticket = {
9 id: string
10 title: string
11 description: string
12 status: TicketStatus
13 branch: string
14 assignee?: string
15 claimedAtMs?: number
16 leaseExpiresAtMs?: number
17 updatedAtMs: number
18}
19
20export const TICKET_STORE_KEY = 'autonomous-loop/tickets'
21
22const DEFAULT_TICKET_LEASE_MS = 120_000
23
24export const DEMO_TICKETS: readonly Ticket[] = [
25 {
26 id: 'DEMO-1',
27 title: 'Add a health-check endpoint',
28 description: 'Expose GET /health returning { ok: true } for uptime checks.',
29 status: 'open',
30 branch: 'auto/demo-1-health-check',
31 updatedAtMs: 0,
32 },
33 // Small and self-contained: sized to run the full graph and pass first time.
34 {
35 id: 'DEMO-2',
36 title: 'Add a formatDuration utility',
37 description:
38 'Add a pure function `formatDuration(ms: number): string` to a new file `hooks/format-duration.ts`. ' +
39 'It formats a non-negative millisecond duration as compact `Xh Ym Zs`-style text, omitting any ' +
40 'leading zero unit (e.g. 1500 -> "1s", 61000 -> "1m 1s", 3661000 -> "1h 1m 1s"), and returns "0s" ' +
41 'for 0. Add `hooks/format-duration.test.ts` with real `node:test` cases covering at least: 0, under ' +
42 'a minute, exactly one minute, and over an hour. Do not modify any other file.',
43 status: 'open',
44 branch: 'auto/demo-2-format-duration',
45 updatedAtMs: 0,
46 },
47]
48
49// The store is one JSON list with only get/set, so every change is a
50// whole-list read-modify-write with awaits in between. Unserialized, two
51// concurrent writers (pool drivers, heartbeat renewals) could each read the
52// same list and the later write would drop the earlier change -- e.g. a lease
53// renewal reverting a fresh claim to `open`. Every public function below runs
54// under `withTicketLock`, a promise-chain mutex. It is not re-entrant, so
55// locked code calls only the `*Unlocked` helpers. It protects one process only.
56
57let lockTail: Promise<unknown> = Promise.resolve()
58
59/** Runs `fn` once every earlier locked call has settled; a failure never wedges the queue. */
60export function withTicketLock<T>(fn: () => Promise<T>): Promise<T> {
61 const run = lockTail.then(fn, fn)
62 lockTail = run.catch(() => undefined)
63 return run
64}
65
66async function listTicketsUnlocked(host: Host): Promise<Ticket[]> {
67 const stored = await host.storeGet(TICKET_STORE_KEY)
68 if (!Array.isArray(stored) || stored.length === 0) {
69 await host.storeSet(TICKET_STORE_KEY, DEMO_TICKETS)
70 return [...DEMO_TICKETS]
71 }
72 const storedList = stored as Ticket[]
73 // Append demo tickets added since the store was seeded; existing entries
74 // (and their live status/claim/lease) are never touched.
75 const knownIds = new Set(storedList.map((t) => t.id))
76 const missing = DEMO_TICKETS.filter((t) => !knownIds.has(t.id))
77 if (missing.length === 0) return storedList
78 const merged = [...storedList, ...missing]
79 await host.storeSet(TICKET_STORE_KEY, merged)
80 return merged
81}
82
83async function updateTicketUnlocked(
84 host: Host,
85 ticketId: string,
86 patch: Partial<Omit<Ticket, 'id'>>,
87): Promise<Ticket> {
88 const list = await listTicketsUnlocked(host)
89 const index = list.findIndex((t) => t.id === ticketId)
90 if (index === -1) {
91 throw new Error(`ticket.ts: unknown ticketId ${ticketId}`)
92 }
93 const existing = list[index] as Ticket
94 const updated: Ticket = {
95 ...existing,
96 ...patch,
97 updatedAtMs: await host.now(),
98 }
99 const next = [...list]
100 next[index] = updated
101 await host.storeSet(TICKET_STORE_KEY, next)
102 return updated
103}
104
105export function listTickets(host: Host): Promise<Ticket[]> {
106 return withTicketLock(() => listTicketsUnlocked(host))
107}
108
109export function seedTickets(
110 host: Host,
111 tickets: readonly Ticket[],
112): Promise<void> {
113 return withTicketLock(() => host.storeSet(TICKET_STORE_KEY, tickets))
114}
115
116export async function getTicket(
117 host: Host,
118 ticketId: string,
119): Promise<Ticket | undefined> {
120 return (await listTickets(host)).find((t) => t.id === ticketId)
121}
122
123export async function nextOpenTicket(host: Host): Promise<Ticket | undefined> {
124 return (await listTickets(host)).find((t) => t.status === 'open')
125}
126
127/** Finds and claims a ticket under one lock hold, so no other writer can slip in between. */
128export function claimTicket(
129 host: Host,
130 args?: { ticketId?: string; assignee?: string; leaseMs?: number },
131): Promise<Ticket | undefined> {
132 return withTicketLock(async () => {
133 const list = await listTicketsUnlocked(host)
134 const ticket = args?.ticketId !== undefined
135 ? list.find((t) => t.id === args.ticketId)
136 : list.find((t) => t.status === 'open')
137 if (ticket === undefined) return undefined
138
139 const leaseMs = args?.leaseMs ?? DEFAULT_TICKET_LEASE_MS
140 const nowMs = await host.now()
141
142 return updateTicketUnlocked(host, ticket.id, {
143 status: 'claimed',
144 claimedAtMs: nowMs,
145 leaseExpiresAtMs: nowMs + leaseMs,
146 ...(args?.assignee !== undefined ? { assignee: args.assignee } : {}),
147 })
148 })
149}
150
151export function updateTicket(
152 host: Host,
153 ticketId: string,
154 patch: Partial<Omit<Ticket, 'id'>>,
155): Promise<Ticket> {
156 return withTicketLock(() => updateTicketUnlocked(host, ticketId, patch))
157}
158
159export function renewLease(
160 host: Host,
161 ticketId: string,
162 leaseMs: number,
163): Promise<Ticket | undefined> {
164 return withTicketLock(async () => {
165 const ticket = (await listTicketsUnlocked(host)).find((t) => t.id === ticketId)
166 if (ticket === undefined) return undefined
167 const nowMs = await host.now()
168 return updateTicketUnlocked(host, ticketId, { leaseExpiresAtMs: nowMs + leaseMs })
169 })
170}
171
172export async function releaseTicket(host: Host, ticketId: string): Promise<void> {
173 try {
174 await updateTicket(host, ticketId, {
175 status: 'open',
176 assignee: undefined,
177 claimedAtMs: undefined,
178 leaseExpiresAtMs: undefined,
179 })
180 } catch {
181 // Unknown ticket: nothing to release.
182 }
183}
184hooks/worktree.ts 155 lines1// hooks/worktree.ts — thin wrapper over git: one worktree per ticket.
2// Every command runs as argv (no shell) with an explicit cwd.
3
4import type { Host } from './host'
5
6export type Worktree = {
7 path: string
8 branch: string
9 baseRef: string
10 createdAtMs: number
11}
12
13export type DiffStat = {
14 filesChanged: number
15 insertions: number
16 deletions: number
17}
18
19export type PrepareWorktreeArgs = {
20 repoRoot: string
21 branch: string
22 baseRef?: string // default 'HEAD'
23 root?: string // parent dir for worktrees; default `${repoRoot}/.worktrees`
24}
25
26export const WORKTREE_DIR_NAME = '.worktrees'
27
28export async function repoRoot(host: Host, cwd: string): Promise<string> {
29 const r = await host.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
30 if (r.exitCode !== 0) {
31 throw new Error(r.stderr || 'git rev-parse failed')
32 }
33 return r.stdout.trim()
34}
35
36export function worktreeExists(host: Host, path: string): Promise<boolean> {
37 return host.fsExists(path)
38}
39
40export async function listWorktrees(
41 host: Host,
42 repoRoot: string,
43): Promise<Worktree[]> {
44 const r = await host.run(['git', 'worktree', 'list', '--porcelain'], {
45 cwd: repoRoot,
46 })
47 if (r.exitCode !== 0) {
48 throw new Error(r.stderr || 'git worktree list failed')
49 }
50
51 // Porcelain output: blank-line-separated blocks of `worktree <path>` plus
52 // `branch refs/heads/<name>` or `detached`. It has no creation time, so
53 // createdAtMs is "observed now".
54 const worktrees: Worktree[] = []
55 const blocks = r.stdout.split(/\r?\n\r?\n/)
56 for (const block of blocks) {
57 const lines = block.split(/\r?\n/).filter((line) => line.length > 0)
58 if (lines.length === 0) continue
59 const worktreeLine = lines.find((line) => line.startsWith('worktree '))
60 if (worktreeLine === undefined) continue
61 const path = worktreeLine.slice('worktree '.length)
62 const branchLine = lines.find((line) => line.startsWith('branch '))
63 const branch =
64 branchLine !== undefined
65 ? branchLine.slice('branch '.length).replace(/^refs\/heads\//, '')
66 : 'detached'
67 worktrees.push({
68 path,
69 branch,
70 baseRef: '',
71 createdAtMs: await host.now(),
72 })
73 }
74 return worktrees
75}
76
77/**
78 * True when `branch` already exists as a local ref. `git worktree add -b`
79 * refuses an existing branch, which is what remains when a worktree
80 * directory was removed but its branch was not.
81 */
82async function branchExists(host: Host, args: { repoRoot: string; branch: string }): Promise<boolean> {
83 const r = await host.run(['git', 'rev-parse', '--verify', '--quiet', `refs/heads/${args.branch}`], {
84 cwd: args.repoRoot,
85 })
86 return r.exitCode === 0
87}
88
89export async function prepareWorktree(
90 host: Host,
91 args: PrepareWorktreeArgs,
92): Promise<Worktree> {
93 const baseRef = args.baseRef ?? 'HEAD'
94 const root = args.root ?? `${args.repoRoot}/${WORKTREE_DIR_NAME}`
95 const path = `${root}/${args.branch}`
96
97 if (await worktreeExists(host, path)) {
98 return { path, branch: args.branch, baseRef, createdAtMs: await host.now() }
99 }
100
101 const alreadyHasBranch = await branchExists(host, { repoRoot: args.repoRoot, branch: args.branch })
102 const argv = alreadyHasBranch
103 ? ['git', 'worktree', 'add', path, args.branch]
104 : ['git', 'worktree', 'add', '-b', args.branch, path, baseRef]
105 const r = await host.run(argv, { cwd: args.repoRoot })
106 if (r.exitCode !== 0) {
107 throw new Error(`git worktree add failed: ${r.stderr}`)
108 }
109
110 return { path, branch: args.branch, baseRef, createdAtMs: await host.now() }
111}
112
113export async function cleanupWorktree(
114 host: Host,
115 worktreePath: string,
116 options?: { removeBranch?: boolean; branch?: string },
117): Promise<void> {
118 try {
119 // `worktreePath` is about to be deleted, so run from its parent (the
120 // worktrees root), which `git worktree remove` leaves in place.
121 const parent = worktreePath.slice(0, worktreePath.lastIndexOf('/')) || '/'
122
123 await host
124 .run(['git', 'worktree', 'remove', '--force', worktreePath], { cwd: parent })
125 .catch(() => undefined)
126
127 if (options?.removeBranch && options.branch) {
128 await host
129 .run(['git', 'branch', '-D', options.branch], { cwd: parent })
130 .catch(() => undefined)
131 }
132 } catch {
133 // never throw
134 }
135}
136
137export async function diffStat(
138 host: Host,
139 worktreePath: string,
140): Promise<DiffStat> {
141 const r = await host.run(['git', 'diff', '--shortstat'], {
142 cwd: worktreePath,
143 })
144
145 const filesMatch = r.stdout.match(/(\d+) files? changed/)
146 const insertionsMatch = r.stdout.match(/(\d+) insertions?\(\+\)/)
147 const deletionsMatch = r.stdout.match(/(\d+) deletions?\(-\)/)
148
149 return {
150 filesChanged: filesMatch ? Number(filesMatch[1]) : 0,
151 insertions: insertionsMatch ? Number(insertionsMatch[1]) : 0,
152 deletions: deletionsMatch ? Number(deletionsMatch[1]) : 0,
153 }
154}
155