SLOPSHOPPER

clay

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.

panebandguardcommandtoasttimer
v0.1.0MITupdated 2026-10-25swolley/clay-kit/plugins/clay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clay
│ ┃ Factory jobs ✕ › fix the failing auth test and add an audit log call │ ┃ No factory jobs found here. │ ┃ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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-progress │ ⎿ clay: Factory progress band hidden. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Factory jobs
No factory jobs found here.
README

clay

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.

Commands

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.

CommandStarted byWhat it does
/factory-brainstormyou, or the model when you describe new workidea to approved design (spec for big work, checked by an independent spec reviewer; short chat design for small work)
/factory-planyou, or the model after an approved designdesign to a plan of tasks and steps
/factory-run [plan]you onlyruns 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 onlyone ad hoc task from a working plan the command writes itself under factory/jobs/; /factory-task tier=careful <text> sets the tier
/factory-statusyou onlyjobs, stages, verdicts, what waits for you
/factory-approve [job]you onlylands a job that was waiting for you
/factory-rework [job] <note>you onlysends a waiting job back to its builder
/factory-answer [job] [text]you onlyanswers the questions a job is waiting on, now or later
/factory-backlog [options]you onlyopen plans, specs without a plan and, on request, code markers; read-only
/factory-unlock [plan]you onlyreleases 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.

Workflow

  1. /factory-brainstorm: questions, approaches, design. You approve it.
  2. /factory-plan: a plan with tasks, tiers and acceptance criteria. You review it.
  3. /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.
  4. When all tasks have landed, you choose how the work reaches your main branch. Nothing is ever pushed.

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.

Progress in the terminal (Claude Code only)

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.

Plan format and why it follows superpowers

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.

Backlog outside the plugin

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.

Requirements

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.

Hosts

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 CodeCodexCursor
Install/plugin marketplace add swolley/clay-kit, then /plugin install clay@clay-kitcodex plugin marketplace add swolley/clay-kit, then codex plugin add clay@clay-kitCLI /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
Agentsthe plugin's clay:<role> agentsgeneric subagents told to read the agent filethe plugin's agents, generic ones if the host does not list them
Model per callmodel, and effort only if the Agent tool shows itmodel and reasoning_effortreported to take only fast or inherit, no effort
QuestionsAskUserQuestion, up to 4 optionsrequest_user_input, 2 to 3 options, in Plan mode, else plain textthe ask-question tool (reported to depend on the model, and to be skipped in headless mode, cursor-agent -p)
Resume the builderSendMessagesend_inputa fresh builder
Progress band and paneyesnono
factory/jobs/dashboard.htmlyesyesyes

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

Model mapping (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.)

Hostmodeleffort
claudehaiku, sonnet, opus, fable, inherit, or an id starting with claude-low, medium, high, xhigh, max
codexany non-empty text, the id Codex knowsany non-empty text, as Codex accepts for that model
cursorfast or inheritnot 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:

  1. The entry in factory/models.json for the host.
  2. On Claude Code, the defaults of tiers.md.
  3. Otherwise the session's own model: on Codex the model is not passed but the effort of 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.

Working with the other vendor (Claude Code and Codex)

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.

Known limits on Codex and Cursor

  • The progress band and pane exist only on Claude Code. factory/jobs/dashboard.html works everywhere.
  • Codex questions. 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.
  • Codex agents. A Codex plugin cannot install agents, so each role runs as a generic subagent that is told to read the plugin's agent file.
  • Cursor model and effort. The per-call model is reported to accept only 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.
  • Cursor menu. Plugin skills marked user-only were reported hidden from the / menu. Type the command by name.
  • Cursor, untested. Whether Cursor finds the plugin's agents by default, and how it imports a Claude Code plugin, are not tested yet; the install route above comes from Cursor's documentation, not from our own run.
  • Model names differ per host, so a repository that runs on Codex or Cursor needs a 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.
  • Codex reviewers. Codex runs them as generic subagents, which ignore the tools: frontmatter of the agent files, so reviewers and the approver keep their no-code-change promise by instruction only.
  • A Cursor builder cannot be resumed: a rework starts a fresh builder that reads build.md, the rounds and the branch log.
  • The Claude Code progress mod in hooks/ is meant to stay unloaded on Codex and Cursor: their manifests declare empty hooks.

Manual checks on Codex and Cursor

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.

HostHost versionDateSections runResult
Codex
Cursor

Credits

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.

Source 4 files
hooks/register.tsx 373 lines
1import { 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}
373
hooks/model.ts 566 lines
1// 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}
566
hooks/svg.ts 64 lines
1// 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, '&amp;')
9    .replace(/</g, '&lt;')
10    .replace(/>/g, '&gt;')
11    .replace(/"/g, '&quot;')
12    .replace(/'/g, '&apos;')
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}
64
types/index.d.ts 52 lines
1// 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