An autonomous "loop engineering" harness for Coding Agents. You don't prompt Claude ; loops do. Each loop wakes up on a cadence, gives a Claude session a task-specific skill, lets it work in an isolated git worktree, has a second Claude session verify the work, and only then ships the output (PR, comments, Slack message).
Warning
This harness runs an LLM with shell access, unattended, against your repositories.
Write-capable loops can commit code, open PRs, and (with output: commit
) push to branches. Read the safety model before pointing it at anything you care about, start with read-only loops (pr-reviewer
, issue-groomer
), smoke-test with run-once
in the foreground, and never grant tools a loop doesn't need. You are responsible for what your loops ship. No warranty β see LICENSE.
scheduler tick ββΆ due loop ββΆ git worktree ββΆ primary agent (claude -p + skill)
β
βΌ
staged output (commits / outbox files)
β
βΌ
verifier agent (claude -p, skeptical) ββ FAIL ββΆ log + retry next cycle
β PASS
βΌ
ship: push + PR / post comments / Slack ββΆ state updated
bash
3.2+ (macOS default works),git
,jq
,curl
β authenticated (gh
gh auth login
)β Claude Code CLI, logged inclaude
| Variable | Required | Purpose |
|---|---|---|
GITHUB_TOKEN |
||
| no* | GitHub auth for gh (*or use gh auth login ) |
|
LOOP_SLACK_WEBHOOK |
||
| no | Slack incoming-webhook URL for notifications. Unset = Slack silently skipped | |
LOOP_WORKTREE_ROOT |
||
| no | Where worktrees are created (default: $TMPDIR/loop-worktrees ) |
Never hardcode tokens anywhere in this tree. The env var names are configurable in config.yaml
(github_token_env
, slack_webhook_env
).
cp config.example.yaml config.yaml
$EDITOR config.yaml
chmod +x orchestrator.sh dashboard.sh connectors/*.sh
./orchestrator.sh run-once triage-ci ~/projects/my-app
./orchestrator.sh start
./dashboard.sh
./orchestrator.sh logs # orchestrator log
./orchestrator.sh logs triage-ci # latest run of one loop
./orchestrator.sh stop
./orchestrator.sh tick
runs exactly one scheduler pass β useful if you'd rather drive the harness from cron/launchd instead of the built-in daemon.
./dashboard.sh
gives a live status table across all configured loops:
Orchestrator: RUNNING (pid 48121)
LOOP CADENCE STATUS LAST RUN RUNS SUCCESS% ITEMS AVG DUR FAILURES
---- ------- ------ -------- ---- -------- ----- ------- --------
dependency-updater 0 6 * * * success 06:01 4 100% 6 312s 0
doc-sync 0 7 * * * success 07:02 4 100% 2 198s 0
issue-groomer every 1h success 14:30 9 100% 14 87s 0
pr-reviewer every 3m running 15:21 112 98% 31 64s 2
triage-ci every 10m success 15:14 38 95% 9 241s 2
| Loop | Cadence | Writes code? | Ships |
|---|---|---|---|
triage-ci |
|||
| every 10m | yes (worktree) | PR with verified CI fix, or diagnosis | |
issue-groomer |
|||
| every 1h | no | labels + P0 implementation plans | |
pr-reviewer |
|||
| every 3m (polls for new PRs) | no | inline comments, summary, approval if clean | |
dependency-updater |
|||
| daily 06:00 | yes (worktree) | PR with test-verified updates | |
doc-sync |
|||
| daily 07:00 | yes (worktree) | PR patching doc drift |
Worktree isolationβ write loops never touch your checkout; each run gets a fresh worktree on aloop/<name>/<ts>
branch.Staged outputsβ the primary agent cannot post or push. It stages commits, aPR_BODY.md
, or "outbox" action files (issue-comment-<n>.md
,pr-approve-<n>.md
, ...).Verification gateβ a secondclaude -p
session with read-only-ish tools inspects the diff and staged outputs, runs cheap checks, and must printVERDICT: PASS
. Only then does the orchestrator push/post.Scoped permissionsβ each loop'sallowed_tools
is passed toclaude --allowedTools
.Idempotenceβ every loop's state file dedups processed item IDs (CI run IDs, issue/PR numbers, package@version, commit SHAs). Re-running is always safe.Graceful degradationβ a failing loop logs, records the failure in state, and the orchestrator moves on. The daemon never crashes because a loop did.
One JSON file per loop-instance at state/<loop>@<repo>.json
:
{
"last_run": 1760000000, "last_status": "success",
"processed": {"12345": "2026-06-10T09:00:00+0000"},
"in_progress": {},
"failures": [{"item": "98", "error": "verification failed", "retries": 1, "ts": "..."}],
"metrics": {"runs": 42, "successes": 40, "items_processed": 61, "total_duration_s": 9000}
}
Delete a state file to make a loop reprocess everything. state/.run/
holds pidfiles and locks β safe to delete when nothing is running.
Skillβskills/my-loop.md
: role, steps, success criteria, failure handling, and a finalRESULT:
line format (RESULT: DONE items=<id,...>
/NOTHING_TO_DO
/BLOCKED reason=...
). Item IDs initems=
drive deduplication β make them stable. -
Definitionβdefinitions/my-loop.yaml
:
name: my-loop
cadence: every 30m # or a 5-field cron expression: "0 9 * * 1-5"
trigger: schedule
skill: my-loop.md
worktree: true # true for anything that writes code
output: pr # pr | issue-comment | slack-message | commit | log-only
state_file: state/my-loop.json
verify: true
timeout_minutes: 15
allowed_tools: "Bash,Read,Edit,Write,Glob,Grep"
notify_slack: false
Wire itβ add the loop name to a repo'sloops:
list inconfig.yaml
. -
Testβ./orchestrator.sh run-once my-loop
, read the logs, tune the skill. Thenstart
.
pr
β agent commits in its worktree + writesPR_BODY.md
(uncommitted); orchestrator pushes the branch and opens the PR after verification.issue-comment
/slack-message
β agent stages action files in$LOOP_OUTBOX
; orchestrator posts them after verification. Formats:NN-issue-comment-<n>.md
,NN-issue-label-<n>.txt
,NN-pr-comment-<n>.md
,NN-pr-inline-<n>.jsonl
,NN-pr-approve-<n>.md
,NN-slack.md
.commit
β push directly to the default branch (use sparingly).log-only
β no external output.
schedule
is native. git-push
/ webhook
/ file-change
are implemented as fast polling (see pr-reviewer
: 3-minute cadence + state dedup β "on new PR"). For true push triggers, call ./orchestrator.sh run-once <loop>
from a webhook handler or git hook β it's idempotent, so duplicate triggers are harmless.
repos: # repo βΈ loops mapping (inline list required)
- path: ~/projects/my-app
loops: [triage-ci, pr-reviewer]
concurrency: 5 # max parallel Claude sessions
log_retention_days: 7
scheduler_tick_seconds: 30
slack_webhook_env: LOOP_SLACK_WEBHOOK
github_token_env: GITHUB_TOKEN
claude_bin: claude # override to pin a path/version
defaults: # per-loop fallbacks
worktree: true
verify: true
max_retries: 2
timeout_minutes: 15
The config parser is intentionally small (portable shell): keep the file flat, inline [a, b]
lists for loops:
, no anchors or multiline values.
Loop never firesβ check./dashboard.sh
(cadence parsed?), thenlogs
. Cron cadences need the daemon running during the matching minute.PRs not openingβgh auth status
; check the run's.log
file for push errors; verifier may be failing (see.verify.log
next to the run log).Everything BLOCKEDβ usuallygh
auth or rate limits; the RESULT line in.agent.log
says why.Stuck lockβ if a machine crash leavesstate/.run/<instance>.lock
behind, delete it.