diff --git a/.claude/commands/pr-loop.md b/.claude/commands/pr-loop.md index 1010adf..2e7e2a5 100644 --- a/.claude/commands/pr-loop.md +++ b/.claude/commands/pr-loop.md @@ -2,6 +2,15 @@ description: Arm (or re-arm) the autonomous PR-loop cron and run one tick now --- +**LEGACY path (issue #102).** This is the session-scoped cron. The RECOMMENDED replacement is the cron-less +daemon: `systemd --user` supervises `.claude/scripts/loop-daemon.sh` forever, independent of any Claude Code +session, and spawns a driver only on an actionable verdict (never on `action=none`). Install it with +`bash .claude/scripts/arm-loop.sh` (run in a real terminal outside Claude Code — see `docs/HARDENING.md` → +Caveats), or via `/orchestrator:setup`'s "arm the loop" step. **Never run both the cron and the daemon against +the same repo at once** — `loop-tick.sh`'s spawn lock makes it *safe* (no double-spawn), merely wasteful (two +firing sources burning ticks against the same state). Keep reading below only if you're intentionally using +the legacy cron (no systemd available, or as a fallback). + You are (re)arming this project's autonomous PR loop. The loop is session-scoped (cron jobs die when Claude Code exits and may not persist across restarts even when durable), so it is lost at the start of each new session. This command restores the whole loop in one step. Do BOTH parts. The repo is derived from the git remote (`bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh repo view --json nameWithOwner -q .nameWithOwner`); the bot login defaults to `$BOT_LOGIN`. Nothing here is project-specific — it reads `.claude/gates.json`, `.claude/scripts/*`, and `docs/USAGE.md`. diff --git a/.claude/scripts/loop-daemon.sh b/.claude/scripts/loop-daemon.sh new file mode 100644 index 0000000..b12751c --- /dev/null +++ b/.claude/scripts/loop-daemon.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# loop-daemon.sh — the forever loop for the cron-less autonomous PR loop +# (issue #102). Replaces the session-scoped CronCreate loop: systemd (user) +# supervises THIS process (pr-loop.service, Restart=always) instead of a +# Claude Code session that dies with the session that armed it. +# +# Each iteration: run loop-event.sh (which runs the deterministic tick via +# loop-tick.sh); on `loop-event: action=none` sleep and repeat WITHOUT +# spawning anything; on an actionable verdict, spawn exactly ONE contained +# driver session, ledger it, then sleep for however long the tick's census +# cadence line says. Never runs two driver sessions concurrently — this loop +# is itself single-threaded/sequential, and loop-tick.sh's own spawn lock +# additionally guards against a second overlapping tick anywhere else +# (e.g. the legacy /pr-loop cron still armed at the same time) double-firing +# the same ADVANCE. +# +# DRIVER CONTAINMENT (claude-code#29096): a driver is spawned via `setsid` +# (its own session/process group, independent of this daemon's) wrapped in +# `timeout ` with `--kill-after=30s`; on +# timeout the whole process GROUP is targeted (not just the immediate child) +# so a driver's own bash children can never be orphaned by a bare SIGTERM. +# +# RUN LEDGER: one line per driver appended to .claude/state/loop-runs.log: +# pid= session= verdict= ts= [result=exit|timeout rc=N] +# session_id is parsed out of the driver's own --output-format json stdout, +# so a hung/dead driver can be inspected later with +# `claude --resume --fork-session` (safe while it's still +# running; transcripts are append-only JSONL). .claude/state/ is gitignored — +# this ledger is never committed. +# +# Env: +# LOOP_MODEL model for the driver (default sonnet; read by loop-event.sh) +# GATES_FILE adapter override, passed straight through the environment +# (self-hosting: .claude/self/gates.json) +# LOOP_DRIVER_TIMEOUT wall-clock cap per driver (default 90m) +# LOOP_DAEMON_SLEEP_FAST/WATCH/IDLE/FALLBACK override the adaptive-sleep seconds (test hook) +# LOOP_DAEMON_MAX_ITERATIONS bound the forever loop; 0 = unbounded (test/debug hook) +# +# Sourcing this file (rather than executing it) has ZERO side effects — every +# function below only runs when called, and `main` only runs when this file +# is executed directly (the BASH_SOURCE guard at the bottom). This is what +# loop-daemon.test.sh relies on to unit-test cadence_to_sleep_seconds and +# ledger_line without spinning up the real forever loop. +set -uo pipefail + +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" + +state_dir="$root/.claude/state" +ledger="$state_dir/loop-runs.log" + +log() { printf '%s loop-daemon: %s\n' "$(date -u +%FT%TZ)" "$*" >&2; } + +# --- adaptive sleep: parse the census `cadence=FAST|WATCH|IDLE cron=` line --- +# $1 = any text to scan (typically loop-event.sh's full stdout, which passes +# loop-tick.sh's `cadence=...` line straight through). Prints seconds to sleep. +cadence_to_sleep_seconds() { + local out="${1:-}" + local cadence + cadence="$(printf '%s\n' "$out" | sed -n 's/^cadence=\([A-Z]*\).*/\1/p' | tail -1)" + case "$cadence" in + FAST) echo "${LOOP_DAEMON_SLEEP_FAST:-60}" ;; + WATCH) echo "${LOOP_DAEMON_SLEEP_WATCH:-300}" ;; + IDLE) echo "${LOOP_DAEMON_SLEEP_IDLE:-900}" ;; + *) echo "${LOOP_DAEMON_SLEEP_FALLBACK:-300}" ;; + esac +} + +# --- run ledger --------------------------------------------------------------- +# $1=pgid $2=session_id (may be empty -> printed as "unknown") $3=verdict +# (e.g. "advance issue=42") $4=ts(ISO8601) $5=extra (optional, e.g. +# "result=exit rc=0" / "result=timeout rc=124") appended verbatim if non-empty. +ledger_line() { + local pgid="$1" session="$2" verdict="$3" ts="$4" extra="${5:-}" + local line="pid=$pgid session=${session:-unknown} verdict=$verdict ts=$ts" + [ -n "$extra" ] && line="$line $extra" + printf '%s\n' "$line" +} + +append_ledger() { + mkdir -p "$state_dir" + ledger_line "$@" >> "$ledger" +} + +# --- claude-on-PATH resolution (daemon/service environments lack nvm) -------- +ensure_claude_on_path() { + if ! command -v claude >/dev/null 2>&1; then + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + # shellcheck disable=SC1091 + [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 + fi + command -v claude >/dev/null 2>&1 +} + +# --- extract a session_id out of a --output-format json driver transcript ---- +# Deliberately grep/sed, not `node -e ...`: a daemon/service environment that +# needed ensure_claude_on_path's nvm fallback to find `claude` may still not +# have `node` itself resolvable the same way (and re-sourcing nvm a second +# time here would re-prepend nvm's bin dir onto PATH, risking it shadowing an +# already-resolved non-nvm `claude`). Covers both the documented single-object +# `--output-format json` shape and a JSONL stream (last match wins either way). +extract_session_id() { + local out_file="$1" + [ -f "$out_file" ] || return 0 + grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' "$out_file" 2>/dev/null \ + | tail -1 \ + | sed -E 's/.*"session_id"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' +} + +# --- spawn ONE contained driver, block until it exits/times out, ledger it --- +# $1=verdict (e.g. "advance issue=42"), $2=model, $3=prompt-file (plain text). +# Returns the driver's exit code (124/137 on timeout). +run_driver() { + local verdict="$1" model="$2" prompt_file="$3" + local timeout_dur="${LOOP_DRIVER_TIMEOUT:-90m}" + local ts; ts="$(date -u +%FT%TZ)" + + if ! ensure_claude_on_path; then + log "'claude' CLI not found on PATH (nor via nvm) — cannot spawn the driver for $verdict" + append_ledger "unknown" "" "$verdict" "$ts" "result=spawn-error rc=127" + return 127 + fi + if [ ! -f "$prompt_file" ]; then + log "prompt-file '$prompt_file' does not exist — cannot spawn the driver for $verdict" + append_ledger "unknown" "" "$verdict" "$ts" "result=spawn-error rc=2" + return 2 + fi + + local prompt; prompt="$(cat "$prompt_file")" + local out_file; out_file="$(mktemp "$state_dir/.loop-driver-out.XXXXXX.json")" + + log "spawning driver ($verdict, model=$model, timeout=$timeout_dur)" + # setsid: own session, so the whole tree (claude + any bash children it + # spawns) shares ONE fresh process group independent of this daemon's own — + # timeout's --kill-after below then has a single group to aim at. Backstop + # explicit group kill after `wait` covers anything that outlives timeout's + # own signal delivery (claude-code#29096: a bare SIGTERM to just the + # immediate child has been observed to orphan bash children). + setsid timeout --kill-after=30s "$timeout_dur" \ + claude --model "$model" -p "$prompt" --output-format json \ + >"$out_file" 2>"$out_file.stderr" & + local pgid=$! + wait "$pgid" + local rc=$? + kill -TERM -- "-$pgid" 2>/dev/null || true + + local session_id; session_id="$(extract_session_id "$out_file")" + + local extra + case "$rc" in + 124|137) extra="result=timeout rc=$rc" ;; + *) extra="result=exit rc=$rc" ;; + esac + + append_ledger "$pgid" "$session_id" "$verdict" "$ts" "$extra" + log "driver finished ($verdict): $extra session=${session_id:-unknown}" + rm -f "$out_file" "$out_file.stderr" "$prompt_file" + return "$rc" +} + +# --- one tick + (maybe) one driver spawn, sets NEXT_SLEEP as a side effect -- +NEXT_SLEEP=300 +run_once() { + local out rc + out="$(bash "$script_dir/loop-event.sh" 2>&1)" + rc=$? + printf '%s\n' "$out" + NEXT_SLEEP="$(cadence_to_sleep_seconds "$out")" + + if [ "$rc" -ne 0 ]; then + log "loop-event.sh exited $rc — not spawning a driver on a broken tick (retrying in ${NEXT_SLEEP}s)" + return + fi + + local action_line + action_line="$(printf '%s\n' "$out" | sed -n 's/^loop-event: action=//p' | tail -1)" + + case "$action_line" in + none|"") + : # nothing actionable — no driver spawned + ;; + "advance issue="*|"feedback pr="*) + local model prompt_file + model="$(printf '%s\n' "$out" | sed -n 's/^loop-event: model=//p' | tail -1)" + model="${model:-${LOOP_MODEL:-sonnet}}" + prompt_file="$(printf '%s\n' "$out" | sed -n 's/^loop-event: prompt-file=//p' | tail -1)" + if [ -z "$prompt_file" ]; then + log "action=$action_line but no prompt-file was emitted — refusing to spawn" + else + run_driver "$action_line" "$model" "$prompt_file" || true + fi + ;; + *) + log "unrecognized loop-event action line: '$action_line' — treating as none this tick" + ;; + esac +} + +main() { + mkdir -p "$state_dir" + log "starting (LOOP_MODEL=${LOOP_MODEL:-sonnet} GATES_FILE=${GATES_FILE:-} LOOP_DRIVER_TIMEOUT=${LOOP_DRIVER_TIMEOUT:-90m})" + local iterations=0 + local max_iterations="${LOOP_DAEMON_MAX_ITERATIONS:-0}" + while :; do + iterations=$((iterations + 1)) + run_once + if [ "$max_iterations" -gt 0 ] && [ "$iterations" -ge "$max_iterations" ]; then + log "LOOP_DAEMON_MAX_ITERATIONS=$max_iterations reached — exiting (test/debug mode only; a real service loops forever)" + break + fi + log "sleeping ${NEXT_SLEEP}s" + sleep "$NEXT_SLEEP" + done +} + +# Only run the forever loop when EXECUTED, never when sourced (test hook). +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" +fi diff --git a/.claude/scripts/loop-daemon.test.sh b/.claude/scripts/loop-daemon.test.sh new file mode 100644 index 0000000..0e2869e --- /dev/null +++ b/.claude/scripts/loop-daemon.test.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# loop-daemon.test.sh — offline smoke test for loop-daemon.sh (issue #102). +# +# Two kinds of checks: +# (A) PURE UNIT checks — `source` the REAL loop-daemon.sh directly into +# this test's own shell (never `main`, thanks to its BASH_SOURCE guard) +# and call cadence_to_sleep_seconds / ledger_line directly. Sourcing +# has zero side effects (no mkdir, no network, no forever loop), so +# this is safe against the real repo tree. +# (B) INTEGRATION checks — build a throwaway fixture `.claude/scripts/` +# (mirroring loop-tick.test.sh's convention) containing the REAL +# loop-daemon.sh + resolve-roots.sh next to a FAKE loop-event.sh, and +# fake `claude`/`setsid`/`timeout` stubs prepended onto PATH, then run +# loop-daemon.sh as a real subprocess with LOOP_DAEMON_MAX_ITERATIONS=1 +# so `main` runs exactly one iteration and exits (instead of forever). +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/loop-daemon.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +loop_daemon_src="$script_dir/loop-daemon.sh" +resolve_roots_src="$script_dir/resolve-roots.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/loop-daemon-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +# ============================================================================= +# (A) Pure unit checks: source the real script, call its functions directly. +# ============================================================================= +# shellcheck source=loop-daemon.sh +. "$loop_daemon_src" + +# --- cadence -> sleep seconds mapping --------------------------------------- +s_fast="$(cadence_to_sleep_seconds 'open_prs=0 +feedback_prs=0 +cadence=FAST cron=* * * * *')" +check "cadence FAST -> 60s" [ "$s_fast" = "60" ] + +s_watch="$(cadence_to_sleep_seconds 'open_prs=1 +cadence=WATCH cron=*/5 * * * *')" +check "cadence WATCH -> 300s" [ "$s_watch" = "300" ] + +s_idle="$(cadence_to_sleep_seconds 'open_prs=0 +planned_issues=0 +cadence=IDLE cron=*/15 * * * *')" +check "cadence IDLE -> 900s" [ "$s_idle" = "900" ] + +s_missing="$(cadence_to_sleep_seconds 'some garbage output with no cadence line at all')" +check "no cadence line -> fallback 300s" [ "$s_missing" = "300" ] + +s_env_override="$(LOOP_DAEMON_SLEEP_FAST=5 cadence_to_sleep_seconds 'cadence=FAST cron=* * * * *')" +check "cadence FAST honors LOOP_DAEMON_SLEEP_FAST override" [ "$s_env_override" = "5" ] + +# --- ledger line format ------------------------------------------------------ +line1="$(ledger_line 12345 sess-abc 'advance issue=42' '2026-07-09T00:00:00Z' 'result=exit rc=0')" +check "ledger line: exact format with all fields" [ "$line1" = "pid=12345 session=sess-abc verdict=advance issue=42 ts=2026-07-09T00:00:00Z result=exit rc=0" ] + +line2="$(ledger_line 999 '' 'feedback pr=7' '2026-07-09T01:00:00Z')" +check "ledger line: empty session_id prints 'unknown'" [ "$line2" = "pid=999 session=unknown verdict=feedback pr=7 ts=2026-07-09T01:00:00Z" ] + +line3="$(ledger_line 111 sess-x 'advance issue=1' '2026-07-09T02:00:00Z' 'result=timeout rc=124')" +check "ledger line: timeout result recorded verbatim" [ "$line3" = "pid=111 session=sess-x verdict=advance issue=1 ts=2026-07-09T02:00:00Z result=timeout rc=124" ] + +# ============================================================================= +# (B) Integration checks: real subprocess, fake loop-event.sh + fake claude. +# ============================================================================= +new_fixture() { + # $1=name $2=fake loop-event.sh body (full script text) -> prints fixture root + local name="$1" body="$2" + local dir="$work/$name" + mkdir -p "$dir/.claude/scripts" "$dir/.claude/state" "$dir/bin" + cp "$loop_daemon_src" "$dir/.claude/scripts/loop-daemon.sh" + cp "$resolve_roots_src" "$dir/.claude/scripts/resolve-roots.sh" + printf '%s\n' "$body" > "$dir/.claude/scripts/loop-event.sh" + chmod +x "$dir/.claude/scripts"/*.sh + printf '%s\n' "$dir" +} + +fake_bin() { + # $1=fixture root $2=binary name $3=script body -> installs bin/$2 on that fixture's PATH dir + local dir="$1" name="$2" body="$3" + printf '%s\n' "$body" > "$dir/bin/$name" + chmod +x "$dir/bin/$name" +} + +run_daemon_once() { + # $1=fixture root; runs loop-daemon.sh for exactly one iteration. + ( cd "$1" && PATH="$1/bin:/usr/bin:/bin" LOOP_DAEMON_MAX_ITERATIONS=1 LOOP_DAEMON_SLEEP_FAST=0 LOOP_DAEMON_SLEEP_WATCH=0 LOOP_DAEMON_SLEEP_IDLE=0 LOOP_DAEMON_SLEEP_FALLBACK=0 bash .claude/scripts/loop-daemon.sh ) +} + +# --------------------------------------------------------------------------- +# 1. action=none: fake loop-event.sh reports nothing actionable. Assert the +# ledger file is never created/written and no marker any stub would leave +# behind exists — i.e. ZERO drivers spawned. Note there is deliberately NO +# 'claude' stub installed for this scenario either: if loop-daemon.sh ever +# tried to spawn one on action=none, the whole run would blow up with +# "command not found" instead of quietly passing. +# --------------------------------------------------------------------------- +dir1="$(new_fixture scenario1 '#!/usr/bin/env bash +echo "cadence=IDLE cron=*/15 * * * *" +echo "loop-event: action=none" +exit 0')" +run_daemon_once "$dir1" >/dev/null 2>&1 +check "scenario 1 (action=none): no ledger file was created" [ ! -f "$dir1/.claude/state/loop-runs.log" ] + +# --------------------------------------------------------------------------- +# 2. Broken tick (loop-event.sh exits non-zero): must not spawn a driver +# either, same as action=none. +# --------------------------------------------------------------------------- +dir2="$(new_fixture scenario2 '#!/usr/bin/env bash +echo "cadence=WATCH cron=*/5 * * * *" +echo "some diagnostic on a broken tick" >&2 +exit 1')" +run_daemon_once "$dir2" >/dev/null 2>&1 +check "scenario 2 (broken tick): no ledger file was created" [ ! -f "$dir2/.claude/state/loop-runs.log" ] + +# --------------------------------------------------------------------------- +# 3. action=advance issue=N: fake claude/setsid/timeout stubs record they ran +# and emit a fake --output-format json line with a session_id; assert the +# driver stub actually ran, and the ledger line has the right shape. +# --------------------------------------------------------------------------- +prompt3_dir="$work/scenario3-support" +mkdir -p "$prompt3_dir" +printf 'Run the ADVANCE step of the autonomous PR loop for issue #55.\n' > "$prompt3_dir/prompt.txt" +dir3="$(new_fixture scenario3 "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=advance issue=55' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt3_dir/prompt.txt' +exit 0")" +fake_bin "$dir3" setsid '#!/usr/bin/env bash +# Real setsid re-execs its argv; this stub just execs straight through so the +# fake timeout/claude below still run, but records that it was invoked first. +echo "setsid-ran" >> "'"$dir3"'/setsid.marker" +exec "$@"' +fake_bin "$dir3" timeout '#!/usr/bin/env bash +echo "timeout-ran args=$*" >> "'"$dir3"'/timeout.marker" +# Drop the leading --kill-after=... and the duration positional, exec the rest. +shift # --kill-after=30s +shift # duration (e.g. 90m) +exec "$@"' +fake_bin "$dir3" claude '#!/usr/bin/env bash +echo "claude-ran args=$*" >> "'"$dir3"'/claude.marker" +echo "{\"session_id\":\"sess-fixture-55\",\"result\":\"ok\"}" +exit 0' +run_daemon_once "$dir3" >/dev/null 2>&1 +check "scenario 3 (advance): setsid stub was invoked" [ -f "$dir3/setsid.marker" ] +check "scenario 3: timeout stub was invoked" [ -f "$dir3/timeout.marker" ] +check "scenario 3: claude stub was invoked" [ -f "$dir3/claude.marker" ] +check "scenario 3: claude stub received the prompt text" bash -c 'grep -qF "issue #55" "$1"' _ "$dir3/claude.marker" +ledger3="$dir3/.claude/state/loop-runs.log" +check "scenario 3: exactly one ledger line was appended" [ "$(wc -l < "$ledger3" 2>/dev/null || echo 0)" -eq 1 ] +check "scenario 3: ledger line has pid=/session=/verdict=/ts=/result= fields" bash -c ' + grep -Eq "^pid=[0-9]+ session=sess-fixture-55 verdict=advance issue=55 ts=[0-9T:Z-]+ result=exit rc=0$" "$1" +' _ "$ledger3" +check "scenario 3: prompt file was cleaned up after the driver ran" [ ! -f "$prompt3_dir/prompt.txt" ] + +# --------------------------------------------------------------------------- +# 4. action=feedback pr=N: same driver path, different verdict text, session +# id missing from the (malformed) driver output -> ledger records 'unknown'. +# --------------------------------------------------------------------------- +prompt4_dir="$work/scenario4-support" +mkdir -p "$prompt4_dir" +printf 'Run the ADDRESS FEEDBACK step for PR #9.\n' > "$prompt4_dir/prompt.txt" +dir4="$(new_fixture scenario4 "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=feedback pr=9' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt4_dir/prompt.txt' +exit 0")" +fake_bin "$dir4" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir4" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir4" claude '#!/usr/bin/env bash +echo "not valid json output, no session_id here" +exit 0' +run_daemon_once "$dir4" >/dev/null 2>&1 +ledger4="$dir4/.claude/state/loop-runs.log" +check "scenario 4 (feedback, no parseable session_id): ledger records session=unknown" bash -c ' + grep -Eq "^pid=[0-9]+ session=unknown verdict=feedback pr=9 ts=[0-9T:Z-]+ result=exit rc=0$" "$1" +' _ "$ledger4" + +# --------------------------------------------------------------------------- +# 5. Driver timeout: fake timeout stub exits 124 (as GNU timeout does on a +# real kill) without ever invoking claude; ledger must record +# result=timeout rc=124. +# --------------------------------------------------------------------------- +prompt5_dir="$work/scenario5-support" +mkdir -p "$prompt5_dir" +printf 'Run the ADVANCE step for issue #3.\n' > "$prompt5_dir/prompt.txt" +dir5="$(new_fixture scenario5 "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=advance issue=3' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt5_dir/prompt.txt' +exit 0")" +fake_bin "$dir5" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir5" timeout '#!/usr/bin/env bash +# Simulate a real timeout: the wrapped command never gets to run. +exit 124' +fake_bin "$dir5" claude '#!/usr/bin/env bash +echo "claude-should-not-run" >> "'"$dir5"'/claude.should-not-run" +exit 0' +run_daemon_once "$dir5" >/dev/null 2>&1 +ledger5="$dir5/.claude/state/loop-runs.log" +check "scenario 5 (timeout): ledger records result=timeout rc=124" bash -c ' + grep -Eq "^pid=[0-9]+ session=unknown verdict=advance issue=3 ts=[0-9T:Z-]+ result=timeout rc=124$" "$1" +' _ "$ledger5" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "loop-daemon.test.sh: PASS ($ok checks)" + exit 0 +else + echo "loop-daemon.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.claude/scripts/loop-event.sh b/.claude/scripts/loop-event.sh new file mode 100644 index 0000000..28cb7c6 --- /dev/null +++ b/.claude/scripts/loop-event.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# loop-event.sh — one firing of the autonomous PR loop (cron-less entry point, +# issue #102). Adapted from the verified draft in the issue. +# +# Runs the deterministic tick (loop-tick.sh), parses its LAST-line verdict, and +# emits a small structured `loop-event: ...` block describing what (if +# anything) a caller should do next. This script touches NO model/driver +# process itself: issue #102's daemon (loop-daemon.sh) owns the +# setsid/timeout/ledger wrapping around the actual `claude -p` spawn, so a +# broken/garbage verdict here can NEVER result in a driver being spawned — the +# spawn is a whole separate step the caller only reaches by parsing the +# `loop-event: action=advance|feedback ...` line below. +# +# Never re-derives the verdict — issue #81 contract: it is computed ONCE, by +# loop-tick.sh's shell logic, and passed through byte-identical. +# +# Output contract — stdout is loop-tick.sh's own full, un-swallowed output, +# FOLLOWED by this script's own lines, every one of which is prefixed +# `loop-event: ` so a caller can `sed -n 's/^loop-event: //p'` them out +# without caring about anything above: +# +# loop-event: action=none +# -> nothing else is printed. NO model/driver process must be spawned. +# loop-event: action=advance issue=N +# loop-event: action=feedback pr=N +# loop-event: model= +# loop-event: prompt-file= +# -> actionable. The caller is expected to spawn something equivalent to +# `claude --model -p "$(cat )" --output-format json` +# itself, under whatever containment it wants (loop-daemon.sh wraps it +# in setsid + timeout + a run-ledger append) — this script never execs +# claude, setsid, or timeout. +# +# Exit code: 0 on `action=none` OR a successfully emitted advance/feedback +# verdict (in which case a prompt-file was written). Non-zero if loop-tick.sh +# itself failed, or its verdict line failed to parse — in EITHER case a +# `loop-event: action=none` line is STILL printed last (so a caller doing a +# blind `sed -n 's/^loop-event: action=//p' | tail -1` never sees a stale or +# missing action), and no prompt-file is written. +# +# Honors $GATES_FILE: not read directly here beyond quoting it into the +# self-hosting adapter clause baked into the prompt below (loop-tick.sh and +# loop-census.sh are what actually act on it). +set -uo pipefail + +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" + +cd "$root" + +state_dir="$root/.claude/state" +mkdir -p "$state_dir" + +# --- 1. Deterministic tick --------------------------------------------------- +tick_out="$(bash "$script_dir/loop-tick.sh")" +tick_rc=$? +printf '%s\n' "$tick_out" +if [ "$tick_rc" -ne 0 ]; then + echo "loop-event: loop-tick.sh exited $tick_rc — not spawning a driver on a broken tick" >&2 + echo "loop-event: action=none" + exit "$tick_rc" +fi +verdict="$(printf '%s\n' "$tick_out" | tail -1)" + +# --- 2. Obey the verdict ----------------------------------------------------- +n="" +case "$verdict" in + action=none) + echo "loop-event: no actionable activity — no driver to spawn" + echo "loop-event: action=none" + exit 0 + ;; + "action=advance issue="*) n="${verdict#action=advance issue=}" ;; + "action=feedback pr="*) n="${verdict#action=feedback pr=}" ;; + *) + echo "loop-event: unexpected verdict line: $verdict" >&2 + echo "loop-event: action=none" + exit 1 + ;; +esac +case "$n" in + *[!0-9]*|'') + echo "loop-event: verdict number failed to parse from: $verdict" >&2 + echo "loop-event: action=none" + exit 1 + ;; +esac + +# Adapter clause: only when this loop runs against a non-default adapter +# (self-hosting). Mirrors the wording in .claude/self/pr-loop-self.md. +adapter="" +if [ -n "${GATES_FILE:-}" ]; then + adapter="Export GATES_FILE=$GATES_FILE for every gate/orchestration step, and instruct every spawned agent (orchestrator, implementers, reviewers) to read $GATES_FILE — NOT the placeholder root .claude/gates.json — as its adapter (module map, gates, review lenses). Every gate.sh invocation MUST run as: GATES_FILE=$GATES_FILE bash $script_dir/gate.sh . " +fi +common="The tick (loop-tick.sh) already ran census/poll/merge/feedback-detection this firing and emitted this verdict — do NOT re-run those scripts and do NOT re-derive the verdict. ${adapter}ALL gh interaction (yours and every agent's) MUST run as the bot via bash $script_dir/bot-gh.sh — never bare gh; only git commits/pushes stay as the owner. Follow docs/USAGE.md and .claude/agents/*; reviewer lenses + consensus per the adapter. Keep the final report to a few lines — it is telemetry, not documentation." + +case "$verdict" in + action=advance*) + prompt="Run the ADVANCE step of the autonomous PR loop for issue #$n. $common +Drive issue #$n through the orchestrator: scope → worktree implementer → gate.sh gates → reviewer lenses → bot PR. One issue in flight at a time — work ONLY issue #$n. \`backlog\` issues are owner-unapproved: if you file an issue yourself, label it backlog — NEVER planned (that label is the owner's formal approval, assigned by the owner alone)." + action_line="action=advance issue=$n" + ;; + *) + prompt="Run the ADDRESS FEEDBACK step of the autonomous PR loop for PR #$n. $common +Address the unaddressed CHANGES_REQUESTED feedback on PR #$n: orchestrator → worktree implementer → reviewer lenses on the SAME branch, push to update the PR in place, and post the \`\` marker comment via bot-gh.sh. Do NOT merge." + action_line="action=feedback pr=$n" + ;; +esac + +prompt_file="$(mktemp "$state_dir/.loop-event-prompt.XXXXXX")" +printf '%s\n' "$prompt" > "$prompt_file" + +echo "=== loop-event: verdict-obeying driver requested ($action_line, model=${LOOP_MODEL:-sonnet}) ===" +echo "loop-event: $action_line" +echo "loop-event: model=${LOOP_MODEL:-sonnet}" +echo "loop-event: prompt-file=$prompt_file" +exit 0 diff --git a/.claude/scripts/loop-event.test.sh b/.claude/scripts/loop-event.test.sh new file mode 100644 index 0000000..4b102d0 --- /dev/null +++ b/.claude/scripts/loop-event.test.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# loop-event.test.sh — offline smoke test for loop-event.sh (issue #102). +# +# loop-event.sh's job is: run loop-tick.sh, parse its LAST-line verdict, and +# print a `loop-event: ...` decision block WITHOUT ever touching claude, +# setsid, or timeout itself. So this test builds a throwaway +# .claude/scripts/ directory containing the REAL loop-event.sh + +# resolve-roots.sh next to a FAKE loop-tick.sh that prints canned, scripted +# output — and deliberately does NOT put a `claude` binary anywhere on PATH. +# If loop-event.sh ever tried to spawn a driver directly, every scenario +# below would fail with "command not found" instead of the assertions it +# actually makes — that absence is itself part of the "action=none spawns +# zero drivers" contract this test enforces for ALL verdicts, not just none. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/loop-event.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +loop_event_src="$script_dir/loop-event.sh" +resolve_roots_src="$script_dir/resolve-roots.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/loop-event-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +# Build one fresh fake "consumer project" per scenario: /.claude/scripts/. +# fake_tick_out is the exact stdout+verdict the corresponding real loop-tick.sh +# would print (last line = the verdict); fake_tick_rc is its exit code. +new_fixture() { + local name="$1" fake_tick_out="$2" fake_tick_rc="${3:-0}" + local dir="$work/$name/.claude/scripts" + mkdir -p "$dir" "$work/$name/.claude/state" + cp "$loop_event_src" "$dir/loop-event.sh" + cp "$resolve_roots_src" "$dir/resolve-roots.sh" + cat > "$dir/loop-tick.sh" < no prompt-file, no driver spawned, exit 0. +# --------------------------------------------------------------------------- +dir1="$(new_fixture scenario1 'cadence=IDLE cron=*/15 * * * * +action=none')" +out1="$(run_event "$dir1")"; rc1=$? +check "scenario 1 (action=none): exits 0" [ "$rc1" -eq 0 ] +check "scenario 1: emits loop-event: action=none" bash -c 'printf "%s\n" "$1" | grep -qxF "loop-event: action=none"' _ "$out1" +check "scenario 1: emits NO prompt-file line" bash -c '! printf "%s\n" "$1" | grep -q "^loop-event: prompt-file="' _ "$out1" +check "scenario 1: no prompt file left on disk" bash -c '! ls "$1"/.claude/state/.loop-event-prompt.* >/dev/null 2>&1' _ "$dir1" + +# --------------------------------------------------------------------------- +# 2. action=advance issue=N -> action/model/prompt-file lines, file exists and +# mentions the issue number; exit 0. +# --------------------------------------------------------------------------- +dir2="$(new_fixture scenario2 'cadence=FAST cron=* * * * * +action=advance issue=42')" +out2="$(run_event "$dir2")"; rc2=$? +check "scenario 2 (advance): exits 0" [ "$rc2" -eq 0 ] +check "scenario 2: emits loop-event: action=advance issue=42" bash -c 'printf "%s\n" "$1" | grep -qxF "loop-event: action=advance issue=42"' _ "$out2" +check "scenario 2: emits a model= line (default sonnet)" bash -c 'printf "%s\n" "$1" | grep -qxF "loop-event: model=sonnet"' _ "$out2" +pf2="$(printf '%s\n' "$out2" | sed -n 's/^loop-event: prompt-file=//p')" +check "scenario 2: prompt-file line points at a real file" bash -c '[ -n "$1" ] && [ -f "$1" ]' _ "$pf2" +check "scenario 2: prompt file mentions issue #42" bash -c 'grep -q "issue #42" "$1"' _ "$pf2" +check "scenario 2: prompt file says ADVANCE, not feedback" bash -c 'grep -q "ADVANCE step" "$1"' _ "$pf2" + +# --------------------------------------------------------------------------- +# 3. action=feedback pr=N -> same contract, feedback wording, LOOP_MODEL honored. +# --------------------------------------------------------------------------- +dir3="$(new_fixture scenario3 'cadence=FAST cron=* * * * * +action=feedback pr=7')" +out3="$(cd "$dir3" && PATH="/usr/bin:/bin" LOOP_MODEL=opus bash .claude/scripts/loop-event.sh)"; rc3=$? +check "scenario 3 (feedback): exits 0" [ "$rc3" -eq 0 ] +check "scenario 3: emits loop-event: action=feedback pr=7" bash -c 'printf "%s\n" "$1" | grep -qxF "loop-event: action=feedback pr=7"' _ "$out3" +check "scenario 3: LOOP_MODEL is honored (model=opus)" bash -c 'printf "%s\n" "$1" | grep -qxF "loop-event: model=opus"' _ "$out3" +pf3="$(printf '%s\n' "$out3" | sed -n 's/^loop-event: prompt-file=//p')" +check "scenario 3: prompt file mentions PR #7" bash -c 'grep -q "PR #7" "$1"' _ "$pf3" +check "scenario 3: prompt file says ADDRESS FEEDBACK, and Do NOT merge" bash -c 'grep -q "ADDRESS FEEDBACK" "$1" && grep -q "Do NOT merge" "$1"' _ "$pf3" + +# --------------------------------------------------------------------------- +# 4. Garbage verdict line -> non-zero exit, action=none fallback line, no +# prompt-file (never spawns on a verdict it can't parse). +# --------------------------------------------------------------------------- +dir4="$(new_fixture scenario4 'cadence=IDLE cron=*/15 * * * * +action=something-else')" +out4="$(run_event "$dir4")"; rc4=$? +check "scenario 4 (garbage verdict): exits non-zero" [ "$rc4" -ne 0 ] +check "scenario 4: falls back to loop-event: action=none" bash -c 'printf "%s\n" "$1" | grep -qxF "loop-event: action=none"' _ "$out4" +check "scenario 4: no prompt-file line emitted" bash -c '! printf "%s\n" "$1" | grep -q "^loop-event: prompt-file="' _ "$out4" + +# --------------------------------------------------------------------------- +# 5. loop-tick.sh itself fails (nonzero exit) -> loop-event.sh propagates +# non-zero, still ends on action=none, never spawns. +# --------------------------------------------------------------------------- +dir5="$(new_fixture scenario5 'error: network blip' 3)" +out5="$(run_event "$dir5")"; rc5=$? +check "scenario 5 (tick failure): exits with the tick's own rc (3)" [ "$rc5" -eq 3 ] +check "scenario 5: falls back to loop-event: action=none" bash -c 'printf "%s\n" "$1" | grep -qxF "loop-event: action=none"' _ "$out5" +check "scenario 5: no prompt-file line emitted" bash -c '! printf "%s\n" "$1" | grep -q "^loop-event: prompt-file="' _ "$out5" + +# --------------------------------------------------------------------------- +# 6. GATES_FILE is threaded into the driver prompt's adapter clause (so a +# spawned agent points every gate.sh call at the right adapter). +# --------------------------------------------------------------------------- +dir6="$(new_fixture scenario6 'cadence=FAST cron=* * * * * +action=advance issue=9')" +out6="$(cd "$dir6" && PATH="/usr/bin:/bin" GATES_FILE=.claude/self/gates.json bash .claude/scripts/loop-event.sh)"; rc6=$? +pf6="$(printf '%s\n' "$out6" | sed -n 's/^loop-event: prompt-file=//p')" +check "scenario 6 (GATES_FILE): exits 0" [ "$rc6" -eq 0 ] +check "scenario 6: prompt file exports the adapter's GATES_FILE" bash -c 'grep -q "GATES_FILE=.claude/self/gates.json" "$1"' _ "$pf6" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "loop-event.test.sh: PASS ($ok checks)" + exit 0 +else + echo "loop-event.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.claude/self/README.md b/.claude/self/README.md index 7ecb8b4..213a04e 100644 --- a/.claude/self/README.md +++ b/.claude/self/README.md @@ -39,7 +39,14 @@ To have the autonomous loop work this repo's own `module:*` backlog: **`.claude/self/gates.json`** as its adapter (module map + gates) for this repo. The generic agents/scripts otherwise behave identically — worker boundaries come from this file's `modules`, gates from its `gates`. -The durable, first-class way to do this is **`.claude/self/pr-loop-self.md`** — it mirrors `/pr-loop` exactly +**Recommended (issue #102): the cron-less daemon.** `bash .claude/scripts/arm-loop.sh --gates-file .claude/self/gates.json` +(run in a real terminal outside Claude Code — installing systemd units/`enable-linger`/tmux touches `$HOME` +and is blocked by the sandbox) arms `.claude/scripts/loop-daemon.sh` under `systemd --user`, forever, adapted +to this repo's own module set. It survives session restarts and spawns a driver only on an actionable +verdict. Never run it alongside `.claude/self/pr-loop-self.md`'s cron at the same time — safe (spawn lock), +just wasteful. + +The legacy, session-scoped way to do this is **`.claude/self/pr-loop-self.md`** — it mirrors `/pr-loop` exactly (arm/re-arm cron, adaptive cadence, poll → merge → address-feedback → advance) but carries `GATES_FILE=.claude/self/gates.json` through every gate call and every spawned agent, and adapts on the self modules (`module:docs`/`module:harness`/`module:examples`/`module:ci`) instead of the project's own diff --git a/.claude/self/checks.sh b/.claude/self/checks.sh index 4127cd7..6be0160 100644 --- a/.claude/self/checks.sh +++ b/.claude/self/checks.sh @@ -39,7 +39,11 @@ do_build() { do_lint() { local rc=0 f - for f in .claude/scripts/*.sh .claude/self/*.sh; do + # .claude/skills/*/*.sh (scaffold.sh, sync.sh) and .claude/skills/*/templates/*.sh + # (issue #102's arm-loop.sh template) are included so a syntax regression in the + # setup/sync machinery or a scaffolded script template is caught here too, not just + # .claude/scripts/*.sh and .claude/self/*.sh. + for f in .claude/scripts/*.sh .claude/self/*.sh .claude/skills/*/*.sh .claude/skills/*/templates/*.sh; do [ -e "$f" ] || continue bash -n "$f" || { echo "lint: shell syntax error — $f"; rc=1; } done diff --git a/.claude/self/pr-loop-self.md b/.claude/self/pr-loop-self.md index 49e70fd..885e042 100644 --- a/.claude/self/pr-loop-self.md +++ b/.claude/self/pr-loop-self.md @@ -2,6 +2,14 @@ description: Arm (or re-arm) the self-hosted PR-loop cron and run one tick now --- +**LEGACY path (issue #102).** This is the session-scoped cron. The RECOMMENDED replacement is the cron-less +daemon: `systemd --user` supervises `.claude/scripts/loop-daemon.sh` forever, independent of any Claude Code +session, and spawns a driver only on an actionable verdict (never on `action=none`). Install it with +`bash .claude/scripts/arm-loop.sh --gates-file .claude/self/gates.json` (run in a real terminal outside Claude +Code — see `docs/HARDENING.md` → Caveats). **Never run both this cron and the daemon against this repo at +once** — `loop-tick.sh`'s spawn lock makes it *safe* (no double-spawn), merely wasteful (two firing sources +burning ticks against the same state). Keep reading below only if you're intentionally using the legacy cron. + You are (re)arming this project's self-hosted PR loop — the loop that works THIS repo's own `.claude`/`docs`/`examples`/`.github` backlog instead of a downstream project's. The loop is session-scoped (cron jobs die when Claude Code exits and may not persist across restarts even when durable), so it is lost at diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index ab1d50a..bd608b3 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -80,11 +80,16 @@ idempotently: in the repo is older than the version scaffold.sh ships, it re-stamps (overwrites); if it's the same or newer, it's left alone. This is the seam a future plugin-upgrade flow uses to push workflow fixes into already-onboarded repos without touching hand-edited copies that opted out (by bumping their own marker). +- `.claude/systemd/pr-loop.service`, `.claude/systemd/claude-rc.service`, `.claude/scripts/arm-loop.sh` + (issue #102) — the cron-less loop daemon's systemd unit TEMPLATES and the installer script, all `managed` + the same way as `feature-fanout.js` (own `@orchestrator-managed vN` marker, re-stamped on upgrade). + These carry `__WORKDIR__`/`__REPO_SLUG__`/etc. placeholders that `arm-loop.sh` substitutes at ARM time, not + at scaffold time — scaffolding them here does NOT install or start anything. See step 9 below for arming. - `.github/workflows/gates.yml` + `.github/actions/setup/action.yml` — the CI gate. Created if absent, left untouched if present. - `.gitignore` entries (append-if-missing, never duplicated): `.env`, `.env.*`, `!.env.example`, `.claude/settings.local.json`, `.claude/state/`. -- `.claude/state/` directory (the notify-poll cursor lives here). +- `.claude/state/` directory (the notify-poll cursor and the loop daemon's run ledger live here). Report the script's per-file summary (created / kept / restamped / up to date / appended) to the user. Then write the interview answers into `.claude/gates.json` (validate with `node -e "require('./.claude/gates.json')"`) @@ -119,7 +124,63 @@ Report created vs already-existing. Remind: **an issue is only loop-eligible onc to reconcile. Note that **branch protection / required checks** (making CI a hard merge gate) is an owner action in repo Settings — flag it as a manual step. -## 9. Arm the PR loop +## 9. Arm the loop +Two ways to fire the loop; ask the user which one (`AskUserQuestion`), presenting the daemon as the default: + +- **Daemon (RECOMMENDED, new default) — issue #102.** A `systemd --user` service (`loop-daemon.sh`) + supervises the loop forever, independent of any Claude Code session: it runs a tick, and only when the + tick's verdict is actionable does it spawn a headless driver (`claude -p`), contained (`setsid` + `timeout`) + and ledgered (`.claude/state/loop-runs.log`). `action=none` spawns nothing — the dominant cost of the old + cron (a fresh full-context session on every quiet firing) is gone. Survives Claude Code restarting/exiting. + Requires Linux systemd (native, or WSL2 with systemd enabled). +- **Legacy cron — `/pr-loop`.** Session-scoped `CronCreate`; dies with the Claude Code session that armed it + and must be re-armed every session. Kept for environments without systemd, or as a fallback. **Never run + both at once** against the same repo — the spawn lock in `loop-tick.sh` makes it *safe* (no double-spawn), + merely wasteful (two firing sources burning ticks against the same state). + +### If the user picks the daemon +1. **Ask the environment**: `Linux` or `WSL2` (`AskUserQuestion`). +2. **WSL2 only — verify systemd first.** Ask the user to check `/etc/wsl.conf` for a `[boot]` section with + `systemd=true`. If it's missing or false, this MUST be fixed before continuing: + - Print the edit for them to make (in a real editor, on the Windows side or via `wsl.exe`): + ``` + [boot] + systemd=true + ``` + - Print the command to apply it: `wsl --shutdown` (run from Windows, then reopen the WSL terminal). + - **Stop here** for this sub-step — do not proceed to unit install until they confirm systemd is enabled + (re-check with `systemctl --version` inside WSL2 after the restart). +3. **Sandbox caveat (both Linux and WSL2).** Installing systemd units under `~/.config/systemd/user/`, + `loginctl enable-linger`, and starting a detached tmux session all touch `$HOME` and systemd — **the + sandbox blocks this** (`docs/HARDENING.md` → Caveats). Tell the user to run the following in a **real + terminal outside Claude Code**: + ``` + bash .claude/scripts/arm-loop.sh + ``` + (Self-hosting: `bash .claude/scripts/arm-loop.sh --gates-file .claude/self/gates.json`.) This one script + installs both `pr-loop-.service` (the loop daemon) and `claude-rc-.service` (`claude + remote-control` in a detached tmux session, so planning sessions can be spawned from claude.ai/mobile), + enables + starts them, and runs `loginctl enable-linger $USER` so they keep running without an open login + session. It substitutes this checkout's actual path/repo-slug/permission-mode into the templates + scaffolded at `.claude/systemd/pr-loop.service` and `.claude/systemd/claude-rc.service` — do not hand-edit + the installed copies under `~/.config/systemd/user/`; edit the checked-in templates and re-run + `arm-loop.sh` instead. +4. **WSL2 only — offer Windows-logon autostart.** Ask whether they want WSL2 to relaunch automatically when + Windows logs in (so the daemon survives a Windows reboot without a manual `wsl` open): + - **Yes** → print the exact command to run in an **elevated Windows terminal** (not WSL): + ``` + schtasks /create /tn "WSL pr-loop autostart" /tr "wsl.exe -d --exec true" /sc onlogon + ``` + substituting `` from `wsl -l` (run on the Windows side) — booting the distro starts systemd, + which starts both units automatically (`WantedBy=default.target` + linger). + - **No** → tell them this is safe to skip: GitHub is the loop's only source of truth, so any events that + land while WSL2 is stopped are simply picked up by the first tick after the next manual WSL2 start — + nothing is dropped, it's just delayed. +5. Report the units' names and how to inspect them (`systemctl --user status pr-loop-.service`, + `journalctl --user -u pr-loop-.service -f`, `tail -f .claude/state/loop-runs.log`, + `tmux attach -t rc-`). + +### If the user picks the legacy cron - Explain `/pr-loop` (session-scoped cron; adaptive cadence). **Offer to run it now** (ask; don't auto-run). If they decline, note they can run `/pr-loop` anytime — and must re-arm it each session. @@ -128,9 +189,11 @@ Report created vs already-existing. Remind: **an issue is only loop-eligible onc hands-off autonomous runs (`docs/HARDENING.md` is the source of truth, if present). **Offer to run `/harden` now** (ask). - Sequencing the user must know: hardening only takes effect after a **restart**, once hardened the agent can no - longer edit `.claude/settings*.json` (by design), and the restart drops the in-session PR-loop cron. So the + longer edit `.claude/settings*.json` (by design), and the restart drops any in-session PR-loop cron. So the correct order is: finish setup → `/harden` → restart Claude Code → **re-run `/pr-loop`** in the hardened - session. Do not harden before the rest of setup is done. + session (legacy cron path only). Do not harden before the rest of setup is done. The **daemon path is + unaffected by this** — `loop-daemon.sh` runs under systemd, entirely outside any Claude Code session, so a + restart (or hardening) never drops it; no re-arming needed. ## 11. Hand off Summarize what changed (files written/kept/restamped by `scaffold.sh`, `gates.json`/`CLAUDE.md` filled, @@ -140,10 +203,13 @@ with an ordered checklist of everything only the human can complete, e.g.: - add `GH_BOT_TOKEN` to `.env` / add the bot as a write collaborator (if step 7 flagged it), - set branch protection / required status checks (if wanted), - OS-level isolation from `docs/HARDENING.md` Step 2 (sudo / VM / WSL interop) if hardening, -- restart, then re-run `/pr-loop`. +- if the daemon path was chosen: run `bash .claude/scripts/arm-loop.sh` in a real terminal outside Claude + Code (sandbox caveat), plus the WSL2-only `systemd=true` fix and/or `schtasks` autostart command if flagged + in step 9, +- if the legacy cron path was chosen: restart, then re-run `/pr-loop`. ## Reference: file inventory this skill scaffolds See `.claude/skills/setup/templates/MANIFEST.md` for the full template → destination map, and `.claude/skills/setup/scaffold.sh` for the idempotent implementation (safe to re-run any time; it never -touches user-owned files that already exist, and only re-stamps the managed workflow when its version marker +touches user-owned files that already exist, and only re-stamps a managed file when its version marker is behind). diff --git a/.claude/skills/setup/scaffold.sh b/.claude/skills/setup/scaffold.sh index 1af1fd0..ae0c12f 100755 --- a/.claude/skills/setup/scaffold.sh +++ b/.claude/skills/setup/scaffold.sh @@ -29,12 +29,21 @@ target_root="${1:-$PWD}" mkdir -p "$target_root" target_root="$(cd "$target_root" && pwd)" -# Single source of truth for the managed workflow's version. Bump this whenever -# templates/feature-fanout.js's behavior changes; scaffold.sh will then re-stamp any -# destination whose marker is older (see issue #38, which drives re-stamping on -# plugin upgrade). -MANAGED_VERSION=2 -MARKER_PREFIX="@orchestrator-managed feature-fanout v" +# --- managed-file table ------------------------------------------------------------- +# "template-name|dest-relpath|marker-prefix" — one entry per file scaffold.sh manages +# going forward (re-stamped, never silently clobbered once at/above the shipped +# version). shipped_version is read straight out of EACH TEMPLATE's own marker line +# (managed_version_of, below) rather than a separately hand-maintained constant — see +# sync.sh's `managed_version_of` comment for why keeping those in two places invites +# drift (issue #102 generalized this from the single-entry feature-fanout-only form). +# Adding a new managed file is exactly one more line here, plus the matching line in +# sync.sh's MANAGED_FILES table and a row in templates/MANIFEST.md. +MANAGED_FILES=( + "feature-fanout.js|.claude/workflows/feature-fanout.js|@orchestrator-managed feature-fanout v" + "pr-loop.service|.claude/systemd/pr-loop.service|@orchestrator-managed pr-loop-service v" + "claude-rc.service|.claude/systemd/claude-rc.service|@orchestrator-managed claude-rc-service v" + "arm-loop.sh|.claude/scripts/arm-loop.sh|@orchestrator-managed arm-loop v" +) echo "orchestrator setup: scaffolding into $target_root" @@ -52,37 +61,54 @@ copy_if_absent() { } managed_version_of() { - # Prints the version number found in the marker line of $1, or empty if none. - local f="$1" + # Prints the version number found in $1's marker line (matched via the literal + # marker prefix $2), or empty if no marker line / the file doesn't exist. + local f="$1" prefix="$2" [ -f "$f" ] || { echo ""; return 0; } - grep -o "${MARKER_PREFIX}[0-9]\+" "$f" 2>/dev/null | head -1 | grep -o '[0-9]\+$' || true + grep -F -- "$prefix" "$f" 2>/dev/null | head -1 | grep -o '[0-9]\+$' || true } # --- 1. user-owned files: create only if absent, never overwritten ----------------- copy_if_absent "$templates_dir/gates.json" "$target_root/.claude/gates.json" "project adapter (.claude/gates.json)" copy_if_absent "$templates_dir/CLAUDE.md" "$target_root/CLAUDE.md" "CLAUDE.md" -# --- 2. managed workflow: re-stamp when the destination's marker is older ---------- -fanout_dst="$target_root/.claude/workflows/feature-fanout.js" -existing_version="$(managed_version_of "$fanout_dst")" -if [ ! -f "$fanout_dst" ]; then - mkdir -p "$(dirname "$fanout_dst")" - cp "$templates_dir/feature-fanout.js" "$fanout_dst" - echo " created: managed workflow (.claude/workflows/feature-fanout.js) at v$MANAGED_VERSION" -elif [ -z "$existing_version" ]; then - # Present but carries no recognizable marker (e.g. hand-authored or pre-marker file) - # — treat as older than any managed version and re-stamp. - cp "$templates_dir/feature-fanout.js" "$fanout_dst" - echo " restamped: managed workflow (.claude/workflows/feature-fanout.js) — no marker found, now v$MANAGED_VERSION" -elif [ "$existing_version" -lt "$MANAGED_VERSION" ]; then - cp "$templates_dir/feature-fanout.js" "$fanout_dst" - echo " restamped: managed workflow (.claude/workflows/feature-fanout.js) v$existing_version -> v$MANAGED_VERSION" -elif [ "$existing_version" -eq "$MANAGED_VERSION" ]; then - echo " up to date: managed workflow (.claude/workflows/feature-fanout.js) already v$MANAGED_VERSION" -else - # Destination carries a NEWER version than this scaffold.sh ships — never clobber. - echo " kept: managed workflow (.claude/workflows/feature-fanout.js) is v$existing_version, newer than this installer's v$MANAGED_VERSION — left untouched" -fi +# --- 2. managed files: create if absent, re-stamp when the destination's marker is +# older than the template's own, never downgrade a newer local marker -------- +for entry in "${MANAGED_FILES[@]}"; do + IFS='|' read -r tmpl_name dest_rel marker_prefix <<<"$entry" + template="$templates_dir/$tmpl_name" + dst="$target_root/$dest_rel" + label="managed file ($dest_rel)" + + shipped_version="$(managed_version_of "$template" "$marker_prefix")" + if [ -z "$shipped_version" ]; then + echo " error: $label — shipped template $template has no valid @orchestrator-managed marker; plugin install looks broken" >&2 + continue + fi + + existing_version="$(managed_version_of "$dst" "$marker_prefix")" + if [ ! -f "$dst" ]; then + mkdir -p "$(dirname "$dst")" + cp "$template" "$dst" + case "$tmpl_name" in *.sh) chmod +x "$dst" ;; esac + echo " created: $label at v$shipped_version" + elif [ -z "$existing_version" ]; then + # Present but carries no recognizable marker (e.g. hand-authored or pre-marker + # file) — treat as older than any managed version and re-stamp. + cp "$template" "$dst" + case "$tmpl_name" in *.sh) chmod +x "$dst" ;; esac + echo " restamped: $label — no marker found, now v$shipped_version" + elif [ "$existing_version" -lt "$shipped_version" ]; then + cp "$template" "$dst" + case "$tmpl_name" in *.sh) chmod +x "$dst" ;; esac + echo " restamped: $label v$existing_version -> v$shipped_version" + elif [ "$existing_version" -eq "$shipped_version" ]; then + echo " up to date: $label already v$shipped_version" + else + # Destination carries a NEWER version than this scaffold.sh ships — never clobber. + echo " kept: $label is v$existing_version, newer than this installer's v$shipped_version — left untouched" + fi +done # --- 3. CI templates: create only if absent ---------------------------------------- copy_if_absent "$templates_dir/gates.yml" "$target_root/.github/workflows/gates.yml" "CI gate workflow (.github/workflows/gates.yml)" diff --git a/.claude/skills/setup/templates/MANIFEST.md b/.claude/skills/setup/templates/MANIFEST.md index b761403..9450dd3 100644 --- a/.claude/skills/setup/templates/MANIFEST.md +++ b/.claude/skills/setup/templates/MANIFEST.md @@ -10,8 +10,18 @@ materializes them into the consumer repo on first run. | `gates.json` | `.claude/gates.json` | user | created only if absent; never overwritten | | `CLAUDE.md` | `CLAUDE.md` | user | created only if absent; never overwritten | | `feature-fanout.js` | `.claude/workflows/feature-fanout.js` | managed | re-stamped when the `@orchestrator-managed feature-fanout vN` marker is older than the version scaffold.sh ships; left alone if same/newer | +| `pr-loop.service` | `.claude/systemd/pr-loop.service` | managed | (issue #102) systemd user unit TEMPLATE for the cron-less loop daemon — `__WORKDIR__`/`__REPO_SLUG__`/`__GATES_ENV__` placeholders are substituted at ARM time by `.claude/scripts/arm-loop.sh`, not by scaffold.sh. Re-stamped like `feature-fanout.js` via its own `@orchestrator-managed pr-loop-service vN` marker. | +| `claude-rc.service` | `.claude/systemd/claude-rc.service` | managed | (issue #102) systemd user unit TEMPLATE that starts `claude remote-control` inside a detached tmux session. Same placeholder-at-arm-time / marker-restamp behavior as `pr-loop.service`, marker `@orchestrator-managed claude-rc-service vN`. | +| `arm-loop.sh` | `.claude/scripts/arm-loop.sh` | managed | (issue #102) installs both systemd units above (with placeholders substituted for THIS checkout) + `loginctl enable-linger` + starts the remote-control tmux session. MUST be run in a real terminal outside Claude Code (sandbox caveat — see `docs/HARDENING.md`). Marker `@orchestrator-managed arm-loop vN`; copied with the executable bit preserved. | | `gates.yml` | `.github/workflows/gates.yml` | ci | created only if absent; never overwritten | | `action.yml` | `.github/actions/setup/action.yml` | ci | created only if absent; never overwritten | +The three `managed` rows added by issue #102 (`pr-loop.service`, `claude-rc.service`, `arm-loop.sh`) +follow exactly the same ownership class and marker convention as `feature-fanout.js` — `scaffold.sh` +creates them on first setup and re-stamps them on a plugin upgrade if their installed marker is +behind, `sync.sh` re-stamps them going forward, and neither ever touches a copy whose content has +locally diverged from the last pristine version it was stamped from (that's a `conflict`, left for a +human — see `.claude/skills/sync/SKILL.md`). + See `.claude/skills/setup/scaffold.sh` for the implementation, and `.claude/skills/setup/SKILL.md` for the full onboarding flow this scaffold step is one part of. diff --git a/.claude/skills/setup/templates/arm-loop.sh b/.claude/skills/setup/templates/arm-loop.sh new file mode 100755 index 0000000..24a4beb --- /dev/null +++ b/.claude/skills/setup/templates/arm-loop.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# @orchestrator-managed arm-loop v1 +# arm-loop.sh — installs the cron-less PR-loop as systemd (user) units +# (issue #102). Templated + re-stamped by `/orchestrator:setup`/`sync`; do +# not hand-edit the copy scaffold.sh wrote into this repo if you want future +# plugin updates to reach it — fork it under a different name instead. +# +# MUST be run in a REAL terminal OUTSIDE Claude Code: installing units under +# ~/.config/systemd/user/, `loginctl enable-linger`, and starting a detached +# tmux session all touch $HOME and systemd, which the sandbox blocks (see +# docs/HARDENING.md -> Caveats). Safe to re-run any time — every step here is +# idempotent (systemctl --user enable/restart, tmux kill-session -t ... || true +# then recreate). +# +# Usage: +# bash .claude/scripts/arm-loop.sh [--gates-file ] [--permission-mode ] [--capacity N] +# +# --gates-file passed to pr-loop.service as GATES_FILE (e.g. +# .claude/self/gates.json for the self-hosted +# loop). Omit for the default project adapter. +# --permission-mode passed to `claude remote-control --permission-mode`. +# Defaults to permissions.defaultMode in +# .claude/settings.local.json if present, else +# "default". +# --capacity N `claude remote-control --capacity`. Default 8. +set -euo pipefail + +gates_file="" +permission_mode="" +capacity="8" +while [ "$#" -gt 0 ]; do + case "$1" in + --gates-file) gates_file="${2:?--gates-file needs a value}"; shift 2 ;; + --gates-file=*) gates_file="${1#--gates-file=}"; shift ;; + --permission-mode) permission_mode="${2:?--permission-mode needs a value}"; shift 2 ;; + --permission-mode=*) permission_mode="${1#--permission-mode=}"; shift ;; + --capacity) capacity="${2:?--capacity needs a value}"; shift 2 ;; + --capacity=*) capacity="${1#--capacity=}"; shift ;; + -h|--help) + sed -n '2,25p' "$0" + exit 0 + ;; + *) echo "arm-loop.sh: unknown argument '$1'" >&2; exit 2 ;; + esac +done + +if ! command -v systemctl >/dev/null 2>&1; then + echo "arm-loop.sh: 'systemctl' not found — this script only supports systemd (user) on Linux/WSL2." >&2 + exit 1 +fi +if ! command -v tmux >/dev/null 2>&1; then + echo "arm-loop.sh: 'tmux' not found — install it first (needed by claude-rc-.service)." >&2 + exit 1 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +# Label-safe slug: lowercase, non [a-z0-9-] runs collapsed to '-'. +repo_slug="$(basename "$repo_root" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed -E 's/-+/-/g; s/^-|-$//g')" +if [ -z "$repo_slug" ]; then + echo "arm-loop.sh: could not derive a repo slug from '$repo_root'" >&2 + exit 1 +fi + +if [ -z "$permission_mode" ]; then + permission_mode="$(node -e ' + try { + const s = require(process.argv[1]); + if (s && s.permissions && s.permissions.defaultMode) { console.log(s.permissions.defaultMode); process.exit(0); } + } catch (e) {} + ' "$repo_root/.claude/settings.local.json" 2>/dev/null || true)" + permission_mode="${permission_mode:-default}" +fi + +gates_env="" +if [ -n "$gates_file" ]; then + gates_env="Environment=GATES_FILE=$gates_file" +fi + +units_dir="$HOME/.config/systemd/user" +mkdir -p "$units_dir" + +pr_loop_src="$repo_root/.claude/systemd/pr-loop.service" +claude_rc_src="$repo_root/.claude/systemd/claude-rc.service" +for f in "$pr_loop_src" "$claude_rc_src"; do + if [ ! -f "$f" ]; then + echo "arm-loop.sh: missing $f — run /orchestrator:setup (or sync) first to scaffold the unit templates." >&2 + exit 1 + fi +done + +pr_loop_dst="$units_dir/pr-loop-$repo_slug.service" +claude_rc_dst="$units_dir/claude-rc-$repo_slug.service" + +sed -e "s#__WORKDIR__#$repo_root#g" \ + -e "s#__REPO_SLUG__#$repo_slug#g" \ + -e "s#__GATES_ENV__#$gates_env#g" \ + "$pr_loop_src" > "$pr_loop_dst" + +sed -e "s#__WORKDIR__#$repo_root#g" \ + -e "s#__REPO_SLUG__#$repo_slug#g" \ + -e "s#__PERMISSION_MODE__#$permission_mode#g" \ + -e "s#__CAPACITY__#$capacity#g" \ + "$claude_rc_src" > "$claude_rc_dst" + +echo "arm-loop.sh: wrote $pr_loop_dst" +echo "arm-loop.sh: wrote $claude_rc_dst" + +systemctl --user daemon-reload +systemctl --user enable --now "pr-loop-$repo_slug.service" +systemctl --user enable --now "claude-rc-$repo_slug.service" + +loginctl enable-linger "$USER" || echo "arm-loop.sh: warning — 'loginctl enable-linger $USER' failed; user units will only run while a login session is open." >&2 + +cat <.service by +# `.claude/scripts/arm-loop.sh`, which substitutes the __PLACEHOLDER__ tokens +# below and runs `systemctl --user enable --now`. +# +# `claude remote-control` has no documented headless mode, so this unit runs +# it inside a DETACHED tmux session (`tmux new -d -s rc-`): tmux +# gives systemd a stable child to track AND gives a human a local attach point +# (`tmux attach -t rc-`) to see the QR/status or restart it by hand. +# +# Type=oneshot + RemainAfterExit=yes (not Type=forking): `tmux new -d` talks +# to the tmux SERVER over a socket and exits immediately once the detached +# session exists — if a tmux server is already running, ExecStart's own PID +# has no parent/child relationship to the long-lived process at all, so +# systemd cannot reliably track it as a "forked" child. This is the standard +# pattern for supervising a tmux/screen-managed daemon from systemd. Caveat: +# systemd only observes ExecStart's (successful) exit, not the health of the +# `claude remote-control` process running inside the tmux session — if THAT +# process itself crashes, tmux keeps the (now-empty) session and systemd sees +# nothing wrong. Inspect with `tmux attach -t rc-`; restart with +# `systemctl --user restart claude-rc-.service`. +# +# DO NOT hand-edit the INSTALLED copy under ~/.config/systemd/user/ — it will +# be silently overwritten the next time arm-loop.sh runs. Edit THIS checked-in +# template instead (then re-run arm-loop.sh), or fork it under a different +# name if you want a permanently custom copy. Re-run `/orchestrator:sync` to +# pick up plugin updates to this template before re-arming. +[Unit] +Description=claude remote-control server for __REPO_SLUG__ (detached tmux) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=__WORKDIR__ +ExecStartPre=-/usr/bin/tmux kill-session -t rc-__REPO_SLUG__ +ExecStart=/usr/bin/tmux new -d -s rc-__REPO_SLUG__ -c __WORKDIR__ claude remote-control --remote-control-session-name-prefix __REPO_SLUG__ --capacity __CAPACITY__ --permission-mode __PERMISSION_MODE__ +ExecStop=-/usr/bin/tmux kill-session -t rc-__REPO_SLUG__ + +[Install] +WantedBy=default.target diff --git a/.claude/skills/setup/templates/pr-loop.service b/.claude/skills/setup/templates/pr-loop.service new file mode 100644 index 0000000..5a994c2 --- /dev/null +++ b/.claude/skills/setup/templates/pr-loop.service @@ -0,0 +1,29 @@ +# @orchestrator-managed pr-loop-service v1 +# systemd (user) unit TEMPLATE (issue #102). Installed into +# ~/.config/systemd/user/pr-loop-.service by +# `.claude/scripts/arm-loop.sh`, which substitutes the __PLACEHOLDER__ tokens +# below for this checkout (WorkingDirectory, repo slug, optional GATES_FILE +# env line) and runs `systemctl --user enable --now`. +# +# DO NOT hand-edit the INSTALLED copy under ~/.config/systemd/user/ — it will +# be silently overwritten the next time arm-loop.sh runs. Edit THIS checked-in +# template instead (then re-run arm-loop.sh), or fork it under a different +# name if you want a permanently custom copy. Re-run `/orchestrator:sync` to +# pick up plugin updates to this template before re-arming. +[Unit] +Description=Cron-less autonomous PR loop daemon (__REPO_SLUG__) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=__WORKDIR__ +__GATES_ENV__ +ExecStart=/usr/bin/env bash __WORKDIR__/.claude/scripts/loop-daemon.sh +Restart=always +RestartSec=15 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target diff --git a/.claude/skills/sync/SKILL.md b/.claude/skills/sync/SKILL.md index 8740ecf..e74b4af 100644 --- a/.claude/skills/sync/SKILL.md +++ b/.claude/skills/sync/SKILL.md @@ -12,8 +12,17 @@ upgrade reach into repos that already onboarded, without a human re-running the ## Ownership model (reuse the setup MANIFEST — do not invent a new scheme) See `.claude/skills/setup/templates/MANIFEST.md` for the authoritative ownership classes. Sync only acts on -the **managed** row (today: `feature-fanout.js` -> `.claude/workflows/feature-fanout.js`). It is designed so -adding a new managed file later is a one-line addition to `sync.sh`'s managed-file table, not a rewrite. +the **managed** rows — today: `feature-fanout.js` -> `.claude/workflows/feature-fanout.js`, and (issue #102) +the cron-less loop daemon's systemd unit templates + installer: +`pr-loop.service` -> `.claude/systemd/pr-loop.service`, `claude-rc.service` -> `.claude/systemd/claude-rc.service`, +and `arm-loop.sh` -> `.claude/scripts/arm-loop.sh`. All four are reconciled by the exact same marker-version +ladder below — the loop-daemon files are ordinary managed files, not a special case. It is designed so adding +a new managed file later is a one-line addition to `sync.sh`'s managed-file table, not a rewrite. + +Re-stamping the loop-daemon templates only updates the checked-in files in the repo — it never touches an +already-installed unit under `~/.config/systemd/user/` or restarts a running daemon. Tell the user to re-run +`bash .claude/scripts/arm-loop.sh` (in a real terminal, per the sandbox caveat) after a restamp if they want +the installed units to pick up the change. Sync **never** touches user-owned files, under any circumstance: - `.claude/gates.json` diff --git a/.claude/skills/sync/sync.sh b/.claude/skills/sync/sync.sh index 92a437a..8e92112 100755 --- a/.claude/skills/sync/sync.sh +++ b/.claude/skills/sync/sync.sh @@ -65,6 +65,9 @@ echo "orchestrator sync: reconciling managed files in $target_root" # straight from its own template, so there is nothing else to wire up. MANAGED_FILES=( "feature-fanout.js|.claude/workflows/feature-fanout.js|@orchestrator-managed feature-fanout v" + "pr-loop.service|.claude/systemd/pr-loop.service|@orchestrator-managed pr-loop-service v" + "claude-rc.service|.claude/systemd/claude-rc.service|@orchestrator-managed claude-rc-service v" + "arm-loop.sh|.claude/scripts/arm-loop.sh|@orchestrator-managed arm-loop v" ) # --- user-owned files: NEVER written by sync, only reported for visibility --------- diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 118f501..a935f08 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -71,7 +71,8 @@ collects and scaffolds: updates, see [`USAGE.md` → "Updating the plugin"](USAGE.md#updating-the-plugin)). - **`.github/workflows/gates.yml`** + **`.github/actions/setup/action.yml`** — the CI gate workflow (Step 4 below). -- **`module:*` GitHub labels**, a **bot-account check** (`GH_BOT_TOKEN`), and offers to arm **`/orchestrator:pr-loop`** +- **`module:*` GitHub labels**, a **bot-account check** (`GH_BOT_TOKEN`), and offers to arm the loop — the + **cron-less loop daemon** (recommended, `systemd --user`) or the legacy **`/orchestrator:pr-loop`** cron — and, last, **`/orchestrator:harden`**. **Sanity-check the gates once it's written** (ask Claude Code to run these, so `${CLAUDE_PLUGIN_ROOT}` @@ -182,16 +183,29 @@ model (the `module:*` opt-in queue + the owner-approval merge gate) that this ch `.claude/scripts/seed-issues.sh`. Without the label, ADVANCE can never queue the issue. 6. **Server-side gates** — confirm `.github/workflows/gates.yml` runs your gate commands (Step 4 above), and set branch protection / required status checks on `merge.baseBranch` if your plan supports it. -7. **Arm the loop** — run **`/orchestrator:pr-loop`**. It self-adjusts cadence (FAST when there's ≥1 open PR or - ≥1 open `module:*` issue, else IDLE) but the cron is session-scoped, so re-run it at the start of each session. - **Pick the right model for each side of the loop:** run the tick session on **Sonnet** — the ticks are - repetitive, and that repetition is exactly where smaller models drift (a Haiku-driven tick session has been - observed to stop running the step scripts and fabricate their output, and to double-spawn orchestrators for - one issue). `.claude/scripts/loop-tick.sh` hardens the tick itself — one script computes the census/feedback/ - advance verdict and a self-healing spawn lock, instead of a model re-deriving it from a prompt every firing - (see [`USAGE.md`](USAGE.md) → "Model selection") — but the driving session still needs Sonnet to read that - verdict and act on it. Use **Fable or Opus** for the owner-side judgment - work — scoping, planning, and filing issues — then let the loop execute the approved queue. +7. **Arm the loop** — recommended: **`bash .claude/scripts/arm-loop.sh`** (run in a real terminal *outside* + Claude Code — installing systemd units, `loginctl enable-linger`, and a detached tmux session all touch + `$HOME`, which the sandbox blocks). This installs `pr-loop-.service` (the cron-less daemon — + supervised by `systemd --user`, survives Claude Code restarts, adaptive FAST/WATCH/IDLE sleep read + straight off the census) and `claude-rc-.service` (`claude remote-control` in a detached tmux + session, for spawning planning sessions remotely). Self-hosting: add `--gates-file + .claude/self/gates.json`. See [`USAGE.md` → "Cron-less loop + (daemon)"](USAGE.md#cron-less-loop-daemon) for the full architecture, cadence, run ledger, and + failure-contract details, including WSL2's `systemd=true` prerequisite. + Fallback (no systemd available): **`/orchestrator:pr-loop`** — the legacy session-scoped cron. It + self-adjusts cadence the same way (FAST when there's ≥1 open PR or ≥1 open `module:*` issue, else IDLE) + but dies with the Claude Code session that armed it, so it must be re-run at the start of each session. + **Never run both against the same repo at once** — `loop-tick.sh`'s spawn lock makes it *safe* (no + double-spawn), just wasteful. + **Pick the right model for each side of the loop:** the daemon's driver sessions default to **Sonnet** + (`LOOP_MODEL` env, read by `loop-event.sh`) — the ticks are repetitive, and that repetition is exactly + where smaller models drift (a Haiku-driven tick session has been observed to stop running the step + scripts and fabricate their output, and to double-spawn orchestrators for one issue). + `.claude/scripts/loop-tick.sh` hardens the tick itself — one script computes the census/feedback/advance + verdict and a self-healing spawn lock, instead of a model re-deriving it from a prompt every firing (see + [`USAGE.md`](USAGE.md) → "Model selection") — but a driving cron session still needs Sonnet to read that + verdict and act on it. Use **Fable or Opus** for the owner-side judgment work — scoping, planning, and + filing issues — then let the loop execute the approved queue. 8. *(optional)* **Hardening** — `/orchestrator:harden` for the bypass + strict-sandbox profile, see [`HARDENING.md`](HARDENING.md). diff --git a/docs/USAGE.md b/docs/USAGE.md index 32b8d00..f55562e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -101,10 +101,12 @@ With `pr-per-agent`, the standing loop per ticket looks like: (*"address the comments on PR #N"*): same implementer loop, same branch, push updates the PR in place. 5. **Merge** — owner approves, merge per `gates.json.merge`, clean the worktree (below). -**Closing the loop automatically:** webhooks rarely reach a dev box, so poll. Either a Claude Code cron -(`CronCreate`, durable) or an in-session `/loop` that every ~10–15 min runs the three loop scripts in order -— each is a single stable command to pre-approve in `settings.json`, since an inline compound command -(loops, `$()`, redirects) never matches a permission rule and would block on a prompt every firing: +**Closing the loop automatically:** webhooks rarely reach a dev box, so poll. Two firing sources exist — the +**cron-less loop daemon** (`systemd --user`, recommended — see [below](#cron-less-loop-daemon)) or the +**legacy session-scoped cron** (`/orchestrator:pr-loop`, kept as a fallback for environments without +systemd). Both ultimately run the same three loop scripts in order — each a single stable command to +pre-approve in `settings.json`, since an inline compound command (loops, `$()`, redirects) never matches a +permission rule and would block on a prompt every firing: 1. **`bash .claude/scripts/notify-poll.sh`** — prints new issues and PR comments/reviews since a cursor file (`.claude/state/notify-cursor`, gitignored), plus a cursor-independent **`open pr status`** section (per @@ -134,19 +136,135 @@ contract. With all three wired, the loop runs hands-off: **add issues → review → approve → it merges and advances**. A natural step 4 is to start the next `module:*` issue only when **no PRs are open**, so work stays -serialized (one issue in flight) and bounded. Caveats: cron jobs fire only while Claude Code is running, -auto-expire after 7 days, and may be session-scoped on some versions — re-arm at session start (the -**`/orchestrator:pr-loop`** command does exactly that: arms or re-arms the cron and runs one tick immediately). +serialized (one issue in flight) and bounded. **How it actually fires** — the recommended cron-less daemon +vs. the legacy session-scoped cron, adaptive cadence, the run ledger, and the one non-self-healing failure +state — is covered in [Cron-less loop (daemon)](#cron-less-loop-daemon) below. **Running it fully hands-off?** Polling still leaves a human approving each tool call. To let the loop run unattended (Claude Code `bypassPermissions`), first harden the environment so the prompt is replaced by always-enforced guardrails — see **[`HARDENING.md`](HARDENING.md)** (deny list + OS sandbox + host -isolation). Don't enable bypass without it. +isolation). Don't enable bypass without it — this applies equally to the daemon's headless driver spawns, +which have no interactive tty to prompt at all. + +## Cron-less loop (daemon) +**Recommended default (issue #102).** Two `systemd --user` services replace the session-scoped cron: + +- **`pr-loop-.service`** → `.claude/scripts/loop-daemon.sh`, `Restart=always`. A genuine forever loop + supervised by `systemd`, not a Claude Code session — it survives Claude Code restarting or exiting + entirely. Each iteration runs **`.claude/scripts/loop-event.sh`**, which runs the deterministic tick + (`loop-tick.sh`) and parses its LAST-line verdict byte-identical (issue #81 contract: the verdict is + computed once, in shell, never re-derived by a model). On `action=none` the daemon sleeps and loops — + **no model/driver process is ever touched**, so a quiet repo costs nothing beyond the tick's own `gh` + calls. On an actionable verdict (`action=advance issue=N` / `action=feedback pr=N`) it spawns exactly + **one** contained driver: `setsid timeout --kill-after=30s claude + --model -p "" --output-format json`. `setsid` gives the driver (and any + bash children it spawns) its own process group, independent of the daemon's; on timeout the whole group + is targeted, not just the immediate child, so a driver's own children can never be orphaned by a bare + `SIGTERM`. The daemon itself never runs two drivers concurrently (it's a single-threaded loop), and + `loop-tick.sh`'s own spawn lock additionally guards against a second overlapping tick anywhere else + (e.g. the legacy cron armed at the same time) double-firing the same ADVANCE. +- **`claude-rc-.service`** → `claude remote-control` inside a detached tmux session (`rc-`), for + spawning **new planning sessions remotely** — from claude.ai or the Claude Code mobile app — decoupled + from the loop's own ticking. `arm-loop.sh --capacity N --permission-mode ` controls its + concurrency/permission posture. + +**Cadence.** The daemon's sleep between ticks is read straight off `loop-tick.sh`'s own census, whose +`cadence=FAST|WATCH|IDLE cron=` line already encodes the loop's desired attentiveness (FAST only when +there's something actionable *now*). `loop-daemon.sh`'s `cadence_to_sleep_seconds()` maps that line to: + +| Census cadence | Sleep | +|---|---| +| `FAST` | 60s | +| `WATCH` | 300s | +| `IDLE` | 900s | +| *(unparseable / missing)* | 300s (fallback) | + +Override any of the four via `LOOP_DAEMON_SLEEP_FAST` / `_WATCH` / `_IDLE` / `_FALLBACK` (seconds, test/debug +hooks). + +**Ledger.** Every driver spawn — successful, timed out, or refused-to-spawn — appends one line to +`.claude/state/loop-runs.log` (gitignored, never committed): +``` +pid= session= verdict= ts= [result=exit|timeout|spawn-error rc=N] +``` +`session_id` is parsed out of the driver's own `--output-format json` stdout, which is what makes a +hung or already-finished driver resumable later. + +**Supervision.** Three read-only windows into a driver, cheapest first: +1. `tail -f .claude/state/loop-runs.log` — the ledger line above, one per spawn. +2. The live transcript: `~/.claude/projects//.jsonl` (`` from the ledger + line) — this file is **append-only**, so `tail -f` it (or read it directly) to watch a driver's tool + calls as they happen without disturbing it. +3. `claude --resume --fork-session` — a full interactive replay/continuation. Safe to run + **while the driver is still executing**: `--fork-session` never mutates the original session, it only + reads the append-only transcript and branches a new one. + +**Intervention is kill-and-let-it-re-advance, never steer.** A driver is a headless `claude -p` process with +no attach point — there is no "type into it and redirect it" option. To stop one: find its `pid=` (a +process **group** id) in the ledger and `kill -TERM -- -` (the same target `timeout --kill-after` +would eventually use anyway). Do **not** try to nudge a running driver's behavior. Instead, let +`loop-tick.sh`'s own pre-branch spawn lock (`.claude/state/loop-advance.lock`, 15-minute TTL) self-heal so a +later tick can re-advance the same issue cleanly, or fix forward with a normal orchestrator pass once +whatever state the killed driver left behind (a branch, a PR) is visible to a fresh tick. + +**Remote planning sessions.** `claude-rc-.service` keeps a `claude remote-control` process alive in a +detached tmux session, independent of the loop daemon's own ticking, so you can spawn a **new** planning +session from claude.ai or the Claude Code mobile app against this checkout at any time — useful for filing +or scoping work from your phone without a terminal open. `tmux attach -t rc-` to see its QR/status +locally, or restart it with `systemctl --user restart claude-rc-.service`. + +**Linux vs WSL2.** Both need `systemd`, `tmux`, and `node`/`git` on `PATH`. WSL2 does **not** run systemd by +default — add (or verify) a `[boot]` section with `systemd=true` in `/etc/wsl.conf`, then `wsl --shutdown` +from **Windows** (not WSL) and reopen the WSL terminal; re-check with `systemctl --version` inside WSL2. +Both platforms: installing units under `~/.config/systemd/user/`, `loginctl enable-linger`, and starting the +detached tmux session all touch `$HOME`/systemd, which the Claude Code sandbox blocks — **run** +```bash +bash .claude/scripts/arm-loop.sh [--gates-file ] [--permission-mode ] [--capacity N] +``` +**in a real terminal outside Claude Code.** It's idempotent (safe to re-run any time). Self-hosting: add +`--gates-file .claude/self/gates.json`. WSL2-only extra: optionally make the loop survive a Windows reboot +by relaunching WSL2 at Windows logon — from an **elevated Windows** terminal (substituting `` from +`wsl -l`, run on the Windows side): +``` +schtasks /create /tn "WSL pr-loop autostart" /tr "wsl.exe -d --exec true" /sc onlogon +``` +Skipping this is safe: GitHub is the loop's only source of truth, so anything that happened while WSL2 was +stopped is simply picked up by the first tick after the next manual WSL2 start. + +Inspect what's armed: +```bash +systemctl --user status pr-loop-.service +journalctl --user -u pr-loop-.service -f +tail -f .claude/state/loop-runs.log +tmux attach -t rc- +``` + +**Failure contract.** + +| Failure | Detected as | Self-heals? | Manual fix | +|---|---|---|---| +| Driver exits non-zero (gate failure, crash mid-run, …) | ledger `result=exit rc=N` | Yes — the next tick's fresh census decides the next action from scratch | none | +| Driver runs past `LOOP_DRIVER_TIMEOUT` (default 90m) | ledger `result=timeout rc=124\|137`; `timeout --kill-after=30s` plus an explicit process-group kill | Yes — same as above | none, unless it left a half-finished branch behind — inspect and fix forward | +| `claude` CLI not found on `PATH` (nor via the `nvm` fallback) | ledger `result=spawn-error rc=127`, logged before any spawn attempt | Partially — the pre-branch spawn lock's 15-minute TTL clears and lets a later tick retry, but every retry hits the same missing-`PATH` wall | fix `PATH`/`nvm` in the daemon's environment (e.g. the systemd unit's `Environment=`), then `systemctl --user restart pr-loop-.service` | +| `loop-tick.sh` / `loop-event.sh` itself exits non-zero (broken tick) | daemon logs "not spawning a driver on a broken tick", sleeps the fallback cadence, retries | Yes — retried automatically every tick | investigate only if it persists across many ticks | +| **Driver pushed `feat/issue-N-*` but died before opening the PR** | census reports `N` as `in_flight`; `loop-tick.sh`'s advance check refuses (`# advance refused: issue=N is in_flight`, logged every single tick) and emits `action=none` | **No** — unlike the pre-branch spawn lock, `in_flight` has no TTL/self-heal; it refuses forever until the branch or a PR's state changes | **delete the abandoned branch** (frees the issue back to `advance_ready`), **or** open the PR by hand for that branch (moves it into the normal review/merge or feedback flow) | +| `claude-rc-.service`'s inner `claude remote-control` process crashes | **not detected by systemd** — the unit is `Type=oneshot`/`RemainAfterExit=yes`; systemd only observes `tmux new -d`'s own (successful) exit, never the health of the process running *inside* that tmux session | No | `tmux attach -t rc-` to check, then `systemctl --user restart claude-rc-.service` | + +**Never run the daemon and the legacy `/pr-loop` cron against the same repo at the same time** — +`loop-tick.sh`'s spawn lock makes double-firing *safe* (no double-spawn), just wasteful (two firing sources +burning ticks against identical state). + +**Legacy cron (`/pr-loop` / `/orchestrator:pr-loop`).** Kept as the fallback for environments without +systemd: session-scoped `CronCreate`, dies with the Claude Code session that armed it, so it must be +re-armed at the start of each session (**`/orchestrator:pr-loop`** does that plus runs one tick +immediately). Same adaptive cadence, same `loop-tick.sh` mechanics underneath — it just fires from inside a +Claude Code cron instead of `loop-daemon.sh`. ## Autonomous loop & the issue queue -Each `/orchestrator:pr-loop` tick runs, in order: **poll → merge → address-feedback → advance** — this -per-tick order, canonically defined in `.claude/commands/pr-loop.md`, is authoritative; the poll / address-feedback / -merge scripts described above are the mechanism it runs. Two human control points +Each tick — whether fired by the daemon's `loop-event.sh`/`loop-tick.sh` or the legacy `/orchestrator:pr-loop` +cron — runs, in order: **poll → merge → address-feedback → advance** — this per-tick order, canonically +defined in `.claude/commands/pr-loop.md` and `.claude/scripts/loop-tick.sh`, is authoritative; the poll / +address-feedback / merge scripts described above are the mechanism it runs. Two human control points decide what the loop actually touches: - **Issue approval is a two-label workflow: `backlog` → `planned`.** An issue enters the loop's work