CONCEPTS · NATIVE LOOPING

Native looping

Pass --loop to a Synthex command (or invoke loop/synthex:loop with a free-form prompt) and the command re-runs itself until a completion promise is emitted or a max-iteration cap is hit — all in the same agent thread, with state persisted to disk so two sessions on the same project never collide and a closed laptop never loses progress.

Why it exists

Synthex commands often need autonomous iteration — crank through a plan, re-run a review until clean, drive a refinement to convergence. An earlier external integration with the ralph-loop plugin handled this, but two failure modes kept surfacing: re-entry conflicts between an external stop hook and Synthex's internal review loops produced stalls and double-execution, and a single shared state file blocked two Claude sessions on the same project from looping concurrently.

Native looping replaces it. Iteration runs internal to the command, state is per-loop in .synthex/loops/<loop-id>.json (concurrent loops just have different IDs), and the framework is part of Synthex itself so the command's own behavior and the iteration behavior are designed together. The external ralph-loop integration was retired in synthex 0.9.8.

How it works

When you pass --loop, the command's markdown instructs the agent to run its normal workflow inside a tight outer loop:

  1. Advance — one script call. A bundled script (loop-step.sh advance) does the bookkeeping atomically: it exits if status is anything but running (for example, another session cancelled the loop), marks max-iterations-reached and exits if the cap is hit, otherwise increments and persists the iteration counter before any iteration work and prints a short marker: [loop <loop-id> iteration N/M]. That's the durability boundary — a crash costs one iteration of work, never the counter.
  2. Run the command's existing workflow unchanged. The command writes its output to your plan, your review report, or whichever artifact it already owns — never to the conversation.
  3. Scan the iteration's final response for the literal <promise>X</promise> XML tag. On match: mark completed, exit.
  4. Otherwise continue in the same turn — after an idle wait if the iteration had nothing to do — back to step 1.

The whole thing runs as a single Claude Code agent thread. Claude's built-in auto-compaction handles the context window — see Auto-compaction for why that's safe.

If you'd rather isolate each iteration (longer plans where context bleed across iterations actively hurts), pass --loop-isolated. Each iteration then spawns a fresh sub-agent via the Task tool; the outer command marshals the loop counter and promise scan.

Completion promise

--completion-promise is optional. Leave it off and the loop uses ALLDONE<session_id> — ALLDONE followed directly by the starting session's id, no separator. Where there's no session id (other hosts, or an empty $CLAUDE_CODE_SESSION_ID), it falls back to ALLDONE<loop_id>. The value is computed once when the loop starts, stored in the state file, and printed as completion promise: <value>. Resuming keeps whatever was stored. An explicit --completion-promise always wins.

Idle iterations

Sometimes an iteration has nothing to do: every remaining task is blocked, or waiting on your [H] review. Rather than ending its turn and spinning, the loop waits in place. It runs one long shell call that returns as soon as a watched file changes (the plan, the PRD, the files under review), the loop stops being running (for example, you cancelled it), or a timeout passes. The timeout backs off across consecutive idle iterations: 60s, then 120s, 300s, and 540s after that. A productive iteration resets it.

The wait doesn't count as an iteration, so a long idle stretch doesn't burn through --max-iterations. Staying in the same turn also means an idle loop doesn't trigger end-of-turn notifications from your other tools.

On Claude Code, a Stop hook is the safety net. If the agent does end its turn while the loop is still running and the promise hasn't been emitted, the hook starts the next iteration. It steps aside when the agent is waiting on an AskUserQuestion, and gives up after a few turns with no progress instead of forcing the loop forever. Other hosts have no Stop hook, so there the in-turn wait is what keeps the loop alive.

Workflow engine (opt-in)

On Claude Code you can set native_looping.engine: workflow in .synthex/config.yaml. Each iteration's "is it done?" decision then comes from a read-only workflow run instead of the agent's own judgment, and a second independent check must confirm done before the loop ends. It only takes effect when the Workflow tool is available and you haven't passed --loop-isolated. Everywhere else, --loop behaves exactly as described on this page. See Workflow engines for setup and details.

Multi-session

Loop state lives at <project>/.synthex/loops/<loop-id>.json — one file per loop, never shared. Two sessions on the same project run independent loops with different IDs and write to different files. list-loops/synthex:list-loops enumerates everything in the directory so each session can see what the other is doing.

Loop IDs come from --name <slug> (whatever you want, lowercase + hyphens, ≤ 64 chars) or auto-generate as <command>-<4-char-hex>. The auto-naming uses crypto-strength random bytes, so collisions are vanishingly rare — and the framework retries on collision regardless.

Each loop is also stamped with the id of the Claude Code session that starts it, recorded as session_id in the state file. That binding is what lets the framework keep driving the loop turn after turn — a loop bound to a live session is the one that gets re-run to completion. When you resume from a different session, the loop is re-stamped with the new session's id so the new session takes over the driving. The exact state-schema field and the gate-ownership rules behind it live in the upstream spec.

Auto-compaction

The iteration loop runs in a single agent thread, so Claude Code's auto-compaction will fire when the conversation grows large. Native looping is designed to survive that without losing its place. Three rules make it safe:

  • State lives on disk, not in conversation. The iteration counter, the completion-promise text, the args — every durable bit is in .synthex/loops/<loop-id>.json. Compaction can summarize the conversation arbitrarily; the state file is untouched.
  • Iteration work lives in your artifact, not in conversation. Each iteration writes its output to the implementation plan, the review report, the PRD — wherever the command already writes. The next iteration reads from disk, not from prior chat history.
  • Markers are short. The [loop <loop-id> iteration N/M] line is one line. It survives compaction summaries verbatim, so the agent always knows where it is.

Compact recovery. On Claude Code, Synthex also registers a hook that runs right after compaction finishes. It reads .synthex/loops/ and prints one line for every loop still running: the loop id, its progress, its state-file path, and the exact command to resume it. That output lands in the resumed conversation, so the agent picks the loop back up even when the compaction summary dropped it. The hook prints nothing when no loop is running.

If the loop id still goes missing, the fallback rule is in the spec: list .synthex/loops/, find the running file matching this command, continue. If multiple match, the agent exits and tells you to resume explicitly.

Resume across sessions

Closing Claude Code mid-loop or losing power doesn't end the work — it pauses it. The state file persists. From a fresh session:

# Resume by id
/synthex:loop --resume my-loop-name

# Resume the most-recent running loop in this project
/synthex:loop --resume-last

Resume re-uses the persisted command, args, prompt, and completion promise — you don't re-supply them. If multiple loops are running, --resume-last prefers the one whose session_id matches your current session; if none match, the most-recently-touched.

Only running loops can be resumed. Once a loop is terminal — completed, cancelled, max-iterations-reached, or crashed — resume refuses; start a new loop instead (with a bigger --max-iterations if the cap was the problem).

Solo example: loop a plan to completion

/synthex:next-priority --loop \
  --completion-promise "ALLDONE" \
  --max-iterations 30 \
  --name release-1

next-priority/synthex:next-priority iterates: pick the next batch of unblocked tasks, spin up worktrees, delegate to Tech Lead instances, merge, mark done in the plan, repeat. The loop exits when every task across every milestone is done — at which point the command emits <promise>ALLDONE</promise> and native looping marks the state file completed.

Mid-run? Check on it from a second session:

/synthex:list-loops

Need to stop early?

/synthex:cancel-loop release-1

Cancellation is polled at the iteration boundary — worst-case latency is one iteration's worth of work before the loop exits cleanly.

Review example: loop a review until clean

/synthex:review-code --loop \
  --completion-promise "REVIEW_CLEAN" \
  --max-iterations 10

Each iteration runs one full review-code/synthex:review-code. The loop ends when the review has zero FAIL findings, no WARNs the reviewers would still pursue, no recommended changes left, and another pass wouldn't surface anything new on the current diff.

This outer loop is separate from the review's own fix-and-re-review cycles (review_loops.max_cycles). Those run inside each iteration; the promise is only checked at the end of a whole review.

If a standing review pool is running and routing is on, each iteration's review goes to the pool like any other review. --loop doesn't start, stop, or change pools.

What --loop does NOT do

A few intentional non-features, called out so you don't expect them:

  • It doesn't bypass [H] gates. Commands that wait for user input (interactive review, AskUserQuestion prompts) still wait — the loop just re-runs the command until those gates resolve naturally. Use cancel-loop if you change your mind. This is unconditional: even next-priority/synthex:next-priority 's opt-in --auto-decide flag (see reference) never touches [H] acceptance-criteria approval — it only removes discretionary escalations where the agent already has a clear recommendation.
  • It doesn't auto-mutate config. --loop never edits .synthex/config.yaml or anything outside the loop's own state file.
  • It doesn't manage standing pools. A loop never starts or stops a pool; reviews inside it route to one only if one is already running.
  • It doesn't auto-reset dismissed flags. A loop respects whatever the project's existing settings dictate — it's an iteration wrapper, not a configuration override.
  • It doesn't push past the 200-iteration ceiling. --max-iterations defaults to 20 and accepts 1–200. The cap is a runaway-protection guarantee: a loop that hits it ends as max-iterations-reached and can't be resumed, so continuing is a deliberate decision to start a new loop rather than a misconfigured loop running forever.

See also

  • loop/synthex:loop — generic looping primitive (reference)
  • list-loops/synthex:list-loops — enumerate loops (reference)
  • cancel-loop/synthex:cancel-loop — cancel a loop (reference)
  • next-priority/synthex:next-priority — autonomous plan execution with --loop (reference)
  • Parallel execution — how next-priority parallelises within a single iteration; native looping is the outer loop
  • Lifecycle — Synthex's five-phase delivery loop, of which native looping is the build-phase iteration primitive
  • Upstream framework spec — plugins/synthex/docs/native-looping.md (state schema, complete iteration loop, FR-NL identifiers)