The factory workflow: brainstorm a design, write a plan, then run each task through build, routed review and approval, with a progress band and pane.

The plugin is called clay; the workflow it contains is the factory: brainstorm, plan, run.
Take an idea to reviewed, merged code. You design and plan with the model in a conversation, then run the plan: each task is built, reviewed by the lenses its change needs, and approved or handed back to you.
Every command is a skill named factory-<verb>, so on Claude Code it is typed /factory-run and listed together in the autocomplete under /factory-. The namespaced form /clay:factory-run also works. On Codex the same skill is $factory-run and on Cursor /factory-run; see Hosts. The tables below write the Claude Code and Cursor form. The exact spelling per host is in docs/hosts.md.
| Command | Started by | What it does |
|---|---|---|
/factory-brainstorm | you, or the model when you describe new work | idea to approved design (spec for big work, checked by an independent spec reviewer; short chat design for small work) |
/factory-plan | you, or the model after an approved design | design to a plan of tasks and steps |
/factory-run [plan] | you only | runs the plan's open tasks, asking after each task what to do next; takes the only plan with open steps when you give none |
/factory-task [tier=<name>] "<text>" | you only | one ad hoc task from a working plan the command writes itself under factory/jobs/; /factory-task tier=careful <text> sets the tier |
/factory-status | you only | jobs, stages, verdicts, what waits for you |
/factory-approve [job] | you only | lands a job that was waiting for you |
/factory-rework [job] <note> | you only | sends a waiting job back to its builder |
/factory-answer [job] [text] | you only | answers the questions a job is waiting on, now or later |
/factory-backlog [options] | you only | open plans, specs without a plan and, on request, code markers; read-only |
/factory-unlock [plan] | you only | releases the run lock of a plan, for a crashed run or to edit it |
[job] may be left out when only one job waits, and may be the id, 002, the task number or a piece of the id.
/factory-brainstorm: questions, approaches, design. You approve it./factory-plan: a plan with tasks, tiers and acceptance criteria. You review it./factory-run: for each task a builder works at the task's tier, up to four reviewers check the real diff (code always; ui, ux and security only when the changed paths call for them, see routing.md); one interface reviewer judges ui, ux or both and writes a verdict per lens, then an approver lands it or escalates it. Reviewer findings are checked, not applied blindly; a job has at most three rounds. A light or medium task that only the code reviewer would read takes a short path: the approver writes the code review itself. When a second round still asks for changes, the third is built by a fresh builder one tier up. After a rework round only the reviewers that asked for changes, or whose paths changed, run again; a lens that passed keeps its verdict. Reviewers and the approver read one ready-made diff.patch per round, and the builder's checks are recorded by state.py verify once per state of the code, so the suite is not run again by every agent. Each task's steps are ticked in the commit that lands it. A builder keeps the documentation the task names under Docs true in the same change and records under ## Deviations what it did differently from the plan; the code reviewer blocks a stale document. When every task has landed a final reviewer reads the plan's whole branch for what only shows across tasks; its findings get one fix task and a recheck of that fix, and what is still open is reported to you. When a plan is finished its header says status: completed and one ## Delivery status section names the documentation that describes the work now (Documented in), lists the divergences and keeps the run's history in an execution record; the plan is never deleted, and the local job folders are then removed. /factory-run <plan> lean goes further for simple tasks (light or medium, files that only the code reviewer would read, declared Accept and Verify): the session builds them and their own checks gate them, with no reviewer or approver per task, and the final review of the branch reads them all; a task whose diff or checks say otherwise goes back to the full loop. Without the word the run proposes it when some tasks qualify, full turns it off, and /factory-task lean verify="<command>" <text> does the same for one task.Pace. The run is guided: after each task it asks whether to go on, stop, or run the rest without asking. When a task needs a decision (an escalation) it puts the question to you in the same prompt, so you approve, rework with a note, skip or stop without typing another command. After "run the rest without asking" a task that needs you stops the run and lists the commands to continue.
Models. The task's tier picks the builder's model: light and medium the faster one, careful and heavy the stronger one (see tiers.md). Some hosts cannot pass an effort, so the model is what the tier really changes. Model names differ per host: Model mapping says how to set them. light runs on haiku as a trial; the execution record has a Findings column (review issues per round and lens) to show whether to go back to sonnet.
Isolation. /factory-run recommends a development branch per plan with one worktree per job, so your checkout is untouched and different plans can run at once in different sessions. You can instead confirm working on the current branch; jobs then run one at a time and are committed after approval.
One run per plan. A run takes a lock on its plan: a second run on the same plan from another chat is refused, and the plan file and the spec it names are read-only while it lasts (the plugin also refuses edits with an explanation). The steps of a task are ticked as it lands. /factory-unlock releases a lock by hand.
The plugin includes a mod that shows progress inside the interface, with no setup: a band above the prompt (tasks done over total, running phases, the jobs that wait for you), a toast when a job needs you, and a pane for whoever follows the work: one sentence of the situation, and per plan a table with a row per task in plan order (status, a progress bar colored by tier, who is in charge and on which model, elapsed time, tokens and agent time, reviewer verdicts). Rows that need you have buttons that write the ready command into the prompt, and the plan shows its lock. /factory-progress shows or hides the band and /factory-agents opens the pane. They use a hyphen because Claude Code refuses a colon in a mod command name. The mod only reads the files in factory/jobs/, so it costs no tokens. Codex and Cursor have no band or pane; the static dashboard below works on every host.
State lives in factory/jobs/ (ignored by git). A static dashboard shows it, with selectable text, copy buttons and the usage of each agent: /factory-run copies the page to factory/jobs/dashboard.html; serve that folder with cd factory/jobs && python3 -m http.server and open /dashboard.html.
Plans live in docs/superpowers/plans/YYYY-MM-DD-<feature>.md and specs in docs/superpowers/specs/, with ### Task N: headings and - [ ] steps. This matches the superpowers conventions on purpose, for compatibility with projects already built with that tool: their plans run here without migration. Per-task fields (Repo, Tier, Depends, Files, Docs, Constraints, Accept, Verify) are optional; missing ones get defaults and /factory-run tells you which. Docs names the documentation a task keeps true, or says none: <reason> for a change with no observable behaviour; a light task without it declares none. A cancelled step - [-] carries its reason on the same line. An INDEX.md next to the plans or specs lists each file with what it covers and no status, which the plans hold. You do not have to design with this plugin: brainstorm and plan with superpowers or by hand, and use /factory-run only to execute.
scripts/plan_status.py works on its own, with no agent host: copy plan_status.py and factory_plan.py together into one folder (the first imports the second) and run it with Python 3; only the standard library is used.
python3 plan_status.py # open work under the current folder
python3 plan_status.py --root ~/work # a whole workspace: every folder holding plans or specs is a repository
python3 plan_status.py --all --format html --out backlog.html
It reads docs/superpowers/plans/ and docs/superpowers/specs/ (also .cursor/plans/, .cursor/specs/ and Modules/*/docs/plans/), subfolders included, in the root and in each folder one level below it; /factory-run and the pane look in the same places. It never edits anything, and --help lists every option. Each plan and spec shows its last change (upd), the date of the last commit that touched it; a file with uncommitted changes shows its modification date, and one git does not track shows it marked ~. Nobody writes that date into the plan: it is read from git, so it cannot drift. --sort updated lists the most recently changed plans first. A finished plan that does not name existing documentation on a Documented in: line is reported.
A host that runs plugins: Claude Code, Codex or Cursor (see Hosts), plus git and python3 (standard library only) for the plan parser, the reviewer routing and the run bookkeeping (scripts/state.py). The target repo's own AGENTS.md or CLAUDE.md must say how to set up a checkout and run its tests, and should say where its documentation lives; /factory-plan asks once when it does not.
The marketplace is clay-kit and the plugin clay. The same files serve all three hosts, with one manifest each; the host differences the run has to handle are written in reference/hosts.md and one part per host (reference/hosts-<host>.md), and the facts behind them, with their sources, in docs/hosts.md.
| Claude Code | Codex | Cursor | |
|---|---|---|---|
| Install | /plugin marketplace add swolley/clay-kit, then /plugin install clay@clay-kit | codex plugin marketplace add swolley/clay-kit, then codex plugin add clay@clay-kit | CLI /plugin marketplace add https://github.com/swolley/clay-kit, or a team admin imports the repo in the Dashboard (Plugins & MCPs, Import from Repo), and users install from Customize. As documented by Cursor, not yet tried by us |
| Manifest read | .claude-plugin/ | .codex-plugin/, and .claude-plugin/ for the marketplace | .cursor-plugin/ |
| Invoke | /factory-run (or /clay:factory-run) | $factory-run, or the /skills picker (clay:factory-run) | /factory-run |
| Agents | the plugin's clay:<role> agents | generic subagents told to read the agent file | the plugin's agents, generic ones if the host does not list them |
| Model per call | model, and effort only if the Agent tool shows it | model and reasoning_effort | reported to take only fast or inherit, no effort |
| Questions | AskUserQuestion, up to 4 options | request_user_input, 2 to 3 options, in Plan mode, else plain text | the ask-question tool (reported to depend on the model, and to be skipped in headless mode, cursor-agent -p) |
| Resume the builder | SendMessage | send_input | a fresh builder |
| Progress band and pane | yes | no | no |
factory/jobs/dashboard.html | yes | yes | yes |
Where the host does not substitute the plugin root, a skill finds the plugin's shared files from the folder of its own SKILL.md (each skill states its plugin root on its first two lines of the body).
factory/models.json)Model names differ per host, and the plugin fixes no OpenAI or Cursor model id. factory/models.json in the target repository, beside factory/routing.md, is optional and maps tiers and agents to the host's own names. It has one entry per host, with tiers and agents inside; every entry may give model, effort or both.
{
"claude": {
"tiers": { "light": { "model": "sonnet", "effort": "low" } },
"agents": {
"approver": { "model": "opus", "effort": "max" },
"approver-risky": { "model": "opus", "effort": "max" }
}
},
"codex": {
"tiers": {
"light": { "model": "<your model id>", "effort": "low" },
"heavy": { "model": "<your model id>", "effort": "xhigh" }
},
"agents": {
"reviewer-code": { "model": "<your model id>", "effort": "medium" },
"approver": { "model": "<your model id>" },
"approver-risky": { "model": "<your model id>" }
}
},
"cursor": {
"tiers": { "light": { "model": "fast" }, "heavy": { "model": "inherit" } }
}
}
(Replace each <your model id> with a model id your Codex accepts; the file is read as JSON, so no comments.)
| Host | model | effort |
|---|---|---|
claude | haiku, sonnet, opus, fable, inherit, or an id starting with claude- | low, medium, high, xhigh, max |
codex | any non-empty text, the id Codex knows | any non-empty text, as Codex accepts for that model |
cursor | fast or inherit | not allowed: Cursor has no per-call effort |
Tiers are light, medium, careful, heavy, fable. Agents are reviewer-code, reviewer-interface, reviewer-security, reviewer-spec, approver, approver-risky (the approver of a risky job), reviewer-final (the review of a finished plan's branch) and reviewer-final-risky (the same, when any job of the run was risky). The names reviewer-ux, reviewer-ui and reviewer-uiux of earlier versions are refused: use reviewer-interface. A -risky entry does not inherit from its agent: to change the agent of risky work, give the -risky name its own entry.
Resolution order, for each of model and effort separately:
factory/models.json for the host.tiers.md is kept; on Cursor nothing is passed.On a host other than Claude Code, tier fable with no entry of its own is resolved as heavy. To check what a run will pass:
python3 <plugin root>/scripts/state.py model --host <host> --tier <tier>
python3 <plugin root>/scripts/state.py model --host <host> --agent <agent> [--risky]
Add --root <repo> when you do not run it from the repository. It prints the model, the effort and the source (models.json, tiers.md or session); null means that key is not passed. A mistake in the file (bad JSON, an unknown host, tier or agent, a value the host cannot take) stops the run with a message that names the key. On Codex and Cursor source is session when the model is the session's, even when the Codex effort comes from tiers.md.
With both the claude and the codex CLI on PATH, a run on either host asks at the start "work with <the other one>: yes (recommended) / no". On yes, the session stays the orchestrator and some agents run through the other vendor's CLI (scripts/peer.py, detached, so a long build never hits a command timeout): the builder stays on your host, and each job's reviewers and approver, and the final review, come from the vendor that did not write the code, or the record says review not crossed. A vendor that hits its limit hands its agents to the other instead of stopping the run, and the execution record keeps each agent's vendor. Cursor sessions have no peer. The optional cross key of factory/models.json sets which builders go to the peer and when a vendor counts as over its limit:
{ "cross": { "builder_tiers": ["light"], "threshold": 80 } }
builder_tiers (default []) lists the tiers whose builders go to the peer; threshold (default 80, 1 to 100) is the Codex used_percent at or above which new agents go to the other vendor. A Codex peer runs in its workspace-write sandbox: it writes only the job's worktree, its job folder and /tmp, opens no network socket (localhost included) and cannot write ~/.cache or other caches in your home. So a Codex reviewer or approver relies on the checks on record (state.py verify --reuse) and says in its file which one it could not run again, a Codex interface reviewer works from the code, and a Codex builder in isolated mode leaves its commit to the orchestrator. On Codex, starting a Claude peer (claude -p needs the network and writes ~/.claude) runs outside the session's sandbox, with your approval.
factory/jobs/dashboard.html works everywhere.request_user_input works only in Plan mode by default and is refused for subagents. Outside it the run asks in plain text: numbered options in the reply, then it waits for your next message. A list longer than 3 options also goes to plain text.fast or inherit (a forum report), and there is no effort. inherit is reported ignored in some versions of Cursor. The model of your chat applies when nothing is passed, Auto included./ menu. Type the command by name.factory/models.json to choose models per tier; without one Codex uses the session's model with the effort of tiers.md, and Cursor the model of your chat.tools: frontmatter of the agent files, so reviewers and the approver keep their no-code-change promise by instruction only.build.md, the rounds and the branch log.hooks/ is meant to stay unloaded on Codex and Cursor: their manifests declare empty hooks.The Codex and Cursor sections of tests/e2e/CHECKLIST.md need the real host and cannot run in CI. Record here which were run and when.
| Host | Host version | Date | Sections run | Result |
|---|---|---|---|---|
| Codex | ||||
| Cursor |
Derived from obra/superpowers (MIT) and JohnnyVizz/claude-kit (MIT); see NOTICE.md. The idea of a spec, build, review and approve loop was inspired by zazencodes software-factory-claude-code, which publishes no license; none of its content is used here.
hooks/register.tsx 373 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import {
5 STAGE_STEPS,
6 activePlans,
7 formatDuration,
8 formatTokens,
9 isStale,
10 jobIdFromPrompt,
11 jobElapsed,
12 labeledBar,
13 lockNote,
14 phaseLabel,
15 planElapsed,
16 protectedBy,
17 reviewerLabel,
18 roleOf,
19 rowStatus,
20 scan,
21 stageStep,
22 summarize,
23 situationText,
24 backlogText,
25 scanBacklog,
26 taskRows,
27 tierColor,
28 verdictMark,
29 waitingIds,
30 waitingLabel,
31 workingNow,
32} from './model'
33import type { Backlog, Fs, Job, Lock, PlanView, Summary, TaskRow } from './model'
34import { pillBar, slimBar } from './svg'
35
36const PANE = 'factory-agents'
37const POLL_MS = 3000
38
39const view = atom({ plugin: 'clay', key: 'view' } as const, { plans: [], waiting: 0, running: 0 } as Summary)
40const backlog = atom({ plugin: 'clay', key: 'backlog' } as const, { openPlans: 0, unplannedSpecs: 0 } as Backlog)
41const isHidden = atom({ plugin: 'clay', key: 'isHidden' } as const, false)
42// The run locks found on disk: while one is fresh, the plan and its spec must not be edited.
43const locks = atom({ plugin: 'clay', key: 'locks' } as const, [] as Lock[])
44const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
45
46const isOnlyMine = atom({ plugin: 'clay', key: 'isOnlyMine' } as const, false)
47// The model each agent of a job really started on, as the engine reported it at spawn, by job id and role.
48const observed = atom({ plugin: 'clay', key: 'observed' } as const, {} as Record<string, Record<string, { model: string }>>)
49
50const ledColor = (verdict: string | undefined) => (verdict === 'PASS' ? '#3fb97c' : verdict === 'CHANGES' ? '#e5675c' : '#7d879a')
51
52// Plans that need the user first, then the ones running, then the rest.
53const byNeed = (a: PlanView, b: PlanView) => b.waiting.length - a.waiting.length || b.running.length - a.running.length
54
55const TRACK = '#343a48'
56const OK = '#3fb97c'
57const OK_FILL = '#2a8f5f'
58const WAIT = '#f2c230'
59
60const INK = '#0c0f15'
61const SOFT = '#d8dde8'
62
63// A solid bar with its label written inside it: the part under the fill and the part past it are two
64// text runs with a background color each, so it draws as one strip on every surface.
65// `Box` and `Text` are the surface's own elements, resolved by the caller.
66// eslint-disable-next-line @typescript-eslint/no-explicit-any
67const labeled = (Box: any, Text: any, label: string, done: number, total: number, width: number, color: string) => {
68 const parts = labeledBar(label, done, total, width)
69 return (
70 <Box flexShrink={0}>
71 {parts.filled.length > 0 && <Text bold color={INK} backgroundColor={color}>{parts.filled}</Text>}
72 {parts.empty.length > 0 && <Text color={SOFT} backgroundColor={TRACK}>{parts.empty}</Text>}
73 </Box>
74 )
75}
76
77const stateIcon = (job: Job) => (job.stage === 'merged' || job.stage === 'committed' ? '✓' : '●')
78
79// The color of a job's status dot says its state and nothing else; the tier has its own color.
80const stateColor = (job: Job) =>
81 job.stage === 'merged' || job.stage === 'committed' ? OK : job.stage === 'needs-human' || job.stage === 'waiting-input' ? WAIT : '#c9d1e0'
82
83
84// A progress bar with its label inside. On surfaces that draw vector art it is a rounded SVG pill; elsewhere the
85// bar is built from colored text cells (`cols` wide). `elements` is what `$.ui.resolve` returned.
86// eslint-disable-next-line @typescript-eslint/no-explicit-any
87const bar = (elements: any, label: string, done: number, total: number, px: number, fill: string, glyph: string, cols: number) => {
88 const { Box, Text } = elements
89 const Svg = 'Svg' in elements ? elements.Svg : undefined
90 if (!Svg) return labeled(Box, Text, label, done, total, cols, glyph)
91 return (
92 <Box alignSelf="center" flexShrink={0}>
93 <Svg source={pillBar({ label, done, total, width: px, fill, track: TRACK })} alt={label} width={px} height={20} />
94 </Box>
95 )
96}
97
98// A thin bar with no text; what it shows is written beside it. Vector art where the surface has it, text cells otherwise.
99// eslint-disable-next-line @typescript-eslint/no-explicit-any
100const slim = (elements: any, done: number, total: number, px: number, color: string, cols: number) => {
101 const { Box, Text } = elements
102 const Svg = 'Svg' in elements ? elements.Svg : undefined
103 if (!Svg) return labeled(Box, Text, '', done, total, cols, color)
104 return (
105 <Box alignSelf="center" flexShrink={0}>
106 <Svg source={slimBar({ done, total, width: px, fill: color, track: TRACK })} alt={`${done} of ${total}`} width={px} height={4} />
107 </Box>
108 )
109}
110
111// Centers one item on the cross axis of its row, so text sits at the middle of a bar and not on its baseline.
112// eslint-disable-next-line @typescript-eslint/no-explicit-any
113const mid = (Box: any, node: unknown, shrink = 0) => (
114 <Box alignSelf="center" flexShrink={shrink}>
115 {node}
116 </Box>
117)
118
119// A reviewer as a colored LED with its name beside it. The mark is a cue besides color.
120// eslint-disable-next-line @typescript-eslint/no-explicit-any
121const led = (Box: any, Text: any, name: string, verdict: string | undefined) => (
122 <Box alignSelf="center" flexShrink={0}>
123 <Text color={ledColor(verdict)}>●</Text>
124 <Text>{` ${reviewerLabel(name)}${verdictMark(verdict)}`}</Text>
125 </Box>
126)
127
128
129
130export const register: Register = on => {
131 let timer: { cancel: () => void } | undefined
132 let known: Set<string> | null = null
133
134 on('session.start', async ($, e, next) => {
135 await $.command.register({ name: 'factory-progress', description: 'Show or hide the factory progress band' })
136 await $.command.register({ name: 'factory-agents', description: 'Open or close the pane with the factory jobs' })
137
138 const fs: Fs = {
139 list: path => $.fs.list(path),
140 read: async path => {
141 const text = await $.fs.read(path)
142 return typeof text === 'string' ? text : ''
143 },
144 exists: path => $.fs.exists(path),
145 }
146
147 const refresh = async () => {
148 try {
149 const found = await scan(fs)
150 const summary = summarize(found.runs, found.jobs, found.locks)
151 await update($, view, () => summary)
152 await update($, locks, () => found.locks)
153 const counts = await scanBacklog(fs)
154 await update($, backlog, () => counts)
155
156 const now = waitingIds(found.jobs)
157 const fresh = [...now].filter(id => !known || !known.has(id))
158 if (fresh.length > 0) {
159 $.ui.toast(known ? `${fresh.join(', ')} needs you` : `${now.size} factory job(s) waiting for you`)
160 }
161 known = now
162 } catch {
163 // A scan that fails shows the previous state until the next tick.
164 }
165 }
166
167 timer?.cancel()
168 await refresh()
169 timer = $.clock.every(POLL_MS, () => void refresh())
170
171 return next(e)
172 })
173
174 on('agent.spawn', async ($, e, next) => {
175 const started = await next(e)
176 const jobId = jobIdFromPrompt(e.prompt)
177 if (jobId && 'model' in started && started.model) {
178 const model = started.model
179 const role = roleOf(e.subagentType)
180 await update($, observed, all => ({ ...all, [jobId]: { ...(all[jobId] ?? {}), [role]: { model } } }))
181 }
182 return started
183 })
184
185 // Refuses an edit of a plan or spec that a run holds. The read-only permissions the lock sets are the hard guard; this hook
186 // says why before the attempt.
187 on('tool.call', async ($, e, next) => {
188 if (!EDIT_TOOLS.has(e.tool)) return next(e)
189 const raw = e as unknown as Record<string, unknown>
190 const path = raw.file_path ?? raw.notebook_path
191 if (typeof path !== 'string') return next(e)
192 const hit = protectedBy(await read($, locks), path, await $.clock.now())
193 if (!hit) return next(e)
194 return {
195 deny: `This file belongs to the plan "${hit.plan}", which a run is executing, so it is locked. Editing it now could change tasks that are already running. Stop the run first, or release the lock with /factory-unlock ${hit.plan}.`,
196 }
197 }).catch(($, e, next) => next(e)) // if the check itself fails, do not block edits: the read-only permissions still guard the files
198
199 on('command.run', { command: 'factory-progress' }, async $ => {
200 const hidden = await read($, isHidden)
201 await update($, isHidden, () => !hidden)
202 return { text: hidden ? 'Factory progress band shown.' : 'Factory progress band hidden.' }
203 })
204
205 on('command.run', { command: 'factory-agents' }, async $ => {
206 await $.ui.open({ id: PANE, title: 'Factory jobs' })
207 return { text: 'Factory jobs pane opened.' }
208 })
209
210 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
211 const summary = await read($, view)
212 const quiet = e.props.hasSurvey || (await read($, isHidden)) || (summary.running === 0 && summary.waiting === 0)
213 if (quiet) return next(e)
214
215 const elements = $.ui.resolve(e)
216 const { Box, Button, Text } = elements
217 const active = activePlans(summary)
218 const lead = active[0]
219 if (!lead) return next(e)
220 const more = active.length > 1 ? ` +${active.length - 1}` : ''
221 const phases = [...new Set(lead.running.map(phaseLabel))].join(', ')
222 const label = `Tasks ${lead.done}/${lead.total}${phases ? ` · ${phases}` : ''}`
223 const dot = summary.waiting > 0 ? WAIT : OK
224
225 // One row, never wrapped. The time stays in the pane.
226 return (
227 <Box flexWrap="nowrap" gap={1} alignItems="center">
228 {mid(Box, <Text bold color={dot}>●</Text>)}
229 {mid(Box, <Text bold>{lead.plan.slice(0, 20)}{more}</Text>)}
230 {bar(elements, label, lead.done, lead.total, 340, OK_FILL, OK, 46)}
231 {summary.waiting > 0 && mid(Box, <Text bold color={WAIT}>waiting: {waitingLabel(summary, 1)}</Text>)}
232 {mid(Box, <Text dimColor>×{summary.running}</Text>)}
233 <Button key="hide" label="×" onPress={() => update($, isHidden, () => true)} />
234 </Box>
235 )
236 })
237
238 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
239 const elements = $.ui.resolve(e)
240 const { Box, Button, Text } = elements
241 const summary = await read($, view)
242 const backlogLine = backlogText(await read($, backlog))
243 if (summary.plans.length === 0) {
244 return (
245 <Box flexDirection="column">
246 <Text dimColor>No factory jobs found here.</Text>
247 {backlogLine && <Text dimColor>{backlogLine}</Text>}
248 </Box>
249 )
250 }
251 const now = await $.clock.now()
252 const seen = await read($, observed)
253 const onlyMine = await read($, isOnlyMine)
254
255 // A button that writes a ready command into the prompt, so ids and paths never have to be copied by hand.
256 const act = (label: string, text: string) => (
257 <Button key={`${label}-${text}`} label={label} onPress={() => $.prompt.fill({ text })} />
258 )
259
260 // The width the pane really has, in cells; the title takes what the fixed columns leave.
261 const columns = e.viewport?.columns ?? 90
262 const W = { bar: 11, status: 24, time: 8 }
263 const titleW = Math.max(18, columns - 8 - W.bar - W.status - W.time - 3)
264 // eslint-disable-next-line @typescript-eslint/no-explicit-any
265 const cell = (width: number, node: any) => (
266 <Box width={width} flexShrink={0} alignSelf="center">
267 {node}
268 </Box>
269 )
270
271 const header = (
272 <Box flexDirection="column">
273 <Box gap={1} alignItems="center">
274 {cell(titleW, <Text dimColor>Task</Text>)}
275 {cell(W.bar, <Text dimColor>Progress</Text>)}
276 {cell(W.status, <Text dimColor>Status</Text>)}
277 {cell(W.time, <Text dimColor>Time</Text>)}
278 </Box>
279 <Box paddingLeft={2}>
280 <Text dimColor>level · in charge · reviews</Text>
281 </Box>
282 </Box>
283 )
284
285 // A task is two lines: what and how far, then the level, who has it and the reviews.
286 const row = (r: TaskRow) => {
287 const st = rowStatus(r)
288 const job = r.job
289 const quiet = st.kind === 'planned' || st.kind === 'landed'
290 const dot = st.kind === 'landed' ? '✓' : st.kind === 'planned' ? '○' : '●'
291 const color = job ? stateColor(job) : '#7d879a'
292 const spent = job ? jobElapsed(job, now) : null
293 const who = job ? workingNow(job, seen[job.id]) : { text: '', mismatch: false }
294 const tier = job ? job.tier : r.planned?.tier ?? ''
295 const owner = st.kind === 'waiting' && !who.text ? 'you' : who.text
296 return (
297 <Box flexDirection="column">
298 <Box gap={1} alignItems="center">
299 {cell(
300 titleW,
301 <Box gap={1}>
302 <Text color={color}>{dot}</Text>
303 <Text bold={st.kind === 'waiting'} dimColor={quiet} wrap="truncate-end">{r.n > 0 ? `${r.n}. ` : ''}{r.title}</Text>
304 </Box>,
305 )}
306 {cell(W.bar, job ? slim(elements, stageStep(job), STAGE_STEPS, 70, tierColor(job.tier), 8) : <Text dimColor> </Text>)}
307 {cell(W.status, <Text color={st.kind === 'waiting' ? WAIT : undefined} dimColor={quiet} wrap="truncate-end">{st.label}</Text>)}
308 {cell(W.time, <Text dimColor>{spent === null ? '' : formatDuration(spent)}</Text>)}
309 </Box>
310 <Box gap={1} paddingLeft={2} alignItems="center">
311 {job && <Text dimColor>{job.id}</Text>}
312 {tier && <Text color={tierColor(tier)}>{tier}</Text>}
313 {job && job.tokens > 0 && <Text dimColor>{formatTokens(job.tokens)} tok · {formatDuration(job.agentMs)} working</Text>}
314 {owner && <Text color={who.mismatch ? WAIT : undefined} dimColor={!who.mismatch}>{owner}</Text>}
315 {job && job.reviewers.map(rv => led(Box, Text, rv, job.reviews[rv]))}
316 {job && job.stage === 'needs-human' && act('approve', `/factory-approve ${job.id}`)}
317 {job && job.stage === 'needs-human' && act('rework', `/factory-rework ${job.id} `)}
318 {job && job.stage === 'waiting-input' && act('answer', `/factory-answer ${job.id} `)}
319 </Box>
320 </Box>
321 )
322 }
323
324 const plan = (p: PlanView) => {
325 const rows = taskRows(p).filter(r => !onlyMine || rowStatus(r).kind === 'waiting')
326 const elapsed = planElapsed(p, now)
327 return (
328 <Box flexDirection="column" borderStyle="round" borderColor={TRACK} paddingX={1}>
329 <Box gap={1} alignItems="center">
330 {mid(Box, <Text bold>{p.plan}</Text>)}
331 {slim(elements, p.done, p.total, 140, OK, 16)}
332 {mid(Box, <Text bold>{p.done}/{p.total}</Text>)}
333 {mid(Box, <Text dimColor>{elapsed === null ? '' : formatDuration(elapsed)}</Text>)}
334 {p.tokens > 0 && mid(Box, <Text dimColor>{formatTokens(p.tokens)} tok · {formatDuration(p.agentMs)} agents</Text>)}
335 {p.path && act('run', `/factory-run ${p.path}`)}
336 {p.lock && act('unlock', `/factory-unlock ${p.plan}`)}
337 </Box>
338 {p.lock && <Text color={isStale(p.lock, now) ? WAIT : undefined} dimColor={!isStale(p.lock, now)}>{lockNote(p.lock, now)}</Text>}
339 {rows.length > 0 && header}
340 {rows.map(row)}
341 {rows.length === 0 && <Text dimColor>Nothing here needs you.</Text>}
342 </Box>
343 )
344 }
345
346 return (
347 <Box flexDirection="column" gap={1}>
348 <Box gap={2} alignItems="center">
349 {mid(Box, <Text bold color={summary.waiting > 0 ? WAIT : undefined}>{situationText(summary)}</Text>)}
350 <Button key="mine" label={`${onlyMine ? '[x]' : '[ ]'} only what needs me`} onPress={() => update($, isOnlyMine, () => !onlyMine)} />
351 </Box>
352 {backlogLine && <Text dimColor>{backlogLine}</Text>}
353 <Box flexDirection="column">
354 <Box gap={1}>
355 <Text dimColor>reviews</Text>
356 {led(Box, Text, 'pass', 'PASS')}
357 {led(Box, Text, 'changes', 'CHANGES')}
358 {led(Box, Text, 'pending', 'pending')}
359 </Box>
360 <Box gap={1}>
361 <Text dimColor>level</Text>
362 {['light', 'medium', 'careful', 'heavy', 'fable'].map(t => (
363 <Text color={tierColor(t)}>{t}</Text>
364 ))}
365 <Text dimColor>(easy to hard: the task's difficulty, which sets the builder's model)</Text>
366 </Box>
367 </Box>
368 {[...summary.plans].sort(byNeed).map(plan)}
369 </Box>
370 )
371 })
372}
373hooks/model.ts 566 lines1// Pure helpers for the progress band and pane. No imports from the host, so they can be tested with plain Node.
2
3export type Stage = 'spec' | 'build' | 'waiting-input' | 'review' | 'approve' | 'merged' | 'committed' | 'needs-human'
4
5// What the orchestrator asked for when it spawned an agent of the job.
6export type AgentInfo = { model: string; effort: string; tokens: number; ms: number; runs: number }
7
8export type PlanTask = { n: number; title: string; tier: string; after: number[] }
9
10export type Job = {
11 id: string
12 plan: string
13 task: number
14 started: number | null
15 updated: number | null
16 repo: string
17 tier: string
18 stage: Stage
19 round: number
20 reviewers: string[]
21 reviews: Record<string, string>
22 agents: Record<string, AgentInfo>
23 tokens: number
24 agentMs: number
25}
26
27// A run lock: while it is fresh, the plan and its spec are protected from edits.
28export type Lock = { plan: string; runId: string; since: number; heartbeat: number; protect: string[] }
29
30const STALE_MS = 30 * 60_000
31
32export type Run = { plan: string; path: string; total: number; done: number; tasks: PlanTask[] }
33
34export type PlanView = {
35 plan: string
36 path: string
37 total: number
38 done: number
39 running: Job[]
40 waiting: Job[]
41 finished: Job[]
42 planned: PlanTask[]
43 tasks: PlanTask[]
44 tokens: number
45 agentMs: number
46 lock: Lock | null
47}
48
49export type Summary = { plans: PlanView[]; waiting: number; running: number }
50
51const STAGES: readonly string[] = ['spec', 'build', 'waiting-input', 'review', 'approve', 'merged', 'committed', 'needs-human']
52const LANDED: readonly string[] = ['merged', 'committed']
53const NEEDS_USER: readonly string[] = ['needs-human', 'waiting-input']
54
55const toMs = (v: unknown): number | null => {
56 if (typeof v !== 'string') return null
57 const t = Date.parse(v)
58 return Number.isNaN(t) ? null : t
59}
60
61const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
62
63function parseAgents(raw: unknown): Record<string, AgentInfo> {
64 const out: Record<string, AgentInfo> = {}
65 if (!isRecord(raw)) return out
66 for (const [role, info] of Object.entries(raw)) {
67 if (isRecord(info) && typeof info.model === 'string') {
68 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
69 out[role] = { model: info.model, effort: typeof info.effort === 'string' ? info.effort : '', tokens: num(info.tokens), ms: num(info.ms), runs: num(info.runs) }
70 }
71 }
72 return out
73}
74
75export function parseJob(text: string): Job | null {
76 let raw: unknown
77 try {
78 raw = JSON.parse(text)
79 } catch {
80 return null
81 }
82 if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.plan !== 'string' || typeof raw.stage !== 'string') return null
83 if (!STAGES.includes(raw.stage)) return null
84 const agents = parseAgents(raw.agents)
85 const reviews = isRecord(raw.reviews) ? Object.fromEntries(Object.entries(raw.reviews).map(([k, v]) => [k, String(v)])) : {}
86 return {
87 id: raw.id,
88 plan: raw.plan,
89 task: typeof raw.task === 'number' ? raw.task : 0,
90 started: toMs(raw.started),
91 updated: toMs(raw.updated),
92 repo: typeof raw.repo === 'string' ? raw.repo : '',
93 tier: typeof raw.tier === 'string' ? raw.tier : 'medium',
94 stage: raw.stage as Stage,
95 round: typeof raw.round === 'number' ? raw.round : 0,
96 reviewers: Array.isArray(raw.reviewers) ? raw.reviewers.map(String) : [],
97 reviews,
98 agents,
99 tokens: Object.values(agents).reduce((n, a) => n + a.tokens, 0),
100 agentMs: Object.values(agents).reduce((n, a) => n + a.ms, 0),
101 }
102}
103
104export function parseRun(text: string): Run | null {
105 let raw: unknown
106 try {
107 raw = JSON.parse(text)
108 } catch {
109 return null
110 }
111 if (!isRecord(raw) || typeof raw.plan !== 'string') return null
112 const tasks: PlanTask[] = []
113 if (Array.isArray(raw.tasks)) {
114 for (const t of raw.tasks) {
115 if (isRecord(t) && typeof t.n === 'number' && typeof t.title === 'string') {
116 tasks.push({
117 n: t.n,
118 title: t.title,
119 tier: typeof t.tier === 'string' ? t.tier : 'medium',
120 after: Array.isArray(t.after) ? t.after.filter((x): x is number => typeof x === 'number') : [],
121 })
122 }
123 }
124 }
125 return {
126 plan: raw.plan,
127 path: typeof raw.path === 'string' ? raw.path : '',
128 total: typeof raw.total === 'number' ? raw.total : 0,
129 done: typeof raw.done === 'number' ? raw.done : 0,
130 tasks,
131 }
132}
133
134export function summarize(runs: Run[], jobs: Job[], locks: Lock[] = []): Summary {
135 const plans = new Map<string, Job[]>()
136 for (const job of jobs) plans.set(job.plan, [...(plans.get(job.plan) ?? []), job])
137 for (const run of runs) if (!plans.has(run.plan)) plans.set(run.plan, [])
138 for (const lock of locks) if (!plans.has(lock.plan)) plans.set(lock.plan, [])
139
140 const views: PlanView[] = []
141 for (const [plan, list] of plans) {
142 const run = runs.find(r => r.plan === plan)
143 const landed = list.filter(j => LANDED.includes(j.stage)).length
144 const started = new Set(list.map(j => j.task))
145 views.push({
146 plan,
147 path: run?.path ?? '',
148 total: run && run.total > 0 ? run.total : run && run.tasks.length > 0 ? run.tasks.length : list.length,
149 done: Math.max(run?.done ?? 0, landed),
150 running: list.filter(j => !LANDED.includes(j.stage) && !NEEDS_USER.includes(j.stage)),
151 waiting: list.filter(j => NEEDS_USER.includes(j.stage)),
152 finished: list.filter(j => LANDED.includes(j.stage)),
153 planned: (run?.tasks ?? []).filter(t => !started.has(t.n)),
154 tasks: run?.tasks ?? [],
155 tokens: list.reduce((n, j) => n + j.tokens, 0),
156 agentMs: list.reduce((n, j) => n + j.agentMs, 0),
157 lock: locks.find(l => l.plan === plan) ?? null,
158 })
159 }
160 return {
161 plans: views,
162 waiting: views.reduce((n, v) => n + v.waiting.length, 0),
163 running: views.reduce((n, v) => n + v.running.length, 0),
164 }
165}
166
167export function bar(done: number, total: number, width = 10): string {
168 if (total <= 0) return ''
169 const filled = Math.min(width, Math.max(0, Math.round((width * done) / total)))
170 return '█'.repeat(filled) + '░'.repeat(width - filled)
171}
172
173// Plans that have something running or waiting, the ones that need the user first.
174export function activePlans(summary: Summary): PlanView[] {
175 return summary.plans
176 .filter(p => p.running.length + p.waiting.length > 0)
177 .sort((a, b) => b.waiting.length - a.waiting.length)
178}
179
180export function barParts(done: number, total: number, width = 10): { filled: string; empty: string } {
181 if (total <= 0) return { filled: '', empty: '' }
182 const filled = Math.min(width, Math.max(0, Math.round((width * done) / total)))
183 return { filled: '█'.repeat(filled), empty: '░'.repeat(width - filled) }
184}
185
186// The ids of the jobs that need the user, at most `max` of them, then how many more.
187export function waitingLabel(summary: Summary, max = 3): string {
188 const ids = summary.plans.flatMap(p => p.waiting.map(j => j.id)).sort()
189 if (ids.length <= max) return ids.join(', ')
190 return `${ids.slice(0, max).join(', ')} +${ids.length - max}`
191}
192
193export function phaseLabel(job: Job): string {
194 if (NEEDS_USER.includes(job.stage)) return job.stage === 'waiting-input' ? 'waiting for you' : 'needs you'
195 if (job.stage === 'review') {
196 const passed = job.reviewers.filter(r => job.reviews[r] === 'PASS').length
197 return `review r${job.round} ${passed}/${job.reviewers.length}`
198 }
199 return job.stage
200}
201
202export function waitingIds(jobs: Job[]): Set<string> {
203 return new Set(jobs.filter(j => NEEDS_USER.includes(j.stage)).map(j => j.id))
204}
205
206export type Fs = {
207 list(path?: string): Promise<{ name: string; kind: string }[]>
208 read(path: string): Promise<string>
209 exists(path: string): Promise<boolean>
210}
211
212const SKIP_DIRS = new Set(['node_modules', 'vendor', 'dist', 'build'])
213const MAX_JOBS = 300
214
215async function jobsFolders(fs: Fs): Promise<string[]> {
216 const folders: string[] = []
217 if (await fs.exists('factory/jobs')) folders.push('factory/jobs')
218 let here: { name: string; kind: string }[] = []
219 try {
220 here = await fs.list('.')
221 } catch {
222 return folders
223 }
224 for (const entry of here) {
225 if (entry.kind !== 'dir' || entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
226 if (await fs.exists(`${entry.name}/factory/jobs`)) folders.push(`${entry.name}/factory/jobs`)
227 }
228 return folders
229}
230
231// Reads the state the run command writes: job.json per job and _runs/<plan>.json per plan.
232// Looks in the working folder and one level below it. A file that cannot be read or parsed is skipped.
233export async function scan(fs: Fs): Promise<{ runs: Run[]; jobs: Job[]; locks: Lock[] }> {
234 const runs: Run[] = []
235 const jobs: Job[] = []
236 const locks: Lock[] = []
237 for (const base of await jobsFolders(fs)) {
238 let entries: { name: string; kind: string }[] = []
239 try {
240 entries = await fs.list(base)
241 } catch {
242 continue
243 }
244 for (const entry of entries) {
245 if (entry.kind !== 'dir') continue
246 if (entry.name === '_runs') {
247 let files: { name: string; kind: string }[] = []
248 try {
249 files = await fs.list(`${base}/_runs`)
250 } catch {
251 continue
252 }
253 for (const f of files.filter(x => x.name.endsWith('.json'))) {
254 const run = await fs.read(`${base}/_runs/${f.name}`).then(parseRun, () => null)
255 if (run) runs.push(run)
256 }
257 for (const f of files.filter(x => x.name.endsWith('.lock'))) {
258 const lock = await fs.read(`${base}/_runs/${f.name}`).then(parseLock, () => null)
259 if (lock) locks.push(lock)
260 }
261 } else if (/^\d{3}-/.test(entry.name) && jobs.length < MAX_JOBS) {
262 const job = await fs.read(`${base}/${entry.name}/job.json`).then(parseJob, () => null)
263 if (job) jobs.push(job)
264 }
265 }
266 }
267 return { runs, jobs, locks }
268}
269
270export type Doc = { name: string; text: string }
271export type Backlog = { openPlans: number; unplannedSpecs: number }
272
273const CLOSED_STATUS = /^status:\s*["']?(completed|done|shipped|superseded|abandoned|cancelled)\b/im
274
275function planIsOpen(text: string): boolean {
276 const front = /^---\n([\s\S]*?)\n---/.exec(text)
277 if (front && CLOSED_STATUS.test(front[1]!)) return false
278 const open = /^\s*- \[ \]/m.test(text)
279 const marked = /^\s*- \[[ x-]\]/im.test(text)
280 return open || !marked
281}
282
283// Counts for the pane: plans with work left, and specs no plan names. A spec that says `Plan: not required`
284// in its first lines is a reference and does not count; a plan for the same subject (`x-design.md` -> `x.md`) does.
285export function backlogCounts(plans: Doc[], specs: Doc[]): Backlog {
286 const blob = plans.map(p => p.text).join('\n')
287 const names = new Set(plans.map(p => p.name))
288 const unplanned = specs.filter(s => {
289 if (blob.includes(s.name)) return false
290 if (/^plan:\s*not required/im.test(s.text.split('\n').slice(0, 30).join('\n'))) return false
291 return !names.has(s.name.replace(/-(design|review|gate|spec)\.md$/, '.md'))
292 })
293 return { openPlans: plans.filter(p => planIsOpen(p.text)).length, unplannedSpecs: unplanned.length }
294}
295
296export function backlogText(b: Backlog): string {
297 if (b.openPlans === 0 && b.unplannedSpecs === 0) return ''
298 const plans = `${b.openPlans} open plan${b.openPlans === 1 ? '' : 's'}`
299 const specs = `${b.unplannedSpecs} unplanned spec${b.unplannedSpecs === 1 ? '' : 's'}`
300 return `${plans} · ${specs}`
301}
302
303// The folders plan_status.py reads, in the working folder and in each folder one level below it.
304const PLAN_DIRS = ['docs/superpowers/plans', '.cursor/plans', 'Modules/*/docs/plans']
305const SPEC_DIRS = ['docs/superpowers/specs', '.cursor/specs']
306const SKIPPED_DOCS = new Set(['INDEX.md', 'README.md'])
307
308async function listed(fs: Fs, dir: string): Promise<{ name: string; kind: string }[]> {
309 try {
310 return await fs.list(dir)
311 } catch {
312 return []
313 }
314}
315
316// The folders a pattern names under `base`; a `*` segment stands for any folder that is not hidden.
317async function expand(fs: Fs, base: string, pattern: string): Promise<string[]> {
318 let found = [base]
319 for (const part of pattern.split('/')) {
320 const next: string[] = []
321 for (const dir of found) {
322 if (part !== '*') next.push(dir ? `${dir}/${part}` : part)
323 else for (const e of await listed(fs, dir || '.')) {
324 if (e.kind === 'dir' && !e.name.startsWith('.')) next.push(dir ? `${dir}/${e.name}` : e.name)
325 }
326 }
327 found = next
328 }
329 return found
330}
331
332// The Markdown files of a folder and of its subfolders, by file name, without index and readme files.
333async function documents(fs: Fs, dir: string): Promise<Doc[]> {
334 const docs: Doc[] = []
335 for (const e of await listed(fs, dir)) {
336 if (e.name.startsWith('.')) continue
337 if (e.kind === 'dir') docs.push(...(await documents(fs, `${dir}/${e.name}`)))
338 else if (e.name.endsWith('.md') && !SKIPPED_DOCS.has(e.name)) {
339 const text = await fs.read(`${dir}/${e.name}`).catch(() => null)
340 if (text !== null) docs.push({ name: e.name, text })
341 }
342 }
343 return docs
344}
345
346// Reads the plans and specs where plan_status.py finds them: the working folder and the folders one level below it,
347// subfolders of the plans and specs folders included. Unreadable files are skipped.
348export async function scanBacklog(fs: Fs): Promise<Backlog> {
349 const bases = ['']
350 for (const e of await listed(fs, '.')) {
351 if (e.kind === 'dir' && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name)) bases.push(e.name)
352 }
353 const load = async (patterns: string[]): Promise<Doc[]> => {
354 const docs: Doc[] = []
355 for (const base of bases) {
356 for (const pattern of patterns) {
357 for (const dir of await expand(fs, base, pattern)) docs.push(...(await documents(fs, dir)))
358 }
359 }
360 return docs
361 }
362 return backlogCounts(await load(PLAN_DIRS), await load(SPEC_DIRS))
363}
364
365export const STAGE_STEPS = 5
366
367// Where a job sits on a five-step track: spec, build, review, approve, landed.
368export function stageStep(job: Job): number {
369 switch (job.stage) {
370 case 'spec':
371 return 1
372 case 'build':
373 case 'waiting-input':
374 return 2
375 case 'review':
376 return 3
377 case 'approve':
378 return 4
379 case 'needs-human':
380 return job.round > 0 ? 4 : 2
381 default:
382 return 5
383 }
384}
385
386const TIER_COLORS: Record<string, string> = { light: '#3fb97c', medium: '#4f7cff', careful: '#e0a526', heavy: '#e5675c', fable: '#b56cf0' }
387
388export function tierColor(tier: string): string {
389 return TIER_COLORS[tier] ?? TIER_COLORS.medium!
390}
391
392export function formatDuration(ms: number): string {
393 const total = Math.max(0, Math.floor(ms / 1000))
394 const h = Math.floor(total / 3600)
395 const m = Math.floor((total % 3600) / 60)
396 const sec = String(total % 60).padStart(2, '0')
397 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
398}
399
400// A live job counts up to now; a landed one stops at its last update.
401export function jobElapsed(job: Job, now: number): number | null {
402 if (job.started === null) return null
403 const end = LANDED.includes(job.stage) && job.updated !== null ? job.updated : now
404 return Math.max(0, end - job.started)
405}
406
407// From the first start to now, or to the last landing once every job has landed.
408export function planElapsed(view: PlanView, now: number): number | null {
409 const jobs = [...view.running, ...view.waiting, ...view.finished]
410 const starts = jobs.map(j => j.started).filter((t): t is number => t !== null)
411 if (starts.length === 0) return null
412 const first = Math.min(...starts)
413 const allLanded = view.running.length === 0 && view.waiting.length === 0 && view.finished.length > 0
414 const ends = view.finished.map(j => j.updated).filter((t): t is number => t !== null)
415 const end = allLanded && ends.length > 0 ? Math.max(...ends) : now
416 return Math.max(0, end - first)
417}
418
419// A bar whose label is written across it: the part under the fill and the part past it,
420// so the label reads as text inside the bar. A label that does not fit is cut with an ellipsis.
421export function labeledBar(label: string, done: number, total: number, width: number): { filled: string; empty: string } {
422 let text = ` ${label} `
423 if (text.length > width) text = `${text.slice(0, Math.max(0, width - 2))}… `
424 text = text.padEnd(width).slice(0, width)
425 const filled = total <= 0 ? 0 : Math.min(width, Math.max(0, Math.round((width * done) / total)))
426 return { filled: text.slice(0, filled), empty: text.slice(filled) }
427}
428
429export function reviewerLabel(name: string): string {
430 return name === 'security' ? 'sec' : name
431}
432
433// A cue besides color for a reviewer badge: `!` for requested changes, `…` while nothing came back yet.
434export function verdictMark(verdict: string | undefined): string {
435 return verdict === 'PASS' ? '' : verdict === 'CHANGES' ? '!' : '…'
436}
437
438// `clay:reviewer-code` -> `code`, `clay:builder` -> `builder`.
439export function roleOf(subagentType: string): string {
440 return subagentType.slice(subagentType.lastIndexOf(':') + 1).replace(/^reviewer-/, '')
441}
442
443// Every prompt the run command sends starts with the job's folder, which holds its id.
444export function jobIdFromPrompt(prompt: string): string | null {
445 return /factory\/jobs\/(\d{3}-[^/\s]+)\//.exec(prompt)?.[1] ?? null
446}
447
448export function shortModel(id: string): string {
449 return id.replace(/^claude-/, '').replace(/-\d{8}$/, '')
450}
451
452const sameModel = (asked: string, seen: string) => {
453 const a = asked.toLowerCase()
454 const b = seen.toLowerCase()
455 return b.includes(a) || a.includes(b)
456}
457
458// Who has the job right now, with the model they run on. `observed` is what the engine reported at spawn.
459export function workingNow(job: Job, observed: Record<string, { model: string }> = {}): { text: string; mismatch: boolean } {
460 let roles: string[] = []
461 if (job.stage === 'spec' || job.stage === 'build' || job.stage === 'waiting-input') roles = ['builder']
462 else if (job.stage === 'approve') roles = ['approver']
463 else if (job.stage === 'review') roles = job.reviewers.filter(r => job.reviews[r] !== 'PASS' && job.reviews[r] !== 'CHANGES')
464
465 const groups = new Map<string, string[]>()
466 let mismatch = false
467 for (const role of roles) {
468 // `ux` and `ui` share the interface reviewer; `uiux` is its role in jobs started before it existed.
469 const lens = role === 'ui' || role === 'ux'
470 const asked = (lens ? job.agents.interface : undefined) ?? job.agents[role] ?? (lens ? job.agents.uiux : undefined)
471 const seen = ((lens ? observed.interface : undefined) ?? observed[role] ?? (lens ? observed.uiux : undefined))?.model
472 const differs = Boolean(asked && seen && !sameModel(asked.model, seen))
473 mismatch = mismatch || differs
474 const model = seen ? shortModel(seen) : asked?.model
475 const key = model ? `${model}${asked?.effort ? ` ${asked.effort}` : ''}${differs ? ` (asked ${asked!.model})` : ''}` : ''
476 groups.set(key, [...(groups.get(key) ?? []), role])
477 }
478 const text = [...groups].map(([key, who]) => (key ? `${who.join(', ')} · ${key}` : who.join(', '))).join(' · ')
479 return { text, mismatch }
480}
481
482export type TaskRow = { n: number; title: string; job: Job | null; planned: PlanTask | null }
483
484// One row per task of the plan, in plan order: the job that carries it, or the task itself while it is only planned.
485// Jobs that no task list explains (an ad hoc task, or a run file without tasks) follow, by id.
486export function taskRows(view: PlanView): TaskRow[] {
487 const jobs = [...view.waiting, ...view.running, ...view.finished]
488 const byTask = new Map<number, Job>()
489 for (const j of jobs) if (j.task > 0 && !byTask.has(j.task)) byTask.set(j.task, j)
490
491 const rows: TaskRow[] = []
492 const used = new Set<string>()
493 for (const t of [...view.tasks].sort((a, b) => a.n - b.n)) {
494 const job = byTask.get(t.n) ?? null
495 if (job) used.add(job.id)
496 rows.push({ n: t.n, title: t.title, job, planned: job ? null : t })
497 }
498 for (const j of jobs.filter(x => !used.has(x.id)).sort((a, b) => a.id.localeCompare(b.id))) {
499 rows.push({ n: j.task, title: j.id, job: j, planned: null })
500 }
501 return rows
502}
503
504// One sentence for the top of the pane: what needs the user first, then what is moving.
505export function situationText(summary: Summary): string {
506 const planned = summary.plans.reduce((n, p) => n + p.planned.length, 0)
507 const landed = summary.plans.reduce((n, p) => n + p.finished.length, 0)
508 const parts = [
509 summary.waiting > 0 ? `${summary.waiting} waiting for you` : '',
510 summary.running > 0 ? `${summary.running} running` : '',
511 planned > 0 ? `${planned} planned` : '',
512 landed > 0 ? `${landed} landed` : '',
513 ].filter(Boolean)
514 return parts.length > 0 ? parts.join(' · ') : 'nothing running'
515}
516
517export function rowStatus(row: TaskRow): { label: string; kind: 'waiting' | 'running' | 'landed' | 'planned' } {
518 if (!row.job) {
519 const after = row.planned?.after ?? []
520 return { label: after.length > 0 ? `planned · after ${after.join(', ')}` : 'planned', kind: 'planned' }
521 }
522 if (LANDED.includes(row.job.stage)) return { label: 'landed', kind: 'landed' }
523 if (NEEDS_USER.includes(row.job.stage)) return { label: phaseLabel(row.job), kind: 'waiting' }
524 return { label: phaseLabel(row.job), kind: 'running' }
525}
526
527// `950`, `9.5k`, `14k`, `1.3M`: short enough for a row of the pane.
528export function formatTokens(n: number): string {
529 if (n < 1000) return String(Math.max(0, Math.round(n)))
530 if (n < 10_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
531 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
532 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
533}
534
535export function parseLock(text: string): Lock | null {
536 let raw: unknown
537 try {
538 raw = JSON.parse(text)
539 } catch {
540 return null
541 }
542 if (!isRecord(raw) || typeof raw.plan !== 'string' || typeof raw.run_id !== 'string') return null
543 return {
544 plan: raw.plan,
545 runId: raw.run_id,
546 since: toMs(raw.started) ?? 0,
547 heartbeat: toMs(raw.heartbeat) ?? 0,
548 protect: Array.isArray(raw.protect) ? raw.protect.filter((p): p is string => typeof p === 'string') : [],
549 }
550}
551
552export function isStale(lock: Lock, now: number): boolean {
553 return now - lock.heartbeat > STALE_MS
554}
555
556// The fresh lock whose plan or spec an edit of `path` would change, if any. Paths are compared by their end, since a lock
557// names them relative to the repository and an edit names them absolutely.
558export function protectedBy(locks: Lock[], path: string, now: number): Lock | null {
559 const norm = path.replace(/\\/g, '/')
560 return locks.find(l => !isStale(l, now) && l.protect.some(p => norm === p || norm.endsWith(`/${p}`))) ?? null
561}
562
563export function lockNote(lock: Lock, now: number): string {
564 return isStale(lock, now) ? 'lock stale: no activity for over 30 minutes' : `locked: plan and spec read-only while this run lasts`
565}
566hooks/svg.ts 64 lines1// Pure builders for the vector pieces of the progress UI: rounded bars and tiles.
2// The strings are SVG documents; every piece of text that comes from a file is escaped.
3
4const FONT = 'system-ui, -apple-system, Segoe UI, sans-serif'
5
6export function escapeXml(text: string): string {
7 return text
8 .replace(/&/g, '&')
9 .replace(/</g, '<')
10 .replace(/>/g, '>')
11 .replace(/"/g, '"')
12 .replace(/'/g, ''')
13}
14
15// A width in CSS pixels that fits the label at the bar's font size.
16export function barWidth(label: string, min = 150): number {
17 return Math.max(min, Math.ceil(label.length * 7 + 24))
18}
19
20export type PillBar = {
21 label: string
22 done: number
23 total: number
24 width: number
25 height?: number
26 fill: string
27 track: string
28}
29
30// A fully rounded bar: a track, a fill clipped to the track's shape, and the label as white text on top.
31export function pillBar(o: PillBar): string {
32 const h = o.height ?? 20
33 const room = Math.max(1, Math.floor((o.width - h) / 6.8))
34 const label = o.label.length > room ? `${o.label.slice(0, Math.max(0, room - 1))}…` : o.label
35 const r = h / 2
36 const filled = o.total <= 0 ? 0 : Math.min(o.width, Math.max(0, Math.round((o.width * o.done) / o.total)))
37 const fill =
38 filled > 0 ? `<rect class="fill" x="0" y="0" width="${filled}" height="${h}" fill="${o.fill}" clip-path="url(#shape)"/>` : ''
39 return (
40 `<svg xmlns="http://www.w3.org/2000/svg" width="${o.width}" height="${h}" viewBox="0 0 ${o.width} ${h}">` +
41 `<defs><clipPath id="shape"><rect width="${o.width}" height="${h}" rx="${r}"/></clipPath></defs>` +
42 `<rect width="${o.width}" height="${h}" rx="${r}" fill="${o.track}"/>${fill}` +
43 `<text x="${r}" y="${r + 0.5}" dominant-baseline="central" font-family="${FONT}" font-size="12" font-weight="600" fill="#ffffff">${escapeXml(label)}</text>` +
44 `</svg>`
45 )
46}
47
48export type SlimBar = { done: number; total: number; width: number; height?: number; fill: string; track: string }
49
50// A thin, fully rounded bar with no text: its meaning is written beside it, and its color says one thing only.
51export function slimBar(o: SlimBar): string {
52 const h = o.height ?? 4
53 const r = h / 2
54 const filled = o.total <= 0 ? 0 : Math.min(o.width, Math.max(0, Math.round((o.width * o.done) / o.total)))
55 const fill =
56 filled > 0 ? `<rect class="fill" x="0" y="0" width="${filled}" height="${h}" fill="${o.fill}" clip-path="url(#shape)"/>` : ''
57 return (
58 `<svg xmlns="http://www.w3.org/2000/svg" width="${o.width}" height="${h}" viewBox="0 0 ${o.width} ${h}">` +
59 `<defs><clipPath id="shape"><rect width="${o.width}" height="${h}" rx="${r}"/></clipPath></defs>` +
60 `<rect width="${o.width}" height="${h}" rx="${r}" fill="${o.track}"/>${fill}` +
61 `</svg>`
62 )
63}
64types/index.d.ts 52 lines1// The contract must be self-contained, so these shapes repeat those of hooks/model.ts.
2export type FactoryJob = {
3 id: string
4 plan: string
5 task: number
6 started: number | null
7 updated: number | null
8 repo: string
9 tier: string
10 stage: 'spec' | 'build' | 'waiting-input' | 'review' | 'approve' | 'merged' | 'committed' | 'needs-human'
11 round: number
12 reviewers: string[]
13 reviews: Record<string, string>
14 agents: Record<string, { model: string; effort: string; tokens: number; ms: number; runs: number }>
15 tokens: number
16 agentMs: number
17}
18
19export type FactoryPlanTask = { n: number; title: string; tier: string; after: number[] }
20
21export type FactorySummary = {
22 plans: {
23 plan: string
24 path: string
25 total: number
26 done: number
27 running: FactoryJob[]
28 waiting: FactoryJob[]
29 finished: FactoryJob[]
30 planned: FactoryPlanTask[]
31 tasks: FactoryPlanTask[]
32 tokens: number
33 agentMs: number
34 lock: { plan: string; runId: string; since: number; heartbeat: number; protect: string[] } | null
35 }[]
36 waiting: number
37 running: number
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 clay: {
43 view: FactorySummary
44 backlog: { openPlans: number; unplannedSpecs: number }
45 isHidden: boolean
46 isOnlyMine: boolean
47 observed: Record<string, Record<string, { model: string }>>
48 locks: { plan: string; runId: string; since: number; heartbeat: number; protect: string[] }[]
49 }
50 }
51}
52