A software factory: /factory <feature> writes a spec, has you approve its plan (in goal-run when it's installed), builds it on a branch, runs the checks and…

Seven mods, each installed on its own. They also work side by side: each draws its part of the band above the prompt, and the others' parts stay.
| Mod | What it does |
|---|---|
| clawd-hud | A pixel spark above the prompt, usage batteries, a prompt-cache timer and a context meter |
| task-hud | A to-do progress bar, and a pane with a pixel creature for each sub-agent |
| followups | Claude flags what it notices, asks whether to track it, and keeps a to-do you can export |
| rtl-toggle | Fixes the reading order of lines that mix English and Hebrew |
| goal-run | Asks for a goal when a session starts, has Claude plan it, and runs a pixel Claude to 100% as the tasks get done |
| brain-hud | Agent memory with its skill built in: mistake-brain logs and routes lessons, a pixel Claude with a brain reacts to each entry, and a pane draws the memory as a graph |
| factory | /factory <feature>: a spec, your approval of its plan, a build on its own branch, checks, parallel reviews and an approver, then Claude opens the PR |
Add the marketplace once, then install the ones you want:
/plugin marketplace add Amitro123/claudemod
/plugin install <mod>@claudemod
The installer may say some options are "not yet set". That's fine: every option has a default, and the mods run on those until you change one with /plugin configure <mod>@claudemod.
If you added this marketplace before it was renamed from clawd-hud, remove it with /plugin marketplace remove clawd-hud, add it again, and reinstall your mods as <mod>@claudemod.

A Claude Code plugin that puts a little animated spark above the prompt, with a pixel battery for each of your usage limits, and adds a prompt-cache timer and a context meter beside the model picker. It works on personal plans and on enterprise seats.
Above the prompt (the AbovePrompt band)
| Mood | When | Scenes |
|---|---|---|
idle | waiting for you | drifting with a firefly, juggling, watering a sprout |
working | a turn is running | at the terminal, stacking blocks, pondering, sending out sub-agents |
working > 90s | a hard task | a smoke break on a crate joins the rotation |
loop | the same tool with the same input 3× in one turn | riding a coaster loop it can't get out of |
done | the turn answered | fireworks and a hop (a lightbulb if the task was hard) |
relax | 4 min after done | stargazing on a hill, a pool float, cocoa by a snowy window |
error | API error or refusal | goes gray under a little storm cloud, then idle |
| Personal (Pro / Max) | Enterprise | |
|---|---|---|
| Batteries | 5h window, 7d weekly | 7d weekly, mo monthly spend cap ("over by …" past 100%) |
| Cost | not shown | $ spent this session |
| "resets in" | the API's reset time | the API's reset time; for the spend cap without one, the 1st of next month |
Cache timer (auto) | 1 hour | 5 minutes |
Beside the model picker (the SessionMode slot)
ctx 34% · 68k/200k.In a terminal (which can't draw SVG) the same readings show up as a text spark ✦(•_•) and ▰▰▰▱▱ batteries. A one-line summary also goes to the status bar.
The spark shares both places with the other mods: what they draw in the band shows under its row, and whatever sits beside the model picker stays next to the bolt and capsule.
/plugin install clawd-hud@claudemod
Set with claude plugin configure clawd-hud@claudemod, or in the plugin settings:
| Option | Values | Default | What it does |
|---|---|---|---|
plan | auto, personal, enterprise | auto | Which limits to show. auto switches to enterprise as soon as the account reports a monthly spend limit (spend_limit). |
cacheTtl | auto, 5m, 1h | auto | The cache timer's length. auto is 1h on personal plans and 5m on enterprise. |
hooks/register.tsx hooks session.start, session.measure, turn.start, tool.call, turn.complete and ui.render, keeps its state in atoms, and redraws the spark 8 times a second while it moves (working, the loop, fireworks, the storm) and less often while it idles or rests: 4 times a second for the SVG scenes, once a second for the terminal's text face.hooks/scene.ts paints one frame of a mood on an 88×20 pixel stage. Every motion is a plain function of the time since the mood began, so a redraw lands on the same frame and never restarts an animation.hooks/gauges.ts draws the batteries, the bolt and the capsule as plain SVG, so the demo renderer uses the exact same art.tests/plan.test.tsx checks the personal and enterprise views, the spend-cap overflow and both cache timers. Run it with claude plugin test plugins/clawd-hud.0.6.2: the idle and resting SVG scenes redraw 4 times a second instead of once, so they move smoothly; the terminal stays at once a second. The terminal face's idle blink shows again (every 4 seconds).
0.6.1: plays well with the other mods: their bands, and other changes beside the model picker, are no longer hidden. Idle redraws drop from 8 a second to 1. No "clawd-hud loaded" toast on every start.
0.6.0: a new look. The spark mascot, its scenes, the stage and the batteries, bolt and capsule are all new, drawn by a new animation engine. Everything else carries over: plans, timers, loop detection, the hard-task and eureka moments.
0.5.0: renamed from pixel-buddy to clawd-hud (install again; old settings don't carry over). Enterprise mode. The loop detector's tool.call hook got a registration .catch, so it can never block a tool call.
0.4.1: only a real answer celebrates (a refusal gets the error mood). The cache countdown flips to "cold" even after the machine slept. Timers no longer stack up when session.start fires again. No more "resets in NaNm".
0.4.0: the hard-task smoke break, the eureka lightbulb and the loop coaster. 0.3.0: the error mood.

<sub>The creatures, the task bar and the progress lines are drawn by the mod's own art.ts; the window around them is a mock-up of the desktop app.</sub>
/plugin install task-hud@claudemod
TodoWrite, or TaskCreate/TaskUpdate): it's titled with the prompt that started the work, fills as each to-do gets ticked off (Tasks 4/5 · 80%), and shows the one in progress (Now: Write the script). The title survives a reload of the mod. When it shares the band with clawd-hud, it draws above the spark. A finished list clears on your next prompt./task-hud. Each agent gets a pixel creature in its own color that walks while it works. Its card shows the task's description, the agent type and model (general-purpose · Opus 5.5), its tokens and run time, the current step (Step 3 · Reading pre-outline.md), the steps before it and a progress line. Cards are grouped into Running and Finished. A finished agent closes its eyes and lists what it did (Wrote script.md). A failed one turns gray. An agent sent back to work gets its card back, or a new one if its old card was already dropped. Clear drops the finished cards.Nobody knows ahead of time how many steps a sub-agent will take, so a running agent's line creeps toward 90% as it works and fills only when it finishes. The token count is the size of the agent's context on its latest request.
Tests: claude plugin test plugins/task-hud.

<sub>A mock-up of version 0.5.0, not a screenshot. The dialog is Claude Code's own question dialog, so it looks like the one in your app rather than exactly like this. The pane follows the mod's drawing: orange open, blue in progress, green done, 🐞 for a bug, and an item offered from a sub-agent's open questions. A dialog closed without an answer leaves the item in a band above the prompt (Add to to-do / Skip / Add all), which isn't shown here.</sub>
While Claude works, it often notices things outside the task: a flaky test, a stale doc, a bug in nearby code. Those get lost unless somebody writes them down. This mod writes them down.
/plugin install followups@claudemod
flag tool and a short system-prompt section on when to use it: things outside the current task that need attention later. Claude doesn't fix them unless you ask.autoDetect option (claude plugin configure followups@claudemod).Unsure-about, Open questions or Open items, as the model router's routed-worker writes), each line in it is offered in the band as soon as the report comes back. This needs no model, so it works even where the after-answer look can't run. A section that says none offers nothing, and the turn's end-of-turn check doesn't stop Claude again for items already offered.not verified, untested, known gap, open questions, לא נבדק, פער, שאלות פתוחות, ...; Open questions: none doesn't count; not follow-up, this mod's own word) and it never called flag that turn, the turn is stopped once with a nudge to flag each item or say there are none. It reads the reply whatever its source, so findings relayed from a subagent's report count; phrases in code blocks and inline code don't. It stops at most once per turn, never when flag was called (even if you turned the item down), and only in interactive sessions (not -p or the SDK). Limits: it is a phrase match, so a reply that leaves something open in other words is missed (the second look above still reads it), and a phrase used in passing can stop a turn needlessly. Add phrases to STOP_PHRASES in hooks/register.tsx; turn it off with the stopCheck option.a), Skip (s) and Add all, so it isn't lost.○ open, blue ◐ in progress, green struck-through ✓ done. A red 🐞 marks bugs (Claude passes kind: "bug" to flag for broken behavior; /followup adds a plain task). ✓ Done / ↺ Reopen toggles an item, ✕ removes it, and /followup <text> adds one by hand. The header counts open, in progress and done. The list is saved per project and comes back in the next session (lists saved by older versions are migrated). Two sessions open in the same project don't overwrite each other: each save merges with what the other saved, so items added, changed or removed in either one are kept that way. When the project has open or in-progress items, the pane opens by itself as the session starts; the openOnStart option turns that off. The list is saved on the machine running Claude Code, so it carries over between local sessions but not between cloud sessions, whose container is gone when they end: use Export to → Memory for that.done tool with that id and the item turns green. done is idempotent, rejects unknown ids, and the prompt tells Claude not to use it for unrelated work. If the message can't be sent, the items go back to open, so you can start them again. Pressing Add to to-do twice keeps the item once.| Button | Writes to | Why |
|---|---|---|
| Memory | CLAUDE.md | Claude reads it at the start of every session |
| Docs | docs/FOLLOW-UPS.md | a page of its own |
| README | README.md, under Known issues & follow-ups | visible to everyone |
The export is a markdown checklist ([ ] open, [x] done, in-progress items tagged _(in progress)_, bugs _(bug)_) between <!-- followups:start --> and <!-- followups:end --> markers. Exporting again replaces that section in place, and the rest of the file is left alone.
Tests: claude plugin test plugins/followups.
| Toggle off | Toggle on |
|---|---|
![]() | ![]() |
A line that starts in English but is mostly Hebrew (or Arabic, or another right-to-left script) can render out of order, with the Hebrew words shifted. This mod fixes those lines in your messages and Claude's.
/plugin install rtl-toggle@claudemod
עברית: פעיל / עברית: כבוי) turns the fix on or off for the session. /rtl does the same on every surface, and /rtl on, /rtl off and /rtl status set or report it.dir or alignment prop, so alignment comes only from the RLM. That works where the surface picks the direction per block (the desktop app). In a terminal it depends on the terminal's own bidi support. Messages already on screen keep their layout until they're drawn again.It also lives in its own repo, Amitro123/RTL-claude-mod, which has the full rules and the performance notes.
Tests: claude plugin test plugins/rtl-toggle.

<sub>Mock-ups, not screenshots: the track and the runner are drawn by the mod's own art.ts, and the band, the pane and the dialogs follow its layout. The dialogs are Claude Code's own, so yours will look like your app's.</sub>
/plugin install goal-run@claudemod

plan tool. Each task has a priority (high, medium or low) and the tasks it depends on. The plan is ordered highest priority first, except that a task comes after what it needs: tests come after the code they test, and say so (needs 1).update tool, and takes the first pending task in the list's current order. Each of your prompts while the goal is open carries the plan's state, so Claude keeps track of where it is.
add tool with a priority and what the task needs. The task goes in among the tasks that haven't started, by priority and after anything it needs. A dialog shows where it lands and what that does to the percentage (It goes in as #4, before "Document the endpoint". Progress 50% → 40%), and you choose Add it or Not now. Done and in-progress tasks stay where they are. The goal's text doesn't change, but the plan grows, so the percentage drops to match the work added./session-goal <text> sets a goal at any time, /session-goal shows it, and /session-goal clear drops it. Claude Code has its own /goal, so the mod's command has a different name.askOnStart (on / off) turns the start-of-session question off./factory plan-approved. If you already have a goal of your own that isn't done, it stays: the factory's plan isn't set as the goal, and the factory asks for approval in its own band. A factory plan starts fresh, without an earlier goal's done tasks.Tests: claude plugin test plugins/goal-run.

<sub>The scenes and the brain map are the mod's own SVG output; the windows around them are mock-ups. The data is a real project's memory, with one new lesson just routed into Projects (✦).</sub>
brain-hud gives Claude a memory that lasts between sessions, and shows it. The mistake-brain skill ships inside it, with its hooks. Claude logs what happened to MISTAKES.md (a mistake, a success, a decision or a handoff). It routes those entries into PARA folders under .agent-brain/shared/ (Projects, Areas, Resources, Archives), promotes a lesson that repeats into a rule (with your approval), and now and then reorganizes the memory ("dream"). The mod shows that memory as it grows. Nothing else needs installing.
/plugin install brain-hud@claudemod
Then, in each project that should remember: /brain-hud:mistake-brain init, or Set up memory in the Brain pane (/brain). It creates MISTAKES.md, the PARA folders, the index .agent-brain/shared/MEMORY.md and the standing rules. A CLAUDE.md tracked in git is left as it is; the instruction goes into .claude/rules/mistake-brain.md.
/brain-hud:mistake-brain whooks/register.tsx 595 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Blocked, Check, Finding, Phase, Reviewer, Role, Run } from '../types'
5import { buildRound, DIAGRAM, diagramSvg, pipelineLines } from './art'
6import type { Theme } from './art'
7import {
8 approvePrompt,
9 buildPrompt,
10 detectChecks,
11 detectSetup,
12 isLive,
13 neverCommit,
14 newRun,
15 openFindings,
16 parseDecision,
17 parseTasks,
18 parseVerdict,
19 planPrompt,
20 prPrompt,
21 reviewPrompt,
22 ROLE_PROMPTS,
23 ROLES,
24 slugOf,
25 specPrompt,
26 splitChecks,
27 tailOf,
28 tok,
29 tokensOf,
30 untrackedOf,
31 waitsOnPerson,
32} from './flow'
33import { FACTORY_PLAN_APPROVED_ARG, parsePlanOutcome, toolText } from './texts'
34
35const run = atom({ plugin: 'factory', key: 'run' } as const, null as Run | null)
36const frame = atom({ plugin: 'factory', key: 'frame' } as const, 0)
37const theme = atom({ plugin: 'factory', key: 'theme' } as const, 'dark' as Theme)
38// The agents of runs that were stopped, newest last: their tool calls stay refused after a new run starts.
39const stoppedAgents = atom({ plugin: 'factory', key: 'stoppedAgents' } as const, [] as string[])
40const MAX_STOPPED = 50
41
42const PANE = 'factory'
43const PANE_TITLE = 'Factory'
44const PLAN_TOOL = 'mcp__goal-run__plan'
45const MAX_CHECK_ROUNDS = 2
46const MAX_SLUG_TRIES = 5
47const CHECK_TIMEOUT_MS = 600_000
48const FRAME_MS = 700
49const ACCENT = '#2563eb'
50const STOPPED_DENY = 'The factory run was stopped. Stop now and answer with one line.'
51
52// Set from the options when the module registers.
53let maxRounds = 2
54let checksOption = ''
55let setupOption = ''
56let autoPr = false
57// Transitions run one at a time: two reviewers finishing together must not both start the approver.
58// Every spawn and every agent's completion goes through this queue, so a completion is handled only
59// after the job that spawned its agent has recorded the agent's id.
60let queue: Promise<void> = Promise.resolve()
61const timers: { cancel: () => void }[] = []
62
63function serial(job: () => Promise<void>): Promise<void> {
64 queue = queue.then(job, job).catch(() => {})
65 return queue
66}
67
68async function patch($: EngineInterface, fn: (r: Run) => Run): Promise<Run | null> {
69 return update($, run, cur => (cur ? fn(cur) : cur))
70}
71
72// A move to a phase the run works in clears what it was blocked on.
73const moveTo = (r: Run, phase: Phase, note: string): Run => ({ ...r, phase, note, blocked: null })
74
75async function block($: EngineInterface, blocked: Blocked, note: string, fn: (r: Run) => Run = r => r) {
76 // A run stopped meanwhile stays stopped.
77 await patch($, r => (isLive(r.phase) ? { ...fn(r), phase: 'needs-human', blocked, note } : r))
78}
79
80async function git($: EngineInterface, args: string[], cwd?: string) {
81 return $.process.run(['git', ...args], { ...(cwd ? { cwd } : {}), timeoutMs: 60_000 }).catch(err => ({ exitCode: 1, stdout: '', stderr: String(err) }))
82}
83
84async function run$($: EngineInterface, argv: string[], cwd: string) {
85 return $.process.run(argv, { cwd, timeoutMs: CHECK_TIMEOUT_MS }).catch(err => ({ exitCode: 1, stdout: '', stderr: String(err) }))
86}
87
88const describe = (role: Role, r: Run) =>
89 role === 'builder' ? `builder, round ${buildRound(r)}` : role === 'spec' ? 'spec-writer' : role === 'approver' ? 'approver' : `${ROLES[role].label} review`
90
91const goalRunThere = async ($: EngineInterface) => (await $.command.list().catch(() => [])).some(c => c.name === 'session-goal')
92
93// Starts one role's agent in the run's worktree. A role type the engine refused to register falls
94// back to a general-purpose agent carrying the role's instructions in its prompt.
95async function spawnRole($: EngineInterface, role: Role, prompt: string): Promise<boolean> {
96 const r = await read($, run)
97 if (!r || !isLive(r.phase)) return false
98 const spec = ROLES[role]
99 const base = { prompt, description: `factory · ${describe(role, r)}`, model: spec.model, cwd: r.worktree }
100 let res = await $.agent.spawn({ ...base, subagentType: `factory:${spec.name}` }).catch(err => ({ deny: String(err) }) as { deny: string })
101 if ('deny' in res && res.deny) {
102 res = await $.agent.spawn({ ...base, subagentType: 'general-purpose', prompt: `${ROLE_PROMPTS[role]}\n\n${prompt}` }).catch(err => ({ deny: String(err) }) as { deny: string })
103 }
104 if ('deny' in res && res.deny) {
105 await block($, { on: 'start', role }, `Could not start the ${describe(role, r)}: ${res.deny}`)
106 return false
107 }
108 // Without an id in the answer, the agent is found in the session's list by its description.
109 const id = (res as { agentId?: string }).agentId ?? (await $.agent.list().catch(() => [])).filter(a => a.description === base.description && !r.agents[a.id]).at(-1)?.id
110 if (!id) {
111 await block($, { on: 'start', role }, `Started the ${describe(role, r)}, but can't follow it: no agent id.`)
112 return false
113 }
114 await patch($, cur => ({
115 ...cur,
116 agents: { ...cur.agents, [id]: role },
117 reviews: cur.reviews.map(x => (x.role === role ? { ...x, agentId: id } : x)),
118 }))
119 return true
120}
121
122async function start($: EngineInterface, feature: string): Promise<string> {
123 const cur = await read($, run)
124 if (cur && isLive(cur.phase)) return `A factory run is already going: ${cur.feature}. /factory stop ends it.`
125 const top = await git($, ['rev-parse', '--show-toplevel'])
126 if (top.exitCode !== 0) return 'The factory needs a git repository: it builds on a branch, in a worktree of its own.'
127 const root = top.stdout.trim()
128 const head = await git($, ['rev-parse', 'HEAD'], root)
129 if (head.exitCode !== 0) return 'The repository has no commit yet to build from.'
130 const at = await $.clock.now()
131 const slug = slugOf(feature)
132 let r: Run | null = null
133 // A branch or folder name already taken gets a number; any other failure is reported as it is.
134 for (let k = 1; k <= MAX_SLUG_TRIES && !r; k++) {
135 const next = newRun(feature, at, head.stdout.trim(), root, k === 1 ? slug : `${slug}-${k}`)
136 const added = await git($, ['worktree', 'add', '-b', next.branch, next.worktree, next.base], root)
137 if (added.exitCode === 0) r = next
138 else if (!/already exists/i.test(added.stderr)) return `Could not create a worktree for the run: ${tailOf(added.stderr, 3)}`
139 }
140 if (!r) return `Could not create a worktree for the run: the branches factory/${slug} to factory/${slug}-${MAX_SLUG_TRIES} are all taken.`
141 const created: Run = { ...r, gate: (await goalRunThere($)) ? 'goal-run' : 'factory' }
142 await keepOutOfGit($, root)
143 await readTheme($)
144 await update($, run, () => created)
145 void $.ui.open({ id: PANE, title: PANE_TITLE })
146 await serial(async () => void (await spawnRole($, 'spec', specPrompt(created))))
147 return `Factory run started: ${created.feature}. Branch ${created.branch}, worktree ${created.worktree}; the spec-writer is writing ${created.specFile}.`
148}
149
150// The worktrees live in .factory/ of the repository: listed in its exclude file, found through
151// git (a .git that is a file, in a worktree or a submodule, has it elsewhere), never committed.
152async function keepOutOfGit($: EngineInterface, root: string) {
153 const found = await git($, ['rev-parse', '--git-path', 'info/exclude'], root)
154 if (found.exitCode !== 0 || !found.stdout.trim()) return
155 const rel = found.stdout.trim()
156 const path = /^([a-zA-Z]:)?[\\/]/.test(rel) ? rel : `${root}/${rel}`
157 const text = await $.fs.read(path).then(t => String(t), () => '')
158 if (/^\.factory\/$/m.test(text)) return
159 await $.fs.write(path, `${text}${text && !text.endsWith('\n') ? '\n' : ''}.factory/\n`).catch(() => {})
160}
161
162// The diagram's palette follows the theme set in /config; dark when it can't be read.
163async function readTheme($: EngineInterface) {
164 const row = (await $.config.list().catch(() => [])).find(r => r.key === 'theme')
165 const value = String((row as { value?: unknown } | undefined)?.value ?? '')
166 await update($, theme, () => (/light/i.test(value) ? 'light' : 'dark'))
167}
168
169async function onSpec($: EngineInterface, answer: string, ok: boolean) {
170 const tasks = ok ? parseTasks(answer) : []
171 if (!tasks.length) {
172 await block($, { on: 'spec' }, ok ? 'The spec-writer gave no TASKS list.' : 'The spec-writer stopped before finishing.', r => ({ ...r, steps: { ...r.steps, spec: { ...r.steps.spec, state: 'fail' } } }))
173 return
174 }
175 // goal-run is asked again: installed or removed since the run started, the plan goes where it can.
176 const viaGoalRun = (await read($, run))?.gate === 'goal-run' && (await goalRunThere($))
177 const r = await patch($, cur => ({
178 ...moveTo(cur, 'plan', 'waiting for you to approve the plan'),
179 tasks,
180 gate: viaGoalRun ? 'goal-run' : 'factory',
181 steps: { ...cur.steps, spec: { ...cur.steps.spec, state: 'pass' } },
182 }))
183 if (r && viaGoalRun) void $.prompt.submit({ text: planPrompt(r) }).catch(() => {})
184}
185
186async function onPlan($: EngineInterface, outcome: { approved: true } | { changes: string } | { kept: true }) {
187 const r = await read($, run)
188 if (!r || r.phase !== 'plan') return
189 if ('approved' in outcome) return startBuild($, [], [])
190 // The person has a goal of their own in goal-run: the factory's band asks instead.
191 if ('kept' in outcome) return void (await patch($, cur => ({ ...cur, gate: 'factory', note: 'waiting for you to approve the plan (here: goal-run keeps your own goal)' })))
192 await respec($, outcome.changes || 'The person asked for changes to the plan.')
193}
194
195async function respec($: EngineInterface, feedback = '') {
196 const r = await patch($, cur => ({ ...moveTo(cur, 'spec', feedback ? 'revising the spec' : 'writing the spec'), steps: { ...cur.steps, spec: { ...cur.steps.spec, state: 'run' } } }))
197 if (r) await spawnRole($, 'spec', specPrompt(r, feedback))
198}
199
200async function startBuild($: EngineInterface, findings: Finding[], failed: Check[]) {
201 const note = failed.length ? 'fixing the checks' : findings.length ? `fixing ${findings.length} finding${findings.length === 1 ? '' : 's'}` : 'building'
202 const r = await patch($, cur => ({ ...moveTo(cur, 'build', note), steps: { ...cur.steps, builder: { ...cur.steps.builder, state: 'run' } } }))
203 if (r) await spawnRole($, 'builder', buildPrompt(r, findings, failed))
204}
205
206// What the builder left uncommitted is committed by the factory: reviewers and the PR see the same
207// commits. Tracked changes all go in; a new file only when it isn't installed dependencies, build
208// output or a credential (those are left out, and the run's note says so).
209async function onBuilt($: EngineInterface) {
210 const r = await read($, run)
211 if (!r) return
212 const status = await git($, ['status', '--porcelain'], r.worktree)
213 if (status.exitCode === 0 && status.stdout.trim()) {
214 const untracked = untrackedOf(status.stdout)
215 const skipped = untracked.filter(neverCommit)
216 const fresh = untracked.filter(p => !neverCommit(p))
217 let out = await git($, ['add', '-u'], r.worktree)
218 if (out.exitCode === 0 && fresh.length) out = await git($, ['add', '--', ...fresh], r.worktree)
219 const staged = out.exitCode === 0 ? await git($, ['diff', '--cached', '--quiet'], r.worktree) : out
220 if (out.exitCode === 0 && staged.exitCode !== 0) out = await git($, ['commit', '-m', `factory: ${r.feature}`], r.worktree)
221 if (out.exitCode !== 0) {
222 await block($, { on: 'builder' }, `The builder's work could not be committed: ${tailOf(`${out.stdout}\n${out.stderr}`, 2)}`)
223 return
224 }
225 if (skipped.length) await patch($, cur => ({ ...cur, skipped }))
226 }
227 await runChecks($)
228}
229
230async function namesIn($: EngineInterface, dir: string) {
231 return (await $.fs.list(dir).catch(() => [])).map(f => f.name)
232}
233
234async function checkCommands($: EngineInterface, worktree: string, files: string[]): Promise<string[][]> {
235 if (checksOption.trim()) return splitChecks(checksOption)
236 const pkg = files.includes('package.json') ? await $.fs.read(`${worktree}/package.json`).then(t => String(t), () => null) : null
237 return detectChecks(files, pkg)
238}
239
240// A fresh worktree has no dependencies installed: the `setup` option, or the lockfile's install, runs
241// once a run before its first checks. Its failure is the person's to look at, not the builder's.
242async function setUp($: EngineInterface, r: Run, files: string[]): Promise<boolean> {
243 if (r.setupDone) return true
244 const cmds = setupOption.trim() ? splitChecks(setupOption) : [detectSetup(files)].filter((c): c is string[] => c !== null)
245 for (const argv of cmds) {
246 if (!(await stillIn($, 'checks'))) return false
247 await patch($, cur => ({ ...cur, note: `installing: ${argv.join(' ')}` }))
248 const out = await run$($, argv, r.worktree)
249 if (out.exitCode !== 0) {
250 const check: Check = { cmd: argv.join(' '), ok: false, tail: tailOf(`${out.stdout}\n${out.stderr}`) }
251 await block($, { on: 'setup' }, `The setup failed: ${check.cmd}. Fix it (or set the setup option), then Retry.`, cur => ({ ...cur, checks: [check], steps: { ...cur.steps, checks: { ...cur.steps.checks, state: 'fail' } } }))
252 return false
253 }
254 }
255 await patch($, cur => ({ ...cur, setupDone: true }))
256 return true
257}
258
259async function stillIn($: EngineInterface, phase: Run['phase']) {
260 return (await read($, run))?.phase === phase
261}
262
263async function runChecks($: EngineInterface) {
264 const r = await patch($, cur => ({
265 ...moveTo(cur, 'checks', 'running the checks'),
266 steps: { ...cur.steps, builder: { ...cur.steps.builder, state: 'pass' }, checks: { ...cur.steps.checks, state: 'run' } },
267 checkRound: cur.checkRound + 1,
268 }))
269 if (!r) return
270 const files = await namesIn($, r.worktree)
271 if (!(await setUp($, r, files))) return
272 const cmds = await checkCommands($, r.worktree, files)
273 await patch($, cur => ({ ...cur, note: 'running the checks' }))
274 const results: Check[] = []
275 for (const argv of cmds) {
276 // A Stop meanwhile ends the checks here: the rest don't run for nothing.
277 if (!(await stillIn($, 'checks'))) return
278 const out = await run$($, argv, r.worktree)
279 results.push({ cmd: argv.join(' '), ok: out.exitCode === 0, tail: out.exitCode === 0 ? '' : tailOf(`${out.stdout}\n${out.stderr}`) })
280 }
281 const failed = results.filter(c => !c.ok)
282 const after = await patch($, cur => ({ ...cur, checks: results, steps: { ...cur.steps, checks: { ...cur.steps.checks, state: !cmds.length ? 'skip' : failed.length ? 'fail' : 'pass' } } }))
283 if (!after || after.phase !== 'checks') return
284 if (!failed.length) return startReviews($)
285 if (after.checkRound < MAX_CHECK_ROUNDS) return startBuild($, [], failed)
286 await block($, { on: 'checks' }, `The checks still fail after ${after.checkRound} builder passes: ${failed.map(c => c.cmd).join(', ')}.`)
287}
288
289// Every reviewer looks at every round: a fix for one reviewer is new code for the others too.
290async function startReviews($: EngineInterface) {
291 const r = await patch($, cur => {
292 const round = cur.round + 1
293 return {
294 ...moveTo(cur, 'review', `reviewing, round ${round}`),
295 round,
296 checkRound: 0,
297 reviews: cur.reviews.map(x => ({ ...x, state: 'run', findings: [], round, agentId: null })),
298 }
299 })
300 if (r) await spawnReviewers($, r)
301}
302
303// The reviewers of this round not started yet (all of them, or those a failed start left).
304async function spawnReviewers($: EngineInterface, r: Run) {
305 const stat = await git($, ['diff', '--stat', r.base, 'HEAD'], r.worktree)
306 for (const x of r.reviews.filter(v => v.round === r.round && v.state === 'run' && !v.agentId)) {
307 if (!(await spawnRole($, x.role, reviewPrompt(r, x.role, stat.exitCode === 0 ? stat.stdout.trim() : '')))) return
308 }
309}
310
311async function onReview($: EngineInterface, role: Reviewer, agentId: string, answer: string, ok: boolean) {
312 const verdict = ok ? parseVerdict(answer) : { state: 'changes' as const, findings: [{ severity: 'major' as const, where: '-', text: 'The reviewer stopped before finishing.' }] }
313 const r = await patch($, cur => ({ ...cur, reviews: cur.reviews.map(x => (x.role === role && x.agentId === agentId ? { ...x, state: verdict.state, findings: verdict.findings } : x)) }))
314 if (!r || r.phase !== 'review') return
315 const mine = r.reviews.filter(x => x.round === r.round)
316 if (mine.some(x => x.state === 'run')) return
317 const changes = mine.filter(x => x.state === 'changes')
318 if (changes.length && r.round < maxRounds) return startBuild($, openFindings(changes), [])
319 await startApprover($)
320}
321
322async function startApprover($: EngineInterface) {
323 const r = await patch($, cur => ({ ...moveTo(cur, 'approve', 'the approver is deciding'), steps: { ...cur.steps, approver: { ...cur.steps.approver, state: 'run' } } }))
324 if (r) await spawnRole($, 'approver', approvePrompt(r))
325}
326
327async function onApprover($: EngineInterface, answer: string, ok: boolean) {
328 const { decision, reason } = ok ? parseDecision(answer) : { decision: 'ESCALATE' as const, reason: 'The approver stopped before deciding.' }
329 if (decision === 'ESCALATE') {
330 await block($, { on: 'escalate' }, reason, r => ({ ...r, steps: { ...r.steps, approver: { ...r.steps.approver, state: 'changes' } } }))
331 $.ui.toast('The factory needs you: Approve PR, or /factory stop')
332 return
333 }
334 // The approver is an agent: the PR, outward-facing, waits for the person unless autoPr is on.
335 if (autoPr) return finish($, reason)
336 await patch($, r => ({ ...moveTo(r, 'ready', `approved${reason ? `: ${reason}` : ''}. Open the PR when you're ready`), steps: { ...r.steps, approver: { ...r.steps.approver, state: 'pass' } } }))
337 $.ui.toast('The approver approved the change: Open PR in the factory band, or /factory approve')
338}
339
340// Approved by the person (or by the approver, with autoPr on): the main Claude opens the PR.
341async function finish($: EngineInterface, reason: string) {
342 const r = await patch($, cur => ({ ...moveTo(cur, 'done', reason || 'approved'), steps: { ...cur.steps, approver: { ...cur.steps.approver, state: 'pass' } } }))
343 if (!r) return
344 void $.prompt.submit({ text: prPrompt(r, reason) }).catch(() => {
345 $.ui.toast(`Approved. Open a PR from ${r.branch} yourself; the prompt could not be sent.`)
346 })
347}
348
349async function onAgentDone($: EngineInterface, agentId: string, answer: string, ok: boolean) {
350 const r = await read($, run)
351 const role = r?.agents[agentId]
352 if (!r || !role || !isLive(r.phase)) return
353 if (role === 'spec') return r.phase === 'spec' ? onSpec($, answer, ok) : undefined
354 if (role === 'builder') {
355 if (r.phase !== 'build') return
356 if (!ok) return block($, { on: 'builder' }, 'The builder stopped before finishing.', cur => ({ ...cur, steps: { ...cur.steps, builder: { ...cur.steps.builder, state: 'fail' } } }))
357 return onBuilt($)
358 }
359 if (role === 'approver') return r.phase === 'approve' ? onApprover($, answer, ok) : undefined
360 return onReview($, role, agentId, answer, ok)
361}
362
363async function addTokens($: EngineInterface, agentId: string, n: number) {
364 await patch($, r => {
365 const role = r.agents[agentId]
366 if (!role || !n) return r
367 if (role === 'security' || role === 'code') return { ...r, reviews: r.reviews.map(x => (x.role === role ? { ...x, tokens: x.tokens + n } : x)) }
368 const key = role === 'spec' ? 'spec' : role === 'builder' ? 'builder' : 'approver'
369 return { ...r, steps: { ...r.steps, [key]: { ...r.steps[key], tokens: r.steps[key].tokens + n } } }
370 })
371}
372
373// Approve: the plan, the PR the approver approved, or a change the approver escalated. Anything else
374// that stopped the run isn't approved past: it's retried or the run is stopped.
375async function approveNow($: EngineInterface): Promise<string> {
376 const r = await read($, run)
377 if (!r) return 'No factory run.'
378 // Queued, not awaited: a prompt submitted while the command's hook still runs is refused.
379 if (r.phase === 'plan') {
380 void serial(() => onPlan($, { approved: true }))
381 return 'Plan approved: the builder starts.'
382 }
383 if (r.phase === 'ready' || (r.phase === 'needs-human' && r.blocked?.on === 'escalate')) {
384 void serial(() => finish($, r.phase === 'ready' ? r.note.replace(/\. Open the PR when you're ready$/, '') : 'approved by the person over the approver'))
385 return 'Approved: Claude opens the PR.'
386 }
387 if (r.phase === 'needs-human') return `Nothing to approve: ${r.note} /factory retry runs that step again; /factory stop ends the run.`
388 return `Nothing waits for approval: the run is ${r.note}.`
389}
390
391// Retry: the step that stopped the run, again.
392async function retryNow($: EngineInterface): Promise<string> {
393 const r = await read($, run)
394 if (!r || r.phase !== 'needs-human' || !r.blocked) return 'Nothing to retry.'
395 if (r.blocked.on === 'escalate') return 'The approver escalated: /factory approve opens the PR anyway, /factory stop ends the run.'
396 const b = r.blocked
397 void serial(async () => {
398 if (b.on === 'spec') return respec($)
399 if (b.on === 'builder') return startBuild($, openFindings(r.reviews.filter(x => x.round === r.round)), r.checks.filter(c => !c.ok))
400 if (b.on === 'checks' || b.on === 'setup') {
401 await patch($, cur => ({ ...cur, checkRound: 0 }))
402 return runChecks($)
403 }
404 if (b.role === 'spec') return respec($)
405 if (b.role === 'builder') return startBuild($, openFindings(r.reviews.filter(x => x.round === r.round)), r.checks.filter(c => !c.ok))
406 if (b.role === 'approver') return startApprover($)
407 const now = await patch($, cur => moveTo(cur, 'review', `reviewing, round ${cur.round}`))
408 if (now) await spawnReviewers($, now)
409 })
410 return `Retrying: ${b.on === 'start' ? `starting the ${describe(b.role, r)}` : b.on === 'setup' ? 'the setup and the checks' : b.on === 'checks' ? 'the checks' : `the ${b.on === 'spec' ? 'spec-writer' : 'builder'}`}.`
411}
412
413async function stopNow($: EngineInterface): Promise<string> {
414 const r = await patch($, cur => (isLive(cur.phase) ? moveTo(cur, 'stopped', 'stopped by the person') : cur))
415 if (r) await update($, stoppedAgents, ids => [...ids.filter(id => !r.agents[id]), ...Object.keys(r.agents)].slice(-MAX_STOPPED))
416 return r ? `Stopped. The branch ${r.branch} and its worktree ${r.worktree} stay; remove them with git worktree remove when done.` : 'No factory run.'
417}
418
419// What the person can do when the run waits on them: open the approved PR, approve past an
420// escalation, or retry the step that stopped the run.
421function actions($: EngineInterface, r: Run): { key: string; label: string; press: () => unknown }[] {
422 if (r.phase === 'ready') return [{ key: 'pr', label: 'Open PR', press: () => approveNow($) }]
423 if (r.phase !== 'needs-human') return []
424 if (r.blocked?.on === 'escalate') return [{ key: 'pr', label: 'Approve PR', press: () => approveNow($) }]
425 return [{ key: 'retry', label: 'Retry', press: () => retryNow($) }]
426}
427
428async function tick($: EngineInterface) {
429 const r = await read($, run)
430 if (r && isLive(r.phase) && !waitsOnPerson(r.phase)) await update($, frame, f => f + 1)
431}
432
433export const register: Register = (on, options) => {
434 maxRounds = Math.max(1, Math.min(3, Number(options.maxRounds ?? 2) || 2))
435 checksOption = String(options.checks ?? '')
436 setupOption = String(options.setup ?? '')
437 autoPr = String(options.autoPr ?? 'off') === 'on'
438
439 on('session.start', async ($, e, next) => {
440 for (const t of timers.splice(0)) t.cancel()
441 await $.command.register({ name: 'factory', description: 'Run a feature through the software factory: spec, plan, build, checks, reviews, approval', argumentHint: '<feature> | approve | retry | stop' })
442 for (const role of Object.keys(ROLES) as Role[]) {
443 const spec = ROLES[role]
444 await $.agent
445 .register({ name: spec.name, description: `The factory's ${spec.label}: started by /factory runs only.`, prompt: ROLE_PROMPTS[role], model: spec.model, ...(spec.tools ? { tools: spec.tools } : {}) })
446 .catch(() => {})
447 }
448 timers.push($.clock.every(FRAME_MS, () => tick($)))
449 return next(e)
450 })
451
452 on('session.end', async ($, e, next) => {
453 for (const t of timers.splice(0)) t.cancel()
454 return next(e)
455 }).catch(($, e, next) => next(e))
456
457 // The roles are the factory's: the model doesn't dispatch them itself.
458 on('agent.offer', async ($, e, next) => (e.agent.startsWith('factory:') ? { isOffered: false } : next(e)))
459
460 // A stopped run's agents can't be aborted, so their every tool call is refused: they stop editing
461 // and spending. Only the run's own agents; the main loop and other agents go on.
462 on('tool.call', async ($, e, next) => {
463 if (e.agentId !== undefined) {
464 const r = await read($, run)
465 if ((r?.agents[e.agentId] && !isLive(r.phase)) || (await read($, stoppedAgents)).includes(e.agentId)) return { deny: STOPPED_DENY }
466 }
467 return next(e)
468 }).catch(($, e, next) => next(e))
469
470 on('command.run', { command: 'factory' }, async ($, e) => {
471 const arg = e.args.trim()
472 if (arg === 'approve' || arg === FACTORY_PLAN_APPROVED_ARG) return { text: await approveNow($) }
473 if (arg === 'retry') return { text: await retryNow($) }
474 if (arg === 'stop') return { text: await stopNow($) }
475 if (!arg) {
476 await $.ui.open({ id: PANE, title: PANE_TITLE })
477 const r = await read($, run)
478 return { text: r ? `${r.feature}: ${r.note}.` : 'No factory run. /factory <feature> starts one.' }
479 }
480 return { text: await start($, arg) }
481 })
482
483 // Each agent's answer moves the run on, in the queue (see serial).
484 on('turn.complete', async ($, e, next) => {
485 const r = await next(e)
486 if (e.agentId !== undefined) {
487 const id = e.agentId
488 void serial(() => onAgentDone($, id, e.answer, e.reason === 'answer'))
489 }
490 return r
491 }).catch(($, e, next) => next(e))
492
493 on('turn.step', async function* ($, e, next) {
494 const r = yield* next(e)
495 const u = r.usage
496 if (e.agentId !== undefined && u && (await read($, run))?.agents[e.agentId]) await addTokens($, e.agentId, u.input_tokens + u.output_tokens + u.cache_creation_input_tokens)
497 return r
498 }).catch(async function* ($, e, next) { return yield* next(e) })
499
500 // goal-run's plan dialog answers for the spec's tasks: approved, changes for the spec-writer, or
501 // kept (the person's own goal stays; the factory asks). Read from the tool call and from
502 // PostToolUse, whichever reaches the factory (the mods' order).
503 on('tool.call', { tool: PLAN_TOOL }, async ($, e, next) => {
504 const r = await next(e)
505 const outcome = parsePlanOutcome(String(r.result ?? ''))
506 if (outcome) void serial(() => onPlan($, outcome))
507 return r
508 }).catch(($, e, next) => next(e))
509
510 on('classic.PostToolUse', async ($, e, next) => {
511 const r = await next(e)
512 if (e.tool_name === PLAN_TOOL) {
513 const outcome = parsePlanOutcome(toolText(e.tool_response))
514 if (outcome) void serial(() => onPlan($, outcome))
515 }
516 return r
517 }).catch(($, e, next) => next(e))
518
519 // ---------- the band ----------
520
521 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
522 const r = await read($, run)
523 if (e.props.hasSurvey || !r || !isLive(r.phase)) return next(e)
524 const { Box, Button, Text } = $.ui.resolve(e)
525 const below = await next(e)
526 return (
527 <Box flexDirection="column">
528 <Box flexDirection="row" gap={1} paddingX={1} alignItems="center">
529 <Text bold color={waitsOnPerson(r.phase) ? '#d97706' : ACCENT}>{`🏭 ${r.feature}`}</Text>
530 <Text dimColor wrap="truncate-end">{`· ${r.note} · ${tok(tokensOf(r))} tokens`}</Text>
531 {r.phase === 'plan' && r.gate === 'factory' ? <Button key="f-approve-plan" label="Approve plan" variant="primary" onPress={() => approveNow($)} /> : null}
532 {actions($, r).map(a => <Button key={`f-${a.key}`} label={a.label} variant="primary" onPress={a.press} />)}
533 <Button key="f-open" label="Open" onPress={() => $.ui.open({ id: PANE, title: PANE_TITLE })} />
534 <Button key="f-stop" label="Stop" onPress={() => stopNow($)} />
535 </Box>
536 {below}
537 </Box>
538 )
539 })
540
541 // ---------- the pane ----------
542
543 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
544 const { Box, Button, Text } = $.ui.resolve(e)
545 const Svg = e.surface === 'terminal' ? null : $.ui.resolve(e as typeof e & { surface: 'desktop' }).Svg
546 const r = await read($, run)
547 if (!r) {
548 return (
549 <Box flexDirection="column" gap={1}>
550 <Text bold color={ACCENT}>🏭 Factory</Text>
551 <Text dimColor>No run yet. /factory <feature> writes a spec, has you approve its plan, builds it on a branch, runs the checks and the reviews, and asks the approver. You open the PR.</Text>
552 </Box>
553 )
554 }
555 const f = await read($, frame)
556 const findings = r.reviews.filter(x => x.round === r.round && x.findings.length)
557 const failed = r.checks.filter(c => !c.ok)
558 return (
559 <Box flexDirection="column" gap={1}>
560 <Box flexDirection="column">
561 <Text bold color={ACCENT}>{`🏭 ${r.feature}`}</Text>
562 <Text dimColor wrap="truncate-end">{`${r.note} · ${tok(tokensOf(r))} tokens · spec ${r.specFile}`}</Text>
563 </Box>
564 {Svg ? (
565 <Svg source={diagramSvg(r, f, await read($, theme))} alt={pipelineLines(r).join('; ')} width={DIAGRAM.width} height={DIAGRAM.height} />
566 ) : (
567 <Box flexDirection="column">{pipelineLines(r).map((l, i) => <Text key={`p-${i}`}>{l}</Text>)}</Box>
568 )}
569 {findings.length ? (
570 <Box flexDirection="column">
571 <Text bold>{`Round ${r.round} findings`}</Text>
572 {findings.flatMap(x => x.findings.map((g, i) => (
573 <Text key={`f-${x.role}-${i}`} wrap="truncate-end">{`${x.role} · [${g.severity}] ${g.where} ${g.text}`}</Text>
574 )))}
575 </Box>
576 ) : null}
577 {r.skipped?.length ? (
578 <Text dimColor wrap="truncate-end">{`Left out of the commits: ${r.skipped.join(', ')}`}</Text>
579 ) : null}
580 {failed.length ? (
581 <Box flexDirection="column">
582 <Text bold color="#dc2626">Failing checks</Text>
583 {failed.map((c, i) => <Text key={`c-${i}`} dimColor wrap="truncate-end">{`$ ${c.cmd}: ${c.tail.split('\n').slice(-1)[0] ?? ''}`}</Text>)}
584 </Box>
585 ) : null}
586 <Box flexDirection="row" gap={1}>
587 {r.phase === 'plan' ? <Button key="p-approve-plan" label="Approve plan" variant="primary" onPress={() => approveNow($)} /> : null}
588 {actions($, r).map(a => <Button key={`p-${a.key}`} label={a.label} variant="primary" onPress={a.press} />)}
589 {isLive(r.phase) ? <Button key="p-stop" label="Stop" onPress={() => stopNow($)} /> : null}
590 </Box>
591 </Box>
592 )
593 })
594}
595hooks/art.ts 124 lines1// The factory's pipeline, drawn from a run: an SVG diagram for the desktop pane, and the same
2// steps as text lines for the terminal.
3
4import type { Run, StepState } from '../types'
5import { ROLES, tok } from './flow'
6
7const COLOR: Record<StepState, string> = { pass: '#16a34a', changes: '#d97706', run: '#2563eb', wait: '#94a3b8', skip: '#cbd5e1', fail: '#dc2626' }
8const MARK: Record<StepState, string> = { pass: '✓', changes: '!', run: '◐', wait: '◌', skip: '–', fail: '✕' }
9const PALETTE = {
10 dark: { bg: '#0b0e14', card: '#111827', line: '#334155', text: '#e2e8f0', dim: '#94a3b8' },
11 light: { bg: '#fbfaf7', card: '#ffffff', line: '#cbd5e1', text: '#1e293b', dim: '#64748b' },
12}
13export type Theme = keyof typeof PALETTE
14
15export const DIAGRAM = { width: 600, height: 556 }
16
17// The builder's round: the review round it builds for (a build before any review is round 1).
18export const buildRound = (run: Run) => (run.phase === 'build' || run.phase === 'checks' ? run.round + 1 : Math.max(1, run.round))
19
20const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<')
21const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
22
23// One step's state, read off the run (the plan gate and the outcomes have no step of their own).
24export function states(run: Run) {
25 const planState: StepState = run.phase === 'spec' ? 'wait' : run.phase === 'plan' ? 'run' : 'pass'
26 const reviewing = run.reviews.filter(r => r.round === run.round && r.round > 0)
27 const inCount = reviewing.filter(r => r.state === 'pass' || r.state === 'changes' || r.state === 'fail').length
28 const waitState: StepState = !reviewing.length ? 'wait' : inCount < reviewing.length ? 'run' : reviewing.some(r => r.state !== 'pass') ? 'changes' : 'pass'
29 const opened = run.phase === 'done' && run.steps.approver.state === 'pass'
30 return {
31 planState,
32 waitState,
33 inCount,
34 outOf: reviewing.length,
35 // Ready: approved, the PR waits for the person's press.
36 pr: (opened ? 'pass' : run.phase === 'ready' ? 'run' : 'wait') as StepState,
37 human: (run.phase === 'needs-human' ? 'changes' : 'wait') as StepState,
38 }
39}
40
41const checksLine = (run: Run) =>
42 run.checks.length ? run.checks.map(c => `${c.cmd.split(' ').slice(-1)[0]} ${c.ok ? '✓' : '✗'}`).join(' · ') : run.steps.checks.state === 'skip' ? 'none configured' : ''
43
44export function diagramSvg(run: Run, frame: number, theme: Theme = 'light'): string {
45 const P = PALETTE[theme]
46 const { width: W, height: H } = DIAGRAM
47 const cx = W / 2
48 const st = states(run)
49 let s = ''
50 const box = (x: number, y: number, w: number, h: number, title: string, model: string, state: StepState, meta = '') => {
51 const col = COLOR[state]
52 let b = ''
53 if (state === 'run') b += `<rect x="${x - 4}" y="${y - 4}" width="${w + 8}" height="${h + 8}" rx="12" fill="${col}" fill-opacity="${frame % 2 ? 0.16 : 0.08}"/>`
54 const dash = state === 'wait' || state === 'skip' ? ' stroke-dasharray="4 3"' : ''
55 b += `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="9" fill="${P.card}" stroke="${col}" stroke-width="${state === 'skip' ? 1.2 : 2}"${dash}/>`
56 b += `<circle cx="${x + 16}" cy="${y + 17}" r="8" fill="${col}"/><text x="${x + 16}" y="${y + 21}" font-size="10" font-weight="700" fill="#fff" text-anchor="middle">${MARK[state]}</text>`
57 b += `<text x="${x + 30}" y="${y + 21}" font-size="12.5" font-weight="700" fill="${P.text}">${esc(title)}</text>`
58 if (model) b += `<text x="${x + w - 10}" y="${y + 21}" font-size="10" fill="${P.dim}" text-anchor="end">${model}</text>`
59 if (meta) b += `<text x="${x + 12}" y="${y + h - 9}" font-size="10" fill="${P.dim}">${esc(short(meta, Math.floor(w / 5.6)))}</text>`
60 return b
61 }
62 const arrow = (y1: number, y2: number, x = cx, dashed = false) =>
63 `<line x1="${x}" y1="${y1}" x2="${x}" y2="${y2 - 6}" stroke="${P.line}" stroke-width="1.6"${dashed ? ' stroke-dasharray="4 3"' : ''}/><path d="M${x - 4} ${y2 - 7}l4 7 4-7z" fill="${P.line}"/>`
64
65 const bw = 230
66 const bx = cx - bw / 2
67 s += box(bx, 10, bw, 44, ROLES.spec.label, ROLES.spec.model, run.steps.spec.state, run.steps.spec.tokens ? `${tok(run.steps.spec.tokens)} tokens · ${run.tasks.length} tasks` : 'writing the spec')
68 s += arrow(54, 72)
69 s += box(bx, 72, bw, 44, 'plan approval', 'you', st.planState, st.planState === 'run' ? 'waiting for you (Goal pane or here)' : '')
70 s += arrow(116, 130)
71 s += box(bx, 130, bw, 44, buildRound(run) > 1 ? `builder · round ${buildRound(run)}` : 'builder', ROLES.builder.model, run.steps.builder.state, run.steps.builder.tokens ? `${tok(run.steps.builder.tokens)} tokens` : '')
72 s += arrow(174, 192)
73 s += box(bx, 192, bw, 44, run.checkRound > 1 ? `checks · pass ${run.checkRound}` : 'checks', 'no model', run.steps.checks.state, checksLine(run))
74
75 // The reviewers, side by side, all waited for.
76 const rw = 180
77 const gap = 20
78 const n = run.reviews.length
79 const x0 = cx - (n * rw + (n - 1) * gap) / 2
80 s += `<line x1="${cx}" y1="236" x2="${cx}" y2="256" stroke="${P.line}" stroke-width="1.6"/>`
81 s += `<line x1="${x0 + rw / 2}" y1="256" x2="${x0 + (n - 1) * (rw + gap) + rw / 2}" y2="256" stroke="${P.line}" stroke-width="1.6"/>`
82 run.reviews.forEach((r, i) => {
83 const x = x0 + i * (rw + gap)
84 s += arrow(256, 272, x + rw / 2)
85 const meta = r.state === 'pass' ? `PASS${r.tokens ? ` · ${tok(r.tokens)}` : ''}` : r.state === 'changes' ? `${r.findings.length} finding${r.findings.length === 1 ? '' : 's'}${r.tokens ? ` · ${tok(r.tokens)}` : ''}` : r.state === 'run' ? 'reviewing' : ''
86 s += box(x, 272, rw, 48, ROLES[r.role].label, ROLES[r.role].model, r.state, meta)
87 s += `<line x1="${x + rw / 2}" y1="320" x2="${x + rw / 2}" y2="338" stroke="${P.line}" stroke-width="1.6"/>`
88 })
89 s += `<line x1="${x0 + rw / 2}" y1="338" x2="${x0 + (n - 1) * (rw + gap) + rw / 2}" y2="338" stroke="${P.line}" stroke-width="1.6"/>`
90 s += `<text x="${x0}" y="266" font-size="9.5" fill="${P.dim}">reviewers${run.round ? ` · round ${run.round}` : ''}</text>`
91 s += arrow(338, 356)
92 const wcol = COLOR[st.waitState]
93 s += `<rect x="${bx}" y="356" width="${bw}" height="40" rx="9" fill="${P.card}" stroke="${wcol}" stroke-width="2"${st.waitState === 'wait' ? ' stroke-dasharray="4 3"' : ''}/>`
94 s += `<text x="${cx}" y="374" font-size="12" font-weight="700" fill="${P.text}" text-anchor="middle">wait for all${st.outOf ? ` · ${st.inCount} of ${st.outOf} in` : ''}</text>`
95 if (st.waitState === 'changes') s += `<text x="${cx}" y="388" font-size="10" fill="${wcol}" text-anchor="middle">${run.reviews.filter(r => r.round === run.round && r.state === 'changes').length} asked for changes</text>`
96 // The loop back to the builder, while rounds are left.
97 s += `<path d="M${bx + bw} 376 H${W - 14} V152 H${bx + bw + 6}" fill="none" stroke="${st.waitState === 'changes' ? COLOR.changes : P.line}" stroke-width="1.6" stroke-dasharray="5 4"/>`
98 s += `<path d="M${bx + bw + 8} 148l-8 4 8 4z" fill="${st.waitState === 'changes' ? COLOR.changes : P.line}"/>`
99 s += `<text x="${W - 20}" y="300" font-size="10" fill="${P.dim}" transform="rotate(-90 ${W - 20} 300)">changes: builder again</text>`
100 s += arrow(396, 414, cx, true)
101 s += box(bx, 414, bw, 44, ROLES.approver.label, ROLES.approver.model, run.steps.approver.state, run.phase === 'needs-human' || run.phase === 'ready' || run.phase === 'done' ? run.note : '')
102 s += `<line x1="${cx - 70}" y1="458" x2="${cx - 70}" y2="486" stroke="${P.line}" stroke-dasharray="4 3"/><line x1="${cx + 70}" y1="458" x2="${cx + 70}" y2="486" stroke="${P.line}" stroke-dasharray="4 3"/>`
103 s += `<text x="${cx - 118}" y="480" font-size="9.5" fill="${P.dim}" text-anchor="middle">APPROVE</text><text x="${cx + 118}" y="480" font-size="9.5" fill="${P.dim}" text-anchor="middle">ESCALATE</text>`
104 s += box(cx - 190, 486, 160, 40, 'open PR', '', st.pr)
105 s += box(cx + 30, 486, 160, 40, 'needs-human', '', st.human)
106 s += `<text x="${cx}" y="546" font-size="10" fill="${P.dim}" text-anchor="middle">${esc(short(`branch ${run.branch} · ${run.worktree.split(/[\\/]/).slice(-2).join('/')}`, 96))}</text>`
107 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><rect width="${W}" height="${H}" rx="10" fill="${P.bg}"/>${s}</svg>`
108}
109
110// The terminal's pipeline: one line a step.
111export function pipelineLines(run: Run): string[] {
112 const st = states(run)
113 const line = (state: StepState, label: string, extra = '') => `${MARK[state]} ${label}${extra ? ` · ${extra}` : ''}`
114 return [
115 line(run.steps.spec.state, `spec-writer (${ROLES.spec.model})`, run.tasks.length ? `${run.tasks.length} tasks` : ''),
116 line(st.planState, 'plan approval (you)'),
117 line(run.steps.builder.state, `builder${buildRound(run) > 1 ? ` · round ${buildRound(run)}` : ''} (${ROLES.builder.model})`),
118 line(run.steps.checks.state, 'checks', checksLine(run)),
119 ...run.reviews.map(r => ` ${line(r.state, `${ROLES[r.role].label} (${ROLES[r.role].model})`, r.state === 'changes' ? `${r.findings.length} findings` : r.state === 'pass' ? 'PASS' : '')}`),
120 line(run.steps.approver.state, `approver (${ROLES.approver.model})`),
121 line(st.pr !== 'wait' ? st.pr : st.human === 'changes' ? 'changes' : 'wait', st.pr === 'pass' ? 'open PR' : st.pr === 'run' ? 'open PR (you)' : st.human === 'changes' ? 'needs-human' : 'PR or needs-human'),
122 ]
123}
124hooks/flow.ts 239 lines1// The factory's pure parts: what each role is told, how its answer is read, which checks run, and
2// how a run starts. The hooks module only spawns, runs and draws.
3
4import type { Check, Finding, Phase, Review, Reviewer, Role, Run, Severity, SpecTask, StepState } from '../types'
5import { FACTORY_SOURCE } from './texts'
6
7export const REVIEWERS: Reviewer[] = ['security', 'code']
8
9// Each role as an agent type `factory:<name>`, with the model it runs on by default.
10export const ROLES: Record<Role, { name: string; model: string; label: string; tools?: string[] }> = {
11 spec: { name: 'spec-writer', model: 'opus', label: 'spec-writer', tools: ['Read', 'Grep', 'Glob', 'Write', 'Edit', 'Bash'] },
12 builder: { name: 'builder', model: 'opus', label: 'builder' },
13 security: { name: 'security-reviewer', model: 'opus', label: 'security', tools: ['Read', 'Grep', 'Glob', 'Bash'] },
14 code: { name: 'code-reviewer', model: 'sonnet', label: 'code', tools: ['Read', 'Grep', 'Glob', 'Bash'] },
15 approver: { name: 'approver', model: 'opus', label: 'approver', tools: ['Read', 'Grep', 'Glob'] },
16}
17
18// The roles' standing instructions, short defaults; a project's own skills can say more.
19export const ROLE_PROMPTS: Record<Role, string> = {
20 spec: `You are the spec-writer of a software factory. You turn a feature request into a spec a builder can implement and reviewers can check against: a short PRD (the problem, who it's for, what's out of scope), a technical spec (the design, interfaces, data, failure modes) and numbered acceptance criteria, each testable. Read the code you need first; don't write any implementation.`,
21 builder: `You are the builder of a software factory. You implement exactly what the spec asks, in the repository you are started in, with tests for each acceptance criterion. Keep the change focused; don't refactor beyond it. Commit your work to the current branch when you are done.`,
22 security: `You are the security reviewer of a software factory. Review the change against the spec for: injection and unsafe input handling, authentication and authorization, secrets in code or logs, unsafe defaults, dependency risk, and data exposure. Report only real, specific problems.`,
23 code: `You are the code reviewer of a software factory. Review the change against the spec for: correctness against each acceptance criterion, missing or weak tests, error handling, clarity, layering and design, and performance traps. Report only real, specific problems.`,
24 approver: `You are the approver of a software factory. You decide whether a change that went through review can go to a pull request. You don't review the code again: you weigh the spec, the check results and the reviewers' verdicts and findings.`,
25}
26
27// ---------- reading the agents' answers ----------
28
29// The spec-writer ends with: TASKS: then lines "1. [high] Title (needs 2, 3)".
30export function parseTasks(answer: string): SpecTask[] {
31 const at = answer.lastIndexOf('TASKS:')
32 if (at < 0) return []
33 const out: SpecTask[] = []
34 for (const raw of answer.slice(at + 6).split('\n')) {
35 const m = /^\s*\d+[.)]\s*\[(high|medium|low)\]\s*(.+?)\s*(?:\(needs ([\d,\s]+)\))?\s*$/i.exec(raw)
36 if (!m) continue
37 out.push({ title: m[2]!.trim(), priority: m[1]!.toLowerCase() as SpecTask['priority'], dependsOn: (m[3] ?? '').split(/[,\s]+/).filter(Boolean).map(Number) })
38 }
39 return out
40}
41
42// A reviewer ends with: VERDICT: PASS | CHANGES and FINDINGS: "- [major] path:line what and why".
43// No verdict reads as CHANGES, so a confused reviewer can't wave a change through.
44export function parseVerdict(answer: string): { state: 'pass' | 'changes'; findings: Finding[] } {
45 const verdict = /VERDICT:\s*(PASS|CHANGES)/i.exec(answer)?.[1]?.toUpperCase()
46 const findings: Finding[] = []
47 for (const raw of answer.split('\n')) {
48 const m = /^\s*[-*]\s*\[(blocker|major|minor)\]\s+(\S+)\s+[—–-]?\s*(.+)$/i.exec(raw)
49 if (m) findings.push({ severity: m[1]!.toLowerCase() as Severity, where: m[2]!, text: m[3]!.trim() })
50 }
51 if (!verdict) return { state: 'changes', findings: [{ severity: 'major', where: '-', text: 'The reviewer gave no VERDICT line.' }, ...findings] }
52 // A PASS that still lists a blocker isn't a pass.
53 const state = verdict === 'PASS' && !findings.some(f => f.severity === 'blocker') ? 'pass' : 'changes'
54 return { state, findings }
55}
56
57// The approver ends with: DECISION: APPROVE | ESCALATE and REASON: one line.
58export function parseDecision(answer: string): { decision: 'APPROVE' | 'ESCALATE'; reason: string } {
59 const decision = /DECISION:\s*(APPROVE|ESCALATE)/i.exec(answer)?.[1]?.toUpperCase() === 'APPROVE' ? 'APPROVE' : 'ESCALATE'
60 const reason = /REASON:\s*(.+)/i.exec(answer)?.[1]?.trim() ?? (decision === 'ESCALATE' ? 'The approver gave no clear decision.' : '')
61 return { decision, reason }
62}
63
64// ---------- the checks ----------
65
66// The `checks` option: commands separated by ";" or new lines, each run without a shell.
67export function splitChecks(option: string): string[][] {
68 return option.split(/[;\n]/).map(c => c.trim()).filter(Boolean).map(c => c.split(/\s+/))
69}
70
71// With no `checks` set: what the project's own files suggest.
72export function detectChecks(files: string[], packageJson: string | null): string[][] {
73 const out: string[][] = []
74 if (packageJson !== null) {
75 let scripts: Record<string, unknown> = {}
76 try {
77 scripts = (JSON.parse(packageJson) as { scripts?: Record<string, unknown> }).scripts ?? {}
78 } catch {
79 scripts = {}
80 }
81 if (scripts.test) out.push(['npm', 'test'])
82 for (const s of ['lint', 'typecheck']) if (scripts[s]) out.push(['npm', 'run', s])
83 }
84 if (files.includes('pyproject.toml') || files.includes('pytest.ini') || files.includes('setup.cfg')) out.push(['python', '-m', 'pytest', '-q'])
85 if (files.includes('go.mod')) out.push(['go', 'test', './...'])
86 if (files.includes('Cargo.toml')) out.push(['cargo', 'test'])
87 return out
88}
89
90// A fresh worktree has no installed dependencies: with no `setup` set, the lockfile says how to
91// install them. Nothing when they're there already (or the project isn't one of these).
92export function detectSetup(files: string[]): string[] | null {
93 if (!files.includes('package.json') || files.includes('node_modules')) return null
94 if (files.includes('pnpm-lock.yaml')) return ['pnpm', 'install', '--frozen-lockfile']
95 if (files.includes('yarn.lock')) return ['yarn', 'install', '--frozen-lockfile']
96 if (files.includes('package-lock.json')) return ['npm', 'ci']
97 return ['npm', 'install']
98}
99
100// The last lines of a failing command's output: enough for the builder, short for the pane.
101export const tailOf = (text: string, lines = 30) => text.trim().split('\n').slice(-lines).join('\n')
102
103// ---------- what each step is asked ----------
104
105export const slugOf = (feature: string) =>
106 feature.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'feature'
107
108export function specPrompt(run: Run, feedback = ''): string {
109 return [
110 `Feature: ${run.feature}`,
111 ...(feedback ? [`The person asked for changes to your earlier spec: ${feedback}`, 'Revise the spec file and the tasks.'] : []),
112 `Write the spec to ${run.specFile} (create the folder if needed).`,
113 'Then end your answer with the task breakdown, in this exact form, 3 to 8 tasks, in any order:',
114 'TASKS:',
115 '1. [high] <short imperative title>',
116 '2. [medium] <title> (needs 1)',
117 'Priority is how much a task matters to the feature; "needs" lists the tasks it depends on.',
118 ].join('\n')
119}
120
121export function buildPrompt(run: Run, findings: Finding[], failed: Check[]): string {
122 const tasks = run.tasks.map((t, i) => `- t${i + 1}: ${t.title}`)
123 return [
124 `Implement the spec in ${run.specFile} for: ${run.feature}`,
125 `You are on branch ${run.branch}, in its own worktree. Commit your work to this branch when done.`,
126 'The tasks (in the session goal, if goal-run is installed: mark each with mcp__goal-run__update as in_progress, then done):',
127 ...tasks,
128 ...(failed.length
129 ? ['', 'The checks failed on your last pass. Fix them:', ...failed.map(c => `$ ${c.cmd}\n${c.tail}`)]
130 : []),
131 ...(findings.length
132 ? ['', `Review round ${run.round} asked for changes. Fix each blocker and major finding; fix a minor one only when it's cheap:`, ...findings.map(f => `- [${f.severity}] ${f.where} ${f.text}`)]
133 : []),
134 '',
135 'End with a two-line summary of what you changed.',
136 ].join('\n')
137}
138
139export function reviewPrompt(run: Run, role: Reviewer, stat: string): string {
140 return [
141 `Review the change for: ${run.feature}`,
142 `The spec is ${run.specFile}. The change is the commits on branch ${run.branch} since ${run.base.slice(0, 12)}, in the repository you are started in (git diff ${run.base.slice(0, 12)}..HEAD): exactly what will be pushed.`,
143 stat ? `Files changed:\n${stat}` : '',
144 `Look only at what a ${role} review covers. Don't change any file.`,
145 'End your answer with exactly:',
146 'VERDICT: PASS or VERDICT: CHANGES',
147 'FINDINGS:',
148 '- [blocker|major|minor] <path:line> <what is wrong and why>',
149 'A blocker or major finding means CHANGES. Write "FINDINGS: none" when there are none.',
150 ].filter(Boolean).join('\n')
151}
152
153export function approvePrompt(run: Run): string {
154 const reviews = run.reviews.map(r => `- ${r.role}: ${r.state === 'pass' ? 'PASS' : 'CHANGES'}${r.findings.length ? `\n${r.findings.map(f => ` - [${f.severity}] ${f.where} ${f.text}`).join('\n')}` : ''}`)
155 return [
156 `Decide on the change for: ${run.feature}`,
157 `The spec is ${run.specFile}; the change is on branch ${run.branch}.`,
158 `Checks: ${run.checks.length ? run.checks.map(c => `${c.cmd} ${c.ok ? 'passed' : 'FAILED'}`).join(', ') : 'none configured'}.`,
159 `Review rounds: ${run.round}. The last verdicts:`,
160 ...reviews,
161 'APPROVE when every acceptance criterion is met and no blocker or major finding is left; otherwise ESCALATE to the person.',
162 'End with exactly:',
163 'DECISION: APPROVE or DECISION: ESCALATE',
164 'REASON: <one line>',
165 ].join('\n')
166}
167
168// To the main Claude: put the spec's tasks into goal-run's plan, where the person approves them.
169export function planPrompt(run: Run): string {
170 const tasks = JSON.stringify(run.tasks.map(t => ({ title: t.title, priority: t.priority, ...(t.dependsOn.length ? { dependsOn: t.dependsOn } : {}) })))
171 return [
172 `The factory's spec for "${run.feature}" is in ${run.specFile}.`,
173 `Call mcp__goal-run__plan now with goal "${run.feature}", source "${FACTORY_SOURCE}", and exactly these tasks, unchanged:`,
174 tasks,
175 "Then stop: the factory's builder does the work. Don't start the tasks or edit any file.",
176 ].join('\n')
177}
178
179// To the main Claude, once approved: the PR, through whatever GitHub access the session has.
180export function prPrompt(run: Run, reason: string): string {
181 const minor = run.reviews.flatMap(r => r.findings.filter(f => f.severity === 'minor').map(f => `- ${r.role}: ${f.where} ${f.text}`))
182 return [
183 `The factory run "${run.feature}" is approved${reason ? ` (${reason})` : ''}.`,
184 `Push branch ${run.branch} (its worktree is ${run.worktree}) and open a pull request from it.`,
185 `Title it after the feature. In the body: what it does (from ${run.specFile}), the checks (${run.checks.map(c => `${c.cmd} ${c.ok ? '✓' : '✗'}`).join(', ') || 'none'}), and ${run.round} review round${run.round === 1 ? '' : 's'}: ${run.reviews.map(r => `${r.role} ${r.state === 'pass' ? 'PASS' : 'CHANGES'}`).join(', ')}.`,
186 ...(minor.length ? ['List these minor findings under "Not fixed":', ...minor] : []),
187 ...(run.skipped?.length ? [`The factory left these new files out of its commits (dependencies, build output or credentials); don't add them: ${run.skipped.join(', ')}.`] : []),
188 ].join('\n')
189}
190
191// ---------- a run ----------
192
193// The worktrees sit inside the repository, in .factory/ (kept out of git): within the session's
194// working directory, so the agents' edits get the same permissions and sandbox as the person's.
195export const worktreeOf = (root: string, slug: string) => `${root.replace(/[\\/]+$/, '')}/.factory/${slug}`
196
197// What the builder may leave untracked that never goes into a commit: installed dependencies,
198// build output, and credentials.
199const NEVER_COMMIT = [/(^|\/)(node_modules|\.venv|venv|__pycache__|dist|build)(\/|$)/, /(^|\/)\.env(\.[^/]*)?$/, /\.(pem|key|p12|pfx)$/i]
200export const neverCommit = (path: string) => NEVER_COMMIT.some(re => re.test(path))
201
202// The untracked paths in `git status --porcelain` output (a folder ends in /).
203export const untrackedOf = (porcelain: string) =>
204 porcelain.split('\n').filter(l => l.startsWith('?? ')).map(l => l.slice(3).replace(/^"(.*)"$/, '$1'))
205
206export function newRun(feature: string, at: number, base: string, root: string, slug = slugOf(feature)): Run {
207 const step = (): { state: StepState; tokens: number } => ({ state: 'wait', tokens: 0 })
208 return {
209 feature: feature.trim(),
210 slug,
211 phase: 'spec',
212 round: 0,
213 checkRound: 0,
214 startedAt: at,
215 base,
216 branch: `factory/${slug}`,
217 worktree: worktreeOf(root, slug),
218 specFile: `docs/specs/${slug}.md`,
219 tasks: [],
220 steps: { spec: { state: 'run', tokens: 0 }, builder: step(), checks: step(), approver: step() },
221 checks: [],
222 reviews: REVIEWERS.map(role => ({ role, state: 'wait' as StepState, findings: [], tokens: 0, round: 0, agentId: null })),
223 agents: {},
224 note: 'writing the spec',
225 blocked: null,
226 gate: 'goal-run',
227 setupDone: false,
228 }
229}
230
231export const isLive = (p: Phase) => p !== 'done' && p !== 'stopped'
232// Waiting for the person, not for an agent or a command.
233export const waitsOnPerson = (p: Phase) => p === 'plan' || p === 'ready' || p === 'needs-human'
234export const tokensOf = (run: Run) => Object.values(run.steps).reduce((n, s) => n + s.tokens, 0) + run.reviews.reduce((n, r) => n + r.tokens, 0)
235export const tok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`)
236
237// The findings the builder must still address: every finding of the reviewers that asked for changes.
238export const openFindings = (reviews: Review[]) => reviews.filter(r => r.state === 'changes').flatMap(r => r.findings)
239hooks/texts.ts 68 lines1// GENERATED from shared/texts.ts by tools/sync-texts.mjs: edit that file, not this one.
2// Wording one mod writes and another reads: followups' Start prompt and done result, goal-run's
3// start prompt, reached-goal toast and result. Edit it here, then run `node tools/sync-texts.mjs`:
4// a mod can import nothing outside its own folder, so each one keeps a copy in hooks/texts.ts.
5
6export type StartItem = { id: string; title: string; isBug?: boolean }
7
8// followups: one line per item it hands Claude when the person presses Start.
9export const startLine = (i: { id: string; title: string; kind?: string; detail?: string; where?: string }) =>
10 `- id ${i.id}: ${i.title}${i.kind === 'bug' ? ' (bug)' : ''}${i.detail ? ` — ${i.detail}` : ''}${i.where ? ` [where: ${i.where}]` : ''}`
11
12export function parseStartPrompt(text: string): StartItem[] {
13 const out: StartItem[] = []
14 for (const line of text.split('\n')) {
15 const m = /^- id (\S+): (.+?)( \(bug\))?(?: — .*?)?(?: \[where: .*\])?$/.exec(line.trim())
16 if (m) out.push({ id: m[1]!, title: m[2]!.trim().slice(0, 120), ...(m[3] ? { isBug: true } : {}) })
17 }
18 return out
19}
20
21// A tool's answer as text, however it arrives: a string, MCP content blocks, or `{ content }`
22// (what a classic PostToolUse hook gets as tool_response).
23export function toolText(response: unknown): string {
24 if (typeof response === 'string') return response
25 const blocks = Array.isArray(response) ? response : (response as { content?: unknown } | null)?.content
26 if (Array.isArray(blocks)) return blocks.map(b => String((b as { text?: unknown })?.text ?? '')).join('\n')
27 return response == null ? '' : JSON.stringify(response)
28}
29
30// followups: what its done tool answers.
31export const doneResult = (title: string, note: string) => `Marked "${title}" done${note ? ` (${note})` : ''}.`
32
33export function parseDoneResult(text: string): { title: string; note: string } | null {
34 const m = /^Marked "(.+)" done(?: \((.*)\))?\.$/.exec(text)
35 return m ? { title: m[1]!, note: m[2] ?? '' } : null
36}
37
38// goal-run: the prompt that hands Claude the goal, the toast when it is reached (on every path), and
39// the update tool's words for it.
40export const goalPrompt = (text: string) => `My goal for this session: ${text}`
41export const parseGoalPrompt = (text: string) => /^My goal for this session: (.+)$/m.exec(text)?.[1]?.trim() ?? null
42export const goalReachedToast = (text: string) => `Goal reached: ${text}`
43export const GOAL_REACHED = 'The goal is reached.'
44// goal-run: the note it adds to followups' done result when that to-do item closed the goal's last task.
45export const goalClosedNote = (text: string) => `That closed the last task of the session goal "${text}". ${GOAL_REACHED}`
46export const parseGoalClosedNote = (text: string) => /^That closed the last task of the session goal "(.+)"\. /.exec(text)?.[1] ?? null
47
48// goal-run ↔ factory: a plan the factory's spec-writer wrote is submitted with source "factory".
49// goal-run then lets the factory's builder do the work, and answers the plan tool in these words;
50// the factory reads them to go on to the build or back to the spec. A plan approved later in the
51// Goal pane is passed on with the command /factory plan-approved.
52export const FACTORY_SOURCE = 'factory' as const
53export const FACTORY_PLAN_APPROVED_ARG = 'plan-approved'
54export const PLAN_APPROVED_FOR_FACTORY = "Approved. The factory's builder does the work: don't start the tasks yourself, and don't propose another plan."
55export const planChangesForFactory = (said: string) => `The person wants changes: ${said}\nThe factory's spec-writer revises the spec; don't propose a plan yourself.`
56export const PLAN_WAITING_FOR_FACTORY = "The person hasn't answered; the plan waits for approval in the Goal pane, and the factory waits with it."
57// The person has a goal of their own: goal-run keeps it, and the factory asks for approval itself.
58export const PLAN_KEPT_FOR_FACTORY = "The person has a session goal of their own, so the factory's plan is not set as the goal. The factory asks the person to approve it in its own band; don't propose a plan or start the tasks."
59
60// Only the factory's own wording counts: a regular plan's "Approved. …" or "The person wants changes
61// to the plan" is the main loop's, not the factory's.
62export function parsePlanOutcome(text: string): { approved: true } | { changes: string } | { kept: true } | null {
63 if (text.startsWith(PLAN_APPROVED_FOR_FACTORY)) return { approved: true }
64 if (text.startsWith(PLAN_KEPT_FOR_FACTORY)) return { kept: true }
65 const m = /^The person wants changes: ([\s\S]*?)\nThe factory's spec-writer revises the spec/.exec(text)
66 return m ? { changes: m[1]!.trim() } : null
67}
68types/index.d.ts 66 lines1// A step's state, as the pane draws it.
2export type StepState = 'wait' | 'run' | 'pass' | 'changes' | 'fail' | 'skip'
3export type Role = 'spec' | 'builder' | 'security' | 'code' | 'approver'
4export type Reviewer = 'security' | 'code'
5export type Severity = 'blocker' | 'major' | 'minor'
6export type Finding = { severity: Severity; where: string; text: string }
7// A task of the spec's breakdown: what goes to goal-run's plan.
8export type SpecTask = { title: string; priority: 'high' | 'medium' | 'low'; dependsOn: number[] }
9export type Check = { cmd: string; ok: boolean; tail: string }
10// agentId: the reviewer's agent this round (null until started), so a late answer from an earlier one is ignored.
11export type Review = { role: Reviewer; state: StepState; findings: Finding[]; tokens: number; round: number; agentId: string | null }
12// Where the run is: each phase waits on one thing (an agent, the checks, the person).
13// 'ready': the approver said APPROVE; the PR waits for the person's Open PR.
14export type Phase = 'spec' | 'plan' | 'build' | 'checks' | 'review' | 'approve' | 'ready' | 'needs-human' | 'done' | 'stopped'
15// Why a run is at needs-human: what stopped it decides what the person can do (Approve only after an
16// ESCALATE; Retry for the rest).
17export type Blocked =
18 | { on: 'escalate' }
19 | { on: 'spec' }
20 | { on: 'builder' }
21 | { on: 'checks' }
22 | { on: 'setup' }
23 | { on: 'start'; role: Role }
24
25export type Run = {
26 feature: string
27 slug: string
28 phase: Phase
29 // Review rounds so far (builder rounds follow them), and builder passes for red checks.
30 round: number
31 checkRound: number
32 startedAt: number
33 // The git branch and worktree the run builds in, and the commit it started from.
34 base: string
35 branch: string
36 worktree: string
37 specFile: string
38 tasks: SpecTask[]
39 steps: Record<'spec' | 'builder' | 'checks' | 'approver', { state: StepState; tokens: number }>
40 checks: Check[]
41 reviews: Review[]
42 // The agents this run spawned, by agentId: the role each one plays.
43 agents: Record<string, Role>
44 // What the person or the approver said last: the reason a run waits on them.
45 note: string
46 blocked: Blocked | null
47 // Who approves the plan: goal-run's dialog, or the factory's own band (no goal-run, or the person
48 // has a goal of their own).
49 gate: 'goal-run' | 'factory'
50 // The dependencies were installed in the worktree (the setup command ran).
51 setupDone: boolean
52 // New files the factory left out of its commits: dependencies, build output, credentials.
53 skipped?: string[]
54}
55
56declare module 'claude-code' {
57 interface PluginState {
58 factory: {
59 run: Run | null
60 frame: number
61 theme: 'dark' | 'light'
62 stoppedAgents: string[]
63 }
64 }
65}
66