@lucasfe/ralph
v0.23.0
Published
Ralph — autonomous GitHub issue resolution loop, packaged as a CLI.
Downloads
2,403
Readme
@lucasfe/ralph
Ralph is an autonomous loop that picks the next open GitHub issue, asks
a coding agent to resolve it, opens a pull request, and waits for the merge
— then moves on to the next one. This package extracts the in-repo Ralph
scripts into a reusable CLI so any project can opt in with a single
npm i -g @lucasfe/ralph invocation.
By default the coding agent is Claude Code. Ralph can also drive the OpenAI Codex CLI instead — see Choosing the coding agent.
By default Ralph draws its work from GitHub issues (the flow described
above). It can instead pull tasks from a local .ralph/tasks/ folder with
no GitHub remote, auth, or gh dependency — committing straight to your dev
branch with no PR. See Choosing the task source.
⚠️ Codex support is experimental. The Codex path is unit- and stub-tested (registry, stream parsing, invocation argv, auth probe, template parity, and the full bash loop driven against a stubbed
codexemitting the realcodex exec --jsonevent shape all have coverage), but it has not been exercised in a live end-to-end run against the realcodexCLI. Expect rough edges and report anything that misbehaves. Claude Code remains the fully-exercised default.
The full design is captured in the original PRD (tracked in the
agenthub repo, where Ralph was first built).
Install
Global install (recommended — gives you ralph on $PATH):
npm install -g @lucasfe/ralphOr run on demand without installing:
npx @lucasfe/ralph initRequirements: Node ≥18, plus a few system tools that ralph doctor
will check for you (git, gh, tmux, jq, curl) and one coding-agent
CLI — either claude (the default) or codex, depending on which agent
you configure. Only the selected agent's CLI is required; ralph doctor
validates that one and never asks a Codex-only machine to install claude (or
vice-versa). gh is required only for the default GitHub task source — in
folder mode (TASK_SOURCE=folder) ralph doctor skips it, so a repo with no
GitHub remote needs only git, the agent CLI, and jq (see
Choosing the task source). macOS, Linux, and WSL2
are supported.
Quick start
In a git repo on the branch you want Ralph to work from:
ralph init # one-time: detect stack, write config, slash command, gitignore
ralph doctor # verify required deps are on PATH, under an identity box you can paste
ralph start # under the sprite and identity box: launch the loop in a detached tmux session
ralph status # under an identity box: run, task in flight, queue, pace, ETA, spend, digest
ralph digest # narrate in prose what the loop is doing, and log it to .ralph/digest.log
ralph stop # kill this project's tmux session when you want Ralph to halt
ralph update # update Ralph itself to the latest published version (any directory)
ralph changelog # what changed in recent Ralph releases (any directory, no network)ralph init must be run inside a git repository. It checks this first and,
if you are outside a git work tree, aborts (exit code 1) before any prompt or
file write with ❌ ralph init must be run inside a git repository. Run 'git
init' first (or cd into your repo).
ralph init inspects the manifests in your repo
(package.json, pyproject.toml, go.mod, Cargo.toml, Gemfile,
composer.json, lockfiles) and writes a ralph.config.sh with the
right install/test/lint commands for your stack. If nothing matches,
the values are left empty and the agent is instructed to figure them out
at runtime. The stack detection is non-interactive; the only prompt is the
coding-agent picker (see below), and even that is skipped when a
--agent flag is passed or stdin is not a TTY (it defaults to claude).
ralph doctor checks the deps required by the agent and task source you
configured, and heads its report with the same identity box ralph start
opens with — described a few paragraphs down, and here carrying the facts a
diagnostic is asked for, in one block to paste into a bug report:
╭─ ralph 0.22.0 ───────────────────────────────────────────╮
│ os mac │
│ agent claude │
│ cached 0.23.0 available — run `ralph update` │
│ cwd /Users/you/repos/your-project │
╰──────────────────────────────────────────────────────────╯The installed version is the box's title; os is the platform Ralph
detected, agent is the agent whose CLI it validated, and cwd is where you
ran it. The cached row answers "am I current?", and it has three readings:
0.23.0 available — run `ralph update` (yellow) when the cache holds
something newer, 0.22.0 — up to date (green) when it holds the version you
already have — or an older one, since a local build ahead of the registry is not
stale — and unknown (no update check cached yet) when nothing usable is
cached. That number is read from the same global update-check.json the
weekly check writes, whether that check ran under ralph start or under a
scheduled ralph cycle (see Updating Ralph): doctor never
queries the registry, never writes that file, and applies neither of the two
7-day windows it holds — it reports whatever the last check left behind, however
old, and running it neither refreshes the check nor spends the week's update
question. That keeps it usable offline and on a half-broken install, which is
when you reach for it.
The box is additive output only — doctor's exit code still answers for the
deps alone, so a wrapper or CI step gating on ralph doctor does not start
failing the day a release lands — and it is printed above the dep report, so
it survives the early exit on a missing required dep. It is the box and nothing
else: no sprite, no animation and no cursor movement at any setting, because
this is output people pipe, quote and diff. A mistyped RALPH_AGENT still gets
its one warning line, printed directly under the box whose agent row it
explains — and exactly one line, whatever you set. The warning quotes your
value back at you untrimmed and in its original case, so three trailing spaces
are visible as three trailing spaces, but every control character in it — a
newline, an ESC, a NUL — is replaced with the Unicode replacement character
U+FFFD and the echo is capped at 200 characters. So no value can break the
warning across two lines, forge an extra row of the box above it, or move your
terminal's cursor: a ralph doctor report pasted into a bug report holds only
lines doctor composed.
RALPH_BANNER governs this box too, read from the
same ralph.config.sh line and overridden by the same environment variable
ralph start obeys (the environment wins): RALPH_BANNER=off ralph doctor
prints no box and not one blank line, so the output starts at the first dep
line, and on that path the update-check cache is not read at all. One difference
from ralph start is deliberate: a value doctor does not recognize falls back
to the full box silently, with no warning on either stream, because a typo in
a purely cosmetic knob does not earn a line in a diagnostic. ralph start —
the command the setting is actually about — is where that typo is reported.
ralph start runs sanity checks (tmux session uniqueness, deps,
gh auth, .mcp.json, label setup, orphan claude-working cleanup),
optionally prints an update notice (and, on an interactive terminal, at
most once a week, offers to install it — see
Updating Ralph), and launches the bash loop inside
a per-project tmux session named ralph-<repo>-<hash> (derived from the
project path, so multiple repos can run Ralph concurrently without
colliding). The exact attach / kill commands for your session are printed by
ralph start; detach with Ctrl+B then D, or tail per-issue logs in
logs/ralph-issue-*.log. When
RALPH_DIGEST_INTERVAL is set in
ralph.config.sh, the same session also gets a second window named
digest that narrates the run on a timer beside the loop — see
ralph digest below. The same box carries ralph status for checking in
later and — on a repo with metrics history behind it — a projection of what the
queue it just accepted should take and cost, and when it should be done (see
The launch projection). Each iteration
also tees the agent's raw JSON stream (Claude's stream-json, or Codex's
codex exec --json JSONL) to logs/ralph-issue-*.jsonl and appends one
telemetry event line to .ralph/metrics/issues.jsonl (see
Monitoring data model).
On an interactive terminal ralph start opens with a small pixel sprite — a
one-second splash in 24-bit colour that plays its frames in place and settles on
a still, printed above the very first preflight line, so it is there on the runs
that fail their checks too. It redraws over itself rather than scrolling, so
what your scrollback keeps is one frame and not five, and its length is a fixed
count of frames rather than a timed loop: it cannot hold a start up for longer
than the second it advertises. The cursor is hidden while it draws and put back
when it settles — including on a Ctrl-C through the middle of it, which still
exits 130. An animation that cannot finish — a terminal that stops taking
bytes halfway through it — costs the picture and not the run: the identity box
prints under wherever it stopped, and the exit code is the one the run would
have had. It is decoration and nothing depends on it: it is gated on the
terminal rather than on the run, so when stdout is not a TTY (a pipe, a
redirect to a file, a launchd log, a CI transcript) or
NO_COLOR is set in the environment, the sprite is not
printed at all — none of its escape sequences, no cursor movement, not even a
blank line where it would have been, and nothing waited for a frame nobody was
going to see — and every other line of ralph start's output, plus its exit
code, is byte-for-byte what the same run prints on a terminal. There is no flag
and no variable that turns the sprite on: a non-terminal never gets it. None
of that is yours to configure, and gating it on the terminal rather than on a
setting is what makes it so: a piped run, a launchd job and a CI transcript come
out clean by default, with no flag to remember in a wrapper script and nothing to
set in a plist or a CI job for the sake of a readable log. A terminal narrower
than the sprite itself — under 26 columns — silences it the same way, and
drops it whole rather than clipping it, because half a face with a torn edge is
not a smaller sprite. Those are the terminal's reasons; you have one of your own,
and it is RALPH_BANNER — the setting described a few
paragraphs down, which can hold the splash still on its settled frame or drop the
whole banner, sprite and box together.
Directly under it, and on every run bar one an explicit
RALPH_BANNER=off silenced, comes the identity
box: which Ralph this is, where it is running, whether a newer one is waiting,
and what changed in the release you are on.
╭─ ralph 0.22.0 ───────────────────────────────────────────╮
│ update 0.23.0 available — run `ralph update` │
│ cwd /Users/you/repos/your-project │
│ new • `ralph digest --loop` + a digest window in th… │
│ • `ralph digest` one-shot — no-tool narration o… │
│ • a digest section in `ralph status` (#63) (#96… │
│ more run `ralph changelog` for the rest │
╰──────────────────────────────────────────────────────────╯The title is the Ralph that is about to run — the installed version, read out
of the package's own package.json — and cwd is the directory you ran the
command in. Those two rows are on every box the three commands draw, though
ralph status's cwd names the git toplevel instead of the directory you typed
in, for the reason its own paragraph below gives. update, new and more each
appear only when Ralph has the fact behind them, and each has a paragraph of its
own below.
Unlike the sprite the box is not gated on the terminal, because it is facts
rather than decoration: a launchd log or a CI transcript is exactly where "which
version, which directory" is the question being asked. A non-TTY or NO_COLOR
costs it its colour and nothing else — the update row is yellow on a colour
terminal and plain text everywhere else, with not one escape byte emitted. It is
printed before every other side effect, so it is on screen even on the runs a
preflight check aborts, and it is additive output only: no other line and no
exit code changes because of it. It holds 60 columns, or your terminal's width
when that is narrower, with anything longer clipped by … and no line ever
wrapped; under 44 columns, where the frame would be spending an eighth of the
screen on decoration, the border is dropped altogether and the same rows print
bare as label value — the same information, with the border's four columns
handed back to the fact. A width Ralph cannot use falls back to that 60-column
default rather than degrading, so a pipe (where there is no column count to read)
gets the box it always did. A fact Ralph could not read (the version, on an
install with an unreadable package.json) reads unknown rather than being
guessed at.
ralph doctor and ralph status head their own reports with this same box —
same composer, same width ladder, same RALPH_BANNER setting — each carrying the
rows it has facts for: doctor the ones a diagnostic needs (os, agent,
cached), status the cwd under the version in the title and nothing else,
where ralph start carries update and the what's-new bullets. Which rows a box
holds is a question of which facts the command resolved, so no command grows
another's; see the ralph doctor paragraph above for that box and its cached
row, and the ralph status paragraph below for why that one is the shortest of
the three.
How much of that banner you get is the one thing about it that is yours to
choose. RALPH_BANNER in ralph.config.sh takes
three values: full — the default, and what every Ralph before this setting
did — plays the splash and prints the box under it; static draws the same
picture with none of the animation, which is the settled frame written once, with
no cursor hidden and no Ctrl-C handler armed; and off prints nothing at
all, not the sprite, not the box, not one blank line, so ralph start's output
begins at its first preflight line exactly as it did before any of this existed.
Values are case-insensitive and surrounding whitespace is ignored; unset or empty
means full, and a value Ralph does not recognize also means full and says so
in one line on stderr — a typo costs you a line of output and never the run,
and nothing it prints reaches stdout, so ralph start | tee is unaffected either
way. That warning quotes the value it did not recognize, flattened to one line and
with every control character in it replaced by U+FFFD, so a stray ESC in the
committed line is shown to you rather than obeyed by your terminal.
An environment variable of the same name wins
over the file, deliberately the opposite way round to
TASK_SOURCE: a task source is a property of the
repository, while a banner is a property of one invocation, so
RALPH_BANNER=off ralph start silences a single run inside a wrapper script, a
cron entry or a CI job without editing — and committing — a file every other run
in the repo shares.
The terminal only ever caps this downward. No value of RALPH_BANNER turns
the sprite on: a pipe, a launchd log, a NO_COLOR run or a window under 26
columns draws no sprite whatever the setting says, and asking for full or
static there costs nothing — no frames, no waiting, not one escape sequence.
What the cap does not reach is the box, for the same reason the terminal
never gated it: a piped or NO_COLOR run still prints it, in plain text, exactly
as it did before this setting existed. Only an explicit off — a user asking
for nothing, rather than a terminal that cannot show something — takes the box
away.
The update row is served entirely from the cache the weekly check already
keeps (see Where the check keeps its state),
so the banner makes no registry query of its own and costs the first paint
nothing: on a machine where that check has never run there is simply no row. It
appears only when what is cached is strictly newer than what you have — the
same comparison behind the weekly check's notice, so the box
and the notice can never disagree about what counts as newer, though a single run
can print both. It also honours
RALPH_NO_UPDATE_CHECK: with the opt-out set the cache
is not read at all and the row never appears, leaving the box its title, its
cwd, and its what's-new rows.
The new rows are the newest release in the CHANGELOG.md that ships inside
the installed package — its first three bullets, in the order a reader of the
file would meet them, clipped to the box's width like every other row. That file
is in the tarball, so this is one local read: no network call, and nothing added
to the first paint. It is resolved against the install, never against your
working directory, so a globally installed Ralph running inside a project that
has a CHANGELOG.md of its own still shows Ralph's release notes and never
yours. The rows print on every run and nothing is recorded as seen — these are
release notes, not the weekly update nag — so starting Ralph twice tells you
twice. If the shipped changelog is missing (a pruned install), empty, or in a
shape nothing can be made of, the new and more rows simply do not appear and
the run starts exactly as it did before.
The more row points at ralph changelog, which is where
the rest of the entry is: it prints the newest release whole — every bullet,
not the box's three — then the two releases behind it, and every release in
the file under --all. It reads the same shipped changelog this box does, from
the install rather than from your project, so it costs no network call and needs
no Ralph project to answer.
ralph status answers "what is Ralph on right now?" without attaching to
anything. Its human view opens with the same identity box ralph start and
ralph doctor head their output with — the one described above — holding two
facts and no more:
╭─ ralph 0.22.0 ───────────────────────────────────────────╮
│ cwd /Users/you/repos/your-project │
╰──────────────────────────────────────────────────────────╯That shortness is the decision. This is the readout people screenshot and paste,
and a table carrying a pace, an ETA and a night's spend says everything about a
run except which run it was and where — so the version in the title and
the cwd row are exactly the two things the numbers below cannot say for
themselves. There is deliberately no update row: "a newer Ralph is waiting" is
advice for the two commands a reader can act on it from — ralph start offers to
install it, ralph doctor reports what is cached — rather than for a view you
refresh on a timer. There are no os / agent rows either: those belong to a
command diagnosing a machine, and this one is reporting a run. And
cwd is the git toplevel rather than your working directory, because that is
the path the record, the cycle lock, issues.jsonl and .ralph/digest.log are
all keyed on: the one line that takes a reader back to the run being reported,
whichever subdirectory it was typed in.
It is the box and nothing else — no sprite, no animation and no cursor movement
at any setting, because this is output people pipe, quote and diff — and it is
additive output only: one blank line separates it from the report, no line of
the report changes because of it, and the exit code is still 0 in all four
modes. RALPH_BANNER governs it exactly as it governs
the other two, read from the same ralph.config.sh line and overridden by the
same environment variable (the environment wins). RALPH_BANNER=off takes the
blank line away with the box, so the report starts at its ▸ ralph line byte for
byte as it did before the box existed. And one mode prints no box whatever the
setting says — never-run, because the box identifies a run and that mode has
none, which is also what keeps it the readout that reads nothing at all,
ralph.config.sh included.
Under the box it reads the run-state record the loop keeps at
.ralph/run-state.json and prints the run and how long it has been going, the
task in flight and for how long, the live queue depth, the pace the run is
holding, an ETA with a range and a wall-clock finish time, the spend so far and
where it projects to, and the attach / kill lines for the session — or, for a
scheduled ralph cycle run, the log to follow instead, since that run has no
session to attach to. Each of those three estimates reads unknown rather than
a guessed number when the run has no history to reason from. A run that has been
narrated also gets the latest ralph digest entry for it printed under those
numbers — the sentence that explains them, with how old it is and which model
wrote it (see The digest section). It anchors on the
git toplevel, so it reports the same run from any subdirectory of the repo, and
it exits 0 whether a run is in flight, was interrupted, is over, or never
happened. See
Run state.
ralph status --json prints that same snapshot as a single JSON document on
stdout instead, so a shell prompt, a status line, or a notifier can read it
without re-parsing the metrics file. The identity box does not reach that path at
all: no box, no blank line, one document, at every RALPH_BANNER value. See
Machine-readable output.
ralph digest answers the same question one resolution coarser: it asks a
cheap model for a few sentences of plain prose about what the run is doing —
which task is in flight, which file it appears to be editing, which phase of
the TDD cycle it looks to be in, and anything that looks wrong. Ralph
assembles the context itself (the in-flight log tail, bounded by both lines
and bytes; git status and git log; and the same snapshot
ralph status --json prints) and hands it over inline in the prompt, so
the model gets no tools at all — it cannot read a file, run a command, or
touch the run, and that is structural rather than a setting it was asked to
respect. It is one turn rather than an agent session, which is what makes it
cheap enough to ask for repeatedly; the model it asks defaults to a cheap
per-agent one (haiku for Claude, gpt-5-mini for Codex) and is overridable
with RALPH_DIGEST_MODEL. Each narrative is also
appended to .ralph/digest.log — one entry per digest, under a heading
naming four things (an ISO timestamp, the run id, the task in flight, and the
model that answered), and never truncated — so a night of digests reads back as
the night's story and greps by any of the four. The latest entry for the run in
flight is also what ralph status reads back to you, so the narration and the
numbers it is about arrive in one view rather than two commands (see
The digest section). Failure is deliberately
silent and harmless: an agent that is missing, unauthenticated, slow to
answer, or that answers with nothing writes no history entry, prints one
line to stderr, and still exits 0, because a digest is an accessory to a run
and must never be able to fail one. In a project with no run recorded yet it
prints one honest line and never invokes the agent at all.
The digest can also keep the loop company on a timer instead of being asked one
question at a time. ralph digest --loop --interval 30m prints a digest
immediately and then one every 30 minutes until it is killed, and
RALPH_DIGEST_INTERVAL in ralph.config.sh is how
you get that without typing it: ralph start opens exactly that command in a
second tmux window named digest, in the session it just created for the
loop.
session ralph-<repo>-<hash>
window 0 the loop — the raw agent stream
window 1 digest ralph digest --loop --interval 30mtmux attach still lands on the loop's window; Ctrl+B then W lists both, so
the stream and the narration sit side by side. Each digest is appended to
.ralph/digest.log exactly as a hand-run one is, so the night's story reads back
without attaching to anything. The window narrates with the agent and model this
repo configures — ralph start forwards whichever of RALPH_AGENT and
RALPH_DIGEST_MODEL ralph.config.sh sets into it (and
nothing when it sets neither, leaving the ambient environment to decide as usual),
so a Codex repo's digest runs Codex rather than a claude that would fail every
tick. Teardown is the session's: ralph stop, and
the loop's own end of run, kill the session, and the digest window goes with
it. There is nothing separate to stop and nothing left narrating afterwards.
The interval is off by default, and turning it on can cost you the digest but
never the run. Empty — what ralph init writes — or any spelling of zero (0,
0m) means no window, no timer, and no model call. In a repo initialized before
this shipped there is no RALPH_DIGEST_INTERVAL line in ralph.config.sh at all,
because ralph init never rewrites that file (see
What survives an update); add the assignment yourself
— an absent knob reads exactly like an empty one, so the only difference is that
nothing in the file tells you it exists. A value the duration grammar
rejects (a fraction like 0.5h) or one longer than a timer can wait (30d; the
ceiling is 24d) is refused after the loop is already running: ralph start
writes ⚠️ Digest window not opened — …. The loop is running. to stderr, and the
startup box's digest line — which quotes the interval exactly as the file spells it
— reads Digest: every 0.5h — NOT running (see the warning on stderr) where a
working one reads Digest: every 30m — runs alongside the loop. The launch itself
still succeeds either way. Run by hand, those two refusals — plus --loop with no
--interval at all, and --interval 0, which the config knob instead reads as
simply off — print one
ralph digest: not looping — … line and exit 0; so does a digest that
fails mid-loop, which writes its line and leaves the timer to keep its next
appointment rather than ending the night early. Only ralph start opens that
window: a scheduled ralph cycle has no tmux
session of its own and starts no digest.
ralph update updates the Ralph CLI itself, from any directory. It, the
--force flag, the install layouts it can and cannot update, and the weekly
check ralph start and ralph cycle run are all covered in
Updating Ralph.
ralph changelog says what a version actually changed — the three newest
releases by default, every one under --all — read from the CHANGELOG.md that
ships inside the install, so it answers offline and from any directory. See
ralph changelog.
How Ralph resolves issues
Each iteration runs a team of context-isolated specialists, coordinated by an orchestrator that processes one issue end-to-end. Solo mode has been retired: team mode is the only mode, with no activation flag.
The orchestrator first triages the issue and scales the team to fit it:
- Tier 0 / Light — trivial / non-behavioral — pure docs, plain config, or dependency bumps without logic changes. It skips the dev-TDD and QA stages and runs only a light review plus the writer. The boundary is conservative: when in doubt, the issue is treated as substantive.
- Tier 1 / Standard — substantive — anything that changes behavior. It runs the full team, in order: dev → QA → review → writer.
- Tier 2 / Heavy — gated, dark — the largest issues (multi-file /
multi-module scope, audit, refactor, migration, or multi-hypothesis
investigation), or any issue carrying the
ralph-heavylabel, which forces Tier 2. This tier is gated behind theRALPH_HEAVY_TIERflag and is off by default: when the flag is0the heavy tier is unavailable and triage falls back to Tier 1. When uncertain the classifier defaults to Tier 1 (never Tier 2 on a guess), and a heavy run that fails to converge degrades to Tier 1 rather than looping. When the flag is on, a Tier-2 run adds an understand phase before the dev: it fans out three read-only explorers chasing competing hypotheses, then an inline synthesizer collapses their structured returns into one plan handed to the dev as plan + issue (see the explorer in the roster below). A Tier-2 run also adds a verify phase after the single-reviewer gate and before the PR opens: an adversarial panel of three reviewers (correctness / security / maintainability lenses) blocks the diff only on a majority — 2 of 3 (see the reviewer contract below).
The specialists each have a single contract:
- Explorer (Tier 2 only) — a read-only hypothesis investigator that runs in the understand phase, before the dev. On a heavy run the orchestrator dispatches three explorers in parallel, each chasing a different, competing hypothesis about the root cause or right approach. An explorer reads, searches, and reasons — it never writes or edits a file — and ends with a structured return (hypothesis, verdict, evidence, proposed approach, risks). An inline synthesizer (a named seam in the orchestrator, not a subagent) collapses the three returns into one plan, handed to the dev as plan + issue. On Tier 0 / Tier 1 this phase is skipped and the dev receives the issue alone.
- Dev — turns the issue into working, tested code through a strict TDD red → green → refactor loop. Red: write a failing test that captures the issue's expected behavior and confirm it fails for the right reason. Green: implement the minimum code that makes it pass and confirm the whole suite is green. Refactor: tighten names and remove duplication while keeping it green. The dev infers its persona from the issue and the repo's detected stack, and skips TDD only for changes with zero behavioral impact.
- QA — runs only after the dev's suite is green, and augments (never rewrites) it with edge-case and adversarial tests. A failing QA test is treated as a defect and blocks until green: it goes back to the dev to fix, then control returns to QA to re-run the suite, until everything passes.
- Reviewer — a pre-PR gate, run after QA is green but before any PR is opened. It judges maintainability (oversized files, tangled control flow, weak abstractions, needless indirection), not just whether the code works. Blocking findings loop back to the dev and then back to the reviewer, bounded to a maximum of 2 rounds. If concerns remain after the round limit, the loop stops and a human is pulled in via the caveat flag (below). On a Tier 2 run this single pass is replaced by an adversarial panel of three reviewers in a verify phase: the same reviewer contract is reused three times with distinct lenses (correctness, security, and the step-4c maintainability standard as the maintainability lens), and the diff is blocked only on a majority — 2 of 3 (a lone objection is recorded but does not gate the PR). The panel keeps the same 2-round bound; on non-convergence the PR opens anyway with the same caveat flag, identical to Tier 1. On Tier 0 / Tier 1 the panel is skipped and the single-reviewer gate above is left unchanged.
- Writer — runs after the review gate passes. It inspects the
diff and infers which docs the change implies (README,
CLAUDE.md/AGENTS.md,docs/pages, inline docstrings), updating only those — it writes no tests and introduces no new behavior.
The new/updated tests and the implementation land in the same commit so the TDD pair is reviewable together. The PR body carries one section per role: Dev/TDD (tests added, red names before, green suite after), QA scenarios, Review verdict, and Docs updated. When TDD is skipped per triage, the Dev/TDD and QA sections record the skip and its justification.
When the reviewer and dev do not converge within the 2-round limit, the PR is opened anyway with a caveat flag — a prominent unresolved-concerns warning block prepended to the PR body listing each blocking finding, so a human knows exactly what still needs judgment before merge.
Choosing the coding agent
Ralph drives one coding-agent CLI per project. The choice is recorded as
RALPH_AGENT in ralph.config.sh:
claude(default) — Claude Code. Fully exercised; unchanged from earlier releases.codex— the OpenAI Codex CLI. Experimental (see the callout at the top of this README): validated by unit + stub tests, not yet by a live end-to-end run.
Pick the agent at ralph init time:
ralph init --agent codex # write RALPH_AGENT="codex"
ralph init --agent claude # write RALPH_AGENT="claude" (same as the default)
ralph init # interactive prompt on a TTY, else defaults to claudeThe --agent value is case-insensitive and trimmed, and it is validated
before anything is written: an invalid value (a typo, a model name, anything
that is not claude or codex) is rejected with
❌ Unknown agent '<x>'. Valid agents: claude, codex. and a nonzero exit, so a
mistyped flag never silently falls back to claude. <x> is the value you
passed, echoed back untrimmed and in its original case — with every control
character in it replaced by U+FFFD and the echo capped at 200 characters, so
the rejection is one line of stderr no matter what the flag carried.
When you run ralph init in an interactive terminal without --agent, it
prompts Use Codex instead of Claude Code? [y/N]: — answer y/yes for
codex; a blank answer or anything else keeps the default claude. This
prompt path never aborts: a stray keystroke just lands on the safe default. When
stdin is not a TTY and no flag is passed, ralph init skips the prompt and
defaults to claude silently, so an unattended run is never blocked.
To switch an existing project, edit RALPH_AGENT in ralph.config.sh by hand
(or delete the file and re-run ralph init --agent <name>). The next
ralph start detects that the resolved agent no longer matches the one recorded
in .ralph/state.json and re-runs config validation once under the new agent —
so the switch self-heals even though the rest of the config is unchanged.
ralph doctor
reports which agent it validated in the agent row of the identity box it opens
with (agent codex) and checks that agent's CLI — Claude needs claude;
Codex needs codex.
Nothing else in ralph.config.sh changes between agents. The two agents share
the same team roles, triage tiers, PR flow, and telemetry; only the
orchestrator template and the invoked CLI differ. For Codex you can also pin a
model with RALPH_CODEX_MODEL (see Configuration reference).
Codex sandbox and network access
Ralph runs codex exec with a workspace-write sandbox and network access
left on (sandbox_workspace_write.network_access=true), with approvals disabled
so the unattended loop never blocks on a prompt.
- The sandbox is a partial boundary, not a substitute for the prompt's
stay-inside-the-project rule. During design the
workspace-writesandbox did not prevent a write to the system temp directory. Treat it as defense in depth, not containment: the orchestrator's "never touch files outside the project root" instruction — not the sandbox — is what keeps a run contained. Do not over-trust the sandbox. - Network access is mandatory. Ralph's loop drives
gh,npm, andgit pushon every iteration, so with network access disabled those commands fail and no PR can be opened or merged. This is why the Codex sandbox is configured with network access enabled; if you tighten it, the loop stops working. (Claude Code runs unsandboxed and likewise needs network — the requirement is not Codex-specific.)
Choosing the task source
Ralph draws its work from one task source per project, recorded as
TASK_SOURCE in ralph.config.sh:
github(default) — today's behavior, unchanged. Ralph resolves open GitHub issues viagh, opens a PR per issue, and waits for the merge.folder— a fully-local mode. Tasks live as numbered markdown files under a gitignored.ralph/tasks/tree whose directories encode status. Ralph drains an autonomous queue, does the work, commits directly to the dev branch, and moves the task file to a terminal directory. No PRs, no auto-merge, and noghdependency — folder mode needs onlygit, the agent CLI, andjq.
Pick the source at ralph init time:
ralph init --source folder # write TASK_SOURCE="folder"
ralph init --source github # write TASK_SOURCE="github" (same as the default)
ralph init # interactive prompt on a TTY, else defaults to githubThe --source value is case-insensitive and trimmed, and it is validated
before anything is written: an invalid value is rejected with
❌ Unknown task source '<x>'. Valid sources: github, folder. and a nonzero
exit, so a mistyped flag never silently falls back. <x> is sanitised exactly as
the --agent rejection is — your value, untrimmed
and in its original case, with control characters replaced by U+FFFD and the
echo capped at 200 characters — so the rejection is always one line.
When you run ralph init in an interactive terminal without --source, it
prompts Draw tasks from a local .ralph/tasks/ folder instead of GitHub? [y/N]:
— answer y/yes for folder; a blank answer or anything else keeps the
default github. When stdin is not a TTY and no flag is passed, ralph
init skips the prompt and defaults to github silently, so existing
automation keeps working unchanged.
To switch an existing project, edit TASK_SOURCE in ralph.config.sh by hand.
The bash loop, the prompt builder, and ralph doctor/cycle preflight all read
this one value, so the loop and prompt consistently honor it on every run.
Folder-mode layout
In folder mode, ralph init scaffolds the .ralph/tasks/ tree (empty
directories only — no README or example task). The tree separates an autonomous
lane (afk) from a human-in-the-loop parking lot (hitl):
.ralph/tasks/
afk/todo/ # queued — Ralph picks the lowest-numbered file here
afk/in-progress/ # the task Ralph is currently working
afk/done/ # resolved successfully
afk/failed/ # failed, crashed, or left unfinished
hitl/todo/ # staging only — Ralph NEVER auto-picks from hereRalph's autonomous loop only ever picks from afk/todo/. The hitl lane is a
human-only parking lot for tasks you are not ready to release to the robot; you
activate a task by moving its file hitl/todo → afk/todo (there is no
command for this — it is a plain file move). The whole .ralph/tasks/ tree is
gitignored, so task files and their status moves never pollute your work
commits; the loop also mkdir -ps any missing status directory before use, so a
partial or freshly-cloned tree never crashes a run.
Task-file format
Each task is a numbered markdown file (e.g. 001-fix-login.md). The leading
integer is the task's stable identity — it drives the branch/log/telemetry
keys (analogous to today's issue-N) and stays constant as the file moves
between status directories. The file is YAML-ish frontmatter (title, optional
labels) delimited by ---, followed by a markdown body:
---
title: Fix login redirect loop
labels: bug, auth
---
When a session expires mid-request the app redirects to `/login` in a loop.
Reproduce by ... and fix so the user lands on the originally requested page.The title and body map 1:1 onto a GitHub issue's title and body, so the
prompt fills the same way regardless of source. labels accepts a comma list
(bug, auth) or a bracketed list ([bug, auth]).
Numbering rule (a spec for a future task-authoring skill; the skill itself
is out of scope): the next number is max(N) + 1 scanned across all
directories in both lanes, so a number is never reused — even by a task
already in done/, failed/, or the hitl parking lot.
How Ralph works a folder task
Each iteration Ralph picks the lowest-numbered file in afk/todo/ (the folder
analog of GitHub's sort:created-asc), then runs the same team flow described
in How Ralph resolves issues — only the intake and
completion differ:
- The agent moves the file
todo → in-progresswhen it starts, resolves the task, commits directly toDEV_BRANCH(no branch, no PR, no merge), and moves the file todone/on success. - The bash loop owns the failure sweep: on a non-zero exit, a file left in
in-progress/, or a no-op (the agent exited 0 but left the file intodo/), bash moves the task tofailed/so the queue always advances and no task is silently lost. - The zero-progress guard still applies: if the same task is re-selected on consecutive iterations with no state change, Ralph stops rather than spin forever.
Per-task telemetry works exactly as in GitHub mode: the same
.ralph/metrics/issues.jsonl stream and daily heartbeat rollup serve both
sources, keyed on the task id, with the terminal directory (done/failed) as
the outcome and the frontmatter labels recorded (see
Monitoring data model). So does
ralph status: the loop
writes its run-state record from both sources, and in folder mode status
counts the queue off the local .ralph/tasks/ tree — no gh call, in keeping
with the rest of the mode.
Accepted tradeoff: committing straight to the dev branch means folder mode has no per-task rollback boundary — a bad autonomous commit lands directly on
DEV_BRANCH.
Scheduling Ralph (macOS launchd)
Beyond the manual ralph start flow, Ralph can run on a launchd
timer so it processes the queue without human intervention. This is
macOS-only; on Linux / WSL use cron or systemd.
ralph schedule install # cycle every 4h + heartbeat at 09:00 (defaults)
ralph schedule install --interval 30m --heartbeat-time 07:30
ralph schedule status # state of every Ralph agent on this machine
ralph schedule status --here # only the agent for the current repo
ralph schedule pause # unload without deleting the plists
ralph schedule resume # reload after a pause
ralph schedule remove # unload + delete plists for this repo
ralph schedule remove --all # unload + delete every Ralph plist (with confirm)install writes two property lists under ~/Library/LaunchAgents/:
| Plist | Schedule | Purpose |
| --- | --- | --- |
| com.lucasfe.ralph.cycle.<slug>.plist | StartInterval (default 4h) | Runs ralph cycle — one queue-processing pass. |
| com.lucasfe.ralph.heartbeat.<slug>.plist | StartCalendarInterval (default 09:00) | Sends the daily 24h summary. |
<slug> is the basename of the repo's working tree, so multiple
repos can each have their own pair of agents on the same user account.
pause, resume, remove, and status operate on both plists
transparently — there is no separate ralph schedule heartbeat
install. The ralph schedule heartbeat subcommand exists, but it is
the entry point launchd invokes when the heartbeat plist fires; you
will not normally call it by hand.
A scheduled pass is observable while it runs, not only after it: the cycle
writes the same run-state record an interactive run does, and proves it is alive
by holding the cycle lock rather than by owning a tmux session, so
ralph status reports it as running and points you at
logs/ralph-cycle.out.log instead of at an attach command (see
Run state).
A launchd agent sources no shell startup file, so the
EnvironmentVariables dict install writes into each plist is the
entire environment a scheduled run ever sees — not your .zshrc, and
not an export you typed in a terminal. Two values from the installing
shell go into it: PATH, and RALPH_NO_UPDATE_CHECK when that is set
to a non-empty value. Both are snapshots taken at install time;
re-run ralph schedule install --force to re-take them (see
Environment variables for what the second one
silences).
Updating Ralph
Ralph ships one update command, ralph update, and offers to run it for you
roughly once a week — from ralph start, or from a ralph cycle you run on a
terminal yourself. There is no ralph upgrade and no alias for one.
ralph changelog is the other half of that pair: what the version you are on —
or the one you just moved to — actually changed.
ralph update
ralph update updates the Ralph CLI itself — it is the one command that needs
neither a git repository nor an initialized Ralph project, so you can run it
from any directory. It asks the npm registry for the latest published version,
works out how this copy of Ralph was installed, and runs that package
manager's own global-install command, reporting both the version it came from
and the version it moved to. When you are already current it prints
✅ Ralph is already up to date (<version>). and installs nothing; pass
--force to reinstall the latest anyway (handy for repairing a broken
install). A failed registry query is reported and attempts no install (exit
code 1) — it never installs a version it could not confirm exists.
It only ever runs a package manager's global-install command, so it touches the installed package alone and writes no file in your project — see What survives an update.
The layout is worked out from where this copy of Ralph lives:
| Install layout | What ralph update does | Exit code |
| --- | --- | --- |
| Global npm — under npm root -g | npm install -g @lucasfe/ralph@latest | 0 |
| Global pnpm | pnpm add -g @lucasfe/ralph@latest | 0 |
| Global yarn | yarn global add @lucasfe/ralph@latest | 0 |
| Global bun | bun add -g @lucasfe/ralph@latest | 0 |
| npx — running out of the npx cache | Nothing to do: npx always fetches the latest published version. | 0 |
| Linked — the package root is a symlink, or holds a .git entry | Nothing: Ralph will not write a published tarball over a linked install or a working tree. A .git entry means a dev checkout, so it points you at git pull; a bare symlink gets the linking manager's own global-add command instead (or, when that is unclear, "update it with whichever package manager created it"). | 0 |
| Unrecognized, or ambiguous — the path matches two managers at once | Refuses to guess, explains what it found, and prints npm install -g @lucasfe/ralph@latest to run by hand. | 1 |
The two refusals — npx and linked — print ℹ️ Nothing for Ralph to update
here. followed by what was found and what to do instead, and exit 0:
nothing failed, there is simply nothing for Ralph to install. They are decided
from the package root alone, before any package-manager guess, so a dev
checkout linked into a pnpm or yarn store is still treated as linked rather
than reinstalled over. An ambiguous path deliberately falls into the last row
instead of picking a manager at random. One gap worth knowing: a pnpm global
directory with no pnpm path segment (pnpm 6's ~/.pnpm-global, a hand-set
global-dir, or PNPM_HOME=/opt/pnpm-home) is not recognized and lands in
that last row too.
When the install command fails
When the install command itself fails, ralph update exits with that command's
own exit code (1, 127, 243 — whatever it returned) and prints the diagnosis
under the headline instead of an opaque exited 1: a bounded tail of what the
package manager wrote, then a hint when the failure names a permission problem.
The tail is the last 12 non-blank lines — npm prints its error code at the
end of a log — each clipped to 200 columns, with every clip marked … and any
dropped lines counted in a … N earlier lines omitted line, so a multi-megabyte
npm log cannot flood the terminal and a truncated tail never reads as complete.
The hint fires on EACCES, EPERM, errno -13, permission denied or
operation not permitted appearing anywhere the failure carries text — both
streams, the error message, the error code — and it is matched before the
tail is clipped, so it still appears when the code itself was clipped away. It
names the two fixes that work, for the manager that actually ran: point the
global install directory somewhere you own (npm config set prefix
~/.npm-global, pnpm setup, yarn config set prefix ~/.yarn, BUN_INSTALL=…,
or, for a manager Ralph has no knob for, that manager's own global-prefix
setting), or re-run that one install with elevated privileges. The hint is
additive: the raw output above it is what tells a root-owned prefix apart from,
say, a manager binary that is not executable.
When both streams are empty — which is what a command that could not be spawned
at all looks like — the failure's own message is reported instead, bounded the
same way, so spawn npm ENOENT (npm is not on your PATH) is never swallowed;
only a failure that says nothing anywhere falls back to naming the command for
you to run yourself. Nothing ran in that case, so there is no exit code to pass
through and it exits 1. All of it goes to stderr, one whole line per write, and a
successful update writes nothing there at all — so a wrapper or CI step that
captures only stderr keeps the whole diagnosis and nothing else.
ralph changelog
ralph changelog answers the question an update leaves behind: what changed. It
is the other command about the install rather than about a project, so like
ralph update it needs neither a git repository nor an initialized Ralph project
— no ralph.config.sh, no .ralph/ — and runs from any directory. The default
view is the three newest releases, in the order the file lists them, under a
count of how many it holds and a pointer to the rest:
Ralph changelog — the 3 newest of 31 releases
run `ralph changelog --all` for every release
0.22.0 — 2026-08-27
Features
• `ralph digest --loop` + a digest window in the tmux session (#62) (#95) (a2f9464)
• `ralph digest` one-shot — no-tool narration on a cheap model (#61) (#93) (6687570)
• a digest section in `ralph status` (#63) (#96) (a6c37ba)
• commit the sprite asset and show it statically in `ralph start` (#67) (#97) (541616f)
0.21.0 — 2026-08-26
Features
• GIF-to-sprite generator and pure half-block renderer (#66) (#87) (6d1834b)
• idle post-mortem and never-run pointer in `ralph status` (#59) (#91) (46ddd1e)
Bug Fixes
• never finish a turn with a subagent in flight (#88) (#89) (c18ea21)
0.20.0 — 2026-08-26
Features
• `ralph status --json` (#58) (#84) (15c8ae0)
• launch projection and `ralph status` hint in the `ralph start` box (#60) (#85) (ec042ac)
• observed pace, ETA with range, and spend projection in `ralph status` (#57) (#83) (89da13d)
• print the update notice in `ralph cycle` (#51) (#79) (2cde79f)
• run-state file + `ralph status` reporting the in-flight task (#55) (#82) (330cedf)
• TTY-gated update prompt in `ralph cycle`, stopping the drain after an install (#52) (#81) (c4a9ec8)--all prints every release in the file instead. Having held nothing back it
drops the pointer line, and the header reads Ralph changelog — 31 releases.
Three is a count of releases, not of bullets: the newest entry is printed
whole, which is the point of the command. The identity box
ralph start opens with shows the first three bullets of that
same entry, clipped to its width; nothing is clipped here, so a bullet longer
than your terminal wraps rather than losing its tail. Structure comes from
indentation — two spaces for a section heading, four and a • for a bullet —
and the listing carries no colour and not one escape byte, so
ralph changelog --all | grep digest, a pager, or a paste into an issue comment
all give back exactly what you saw. A release with no day on its heading (an
Unreleased entry) prints its version alone rather than a dangling separator.
What it reads is the CHANGELOG.md inside the installed
package, resolved against the install and never against your working
directory — so standing in a project that has a CHANGELOG.md of its own still
prints Ralph's releases and never yours. It is one local read: no registry
query and no network call at all, which is what makes it answerable offline,
instantly, from anywhere.
A changelog it cannot answer from costs you two lines on stderr — what it
could not do, and what to do about it — and exit code 1, never a stack trace.
Stdout is left empty in every case, so a pipe into a pager gets an empty document
rather than half a listing. The three failures are worded apart because the
repairs differ: a file it could not read is reported with the path it tried and the note that
reinstalling Ralph restores it (a pruned install, or a tarball built without the
file); a file it read but could not parse says that instead; and a file that is
readable but holds no ## <version> release heading is named along with how many
characters long it is. This is where the command and the identity box part
company: the box drops rows nobody asked for and starts the loop, while a
question you typed is owed either an answer or a failure.
The weekly check
Before the loop launches, ralph start asks npm for the latest published
version — at most once every 7 days — and prints
New version available: <version> (run npm i -g @lucasfe/ralph to update)
when what it knows about is newer than what you have. The notice itself is not
throttled and keeps printing on every run that finds something newer (see
Troubleshooting).
ralph cycle runs the identical check, and on a terminal asks the identical
question. A scheduled cycle (see
Scheduling Ralph) runs the same check and
prints the same one-line notice, on stdout — which launchd captures in
logs/ralph-cycle.out.log, where an unattended run is read. Both global
7-day windows are shared with ralph start, so six cycles a day cost at most
one registry query and one question a week between them, not six a day — and
being asked by one of the two commands this week means the other will not ask
again until the window rolls over. ralph doctor is the exception that draws
from neither: it reads that same file for the cached row of its identity box
and stamps neither window, so running doctor never spends the week's question.
A scheduled cycle is notice-only: launchd attaches no terminal, so the
question below is never asked and its window is never spent. Ralph never
auto-updates on a schedule — an unattended tick notifies and nothing more;
the install runs only after a human answers the question on a terminal. And
printing is not asking, so the check can never block a scheduled tick and
never fail one: it prints, and the pass drains as it always would. To silence a
scheduled cycle, RALPH_NO_UPDATE_CHECK has to
reach the launchd agent, which is not the same thing as exporting it in your
shell.
When that notice fires, stdin is a terminal, and you have not already been
asked in the last 7 days, ralph start asks Update now? [y/N]: — before the
gh checks and before the tmux session, so nothing has been launched yet when
you answer. An empty answer declines, and so does anything that is not y;
the answer is trimmed and lowercased first, so Y and y accept too.
Answering y runs the same update ralph update does, prints
✅ Updated to <version> — run `ralph start` again., and exits 0 without
starting the loop: the running process still holds pre-update code and the
old install's copy of the loop script, so re-launching by hand is the only way
to be sure the loop runs one version rather than a mixture of two. Declining
costs nothing — the loop starts immediately, with no extra output. An update
that does not complete is not fatal either: a failed install, or an npx run /
linked dev checkout with nothing for Ralph to install, prints
⚠️ Update did not complete — starting Ralph on <version>. and the loop runs
on the version you already have.
Without a TTY — cron, launchd, CI, a piped stdin — nothing is ever asked and the loop always starts. Because no question was displayed, the prompt window is left untouched, so a nightly headless run cannot spend the week's question on nobody.
Run ralph cycle by hand on a terminal and the same question follows its
notice — same wording, same window — with one difference in what accepting
means: the update runs, ✅ Updated to <version> — run `ralph cycle` again.
is printed, and the cycle stops without draining the queue, for the reason
ralph start refuses to launch. This process holds pre-update code and reaches
the loop script through the install that was just replaced, so stopping is what
guarantees no issue is processed by a mixture of two versions. Nothing is lost:
re-run ralph cycle yourself, or let the next scheduled tick pick the new
version up on its own. Declining, a failed install, or an npx run / linked
dev checkout with nothing to install all leave the cycle draining normally on
the version you already have — the last two after one neutral line,
⚠️ Update did not complete — continuing this cycle on <version>. A stopped
cycle still appends one RALPH_CYCLE_EVENT (status updated, every count
zero) to logs/ralph-cycle.out.log, so the
daily heartbeat counts the tick but attributes
no issues and no run time to it.
The question is asked at most once every 7 days, throttled by its own
last_prompted_at stamp in the global update-check cache — a window
independent of the weekly registry query, so declining is remembered, but
only until that window rolls over. Being shown the question is what consumes
the window, and the stamp is written before your answer is read, so
interrupting at the prompt (Ctrl+C) still counts as having been asked and you
are not asked again on the next run seconds later. There is deliberately no
per-release dedupe and nothing records which version you turned down: once
the week is up the same still-newer version is offered again, so a release you
deferred is never permanently forgotten.
To silence the check, the notice, and the question together, set
RALPH_NO_UPDATE_CHECK.
Where the check keeps its state
Both 7-day windows are global, not per-repo. They live in
$XDG_CONFIG_HOME/ralph/update-check.json, or
~/.config/ralph/update-check.json when XDG_CONFIG_HOME is unset or blank
(the value is trimmed before it is used) — one file for your whole machine, so
five Ralph repos cost one check a week between them rather than one each, and
one question a week between them rather than five.
The file holds two independent windows: last_check_at gates the registry
query, last_prompted_at gates the question. Neither gates the other, so a run
can query the registry without asking you anything, and can ask without
querying. A scheduled ralph cycle is the pure form of the first case: it
reads and writes the same file and stamps last_check_at, but never
last_prompted_at, because launchd gives it no terminal to ask on. Run by hand
on a terminal it stamps both, out of the very same windows ralph start draws
from. The prompt is always served from the cached latest_version,
so a query that was skipped or that failed outright still gets you the question
as long as what is cached is newer than what you have — which is the point: a
flaky network no longer hides an update Ralph already knows about. With the
network down and nothing useful cached there is simply nothing to say, and
ralph start goes quiet and launches the loop. A stamp that is missing,
unparseable, or somehow in the future counts as an open window rather than
one that never expires.
The file is separate from the credential dotenv (ralph/.env) in that same
directory, which the check never reads or writes — and it lives outside your
project entirely, so deleting .ralph/state.json or the whole .ralph/
directory resets neither window. .ralph/state.json does still carry a
last_seen_release field (the state writer requires it), but nothing in the
update path reads it any more: it is a leftover of the old per-release dedupe,
not a knob. ralph doctor reads the update-check file for the cached row of
its identity box, and only reads it: it makes no registry query and stamps
neither window, so running doctor neither refreshes the weekly check nor
consumes the week's question. The update row of the identity box
ralph start opens with reads it the same way — one field,
latest_version, no query and no stamp, so it can never move either window — with
one difference from doctor: it is silenced by
RALPH_NO_UPDATE_CHECK, and on that path the file is
not opened at all. doctor's row has a switch of its own instead, and it is the
one that removes the whole box: under
RALPH_BANNER=off there is no row to fill, so that
run does not open the file either.
What survives an update
ralph update, a manual npm i -g @lucasfe/ralph@latest, and a re-run of
ralph init all treat user-authored config files as read-only. Updating will
never silently overwrite credentials, secrets, or your project notes.
ralph update is the strongest case of the three: it runs a package manager's
global-install command and nothing else, so it writes no project file at
all — the table below is about what a re-run of ralph init leaves alone.
| File | Status on re-run | How to overwrite |
| --- | --- | --- |
| .env.local | Never written or modified. Ralph only writes .env.local.example (a template you copy from). | Edit by hand; Ralph stays out of it. |
| ralph-notify.sh | Never written or modified. Ralph only writes ralph-notify.sh.example. | Edit by hand. |
| PROMPT.md | Preserved on re-run; Ralph prints PROMPT.md already exists — leaving it alone (pass --reset-prompt to overwrite). | ralph init --reset-prompt |
| ralph.config.sh | Preserved on re-run. | Delete the file and re-run ralph init. |
| .claude/commands/ralph.md | Preserved on re-run. | Delete the file and re-run ralph init. |
| .env.local.example | Overwritten on every run (it is a template, not a credential store). | n/a |
| ralph-notify.sh.example | Overwritten on every run (template). | n/a |
| .gitignore | Ralph appends missing entries idempotently; existing lines are untouched. | n/a |
The split is enforced by automated tests in lib/init.test.js, so a
future template-management refactor cannot silently break the invariant.
Configuration reference
ralph init writes ralph.config.sh at the repo root. It is meant to
be committed. Re-running ralph init never overwrites it.
| Variable | Default | Purpose |
| --------------------- | ------------------------------------ | ----------------------------------------------------------------------- |
| RALPH_AGENT | claude | Coding agent Ralph drives: claude (default, Claude Code) or codex (OpenAI Codex CLI, experimental). Unset or unrecognized falls back to claude (with a warning). Set by `ralph init --age
