GitHub - lSAAGl/loop-harness: Autonomous loop harness for Agents : scheduled AI loops with worktree isolation, a second-agent verification gate, and staged outputs (PRs, reviews, Slack).

6 min read Original article ↗

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

Requirements

  • bash 3.2+ (macOS default works), git, jq, curl
  • gh — authenticated (gh auth login)
  • claude — Claude Code CLI, logged in

Environment variables

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).

Quick start

# 1. Create your config and point it at your repos
cp config.example.yaml config.yaml
$EDITOR config.yaml

# 2. Make scripts executable (once, after clone)
chmod +x orchestrator.sh dashboard.sh connectors/*.sh

# 3. Smoke-test a single loop in the foreground
./orchestrator.sh run-once triage-ci ~/projects/my-app

# 4. Start everything
./orchestrator.sh start

# 5. Watch
./dashboard.sh
./orchestrator.sh logs            # orchestrator log
./orchestrator.sh logs triage-ci  # latest run of one loop

# 6. Graceful shutdown (waits for in-flight loops, up to 5 min)
./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

./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

The five starter loops

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

How safety works

  1. Worktree isolation — write loops never touch your checkout; each run gets a fresh worktree on a loop/<name>/<ts> branch.
  2. Staged outputs — the primary agent cannot post or push. It stages commits, a PR_BODY.md, or "outbox" action files (issue-comment-<n>.md, pr-approve-<n>.md, ...).
  3. Verification gate — a second claude -p session with read-only-ish tools inspects the diff and staged outputs, runs cheap checks, and must print VERDICT: PASS. Only then does the orchestrator push/post.
  4. Scoped permissions — each loop's allowed_tools is passed to claude --allowedTools.
  5. 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.
  6. Graceful degradation — a failing loop logs, records the failure in state, and the orchestrator moves on. The daemon never crashes because a loop did.

State

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.

Adding a new loop

  1. Skillskills/my-loop.md: role, steps, success criteria, failure handling, and a final RESULT: line format (RESULT: DONE items=<id,...> / NOTHING_TO_DO / BLOCKED reason=...). Item IDs in items= drive deduplication — make them stable.

  2. Definitiondefinitions/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
  3. Wire it — add the loop name to a repo's loops: list in config.yaml.

  4. Test./orchestrator.sh run-once my-loop, read the logs, tune the skill. Then start.

Output types

  • pr — agent commits in its worktree + writes PR_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.

Triggers

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.

Config reference (config.yaml)

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.

Troubleshooting

  • Loop never fires — check ./dashboard.sh (cadence parsed?), then logs. Cron cadences need the daemon running during the matching minute.
  • PRs not openinggh 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 — usually gh auth or rate limits; the RESULT line in .agent.log says why.
  • Stuck lock — if a machine crash leaves state/.run/<instance>.lock behind, delete it.