SLOPSHOPPER

factory

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…

newpanebandguardcommandtoast
★ 2v0.2.1MITupdated 2026-10-26Amitro123/claudemod/plugins/factory
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · factory
│ ┃ Factory ✕ › fix the failing auth test and add an audit log call │ ┃ 🏭 Factory │ ┃ ⏺ Read(src/auth.ts) │ ┃ No run yet. /factory <feature> writes a ⎿ Read 6 lines │ ┃ spec, has you approve its plan, builds it on ⏺ Update(src/auth.ts) │ ┃ a branch, runs the checks and the reviews, ⎿ Added 2 lines, removed 1 line │ ┃ and asks the approver. You open the PR. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /factory │ ⎿ factory: No factory run. /factory <feature> starts one. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Factory
🏭 Factory 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.
README

claudemod: mods for Claude Code

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.

ModWhat it does
clawd-hudA pixel spark above the prompt, usage batteries, a prompt-cache timer and a context meter
task-hudA to-do progress bar, and a pane with a pixel creature for each sub-agent
followupsClaude flags what it notices, asks whether to track it, and keeps a to-do you can export
rtl-toggleFixes the reading order of lines that mix English and Hebrew
goal-runAsks for a goal when a session starts, has Claude plan it, and runs a pixel Claude to 100% as the tasks get done
brain-hudAgent 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.

clawd-hud: a pixel spark

clawd-hud demo

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.

What it shows

Above the prompt (the AbovePrompt band)

  • The spark on the left. What it does follows what Claude is doing:
MoodWhenScenes
idlewaiting for youdrifting with a firefly, juggling, watering a sprout
workinga turn is runningat the terminal, stacking blocks, pondering, sending out sub-agents
working > 90sa hard taska smoke break on a crate joins the rotation
loopthe same tool with the same input 3× in one turnriding a coaster loop it can't get out of
donethe turn answeredfireworks and a hop (a lightbulb if the task was hard)
relax4 min after donestargazing on a hill, a pool float, cocoa by a snowy window
errorAPI error or refusalgoes gray under a little storm cloud, then idle
  • A battery per usage window, showing what is left: green above 30%, amber above 10%, red below. Each has a "resets in …" countdown.
Personal (Pro / Max)Enterprise
Batteries5h window, 7d weekly7d weekly, mo monthly spend cap ("over by …" past 100%)
Costnot shown$ spent this session
"resets in"the API's reset timethe API's reset time; for the spend cap without one, the 1st of next month
Cache timer (auto)1 hour5 minutes

Beside the model picker (the SessionMode slot)

  • Cache bolt: counts down the prompt-cache TTL from the last response. Green while warm, amber in the last minute, gray once cold.
  • Context capsule: how full the context window is, e.g. 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.

Install

/plugin install clawd-hud@claudemod

Options

Set with claude plugin configure clawd-hud@claudemod, or in the plugin settings:

OptionValuesDefaultWhat it does
planauto, personal, enterpriseautoWhich limits to show. auto switches to enterprise as soon as the account reports a monthly spend limit (spend_limit).
cacheTtlauto, 5m, 1hautoThe cache timer's length. auto is 1h on personal plans and 5m on enterprise.

How it works

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

Changelog

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.

task-hud: to-dos and sub-agents

task-hud: the to-do bar above the prompt and the Sub-agents pane

<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
  • A progress bar above the prompt once Claude writes a to-do list (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.
  • A Sub-agents pane that opens the first time Claude sends out a sub-agent, and reopens with /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.

followups: don't lose what Claude notices

followups: Claude asks about a bug it noticed in the question dialog; the To-do pane shows open, in-progress and done items with Start, Done and Export to

<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
  • Claude flags what it notices. The mod gives Claude a 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.
  • A second look after each answer. Claude doesn't always remember to flag. After each of its answers (in the main conversation, 200 characters or longer), a small model (haiku) reads the answer, together with the reports any sub-agents handed back since the last answer (where the work, and its loose ends, often is), for problems mentioned but left open: something flaky, unverified, skipped or put off, or an open question or decision that wasn't settled. If it can't run (haiku not allowed on the account, an API error), one line in the transcript says so instead of it failing quietly. Up to 3 of those wait in the band above the prompt. Anything already tracked, or that you said isn't needed (remembered per project, across sessions), isn't offered again. It costs one small call per answer and never delays the end of a turn. Turn it off with the autoDetect option (claude plugin configure followups@claudemod).
  • A sub-agent's open questions, read directly. When a sub-agent's report has a section for what it left open (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.
  • A check when the turn ends. If Claude's final reply says something is unverified, untested or left open (a short English and Hebrew phrase list: 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.
  • Claude stops and asks you about each one in the question dialog: Claude noticed: "Fix the flaky upload test" (Fails 1 in 5 runs · tests/upload.test.ts:42). Add it to your follow-ups? You answer Add to to-do or Not needed, or type your own wording, which is kept as the title. Claude gets your answer back and carries on. If you close the dialog without answering, the item waits in a band above the prompt with Add to to-do (a), Skip (s) and Add all, so it isn't lost.
  • A To-do pane lists what you kept, colored by status: orange ○ 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.
  • ▶ Start (per open item, or Start all open) marks it in progress and submits a message to Claude with the item's id, title, detail and location, asking it to do the work. When it finishes, Claude calls the 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.
  • Export to:
ButtonWrites toWhy
MemoryCLAUDE.mdClaude reads it at the start of every session
Docsdocs/FOLLOW-UPS.mda page of its own
READMEREADME.md, under Known issues & follow-upsvisible 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.

rtl-toggle: Hebrew and English on one line

Toggle offToggle on
Toggle off: embedded Hebrew renders out of orderToggle on: embedded Hebrew renders in correct order

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
  • A button above the prompt (עברית: פעיל / עברית: כבוי) 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.
  • What it changes: a line that starts with an English letter but has at least as many RTL words as LTR words gets one invisible leading RLM (U+200F). That gives the line a right-to-left base direction and right alignment, like a line that starts in Hebrew. Nothing else in the text changes.
  • What it leaves alone: code blocks (fenced and indented), lines that already start in Hebrew, lines with no RTL text, and inline code, links and URLs, which aren't counted. Table rows are fixed cell by cell, except in the terminal.
  • Limits: the mod API has no 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.

goal-run: a goal for the session

goal-run: the question a session opens with, and the Goal pane at 40%, with each task's priority and tokens

<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

How a goal runs

goal-run in four steps: the start-of-session question; the plan by priority in the approval dialog, with the pane waiting at 0%; the runner at 40% with each task's tokens and a to-do item offered to the goal; the runner cheering at 100%

  1. The question. When a session starts, a band above the prompt asks Is there a goal you want to work on this session?, with a field to type it. No goal this time dismisses it. If an earlier session in this project left a goal unfinished, the band offers Continue, New goal or Not now instead.
  2. The plan, by priority. Your goal goes to Claude, which breaks it into 3 to 8 tasks with the 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).
  3. Your approval, and the pace. The plan is put to you in a dialog, each task with its priority. Pick Approve, run through, Approve, ask between tasks, or Change it (Claude revises and asks again). Nothing starts before you approve. If you close the dialog, the plan waits in the pane with both approve buttons.
  4. Run through: Claude goes from task to task on its own. If it tries to stop with tasks left, it's sent on once; it can still stop when it's blocked or needs your decision.
  5. Ask between tasks: after each task, a dialog asks "Add tests" is done (40%). Go on to "Document the endpoint"?, with Continue or Stop here.
  6. A button in the pane switches between the two while you work.
  7. The run. The Goal pane shows the goal, a track from 0 to 100%, and a pixel Claude in blue sneakers. It stands still between tasks, runs while one is in progress, and cheers at the flag at 100%. Claude marks each task in progress and done with the 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.
  8. What each task cost. Every model request, sub-agents' included, is counted against the task in progress, and the pane shows each task's tokens. Planning and work between tasks have a line of their own. The count is the new tokens of each request (output, input written to the cache, uncached input). Cache reads are left out: they are the context earlier requests already counted, and counting them again on every step would grow the number far past what the task added. It is not a price.

Steering it while it runs

goal-run while it runs: the plan by priority with the pace choice; a task added mid-goal, placed by priority; the question between tasks; running through, sent on to the next task

  • Reorder. ↑ ↓ in the pane move tasks that haven't started. A task moved up takes the tasks it needs along with it, and one moved down takes the tasks that need it, so dependencies stay first. Claude works the list as you leave it.
  • Add a task. Ask Claude for more work during the goal, and it uses the 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.
  • Re-plan. A new plan in the middle of a goal keeps the done tasks and their tokens.
  • Work from followups' to-do list. Pressing ▶ Start on a to-do item while a goal is open is a side job. Claude gets no goal reminder with it, so the two don't compete, and its tokens show on a line of their own, Outside the goal. The band above the prompt asks whether to Add to goal, showing what that does to the plan (grows to 6 tasks: 40% → 33%), or Keep it separate. Added, the item becomes a task in progress, carrying the tokens it used so far, and a bug comes in as high priority. It turns done when followups marks the item done. The side job ends when you send a prompt of your own.

Commands and options

  • /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.
  • The goal is saved per project on the machine running Claude Code, like followups' list, so it carries over between local sessions in the same folder. If two sessions in the folder each set a goal, the one set later is the one saved. The other session is told once, with a toast, that its progress isn't saved.
  • askOnStart (on / off) turns the start-of-session question off.
  • A plan from factory. When the factory's spec is put to you as the goal's plan, the dialog is titled Factory plan and offers Approve, start building or Change it. Approved, the factory's builder does the tasks and marks them, and the main Claude is told not to: there's no goal reminder on your prompts, no nudge at a stop and no question between tasks. Changes go back to the factory's spec-writer. Approving later in the pane runs /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.

brain-hud: the agent's memory, made visible

brain-hud: three of the scenes above the prompt, an offer from followups, and the Brain pane with the memory drawn as a knowledge graph

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

The skill, built in

  • The command is /brain-hud:mistake-brain w
Source 5 files
hooks/register.tsx 595 lines
1import { 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 &lt;feature&gt; 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}
595
hooks/art.ts 124 lines
1// 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, '&amp;').replace(/</g, '&lt;')
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}
124
hooks/flow.ts 239 lines
1// 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)
239
hooks/texts.ts 68 lines
1// 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}
68
types/index.d.ts 66 lines
1// 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