Browse Source

feat(skills): loop-ops native-first scheduling + runner-agnostic loop-run.sh

Answers "use /loop, /schedule, routines, /goal — not GitHub Actions":

- claude-code-loops.md now leads with Claude Code's NATIVE primitives, split into
  cadence (when a tick fires) vs completion (when work stops):
    cadence  — /loop (in-session) · Desktop scheduled task (local, unattended) ·
               /schedule cloud routines · ScheduleWakeup
    completion — /goal (v2.1.139+): keep working until a fast-model evaluator
               confirms the condition, the native inner-loop gate; pairs with auto mode.
  Surfaces the load-bearing caveat: cloud routines run on a FRESH CLONE with no local
  files, so local-state loops (repos/builds/tools) must use a Desktop scheduled task or
  /loop, never a cloud routine.
- loop-init scaffolds an executable, runner-agnostic loop-run.sh (kill-switch ->
  gated claude -p -> commit STATE/run-log) for EXTERNAL schedulers (cron / Task
  Scheduler / systemd / process-compose); native paths run the prompt directly and
  need no wrapper. GitHub Actions demoted to one optional path; no GH Actions
  dependency anywhere (upstream loop-engineering is Actions-centric — we're not).
- Worked example ships loop-run.sh (primary) + github-actions.yml (optional, reframed).
- SKILL.md scheduling/primitives/workflow updated native-first. Suite 92->96.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
1c2366d07d

+ 7 - 0
CHANGELOG.md

@@ -48,6 +48,13 @@ feature releases live in the README "Recent Updates" section.
   to the control that catches it); connector/MCP least-privilege scoping + the auto-merge
   guard in `references/risk-tiers.md`; and an honest "why Claude Code-specific, not a
   multi-tool matrix" note. 92-assertion suite.
+- **loop-ops native-first, runner-agnostic scheduling**: the cadence layer now leads with
+  Claude Code's own primitives — `/loop` (in-session), **Desktop scheduled tasks** (local,
+  unattended), `/schedule` cloud routines (with the load-bearing *fresh-clone, no-local-files*
+  caveat surfaced), and `/goal` as the native completion gate — with `loop-init` scaffolding
+  an executable runner-agnostic `loop-run.sh` for external schedulers (cron / Task Scheduler /
+  systemd / process-compose) and GitHub Actions demoted to one optional path. No GitHub
+  Actions dependency anywhere. 96-assertion suite.
 
 ## [3.2.0] - 2026-06-22
 

+ 19 - 11
skills/loop-ops/SKILL.md

@@ -34,7 +34,7 @@ already exist:
 
 | Primitive | What it is | Owned in claude-mods by |
 |---|---|---|
-| **Schedule** | fire the loop on a cadence | native `/loop`, `/schedule` (cron agents), `ScheduleWakeup` |
+| **Schedule** | fire the loop on a cadence | native-first: `/loop` (in-session), **Desktop scheduled task** (local, unattended), `/schedule` cloud routines (no local files); `/goal` is the native completion gate. External (cron/Task Scheduler + `loop-run.sh`) only for non-Claude-Code control |
 | **Worktree** | isolated, discardable execution context | `git-ops` worktrees, `fleet-worker` (per-task worktree) |
 | **Skills** | persistent project knowledge the run loads | this repo's skill layer + your `CLAUDE.md` |
 | **Sub-agents** | maker/checker separation | `Agent`/`Task`; dispatching skills (`review`, `testgen`) |
@@ -148,7 +148,7 @@ Running several loops? Two non-negotiables (detail in
 | improve one metric in one session | [`iterate`](../iterate/SKILL.md) | a hand-rolled inner loop |
 | spawn cheap parallel makers | [`fleet-worker`](../fleet-worker/SKILL.md) | bespoke `claude -p` plumbing |
 | test-gate + land winning branches | [`fleet-ops`](../fleet-ops/SKILL.md) | a manual merge step |
-| fire on a cadence | native `/loop`, `/schedule` | a custom cron in this skill |
+| fire on a cadence | native `/loop` · Desktop scheduled task · `/schedule` cloud routine; `/goal` for completion | a custom cron in this skill |
 | commit / PR / release | [`git-ops`](../git-ops/SKILL.md), [`github-ops`](../github-ops/SKILL.md) | raw `git push` |
 | signal between loops | [`pigeon`](../pigeon/SKILL.md) | a shared scratch file |
 
@@ -167,11 +167,13 @@ preflights whether it will actually *run*, **cost** estimates spend (caching-awa
 
 ### `scripts/loop-init.sh` — scaffold a loop's state spine
 
-Writes `<dir>/<name>/` with four files from the bundled templates:
+Writes `<dir>/<name>/` with five files from the bundled templates:
 `loop.config.yaml` ([assets/loop.config.template.yaml](assets/loop.config.template.yaml)),
-`STATE.md` ([assets/STATE.template.md](assets/STATE.template.md)), `run-log.md`, and
-`run.md` — the headless run prompt a scheduler feeds to `claude -p`
-([assets/run.template.md](assets/run.template.md)). Pass a known `--pattern`
+`STATE.md` ([assets/STATE.template.md](assets/STATE.template.md)), `run-log.md`, `run.md`
+(the headless run prompt, [assets/run.template.md](assets/run.template.md)), and an
+executable **`loop-run.sh`** ([assets/run.sh.template](assets/run.sh.template)) — the
+runner-agnostic tick wrapper any scheduler invokes (cron / Windows Task Scheduler /
+systemd / by hand), **no GitHub Actions required**. Pass a known `--pattern`
 (pr-babysitter, ci-sweeper, dependency-sweeper, …) and the config is **seeded** with that
 pattern's scope/goal/escalation — and, at L2+, its gate — so you get a near-ready config to
 review, not blank placeholders (it audits clean immediately). Doctrine holds: it still
@@ -275,8 +277,12 @@ python scripts/check-pricing-sync.py --offline   # exit 0 in sync, 10 drift, 3 a
 6. **Doctor it:** `bash scripts/loop-doctor.sh --live .loops/<n>/loop.config.yaml` — prove
    it will actually *run* (gate binary on PATH, budget fits a tick). Audit = well-formed;
    doctor = will-run.
-7. **Schedule** the L1 run with native `/loop` or `/schedule` (read-only — it just
-   writes `STATE.md` + a report).
+7. **Schedule** the L1 run, native-first (read-only — it just writes `STATE.md` + a
+   report): `/loop` while you watch, a **Desktop scheduled task** for unattended *local*
+   loops, or a `/schedule` cloud routine for cloud-only work (no local files — see
+   [references/claude-code-loops.md](references/claude-code-loops.md)). Use `loop-run.sh` +
+   cron/Task Scheduler only if you want non-Claude-Code control. Bound a "work-until-done"
+   tick with `/goal`.
 8. **Read the reports.** Only after the loop's judgment is proven do you graduate it to
    **L2** (worktree + guard + `fleet-ops` landing) and re-audit at the higher tier.
 
@@ -285,9 +291,11 @@ python scripts/check-pricing-sync.py --offline   # exit 0 in sync, 10 drift, 3 a
 A complete, **audit + doctor-clean** L1 loop ships at
 [assets/examples/pr-babysitter/](assets/examples/pr-babysitter/): a filled
 `loop.config.yaml`, a *populated* `STATE.md`, the `run.md` run prompt, a sample
-`run-log.md`, and `github-actions.yml` — the scheduler with the **kill-switch gate and
-`dontAsk` + allowlist profile baked in**. Copy the dir, adjust scope/cadence, run
-`loop-audit` + `loop-doctor --live`, wire the workflow. The other patterns don't ship as
+`run-log.md`, the runner-agnostic **`loop-run.sh`** (the tick wrapper, with the
+kill-switch gate and `dontAsk` + allowlist baked in — point cron / Task Scheduler at it),
+and an *optional* `github-actions.yml` for repos already on GitHub. Copy the dir, adjust
+scope/cadence, run `loop-audit` + `loop-doctor --live`, then wire `loop-run.sh` to your
+scheduler. The other patterns don't ship as
 static dirs that rot — `loop-init --pattern <name>` *generates* the same, seeded and
 gate-clean, for any pattern at any tier. CI runs `loop-audit` + `loop-doctor` on this
 example every build, so it can't drift out of validity.

+ 3 - 1
skills/loop-ops/assets/examples/pr-babysitter/github-actions.yml

@@ -1,4 +1,6 @@
-# EXAMPLE scheduler for the pr-babysitter L1 loop — the "clone-and-run" glue.
+# OPTIONAL scheduler — use this ONLY if your repo already lives on GitHub. The portable,
+# runner-agnostic path is loop-run.sh (cron / Windows Task Scheduler / systemd / by hand) —
+# no GitHub Actions dependency. This file just wraps the same loop-run.sh idea in Actions.
 # Copy to .github/workflows/pr-babysitter.yml, PIN the action/CLI versions, add the
 # ANTHROPIC_API_KEY secret. The SCHEDULER is the authorizer (no auto-mode session in the
 # loop), and the child runs gated (--permission-mode dontAsk + a narrow allowlist), never

+ 30 - 0
skills/loop-ops/assets/examples/pr-babysitter/loop-run.sh

@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# loop-run.sh - one tick of the pr-babysitter loop. RUNNER-AGNOSTIC: point any scheduler
+# at it. No GitHub Actions required.
+#   cron:                 */10 * * * *  /path/.loops/pr-babysitter/loop-run.sh >> tick.log 2>&1
+#   Windows Task Scheduler: schtasks /Create /SC MINUTE /MO 10 /TN pr-babysitter \
+#                             /TR "bash -lc '/path/.loops/pr-babysitter/loop-run.sh'"
+#   by hand:              bash loop-run.sh
+# The scheduler is the authorizer; this runs a gated `claude -p` (dontAsk + an allowlist),
+# never bypassPermissions on a shared host. (github-actions.yml is one OPTIONAL scheduler.)
+set -uo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$HERE"
+
+# 1. Kill switch first.
+if [ -f PAUSED ]; then echo "pr-babysitter: paused (PAUSED sentinel) - skipping tick" >&2; exit 0; fi
+command -v claude >/dev/null 2>&1 || { echo "pr-babysitter: 'claude' not on PATH" >&2; exit 5; }
+
+# 2. One tick. SAME prompt every time (cache-friendly). Allowlist = exactly what an L1
+#    report loop needs: read-only gh + Read + the STATE/run-log writes. No 'gh pr merge'.
+claude -p "$(cat run.md)" \
+  --permission-mode dontAsk \
+  --append-system-prompt "$(cat STATE.md)" \
+  --allowedTools 'Bash(gh pr list:*)' 'Bash(gh pr view:*)' 'Bash(gh pr comment:*)' 'Read' 'Write(STATE.md)' 'Write(run-log.md)' \
+  --max-turns 30
+
+# 3. Persist STATE + run-log if this dir lives in a git repo.
+if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+  git add STATE.md run-log.md 2>/dev/null || true
+  git diff --cached --quiet 2>/dev/null || git commit -q -m "chore(loop): pr-babysitter tick" || true
+fi

+ 28 - 0
skills/loop-ops/assets/run.sh.template

@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# loop-run.sh - one tick of the <loop-name> loop. RUNNER-AGNOSTIC: point any scheduler
+# at it - cron, Windows Task Scheduler, a systemd timer, process-compose, or run by hand.
+# No GitHub Actions required. The scheduler is the authorizer; this runs a gated `claude -p`
+# (dontAsk + an allowlist), never bypassPermissions on a shared host. See
+# references/claude-code-loops.md for cron / Task Scheduler wiring.
+set -uo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$HERE"
+
+# 1. Kill switch first - a PAUSED sentinel here, or the 'loop-pause' label if you use one.
+if [ -f PAUSED ]; then echo "<loop-name>: paused (PAUSED sentinel) - skipping tick" >&2; exit 0; fi
+
+command -v claude >/dev/null 2>&1 || { echo "<loop-name>: 'claude' not on PATH" >&2; exit 5; }
+
+# 2. Run one tick. SAME prompt every time (cache-friendly); fresh context each run.
+#    Add this loop's own tools to --allowedTools (e.g. 'Bash(gh pr list:*)').
+claude -p "$(cat run.md)" \
+  --permission-mode <permission-mode> \
+  --append-system-prompt "$(cat STATE.md)" \
+  --allowedTools 'Read' 'Write(STATE.md)' 'Write(run-log.md)' \
+  --max-turns 30
+
+# 3. Persist STATE + run-log if this dir lives in a git repo (skipped otherwise).
+if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+  git add STATE.md run-log.md 2>/dev/null || true
+  git diff --cached --quiet 2>/dev/null || git commit -q -m "chore(loop): <loop-name> tick" || true
+fi

+ 72 - 21
skills/loop-ops/references/claude-code-loops.md

@@ -7,40 +7,91 @@ children* — is in [risk-tiers.md](risk-tiers.md); this is the how.
 
 ---
 
-## The four cadence mechanisms
-
-| Mechanism | What it is | Best for | Tier fit |
-|---|---|---|---|
-| **`/loop`** | runs a prompt/slash-command on a recurring interval (or self-paced) in the *current* session | interactive, supervised loops; polling you watch | L1, supervised |
-| **`/schedule`** | cron-scheduled **cloud** agents (routines) that run detached | unattended recurring loops, the real L2/L3 cadence | L2/L3 |
-| **`ScheduleWakeup`** | re-enter *this* session after a delay (dynamic `/loop` pacing) | self-pacing a single long task; polling external state | L1, supervised |
-| **OS scheduler / CI** | Task Scheduler / cron / GitHub Actions invoking `claude -p` | the canonical unattended loop; the authorizer is the scheduler | L2/L3 |
+A loop is two things, and Claude Code has **native** answers to both: a **cadence** (when
+a tick fires) and a **completion** rule (when the work stops). **Prefer the native
+mechanisms — they're zero/low-infra and need no GitHub Actions.** Reach for an external
+scheduler only when you want non-Claude-Code control.
+
+## Cadence — when a tick fires
+
+| Mechanism | Runs on | Local files? | Open session? | Min interval | Best for |
+|---|---|---|---|---|---|
+| **`/loop`** | your machine | ✅ | **yes** | 1 min | supervised, in-session polling (L1) |
+| **Desktop scheduled task** | your machine | ✅ | no | 1 min | **the local-first unattended default** — loops that touch the repo/build/tools |
+| **Cloud routine** (`/schedule` → [Routines](https://code.claude.com/docs/en/routines)) | **Anthropic cloud** | ❌ **fresh clone** | no | **1 hour** | unattended loops needing **no** local state (GitHub PRs, web) |
+| **`ScheduleWakeup`** | your machine | ✅ | yes | — | self-pacing one long task |
+| external scheduler + `loop-run.sh` | your machine | ✅ | no | your call | non-Claude-Code control: cron / Task Scheduler / systemd / process-compose / CI |
+| **GitHub Actions** | GH runner | fresh clone | no | — | *optional* — only if the repo already lives on GitHub |
+
+> **Load-bearing caveat:** **cloud routines run on a fresh clone with no access to your
+> local files.** A loop that touches a local repo, build, model dir, or tool **cannot** be
+> a cloud routine — use a **Desktop scheduled task** or `/loop`. Cloud routines are for
+> cloud-reachable, local-state-free work only.
+
+The unattended options (Desktop task, cloud routine, external scheduler, Actions) are the
+human-configured **authorizer** — no parent auto-mode session, so nothing blocks the
+headless child. Upstream loop-engineering is GitHub-Actions-centric; loop-ops is
+runner-agnostic and **native-first** on purpose.
+
+## Completion — when the work stops: `/goal`
+
+[`/goal <condition>`](https://code.claude.com/docs/en/goal) (v2.1.139+) keeps the session
+working turn-after-turn until a small fast model confirms the condition holds, then
+auto-clears — the **native inner-loop gate**. It's the native expression of a loop's
+`verify`/Until rule: *"keep going until the acceptance criteria hold."* Bound it with
+`or stop after N turns`. It's a session-scoped **prompt-based Stop hook**, and it pairs
+with auto mode (auto removes per-*tool* prompts; `/goal` removes per-*turn* prompts).
+Headless, one tick to completion:
+
+```bash
+claude -p "/goal all tests in test/auth pass and lint is clean, or stop after 20 turns"
+```
 
-The first three keep a session in the loop (good for L1, supervised). The fourth is the
-unattended pattern: **the scheduler is the human-configured authorizer**, so there's no
-parent classifier to block the headless child.
+**The fully-native, zero-external-infra loop** = a **Desktop scheduled task** (local, has
+files, no open session) that runs `claude -p "/goal <tick condition>"` against the STATE
+spine. No cron, no Task Scheduler, no Actions.
 
 ---
 
-## The canonical unattended shape
+## The external-scheduler shape (when you're not using a native mechanism)
 
+Native paths (Desktop task, cloud routine, `/loop`) run the tick prompt — or
+`claude -p "/goal …"` — **directly**, so they need no wrapper. When you instead drive the
+loop from an **external** scheduler (cron / Task Scheduler / systemd / process-compose /
+CI — e.g. for sub-minute cadence or to fit existing infra), `loop-init` scaffolds a
+**`loop-run.sh`** in the loop dir as the runner-agnostic glue. No GitHub Actions required.
+
+```
+   any scheduler ──▶ .loops/<name>/loop-run.sh
+   (the authorizer)      ├─ kill switch first (PAUSED sentinel) → exit if set
+                         ├─ claude -p "$(cat run.md)" --permission-mode dontAsk \
+                         │     --append-system-prompt "$(cat STATE.md)" --allowedTools …
+                         └─ git add/commit STATE.md + run-log.md (if in a repo)
 ```
-                    ┌────────────────────────────────────────────┐
-   cron / Task      │  for each tick:                            │
-   Scheduler / CI ──┤    claude -p "$(cat .loops/<name>/run.md)" \│
-   (the authorizer) │      --permission-mode dontAsk \           │
-                    │      --append-system-prompt "$(cat STATE.md)"│
-                    │    → run reads STATE, does work, rewrites it │
-                    └────────────────────────────────────────────┘
+
+Wire it with whatever you already run — **no cloud dependency**:
+
+```bash
+# cron (Linux/macOS):
+*/10 * * * *  /path/.loops/pr-babysitter/loop-run.sh >> /path/.loops/pr-babysitter/tick.log 2>&1
+
+# Windows Task Scheduler (every 10 min; S4U logon, see windows-ops for the hardened form):
+schtasks /Create /SC MINUTE /MO 10 /TN pr-babysitter \
+  /TR "bash -lc '/c/path/.loops/pr-babysitter/loop-run.sh'"
+
+# process-compose / systemd timer / a while-sleep loop — all work; loop-run.sh is just a script.
 ```
 
-- The **scheduler** (not a Claude session) invokes `claude -p`. It is the human-configured
-  authorizer; nothing upstream gates the run.
+- The **scheduler** (not a Claude session) invokes `loop-run.sh`. It is the
+  human-configured authorizer; nothing upstream gates the run.
 - `--permission-mode dontAsk` + a curated allowlist = a **gated** worker that runs
   anywhere. (For L3 arbitrary-execution jobs, swap to a container + `bypassPermissions` —
   see the enumerate-vs-isolate fork in [risk-tiers.md](risk-tiers.md).)
 - The run prompt (`run.md`) is the same every tick — fresh context each time (the Ralph
   property). State survives in `STATE.md` + the codebase + git, not the conversation.
+- **GitHub Actions** is one option, not a requirement — the worked example ships an
+  optional `github-actions.yml` for repos already on GitHub; everyone else uses the local
+  schedulers above.
 
 ### Why not "a Claude session that launches the loop"?
 

+ 16 - 2
skills/loop-ops/scripts/loop-init.sh

@@ -38,6 +38,7 @@ ASSETS="$HERE/../assets"
 CFG_TPL="$ASSETS/loop.config.template.yaml"
 STATE_TPL="$ASSETS/STATE.template.md"
 RUN_TPL="$ASSETS/run.template.md"
+RUN_SH_TPL="$ASSETS/run.sh.template"
 
 # ── defaults ────────────────────────────────────────────────────────────────
 NAME=""
@@ -107,6 +108,7 @@ case "$TIER" in L1|L2|L3) ;; *) die_usage "--tier must be L1|L2|L3 (got '$TIER')
 [[ -f "$CFG_TPL" ]]   || { printf 'error: config template not found at %s\n' "$CFG_TPL" >&2; exit "$EX_NOTFOUND"; }
 [[ -f "$STATE_TPL" ]] || { printf 'error: STATE template not found at %s\n' "$STATE_TPL" >&2; exit "$EX_NOTFOUND"; }
 [[ -f "$RUN_TPL" ]]   || { printf 'error: run template not found at %s\n' "$RUN_TPL" >&2; exit "$EX_NOTFOUND"; }
+[[ -f "$RUN_SH_TPL" ]] || { printf 'error: run.sh template not found at %s\n' "$RUN_SH_TPL" >&2; exit "$EX_NOTFOUND"; }
 
 # Default permission_mode from tier (the workhorse mapping; see references/risk-tiers.md).
 case "$TIER" in
@@ -157,6 +159,7 @@ CFG_OUT="$TARGET_DIR/loop.config.yaml"
 STATE_OUT="$TARGET_DIR/STATE.md"
 LOG_OUT="$TARGET_DIR/run-log.md"
 RUN_OUT="$TARGET_DIR/run.md"
+RUN_SH_OUT="$TARGET_DIR/loop-run.sh"
 
 # Refuse a populated target unless --force.
 if [[ -d "$TARGET_DIR" ]] && [[ -n "$(ls -A "$TARGET_DIR" 2>/dev/null)" ]] && [[ "$FORCE" -ne 1 ]]; then
@@ -200,6 +203,15 @@ render_run() {
     "$RUN_TPL"
 }
 
+# The runner-agnostic tick wrapper any scheduler invokes (cron / Task Scheduler /
+# systemd / process-compose / by hand) — no GitHub Actions required.
+render_run_sh() {
+  sed -E \
+    -e "s|<loop-name>|$NAME|g" \
+    -e "s|<permission-mode>|$PMODE|g" \
+    "$RUN_SH_TPL"
+}
+
 # Seeded config for a known pattern. L1 stays report-only (gate fields are a
 # commented graduation block); L2/L3 emit verify/guard/worktree/land_via — using
 # the pattern's gate if it has one, else a <fill:…> placeholder the audit will flag.
@@ -249,7 +261,7 @@ if [[ "$DRY_RUN" -eq 1 ]]; then
     term_panel_vert
     term_status_row skip "would create  $TARGET_DIR/" "tier $TIER ${TERM_DOT} $PATTERN ${TERM_DOT} $CADENCE"
     term_status_row skip "  loop.config.yaml" "permission_mode: $PMODE"
-    term_status_row skip "  STATE.md / run-log.md / run.md" ""
+    term_status_row skip "  STATE.md / run-log.md / run.md / loop-run.sh" ""
     term_panel_vert
     term_panel_close "nothing written" ""
   } >&2
@@ -271,6 +283,8 @@ write_atomic "$CFG_OUT"   "$(emit_config)"
 write_atomic "$STATE_OUT" "$(render_state)"
 write_atomic "$LOG_OUT"   "$(render_log)"
 write_atomic "$RUN_OUT"   "$(render_run)"
+write_atomic "$RUN_SH_OUT" "$(render_run_sh)"
+chmod +x "$RUN_SH_OUT" 2>/dev/null || true
 
 printf '%s\n' "$CFG_OUT"
 
@@ -279,7 +293,7 @@ printf '%s\n' "$CFG_OUT"
   term_panel_vert
   term_status_row ok "created  $TARGET_DIR/" "tier $TIER ${TERM_DOT} $PATTERN ${TERM_DOT} $CADENCE"
   term_status_row ok "  loop.config.yaml" "permission_mode: $PMODE"
-  term_status_row ok "  STATE.md / run-log.md / run.md" ""
+  term_status_row ok "  STATE.md / run-log.md / run.md / loop-run.sh" ""
   if [[ "$TIER" != "L1" ]]; then
     term_alert warning "tier $TIER needs a verify gate, guard, worktree, escalation + land_via — fill them before auditing"
   fi

+ 7 - 1
skills/loop-ops/tests/run.sh

@@ -91,6 +91,11 @@ expect_has  "prints the config path" "pr-watch/loop.config.yaml" "$out"
 runmd="$(cat "$SB/loops/pr-watch/run.md")"
 expect_has "run.md substitutes loop name" "Run: pr-watch" "$runmd"
 expect_has "run.md substitutes tier" "tier L1)" "$runmd"
+# runner-agnostic wrapper: emitted, executable, fully substituted, no GH Actions dep
+[[ -f "$SB/loops/pr-watch/loop-run.sh" ]] && ok "wrote loop-run.sh" || no "no loop-run.sh"
+runsh="$(cat "$SB/loops/pr-watch/loop-run.sh")"
+case "$runsh" in *"<loop-name>"*|*"<permission-mode>"*) no "loop-run.sh left a placeholder";; *) ok "loop-run.sh fully substituted";; esac
+expect_has "loop-run.sh wires the gated mode" "--permission-mode dontAsk" "$runsh"
 cfg="$(cat "$SB/loops/pr-watch/loop.config.yaml")"
 expect_has "substituted name" "name: pr-watch" "$cfg"
 expect_has "substituted tier" "tier: L1" "$cfg"
@@ -258,7 +263,8 @@ EX="$SKILL/assets/examples/pr-babysitter/loop.config.yaml"
 [[ -f "$EX" ]] && ok "worked example present" || no "worked example missing"
 bash "$AUDIT" "$EX" >/dev/null 2>&1; expect_exit "shipped example audits clean -> 0" 0 $?
 bash "$DOCTOR" --offline "$EX" >/dev/null 2>&1; expect_exit "shipped example doctors clean -> 0" 0 $?
-[[ -f "$SKILL/assets/examples/pr-babysitter/github-actions.yml" ]] && ok "example ships a scheduler" || no "example missing scheduler"
+[[ -f "$SKILL/assets/examples/pr-babysitter/loop-run.sh" ]] && ok "example ships loop-run.sh (runner-agnostic)" || no "example missing loop-run.sh"
+[[ -f "$SKILL/assets/examples/pr-babysitter/github-actions.yml" ]] && ok "example ships an optional GH Actions scheduler" || no "example missing GH Actions option"
 [[ -f "$SKILL/assets/examples/pr-babysitter/run.md" ]] && ok "example ships a run prompt" || no "example missing run.md"
 
 # ── terminal design system ─────────────────────────────────────────────────