Skip to content

swinney/keep-on-ralphing

Repository files navigation

keep-on-ralphing

A reusable Ralph loop harness: drive an LLM coding agent through a task list one turn at a time, in a sandbox container, using the commit graph as the objective progress signal. Install it once, point it at any project.

Distilled from the Keep on the Borderlands build and generalized. Scope is deliberate: Linux + podman, single architecture, team-shared via GitHub. Not multi-arch, not macOS/Windows, not Docker-tested.

Three channels

The harness splits by where each piece runs:

   IN-CONTAINER            HOST-SIDE (Claude Code)        IN-REPO (data)
   ────────────            ──────────────────────         ──────────────
   the loop runner    →    /ralph-init      (scaffold)  →   ralph.conf  PROMPT.md
   (ralph.sh,              /ralph-status    (report)        tasks.md    STATUS.md
    until_reset.py)        /ralph-build-base (build img)    scripts/gate.sh + hook
   ⇒ base/ image          ⇒ skills/ (this plugin)         .github/workflows/ci.yml
                                                          specs/ tests/ decisions/
                                                          thin Containerfile + Makefile
                                                          ⇒ generated by /ralph-init
  • base/ — the podman base image. It bakes ralph.sh + until_reset.py onto PATH, so a consuming project carries no machinery; its Containerfile just FROMs the base. One source of the runner → no drift.
  • skills/ — the Claude Code plugin: /ralph-init scaffolds a project's config from templates/; /ralph-status reports loop state from .ralph/; /ralph-build-base builds ralph-base:v1 from the plugin's bundled base/ (clone-free); /ralph-upgrade brings an already-scaffolded project's config up to the current templates (see Upgrading).
  • templates/ + example/ — the per-project config surface and a fully resolved sample.
  • extras/ — unsupported odds and ends (see its README).

Use it on a project

Install the plugin (Claude Code):

/plugin marketplace add swinney/keep-on-ralphing
/plugin install ralph-harness@keep-on-ralphing

Build the base image once — no clone, it ships with the plugin:

/ralph-build-base    # builds ralph-base:v1 from the plugin's bundled base/ (registry-free)

The plugin bundles base/ + the Makefile, so the image builds straight from the install. Re-run /ralph-build-base after a /plugin update that touched the runner. (Contributors working from a keep-on-ralphing checkout can make build-base instead.)

In your project:

/ralph-init          # scaffolds config + an ENFORCED gate (scripts/gate.sh, a
                     # pre-commit hook, CI) + the specs/tests/decisions skeleton
make build           # your loop image, FROM ralph-base (also installs the gate hook)
make login           # one-time Claude Code auth
make loop            # run the loop (Ctrl-C to stop)   |  make loop-once for a single turn
/ralph-status        # check on it anytime

The loop stops when a turn writes a reason to STATUS.md, after RALPH_MAX_STALLS consecutive no-commit turns, or on Ctrl-C.

The scaffolded gate is enforced: scripts/gate.sh is the single source of the gate command, a pre-commit hook runs it and blocks red commits, and a CI workflow runs the same script. A red gate therefore yields no commit — which the loop counts as a stall — so broken work halts the loop for review instead of compounding. The gate also includes a coverage threshold (a global floor by default), so "tests pass" can't stand in for "the code is tested" — a supporting check alongside the review gate above.

The hook is installed via core.hooksPath (the Makefile hooks target), which is repo-wide: once set, it gates every commit, host or container. The gate runs where its toolchain lives — the container — so host-side committers need that toolchain locally or should commit via make shell.

Outer-loop review gate (on by default)

The local gate above is the inner loop. There is also an outer loop (RALPH_REVIEW_GATE, on by default): after a committing turn the runner pushes the branch, opens/uses a PR, asks an independent reviewer (GitHub Copilot by default) to review it, and treats zero findings + green CI as the only PASS. Findings are written to review-findings.md, which the agent must resolve before any new task; persistent findings halt the loop for a human after RALPH_REVIEW_MAX_ROUNDS. RALPH_AUTO_MERGE (separate, default off) controls whether a clean PR auto-merges or is parked for you. All GitHub work is the runner's — the container agent never touches git/gh — so the gate works with any coding agent. Independent review catches the "wired-wrong but green" class that tests, types, and lint structurally cannot (the lesson the field log earned the hard way).

This makes the loop GitHub-dependent by default: loop mode refuses to start without a git remote, an authenticated gh, and a non-base feature branch. /ralph-init ensures these during setup. If you want a purely offline loop with no review, set RALPH_REVIEW_GATE=0.

The base image

ralph-base:v1 is built locally from base/Containerfile (no registry). It is python:3.12-slim + git/node/gh/Claude Code + the runner machinery on PATH + a UID/GID-matched non-root user (so bind-mounted files are host-owned under rootless podman). gh is included because the review-gate runner runs in the container; the consumer Makefile forwards a host-derived GH_TOKEN to authenticate it. A consumer's Containerfile is just FROM ralph-base:v1 plus its own toolchain. Pushing to a registry (GHCR) is an optional later add — never required.

The build material ships inside the plugin (base/ + the Makefile), so /ralph-build-base builds the image clone-free via make -C "$CLAUDE_PLUGIN_ROOT" build-base; a keep-on-ralphing checkout can run make build-base directly. Because the runner travels with the plugin, a /plugin update + /ralph-build-base keeps the image in lockstep with the installed plugin — resolve $CLAUDE_PLUGIN_ROOT at build time (the versioned plugin cache is pruned after an update), never a frozen cache path.

Freshness. The :v1 tag is content-mutable, so the build bakes a content-hash provenance stamp into the image. You don't have to track rebuilds by hand: /ralph-build-base rebuilds only when the image is actually stale (and skips otherwise), /ralph-status flags a drifted base, the runner prints its baked stamp at startup, and make loop refuses a loop image built on a superseded base (run make build). Freshness keys on the content hash (plus the host UID/GID), not the plugin version.

Upgrading an already-installed harness

Upgrading spans all three channels (see Three channels): the plugin (skills + templates) updates via /plugin update, the base image (the runner) via /ralph-build-base, and your in-repo config (the files /ralph-init generated) via /ralph-upgrade (steps 1–3 below). The config channel needs its own step because re-running /ralph-init does NOT upgrade an existing project — it is no-overwrite (it only scaffolds missing files, never changes ones you already have), so propagating template changes is /ralph-upgrade's job, not init's.

1. Update the plugin (latest skills + templates):

/plugin marketplace update keep-on-ralphing
/plugin update ralph-harness@keep-on-ralphing
/reload-plugins

2. Rebuild the base image when the runner changed (most releases) — the loop keeps running the old runner until you do:

/ralph-build-base        # rebuilds ralph-base:v1 from the freshly-updated plugin

then make build in each project. /ralph-build-base is idempotent (it skips the rebuild when already current), and /ralph-status plus the make loop check-base preflight flag a stale base, so you don't have to track this by hand.

3. Adopt config changes with /ralph-upgrade. Because /ralph-init never overwrites, new template content doesn't reach a project that already has those files — so run /ralph-upgrade, which detects what's missing and proposes a confirm-gated merge that preserves your customizations. It covers the whole in-repo config channel:

  • Makefile — the one that can break the loop. It forwards GH_TOKEN into the container (the default-on review gate needs it) and adds a check-base preflight; a Makefile missing the GH_TOKEN forwarding makes a review-gated loop refuse to start. /ralph-upgrade flags this as high-stakes and inserts the missing blocks (or set RALPH_REVIEW_GATE=0 for an offline loop).
  • ralph.conf — appends new keys commented / at defaults (behavior unchanged until you tune them).
  • PROMPT.md — offers to append new contract clauses, preserving your edits.
  • New files (e.g. docs/operator-checklist.md) — created if absent.

/ralph-upgrade is precise when the project carries a tracked .ralph-scaffold.json (written by /ralph-init: it classifies each file as pristine-since-scaffold vs. customized) and falls back to feature-detection on older projects that lack it. It is config only — it never rebuilds the base image (that's step 2 / /ralph-build-base). To merge by hand instead, diff against the plugin's bundled templates/ (under $CLAUDE_PLUGIN_ROOT, resolvable inside Claude Code) or example/.

See CHANGELOG.md for the per-version feature list — entries that change the runner are marked "rebuild the base image."

Configuration

Everything project-specific lives in one ralph.conf (env vars override it). See templates/ralph.conf.example for every key. /ralph-init fills it for you.

Watching a loop in realtime

The loop is a log source: it writes .ralph/status.jsonl (structured, one record per turn) and, with RALPH_LIVE_LOG=1 (default), an append-only .ralph/log/live.log that interleaves runner narration with agent output, every line turn-prefixed — one stable tail -f target. Point your own aggregator at the bind-mounted files. See docs/recipes/vector-console.md for a zero-backend Vector recipe (console sink + vector top) that also centralizes several loops via one glob.

For a glanceable built-in view, set RALPH_DASHBOARD=1 (opt-in, default off) and make loop auto-launches an ephemeral localhost web dashboard — a churn-weighted Commit Ribbon, an X/N task arc, and a stakes strip (rate-limit countdown, stall-pressure meter, review-round pips) that lights only while the loop is live, tearing itself down when the loop ends. It runs host-side against the bind-mounted .ralph/, so the loop container stays port-free — the "log source, not a service" stance is intact. See docs/recipes/dashboard.md.

Getting summoned back

Watching is the pull half; RALPH_NOTIFY_CMD is the push half. Set it to a command and the runner (never the agent) pings you at every needs-human halt — stop, stall, review-exhausted, or blocked (a new docs/questions.md entry, which now stops the loop immediately instead of burning stall turns). It is opt-in (empty = off) and non-fatal (a failing/slow notifier never changes the loop's flow). See docs/recipes/slack-notify.md for a Slack-webhook recipe and a zero-dep gh pr comment alternative.

Work-class model dispatch (optional)

One model for everything wastes the strong model on trivial tasks and stalls the cheap one on hard tasks. So the runner can match the model to each task's work class: tag a task in tasks.md with a trailing class token —

- [ ] 2.3 migrate the session store (stateful)

— and map that class to a model in ralph.conf with RALPH_MODEL_<CLASS> (e.g. RALPH_MODEL_STATEFUL="claude-opus-4-8"). That task's turn runs on the mapped model; an untagged task uses RALPH_MODEL exactly as before, so the feature is inert until you both tag tasks and set keys. Classification is the operator's call — the runner never auto-classifies. It is a cost dial, not a correctness lever: the gate and commit-as-truth remain the correctness guarantee, so a misclassified task can at worst stall (caught by the stall detector), never ship a bad commit.

One orchestrator per workspace

The runner takes a flock on $RALPH_STATE_DIR/lock on startup and refuses to start a second concurrent loop on the same workspace — competing loops corrupt shared state (.ralph/, the branch). Because the loop runs as PID 1 in its container, the lock uses flock (not a PID file): the kernel releases it when the holder dies — including a podman stop/SIGKILL/OOM that skips the exit handler — so a leftover lock self-heals and the next start reacquires it cleanly, rather than a stale "PID 1" blocking the loop forever. (Where flock is unavailable, outside the supported Linux+podman scope, the runner warns and runs without the lock.)

Autonomy is a gated opt-in; velocity targets serial latency

Unattended (walk-away) operation is an opt-in mode, not the headline — in evidence it was catalytic and narrow-band, not a general accelerator. Walk away only when all four preconditions hold: (1) the work is well-specified, (2) the model is matched from turn 1 (tag the class, don't "start cheap, escalate"), (3) you are genuinely absent (if you'll watch anyway, run it supervised — supervision catches more), and (4) for fan-out only, single-unit build time dominates per-unit coordination cost. If any is false, build supervised-direct. Parallel content fan-out (extras/) is serial-by-default / unsupported and was net-negative at small unit size — not a turnkey speedup.

To go faster, target serial latency, not turn throughput: the bottleneck is the human-gate cycle (PR → CI → review → fixes → merge), so batch a milestone per PR, auto-merge on a clean review, and match the model to the work class — rather than adding containers/agents to the same milestone (systems-layer work shares files and collides; turn throughput was never the constraint).

/ralph-init scaffolds an operator checklist (docs/operator-checklist.md) carrying these preconditions plus the pre-action checklists — and the caution that a clean commit graph is not evidence of a well-run session ("resilience masks sloppiness"); discipline needs its own signal, not the green graph.

Tests

make test     # or: bash base/tests/run.sh

Runs the until_reset.py unit tests, the ralph.sh behavioural scaffold (git-fixture based, stubs the claude binary), the gate-hook test (renders the gate templates into a fixture repo and asserts a red gate blocks the commit), and the review-gate test (stubs gh + a bare remote to drive the outer loop with no network). Self-contained — needs only bash, git, python3 (+ pytest), and timeout.

Lineage

The methodology behind the loop (why it's shaped this way) and its field log live alongside the harness; the original incubator was keep-on-the-borderlands, which is now this harness's first consumer.

About

keep-on-the-borderlands' ralph-loop experiment deliverable. Glorious Ralphs.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors