# Loop Harness Is Here

> Source: <https://github.com/lSAAGl/loop-harness>
> Published: 2026-06-11 23:31:07+00:00

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](#how-safety-works) 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](/lSAAGl/loop-harness/blob/main/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 in`claude`

| 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`

).

```
# 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.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 a`loop/<name>/<ts>`

branch.**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`

, ...).**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.**Scoped permissions**— each loop's`allowed_tools`

is passed to`claude --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 final`RESULT:`

line format (`RESULT: DONE items=<id,...>`

/`NOTHING_TO_DO`

/`BLOCKED reason=...`

). Item IDs in`items=`

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's`loops:`

list in`config.yaml`

. -
**Test**—`./orchestrator.sh run-once my-loop`

, read the logs, tune the skill. Then`start`

.

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

`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?), then`logs`

. 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**— 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.
