Loops is a lightweight task runner that picks up ready work from GitHub Projects and drives each task through an inner Codex loop until completion.
Warning Loops is a preview for trusted environments. It is not hardened for untrusted repositories, untrusted task content, or multi-tenant use.
It has two runtime layers:
- An outer loop that polls tasks and launches runs.
- An inner loop that executes Codex turns, tracks PR/review state, and requests user input when needed.
- Python 3.10+
ghCLI inPATH- Codex CLI in
PATH(or setCODEX_CMD) - Python package installer (
pip)
Install Loops (editable) to get the loops command on your PATH:
python -m pip install -e .- Initialize Loops:
loops initThis creates .loops/, .loops/jobs/, .loops/config.json, .loops/outer_state.json, and .loops/oloops.log.
- Provide GitHub auth for provider polling:
export GITHUB_TOKEN=YOUR_TOKEN- Run one poll cycle:
loops run --run-once- For continuous polling:
loops runLoops writes runtime state under .loops/:
.loops/
.archive/
YYYY-MM-DD-task-title-task-id/
run.json
run.log
agent.log
oloops.log
outer_state.json
jobs/
YYYY-MM-DD-task-title-task-id/
run.json
run.log
agent.log
Top-level config file: .loops/config.json
task_provider_id(string, required): currently only"github_projects_v2".version(integer, required for latest schema): config schema version. Legacy files without this field are treated as version0; latest is3.task_provider_config(object, required):task_provider_config.url(string, required): GitHub Projects V2 URL, for examplehttps://github.com/orgs/acme/projects/7.task_provider_config.status_field(string, optional, default"Status"): Project field name to map task status.task_provider_config.page_size(integer, optional, default50): GraphQL page size.task_provider_config.github_token(string, optional): Overrides token used by the provider once launched.task_provider_config.approval_comment_usernames(string[], optional, default[]): allowlisted GitHub usernames whose approval comments/review bodies can mark a PR as approved.task_provider_config.approval_comment_pattern(string, optional, default^\s*/approve\b): regex used to match approval comments/review bodies fromtask_provider_config.approval_comment_usernames.task_provider_config.allowlist(string[], optional, default[]): GitHub usernames allowed to contribute review-phase signals (PR comments/reviews) during inner-loop polling. When set, non-allowlisted actors are ignored in review polling.task_provider_config.filters(string[], optional): provider-sidekey=valuefilters. Supported keys:repository=<owner>/<repo>(repeatable; multiple repository filters are OR)tag=<label-name>(repeatable; multiple tag filters are AND)
task_provider_configis validated by the provider's typed Pydantic model (unknown keys and invalid types fail fast).- Required provider secrets are declared by provider metadata and validated from env before provider construction.
loop_config(object, optional):loop_config.poll_interval_seconds(integer, default30)loop_config.parallel_tasks(boolean, defaultfalse)loop_config.parallel_tasks_limit(integer, default5)loop_config.sync_mode(boolean, defaultfalse): run inner loop in foreground (interactive handoff enabled) instead of detached child.loop_config.emit_on_first_run(boolean, defaultfalse)loop_config.force(boolean, defaultfalse)loop_config.task_ready_status(string, default"Ready")loop_config.auto_approve_enabled(boolean, defaultfalse): enables one-time$ag-judgeauto-approval evaluation when review is not already approved and CI is green.loop_config.handoff_handler(string, default"stdin_handler"): built-in NEEDS_INPUT handoff strategy. Supported values:stdin_handler: prompt on stdin/stdout (interactive mode).gh_comment_handler: post handoff prompts to the task GitHub issue and wait for/loops-reply ...comments. Requirestask_provider_id="github_projects_v2"andtask.urlin issue URL format.
inner_loop(object, optional when using outer CLI):inner_loop.command(string or string[], required wheninner_loopis provided)inner_loop.working_dir(string, optional): relative paths are resolved from config directory.inner_loop.env(object string->string, optional): run-scoped inner-loop runtime env map (for exampleCODEX_CMD, prompt-file envs, or subprocess tokens) persisted to each run'sinner_loop_runtime_config.json.inner_loop.append_task_url(boolean, defaulttrue)
If inner_loop is omitted, the CLI injects:
commandequivalent toloops inner-loopappend_task_url = false
All CLI interfaces in this repo are listed below.
Wrapper CLI for all Loops interfaces.
Subcommands:
init: initialize.loops/structure and default config.run: run the outer loop runner.inner-loop: run inner loop for one run directory.handoff: seed a run from an existing Codex session and hand review-driving to Loops.doctor: upgrade config schema/default keys inconfig.json.clean: delete empty run directories and archive completed runs.
Examples:
loops init
loops doctor
loops run --run-once
loops inner-loop --run-dir .loops/jobs/2026-02-09-example-task-123
loops handoff 019cab59-95c2-7923-9049-be455a2beb37
loops clean --dry-runLegacy compatibility:
loops --run-onceis accepted and routed toloops run --run-once.
Runs the outer loop runner.
Options:
--config PATH: Config file path. Default:.loops/config.json.--run-once / --run-forever: Run one cycle then exit, or keep polling forever. Default:--run-forever.--limit INTEGER: Optional provider poll limit.--force / --no-force: Overrideloop_config.forcefrom config.--task-url TEXT: Force processing a specific task URL from provider results. This implies--run-once,--force, andsync_mode=true.-h, --help: Show help.
Examples:
loops run --run-once
loops run --run-once --limit 3
loops run --config /path/to/config.json --force
loops run --task-url https://github.com/acme/api/issues/123
loops runNotes:
- Outer loop filters tasks by
loop_config.task_ready_status. - With
loop_config.sync_mode=true, inner loop runs in the foreground and can prompt for user input in the same terminal. - With
loop_config.sync_mode=true, outer-loop logs (oloops.log) and inner-loop orchestration logs (run.log) are also mirrored to stdout. - If
Ctrl+Cinterrupts a sync-mode run, Loops prints resume instructions so you can continue withloops inner-loop --run-dir .... - Log timestamps are local-time strings without timezone suffix and use fixed fractional digits.
- With
emit_on_first_run=false, first run initializes dedupe state but does not launch tasks. --task-urldoes not changetask_provider_config.url; it selects one task after polling by URL match and runs only that task.--task-urlforces foreground execution for that run (sync_mode=true) so targeted runs are interactive and deterministic.- URL matching for
--task-urlcompares normalized URLs (scheme/host case-insensitive, query/fragment removed, trailing slash ignored). --task-urlbypasses ready-status filtering for the selected task and raises an error when the URL is missing or ambiguous in poll results.- Provider filters (
task_provider_config.filters) are applied during provider polling before outer-loop status filtering. - Outer loop always injects
LOOPS_RUN_DIRinto launched inner-loop processes. For custom commands that are notloops inner-loop,inner_loop.envis also merged into child env; for Loops inner-loop commands, runtime settings are read from run-scopedinner_loop_runtime_config.json. - PR approval is detected from GitHub review decision or from allowlisted approval comments configured in
task_provider_config, after optional review-actor filtering fromtask_provider_config.allowlist.
Deletes empty runs and archives completed runs.
Options:
--loops-root PATH: Loops runtime root. Default:.loops.--dry-run: Print planned delete/archive actions without changing files.-h, --help: Show help.
Examples:
loops clean --dry-run
loops clean
loops clean --loops-root /path/to/.loopsBehavior summary:
- A run is deleted when both
run.logandagent.logexist and are byte-empty, and the run is not in an active state. - A run is archived when
run.jsonexists andlast_state == "DONE". DONEruns are always archived (never deleted as empty runs).- Completed runs are moved to
.loops/.archive/, and name collisions are resolved by appending-1,-2, etc.
Upgrades config.json to the latest supported schema version and fills missing
loop_config keys (and GitHub task_provider_config defaults) without overwriting existing values.
Options:
--config PATH: Config file path. Default:.loops/config.json.-h, --help: Show help.
Examples:
loops doctor
loops doctor --config /path/to/config.jsonRuns the inner loop for a single run directory.
Options:
--run-dir PATH: Run directory path. If omitted, usesLOOPS_RUN_DIR.--prompt-file PATH: Optional base prompt file. If omitted, inner loop checks run-scoped runtime config (inner_loop_runtime_config.json) first, then falls back toLOOPS_PROMPT_FILE, thenCODEX_PROMPT_FILE.--reset: Resetrun.jsonorchestration/session/input state and exit.-h, --help: Show help.
Examples:
loops inner-loop --run-dir .loops/jobs/2026-02-14-test-issue-i-kwdoqyyzws7nwoys
loops inner-loop --run-dir .loops/jobs/2026-02-14-test-issue-i-kwdoqyyzws7nwoys --reset
LOOPS_RUN_DIR=.loops/jobs/2026-02-09-example-task-123 loops inner-loop
LOOPS_RUN_DIR=.loops/jobs/2026-02-09-example-task-123 CODEX_CMD="codex exec --yolo" loops inner-loopBehavior summary:
- Reads and writes
run.jsonas the authoritative state file. - Persists
run.json.stream_logs_stdoutas the effective log-mirroring setting for the run. - Writes inner-loop orchestration logs to
run.logand appends Codex output there. - Streams Codex/agent output to
agent.log. - If run-scoped runtime config has
stream_logs_stdout=true(written by outer loop insync_mode=true), also mirrorsrun.loglines to stdout. - Uses
CODEX_CMDfrom run-scoped runtime config when present; if absent there, it falls back to processCODEX_CMD. Default command iscodex exec --yolo. - Always sets
LOOPS_RUN_DIRin the Codex subprocess env to the active run directory (even for direct/manualloops inner-loopruns). - Polls PR state with
gh pr viewwhen a PR is present. - For the initial PR, Loops expects the initial push sequence to run
scripts/push-pr.pyand write${LOOPS_RUN_DIR}/push-pr.url; after a successfulRUNNINGturn, inner loop reads that artifact only whenrun.json.pris still missing, then populatesrun.json.pr. - If
${LOOPS_RUN_DIR}/push-pr.urlis missing or invalid whenrun.json.pris missing after a successful initialRUNNINGturn, inner loop forcesNEEDS_INPUTand includes the artifact path inneeds_user_input_payload.context. - In review polling, Loops treats a PR as approved if
reviewDecision=APPROVEDor if a matching approval comment fromtask_provider_config.approval_comment_usernamesis newer than the latestCHANGES_REQUESTEDreview. - If
task_provider_config.allowlistis configured, review polling filters PR comments/reviews to those actors before deriving feedback and review-status signals. - Selects handoff behavior from run-scoped runtime config (or
LOOPS_HANDOFF_HANDLERfor direct/manual runs):stdin_handler: prompt directly in terminal.gh_comment_handler: comment on task issue and wait for/loops-reply ....
--resetkeeps task metadata and preserves an existing PR link (pr.url/number/repo) when present; non-link PR status fields are cleared.- If
run.jsonis missing, task fields fall back toLOOPS_TASK_*env vars (or defaults). - Exact state-mapped prompt strings are documented in
DESIGN.mdunder### Prompt catalog.
Seeds and launches a WAITING_ON_REVIEW run from an existing Codex session.
Usage:
loops handoff [session-id]Options:
--config PATH: Config file path. Default:.loops/config.json.--pr-url TEXT: Optional PR URL override when transcript-based discovery is ambiguous or unavailable.--task-url TEXT: Optional tracking task URL override when transcript-based discovery is ambiguous or unavailable.-h, --help: Show help.
Behavior summary:
- Resolves session id from argument, then
CODEX_THREAD_ID, then latest${CODEX_HOME:-~/.codex}/history.jsonlsession. - Reads the matching Codex transcript under
${CODEX_HOME:-~/.codex}/sessionsor${CODEX_HOME:-~/.codex}/archived_sessions. - Derives PR URL and tracking task URL from conversation content; if either cannot be determined, prompts for it interactively (or fails with guidance in non-interactive mode).
- Maps tracking task URL against provider poll results when possible; otherwise falls back to synthesized task metadata.
- Creates a new run with:
codex_session.idset to the handed-off session id,pr.review_status="open",pr.review_addressed_at=null(treat all existing PR feedback/comments as unread),- derived state
WAITING_ON_REVIEW.
- Launches inner loop using the configured launcher path (
inner_loop.command) and runtime config behavior.
GITHUB_TOKENorGH_TOKEN: required for GitHub Projects provider startup checks (GH_TOKENis accepted as alias fallback).CODEX_CMD: command used for Codex execution fallback when run-scoped runtime config does not set it. Default:codex exec --yolo.LOOPS_RUN_DIR: run directory forloops inner-loopwhen--run-diris not passed.LOOPS_RUN_DIRis also required byscripts/push-pr.py, which writes deterministic PR URL discovery artifact${LOOPS_RUN_DIR}/push-pr.url.LOOPS_PROMPT_FILE/CODEX_PROMPT_FILE: optional base prompt file fallback when run-scoped runtime config does not set one.LOOPS_HANDOFF_HANDLER: handoff strategy fallback for direct/manual inner-loop runs (stdin_handlerorgh_comment_handler).LOOPS_TASK_ID,LOOPS_TASK_TITLE,LOOPS_TASK_URL,LOOPS_TASK_PROVIDER: legacy fallback metadata used only whenrun.jsonis missing duringloops inner-loop --reset.
Run tests:
make testRun a targeted test module:
python -m pytest tests/test_outer_loop.py -qRun live integration harness (opt-in):
LOOPS_INTEG_LIVE=1 python -m pytest tests/integ -k outer_loop_pickup_live -sRun full live end-to-end lifecycle harness (opt-in, manual-only):
make test-integ-end2end
# equivalent:
LOOPS_INTEG_END2END=1 python -m pytest tests/integ -k end2end_live -sLive integration prerequisites:
ghinPATHand authenticated with a token that can mutate the target project/repo.codexinPATHand authenticated.GITHUB_TOKENorGH_TOKENexported.
Notes:
- The live harness currently targets
https://github.com/users/kevinslin/projects/6/views/1. - Test issues are created in
kevinslin/loops-integand are cleaned up at test end. - The end-to-end harness also clones/syncs
.integ/loops-integ, runsloops run --run-oncewith a 15-minute timeout, reverts merged changes inkevinslin/loops-integ, and runsloops cleanfor run-archive hygiene. - End-to-end runs require push access to
kevinslin/loops-integ. make test-integ-end2endis not part of the defaultmake testsuite.- Pytest startup enforces this repository root at
sys.path[0](tests/conftest.py) so imports resolve to the active worktree checkout.