Agent runtime (SWE-ReX)
The agent runtime is the layer beneath the engine that actually spawns coding
agents. When the engine runs a step it selects exactly one runtime implementation
based on the resolved locus setting, then drives exactly one agent through that
runtime for the transition. The runtime is injected into the engine as a seam so
tests can supply a fake without starting Python.
Defined in src/core/batch/engine/runtime/.
Runtime contract
Defined in src/core/batch/engine/runtime/contract.ts.
interface AgentEvent {
kind: 'stdout' | 'exit' | 'error';
/** Present for kind 'stdout' — one line of agent output (no trailing newline). */
line?: string;
/** Present for kind 'exit' — the agent's process exit code. */
exitCode?: number;
/** Present for kind 'error' — an actionable failure message. */
message?: string;
}
type AgentRuntime = (
req: AgentSpawnRequest,
onEvent: (e: AgentEvent) => void
) => Promise<AgentSpawnResult>;
The spawn request and result types are defined in
src/core/batch/engine/agent.ts:
interface AgentSpawnRequest {
command: string;
args: string[];
/** Instructions passed to the agent via stdin. */
instructions: string;
cwd: string;
env: NodeJS.ProcessEnv;
}
interface AgentSpawnResult {
/** Process exit code (null if killed by signal). */
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}
AgentRuntime streams AgentEvents live to the onEvent callback as the agent
runs and simultaneously accumulates the full AgentSpawnResult (stdout
newline-joined, exitCode from the exit event). A bootstrap failure or sidecar
error event resolves with a non-zero exitCode and the message in stderr —
no new outcome states — so the engine maps it to blocked/failed and the step
remains resumable.
SWE-ReX sidecar
For the local and docker loci, ratchet bootstraps an isolated Python sidecar
(sidecar.py) that wraps SWE-ReX. The Node side manages the sidecar lifecycle;
the Python side drives the SWE-ReX deployment.
Bootstrap (rex-bootstrap.ts)
bootstrapRexRuntime provisions a ratchet-owned virtual environment on first use
and returns a ResolvedLaunch (the command, args, and env to spawn the sidecar).
It is lazy and idempotent: a ready venv is reused without a rebuild.
interface ResolvedLaunch {
/** The venv's Python interpreter (absolute path). */
command: string;
/** Arguments — the resolved sidecar.py path. */
args: string[];
/** Environment for the sidecar (REX_* passthrough + venv on PATH). */
env: NodeJS.ProcessEnv;
}
Bootstrap behavior:
- The venv lives at
$XDG_CACHE_HOME/ratchet/rex/venv(falling back to~/.cache/ratchet/rex/venvwhenXDG_CACHE_HOMEis unset). It never touches the user's global Python environment. - A JSON readiness marker (
.ratchet-rex-ready.json) inside the venv dir is written last, after a successful import check. A missing or stale marker (wrongsweRexVersionor missing required extras) triggers a full rebuild after clearing the directory so no partial venv is mistaken for ready. - The pinned swe-rex version is
1.4.0(SWE_REX_VERSION). A version change forces a rebuild. uvis preferred for creating the venv and installing packages. Whenuvis not on PATH, bootstrap falls back topython -m venvand the venv's own pip.- Python candidates are probed in order —
python3,python,python3.12,python3.11,python3.10— and the first interpreter that reports Python= 3.10 is used. A
pythonOverrideskips the probe. - After install, bootstrap verifies
import swerexsucceeds from the venv interpreter before writing the marker. For the docker locus it additionally verifiesimport swerex.deployment.docker. - The venv's
bindirectory is prepended toPATHandVIRTUAL_ENVis set in the sidecar's environment.
Environment variables threaded to the sidecar:
| Variable | Set when | Value |
|---|---|---|
REX_LOCUS | always | 'local' or 'docker' |
REX_WORKDIR | always | project root (local) or /workspace (docker) |
REX_IMAGE | docker only | configured image, or DEFAULT_DOCKER_IMAGE (python:3.12) |
REX_MOUNT_HOST | docker only | project root (host path bind-mounted into the container) |
REX_MOUNT_CONTAINER | docker only | /workspace (in-container mount point) |
Sidecar process (sidecar.py)
The sidecar is a Python script driven over a newline-delimited JSON protocol on stdio. It starts a SWE-ReX deployment (local or docker), runs shell commands through it on demand, streams output back, and shuts down cleanly.
The sidecar suppresses SWE-ReX's Rich console logger before import
(SWE_REX_LOG_STREAM_LEVEL=CRITICAL) so no non-JSON output contaminates the
protocol channel.
Node → sidecar (stdin):
| Message | Effect |
|---|---|
{"op":"run","id":N,"command":"<shell>"} | Launch the shell command, stream stdout, emit exit. |
{"op":"shutdown"} | Stop the deployment and exit 0. |
Sidecar → Node (stdout):
| Event | Emitted when |
|---|---|
{"event":"ready","locus":"local"|"docker"} | Deployment started; emitted once before any run op. |
{"event":"stdout","id":N,"line":"..."} | One complete line of command output. |
{"event":"exit","id":N,"exit_code":N} | Command finished; exactly once per run op. |
{"event":"closed"} | Clean shutdown complete. |
{"event":"error","id":N|null,"message":"...",...} | Any caught exception. |
Streaming model: the sidecar launches each shell command detached to a per-run
logfile and tail-polls that logfile at 300 ms intervals (POLL_INTERVAL = 0.3)
via the SWE-ReX execute() API. It advances a byte cursor over the logfile so
only new bytes are read each poll, and emits complete lines as stdout events.
The exit sentinel file signals completion; the sidecar drains any final bytes
before emitting the exit event.
Execution loci
The locus setting selects the runtime implementation. The default locus is
local.
local — RexSidecarRuntime (rex-sidecar-runtime.ts)
The local runtime bootstraps the SWE-ReX sidecar, spawns it as a child process, and drives the JSON-lines protocol described above.
Prompt delivery: the agent's instructions are written to a temporary prompt file
at .ratchet/batches/<batch>/.run/<id>/prompt.txt on the host. The run command
sent to the sidecar is cd <cwd>; cat <promptfile> | <agent argv>. The prompt
file is removed after the run (in a finally block).
The overall run timeout defaults to 600000 ms (10 minutes) and is configurable
via batch.agentTimeoutMs or the RATCHET_AGENT_TIMEOUT_MS environment variable
(see Per-agent timeout below). On completion or timeout the
sidecar receives SIGTERM followed (after a 2 s grace) by SIGKILL if it has not
exited.
docker — RexSidecarRuntime with DockerDeployment
The docker locus runs the same local sidecar but selects DockerDeployment
(via REX_LOCUS=docker). The project root is bind-mounted into the container
at /workspace (DOCKER_MOUNT_CONTAINER).
Additional behavior specific to the docker locus:
- A
docker infopre-flight runs before any venv work. A non-zero result throwsRexBootstrapErrorimmediately, so the run never hangs on SWE-ReX's own startup timeout. - The venv must carry the
dockerextra, which installsaiohttpexplicitly. (swe-rex1.4.0 does not declareaiohttpin its package metadata, butswerex.deployment.dockerimports it at runtime.) A local-only venv is rebuilt the first time the docker locus is requested. REX_WORKDIRis set to/workspace(the in-container path), not the host project root. The prompt file is written on the host and its path is translated to the in-container equivalent before it is passed to the sidecar.REX_IMAGEis set to the configuredimage, orDEFAULT_DOCKER_IMAGE(python:3.12) when none is configured.
remote — RexRemoteRuntime (rex-remote-runtime.ts)
The remote runtime drives an external swerex-remote server over its REST API
using the Node global fetch. No local Python sidecar is started; the Python
lives on the server.
Required settings: host, port, and authToken. The auth token is sent as
the X-API-Key request header and is never printed in any error message.
Transport scheme selection:
- A bare loopback host (
localhost,127.x.x.x,::1) defaults tohttp. - A bare non-local host defaults to
https. - An explicit
https://prefix is honored. - An explicit
http://prefix to a non-local host is refused unlessinsecure: trueis set in the settings.
The remote runtime reproduces the sidecar's tail-poll streaming over REST:
GET /is_alive— health check with a short per-request timeout (30 s default).POST /create_session— create the bash session.- Write the prompt onto the server filesystem via
POST /execute(mkdir +POST /write_file). POST /execute(non-blocking) — detach the agent command to a logfile + exit sentinel.POST /executein a poll loop (300 ms default) —tail -c +<offset+1>to advance a byte cursor; emitstdoutevents as complete lines arrive.- Read the exit sentinel. Drain final bytes, emit
exit, then close the session and runtime (POST /close_session,POST /close).
The overall run timeout defaults to 600000 ms (10 minutes) and is configurable
via batch.agentTimeoutMs or the RATCHET_AGENT_TIMEOUT_MS environment variable
(see Per-agent timeout below). A swerexception body on any
response is surfaced as an actionable AgentEvent{kind:'error'} with the engine
mapped to blocked/failed and no hang.
Per-agent timeout
Every runtime applies a per-agent timeout — the guard against a hung agent. It is
locus-uniform and agent-neutral: the same resolved value applies to local,
docker, and remote, and to every coding agent. The built-in default is
600000 ms (10 minutes); each runtime keeps applying that default whenever no
value is configured.
selectRuntime resolves the effective timeout once (via resolveAgentTimeoutMs)
and threads it into the chosen runtime's timeoutMs option, omitting the option
entirely when nothing is set so the runtime's own default applies. Resolution
precedence is env > manifest > project config > built-in default:
RATCHET_AGENT_TIMEOUT_MSwins when it parses to a positive integer. A zero, negative, non-numeric, or empty value is ignored (a typo never shortens or removes the guard) and resolution falls through to the config layers.- Otherwise
batch.agentTimeoutMsfrom the per-change manifest, then the project config (.ratchet/config.yaml). - Otherwise the runtime's built-in
DEFAULT_TIMEOUT_MS(600000ms).
Agent adapters
An adapter knows how to build the spawn request for one coding agent. The engine
resolves the adapter by name from the resolved settings before any spawn, and
throws UnknownAgentError (listing available adapters) when the name is not
registered.
The default agent when no adapter is configured is claude.
Built-in adapters:
| Agent | Command | Base args | Stream-JSON |
|---|---|---|---|
claude | claude binary | -p --output-format stream-json --verbose --include-partial-messages | yes |
codex | codex binary | exec - | no |
gemini | gemini binary | -p | no |
cursor | cursor binary | -p | no |
opencode | opencode binary | run --format json | yes |
All adapters pass the agent instructions on stdin. The binary name for each
adapter is read from the AI_TOOLS registry in src/core/config.ts
(agentBinary field); the adapter code does not hardcode binary names. The same
registry drives ratchet doctor's PATH checks, so the two cannot drift.
Permission flags resolved from the active policy (see Agent permissions below) are appended to the base args after the adapter's own argv.
Skill-in-spawn-locus guarantee
Defined in src/core/batch/engine/skill-locus.ts.
Engine-spawned change-verb agents delegate the lifecycle to the canonical ratchet
skill rather than re-authoring it inline (see the delegated-lifecycle standard):
the spawned-agent prompt tells a headless agent to invoke /rct:<transition> <change>. That delegation is only safe if the rct command for the transition
actually exists in the locus where the agent runs. Before spawning, the engine
therefore guarantees that command is present — rendering it when absent, verifying
it when present, and failing loudly (never spawning) when it cannot. This is a
render-or-fail precondition, evaluated inside runChangeStep before the
spawn request is built and before the runtime is selected/invoked.
The engine drives three spawn kinds, all routed through this one guarantee
(ensureCommandInSpawnLocus, with ensureSkillInSpawnLocus as the per-change
wrapper):
- Change-scoped propose/apply/verify spawns (
runChangeStep) — delegate to/rct:<transition> <change>. - Phase-scoped decomposition spawns (
runDecompositionStep) — delegate to/rct:decompose-phase <phase>to author a reachable empty phase's concrete change intents intobatch.yaml. The decomposition spawn routes through thedecomposeagent stage (soagent.decomposeor a scalaragentselects which agent runs it), and the guarantee renders/verifies thedecompose-phasecommand for that stage the same way before that spawn. - Group-scoped PR spawns (
runPrStep) — delegate to/rct:open-prto commit the prior stage agents' accumulated work and open one pull request per fired group boundary (the whole batch underwhole-batch, or one stacked PR per phase / change underper-phase/per-change). The guarantee renders/verifies theopen-prcommand for theprstage the same way before that spawn (see PR step below).
The prompt itself now delegates to that command: buildAgentInstructions
emits the /rct:<transition> <change> invocation instead of a hand-built inline
recipe — see Agent instructions below. This guarantee is
its precondition: it ensures the command the prompt names actually exists in the
spawn locus before the agent is told to run it.
The caller's -m guidance and any resolved resume answer are injected as
ARGUMENTS of that /rct:<transition> <change> invocation — handed to the skill
as $ARGUMENTS rather than floated off in a detached block (see
Agent instructions below for the argument-injection
contract).
Step kind → rct command
The forced transition selects exactly its own canonical rct command, kept in one
place (rctCommandIdForTransition) so it stays aligned with the prompt-delegation
change; the phase-scoped decomposition step uses its own single-source id
(DECOMPOSE_COMMAND_ID), and the whole-batch PR step uses PR_OPEN_COMMAND_ID:
| Step kind | rct command |
|---|---|
propose | /rct:propose |
apply | /rct:apply |
verify | /rct:verify |
decompose (phase) | /rct:decompose-phase |
open-pr (per-group PR) | /rct:open-pr |
open-pr is the command id (what instruction the PR agent runs), single-sourced in
PR_OPEN_COMMAND_ID beside DECOMPOSE_COMMAND_ID — deliberately distinct from the
pr routable agent stage (which agent runs the PR step). The same render-or-fail
guarantee renders/verifies the open-pr command into the spawn locus exactly as it
does the others, via ensureCommandInSpawnLocus(PR_OPEN_COMMAND_ID, …, 'pr'), so it
is present before the PR agent is told to run it. The engine step that drives this
command at each fired group boundary is runPrStep (see PR step
below).
Per-agent command path
The command file path is resolved through the command-generation registry
(src/core/command-generation/), never a hard-coded single-agent path. The
guarantee resolves the configured spawn agent's adapter (default claude) and
computes the path via adapter.getFilePath(<command-id>), then renders the file
from the shared command definition (getCommandContents → adapter.formatFile)
— the same content ratchet init writes, so there is one author of the lifecycle
text. The applicable set is the batch-engine spawnable agents (the BUILTIN_ADAPTERS):
| Agent | Rendered command path (apply) |
|---|---|
claude | .claude/commands/rct/apply.md |
cursor | .cursor/commands/rct-apply.md |
gemini | .gemini/commands/rct-apply.md |
codex | <CODEX_HOME>/prompts/rct-apply.md (global-scoped, absolute) |
opencode | .opencode/commands/rct-apply.md |
github-copilot has a command-generation adapter but no batch-engine spawn
adapter, so resolveAdapter rejects it with UnknownAgentError before any
spawn — it can never be the spawn agent and is out of scope for this spawn-time
guarantee. (opencode is now a full spawnable agent and is in scope.)
Behavior and the bootstrap-failure contract
ensureSkillInSpawnLocus(ctx, projectRoot, deps) (side effects through an
injectable exists/writeText seam, mirroring rex-bootstrap.ts):
- Absent → render the command from the shared definition into the spawn locus, then spawn.
- Present → verify and leave the existing file untouched (no re-render), then spawn.
- Unrenderable locus (
remote— the agent runs in a remote workdir the engine does not control on disk) → throwSkillLocusError.localanddockerare renderable (docker bind-mounts the project root, so a file rendered there is visible in the container). - Render/write failure → throw
SkillLocusErrornaming the failing path and the underlying detail.
A SkillLocusError short-circuits the step to a blocked/failed StepResult
carrying the actionable message — the same bootstrap-error contract as
UnknownAgentError / the locus failingRuntime path: no agent is spawned, the
message is surfaced live on the engine's line sink, no new outcome state is
introduced, and the step stays resumable. The message names the missing rct
command, the spawn locus, and the remedy, and never instructs the agent to invoke
a skill it cannot run.
Spawn flow
The guarantee adds no user-facing command, flag, or config key — it is internal
engine behavior — so README.md needs no edit.
PR step
Overview
At each fired group boundary, runPrStep gates on the active grouping mode
(isPrGroupingActive) and the group's own already-opened flag
(hasJournaledPrForGroup), then spawns one pr-stage agent that delegates to
/rct:open-pr to commit the accumulated work in the repo's git log style, open
exactly one PR to the group's stacked base branch, and record the outcome in
run-state keyed by group — with off/unset and an already-opened group both
short-circuiting to nothing-ready.
Defined in src/core/batch/engine/engine.ts (runPrStep).
runPrStep spawns exactly one PR agent that delegates to /rct:open-pr to
commit the prior stage agents' accumulated work in the repository's own git log
commit style (defaulting to semantic / Conventional Commits) and open a single
pull request from the work branch to its base branch, using whichever forge CLI the
environment provides. It is the structural twin of runDecompositionStep: it takes
the per-batch lock, guarantees the shared command in the spawn locus, builds
instructions, and hands the request to the shared spawnAndMap tail — the engine
spawns one agent and JOURNALS the outcome, but never re-authors the
commit/push/PR-open steps inline (those live once in the /rct:open-pr body). The
resolved work/base branch are supplied to the engine as data (PrStepContext)
and injected into the prompt as the open-pr "Input" — the engine never derives
which git branch is base/work.
Under a stacked grouping mode (per-phase / per-change) the host loop calls
runPrStep once per detected boundary, passing that group's boundary and its
resolved stacked base: group 0 targets the batch base branch, and group N (N ≥ 1)
targets group N-1's branch, so each PR's diff stays scoped to its own unit while
dependent code still compiles. The two policies that resolve "where are the
boundaries" (detectPrGroupBoundaries) and "what does each group stack on"
(selectStackedBases) remain the single home of those rules; runPrStep consumes
their output as data (PrStepContext.boundary + baseBranch/workBranch) and
never re-derives boundary or stacking rules inline. A whole-batch step carries no
boundary and behaves exactly as before.
- Surfaced and routed by
batch apply.batch applyis what selects a PR step:pickNextStepnames it as theprApplyTarget(a fourth kind alongsidechange | decompose | proof-of-work) once the batch is otherwisedone— every change done AND the terminal boundary proof recorded and passed — andbatchApplyCommandroutes that target toengine.runPrStep, persisting and rendering its outcome through the same paths a change step uses. Underwhole-batchthe target is the single boundary-less completion PR, gated in the CLI onprGrouping === 'whole-batch'and!hasJournaledPr(...). Under a stacked mode (per-phase/per-change)batch applysurfaces oneprtarget per detected group boundary, in boundary order: the selector derives the ordered boundaries from the manifest viadetectPrGroupBoundariesand returns the first boundary whose per-group key (prJournalKey(batch, boundary)) is not yet in the recorded-state set (openedGroupKeys), so each apply opens the next unopened group and a resumed loop opens every group exactly once. Anoff/unset batch and a fully-opened batch (whole-batch PR opened, or every group's key recorded) surface no target: the existing terminal "Nothing to do — all changes are done." output is unchanged. The gating inputs (the resolvedprGroupingmode, the whole-batch already-opened flag, and the per-groupopenedGroupKeysset — thechangekeys of the journal'sprcompletions, exactly whathasJournaledPrForGroupmatches) are resolved once at the command seam and passed to the purepickNextStepselector as data, which reads neither config nor the journal itself (instruction-fed-config). - Branches resolved by the CLI. For the whole-batch PR,
batch applyresolves the work branch (the current branch) and base branch (the repo's default branch via the remote HEAD, falling back tomainwith no remote) from git only — no forge CLI — and hands them torunPrStepasPrStepContextdata. For a stacked boundary it composes the two pure policies —detectPrGroupBoundariesorders the groups andselectStackedBasesmaps group 0's base to the repo base branch and group N's base to group N-1's own branch — and hands the fired group's resolvedbaseBranch/workBranch(the group's own branch, named from its stablegroupId) torunPrStepthe same way; the engine never derives which branch is base/work. Both seams (branches,groupBranch) are injectable so tests supply fake branch names without a real git repo. - Gated on an active
prGrouping. The step runs for any active grouping mode —whole-batch,per-phase, orper-change— resolved through the singleisPrGroupingActivepredicate. WithprGrouping: off(the default) or unset,runPrStepreturns anothing-readyresult and no PR agent is ever spawned — existing batches behave exactly as before. This engine precondition holds independent of any CLI wiring, so a directrunPrStepcaller is guarded even before per-boundary CLI surfacing lands. - Routed through the
prstage. The spawn agent is resolved viaresolveAgentForStage(settings.agent, 'pr')— a stage-map routesprto a specific agent (spawned with that agent's own/rct:open-prinvocation syntax), a scalaragentroutes every stage (includingpr) to it, and an unset/unmappedprfalls back toDEFAULT_AGENT. An unknown mapped agent is rejected (UnknownAgentError) before any spawn. - Per-group idempotent resume. Each group's PR-open outcome is recorded in
run-state under its own key —
pr:<batch>for a whole-batch step,pr:<batch>:<groupId>for a stacked phase/change group (prJournalKey(batch, boundary)). Only a successful open journals acompletionentry withtransition: 'pr'for that key, and the group-scoped rulehasJournaledPrForGroup(journal, key)— the single home of "this group's PR is open" — guards the spawn: a resumed run that sees the entry returnsnothing-readyand never double-opens the group, while distinct groups (distinct keys) are guarded independently, so a resumed loop opens every group exactly once. - Failures surface as reported step failures. A commit/push/PR-open failure (a
non-zero exit without a reported completion, or a reported blocker) maps through
the shared outcome path to a failed/blocked step and records a
blocker, NOT acompletion— sohasJournaledPrForGroupstays UNSET for that group and a subsequent run is free to retry it (other groups are unaffected). An unrenderable spawn locus (remote) throwsSkillLocusErrorand fails the step with an actionable message before any agent is spawned, the same bootstrap-failure contract as the change/decomposition paths.
The pr stage is a fourth routable agent stage alongside propose | apply | verify (see the agent setting); pr is a StepKind (for the
result/journal), never a per-change Transition, so it is never derived by
computeNextTransition and never keys a change's done-rule.
PR group boundary detection
Defined in src/core/batch/engine/boundary.ts (detectPrGroupBoundaries).
detectPrGroupBoundaries(batch, mode) is a pure function that maps the batch's
ordered phases/changes and the resolved prGrouping mode to the ordered list of
PR group boundaries — the single authoritative answer to "where are the PR groups
and what identifies each one?" It is a purely structural mapping over the batch
plan (the phases and, within each, its ordered change names); it reads no
filesystem, consults no run-state journal, and spawns no agent, which makes it
exhaustively unit-testable over tiny in-memory inputs.
Each mode maps as follows:
off→ no boundaries ([]) — no PR is ever opened.whole-batch→ exactly one boundary at batch completion, spanning every change across all phases in order; its identity is the batch name.per-phase→ one boundary per phase that has at least one change (empty phases are skipped); each is identified by its phase name, and its members are that phase's changes.per-change→ one boundary per change across all phases in order; each is identified by its change name.
A batch with no changes in any phase yields no boundaries under every mode. Every
boundary carries a stable group identity (batch, phase, or change name — each
unique within a batch) and a contiguous 0-based index ("group N"), assigned
sequentially over the produced boundaries so skipped empty phases leave no gap.
Each boundary also names its triggerChange — the group's last change, whose
completion a later consumer matches to decide when the boundary fires — while
the boundary set itself stays fixed by the plan, independent of run progress.
The engine's PR step consumes each boundary as data: the host loop
resolves the fired boundary (from detectPrGroupBoundaries) and its stacked base
(from selectStackedBases) and hands them to
runPrStep via PrStepContext.boundary + baseBranch/workBranch, which keys the
group's run-state entry (pr:<batch>:<groupId>) and injects the stacked base into
the /rct:open-pr payload. detectPrGroupBoundaries stays the single home of the
boundary rules; the engine reads its output and never re-derives boundaries inline.
Stacked PR base selection
Defined in src/core/batch/engine/stacked-base.ts (selectStackedBases).
Overview
Reading top-down: group 0's PR targets the batch base branch (main), and each
later group's PR targets the previous group's own branch, so the groups stack.
per-phase makes one group per completed phase; per-change makes one group per
change — the stacking rule is identical, only the group unit differs. The diagram
matches selectStackedBases exactly: group 0 → batchBaseBranch, group N →
branchForGroup(boundary N-1).
selectStackedBases(boundaries, batchBaseBranch, branchForGroup) is a pure
function that answers the next question after boundary detection: "what branch
does each PR group's PR target?" Given the ordered PrGroupBoundary[] (from
detectPrGroupBoundaries), the batch's base branch, and a branchForGroup
resolver that names each group's own branch, it returns one StackedBase per
boundary, in order:
- group 0 →
baseBranchis the batch base branch;headBranchisbranchForGroup(boundary0). - group N (N ≥ 1) →
baseBranchisbranchForGroup(boundary N-1)— the previous group's own branch;headBranchisbranchForGroup(boundary N). - an empty boundary list yields
[]— nothing to stack.
So the base chain for groups a, b, c with branches pr/<id> off main is
main -> pr/a -> pr/b: each stacked PR's diff stays scoped to its own unit while
dependent code still compiles. Each StackedBase carries its originating
boundary, so a consumer keeps the group's identity/triggerChange/member
changes alongside the resolved base without a second lookup. The mapping runs
purely over the array's ordering (not boundary.index), which keeps it correct
for any ordered input and — like detectPrGroupBoundaries — exhaustively
unit-testable over tiny in-memory inputs.
Branch naming is supplied, not baked in: the concrete group-branch scheme is
the caller's (engine's) concern, threaded in as the branchForGroup resolver, so
there is no git/gh/origin literal in the policy and no premature commitment
to a naming convention (instruction-fed-config, generalizable-defaults).
This policy is wired into batch apply: at genuine batch completion under a
stacked mode, runStackedPr composes the two pure policies — detectPrGroupBoundaries
orders the groups and selectStackedBases maps group 0's base to the batch base
branch and group N's base to group N-1's branch — then hands the fired boundary's
resolved base/head branch to the /rct:open-pr payload as Input. pickNextStep
surfaces the first group whose per-group run-state key (pr:<batch>:<groupId>) is
unrecorded, so each apply opens exactly the next unopened group in boundary order
and a resumed loop opens every group exactly once, idempotently per group.
Agent instructions
Defined in src/core/batch/engine/instructions.ts (buildAgentInstructions).
The spawned-agent prompt delegates each transition to the canonical ratchet
skill rather than re-authoring the lifecycle inline (delegated-lifecycle
standard: the engine orchestrates the lifecycle, it does not re-author it).
transitionGuidance no longer hand-builds the propose/apply/verify steps (no
"write files directly on disk", no inline ## Tasks recipe); instead it emits a
single instruction to invoke /rct:<transition> <change>, which loads
.ratchet/standards/ and authors/advances the change to its canonical definition
of done.
- Command id comes from the single-source
rctCommandIdForTransitionmap (shared with the skill-in-spawn-locus guarantee), so the invocation and the rendered command can never drift. - Invocation token is resolved from the configured spawn agent's command
adapter via
adapter.getInvocation(<command-id>)— claude/rct:<id>, cursor/gemini/codex/rct-<id>— never a hard-coded literal, because the syntax genuinely differs per agent (multi-agent-support). The surrounding prose names no coding agent. - Delegation is context-preserving. The invocation sits alongside the prompt's
existing top block — phase goal/success/proof-of-work and the per-change
Definition of done:line — plus any strategy guidance. It is never reduced to a bare, context-free skill call. - Caller guidance and resume answer ride along as invocation ARGUMENTS. The
caller's
-mguidance and any resolved resume answer/feedback are appended to the invocation byinvocationArguments, so the agent passes them to the skill as$ARGUMENTSrather than reading them from a detached prose block (delegated-lifecycle: "it hands that context to the skill as arguments"). The parts join with a single newline into one contiguous block glued to the invocation, distinct from the blank-line-separated sections around it. When both a-mmessage and a resume answer exist, both are injected (neither dropped); the answer/feedback no longer re-appears in the resume block, which keeps only the intent framing (the original question/proposal plus the incorporate-the-answer / revise-don't-restart directive). With no guidance and no resume context — the plainbatch applypath — the invocation stays the bare/rct:<transition> <change>with no trailing argument noise. - Argument injection stays agent-neutral. Only the trailing arguments are
appended; the invocation TOKEN still resolves from the configured spawn agent's
adapter (claude
/rct:<id>, cursor/gemini/codex/rct-<id>), so injecting a guidance argument never smuggles in another agent's syntax.
The decomposition spawn has its own prompt builder,
buildDecompositionInstructions, following the same delegation contract: it emits
/rct:decompose-phase <phase> (token resolved through the configured agent's
adapter) and injects the empty phase's goal/success/proof-of-work plus the prior
phases' shipped results as the delegation context — never an inline re-description
of the decomposition steps. A decomposition has no change, so its report channel
and journal key are the phase name (decompositionJournalKey).
This change adds no user-facing command, flag, or config key — it is internal
prompt-builder behavior — so README.md needs no edit.
Streaming
Defined in src/core/batch/engine/runtime/stream-json-renderer.ts.
When an adapter's emitsStreamJson capability is true, the engine routes each
stdout line through makeStreamJsonRenderer rather than printing it raw. The
renderer parses one-event-per-line NDJSON and writes polished output to the
engine's line printer. The gating is on the adapter capability flag, not on the
agent name, so any future adapter that sets emitsStreamJson: true reuses the
same renderer. The renderer is multi-schema, not agent-named: its dispatch
switch parses multiple event envelopes (claude's and opencode's) side by side,
gated solely on the capability flag.
Event handling:
Top-level type | Behavior |
|---|---|
system, rate_limit_event | Recognized control noise; silently skipped. |
stream_event | content_block_delta with text_delta → text streamed live and accumulated. |
assistant | text items printed as prose; tool_use items printed with glyph + name + target field. |
user | tool_result items printed (truncated to 200 chars / 3 lines; errors in red). |
result | Closing summary line with success/error, token counts, and USD cost. |
step_start | OpenCode control noise; silently skipped (mirrors system). |
text | OpenCode prose: part.text accumulated and streamed live, like claude's deltas. |
step_finish | OpenCode closing summary from part.reason/part.tokens/part.cost; an error reason is flagged. |
| Unknown or non-JSON | Raw line printed as fallback; renderer never throws. |
An opencode event whose top-level type is recognized but whose part.type is
unrecognized falls through to the raw-line fallback (no new failure mode).
The renderer never mutates the accumulated AgentSpawnResult.stdout; the raw
NDJSON transcript that mapSessionToOutcome reads is byte-identical with or
without rendering.
Agent permissions
Defined in src/core/batch/permissions-policy.ts (policy schema and types) and
src/core/batch/runtime/agent-permissions.ts (per-agent translator).
Policy shape
interface ResolvedPermissionsPolicy {
posture: PermissionPosture; // 'repo-sandboxed-permissive' | 'curated-allowlist' | 'full-autonomy'
allow: string[]; // tool-pattern allowlist (agent-neutral)
deny: string[]; // tool-pattern denylist (agent-neutral)
raw: Partial<Record<'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode', string[]>>;
}
The default posture is repo-sandboxed-permissive.
Postures
repo-sandboxed-permissive(default): edits and ordinary build/test shell commands run unprompted; the agent is scoped to the repo; a baseline denylist forbids destructive/exfiltrating operations.curated-allowlist: nothing runs unprompted except an explicitallowlist; the deny list still applies. Operators must include aBash(...)entry inallowor any shell step will stall headless.full-autonomy: all permission checks are bypassed.
Baseline deny patterns (repo-sandboxed-permissive)
The following patterns are merged into the effective denylist for the sandboxed
and curated postures (not for full-autonomy):
Bash(rm -rf *)
Bash(sudo *)
Bash(* > /*)
Bash(curl * | sh)
Bash(curl * | bash)
Bash(wget * | sh)
Bash(wget * | bash)
Per-agent flag translation
resolvePermissionFlags(agentName, policy, repoRoot) returns a concrete argv
fragment appended to each adapter's base args. The translation is pure (no I/O).
claude:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | --permission-mode acceptEdits --add-dir <repoRoot> --allowedTools Bash [<allow>] --disallowedTools <deny> |
curated-allowlist | --permission-mode default [--allowedTools <allow>] [--disallowedTools <deny>] |
full-autonomy | --dangerously-skip-permissions |
For repo-sandboxed-permissive, --allowedTools defaults to ['Bash'] when
the operator's allow list is empty. acceptEdits covers file edits only;
Bash must be explicitly allowed or headless shell calls are denied.
gemini:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | --approval-mode auto_edit |
curated-allowlist | --approval-mode default |
full-autonomy | --yolo |
Known limitation: gemini's auto_edit covers file edits only and does not
unblock headless shell calls. An argv-only bounded shell allowance is not
available for gemini (the --allowed-tools flag is deprecated; the Policy Engine
is file-based). The sandboxed mapping stays auto_edit — it may prompt or stall
on shell steps in headless mode.
codex:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | --sandbox workspace-write --ask-for-approval never |
curated-allowlist | --sandbox workspace-write --ask-for-approval on-request |
full-autonomy | --full-auto |
cursor:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | (none — cursor's default per-action gating applies; a one-time warning is emitted) |
curated-allowlist | (none — same) |
full-autonomy | --force |
cursor's allow/deny is config-file only and cannot be injected via argv; the sandboxed and curated postures rely on cursor's built-in approval prompting.
opencode:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | (none — opencode's default per-action gating applies; a one-time warning is emitted) |
curated-allowlist | (none — same) |
full-autonomy | --dangerously-skip-permissions |
opencode exposes a single permission knob, --dangerously-skip-permissions
("auto-approve permissions that are not explicitly denied"), and no argv-only
bounded auto-edit mode analogous to claude's acceptEdits or gemini's
auto_edit. Like cursor, the sandboxed/curated postures therefore rely on
opencode's own default per-action approval gating — strictly narrower than the
bypass, not silently equivalent to full autonomy.
Raw override escape hatch
The raw field carries per-agent argv fragments appended after the posture-derived
flags for that specific agent. Entries for other agents are ignored. An
unrecognized agent name receives no posture flags but honors its raw entry.
Batch config permissions feed-in
batch config permissions sets the permissions key in .ratchet/config.yaml
under the batch: section. The resolved policy from that config is merged with
the user/global scope and per-manifest scope before being injected into the spawn
request. See batch command.
Settings resolution
Agent, locus, and image resolve across four scopes in increasing precedence:
built-in default ← user/global ← project config (.ratchet/config.yaml batch:) ← per-change manifest
Scalar settings (including locus, agent, image, host, port,
authToken, insecure) are nearest-wins. Permissions use per-field merge
semantics: posture nearest-wins, deny is the union of all scopes, allow is
replaced by the nearest defining scope, and each agent's raw entry is
nearest-wins.
Built-in defaults:
| Setting | Default |
|---|---|
locus | local |
agent | claude (via DEFAULT_AGENT when settings name none) |
image | python:3.12 (DEFAULT_DOCKER_IMAGE, docker locus only) |
permissions.posture | repo-sandboxed-permissive |
For standalone (headless) steps the cascade is flag → project config → default
with no manifest scope. See Standalone settings and
batch command.
Requirements
- Agent CLI on PATH: one of
claude,codex,gemini,cursor, oropencodematching the configuredagent. See doctor, which probes each registered agent binary. - Python >= 3.10: required for the
localanddockerloci. Bootstrap probespython3,python,python3.12,python3.11,python3.10in order.uvis preferred for venv creation and package install; it falls back topython -m venv+ pip whenuvis not available. - Docker daemon: required for
locus: dockeronly. The bootstrap runs adocker infopre-flight before any other work; a non-zero result surfaces as an actionable error. - No local Python is required for
locus: remote; all Python runs on the remote server.
See doctor for the full runtime requirements check.