looply

007 config

Configuration

Every key .looply/config.yaml parses, with its type, default and effect, plus the keys that parse today but change nothing.

One file decides how a run behaves: .looply/config.yaml. It names the project, lists the checks that decide whether a round's work is kept, draws the paths nothing may touch, and says how to start the coding agent. This is where you set what the agent may work on, which checks must pass, and what to do when a second agent is not convinced by the result.

Where the file lives and when it is read

The default path is .looply/config.yaml, relative to the working directory. The commands run, scan, verify, doctor and daemon each accept --config <path> to point elsewhere. status and report read no config at all — only their state files, .looply/state/status.json and .looply/state/events.jsonl. agent reads .looply/config.yaml at the default path and takes no --config. Validation is strict and happens once, at load, before any agent is started or any check is run. A bad value throws a ConfigError whose message names the exact key: config: run.max_rounds — must be >= 1. A config mistake costs you the ninety seconds it takes to fix it, not the night.

looply doctor                          # validate, run interlocks, run one full gate pass
looply doctor --config ci/looply.yaml  # same, against another file
looply run --hours 6                   # --hours overrides run.min_hours for this run
looply scan --config ci/looply.yaml

Unknown keys are ignored in silence. There is no strict-unknown-key check, so a typo like rail: for rails: parses cleanly and leaves you with no rails at all. This matters immediately: looply init writes a publish: block that the parser never reads, so editing it changes nothing. Only the keys listed below have any effect.

Every key the parser reads

Key Type, default and meaning
project.nameString, required, non-empty. Named in the prompts the implementer and the scanner receive, in the looply doctor header, on the welcome screen and in looply agent, and in the project registered with the server when the machine is paired.
project.idString, optional, no default. Stamped on every ledger event a run writes; absent means events carry the literal local. scan and verify prefer the project id the server returned when the machine is paired.
run.min_hoursNumber, minimum 0, default 4. Rounds keep starting until this much wall time has elapsed. Overridden by looply run --hours N.
run.max_roundsNumber, minimum 1, default 400. Hard ceiling on rounds. Whichever of min_hours and max_rounds is reached first ends the run.
run.cooldown_sNumber, minimum 0, default 5. Sleep between rounds. At 0 there is no pause.
gatesList of step objects, required, non-empty. An empty list is rejected: a run with no checks proves nothing. Fields are in the next table.
rails.forbidden_pathsList of glob strings, default []. A diff that touches any of these aborts the round before a single check runs.
rails.prod_patternsList of strings, default []. Matched case-insensitively as substrings against the text of each env file and every live environment variable value. A hit aborts the round.
rails.prod_env_filesList of strings, default [".env"]. Files read for the prod_patterns check, relative to the working directory. A file that does not exist is skipped, not an error.
rails.exemptionsask or never, default ask. Parsed and validated; see the section on keys that do nothing yet.
scope.freeList of glob strings, default []. Paths the agent is told it may edit.
scope.additiveList of glob strings, default []. Paths where the agent is told it may add new files but not edit existing ones.
queue.topup_thresholdNumber, minimum 0, default 5. Parsed; nothing reads it yet.
queue.topup_sizeNumber, minimum 1, default 10. Parsed; nothing reads it yet.
queue.max_same_shapeNumber, minimum 1, default 3. Caps how many near-identical items one scan may add to the queue. The rest are dropped.
proof.enabledBoolean, default true. When true, a second agent that did not write the change must return proven before the commit is allowed. When false, a green gate alone keeps the work.
proof.on_partialask, keep or discard, default ask. What to do when the prover says the change is partly right. From a bare terminal run there is nobody to ask, so ask logs that fact and discards.
notify.channelString, non-empty, default sms. Names the script at ~/.looply/notify/<channel>.sh. The parser checks nothing beyond non-empty; the name and the script are checked later — see the note below the table.
notify.every_minNumber, minimum 0, default 30. Minutes between progress messages. 0 means total silence, including the start and finish messages.
notify.toString, optional, no default. Passed to the channel script as its second argument. What it means is the script's business.
server.urlString, required when a server: block is present. Parsed; see the section on keys that do nothing yet.
server.machine_token_fileString, optional. Parsed; nothing reads it.
agent.commandString, non-empty, default claude -p --dangerously-skip-permissions. The command line that starts the coding agent, flags only.
agent.output_format_flagString, default --output-format. The empty string is explicitly allowed, for an agent that has no such flag. Any other empty or non-string value is rejected.

The notify channel is checked twice, in two different places. The parser only demands a non-empty string. At the start of a run the dispatcher tests the name against ^[A-Za-z0-9_-]+$ — it is interpolated into a script path, so a name like ../../x must never reach the filesystem — and then tests that ~/.looply/notify/<channel>.sh exists. Either check failing disables the channel with a warning and the run carries on; neither fails the config load. Note also that the parser default is sms but looply init writes channel: push, so a freshly initialised repository is not on the default.

Gate steps

Steps run in the order written, so put the cheapest first: a broken typecheck should not wait on a full test suite. Execution stops at the first failing step, because everything after it says nothing about this round. A step filtered out by when: is recorded as skipped rather than omitted, so the log never claims coverage the run did not have. Step names must be unique across the list.

Gate step field Type, default and meaning
nameString, required, non-empty, unique. Appears in the commit trailer and the step log.
runString, required, non-empty. The shell command to run.
timeout_sNumber, minimum 1, optional. When omitted the step gets 600 seconds. A value of 0 is rejected. On timeout the process tree is killed and the step counts as failed.
whenGlob string, optional. The step is skipped when no changed path matches. Supports *, **, ?, character classes, and a trailing / meaning the directory and everything under it.
requiredBoolean, default false. A required step must have run and passed for the commit to be allowed.

required: true is stronger than it looks. A required step that never ran, or that was skipped by its own when: filter, blocks the commit exactly as a failure does. That is the point: a step skipped by a narrow glob is not an implicit pass. If no step in the list is marked required, looply warns before the run starts.

Where these keys are enforced

The two rails keys are the only ones in this file checked in code: the interlock runs before any gate step, and a hit aborts the round rather than failing a check. scope.free and scope.additive are never compared against a diff — they are interpolated into the prompt the implementer receives and into the paths a scan reads. The Rails page covers the abort codes, the glob semantics and the exemption door; this page only says what the keys hold and what they default to.

The agent section

agent.command is the invocation minus the output format. Each caller appends the format it needs: the implementer, the prover and the verifier want the plain transcript, and scan reads a structured JSON envelope. The final line is command + " " + output_format_flag + " " + format, or the command alone when output_format_flag is the empty string. The prompt is never part of the command line — it is written to the agent's stdin, which is why a multi-line prompt or a prompt carrying a whole diff works on every platform. Nothing derived from the repository ever reaches the command line.

# agent.command: "claude -p --dangerously-skip-permissions"
# agent.output_format_flag: "--output-format"

claude -p --dangerously-skip-permissions --output-format text   # implementer, prover, verifier
claude -p --dangerously-skip-permissions --output-format json   # scan

# with output_format_flag: "" the line is used unchanged for both.

Keys that parse but change nothing yet

These are accepted and validated, and some of them also have an entry in the app's settings model, but no code path reads them during a run. Setting them has no effect today.

Key What it does today
rails.exemptionsNothing. The round runner has a seam for per-item, per-path, expiring exemption grants, but no command wires it. In practice every railed path behaves as never.
queue.topup_thresholdNothing. No code reads it to decide when to draft more items.
queue.topup_sizeNothing. No code reads it to size a batch.
server.urlNothing. The server URL actually used comes from ~/.looply/machine.json, written by looply pair --server <url>.
server.machine_token_fileNothing. The token is read from the same machine file, which lives outside the repository so a discarded round cannot delete it.

A full config, annotated

# .looply/config.yaml

project:
  name: "tabsy"          # required; named in prompts, in doctor and on the server
  id: "prj_4c1e"         # optional; stamped on events. Absent means "local".

run:
  min_hours: 4           # keep starting rounds for 4 hours
  max_rounds: 400        # or 400 rounds, whichever comes first
  cooldown_s: 5          # pause between rounds; 0 for none

gates:                   # required and non-empty; order is execution order
  - { name: "tsc",  run: "npx tsc --noEmit", timeout_s: 300 }
  - { name: "jest", run: "npx jest --ci", timeout_s: 900, required: true }
  # runs only when the diff touched admin/; a skip is recorded, not hidden
  - { name: "build:admin", run: "npm --prefix admin run build", when: "admin/**" }

rails:
  # A diff touching these aborts the round before any check runs.
  forbidden_paths: ["prisma/", "src/common/guards/"]
  # Matched case-insensitively inside env files and live env values.
  # A hit aborts: a reachable production database stops everything.
  prod_patterns: ["herokuapp", "amazonaws", ".neon.tech"]
  prod_env_files: [".env", ".env.local"]   # missing files are skipped
  exemptions: ask        # accepted, but nothing reads it yet

scope:                   # prompt text, not enforcement — see rails for that
  free: ["web/src/**", "packages/**"]
  additive: ["src/**"]   # new files only, no edits to existing ones

queue:
  topup_threshold: 5     # parsed, inert
  topup_size: 10         # parsed, inert
  max_same_shape: 3      # caps near-identical items per scan; this one is live

proof:
  # A second agent, which did not write the change, must return "proven".
  # false means work is kept on a passing build alone.
  enabled: true
  on_partial: ask        # from a terminal run, ask degrades to discard

notify:
  channel: sms           # parser default; looply init writes push instead
                         # runs ~/.looply/notify/<channel>.sh; a missing script disables it
  every_min: 30          # 0 is total silence, start and finish included
  to: "+15551234567"     # second argument to the script

agent:
  command: "claude -p --dangerously-skip-permissions"
  output_format_flag: "--output-format"   # "" for an agent without one

Warnings printed before a run

These do not block anything. looply run and looply doctor print them and carry on. Each one marks a configuration that will still produce green rounds while proving less than it appears to.

Condition Warning
rails.forbidden_paths is emptynothing in this repo is off limits
No gate step has required: truea green gate proves less than you think
proof.enabled is falserounds will commit on a green build alone
notify.every_min is 0this run will report nothing, including when it finishes

The agent time budgets are not configurable from this file. They are fixed in the command that starts each agent: 45 minutes per implementer attempt and 10 minutes for the prover in the run command, 5 minutes per item for the verifier, and 8 minutes per lens in a scan. Only gate steps take a timeout you write.

Before the first long run, use looply doctor. It validates this file, prints the warnings above, runs the interlocks against an empty diff, and then executes every configured gate command once with no agent involved. Unlike the gate it applies no when: filter and does not stop at the first failure, so a single pass names every broken command — a wrong test command is found in ninety seconds instead of at round one.