The workspace probe's stand-in for the factory loop: prepares one slot as factory-slot does and spawns one builder from a timer, for the companion case. Never…

Run autonomous coding agents on any repo, from issue to merged pull request, with one shared, versioned setup. It is for people who want to hand work to Claude Code (or codex) from a phone or a browser and come back to a reviewed PR, without keeping a shell open on any machine.
docker run, on Kubernetes or as a Coder workspace. In an orchestrator container, tmux holds one Claude session and a supervisor restarts it when it exits. dev-system is not a tmux multiplexer running many terminals on one machine; you scale out by running more containers.ready. The orchestrator session watching the repo delegates design and implementation to sub-agents, which work in git worktrees and open PRs through the no-mistakes pipeline.Three layers, each pinned independently:
callum-flow plugin in plugins/callum-flow/: the skills and hooks that turn issues into PRs. Beside it, the opt-in factory-hud mod shows usage and factory counts on every client, the phone included.images/base/: the tools, headless Claude with Remote Control, logins and persistent state.templates/ synced into each repo by callum-dev, so a repo pins a version and pulls updates.flowchart LR
app["Claude app<br/>(Remote Control)"] --> orch
gh["GitHub issue<br/>labelled ready"] --> orch
subgraph c1["Container: orchestrator (one per repo)"]
orch["Orchestrator session<br/>(one supervised Claude)"]
orch --> w1["Sub-agent<br/>worktree A"]
orch --> w2["Sub-agent<br/>worktree B"]
end
subgraph c2["Container: hands-on workspace"]
ws["Remote Control server<br/>(own worktree per new session)"]
end
app --> ws
w1 --> pr["Pull requests<br/>(no-mistakes pipeline)"]
w2 --> pr
ws --> pr
Containers are independent and sit side by side. See docs/architecture.md for the layers, versioning and the runtime map.
Dispatch from a phone
Label an issue ready
ready label.docker run, Kubernetes and Coder (images/base/).images/base/)./persist volumes (runtime storage).issue-orchestrator, implement-issue, /update-dev and /callum-flow:onboard (plugin in plugins/callum-flow/).callum-dev init, update and check (templates/).images/base/).New or existing repo: open an agent in the folder and tell it to follow docs/onboarding.md ("make this a dev-system repo"), or, with the callum-flow plugin installed, run /callum-flow:onboard.
By hand:
npm i -D github:cbundy/dev-system#semver:0.x
npx callum-dev init # then fill every <REPLACE> and commit
| Page | Read it for |
|---|---|
docs/onboarding.md | Making a repo a dev-system repo (written for agents) |
docs/architecture.md | Layers, versioning, how updates reach repos, the runtime map for debugging |
docs/metrics.md | Factory metrics queries and the no-mistakes PostgreSQL mirror |
images/base/README.md | The base image: persistence, dev-init, dev-doctor, Remote Control, logins, agentsview |
coder/dev-system/README.md | The Coder templates: pushing them, parameters, OTLP |
docs/developing-on-coder.md | Developing dev-system itself from a Coder workspace: logins, and what only CI can verify |
templates/README.md | Each template file: what is synced and what the repo owns |
RELEASING.md | Cutting a release of each layer |
hooks/register.ts 87 lines1// The workspace probe's stand-in for the factory loop (README.md, case `companion`), loaded with
2// the real companion plugin plugins/factory-loop-workspace. Its session.start prepares one slot as
3// `factory-slot prepare` does (a git worktree on the issue's branch, the step marker in the slot's
4// own git dir), then spawns `factory-loop:builder` from a timer, outside any frame, with the name
5// and slot line the loop gives an agent step (v2/steps.ts, v2/workspace.ts). The builder sleeps
6// 130 s with no timeout, tries a write in the main checkout, a push to main and a gh write, writes in
7// its slot and finishes; the gate wants fixed.txt, so its first finish is refused. The companion's
8// log (<run>/loop/companion.jsonl) and its gate record (factory-gate.json in the slot's git dir)
9// are the evidence, beside <run>/log.jsonl.
10import type { EngineInterface, Register } from 'claude-code'
11
12const ISSUE = 571
13const BRANCH = 'issue-571'
14const GATE = 'test -f fixed.txt || { echo "the gate wants the file fixed.txt in your slot: create it with the Write tool, then finish again"; exit 1; }'
15
16const ask = (slot: string) => `factory-slot: ${slot}
17Steps, each its own tool call:
181. Bash: pwd
192. Bash: date -u +%T; sleep 130; date -u +%T (pass no timeout parameter and do not run it in the background)
203. Write tool: file_path "../repo/outside.txt", content "x" (the main checkout)
214. Bash: git push origin main
225. Bash: gh pr create --title probe --body probe
236. Write tool: file_path "notes.txt", content "work"
24Then reply with one line per step saying what each returned or was told, and finish. If you are told a check failed when you finish, do what it says, then finish again.`
25
26let agent: string | null = null
27
28async function cfg($: EngineInterface) {
29 return { run: (await $.env.get('PROBE_RUN')) || '/tmp/workspace-probe' }
30}
31
32async function log($: EngineInterface, check: string, fields: Record<string, unknown> = {}) {
33 const { run } = await cfg($)
34 const line = JSON.stringify({ at: new Date(await $.clock.now()).toISOString(), plugin: 'loop', check, ...fields }) + '\n'
35 await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', `${run}/log.jsonl`], { stdin: line, timeoutMs: 10_000 }).catch(() => undefined)
36}
37
38// prepare: a repository with its main checkout at <run>/repo and the slot, a worktree on the
39// issue's branch, at <run>/slot; the marker goes in the slot's own git dir.
40async function prepare($: EngineInterface): Promise<string> {
41 const { run } = await cfg($)
42 const slot = `${run}/slot`
43 const script = [
44 'set -e',
45 'git init -q -b main "$1/repo"',
46 'cd "$1/repo"',
47 'git -c user.name=probe -c user.email=probe@example.com commit -q --allow-empty -m init',
48 'git worktree add -q -b "$2" "$1/slot"',
49 'printf "%s\\n" "$3" > "$(git -C "$1/slot" rev-parse --absolute-git-dir)/factory-step.json"',
50 ].join('\n')
51 const marker = JSON.stringify({ v: 1, repo: 'probe/probe', issue: ISSUE, life: 'probe1', seq: 7, step: 'build', role: 'writer', branch: BRANCH, gateCommands: [GATE], gateTimeoutMs: 600_000, loopDir: `${run}/loop` })
52 const r = await $.process.run(['sh', '-c', script, 'sh', run, BRANCH, marker], { timeoutMs: 30_000 })
53 await log($, 'prepared', { slot, exitCode: r.exitCode, stderr: r.stderr.trim() })
54 return slot
55}
56
57async function spawn($: EngineInterface, slot: string, tries = 1) {
58 try {
59 const r = await $.agent.spawn({ subagentType: 'factory-loop:builder', description: `Build #${ISSUE}`, name: `build-${ISSUE}-s7-a1`, prompt: ask(slot) })
60 agent = r.agentId ?? null
61 // the rail's read (plugins/factory-loop/hooks/v2/workspace.ts handshakePath): the companion's
62 // handshake for this very session
63 const session = await $.session.id()
64 const base = `${(await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) || '~'}/.claude`}/factory-loop`
65 const handshake = await $.fs.read(`${base}/workspace/${session.replace(/[^A-Za-z0-9._-]/g, '_')}.json`).catch(() => null)
66 await log($, 'spawned', { tries, agentId: r.agentId ?? null, deny: r.deny ?? null, session, handshake })
67 } catch (err) {
68 await log($, 'spawned', { tries, error: String(err) })
69 if (tries < 10) $.clock.after(2_000, () => void spawn($, slot, tries + 1))
70 }
71}
72
73export const register: Register = on => {
74 on('session.start', async ($, e, next) => {
75 const r = await next(e)
76 const slot = await prepare($)
77 $.clock.after(4_000, () => void spawn($, slot))
78 return r
79 })
80
81 on('turn.complete', async ($, e, next) => {
82 const r = await next(e)
83 if (e.agentId !== undefined && e.agentId === agent) await log($, 'answered', { agentId: e.agentId, answer: e.answer.slice(0, 2000) })
84 return r
85 }).catch(($, e, next) => next(e))
86}
87