SLOPSHOPPER

Factory loop workspace

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…

newguardprocesstimeragents
★ 2v0.40.0no licenseupdated 2026-10-16cbundy/dev-system/plugins/factory-loop-workspace
A shopper browsing a rack in a slop shop
README

factory-loop-workspace

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.

What it does

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.

HookWhat it does
agent.spawnReads 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.callThe 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.SubagentStopThe 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.startThe 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.

What it writes

  • <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 handshake file above. Files older than 7 days are pruned at session start.

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.

Enable it

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.

Develop it

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.

Source 6 files
hooks/register.ts 308 lines
1// 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}
308
hooks/gate.ts 86 lines
1// 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}
86
hooks/guard.ts 238 lines
1// 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}
238
hooks/marker.ts 88 lines
1// 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}
88
hooks/shell.ts 315 lines
1// 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}
315
types/index.d.ts 32 lines
1// 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