The factory loop's companion: starts each agent the loop runs in its worktree slot, confines its writes and pushes, gives its shell commands a 10-minute…

The factory loop's companion plugin. It is enabled beside factory-loop in the orchestrator-loop workspace, and does for each agent the loop starts what the loop cannot do itself (v2 SPEC section 10).
The engine never runs a plugin's hooks for anything that plugin's own code started, and the loop starts its agents from its timer, so its own hooks never see them (SPIKES.md). A second plugin's hooks do. That is why this is a plugin of its own.
It acts only on a spawn whose prompt opens with factory-slot: <absolute slot path>, which the loop gives every agent step under planner v2. Every other agent, the loop's v1 agents included, is left alone.
| Hook | What it does |
|---|---|
agent.spawn | Reads the slot line and the step marker factory-step.json that factory-slot prepare wrote into the slot's git dir. Starts the agent in the slot when the marker is the loop's agent's (its name <step>-<N>-s<seq>-a<k> and its type factory-loop:*); refuses the spawn when the marker is missing or names another step. Keeps the agent's id, and its children's, in $.state. |
tool.call | The write guard. A writer writes only inside its slot (or outside the repository, such as /tmp); a reader writes nowhere in the repository. Every loop agent is refused GitHub writes (PRs, issues, labels, re-runs), no-mistakes, axi, a push to any remote but origin, any call in the background (Bash or Agent), and an isolated Agent call. A writer pushes only its branch, never by force, and creates no branch or worktree. A reader neither commits nor pushes. A Bash call with no timeout gets 10 minutes. |
classic.SubagentStop | The stop gate. When a writer finishes, it runs the repo's gateCommands in the slot, one after another, within gateTimeoutMs. While one fails, the finish is refused with the command, its exit code and the last 150 lines of its output. A gate that cannot run lets the agent finish and is logged. |
session.start | The handshake: writes <CLAUDE_CONFIG_DIR>/factory-loop/workspace/<session>.json with the time and this process's id (its pid and start time, from /proc/self/stat), again every minute (the first row or turn starts the beat when a /reload-plugins added the plugin). The loop holds every agent step while this session's file is missing, names another process or is older than 3 minutes. |
<loop dir>/companion.jsonl: one line per refusal (guard:write, guard:push, guard:command) and per gate run (gate-pass, gate-fail, gate-unknown, with the head, the duration and a failing command), with the issue, life and seq. The loop logs each refusal and each gate-unknown as an intervention; a gate that passed or failed is not one.factory-gate.json in the slot's git dir: the last gate result (pass, fail or unknown) with the slot's HEAD.The guard matches commands, not effects. A Bash write into another slot is not refused; each slot is reset at its next prepare, and callum-flow's main-checkout guard covers the main checkout.
The orchestrator-loop Coder template lists it beside factory-loop (coder/push.sh). On any other machine:
/plugin install factory-loop-workspace@callum
From a shell instead: claude plugin install factory-loop-workspace@callum --scope user, then start a new session.
Gate commands are repo settings: gateCommands (for example ["npm run lint", "npm test"]) and gateTimeoutMs in the repo's .claude/factory-loop.json or the workspace config. Empty means no gate.
npm test runs claude plugin test on this folder: tests/rules.test.ts holds the rules, tests/companion.test.ts drives each hook as the engine does for an agent the loop starts.
Re-run the workspace probe (docs/factory-loop/probes/workspace-probe/) on each Claude Code upgrade. Its companion case runs this plugin for real beside a stand-in loop.
hooks/register.ts 308 lines1// factory-loop-workspace: the factory loop's companion plugin (docs/factory-loop/v2/SPEC.md 10.3 to
2// 10.6, 10.9). The engine never runs a plugin's hooks for what that plugin's own code started, and
3// the loop starts its agents from its timer, so its own hooks never see them (SPIKES.md). This
4// second plugin's hooks do, and carry what the loop needs around each agent it starts:
5//
6// - the folder (10.3): a spawn whose prompt opens with `factory-slot: <path>` is started in that
7// slot, once the slot's step marker matches the spawn; a missing or mismatched marker refuses it;
8// - the map: the agent's id, and its children's, to its slot and marker, kept in $.state;
9// - the write guard (10.4): file writes and commands the agent's role may not make are refused,
10// each refusal appended to <loop dir>/companion.jsonl, which the loop logs as an intervention;
11// - the timeout (10.5): a Bash call with no timeout gets 10 minutes;
12// - the stop gate (10.6): a writer may not finish while the repo's gate commands fail; a gate that
13// cannot run lets it finish, logged as gate-unknown; every run is a line of companion.jsonl, the
14// last also in factory-gate.json;
15// - the handshake (10.9): <CLAUDE_CONFIG_DIR>/factory-loop/workspace/<session>.json says this
16// session has the companion, and in which process, written again every minute; the loop holds
17// every agent step while its own is missing, from another process or stale.
18//
19// It acts only on spawns that carry the slot line, so the loop's v1 agents and every other agent
20// pass untouched.
21import type { EngineInterface, Register } from 'claude-code'
22import type { WorkspaceAgent } from '../types'
23import { blockText, gateLine, gateRecord, oneLine, runGates } from './gate'
24import type { Ran } from './gate'
25import { FILE_TOOLS, agentRefusal, backgroundRefusal, bashRefusal, fileRefusal, needsHead, normalize, within } from './guard'
26import type { Guarded, Refusal } from './guard'
27import { MARKER_FILE, mismatch, parseMarker, slotLine } from './marker'
28
29const ME = 'factory-loop-workspace'
30const AGENTS = { plugin: 'factory-loop-workspace', key: 'agents' } as const
31export const BASH_TIMEOUT_MS = 600_000
32// the most a single $.process.run may take
33const RUN_MAX_MS = 600_000
34const QUICK_MS = 15_000
35// the handshake is written again this often: its `at` shows the companion runs now, and the loop
36// takes a handshake a few beats old as missing (factory-loop hooks/v2/sources-workspace.ts)
37export const HANDSHAKE_EVERY_MS = 60_000
38const HANDSHAKE_PRUNE_DAYS = 7
39
40// --- small helpers over $ ---------------------------------------------------------------------------
41
42async function run($: EngineInterface, argv: string[], opts: { cwd?: string; stdin?: string; timeoutMs?: number } = {}) {
43 return $.process.run(argv, { timeoutMs: QUICK_MS, ...opts })
44}
45
46async function readText($: EngineInterface, path: string): Promise<string | null> {
47 try {
48 return await $.fs.read(path)
49 } catch {
50 return null
51 }
52}
53
54// writeFile: written whole by rename, its folder made first.
55async function writeFile($: EngineInterface, path: string, text: string): Promise<void> {
56 const r = await run($, ['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat > "$1.tmp.$$" && mv -f "$1.tmp.$$" "$1"', 'sh', path], { stdin: text })
57 if (r.exitCode !== 0) throw new Error(`${path}: ${oneLine(r.stderr)}`)
58}
59
60async function appendFile($: EngineInterface, path: string, text: string): Promise<void> {
61 const r = await run($, ['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', path], { stdin: text })
62 if (r.exitCode !== 0) throw new Error(`${path}: ${oneLine(r.stderr)}`)
63}
64
65// configDir: CLAUDE_CONFIG_DIR, else ~/.claude, as the loop builds its own paths.
66async function configDir($: EngineInterface): Promise<string> {
67 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) || '~'}/.claude`
68 return dir.replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1')
69}
70
71// --- the handshake (10.9) ---------------------------------------------------------------------------
72
73// handshakePath: one file per session, so two sessions with the companion never write one file.
74export function handshakePath(configDir: string, session: string): string {
75 return `${configDir}/factory-loop/workspace/${session.replace(/[^A-Za-z0-9._-]/g, '_')}.json`
76}
77
78// processIdOf: the process that /proc/<pid>/stat describes, by its pid and start time, which no
79// other process has: not a child, which has its own, and not a later one given the same pid. Its
80// field 22 is counted after the command name, which may hold spaces. undefined: no stat to read.
81export function processIdOf(stat: string | null): string | undefined {
82 const pid = stat?.split(' ', 1)[0] ?? ''
83 const start = stat?.slice(stat.lastIndexOf(') ') + 2).split(' ')[19] ?? ''
84 return /^\d+$/.test(pid) && /^\d+$/.test(start) ? `${pid}:${start}` : undefined
85}
86
87// handshake: this session's file, written whole with this process's id and the time now (no id:
88// null, which the loop never takes).
89async function handshake($: EngineInterface): Promise<void> {
90 const session = await $.session.id()
91 // this process, as the loop, a plugin of the same process, reads it too
92 const proc = processIdOf(await readText($, '/proc/self/stat')) ?? null
93 const now = await $.clock.now()
94 const path = handshakePath(await configDir($), session)
95 const manifest = await readText($, `${$.plugin.root}/.claude-plugin/plugin.json`)
96 let version = 'unknown'
97 try {
98 const v = (JSON.parse(manifest ?? '{}') as { version?: unknown }).version
99 if (typeof v === 'string') version = v
100 } catch {
101 // the version is for people reading the file; the loop reads the rest
102 }
103 await writeFile($, path, `${JSON.stringify({ v: 1, session, process: proc, version, at: new Date(now).toISOString() })}\n`)
104}
105
106let beat: { cancel: () => void } | null = null
107
108// beating: the handshake now and again every minute, so its `at` stays fresh, a /clear's new
109// session gets its own file, and a pruned or deleted one comes back.
110async function beating($: EngineInterface): Promise<void> {
111 beat?.cancel()
112 beat = $.clock.every(HANDSHAKE_EVERY_MS, () => void handshake($).catch(() => undefined))
113 await handshake($).catch(() => undefined)
114}
115
116async function pruneHandshakes($: EngineInterface): Promise<void> {
117 const dir = `${await configDir($)}/factory-loop/workspace`
118 await run($, ['find', dir, '-maxdepth', '1', '-name', '*.json', '-mtime', `+${HANDSHAKE_PRUNE_DAYS}`, '-delete'])
119}
120
121// --- the map ----------------------------------------------------------------------------------------
122
123async function agentOf($: EngineInterface, agentId: string): Promise<WorkspaceAgent | null> {
124 const { value } = await $.state.get({ ...AGENTS, id: agentId })
125 return value ?? null
126}
127
128async function keep($: EngineInterface, agentId: string, agent: WorkspaceAgent): Promise<void> {
129 await $.state.set({ ...AGENTS, id: agentId }, agent)
130}
131
132// --- the slot (10.3) --------------------------------------------------------------------------------
133
134// placeOf: the slot's git dir, real path and repository roots, or why it is no slot.
135async function placeOf($: EngineInterface, slot: string): Promise<{ gitDir: string; real: string; roots: string[] } | { deny: string }> {
136 let real: string
137 try {
138 const st = await $.fs.stat(slot, { resolve: true })
139 if (st.kind !== 'dir') return { deny: `${slot} is not a folder` }
140 real = st.realPath ?? slot
141 } catch {
142 return { deny: `${slot} does not exist` }
143 }
144 const r = await run($, ['git', '-C', slot, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'])
145 const [top, gitDir, common] = r.stdout.trim().split('\n')
146 if (r.exitCode !== 0 || gitDir === undefined || common === undefined) return { deny: `${slot} is not a git checkout: ${oneLine(r.stderr)}` }
147 if (top !== real && top !== slot) return { deny: `${slot} is not the top of its checkout (${top})` }
148 const roots = [...new Set([slot, real, ...(common.endsWith('/.git') ? [common.slice(0, -'/.git'.length)] : [])])]
149 return { gitDir, real, roots }
150}
151
152// --- the guard (10.4) -------------------------------------------------------------------------------
153
154function guardedOf(a: WorkspaceAgent): Guarded {
155 return { slot: a.slot, role: a.role, branch: a.branch, roots: a.roots }
156}
157
158// placed: a path's real location, symlinks resolved through its nearest existing folder; null when
159// none can be read.
160async function placed($: EngineInterface, path: string): Promise<string | null> {
161 let rest = ''
162 let at = path
163 for (let i = 0; i < 64 && at !== ''; i++) {
164 const st = await $.fs.stat(at, { resolve: true }).catch(() => null)
165 if (st !== null && st.realPath !== undefined) return normalize(`${st.realPath}${rest}`, '/')
166 const cut = at.lastIndexOf('/')
167 rest = `${at.slice(cut)}${rest}`
168 at = at.slice(0, cut)
169 }
170 return null
171}
172
173async function headBranch($: EngineInterface, slot: string): Promise<string | null> {
174 const r = await run($, ['git', '-C', slot, 'symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => null)
175 return r !== null && r.exitCode === 0 ? r.stdout.trim() : null
176}
177
178async function refusalOf($: EngineInterface, a: WorkspaceAgent, e: Record<string, unknown>, tool: string): Promise<Refusal | null> {
179 const g = guardedOf(a)
180 const background = backgroundRefusal(tool, e)
181 if (background !== null) return background
182 if (tool === 'Agent') return agentRefusal(e)
183 if ((FILE_TOOLS as readonly string[]).includes(tool)) {
184 const raw = typeof e.file_path === 'string' ? e.file_path : typeof e.notebook_path === 'string' ? e.notebook_path : null
185 if (raw === null) return null
186 const path = normalize(raw, a.slot)
187 const lexical = fileRefusal(g, path)
188 if (lexical !== null) return lexical
189 // a symlink must not carry a write out of the slot, or into the repository for a reader
190 const real = await placed($, path)
191 if (real === null || real === path) return null
192 const refused = fileRefusal({ ...g, slot: within(path, a.slot) ? a.real : g.slot }, real)
193 return refused === null ? null : { ...refused, reason: `${raw} resolves to ${real}: ${refused.reason}` }
194 }
195 if (tool === 'Bash' && typeof e.command === 'string') {
196 const head = needsHead(e.command) ? await headBranch($, a.slot) : null
197 return bashRefusal(g, e.command, head)
198 }
199 return null
200}
201
202// logLine: one line of the companion's log, which the loop reads (v2/sources-interventions.ts).
203async function logLine($: EngineInterface, a: WorkspaceAgent, cause: string, reason: string, fields: Record<string, unknown> = {}): Promise<void> {
204 const line = { at: new Date(await $.clock.now()).toISOString(), issue: a.issue, life: a.life, seq: a.seq, cause, reason: oneLine(reason, 500), ...fields }
205 await appendFile($, `${a.loopDir}/companion.jsonl`, `${JSON.stringify(line)}\n`).catch(() => undefined)
206}
207
208// --- the stop gate (10.6) ---------------------------------------------------------------------------
209
210async function runIn($: EngineInterface, slot: string, command: string, budgetMs: number): Promise<Ran> {
211 try {
212 const r = await $.process.run(['sh', '-c', `exec 2>&1\n${command}`], { cwd: slot, timeoutMs: Math.min(budgetMs, RUN_MAX_MS) })
213 return { exitCode: r.exitCode, output: r.stdout }
214 } catch (err) {
215 return { error: err instanceof Error ? err.message : String(err) }
216 }
217}
218
219async function headSha($: EngineInterface, slot: string): Promise<string | null> {
220 const r = await run($, ['git', '-C', slot, 'rev-parse', 'HEAD']).catch(() => null)
221 return r !== null && r.exitCode === 0 ? r.stdout.trim() : null
222}
223
224// --- the hooks --------------------------------------------------------------------------------------
225
226export const register: Register = on => {
227
228 on('session.start', async ($, e, next) => {
229 const r = await next(e)
230 await beating($)
231 await pruneHandshakes($).catch(() => undefined)
232 return r
233 })
234
235 // session.start does not fire for a plugin a /reload-plugins adds to a running session: the
236 // first row or turn after such a load starts the beat instead
237 on('session.append', async ($, e, next) => {
238 if (beat === null) void beating($).catch(() => undefined)
239 return next(e)
240 }).catch(($, e, next) => next(e))
241
242 on('turn.start', async ($, e, next) => {
243 if (beat === null) void beating($).catch(() => undefined)
244 return next(e)
245 }).catch(($, e, next) => next(e))
246
247 on('agent.spawn', async ($, e, next) => {
248 // a child of an agent in a slot: the same slot and rules, and its folder is inherited
249 if (e.parentAgentId !== undefined) {
250 const parent = await agentOf($, e.parentAgentId)
251 if (parent !== null) {
252 const r = await next(e)
253 if (r.agentId !== undefined) await keep($, r.agentId, parent)
254 return r
255 }
256 }
257 const slot = slotLine(e.prompt)
258 if (slot === null) return next(e)
259 const place = await placeOf($, slot)
260 if ('deny' in place) return { deny: `${ME}: ${place.deny}` }
261 const marker = parseMarker(await readText($, `${place.gitDir}/${MARKER_FILE}`))
262 const why = mismatch(marker, e)
263 if (why !== null || marker === null) return { deny: `${ME}: ${why ?? 'no marker'}` }
264 const r = await next({ ...e, cwd: slot })
265 if (r.agentId !== undefined) {
266 const { v: _v, ...fields } = marker
267 await keep($, r.agentId, { ...fields, root: r.agentId, slot, real: place.real, roots: place.roots, gitDir: place.gitDir })
268 }
269 return r
270 }).catch(($, e, next) => (next.called ? next(e) : slotLine(e.prompt) !== null ? { deny: `${ME}: its spawn hook failed, so the agent was not started outside its slot` } : next(e)))
271
272 on('tool.call', async ($, e, next) => {
273 if (e.agentId === undefined) return next(e)
274 const a = await agentOf($, e.agentId)
275 if (a === null) return next(e)
276 const input = e as unknown as Record<string, unknown>
277 const refused = await refusalOf($, a, input, e.tool)
278 if (refused !== null) {
279 await logLine($, a, refused.cause, `${e.tool}: ${refused.reason}`)
280 return { deny: `${ME}: ${refused.reason}` }
281 }
282 if (e.tool === 'Bash' && e.timeout === undefined) return next({ ...e, timeout: BASH_TIMEOUT_MS })
283 return next(e)
284 }).catch(($, e, next) => (next.called || e.agentId === undefined ? next(e) : { deny: `${ME}: its guard failed, so the call is refused` }))
285
286 on('classic.SubagentStop', async ($, e, next) => {
287 const a = await agentOf($, e.agent_id)
288 if (a === null || a.root !== e.agent_id || a.role !== 'writer' || a.gateCommands.length === 0) return next(e)
289 const result = await runGates({ run: (command, budgetMs) => runIn($, a.slot, command, budgetMs), now: () => $.clock.now() }, a.gateCommands, a.gateTimeoutMs)
290 const head = await headSha($, a.slot)
291 const record = gateRecord(result, head, await $.clock.now(), a.gateCommands)
292 await writeFile($, `${a.gitDir}/factory-gate.json`, `${JSON.stringify(record, null, 2)}\n`).catch(() => undefined)
293 const line = gateLine(result, head)
294 await logLine($, a, line.cause, line.reason, line.fields)
295 if (result.result === 'fail') return { block: blockText(result, a.slot, a.gateCommands) }
296 return next(e)
297 }).catch(async ($, e, next) => {
298 // a gate that could not run is never passed silently
299 try {
300 const a = await agentOf($, e.agent_id)
301 if (a !== null && a.root === e.agent_id && a.role === 'writer' && a.gateCommands.length > 0) await logLine($, a, 'gate-unknown', 'the stop gate hook failed')
302 } catch {
303 // nothing more can be said
304 }
305 return next(e)
306 })
307}
308hooks/gate.ts 86 lines1// The stop gate (docs/factory-loop/v2/SPEC.md 10.6): the repo's gate commands, run in the slot one
2// after another as `a && b` would run them, inside one total budget. A failing command refuses the
3// writer's finish with the command, its exit code and the last lines of its output; a gate that
4// cannot run (its budget spent, or a command that could not start) lets the agent finish and is
5// recorded as unknown, never as a pass. register.ts supplies how a command runs and the clock.
6
7export const TAIL_LINES = 150
8
9// One command's run: its exit code and its output (standard output then standard error), or the
10// error that kept it from finishing (a timeout, a failure to start).
11export type Ran = { exitCode: number; output: string } | { error: string }
12
13export type GateResult =
14 | { result: 'pass'; ms: number }
15 | { result: 'fail'; ms: number; command: string; exitCode: number; tail: string }
16 | { result: 'unknown'; ms: number; why: string }
17
18export type GateIo = {
19 // runs one command in the slot within budgetMs
20 run: (command: string, budgetMs: number) => Promise<Ran>
21 now: () => Promise<number>
22}
23
24// The least budget a command is started with; less than this left counts as the budget spent.
25const MIN_BUDGET_MS = 1_000
26
27// runGates: the commands in order, stopping at the first that fails or cannot run.
28export async function runGates(io: GateIo, commands: readonly string[], budgetMs: number): Promise<GateResult> {
29 const t0 = await io.now()
30 const spent = async () => (await io.now()) - t0
31 for (const command of commands) {
32 const left = budgetMs - (await spent())
33 if (left < MIN_BUDGET_MS) return { result: 'unknown', ms: await spent(), why: `the gate's budget of ${Math.round(budgetMs / 1000)} s ran out before \`${command}\`` }
34 let ran: Ran
35 try {
36 ran = await io.run(command, left)
37 } catch (err) {
38 ran = { error: err instanceof Error ? err.message : String(err) }
39 }
40 if ('error' in ran) return { result: 'unknown', ms: await spent(), why: `\`${command}\` did not finish: ${oneLine(ran.error)}` }
41 if (ran.exitCode !== 0) return { result: 'fail', ms: await spent(), command, exitCode: ran.exitCode, tail: tail(ran.output) }
42 }
43 return { result: 'pass', ms: await spent() }
44}
45
46// tail: the last TAIL_LINES lines of an output, without its trailing blank lines.
47export function tail(output: string, lines = TAIL_LINES): string {
48 const all = output.replace(/\s+$/, '').split('\n')
49 return all.slice(-lines).join('\n')
50}
51
52export function oneLine(text: string, max = 300): string {
53 const line = text.replace(/\s+/g, ' ').trim()
54 return line.length > max ? `${line.slice(0, max - 3)}...` : line
55}
56
57// blockText: what the agent is told when the gate refuses its finish.
58export function blockText(r: Extract<GateResult, { result: 'fail' }>, slot: string, commands: readonly string[]): string {
59 return [
60 `The stop gate failed in your slot ${slot}: \`${r.command}\` exited ${r.exitCode}. The gates are ${commands.map(c => `\`${c}\``).join(' then ')}; your step cannot finish while one fails.`,
61 `Fix the failures, commit and push your branch, then finish again. The last ${TAIL_LINES} lines of its output:`,
62 '```',
63 r.tail,
64 '```',
65 ].join('\n')
66}
67
68// gateRecord: the result as factory-gate.json holds it in the slot's git dir, with the slot's HEAD
69// (null: not read), for the decision log and the triage's inputs.
70export function gateRecord(r: GateResult, head: string | null, at: number, commands: readonly string[]): Record<string, unknown> {
71 const base = { v: 1, at: new Date(at).toISOString(), head, commands: [...commands], result: r.result, ms: r.ms }
72 if (r.result === 'fail') return { ...base, command: r.command, exitCode: r.exitCode, tail: r.tail }
73 if (r.result === 'unknown') return { ...base, why: r.why }
74 return base
75}
76
77// gateLine: every run as one line of the companion's log, which outlives factory-gate.json (the
78// next run overwrites it), so a step's gate runs can be audited. Only `gate-unknown` is an
79// intervention (SPEC 7.2); the loop skips `gate-pass` and `gate-fail`, the gate doing its work.
80export function gateLine(r: GateResult, head: string | null): { cause: string; reason: string; fields: Record<string, unknown> } {
81 const fields = { head, ms: r.ms }
82 if (r.result === 'pass') return { cause: 'gate-pass', reason: 'the stop gate passed', fields }
83 if (r.result === 'fail') return { cause: 'gate-fail', reason: `the stop gate refused the finish: \`${r.command}\` exited ${r.exitCode}`, fields: { ...fields, command: r.command, exitCode: r.exitCode } }
84 return { cause: 'gate-unknown', reason: `the stop gate could not run: ${r.why}`, fields }
85}
86hooks/guard.ts 238 lines1// The write guard's rules (docs/factory-loop/v2/SPEC.md 10.4), pure: what a loop agent may not
2// do, by its role, with the reason the agent is told. register.ts applies them in its tool.call
3// hook to the agents the loop started in a slot, and to their children.
4//
5// - File tools: a writer writes inside its slot or outside the repository (a scratch file in
6// /tmp); a reader writes nowhere in the repository. The repository is the slot and the main
7// checkout, whose tree holds the pool's other slots.
8// - Every loop agent: no GitHub writes (the loop owns PRs, issues, labels and re-runs), no
9// no-mistakes, no axi, no push to a remote but origin, no call in the background (Bash or
10// Agent), no isolated Agent call.
11// - A writer pushes only its branch to origin, never by force, creates no branch and no worktree.
12// - A reader neither commits nor pushes.
13import { program, simpleCommands } from './shell'
14
15export type Role = 'writer' | 'reader'
16
17// What the guard knows of an agent: its slot, its role and branch (the step marker's), and the
18// repository's roots (the slot, and the main checkout when the slot is one of its worktrees).
19export type Guarded = { slot: string; role: Role; branch: string; roots: string[] }
20
21export type Cause = 'guard:write' | 'guard:push' | 'guard:command'
22export type Refusal = { cause: Cause; reason: string }
23
24export const FILE_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] as const
25
26// --- paths ------------------------------------------------------------------------------------------
27
28// normalize: an absolute path with no `.`, `..` or doubled `/`; a relative one is resolved
29// against base first (Write's file_path can arrive relative).
30export function normalize(path: string, base: string): string {
31 const out: string[] = []
32 for (const part of (path.startsWith('/') ? path : `${base}/${path}`).split('/')) {
33 if (part === '' || part === '.') continue
34 if (part === '..') out.pop()
35 else out.push(part)
36 }
37 return `/${out.join('/')}`
38}
39
40export function within(path: string, root: string): boolean {
41 return root === '/' || path === root || path.startsWith(`${root}/`)
42}
43
44// fileRefusal: a file tool's write to path (absolute, normalized), or null when it may go ahead.
45export function fileRefusal(g: Guarded, path: string): Refusal | null {
46 const inRepo = within(path, g.slot) || g.roots.some(r => within(path, r))
47 if (g.role === 'writer') {
48 if (within(path, g.slot) || !inRepo) return null
49 return { cause: 'guard:write', reason: `${path} is outside your slot ${g.slot}: write only inside it` }
50 }
51 if (!inRepo) return null
52 return { cause: 'guard:write', reason: `${path} is in the repository, and this step reads only: write nowhere in ${g.slot} or another checkout (a scratch file outside the repository is fine)` }
53}
54
55// --- commands ---------------------------------------------------------------------------------------
56
57// The gh commands that write to GitHub, by group, aliases included (`gh pr new` is `gh pr
58// create`): the loop owns these (10.4). `*`: every subcommand of the group.
59const GH_WRITES: Record<string, readonly string[] | '*'> = {
60 pr: ['create', 'new', 'edit', 'merge', 'close', 'reopen', 'review', 'update-branch', 'comment', 'ready', 'revert', 'lock', 'unlock'],
61 issue: ['create', 'new', 'edit', 'close', 'comment', 'reopen', 'delete', 'transfer', 'lock', 'unlock', 'pin', 'unpin', 'develop'],
62 label: '*',
63 run: ['rerun', 'cancel', 'delete'],
64 workflow: ['run', 'enable', 'disable'],
65 release: ['create', 'new', 'edit', 'delete', 'upload', 'delete-asset'],
66 repo: ['create', 'new', 'edit', 'rename', 'archive', 'unarchive', 'delete', 'sync', 'fork', 'deploy-key'],
67 secret: '*',
68 variable: '*',
69 cache: ['delete'],
70 gist: ['create', 'new', 'edit', 'rename', 'delete'],
71}
72const GH_VALUED = ['-R', '--repo', '-H', '--header', '-q', '--jq', '-t', '--template', '-p', '--preview', '--hostname', '--cache']
73const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']
74const FIELD_FLAGS = ['-f', '-F', '--field', '--raw-field', '--input']
75const LOOP_TOOLS = ['no-mistakes', 'axi']
76
77// The git options that come before the subcommand and take a value.
78const GIT_VALUED = ['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env']
79
80// gitArgs: a git command's subcommand and its arguments, past git's own options; null when argv
81// runs no git subcommand.
82function gitArgs(argv: readonly string[]): string[] | null {
83 let i = 1
84 while (i < argv.length && (argv[i] ?? '').startsWith('-')) i += GIT_VALUED.includes(argv[i] ?? '') ? 2 : 1
85 return i < argv.length ? argv.slice(i) : null
86}
87
88function ghArgs(argv: readonly string[]): string[] {
89 const out: string[] = []
90 for (let i = 1; i < argv.length; i++) {
91 const a = argv[i] ?? ''
92 if (GH_VALUED.includes(a)) i++
93 else if (!a.startsWith('-')) out.push(a)
94 }
95 return out
96}
97
98// ghWrite: why a gh command writes to GitHub, or null.
99function ghWrite(argv: readonly string[]): string | null {
100 const [group, sub] = ghArgs(argv)
101 if (group === 'api') {
102 let method: string | null = null
103 let fields = false
104 for (let i = 1; i < argv.length; i++) {
105 const a = argv[i] ?? ''
106 if (a === '-X' || a === '--method') method = (argv[i + 1] ?? '').toUpperCase()
107 else if (/^-X./.test(a)) method = a.slice(2).toUpperCase()
108 else if (a.startsWith('--method=')) method = a.slice('--method='.length).toUpperCase()
109 else if (FIELD_FLAGS.includes(a) || FIELD_FLAGS.some(f => a.startsWith(`${f}=`)) || /^-[fF]./.test(a)) fields = true
110 }
111 if (method !== null && WRITE_METHODS.includes(method)) return `gh api with method ${method}`
112 if (fields && method !== 'GET') return 'gh api with field flags (a write)'
113 return null
114 }
115 const writes = group === undefined ? undefined : GH_WRITES[group]
116 if (writes === undefined) return null
117 if (writes === '*' || (sub !== undefined && writes.includes(sub))) return `gh ${group}${writes === '*' ? '' : ` ${sub}`}`
118 return null
119}
120
121// A push's parts: the remote (origin when none is named), its refspecs, and its flags.
122type Push = { remote: string; refspecs: string[]; flags: string[] }
123
124const PUSH_VALUED = ['-o', '--push-option', '--receive-pack', '--exec']
125const PUSH_OK_FLAGS = ['-u', '--set-upstream', '-v', '--verbose', '-q', '--quiet', '--porcelain', '--progress', '--no-progress', '-n', '--dry-run', '--no-verify', '--verify', '--atomic']
126
127function pushOf(args: readonly string[]): Push {
128 const flags: string[] = []
129 const rest: string[] = []
130 let remote: string | null = null
131 for (let i = 1; i < args.length; i++) {
132 const a = args[i] ?? ''
133 if (a.startsWith('--repo=')) remote = a.slice('--repo='.length)
134 else if (a === '--repo') remote = args[++i] ?? ''
135 else if (PUSH_VALUED.includes(a)) i++
136 else if (/^-[a-zA-Z]{2,}$/.test(a)) flags.push(...[...a.slice(1)].map(ch => `-${ch}`))
137 else if (a.startsWith('-')) flags.push(a)
138 else rest.push(a)
139 }
140 if (remote === null) remote = rest.shift() ?? 'origin'
141 return { remote, refspecs: rest, flags }
142}
143
144// pushRefusal: why a writer's push may not go ahead, or null. head is the slot's current branch
145// (null: unknown), read only when the push names no refspec or pushes HEAD.
146function pushRefusal(g: Guarded, p: Push, head: string | null): string | null {
147 const force = p.flags.find(f => f === '-f' || f === '--force' || f.startsWith('--force-with-lease') || f === '--force-if-includes')
148 if (force !== undefined) return `a force push (${force}) is refused: push your branch ${g.branch} without force`
149 const other = p.flags.find(f => !PUSH_OK_FLAGS.includes(f))
150 if (other !== undefined) return `git push ${other} is refused: push only your branch, as git push origin ${g.branch}`
151 const specs = p.refspecs.length === 0 ? [null] : p.refspecs
152 for (const spec of specs) {
153 if (spec !== null && spec.startsWith('+')) return `a force push (${spec}) is refused: push your branch ${g.branch} without force`
154 let dst = spec === null ? 'HEAD' : spec.includes(':') ? spec.slice(spec.indexOf(':') + 1) : spec
155 if (spec !== null && spec.startsWith(':')) return `deleting a remote ref (${spec}) is refused`
156 if (dst === 'HEAD') dst = head ?? 'HEAD'
157 dst = dst.replace(/^refs\/heads\//, '')
158 if (dst !== g.branch) return `a push to ${dst === 'HEAD' ? 'the current branch, which is not known' : dst} is refused: push only your branch, as git push origin ${g.branch}`
159 }
160 return null
161}
162
163// needsHead: whether a command's pushes need the slot's current branch to be judged.
164export function needsHead(command: string): boolean {
165 return simpleCommands(command).some(argv => {
166 if (program(argv) !== 'git') return false
167 const args = gitArgs(argv)
168 if (args === null || args[0] !== 'push') return false
169 const p = pushOf(args)
170 return p.refspecs.length === 0 || p.refspecs.some(s => s === 'HEAD' || s.endsWith(':HEAD'))
171 })
172}
173
174// The git branch options that read, delete or set an upstream rather than create a branch.
175const BRANCH_READS = ['-l', '--list', '-a', '--all', '-r', '--remotes', '-v', '-vv', '--verbose', '--show-current', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--format', '--sort', '-d', '-D', '--delete', '-u', '--set-upstream-to', '--unset-upstream', '--edit-description', '--column', '--no-column', '--color', '--no-color']
176const BRANCH_MAKES = ['-m', '-M', '-c', '-C', '--move', '--copy', '-f', '--force']
177
178// branchMade: the branch command a writer may not run (creating, renaming or copying a branch, or
179// a worktree), or null.
180function branchMade(args: readonly string[]): string | null {
181 const [sub, ...rest] = args
182 if (sub === 'switch' && rest.some(a => ['-c', '-C', '--create', '--force-create', '--orphan'].includes(a) || /^--(force-)?create=/.test(a))) return 'git switch -c'
183 if (sub === 'checkout' && rest.some(a => ['-b', '-B', '--orphan'].includes(a))) return 'git checkout -b'
184 if (sub === 'branch') {
185 if (rest.some(a => BRANCH_MAKES.includes(a))) return 'git branch -m/-c'
186 const named = rest.some(a => !a.startsWith('-'))
187 if (named && !rest.some(a => BRANCH_READS.includes(a) || BRANCH_READS.some(r => a.startsWith(`${r}=`)))) return 'git branch <name>'
188 }
189 if (sub === 'worktree' && rest[0] !== 'list') return `git worktree${rest[0] === undefined ? '' : ` ${rest[0]}`}`
190 return null
191}
192
193// bashRefusal: the first rule a Bash command breaks, or null. head: the slot's current branch,
194// which the hook reads when needsHead says a push needs it.
195export function bashRefusal(g: Guarded, command: string, head: string | null = null): Refusal | null {
196 for (const argv of simpleCommands(command)) {
197 const name = program(argv)
198 if (LOOP_TOOLS.includes(name)) return { cause: 'guard:command', reason: `${name} is not used by the factory loop's agents: the loop reviews, opens the PR and merges itself` }
199 if (name === 'gh') {
200 const write = ghWrite(argv)
201 if (write !== null) return { cause: 'guard:command', reason: `${write} is refused: the factory loop owns GitHub writes (PRs, issues, labels, re-runs); say what you need in your answer instead` }
202 continue
203 }
204 if (name !== 'git') continue
205 const args = gitArgs(argv)
206 if (args === null) continue
207 if (args[0] === 'push') {
208 const p = pushOf(args)
209 if (g.role === 'reader') return { cause: 'guard:push', reason: 'git push is refused: this step reads only' }
210 if (p.remote !== 'origin') return { cause: 'guard:push', reason: `a push to ${p.remote} is refused: push only to origin, as git push origin ${g.branch}` }
211 const why = pushRefusal(g, p, head)
212 if (why !== null) return { cause: 'guard:push', reason: why }
213 continue
214 }
215 if (g.role === 'reader' && args[0] === 'commit') return { cause: 'guard:command', reason: 'git commit is refused: this step reads only' }
216 if (g.role === 'writer') {
217 const made = branchMade(args)
218 if (made !== null) return { cause: 'guard:command', reason: `${made} is refused: work on your branch ${g.branch} in your slot ${g.slot}` }
219 }
220 }
221 return null
222}
223
224// backgroundRefusal: any call in the background, Bash or Agent, so an agent neither ends its turn
225// while work it started still runs (failures/F13) nor sits polling for it (R4-O4); a command runs
226// in the foreground under its 10-minute timeout (10.5).
227export function backgroundRefusal(tool: string, input: { run_in_background?: unknown }): Refusal | null {
228 if (input.run_in_background !== true) return null
229 return { cause: 'guard:command', reason: `${tool} in the background is refused: run it in the foreground and wait for it, a command with a timeout of up to 600000 ms` }
230}
231
232// agentRefusal: an Agent call with an isolation: `remote` always runs in the background, and
233// `worktree` makes a git worktree, which a writer may not.
234export function agentRefusal(input: { isolation?: unknown }): Refusal | null {
235 if (input.isolation === undefined || input.isolation === null) return null
236 return { cause: 'guard:command', reason: `an Agent call with isolation ${JSON.stringify(input.isolation)} is refused: leave isolation out, run it in the foreground in your own folder and wait for its answer` }
237}
238hooks/marker.ts 88 lines1// The spawn's slot line and the slot's step marker (docs/factory-loop/v2/SPEC.md 10.2, 10.3),
2// pure. The loop's prompt opens with `factory-slot: <absolute slot path>`; factory-slot prepare
3// wrote the marker `factory-step.json` into that slot's git dir. The companion starts the agent
4// there only when the two agree with the spawn: the marker's issue, seq and step are the ones the
5// agent's name carries (`<step>-<N>-s<seq>-a<attempt>`, D-11), and its type is the loop's.
6import { normalize } from './guard'
7import type { Role } from './guard'
8
9export const MARKER_FILE = 'factory-step.json'
10const STEPS = ['design', 'build', 'review', 'fix', 'triage'] as const
11export type Step = (typeof STEPS)[number]
12
13// The marker as the loop writes it (plugins/factory-loop/hooks/v2/types.ts StepMarker).
14export type Marker = {
15 v: 1
16 repo: string
17 issue: number
18 life: string
19 seq: number
20 step: Step
21 role: Role
22 branch: string
23 gateCommands: string[]
24 gateTimeoutMs: number
25 loopDir: string
26}
27
28// slotLine: the slot the prompt's first line names, or null when it names none. The path must be
29// absolute and plain (no `.` or `..` parts, no doubled or trailing `/`).
30export function slotLine(prompt: string): string | null {
31 const first = (prompt.split('\n', 1)[0] ?? '').replace(/\r$/, '')
32 const m = /^factory-slot: (\/.*\S)\s*$/.exec(first)
33 const path = m?.[1]
34 if (path === undefined) return null
35 return normalize(path, '/') === path ? path : null
36}
37
38const int = (v: unknown, min: number): v is number => typeof v === 'number' && Number.isInteger(v) && v >= min
39const str = (v: unknown): v is string => typeof v === 'string' && v !== ''
40
41// parseMarker: the marker file's text as a Marker; null when it does not read.
42export function parseMarker(text: string | null): Marker | null {
43 if (text === null) return null
44 let o: Record<string, unknown>
45 try {
46 const v: unknown = JSON.parse(text)
47 if (v === null || typeof v !== 'object' || Array.isArray(v)) return null
48 o = v as Record<string, unknown>
49 } catch {
50 return null
51 }
52 if (o.v !== 1 || !str(o.repo) || !int(o.issue, 1) || !str(o.life) || !/^[0-9a-z]+$/.test(o.life) || !int(o.seq, 0)) return null
53 if (!STEPS.includes(o.step as Step) || (o.role !== 'writer' && o.role !== 'reader') || !str(o.branch)) return null
54 if (!Array.isArray(o.gateCommands) || !o.gateCommands.every(c => typeof c === 'string')) return null
55 if (!int(o.gateTimeoutMs, 1) || !str(o.loopDir) || !o.loopDir.startsWith('/')) return null
56 return {
57 v: 1,
58 repo: o.repo,
59 issue: o.issue,
60 life: o.life,
61 seq: o.seq,
62 step: o.step as Step,
63 role: o.role,
64 branch: o.branch,
65 gateCommands: (o.gateCommands as string[]).map(c => c.trim()).filter(c => c !== ''),
66 gateTimeoutMs: o.gateTimeoutMs,
67 loopDir: o.loopDir.replace(/\/+$/, ''),
68 }
69}
70
71// nameParts: what the loop puts in an agent step's name (v2/steps.ts): `<step>-<N>-s<seq>-a<k>`.
72export function nameParts(name: string | undefined): { step: string; issue: number; seq: number } | null {
73 const m = /^([a-z]+)-(\d+)-s(\d+)-a\d+$/.exec(name ?? '')
74 return m === null ? null : { step: m[1] ?? '', issue: Number(m[2]), seq: Number(m[3]) }
75}
76
77// mismatch: why the marker does not belong to this spawn, or null when it does.
78export function mismatch(marker: Marker | null, spawn: { name?: string; subagentType: string }): string | null {
79 if (marker === null) return `the slot has no readable step marker (${MARKER_FILE} in its git dir)`
80 if (!spawn.subagentType.startsWith('factory-loop:')) return `${spawn.subagentType} is not one of the factory loop's agents`
81 const n = nameParts(spawn.name)
82 if (n === null) return `the agent's name ${spawn.name ?? '(none)'} does not say its step, issue and seq`
83 if (n.issue !== marker.issue || n.seq !== marker.seq || n.step !== marker.step) {
84 return `the slot is prepared for ${marker.step} of #${marker.issue} seq ${marker.seq}, not ${n.step} of #${n.issue} seq ${n.seq}`
85 }
86 return null
87}
88hooks/shell.ts 315 lines1// A Bash command line as the simple commands it runs, each as its words, for the write guard
2// (docs/factory-loop/v2/SPEC.md 10.4). Best effort by design: the guard matches commands, not
3// effects. It splits on the shell's separators (; & && | || newlines, subshells, $( ) and
4// backquotes), honours quotes and escapes, skips comments and here-document text, drops
5// redirections, and looks through the wrappers that run another command (env, sudo, timeout,
6// xargs, bash -c, eval and the like), so `cd x && FOO=1 timeout 60 git push -f` still reads as a
7// git push. A command substitution the shell runs inside double quotes or in an unquoted
8// here-document (`echo "$(git push -f)"`) is read as a command line of its own.
9
10// One word as read: its text, and whether any of it was quoted (a quoted `>` is no redirection).
11type Word = { text: string; quoted: boolean }
12
13const MAX_DEPTH = 3
14
15// simpleCommands: every simple command of a line, outermost first, each as its argv.
16export function simpleCommands(line: string, depth = 0): string[][] {
17 const out: string[][] = []
18 const { cmds, subs } = split(line)
19 for (const words of cmds) out.push(...unwrap(dropRedirects(words), depth))
20 for (const sub of subs) out.push(...simpleCommands(sub, depth + 1))
21 return out
22}
23
24// program: a command's program name, without its folder (`/usr/bin/git` is `git`).
25export function program(argv: readonly string[]): string {
26 return (argv[0] ?? '').replace(/^.*\//, '')
27}
28
29// --- splitting ------------------------------------------------------------------------------------
30
31// substEnd: the index of the `)` that closes a `$(` whose body starts at i, past nested
32// parentheses, quotes and backquotes; -1 when it never closes.
33function substEnd(s: string, i: number): number {
34 let depth = 1
35 while (i < s.length) {
36 const c = s[i]
37 if (c === '\\') i += 2
38 else if (c === "'") {
39 const close = s.indexOf("'", i + 1)
40 if (close === -1) return -1
41 i = close + 1
42 } else if (c === '"' || c === '`') {
43 const close = c === '"' ? dquoteEnd(s, i + 1) : backquoteEnd(s, i + 1)
44 if (close === -1) return -1
45 i = close + 1
46 } else {
47 if (c === '(') depth++
48 else if (c === ')' && --depth === 0) return i
49 i++
50 }
51 }
52 return -1
53}
54
55// backquoteEnd: the index of the backquote that closes one whose body starts at i; -1 when none.
56function backquoteEnd(s: string, i: number): number {
57 while (i < s.length) {
58 if (s[i] === '\\') i += 2
59 else if (s[i] === '`') return i
60 else i++
61 }
62 return -1
63}
64
65// dquoteEnd: the index of the `"` that closes a double-quoted string whose text starts at i, past
66// the substitutions in it (which may hold quotes of their own); -1 when none.
67function dquoteEnd(s: string, i: number): number {
68 while (i < s.length) {
69 const c = s[i]
70 if (c === '\\') i += 2
71 else if (c === '"') return i
72 else if (c === '$' && s[i + 1] === '(') {
73 const close = substEnd(s, i + 2)
74 if (close === -1) return -1
75 i = close + 1
76 } else if (c === '`') {
77 const close = backquoteEnd(s, i + 1)
78 if (close === -1) return -1
79 i = close + 1
80 } else i++
81 }
82 return -1
83}
84
85// expansions: text the shell expands (a double-quoted string's, or an unquoted here-document's) as
86// the word it reads as, and the bodies of the command substitutions in it, which the shell runs.
87// escapes: the characters a backslash escapes in this text.
88function expansions(text: string, escapes: string): { text: string; subs: string[] } {
89 let out = ''
90 const subs: string[] = []
91 let j = 0
92 while (j < text.length) {
93 const c = text[j] ?? ''
94 if (c === '\\' && escapes.includes(text[j + 1] ?? '')) {
95 if (text[j + 1] !== '\n') out += text[j + 1]
96 j += 2
97 } else if (c === '$' && text[j + 1] === '(') {
98 const close = substEnd(text, j + 2)
99 const stop = close === -1 ? text.length : close
100 subs.push(text.slice(j + 2, stop))
101 out += text.slice(j, stop + 1)
102 j = stop + 1
103 } else if (c === '`') {
104 const close = backquoteEnd(text, j + 1)
105 const stop = close === -1 ? text.length : close
106 // in backquotes a backslash escapes only $, ` and itself
107 subs.push(text.slice(j + 1, stop).replace(/\\([$`\\])/g, '$1'))
108 out += text.slice(j, stop + 1)
109 j = stop + 1
110 } else {
111 out += c
112 j++
113 }
114 }
115 return { text: out, subs }
116}
117
118// split: a line's simple commands as words, and the bodies of the command substitutions the shell
119// runs from inside double quotes or unquoted here-documents (an unquoted one splits in place).
120function split(line: string): { cmds: Word[][]; subs: string[] } {
121 const cmds: Word[][] = []
122 const subs: string[] = []
123 let words: Word[] = []
124 // the word being read, if any (a holder, so the closures below can change it)
125 const at: { word: Word | null } = { word: null }
126 // expand: an unquoted delimiter, so the body's substitutions run
127 const heredocs: { delim: string; strip: boolean; expand: boolean }[] = []
128 const add = (text: string, quoted = false) => {
129 at.word ??= { text: '', quoted: false }
130 at.word.text += text
131 if (quoted) at.word.quoted = true
132 }
133 const flush = () => {
134 if (at.word !== null) words.push(at.word)
135 at.word = null
136 }
137 const end = () => {
138 flush()
139 if (words.length > 0) cmds.push(words)
140 words = []
141 }
142 let i = 0
143 while (i < line.length) {
144 const c = line[i] ?? ''
145 const next = line[i + 1] ?? ''
146 if (c === '\n') {
147 end()
148 i++
149 // the here-documents opened on that line: their bodies are text, not commands, but an
150 // unquoted delimiter's body runs its substitutions
151 while (heredocs.length > 0) {
152 const h = heredocs.shift() as { delim: string; strip: boolean; expand: boolean }
153 const body: string[] = []
154 while (i < line.length) {
155 const nl = line.indexOf('\n', i)
156 const text = line.slice(i, nl === -1 ? line.length : nl)
157 i = nl === -1 ? line.length : nl + 1
158 if ((h.strip ? text.replace(/^\t+/, '') : text) === h.delim) break
159 body.push(text)
160 }
161 if (h.expand) subs.push(...expansions(body.join('\n'), '$`\\\n').subs)
162 }
163 continue
164 }
165 if (c === '#' && at.word === null) {
166 while (i < line.length && line[i] !== '\n') i++
167 continue
168 }
169 if (c === '\\') {
170 if (next === '\n') i += 2
171 else {
172 add(next, true)
173 i += 2
174 }
175 continue
176 }
177 if (c === "'") {
178 const close = line.indexOf("'", i + 1)
179 const stop = close === -1 ? line.length : close
180 add(line.slice(i + 1, stop), true)
181 i = stop + 1
182 continue
183 }
184 if (c === '"') {
185 const close = dquoteEnd(line, i + 1)
186 const stop = close === -1 ? line.length : close
187 const quoted = expansions(line.slice(i + 1, stop), '"\\$`\n')
188 add(quoted.text, true)
189 subs.push(...quoted.subs)
190 i = stop + 1
191 continue
192 }
193 if (c === ' ' || c === '\t') {
194 flush()
195 i++
196 continue
197 }
198 if (c === '<' && next === '<' && line[i + 2] !== '<') {
199 // a here-document: <<DELIM or <<-DELIM, the delimiter quoted or not
200 flush()
201 let j = i + 2
202 const strip = line[j] === '-'
203 if (strip) j++
204 while (line[j] === ' ' || line[j] === '\t') j++
205 let delim = ''
206 let expand = true
207 while (j < line.length && !' \t\n;&|()<>'.includes(line[j] ?? '')) {
208 if (line[j] !== "'" && line[j] !== '"' && line[j] !== '\\') delim += line[j]
209 else expand = false
210 j++
211 }
212 if (delim !== '') heredocs.push({ delim, strip, expand })
213 i = j
214 continue
215 }
216 if (c === '&') {
217 // part of a redirection (2>&1, >&2, &>file), else a separator (& and &&)
218 if (next === '>' || at.word?.text.endsWith('>') || at.word?.text.endsWith('<')) {
219 add(c)
220 i++
221 continue
222 }
223 end()
224 i += next === '&' ? 2 : 1
225 continue
226 }
227 if (c === ';' || c === '|' || c === '(' || c === ')' || c === '`') {
228 end()
229 i += c === '|' && (next === '|' || next === '&') ? 2 : 1
230 continue
231 }
232 if (c === '$' && next === '(') {
233 end()
234 i += 2
235 continue
236 }
237 add(c)
238 i++
239 }
240 end()
241 return { cmds, subs }
242}
243
244// dropRedirects: the words that are redirections (>f, 2>&1, < f, &>f), and the target that
245// follows an operator standing alone.
246function dropRedirects(words: Word[]): Word[] {
247 const out: Word[] = []
248 for (let i = 0; i < words.length; i++) {
249 const w = words[i]
250 if (w === undefined) continue
251 const m = w.quoted ? null : /^(\d*|&)(>>?|<|>&|<&|>\|)(.*)$/.exec(w.text)
252 if (m === null) out.push(w)
253 else if (m[3] === '') i++
254 }
255 return out
256}
257
258// --- wrappers ---------------------------------------------------------------------------------------
259
260// Words that open a compound command and run the command after them.
261const KEYWORDS = new Set(['{', '}', '!', 'if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'time'])
262
263// The wrappers that run the command after their own options, and those options that take a value.
264const WRAPPERS: Record<string, { valued: string[]; positional: number }> = {
265 env: { valued: ['-u', '--unset', '-C', '--chdir', '-S', '--split-string'], positional: 0 },
266 sudo: { valued: ['-u', '-g', '-C', '-D', '-h', '-p', '-U', '-r', '-t'], positional: 0 },
267 command: { valued: [], positional: 0 },
268 exec: { valued: ['-a'], positional: 0 },
269 nohup: { valued: [], positional: 0 },
270 nice: { valued: ['-n', '--adjustment'], positional: 0 },
271 stdbuf: { valued: ['-i', '-o', '-e'], positional: 0 },
272 timeout: { valued: ['-s', '--signal', '-k', '--kill-after'], positional: 1 },
273 xargs: { valued: ['-I', '-i', '-n', '-P', '-d', '-L', '-l', '-s', '-a', '-E', '-e', '--max-args', '--max-procs', '--delimiter', '--arg-file', '--replace'], positional: 0 },
274}
275
276const SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh'])
277
278// unwrap: the command a simple command runs, through assignments, keywords and wrappers; a shell's
279// -c script and eval's words are read as command lines of their own.
280function unwrap(words: Word[], depth: number): string[][] {
281 let argv = words.map(w => w.text)
282 for (;;) {
283 while (argv.length > 0 && (KEYWORDS.has(argv[0] ?? '') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0] ?? ''))) argv = argv.slice(1)
284 if (argv.length === 0) return []
285 const name = program(argv)
286 const wrapper = WRAPPERS[name]
287 if (wrapper !== undefined) {
288 let i = 1
289 let positional = wrapper.positional
290 while (i < argv.length) {
291 const a = argv[i] ?? ''
292 if (a === '--') {
293 i++
294 break
295 }
296 if (name === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) i++
297 else if (a.startsWith('-') && a.length > 1) i += wrapper.valued.includes(a) ? 2 : 1
298 else if (positional > 0) {
299 positional--
300 i++
301 } else break
302 }
303 argv = argv.slice(i)
304 continue
305 }
306 if (depth < MAX_DEPTH && SHELLS.has(name)) {
307 const at = argv.findIndex((a, i) => i > 0 && /^-[a-z]*c[a-z]*$/.test(a))
308 const script = at === -1 ? undefined : argv[at + 1]
309 if (script !== undefined) return simpleCommands(script, depth + 1)
310 }
311 if (depth < MAX_DEPTH && name === 'eval') return simpleCommands(argv.slice(1).join(' '), depth + 1)
312 return [argv]
313 }
314}
315types/index.d.ts 32 lines1// What the companion keeps for the session: each agent the factory loop started in a slot, and
2// each child such an agent started, by agent id, so the guard and the stop gate find them after a
3// reload of the module.
4export type WorkspaceAgent = {
5 // the id of the agent the loop started: this agent's own, or its parent's for a child
6 root: string
7 // the slot as the spawn named it, and its real path (symlinks resolved)
8 slot: string
9 real: string
10 // the repository's roots: the slot and, when the slot is a worktree of one, the main checkout
11 roots: string[]
12 // the slot's own git dir, which holds the step marker and the gate's result
13 gitDir: string
14 // from the step marker (factory-step.json)
15 repo: string
16 issue: number
17 life: string
18 seq: number
19 step: string
20 role: 'writer' | 'reader'
21 branch: string
22 gateCommands: string[]
23 gateTimeoutMs: number
24 loopDir: string
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'factory-loop-workspace': { agents: StateFamily<WorkspaceAgent> }
30 }
31}
32