001 start
Getting started
Install the CLI, let `looply init` profile the repository and ask the five things it cannot infer, prove your checks with `looply doctor`, then fill the queue, check what is in it, and start a run.
looply does not write code. A coding agent you already have — Claude Code by default — writes it. looply decides what that agent is handed, holds the rails it may not cross, runs your checks, asks a second agent to prove the change against the task, and then keeps or discards the result. Setting it up on a repository is one file and a short sequence of commands: install, looply init, looply doctor, then looply scan, looply verify, looply run.
What you need first
| Requirement | What the code expects |
|---|---|
| Node 20 or newer | The workspace and the CLI package both declare engines.node as >=20. The CLI is plain Node with one third-party runtime dependency, yaml; the other, @looply/core, is built from the same checkout. |
| A git repository with a clean tree | The round runner reads git status as its first act and refuses to start while any path outside .looply differs from HEAD: "working tree has N changed path(s) — refusing to start (commit or stash first)". The state directory is filtered out of that check, so the runner's own writes never read as agent changes. That check sits inside the runner, so it fires after looply run has loaded the config and, when nothing is ready, after it has already paid for a verification pass. |
| A coding agent you can start from a shell | The default command line is claude -p --dangerously-skip-permissions, with --output-format text or --output-format json appended by looply. Point agent.command at a different agent if you use one; the prompt travels on stdin, never on the command line. |
| At least one check that exits non-zero when it fails | gates is a required key. A config with no gate step does not load at all, so a repository with no test runner leaves you a line to write by hand before anything can run. |
Install
git clone <your checkout of looply> looply
cd looply
npm install
# builds @looply/core, then the CLI, into packages/cli/dist
npx tsc -b packages/cli
node packages/cli/dist/bin.js version # 0.1.0The CLI package is looply-cli and declares a looply binary pointing at dist/bin.js, so npm link from packages/cli puts looply on your PATH. The rest of this page writes looply; substitute node <path>/packages/cli/dist/bin.js if you have not linked it. Build the CLI target specifically rather than the root npm run build, which also generates a Prisma client and compiles the server you do not need in order to run a loop.
looply init
Run looply init in the repository you want worked on. It profiles the repo, prints what it found before it asks anything, puts five questions, and writes .looply/config.yaml. It never overwrites: if that file exists it prints "already exists — move it aside first for a fresh draft" and exits 1. The file is meant to be read and edited afterwards — every value carries a comment saying what it costs.
| Detected automatically | How it is decided |
|---|---|
project.name | The name field from package.json, else the name of the current directory. |
| Package manager | The packageManager field in package.json wins; then lockfiles in this order — pnpm-lock.yaml, yarn.lock, bun.lockb, bun.lock, package-lock.json; then npm. It is printed with the evidence that decided it, and used only for the fallback test command. |
| Workspaces | package.json workspaces (either the array or the packages: object), else packages: in pnpm-workspace.yaml. Each becomes a <workspace>/** glob in scope.free; with none detected, scope.free is **. |
| Typecheck gate | The first package.json script named typecheck, type-check, types, tsc or check-types, run as npm run <name> --silent. With no such script it falls back to tsconfig.json: npx --no-install tsc -b when the file has a non-empty references array, otherwise npx --no-install tsc --noEmit. Neither present means no typecheck gate. |
| Test gate | jest in dependencies or devDependencies gives npx --no-install jest --ci; vitest gives npx --no-install vitest run; otherwise a test script, run as <package manager> test. A test script containing "no test specified" is ignored — npm seeds one that always exits 1, and a gate built on it would fail every round. |
Two details worth knowing before you read the file it writes. The gates land with fixed budgets — timeout_s: 300 for the typecheck step and timeout_s: 900, required: true for the test step — whatever the detector's own numbers were. And when a package.json script matched, the typecheck line is written as npm run <name> --silent even if detection reported pnpm or bun, so change it by hand if npm is not on the machine; the tsconfig fallback uses npx --no-install tsc instead. If neither gate is detected, init still writes the file with a comment where gates belongs; that file will not load, and looply doctor refuses it with "config: gates — must be a non-empty list".
Then it asks five things. These are the answers that are expensive to get wrong and that no repository states about itself. Pressing enter takes the default shown in the prompt; a rejected answer is re-asked rather than aborting. --yes or -y skips all five, and so does a non-TTY stdin — a piped setup command that waits for a prompt hangs forever, so it takes the defaults instead and warns "no rails were set — nothing in this repo is off limits".
| Question | Default, and where the answer lands |
|---|---|
| Which paths must looply never touch? | Default none. Written to rails.forbidden_paths. Init offers suggestions — prisma/, migrations/, src/common/guards/, src/modules/payments/, src/modules/billing/, src/auth/, infra/, .github/workflows/ — and applies none of them. An absolute path, or one containing .., is rejected and re-asked. |
| Can this machine reach a production database? | Default no. Both answers write the same rails.prod_patterns list (herokuapp, amazonaws, .render.com, supabase.co, .neon.tech); a yes only adds a comment above it. The interlock is armed either way. |
| May looply publish anything? | never (default), on-approval, or manual. Written to publish.policy, which no code in the CLI reads. Nothing is pushed under any of the three: the runner commits to a local branch and has no push path. |
| What kind of work should it do? | improve (default) or anything. improve writes the detected globs into both scope.free and scope.additive; anything writes them to scope.free and leaves scope.additive empty. |
| How long should a run go by default? | 4 hours. Must be positive and 24 or less. Written to run.min_hours, which is a floor rather than a deadline — the run finishes the round it is on. |
Only one of those five is enforced mechanically. rails.forbidden_paths and rails.prod_patterns are interlocks: they are checked against the round's changed paths and against your environment before any gate step runs, and a hit aborts rather than failing a check — abort code 91 for a production string, 92 for a railed path, recorded on the gate.result event rather than returned as a process exit status, since the process itself still exits 1. The rails page has the full account. scope.free and scope.additive are text in the implementer's prompt — the agent is told where it may work, and nothing in the runner rejects a diff for leaving that scope. Put anything you genuinely cannot afford to lose in the rails.
looply doctor
looply doctor loads the config, prints the decisions in it, and then runs the interlocks and one full gate pass with no agent call — which is how a wrong tsc invocation costs you ninety seconds instead of round one of an overnight run. It differs from the gate a run uses in two deliberate ways: every step runs, so one pass names every broken command rather than the first, and there is no diff, so when: filters do not apply. Each step gets the same timeout_s the real gate would give it (600 seconds when the key is omitted). Output is kept only for steps that failed. It exits 0 when every step passes, 1 on a config error, an interlock abort, or any red step. Only the production interlock can fire here, since doctor passes an empty diff. The first lines echo the config as parsed, which is why a freshly initialised repo reads notify : push every 30min — looply init writes channel: push, rather than leaving the parser's own default in place.
$ looply doctor
project : shopfront
budget : 4h, up to 400 rounds
proof : on
notify : push every 30min
gates :
- typecheck: npm run typecheck --silent
- jest (required): npx --no-install jest --ci
rails : (none)
warning : rails.forbidden_paths is empty — nothing in this repo is off limits
doctor : running interlocks and one full gate pass (no agent call)
FAIL typecheck (439ms)
'tsc' is not recognized as an internal or external command
FAIL jest (2347ms)
No tests found, exiting with code 1
doctor : gate steps failedScan, verify, run
looply scanfills the queue. Six read-only lenses run one at a time — silent-failures, dead-ends, missing-states, untested-logic, accessibility, boundaries — each pass capped at 8 minutes and asked for at most 8 findings. Everything it files enters the queue asdraft: a claim about the code, not yet a task.looply verifychecks those claims. A read-only agent judges each draft against the code as it stands now, 5 minutes per item. confirmed and amended becomeready; stale, duplicate and blocked move to those states; too-big becomessplit; unclear goes back todraft. Onlyreadyitems are pickable. It also picks up anything left inverifying— that state is held only for the length of one verifier call, so an item still in it is one whose process died mid-call, and left out it would be stranded forever.looply runworks through them. If nothing is ready but drafts exist, the run performs the verification pass itself before picking. If nothing is pickable after that, it exits 1 with "nothing to do — no ready items: …" and names what the queue actually holds.
looply scan # one lens at a time, six by default
looply scan --lens boundaries,untested-logic
looply verify --max 5 # one agent call per draft item
looply queue # what is waiting, and why it is waiting
looply run --dry-run # plan only: branch, budget, queue, gates
looply run --hours 2 # override run.min_hours for this run
looply stop # from another terminal: end after this round| Command | Flags, with the defaults the code uses |
|---|---|
looply init | --yes / -y takes every default. There is no --config: it always writes .looply/config.yaml under the current directory. |
looply doctor | --config <path>, default .looply/config.yaml. |
looply scan | --config <path>; --lens a,b picks from the six keys, all six by default; --per-lens N, default 8; --dry-run. A --lens list matching no key is an error naming the available ones. |
looply verify | --config <path>; --max N checks at most N items and leaves the rest as they are. With nothing waiting it says "nothing to check — no items are waiting to be confirmed" and exits 0. |
looply queue | --all adds the settled states — done, closed, stale, duplicate — which are hidden otherwise. No --config: it reads .looply/state/queue.json and nothing else. |
looply run | --config <path>; --hours N overrides run.min_hours for one run; --dry-run. |
looply stop | --now, which changes nothing about when the run ends and only prints a line saying so. No --config: it reads .looply/state/status.json and writes .looply/state/control.json. |
| Everywhere | --no-color, stripped from the arguments before the command is dispatched. looply status, looply report, looply queue and looply stop read only their state files and take no --config; looply agent also reads .looply/config.yaml from the default path when it is there, and still answers when it is not — JSON unless you pass --text. |
--dry-run does not mean the same thing on both commands. looply run --dry-run calls no agent at all: it prints the branch it would create and the sha it would branch from, the budget, the ready count, whether proof is on, the gate names, and the title of the item it would start with, then exits. looply scan --dry-run still runs every lens and still pays for every agent call; it only skips writing to the queue and the ledger — and with no ledger open, nothing reaches a paired phone either.
A run is a loop of rounds: pick an item, hand it to the agent, run the gate, prove the diff with a second agent, then commit or discard. The implementer gets 45 minutes per attempt, the prover 10, each gate step its own timeout_s. Gate steps run in file order and stop at the first failure, so put the cheapest first. Commits go to a fresh looply/run-<YYYYMMDDHHMMSS> branch and are never pushed. Between rounds — never inside one — it checks Ctrl-C first, then .looply/state/control.json, then the budget (min_hours, max_rounds 400), and sleeps cooldown_s (5) after each round; a limit reached mid-round lets that round finish. Ctrl-C once is a graceful stop that ends the run with signal after the current round; a second press only re-arms Node's own handling, so it takes a third to kill the process outright. proof.on_partial set to keep or discard is obeyed directly, but ask has nobody to ask from a terminal run, so the round is discarded and the log says so. Notifications shell out to ~/.looply/notify/<channel>.sh; with no such script the channel is disabled and the run continues without it.
The runner owns git. looply run prints the branch, the base and the budget; the round runner then checks the tree and creates that branch itself, once. A branch name that already exists is still refused rather than reused, so a second run started in the same second of the clock is the one case where you will see "branch already exists". A whole round — pick, implement, gate, prove, then commit or discard — is driven end to end against a stubbed agent by scripts/round-e2e.sh (npm run test:round), in both the kept outcome and the one the prover rejects.
Seeing the queue, and stopping a run
looply queue prints what is waiting and why. It reads .looply/state/queue.json and nothing else — no config, no agent call — and groups the items by state in triage order: ready, in progress, being checked, draft, blocked, split. Under each item it prints the three things that decide whether it is worth picking: where a round would start, which is the first piece of evidence as path:line; how many attempts it has already had; and why it is where it is — the blocked reason, the item a duplicate was merged into, or the reason the last attempt was thrown away.
The settled states — done, closed, stale, duplicate — are left out by default, with one line saying how many were hidden and that --all shows them. A queue worked through a night is mostly history, and history at the top buries the two or three items you can act on. When nothing is ready but drafts exist the screen ends by saying nothing is pickable yet and pointing at looply verify; an empty queue says so and points at looply scan.
$ looply queue
2 ready to pick
itm_01k3xm7q findOne is not scoped by companyId
src/modules/catalog/categories.service.ts:64
itm_01k3xm81 the table charge is billed but never shown to the diner
src/modules/menu/table.ts:118 · 1 attempt
last attempt: no test covered the new branch
1 blocked — needs you
itm_01k3xn0d refresh tokens are never rotated
src/auth/tokens.ts:203
needs a schema change, and prisma/ is railed
4 more already dealt with — looply queue --alllooply stop asks a live run to end. It writes a stop request to .looply/state/control.json, which is the only channel the two processes share — a run started from another terminal, or spawned detached by the daemon, has no memory in common with the command you are typing. The runner reads that file between rounds, and writes resume back the instant it acts on a stop, so a request cannot outlive the run it was aimed at; a run also clears any pending request as it starts, so last night's stop cannot wedge tonight's run. The round in flight always finishes. --now does not change that and is not meant to: it prints one extra line saying the round will finish, because cutting one off halfway leaves a half-written tree and the next looply run refuses to start on one. The phone writes the same file, and can also write pause, which holds the run on its branch with its queue intact rather than ending it.
With no run going, looply stop writes nothing at all and says "no run is going — nothing to stop". Writing a request nobody will read would leave a file that looks like a pending order to whoever finds it next, and the next run would clear it unread anyway.
Everything looply keeps lives in .looply/ beside the config: state/queue.json is the truth for curation, state/events.jsonl is the append-only ledger, state/status.json is the projection looply status reads, and looply report reads the ledger. Git never stages that directory and the discard path's git clean -fd skips it explicitly, so a reverted round cannot take the record of itself with it. looply queue and looply stop read and write that directory too, which is why neither takes a config. Pairing a phone with looply pair and looply daemon is optional throughout — unpaired, all of the above works and the events stay on the machine. One command is still unbuilt, and the usage text (looply help) says so in a single line, "not built yet: item": typing looply item prints "unknown command" and exits 64. The bare looply welcome screen lists nothing that is missing — verify, queue and stop are all on it, beside pair, init, doctor, scan, run, status, report, daemon and agent.