looply

005 scan

Scanning

How `looply scan` reads the code through six narrow lenses and fills the queue with draft items that still have to be verified.

The queue is what a run works from, and looply scan is how the queue gets filled. A scan runs several read-only passes over your repository, one per lens, and turns what comes back into queue items. Each pass is your coding agent reading files and reporting; looply's part is to shape the question, cap the answer, drop what has no evidence behind it, and write the survivors down.

Nothing a scan produces is work yet. Every item it writes lands in the draft state, which no run will pick. A finding is a claim about the code until a second, separate pass has checked that claim against the code as it stands.

looply scan
looply scan --lens silent-failures,boundaries
looply scan --per-lens 3 --dry-run
looply scan --config path/to/config.yaml

The six lenses

One pass told to "find problems" returns whatever was cheapest to grep for. Each lens is deliberately narrow instead, and each gets its own agent call. These are the defaults in DEFAULT_LENSES; there is no way to add a lens from config today.

lens key what the pass is asked to find
silent-failuresCalls whose failure path does nothing: an empty catch, a promise with no rejection handler, a mutation with no error branch, a response code nobody checks.
dead-endsExported functions, endpoints, components and flags with no caller anywhere, with a statement of where each should have been called from.
missing-statesViews that render a list, a fetch or a form with no handling for zero results, a slow response, or a refusal.
untested-logicBranching logic with no test: cascades, permission checks, money or date arithmetic, state machines.
accessibilityIcon-only controls with no label, dialogs with no role or focus management, targets under 44px, colour as the only signal.
boundariesQuery parameters, bodies and route params that reach a sink without validation, named as a path from the edge to the sink.

Each lens brief also states what the lens is not for, which is half of why the six answers differ from each other: silent-failures excludes style and naming, accessibility excludes colour-contrast opinions. The lens key travels with every finding it produced, so a lens that floods the queue with near-identical work is identifiable afterwards.

One pass, start to finish

  1. The prompt is built from the project name, the lens label and brief, your scope.free plus scope.additive paths, and the per-lens cap. It opens with the task rather than a role, names writing the file as the whole job, and ends by saying a reply is not read. With no scope configured it says to look at the whole repository.
  2. The agent is started with --output-format json and the prompt is written to its stdin, never onto the command line. Default command line: claude -p --dangerously-skip-permissions.
  3. The agent is asked to write its findings to a JSON file in the system temp directory, named looply-scan-<pid>-<lens>.json, with forward slashes in the path. The file is deleted before the pass starts so a stale one cannot be read as this pass's answer.
  4. The JSON envelope is unwrapped and its is_error is combined with the exit code: a pass truncated at its turn limit still exits zero, so is_error is what turns it into a failed pass rather than a lens reporting the code clean, and a non-zero exit stays a failure whatever the envelope claims.
  5. Findings are read from the file if it exists, otherwise from the transcript, and from the transcript as a second try if the file was the wrong shape.
  6. Findings with no title, or with no evidence entry carrying a path, are dropped. A finding with no location is a guess.
  7. Anything past the per-lens cap is cut, and the number cut is reported as overflow rather than dropped in silence.

If the pass came back without error, did not time out, and nothing parseable was found in it, looply pays for one more agent call that reformats the prose into JSON. The prose it is handed is the file the pass wrote when that file is at least 200 characters, and the transcript otherwise, and there has to be 200 characters of it either way: below that the answer is a refusal, a question, or a one-line acknowledgement, and paying a second time to turn any of those into an empty list buys nothing. That call is told to open no files and is given 90 seconds. It is reported in the log as "the pass answered in prose; a second call reformatted it", and its time is added to the lens's own, because a scan where every lens needs it is telling you the first prompt is what to fix.

Flags

flag effect
--lens a,bRun only the lenses whose keys are listed. Split on commas and matched exactly, so no space after a comma. No match at all is an error that lists the available keys.
--per-lens NHow many findings each lens may contribute. Default 8. Must be a positive whole number. It is both the number written into the prompt and the hard cut applied to what comes back, because a cap that is only advisory is not a cap.
--dry-runRun every lens, print the report, write nothing to the queue and open no ledger. It costs the same agent calls as a real scan.
--config <path>Config file to read. Default .looply/config.yaml.
# a cheap first look: two lenses, three findings each, nothing written
looply scan --lens silent-failures,untested-logic --per-lens 3 --dry-run

# re-run only the lenses that produced nothing usable
looply scan --lens accessibility,boundaries

What it prints

scan    : places a failure goes unnoticed…
          3 found
scan    : things built but never reachable…
          2 found
scan    : screens with no empty, loading or error state…
          0 found

    3 silent-failures
    2 dead-ends
    0 missing-states
    4 untested-logic
    0 accessibility
    1 boundaries

found   : 9
queued  : 8
trimmed : 1 — too many of one shape in a single batch
failed  : accessibility — produced nothing usable, check the lens
quiet   : missing-states — ran to the end with nothing to report

queued  : 8 items, all unverified until a check pass confirms them.

found is the distinct findings after two lenses noticing the same first-evidence path:line have been collapsed into one, because two lenses landing on the same line is agreement rather than two pieces of work. queued is what survived the shape cap that keeps one kind of chore from filling a batch, and trimmed is the difference. The queue page defines what a shape is and carries the queue.max_same_shape default. If a scan queues five or more items across fewer than three distinct shapes, it also prints a note saying the batch is nearly all one kind of work, which is a fact about the lenses rather than about the code.

Unless --dry-run is set, the scan writes to the local ledger as it goes: scan.started with the lens list, one alert per finished pass (scan-lens at info, scan-lens-failed at warn), then one item.created per inserted item and a final scan.finished carrying the total and the per-lens counts. The item.created events are written after the queue write, never before, so the phone is never sent to an item the queue does not hold. All of them share one run-shaped id and carry lane scan and actor system, so a scan is never counted as a round. If the machine is paired they stream to the phone as each pass ends, rather than all at the end. A scan asked for from the phone is this same command: the daemon spawns looply scan as a child, which is why its passes arrive one at a time instead of as twenty silent minutes.

quiet is not clean

A lens that ran to the end and reported nothing is quiet. A lens that timed out, exited non-zero, or answered in prose that could not be turned into findings is failed. Both contribute zero to the count, and looply keeps them in separate lists for exactly that reason. When a failed pass is reported, the last 600 characters of what it produced are kept — the file it wrote when there was a substantial one, otherwise its transcript — and the final six lines are printed, each cut to 110 characters, because "produced nothing usable" on its own does not tell you whether it was a timeout, a refusal, or a competent review in the wrong format.

If a scan queues nothing, read the block underneath the report before concluding anything about your code. It names which of three cases this was: every lens ran and had nothing to queue, which is a real result; no lens produced a usable answer, which is a failed scan and not a clean codebase; or some of each, where the zero is partly a failed scan. In the last two it prints the exact looply scan --lens … command to re-run only the lenses that failed.

Every finding starts as a claim

Scanned items are written with state: draft and source: scan. A run picks items in exactly one state, ready, so a fresh scan on its own cannot start any work. looply verify is what moves them: a read-only agent judges each draft's premise against the code as it stands now and returns one of seven verdicts. confirmed and amended make the item ready and cache the evidence against the current HEAD; the other five take the item out of the pickable path or send it back to draft. The queue page has the full verdict-to-state mapping and what the verdict parser refuses. The one rule worth repeating here is that an unparseable verdict is never read as a confirmation, because a queue that fills with unchecked work while looking checked is the failure this pass exists to prevent.

# check the drafts deliberately, and see what survives contact with the code
looply verify
looply verify --max 5

It walks the drafts in queue order, one agent call each, and prints a line per item as its verdict lands, then how many were checked with the verdict tally, and how many are now ready to work on. It also picks up anything stranded in verifying: that state is held only for the length of one verifier call, so an item still in it belongs to a process that died mid-call, and left out it would be pickable by nothing and looked at by nothing ever again. Items in any other state are not touched. --max N caps how many are checked and leaves the rest as drafts, and with nothing waiting it says there is nothing to check rather than printing an empty tally. Each verdict is written to the ledger as item.verified on lane verify with actor system, so a check pass is not counted as a round either.

You do not have to run it separately. When looply run finds nothing it can pick and there are drafts waiting, it performs the same pass itself before picking, and starts the run if anything came back ready. Running verify by hand is still worth it: it is the cheapest agent work looply does, and knowing that six of your eight findings are already stale is better learned in a few minutes than at round one of an overnight run.

Reading what a scan left behind

looply queue is how you see it from the terminal. Items are grouped by state in triage order — what you can work on, then what needs you, then what is finished: ready, in progress, being checked, draft, blocked, split. Each item prints its id and title, and underneath, where a round would start: the path:line of its first evidence entry, which is the same first entry the scan deduplicated on, plus how many attempts it has had. Under that, when there is one, the reason it is where it is — the blocked reason, the id a duplicate was merged into, or why the last attempt failed.

looply queue
looply queue --all

Settled items — done, closed, stale, duplicate — are hidden, with a count of them and a pointer to --all, because a queue worked through overnight otherwise buries its live items under its own history. Straight after a scan everything sits in the one group headed "not checked yet", and because nothing is ready the screen ends by saying nothing is pickable yet and naming looply verify as what checks the drafts against the code. An empty queue says so and points at looply scan.

What a scan costs

thing value
Lens passes per scan6 by default, one agent call each, run one after another rather than in parallel
Timeout per lens pass8 minutes, then the process tree is killed and the lens is marked failed
Reformat call90 seconds, only for a pass that came back without error and said at least 200 characters
Worst case wall clockSix lenses at their timeout is 48 minutes, plus any reformat calls
Findings per lens--per-lens, default 8
Verifier timeout per item5 minutes, one agent call per draft checked

looply stop does not stop a scan. It writes a stop request into control.json, which a run reads between rounds; a scan is not a run and never reads that file, so a scan in flight ends when its last lens ends or when you interrupt the terminal it is in. --now changes nothing about that even for a run — it kills nothing on purpose, because a round cut off halfway leaves a half-written tree and the next run refuses to start on one. And when no run is live, stop writes nothing at all and says so: a run clears any pending request as it starts, so a file left behind would be an order nobody is going to obey.

Still not built

There is no automatic top-up. queue.topup_threshold (default 5) and queue.topup_size (default 10) are read from config, but nothing decides to scan on its own: a scan happens when you run one, or when your phone asks a paired daemon to start one. item is the only command the CLI's own usage still lists as missing — the footer reads "not built yet: item" — so a piece of work you already know you want, which no lens would have gone looking for, is filed from the app rather than from the terminal. And although rails.forbidden_paths is passed into the scan context, the lens prompt does not mention it: a pass is told where to look, not where it may not report, so a finding can land on a railed path and be stopped later rather than never be filed.