diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md deleted file mode 100644 index d6ad96f..0000000 --- a/.claude/agents/implementer.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: implementer -description: Implements ONE well-scoped sub-task end-to-end on its own branch, inside an isolated git worktree so it can never clash with sibling workers. Runs the project's gates before declaring done. Spawned by the orchestrator. -tools: Read, Edit, Write, Bash, Grep, Glob, Agent -model: sonnet -isolation: worktree ---- - -You own ONE sub-task end-to-end, on your own branch, in your own worktree. - -## Read first -- `.claude/gates.json` — for the exact gate commands (`build`, `lint`, `typecheck`, `test_affected`, `coverage`) and your module boundary. -- `CLAUDE.md` — conventions, style, definition of done. - -## Workflow -1. **Explore, don't guess.** Delegate codebase discovery to the `Explore` subagent to map the files you'll touch. Stay read-only until you understand the area. -2. **Respect your boundary.** You were assigned a module/path. NEVER edit files outside it. If the task truly requires touching another module, stop and report back to the orchestrator — do not reach across the boundary. -3. **Implement in small commits.** Match surrounding code style. Write/extend tests alongside the change. -4. **Self-gate before declaring done.** Run, in order, the commands from `.claude/gates.json`: `build` → `lint` → `typecheck` → `test_affected` → `coverage`. Use `.claude/scripts/gate.sh ` if present. Fix anything that fails. Do not report done with a red gate. -5. **Open a PR** (or leave the branch ready, per `CLAUDE.md` merge policy). -6. **Report back** in this format: -``` -- Sub-task: -- Branch: <name> -- Files touched: <list — confirm all within boundary> -- Gates: build/lint/typecheck/test/coverage = <pass|fail each> -- Tests added: <summary> -- Open risks: <bullets> -``` - -If a reviewer rejects your work, address every reason, re-run the gates, and report again. Iterate until approved. diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md deleted file mode 100644 index 3cdd27d..0000000 --- a/.claude/agents/orchestrator.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: orchestrator -description: Lead agent. Scopes a task into independent, non-overlapping sub-tasks, delegates each to an isolated implementer, routes results through reviewers, and reports status. Use for any task large enough to split across more than one worker, or when the user asks to "orchestrate", "fan out", or "delegate" work. -tools: Read, Grep, Glob, Bash, Agent, TodoWrite -model: opus ---- - -You are the LEAD orchestrator. You coordinate; you do NOT write feature code yourself. - -## First, read the contract -Before anything else, read these and treat them as ground truth: -- `.claude/gates.json` — the project adapter: module map, gate commands, model routing, max parallel workers. -- `CLAUDE.md` — project context and conventions. -If `.claude/gates.json` has empty `gates`, STOP and tell the user the project hasn't been adapted yet (point them at `docs/GETTING_STARTED.md`). - -## Your loop -1. **Scope.** Decompose the task into sub-tasks that are *independent* and *non-overlapping at the file level*. Use the `modules` map in `gates.json` to assign each sub-task to exactly one module/path. If two sub-tasks would touch the same files, either merge them into one sub-task or sequence them (declare the dependency). Scale effort to complexity: a trivial task gets ONE worker and no parallelism — do not fan out for its own sake. -2. **Present the plan and WAIT.** Output the plan: each sub-task's title, target module/path, owner boundary, dependencies, and which reviewers will gate it. Enter plan mode and wait for human approval before any code is written. This is the planning checkpoint. -3. **Delegate.** For each approved sub-task, spawn an `implementer` (it runs in its own git worktree/branch, so workers never clash). Respect `budget.max_parallel_workers` from `gates.json` — queue the rest. Give each implementer: the objective, its module boundary ("never edit outside `<path>`"), the definition of done, and the required gates. -4. **Review gate.** When an implementer reports done, route its change through `reviewer` agents (one per lens in `gates.json.review.lenses`). Require the configured majority/consensus to approve. On reject, feed the reasons back to the same implementer and iterate. Do not advance a sub-task until its gates pass. -5. **Integrate.** Use the merge discipline from `CLAUDE.md` (default: PR-per-agent). Surface conflicts to the user; do not force-merge. -6. **Report.** End with a structured status block (see below). - -## Delegation rules (learned the hard way) -- Give every worker a crisp objective, an explicit file/module boundary, an output format, and the exact gate commands. Vague delegation produces overlap and rework. -- Never spawn more than `max_parallel_workers` at once. -- Keep your own context clean: delegate exploration to the `Explore` subagent (read-only, cheap), not yourself. - -## Status report format (your "standup") -``` -## Run summary -- Task: <one line> -- Sub-tasks: <n> | done: <n> in-progress: <n> blocked: <n> -- Branches/PRs: <list> -- Gates: <pass/fail per sub-task> -- Open risks / decisions for human: <bullets> -- Tokens: run `/cost` or `npx ccusage` for spend -``` diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md deleted file mode 100644 index 0187e4f..0000000 --- a/.claude/agents/reviewer.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: reviewer -description: Adversarial reviewer. Reviews ONE change through ONE lens (correctness, tests, security, performance, etc.) and returns an approve/reject verdict with concrete reasons. Read-only — never edits. Spawned one-per-lens by the orchestrator. -tools: Read, Grep, Glob, Bash -model: opus ---- - -You are an ADVERSARIAL reviewer. Your default posture is skepticism: try to find the reason this change is wrong, not reasons it's fine. A change you cannot refute is one you approve. - -## Read first -- `.claude/gates.json` — review lenses and any project review skills. -- `CLAUDE.md` — the project's definition of done and conventions. - -## Inputs you'll be given -- The lens you must apply (e.g. `correctness`, `tests`, `security`, `performance`). -- The diff/branch to review. - -## How to review -1. Read the diff and the surrounding code it affects. -2. Apply ONLY your assigned lens — go deep, not broad: - - **correctness**: logic errors, edge cases, off-by-one, error handling, race conditions, broken invariants. - - **tests**: do tests actually exercise the change? coverage of edge/failure paths? meaningful assertions, not just "it runs"? Run the test gate if needed. - - **security**: injection, auth/access control, unsafe input, secrets, dependency risk, (for smart contracts) reentrancy/overflow/access — defer to the project security skill if configured. - - **performance**: needless work, N+1, allocations, blocking calls, complexity regressions. -3. Verify claims by reading code or running read-only commands — don't take the implementer's word. - -## Verdict (required output) -``` -- Lens: <lens> -- Verdict: approve | reject -- Confidence: low | medium | high -- Findings: - - [severity] <file:line> — <what's wrong, why it matters, how to fix> -- If approve: one line on what you checked and why you're satisfied. -``` -Reject if you find anything that would block merge under your lens. Be specific and actionable so the implementer can fix without guessing. diff --git a/.claude/agents/test-runner.md b/.claude/agents/test-runner.md deleted file mode 100644 index b85a435..0000000 --- a/.claude/agents/test-runner.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: test-runner -description: Runs the project's validation gates (build, lint, typecheck, tests, coverage, e2e, security) defined in .claude/gates.json and reports a clean pass/fail summary. Read-only except for running commands. Use to gate a branch or verify the repo is green. -tools: Read, Bash, Grep, Glob -model: haiku ---- - -You run the project's gates and report results. You do not fix code — you report what's red so others can. - -## What to run -Read `.claude/gates.json` and run the requested gates (or all configured ones) using `.claude/scripts/gate.sh <name>` when available, else the raw command from the file. Typical order: `install` (if needed) → `build` → `lint` → `typecheck` → `test` (or `test_affected`) → `coverage` → `e2e` → `security`. - -Skip any gate whose command is empty in `gates.json` and note it as "not configured". - -## Report format -``` -| gate | status | notes | -|------------|--------|-------| -| build | ✅/❌/➖ | ... | -| lint | ... | -| typecheck | ... | -| test | ... | -| coverage | ... (vs threshold) | -| e2e | ... | -| security | ... | -``` -For any ❌, include the most relevant ~20 lines of output (the failing assertion / error), not the whole log. Compare coverage against `gates.coverage_threshold` and fail if below. diff --git a/.claude/gates.json b/.claude/gates.json index 5a4508c..74e4346 100644 --- a/.claude/gates.json +++ b/.claude/gates.json @@ -71,13 +71,20 @@ "lint": "pnpm -r lint && pnpm exec solhint 'contracts/src/**/*.sol' 'contracts/test/**/*.sol' && (cd contracts && forge fmt --check)", "typecheck": "pnpm -r build && pnpm -r typecheck", "test": "pnpm -r build && pnpm -r test && (cd contracts && forge test)", - "test_affected": "pnpm -r build && pnpm -r test && (cd contracts && forge test)", + "_note_test_affected": "LOOP-ONLY (deliberate). test_affected is the gate the Stop hook fires, so an unguarded version ran the whole monorepo build+test after EVERY assistant turn — including pure planning turns in a `claude remote-control` session, since claude-rc.service runs with WorkingDirectory=<repo> and therefore loads these same hooks. The guard below no-ops it unless the turn belongs to the autonomous loop. Detection: loop-daemon.sh exports CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS into every `claude -p` it spawns (scripts/loop-daemon.sh:565, and again via --setenv= for the sandboxed path); interactive sessions do not have it. REDEPLOY_LOOP=1 is an explicit manual override. Fail-open risk is bounded: if detection ever breaks, the loop merely skips this belt-and-braces layer — .github/workflows/gates.yml still runs build/lint/typecheck/test/coverage on every PR and is the authoritative merge gate. The skip is printed, not silent, so a misdetection is visible in the loop log. NOTE: test_affected is hook-only — it is NOT in the CI matrix — so this guard cannot weaken CI.", + "test_affected": "if [ -z \"${REDEPLOY_LOOP:-}${CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS:-}\" ]; then echo 'gate test_affected: skipped — interactive session (not the autonomous loop). Force with REDEPLOY_LOOP=1.'; exit 0; fi; pnpm -r build && pnpm -r test && (cd contracts && forge test)", "coverage": "pnpm -r build && pnpm -r coverage", "coverage_threshold": 80, "e2e": "pnpm -F @redeploy/core build && (cd contracts && forge build) && pnpm -F @redeploy/core test:e2e", "security": "" }, + "worktree": { + "_note": "Per-worktree lifecycle, run by .claude/scripts/worktree.sh. `setup` runs right after an isolated implementer/reviewer worktree is created and bootstraps the toolchain state that lives OUTSIDE the tree — node_modules (pnpm) and contracts/lib (Foundry deps, gitignored) — so EVERY gate is runnable in-worktree, not just in the main checkout. Without it, in-worktree gates fail for missing deps rather than for real defects. Mirrors gates.install deliberately: same idempotent guards, so re-running is cheap and a fresh worktree needs no manual step. `teardown` is unused (pnpm's store is shared and forge libs are per-worktree; nothing to free).", + "setup": "pnpm install && (cd contracts && { [ -d lib/forge-std ] || forge install --no-git foundry-rs/forge-std@v1.16.1; } && { [ -d lib/openzeppelin-contracts ] || forge install --no-git OpenZeppelin/openzeppelin-contracts@v5.1.0; })", + "teardown": "" + }, + "review": { "lenses": ["correctness", "tests", "security", "performance"], "consensus": "all", diff --git a/.claude/scripts/arm-loop.sh b/.claude/scripts/arm-loop.sh index 3c3e9d7..7be3fbf 100755 --- a/.claude/scripts/arm-loop.sh +++ b/.claude/scripts/arm-loop.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# @orchestrator-managed arm-loop v5 +# @orchestrator-managed arm-loop v7 # 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 @@ -13,10 +13,10 @@ # then recreate). # # Usage: -# bash .claude/scripts/arm-loop.sh [--gates-file <path>] [--permission-mode <mode>] [--capacity N] [--rc-name <name>] [--spawn <mode>] +# bash .claude/scripts/arm-loop.sh [--gates-file <path>] [--permission-mode <mode>] [--capacity N] [--rc-name <name>] [--spawn <mode>] [--stop-after-days N] # # --gates-file <path> passed to pr-loop.service as GATES_FILE (e.g. -# .claude/self/gates.json for the self-hosted +# self/gates.json for the self-hosted # loop). Omit for the default project adapter. # --permission-mode <mode> passed to `claude remote-control --permission-mode`. # Defaults to permissions.defaultMode in @@ -30,6 +30,16 @@ # --spawn <mode> remote-control spawn mode: same-dir (default) or # worktree. Passed explicitly so the server never # blocks on its interactive first-run question. +# --stop-after-days N self-disarm horizon (issue #95): loop-tick.sh +# refuses every advance/feedback dispatch once +# armed_at + N days has passed, until re-armed. +# Defaults to budget.stop_after_days in the +# adapter picked by --gates-file (or the default +# .claude/gates.json when --gates-file is +# omitted), else 7. Every re-arm rewrites +# .claude/state/loop-arming.json fresh -- +# clearing any prior expiry AND the one-time +# "disarmed" notification guard. set -euo pipefail gates_file="" @@ -37,6 +47,7 @@ permission_mode="" capacity="8" rc_name="" spawn_mode="same-dir" +stop_after_days="" while [ "$#" -gt 0 ]; do case "$1" in --gates-file) gates_file="${2:?--gates-file needs a value}"; shift 2 ;; @@ -49,8 +60,10 @@ while [ "$#" -gt 0 ]; do --spawn) spawn_mode="${2:?--spawn needs a value}"; shift 2 ;; --spawn=*) spawn_mode="${1#--spawn=}"; shift ;; --capacity=*) capacity="${1#--capacity=}"; shift ;; + --stop-after-days) stop_after_days="${2:?--stop-after-days needs a value}"; shift 2 ;; + --stop-after-days=*) stop_after_days="${1#--stop-after-days=}"; shift ;; -h|--help) - sed -n '2,32p' "$0" + sed -n '2,42p' "$0" exit 0 ;; *) echo "arm-loop.sh: unknown argument '$1'" >&2; exit 2 ;; @@ -96,6 +109,46 @@ if [ -n "$gates_file" ]; then gates_env="Environment=GATES_FILE=$gates_file" fi +# --- spend-ceiling arming state (issue #95) --------------------------------- +# Resolve the stop-after horizon: --stop-after-days wins; else +# budget.stop_after_days from the SAME adapter the armed daemon will read +# (gates_file, defaulting to .claude/gates.json); else 7. Always WRITE a +# fresh .claude/state/loop-arming.json on every arm/re-arm -- this is what +# clears a prior expiry and the one-time "disarmed" notification guard. +if [ -z "$stop_after_days" ]; then + adapter_for_stop_after="${gates_file:-.claude/gates.json}" + case "$adapter_for_stop_after" in + /*) ;; + *) adapter_for_stop_after="$repo_root/$adapter_for_stop_after" ;; + esac + stop_after_days="$(node -e ' + try { + const g = require(process.argv[1]); + const d = g && g.budget && g.budget.stop_after_days; + if (Number.isFinite(d) && d > 0) { console.log(d); process.exit(0); } + } catch (e) {} + ' "$adapter_for_stop_after" 2>/dev/null || true)" + stop_after_days="${stop_after_days:-7}" +fi +case "$stop_after_days" in + ''|*[!0-9.]*) echo "arm-loop.sh: --stop-after-days must be a positive number (got '$stop_after_days')" >&2; exit 2 ;; +esac + +arming_state_dir="$repo_root/.claude/state" +mkdir -p "$arming_state_dir" +arm_now="$(date -u +%FT%TZ)" +node -e ' + const fs = require("fs"); + const now = process.argv[2]; + const days = parseFloat(process.argv[3]); + const expires = new Date(Date.parse(now) + days * 86400000).toISOString(); + fs.writeFileSync(process.argv[1], JSON.stringify({ + armed_at: now, expires_at: expires, stop_after_days: days, + notified_expired: false, notice_issue: null, + }, null, 2) + "\n"); +' "$arming_state_dir/loop-arming.json" "$arm_now" "$stop_after_days" +echo "arm-loop.sh: armed until $(node -e 'const j=require(process.argv[1]);console.log(j.expires_at)' "$arming_state_dir/loop-arming.json") (stop_after_days=$stop_after_days) -- .claude/state/loop-arming.json" + # Absolute claude path, resolved HERE — this script runs in a real terminal # with the user's full environment, while the installed unit runs under # systemd's minimal PATH (gh but no nvm-provisioned node/claude). A bare @@ -111,6 +164,26 @@ fi rc_name="${rc_name:-$repo_slug-planner}" claude_dir="$(dirname "$claude_bin")" +# Same rationale as claude_bin above, plus issue #107: the installed +# pr-loop.service unit (the loop daemon itself, NOT claude-rc) previously got +# NO baked PATH at all and ran under systemd's minimal PATH — which has `gh` +# but neither `node` nor `claude`, silently stalling node-dependent tick steps +# (loop-census.sh, merge-ready.sh, write_tick_record) until the daemon's own +# runtime ensure_claude_on_path fallback (loop-daemon.sh) kicked in. Bake the +# resolved node/claude dirs in here too so the unit starts with a working PATH +# from the first tick, with the nvm-sourcing fallback staying as a safety net +# for installs that predate this change or use fnm/volta/system node. +node_bin="$(command -v node || true)" +if [ -z "$node_bin" ]; then + echo "arm-loop.sh: 'node' not found on PATH — run this from a real terminal where \`node\` works." >&2 + exit 1 +fi +node_dir="$(dirname "$node_bin")" + +# Compose the baked PATH: node_dir, claude_dir, then the standard system dirs +# — deduped, since under nvm node_dir and claude_dir are frequently identical. +baked_path="$(printf '%s\n' "$node_dir" "$claude_dir" "/usr/local/sbin" "/usr/local/bin" "/usr/sbin" "/usr/bin" "/sbin" "/bin" | awk '!seen[$0]++' | paste -sd: -)" + units_dir="$HOME/.config/systemd/user" mkdir -p "$units_dir" @@ -129,6 +202,7 @@ 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" \ + -e "s#__PATH__#$baked_path#g" \ "$pr_loop_src" > "$pr_loop_dst" sed -e "s#__WORKDIR__#$repo_root#g" \ @@ -144,15 +218,39 @@ sed -e "s#__WORKDIR__#$repo_root#g" \ echo "arm-loop.sh: wrote $pr_loop_dst" echo "arm-loop.sh: wrote $claude_rc_dst" +# Guard against template/script skew (issue #130): if either sed block above +# is missing a substitution for a placeholder the template still contains +# (e.g. a new __FOO__ added to the .service template without a matching -e +# here), the installed unit silently keeps the literal token and systemd +# fails it at the NEXT boot with an opaque status=127 -- long after this +# script has exited 0. Fail loudly, right here, instead. +for dst in "$pr_loop_dst" "$claude_rc_dst"; do + # Scan only directive (non-comment) lines: the template header comments carry + # the literal doc token __PLACEHOLDER__, which is not a sed target and must + # not false-positive. A REAL leftover lives in a directive line. `|| true` + # keeps the no-leftover healthy path from aborting under `set -euo pipefail` + # (grep exits 1 on no match). Fail loudly on genuine skew (issue #130). + leftover="$(grep -v '^[[:space:]]*#' "$dst" | grep -o '__[A-Z_]*__' | sort -u | tr '\n' ' ' || true)" + if [ -n "$leftover" ]; then + echo "arm-loop.sh: unsubstituted placeholder(s) leaked into $dst: ${leftover}-- the sed block that generated this file is missing a substitution (issue #130); fix arm-loop.sh before re-running." >&2 + exit 1 + fi +done + systemctl --user daemon-reload # pr-loop: enable --now on purpose (NOT restart) — never kill a daemon that # may have a driver in flight; a re-arm only rewrites its unit file, and the # owner restarts it explicitly when they want the new unit picked up. systemctl --user enable --now "pr-loop-$repo_slug.service" -# claude-rc: enable + restart on purpose — Type=oneshot + RemainAfterExit -# stays "active" forever, so `enable --now` would never re-run ExecStart and -# a re-arm would silently keep serving the OLD unit. Restart is safe here -# (independent of the loop daemon) and relaunches the tmux with the fresh unit. +# claude-rc: enable + restart on purpose — even with Type=simple + +# Restart=on-failure (issue #124), `systemctl --user enable --now` on an +# ALREADY-enabled, already-running unit is a no-op: it does not re-run +# ExecStart. So a re-arm's freshly-written unit file (new PATH, capacity, +# permission-mode, spawn mode, etc.) would silently keep being ignored by the +# still-running OLD supervisor process until something restarts it. `restart` +# is what actually loads the new unit; it is safe here (independent of the +# loop daemon) — the inline supervisor's ExecStop/kill-session step tears +# down the old tmux session cleanly before the fresh ExecStart relaunches it. systemctl --user enable "claude-rc-$repo_slug.service" systemctl --user restart "claude-rc-$repo_slug.service" @@ -162,11 +260,15 @@ cat <<EOF armed: pr-loop-$repo_slug.service (the cron-less loop daemon; adaptive tick+sleep) - claude-rc-$repo_slug.service (claude remote-control, in tmux session rc-$repo_slug) + claude-rc-$repo_slug.service (claude remote-control, supervised tmux session + rc-$repo_slug; auto-restarts within RestartSec=10s + if the planner process dies) inspect: systemctl --user status pr-loop-$repo_slug.service + systemctl --user status claude-rc-$repo_slug.service journalctl --user -u pr-loop-$repo_slug.service -f + journalctl --user -u claude-rc-$repo_slug.service -f tail -f "$repo_root/.claude/state/loop-runs.log" tmux attach -t rc-$repo_slug diff --git a/.claude/scripts/bot-gh.sh b/.claude/scripts/bot-gh.sh index 46e286f..8c080ed 100755 --- a/.claude/scripts/bot-gh.sh +++ b/.claude/scripts/bot-gh.sh @@ -22,7 +22,9 @@ # Usage: .claude/scripts/bot-gh.sh pr create --title "..." --body "..." set -euo pipefail -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# 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" if [ -f "$root/.env" ]; then set -a # shellcheck disable=SC1091 @@ -31,9 +33,6 @@ if [ -f "$root/.env" ]; then fi : "${GH_BOT_TOKEN:?GH_BOT_TOKEN not set — add it to .env (see setup notes in this script)}" -# reDeploy sandbox: gh lives under ~/.local/bin, which isn't always on PATH here. -export PATH="$HOME/.local/bin:$PATH" - # Preflight: the bot needs collaborator access to EACH (private) repo it acts on # (setup step 2). Without it, gh fails with an opaque # "Could not resolve to a Repository with the name '<owner>/<repo>'" that reads like @@ -140,13 +139,30 @@ if [ "$has_assignee" -eq 0 ] && [ -n "$owner" ]; then if [ "$status" -ne 0 ]; then exit "$status"; fi pr_number="${pr_url##*/}" if [ -n "$repo_full" ] && [ -n "$pr_number" ]; then - # NOTE: use a JSON body via --input, NOT `-f "assignees[]=$owner"`. The -f - # array form is silently ignored by the assignees endpoint here (returns 200 - # but assigns no one), which would defeat this whole block. The JSON body is - # the form empirically confirmed to work with a repo-scoped bot token. - if ! printf '{"assignees":["%s"]}' "$owner" | GH_TOKEN="$GH_BOT_TOKEN" gh api -X POST "repos/$repo_full/issues/$pr_number/assignees" --input - >/dev/null 2>&1; then + # Send the payload as an explicit JSON --input body, NEVER as + # `-f "assignees[]=..."`: gh only grew the bracket array syntax in later + # 2.x releases — on older gh (e.g. Ubuntu 22.04's packaged 2.4.0) it + # sends a literal "assignees[]" STRING field, which the API silently + # ignores while still returning 200. Exit code 0, nobody assigned, no + # warning — this exact silent no-op shipped for weeks. Same lesson for + # verification: don't trust the exit code, check the response actually + # names the owner (the closing quote in the grep keeps '<owner>-bot' + # style logins from matching as a prefix). + resp="$(printf '{"assignees":["%s"]}' "$owner" \ + | GH_TOKEN="$GH_BOT_TOKEN" gh api -X POST "repos/$repo_full/issues/$pr_number/assignees" --input - 2>/dev/null)" || resp="" + if ! printf '%s' "$resp" | grep -q "\"login\":\"$owner\""; then echo "bot-gh.sh: warning — created $pr_url but could not assign it to '$owner' (bot token may lack read:org); PR left unassigned." >&2 fi + # Also request a formal review from the owner: assignment alone does not + # put the PR in the owner's GitHub review queue or fire the + # review-requested notification. The author is always the bot here (the + # whole point of bot-gh.sh), so GitHub's no-self-review-request rule + # cannot trip on the owner. Soft-fail like the assignment. + resp="$(printf '{"reviewers":["%s"]}' "$owner" \ + | GH_TOKEN="$GH_BOT_TOKEN" gh api -X POST "repos/$repo_full/pulls/$pr_number/requested_reviewers" --input - 2>/dev/null)" || resp="" + if ! printf '%s' "$resp" | grep -q "\"login\":\"$owner\""; then + echo "bot-gh.sh: warning — created $pr_url but could not request a review from '$owner'; request it by hand so the owner is notified." >&2 + fi fi exit 0 fi diff --git a/.claude/scripts/gate.sh b/.claude/scripts/gate.sh index e08744c..a43dfe4 100755 --- a/.claude/scripts/gate.sh +++ b/.claude/scripts/gate.sh @@ -6,30 +6,20 @@ set -uo pipefail key="${1:?usage: gate.sh <gate-name>}" -# Repo root is two levels up from this script (<root>/.claude/scripts/gate.sh) — -# robust whether or not we're nested inside another git repo. -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -root="$(cd "$script_dir/../.." && pwd)" - -# Dependency-freshness preflight. pnpm copies the resolved lockfile to -# node_modules/.pnpm/lock.yaml on every install, so a byte-diff against the -# working pnpm-lock.yaml is a fast, offline staleness check. When they differ, -# node_modules is behind the lockfile (e.g. a merged PR added a dependency like -# viem) and TS builds fail with opaque "Cannot find module" errors that print to -# STDOUT — leaving hooks to report an unhelpful "No stderr output". Surface the -# real cause on STDERR and stop early so the fix ('pnpm install') is obvious. -# The 'install' gate is exempt: it IS 'pnpm install' — the step that resolves -# staleness (and the first thing CI runs, before node_modules exists) — so -# gating it on a fresh/stale node_modules would deadlock (chicken-and-egg). -if [ "$key" != "install" ] && [ -f "$root/pnpm-lock.yaml" ]; then - installed_lock="$root/node_modules/.pnpm/lock.yaml" - if [ ! -e "$installed_lock" ] || ! cmp -s "$root/pnpm-lock.yaml" "$installed_lock"; then - echo "gate.sh: node_modules is out of sync with pnpm-lock.yaml — run 'pnpm install' (gate '$key' skipped)." >&2 - exit 1 - fi -fi +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer +# project (repo-tracked <root>/.claude/scripts layout wins — robust in worktrees — +# else CLAUDE_PROJECT_DIR/git-toplevel/cwd for the plugin-cache layout). +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" -gates="$root/.claude/gates.json" +# Which adapter to read. Defaults to the project adapter; set GATES_FILE to run a +# different one (e.g. GATES_FILE=self/gates.json for the self-host loop — +# see self/README.md). Relative paths resolve from the repo root. +gates_ref="${GATES_FILE:-.claude/gates.json}" +case "$gates_ref" in + /*) gates="$gates_ref" ;; + *) gates="$root/$gates_ref" ;; +esac if [ ! -f "$gates" ]; then echo "gate.sh: no $gates found — skipping '$key'"; exit 0 @@ -41,5 +31,50 @@ if [ -z "$cmd" ]; then echo "gate.sh: gate '$key' not configured in gates.json — skipping"; exit 0 fi +# Dependency-freshness preflight (pnpm-gated: no-ops unless the repo uses pnpm). +# pnpm copies the resolved lockfile to node_modules/.pnpm/lock.yaml on every +# install, so a byte-diff against the working pnpm-lock.yaml is a fast, offline +# staleness check. When they differ, node_modules is behind the lockfile (e.g. a +# merged PR added a dependency) and TS builds fail with opaque "Cannot find +# module" errors printed to STDOUT — leaving Stop hooks to report an unhelpful +# "No stderr output". Surface the real cause on STDERR and abort early so the fix +# ('pnpm install') is obvious. Projects without a root pnpm-lock.yaml skip this. +# Runs AFTER the empty-gate skip above (issue #129) — a deliberately blanked gate +# (the documented "not configured" marker) is a no-op and must stay one even when +# node_modules is momentarily stale; only a gate that's actually about to execute +# needs a fresh node_modules. +# Exempt the 'install' gate itself: this message points at 'pnpm install' as the +# remedy, and on a brand-new checkout there is no node_modules at all yet, so the +# staleness check would always fire and abort before the remedy command ever runs +# — deadlocking every CI job, since every other gate calls 'install' first. +if [ -f "$root/pnpm-lock.yaml" ] && [ "$key" != "install" ]; then + installed_lock="$root/node_modules/.pnpm/lock.yaml" + if [ ! -e "$installed_lock" ] || ! cmp -s "$root/pnpm-lock.yaml" "$installed_lock"; then + echo "gate.sh: node_modules is out of sync with pnpm-lock.yaml — run 'pnpm install' (gate '$key' aborted)." >&2 + exit 1 + fi +fi + echo "▶ gate '$key': $cmd" -cd "$root" && eval "$cmd" + +# Token hygiene: gate output lands in an agent's context every time a hook fires, so a +# passing gate's full log is pure waste. Buffer the run and print a short tail on pass, +# the last GATE_TAIL_FAIL lines on fail. GATE_VERBOSE=1 streams everything (CI does too — +# its logs live server-side, not in a context window). +if [ -n "${GATE_VERBOSE:-}" ] || [ -n "${CI:-}" ]; then + cd "$root" && eval "$cmd" + exit $? +fi + +out="$(mktemp "${TMPDIR:-/tmp}/gate.$key.XXXXXX")" +trap 'rm -f "$out"' EXIT +( cd "$root" && eval "$cmd" ) >"$out" 2>&1 +rc=$? +if [ "$rc" -eq 0 ]; then + tail -n "${GATE_TAIL_PASS:-5}" "$out" + echo "✓ gate '$key' passed" +else + tail -n "${GATE_TAIL_FAIL:-100}" "$out" + echo "✗ gate '$key' FAILED (exit $rc) — last ${GATE_TAIL_FAIL:-100} lines shown; re-run with GATE_VERBOSE=1 for full output" +fi +exit "$rc" diff --git a/.claude/scripts/loop-census.sh b/.claude/scripts/loop-census.sh deleted file mode 100755 index 8b44c4f..0000000 --- a/.claude/scripts/loop-census.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# loop-census.sh — one-shot STEP 0 census for the PR loop (base and self-hosted). -# Prints, as stable key=value telemetry, everything a tick needs to decide -# whether it can ACT — so the actionability check is a single pre-approvable -# command instead of a discipline the tick can silently skip: -# -# open_prs=N open PRs against the adapter's base branch -# feedback_prs=N bot PRs with unaddressed CHANGES_REQUESTED (pr-feedback.sh) -# planned_issues=N open issues labelled `planned` AND one of the -# adapter's module:* labels, one detail line each: -# issue=<n> branch=<feat/issue-n-* or none> title=<title> -# in_flight=<n> one line PER planned issue that has a -# feat/issue-n-* branch (local or remote) but NO -# open PR for it yet — i.e. work has started but -# hasn't reached PR stage. A tick uses this to -# avoid double-spawning an orchestrator for an -# issue that already has a worktree in progress. -# advance_ready=<n|none> lowest-numbered planned issue with no branch, -# only when open_prs=0 (the ADVANCE precondition) -# cadence=FAST|WATCH|IDLE cron=<expr> desired cadence per the loop policy -# -# The module label set is derived from $GATES_FILE (default .claude/gates.json) -# → modules[].name, so the same script serves the self-hosted loop -# (GATES_FILE=.claude/self/gates.json) and downstream adopters. -# -# WHY THIS EXISTS (issue: loop stalled 13h with two planned issues): ticks that -# "optimized" STEP 0 away — or piped the cursor-advancing notify-poll.sh through -# `tail -1` — reported "No actionable activity" while ADVANCE work sat ready. -# A tick may claim "No actionable activity" ONLY when this census prints zeros. -# -# Repo derived from the git remote; override with $1. Bot login via $BOT_LOGIN. -# Invoke as `bash .claude/scripts/loop-census.sh` (pre-approve that exact -# command). Read-only: advances no cursor, mutates nothing — safe to re-run. -set -euo 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" -# Route EVERY gh call through the bot identity (see bot-gh.sh). -gh() { bash "$script_dir/bot-gh.sh" "$@"; } -repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" - -gates_rel="${GATES_FILE:-.claude/gates.json}" -case "$gates_rel" in /*) gates="$gates_rel" ;; *) gates="$root/$gates_rel" ;; esac - -# Adapter-derived facts: base branch + the module:* label set. -base=$(node -e 'const g=require(process.argv[1]); console.log((g.merge&&g.merge.baseBranch)||"main")' "$gates") -module_labels=$(node -e 'const g=require(process.argv[1]); console.log(g.modules.map(m=>"module:"+m.name).join("\n"))' "$gates") - -open_prs=$(gh pr list -R "$repo" --state open --base "$base" --json number --jq 'length') -echo "open_prs=$open_prs" - -# Head branch names of every open PR (against base) — used below to tell -# in_flight (branch exists, no PR yet) apart from already-at-PR-stage. -open_pr_branches=$(gh pr list -R "$repo" --state open --base "$base" --json headRefName --jq '.[].headRefName') - -feedback_prs=$(bash "$script_dir/pr-feedback.sh" "$repo" | grep -c . || true) -echo "feedback_prs=$feedback_prs" - -# Open `planned` issues carrying any of the adapter's module labels, ascending. -planned=$(gh issue list -R "$repo" --state open --label planned --json number,title,labels \ - --jq '.[] | [.number, ([.labels[].name]|join(",")), .title] | @tsv' | sort -n) - -planned_count=0 -advance_ready="none" -detail="" -in_flight="" -while IFS=$'\t' read -r num labels title; do - [ -z "${num:-}" ] && continue - hit=0 - while IFS= read -r ml; do - case ",$labels," in *",$ml,"*) hit=1; break;; esac - done <<< "$module_labels" - [ "$hit" -eq 1 ] || continue - planned_count=$((planned_count + 1)) - # Existing feat/issue-<n>-* branch (local or remote) means it's already in flight. - # NOTE: `| head -1` can make `git` see SIGPIPE (exit 141) if head closes the - # pipe before git finishes writing; under `set -euo pipefail` that would abort - # this whole script. `|| true` on the assignment absorbs that non-fatal - # pipeline failure — the captured output (head's one line) is unaffected. - branch=$(git -C "$root" branch -a --list "*feat/issue-$num-*" | head -1 | sed 's/^[* ]*//;s|^remotes/||') || true - [ -n "$branch" ] || branch="none" - detail+="issue=$num branch=$branch title=$title"$'\n' - if [ "$advance_ready" = "none" ] && [ "$branch" = "none" ] && [ "$open_prs" -eq 0 ]; then - advance_ready="$num" - fi - # in_flight: a branch exists for this issue but no open PR carries it yet - # (branch may be printed with a "origin/" remote prefix above; strip it — - # or match it as a "/"-suffix — before comparing against headRefName, which - # is always the bare branch name). - if [ "$branch" != "none" ]; then - has_open_pr=0 - while IFS= read -r b; do - [ -z "$b" ] && continue - case "$branch" in - "$b"|*"/$b") has_open_pr=1; break ;; - esac - done <<< "$open_pr_branches" - [ "$has_open_pr" -eq 1 ] || in_flight+="in_flight=$num"$'\n' - fi -done <<< "$planned" - -echo "planned_issues=$planned_count" -[ -n "$detail" ] && printf '%s' "$detail" -[ -n "$in_flight" ] && printf '%s' "$in_flight" -echo "advance_ready=$advance_ready" - -# Desired cadence per the loop policy: FAST only when the loop can ACT now. -if [ "$feedback_prs" -ge 1 ] || { [ "$open_prs" -eq 0 ] && [ "$planned_count" -ge 1 ]; }; then - echo 'cadence=FAST cron=* * * * *' -elif [ "$open_prs" -ge 1 ]; then - echo 'cadence=WATCH cron=*/5 * * * *' -else - echo 'cadence=IDLE cron=*/15 * * * *' -fi diff --git a/.claude/scripts/loop-census.test.sh b/.claude/scripts/loop-census.test.sh deleted file mode 100755 index ca8e611..0000000 --- a/.claude/scripts/loop-census.test.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -# loop-census.test.sh — offline smoke test for loop-census.sh's in_flight -# detection (issue #81 re-review, finding 5). -# -# loop-tick.test.sh exercises loop-tick.sh against a FAKE loop-census.sh that -# just echoes canned `in_flight=N` lines — it never runs loop-census.sh's own -# branch-detection algorithm. This test closes that gap: it runs the REAL -# loop-census.sh (+ real resolve-roots.sh) against a REAL git repo with real -# local and remote-tracking branches, stubbing only `gh` (via a fake -# bot-gh.sh) and pr-feedback.sh (no network, no gh CLI required), and asserts -# on the actual `in_flight=`/`branch=` lines the real algorithm prints. -# -# Specifically covers the two failure modes called out in re-review: -# -# - PREFIX COLLISION: issue 4 has NO branch of its own, while issue 42 and -# issue 43 DO (as "issue-4" is a literal prefix of "issue-42"/"issue-43"). -# A glob without the trailing "-" (`*feat/issue-4*` instead of -# `*feat/issue-4-*`) would make `git branch -a --list` for issue 4 also -# match issue 42's/43's branches; since issue 4 has no LOCAL branch of -# its own to sort first, `head -1` would then wrongly attribute one of -# THEIR branches to issue 4. Asserted directly: issue 4 must come back -# branch=none despite 42/43 existing. -# -# - "remotes/origin/" HANDLING: issue 42's and 43's branches exist ONLY as -# remote-tracking refs (pushed, then the local branch deleted), so -# `git branch -a` reports them as "remotes/origin/feat/issue-4N-*". -# Issue 43 additionally already has an open PR under its BARE branch -# name (`feat/issue-43-z`, no "origin/" prefix, matching a real -# `headRefName`) — that must still register as "already has a PR" (not -# in_flight) via the "*/<bare>" suffix rule, not just an exact-string -# match; issue 100's LOCAL (non-remote) branch with an open PR is the -# control for the exact-match path. -# -# Exit 0 on success, non-zero if any assertion fails. Runnable bare: -# bash .claude/scripts/loop-census.test.sh -set -uo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -census_src="$script_dir/loop-census.sh" -resolve_roots_src="$script_dir/resolve-roots.sh" - -work="$(mktemp -d "${TMPDIR:-/tmp}/loop-census-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 fixture: a real git repo (fixture root = census's $root) with: -# - issue 4: NO branch at all -> branch=none. Must not be fooled by -# issue 42's/43's branches, whose names have "issue-4" as a -# literal prefix. -# - issue 42: a REMOTE-tracking-only branch feat/issue-42-y (pushed, local -# copy deleted), no open PR for it -> MUST be in_flight. -# - issue 43: a REMOTE-tracking-only branch feat/issue-43-z, which -# ALREADY has an open PR under its bare name -> must NOT be -# in_flight (the "remotes/origin/" strip + "*/<bare>" suffix -# match on the POSITIVE path). -# - issue 100: a LOCAL branch feat/issue-100-w, which ALREADY has an open -# PR under its bare (exact, no prefix) name -> must NOT be -# in_flight (the plain exact-match control case). -# --------------------------------------------------------------------------- -fixture="$work/fixture1" -scripts_dir="$fixture/.claude/scripts" -mkdir -p "$scripts_dir" -cp "$census_src" "$scripts_dir/loop-census.sh" -cp "$resolve_roots_src" "$scripts_dir/resolve-roots.sh" - -# Minimal adapter: one module, so "module:test" is the only label census cares -# about; base branch is "main" to match the repo below. -cat > "$fixture/.claude/gates.json" <<'EOF' -{ - "modules": [{ "name": "test", "path": ".", "description": "", "owner": "" }], - "merge": { "baseBranch": "main" } -} -EOF - -# pr-feedback.sh is exercised by its own test (loop-tick.test.sh); here it's -# just a no-op stub so census's feedback_prs line is deterministic (0). -cat > "$scripts_dir/pr-feedback.sh" <<'EOF' -#!/usr/bin/env bash -exit 0 -EOF - -# Fake bot-gh.sh: no network, no real `gh` — dispatches on the subcommand and -# a `--json` marker to canned, fixture-appropriate output. -# - `pr list ... --json headRefName ...`: bare branch names of open PRs — -# issues 43 and 100 already have one; issue 42 does not (issue 4 has no -# branch, so it can't have a PR either). -# - `pr list ... --json number ...`: open PR count (2, matching above). -# - `issue list ...`: TSV `num<TAB>labels<TAB>title` -# for the four planned+module:test issues. -cat > "$scripts_dir/bot-gh.sh" <<'EOF' -#!/usr/bin/env bash -case "$1" in - repo) echo "acme/repo" ;; - pr) - if printf '%s\n' "$*" | grep -q 'headRefName'; then - printf '%s\n' "feat/issue-43-z" - printf '%s\n' "feat/issue-100-w" - else - echo 2 - fi - ;; - issue) - printf '4\tplanned,module:test\tIssue four\n' - printf '42\tplanned,module:test\tIssue forty two\n' - printf '43\tplanned,module:test\tIssue forty three\n' - printf '100\tplanned,module:test\tIssue one hundred\n' - ;; - *) echo "fake-bot-gh.sh: unhandled args: $*" >&2; exit 1 ;; -esac -EOF -chmod +x "$scripts_dir"/*.sh - -# Real git repo at the fixture root (census does `git -C "$root" branch -a`). -git -C "$fixture" init -q -b main -git -C "$fixture" -c user.email=t@e.st -c user.name=t commit -q --allow-empty -m init - -# Bare "remote" so `git branch -a` prints genuine "remotes/origin/..." lines. -remote="$work/remote.git" -git init -q --bare "$remote" -git -C "$fixture" remote add origin "$remote" - -# issue 4: deliberately NO branch at all (see the prefix-collision note above). - -# issue 42 and 43: pushed to origin, then the LOCAL copy is deleted so only -# the "remotes/origin/..." remote-tracking ref remains — this is the case -# census's "strip remotes/ or match as /-suffix" logic exists for. -git -C "$fixture" branch feat/issue-42-y main >/dev/null -git -C "$fixture" push -q origin feat/issue-42-y >/dev/null 2>&1 -git -C "$fixture" branch -D feat/issue-42-y >/dev/null - -git -C "$fixture" branch feat/issue-43-z main >/dev/null -git -C "$fixture" push -q origin feat/issue-43-z >/dev/null 2>&1 -git -C "$fixture" branch -D feat/issue-43-z >/dev/null - -# issue 100: LOCAL-only branch (never pushed) — exact-match control. -git -C "$fixture" branch feat/issue-100-w main >/dev/null - -# Unset GATES_FILE explicitly: loop-census.sh reads it straight from the -# environment, and this test may itself be run from inside a gate invocation -# that exports GATES_FILE=.claude/self/gates.json for the OUTER repo — which -# would leak in here and make census look for a gates.json this fixture never -# created. Force it back to the fixture's own default-relative gates.json. -out="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" - -check "issue 4 (no branch at all) reports branch=none" bash -c 'printf "%s\n" "$1" | grep -q "^issue=4 branch=none"' _ "$out" -check "issue 4 is NOT in_flight (no branch to be in flight with)" bash -c '! printf "%s\n" "$1" | grep -qx "in_flight=4"' _ "$out" -check "issue 42 (remote-only branch, no open PR) IS in_flight" bash -c 'printf "%s\n" "$1" | grep -qx "in_flight=42"' _ "$out" -check "issue 43 (remote-only branch, already has an open PR via origin/ strip+suffix match) is NOT in_flight" bash -c '! printf "%s\n" "$1" | grep -qx "in_flight=43"' _ "$out" -check "issue 100 (local branch, already has an open PR, exact-match control) is NOT in_flight" bash -c '! printf "%s\n" "$1" | grep -qx "in_flight=100"' _ "$out" -check "exactly one in_flight line total (only issue 42 qualifies)" bash -c '[ "$(printf "%s\n" "$1" | grep -c "^in_flight=")" -eq 1 ]' _ "$out" -check "planned_issues=4 counted" bash -c 'printf "%s\n" "$1" | grep -qx "planned_issues=4"' _ "$out" -check "issue=42 branch line shows the origin-prefixed remote-tracking name" bash -c 'printf "%s\n" "$1" | grep -q "^issue=42 branch=origin/feat/issue-42-y"' _ "$out" - -echo "" -if [ "$fail" -eq 0 ]; then - echo "loop-census.test.sh: PASS ($ok checks)" - exit 0 -else - echo "loop-census.test.sh: FAIL (see FAIL lines above)" - exit 1 -fi diff --git a/.claude/scripts/loop-daemon.sh b/.claude/scripts/loop-daemon.sh deleted file mode 100755 index 4823f27..0000000 --- a/.claude/scripts/loop-daemon.sh +++ /dev/null @@ -1,229 +0,0 @@ -#!/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 <LOOP_DRIVER_TIMEOUT, default 90m>` 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=<pgid> session=<session_id> verdict=<advance issue=N|feedback pr=N> ts=<ISO8601> [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 <session_id> --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=<expr>` 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" - # Resolve nvm-provisioned binaries ONCE, up front, for the whole daemon — - # not only inside run_driver. A systemd (user) service PATH has `gh` but - # neither `node` nor `claude`, and the tick's step scripts need node - # (merge-ready.sh, loop-census.sh, write_tick_record): without this, ticks - # under the service silently skip merges and tick records while polling - # still works — a deadlock, since the only path that DID source nvm - # (run_driver) is unreachable while an unmergeable PR keeps advance away. - ensure_claude_on_path \ - || log "warning: 'claude' not resolvable at startup (nor via nvm) — node-dependent tick steps and driver spawns will fail until PATH provides it" - log "starting (LOOP_MODEL=${LOOP_MODEL:-sonnet} GATES_FILE=${GATES_FILE:-<default>} 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 deleted file mode 100755 index 0e2869e..0000000 --- a/.claude/scripts/loop-daemon.test.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/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 deleted file mode 100755 index b0a3489..0000000 --- a/.claude/scripts/loop-event.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/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=<model> -# loop-event: prompt-file=<absolute path to a plain-text file holding the -# verdict-obeying prompt for the driver session> -# -> actionable. The caller is expected to spawn something equivalent to -# `claude --model <model> -p "$(cat <prompt-file>)" --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 <name>. " -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. YOU ARE A HEADLESS ONE-SHOT SESSION: the moment you end your turn, this session and every background process/agent it spawned are terminated (a background orchestrator gets at most a short grace ceiling, then is killed mid-work — observed 2026-07-10: two drivers exited 'cleanly' leaving half-born local branches that wedged their issues as in_flight). Therefore run the ENTIRE orchestration SYNCHRONOUSLY: spawn the orchestrator and every agent in the FOREGROUND (run_in_background: false), wait for each to finish, and do NOT end your turn until the work product exists on GitHub (the bot PR is open, or the feedback push + marker comment landed) or you are reporting a definite failure — never a 'running in background, will report later' message, which is a self-deception in this mode. If orchestration fails partway, CLEAN UP before exiting: delete any local feat/issue-N-* branch and worktree you created that has no open PR, so census never mistakes your debris for in-flight work. 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 \`<!-- claude-addressed -->\` 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 deleted file mode 100755 index 4b102d0..0000000 --- a/.claude/scripts/loop-event.test.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/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: <fixture>/.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" <<EOF -#!/usr/bin/env bash -cat <<'TICK' -$fake_tick_out -TICK -exit $fake_tick_rc -EOF - chmod +x "$dir"/*.sh - # Deliberately NO 'claude', 'setsid', or 'timeout' anywhere on this fixture's - # PATH — loop-event.sh must never need them. - printf '%s\n' "$work/$name" -} - -run_event() { - # $1 = fixture project root. Run with a PATH stripped of this shell's own - # dirs so a stray 'claude'/'timeout' on the real machine can't mask a bug. - ( cd "$1" && PATH="/usr/bin:/bin" bash .claude/scripts/loop-event.sh ) -} - -last_line() { tail -1; } - -# --------------------------------------------------------------------------- -# 1. action=none -> 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/scripts/loop-tick.sh b/.claude/scripts/loop-tick.sh deleted file mode 100755 index 8aef437..0000000 --- a/.claude/scripts/loop-tick.sh +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env bash -# loop-tick.sh — one-shot orchestration tick for the autonomous PR loop. -# -# Runs the loop's four step scripts, IN ORDER, with their FULL output -# preserved (never swallowed or `tail -1`'d), then emits exactly one -# machine-readable verdict line as the LAST line of output: -# action=none -# action=advance issue=N -# action=feedback pr=N -# -# WHY THIS EXISTS (issue #81): the tick used to be a multi-step PROMPT -# (.claude/commands/pr-loop.md) that a model re-derived, from scratch, every -# firing. Repetition is exactly where smaller/cheaper models drift — a -# Haiku-driven tick has been observed to stop invoking the step scripts and -# fabricate their output, and to double-spawn an orchestrator for the same -# issue because it misread an in-flight worktree as hung. Collapsing the -# whole tick to ONE script plus one conditional spawn (of the ADVANCE/FEEDBACK -# work itself) makes the protocol immune to that drift: the verdict line is -# computed by shell/node logic, not recalled by the model from a prompt. -# -# This script does NOT reimplement census, polling, merge, or feedback-detection -# logic — it calls the existing sibling scripts and only adds the verdict -# arithmetic + the spawn lock (see .claude/state/loop-advance.lock below). -# -# Precedence: unaddressed CHANGES_REQUESTED feedback (pr-feedback.sh) always -# wins over ADVANCE — a human is waiting on a reply. When multiple PRs need -# feedback addressed, the lowest-numbered PR is picked. ADVANCE additionally -# requires: census says advance_ready=N (already means zero open PRs + a -# planned+module issue + no existing branch), N is not census's in_flight=N -# (a feat/issue-N-* branch with no open PR — someone/something is already -# mid-flight on it), and the spawn lock (below) is not already held for N. -# -# Spawn lock: .claude/state/loop-advance.lock (root-relative; .claude/state/ -# is already gitignored). Written the moment this script emits -# `action=advance issue=N`, so a SECOND tick — fired before the first -# implementer has even pushed a branch — is refused by this script's own -# logic rather than by model discipline. Format: one line, -# `issue=N ts=<UTC ISO-8601>`. -# -# INVARIANT (corrected — see issue #81 re-review): a lock for issue N is -# held to cover exactly the narrow window between "this tick just emitted -# action=advance issue=N" and "an orchestrator has pushed feat/issue-N-*". -# While that window is open, census reports N as advance_ready (no branch -# yet) — the SAME signal that means "N still needs advancing" — so the two -# cannot be told apart by advance_ready alone. The lock is released as soon -# as EITHER: -# (a) an open PR now exists for N (census no longer reports N as -# advance_ready — feedback/merge scripts own N from here), OR -# (b) a feat/issue-N-* branch now exists with no open PR yet (census -# reports N as in_flight) — the orchestrator got at least as far as -# pushing a branch, so the pre-branch race this lock guards against is -# over; a second tick would refuse to re-advance N anyway once it's -# in_flight, OR -# (c) the lock is older than LOCK_TTL_SECONDS and N is STILL -# advance_ready with no branch — this can only mean the spawn that -# should have created the branch crashed (or never started) before -# reaching (b), so a lock stuck in this state is treated as a crashed -# spawn and cleared to let a later tick re-advance N. -# This script self-heals: on every run it checks the held lock (if any) -# against the FRESH census output plus the TTL above and clears it whenever -# it no longer qualifies, so a crashed orchestrator never permanently wedges -# the issue. Written atomically (temp file + mv) to avoid a torn read, and -# the whole read-check-write critical section is additionally serialized -# with `flock` (a separate .claude/state/loop-advance.flock) so two ticks -# racing each other cannot both observe "no lock" and both emit -# `action=advance issue=N` (a TOCTOU double-spawn — atomic temp+mv alone only -# prevents a torn READ, not two processes interleaving read-then-write). -# -# Repo derived from the git remote; override with $1. Bot login via -# $BOT_LOGIN (passed through to the step scripts). Honors $GATES_FILE exactly -# like the sibling scripts (loop-census.sh reads it directly; the others fall -# back to the default adapter). -# -# Invoke as `bash .claude/scripts/loop-tick.sh` (pre-approve that exact -# command). Safe to run: this script itself only reads and computes a -# verdict + lock file — its only SIDE EFFECTS are the ones already documented -# on the step scripts it calls (notify-poll.sh advances its cursor; -# merge-ready.sh merges owner-approved, CI-green PRs and fast-forwards a -# clean local checkout on main). It never itself opens a PR, merges, or -# spawns an agent — it only tells the caller which single action to take. -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" -# Route EVERY gh call (ours and the step scripts') through the bot identity. -gh() { bash "$script_dir/bot-gh.sh" "$@"; } -repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" - -# --------------------------------------------------------------------------- -# Tick record (issue #85): append ONE record per firing to -# .claude/state/loop-ticks.jsonl, so the cockpit's "Loop health" panel can -# show the last tick, current cadence, verdict history, and detect a stalled -# loop. Mirrors log-event.sh's EXACT pattern: the JSON line is built with -# `node` (never hand-rolled string interpolation) so values are safely -# escaped, and the file is rotated to the last N lines via temp-file + atomic -# `mv` (crash-safe). -# -# CRITICAL INVARIANT: this must NEVER print to stdout and must NEVER change -# this script's exit status or verdict -- the verdict line printed at the end -# of this script MUST remain the LAST line of stdout (the daemon/tick parser -# reads the last line). Best-effort/never-break, exactly like log-event.sh: -# every step below is guarded so a failure here can never affect the tick. -# -# Log file: defaults to <root>/.claude/state/loop-ticks.jsonl. Override with -# CLAUDE_TICKS_FILE=<absolute path> (used by tests to point at a temp file -# instead of the real, gitignored state dir). Override the rotation cap with -# LOOP_TICKS_MAX_LINES (default 2000), matching log-event.sh's -# EVENTS_MAX_LINES. -write_tick_record() { - local verdict="$1" cadence="$2" - local ticks_file="${CLAUDE_TICKS_FILE:-$root/.claude/state/loop-ticks.jsonl}" - local max_lines="${LOOP_TICKS_MAX_LINES:-2000}" - local action="" issue="" pr="" - case "$verdict" in - "action=advance issue="*) action="advance"; issue="${verdict#action=advance issue=}" ;; - "action=feedback pr="*) action="feedback"; pr="${verdict#action=feedback pr=}" ;; - "action=none") action="none" ;; - *) - action="${verdict#action=}" - action="${action%% *}" - ;; - esac - - mkdir -p "$(dirname "$ticks_file")" 2>/dev/null || return 0 - - local ts - ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" || ts="" - - CLAUDE_TICK_TS="$ts" \ - CLAUDE_TICK_VERDICT="$verdict" \ - CLAUDE_TICK_CADENCE="$cadence" \ - CLAUDE_TICK_ACTION="$action" \ - CLAUDE_TICK_ISSUE="$issue" \ - CLAUDE_TICK_PR="$pr" \ - node -e ' - const line = JSON.stringify({ - ts: process.env.CLAUDE_TICK_TS || "", - verdict: process.env.CLAUDE_TICK_VERDICT || "", - cadence: process.env.CLAUDE_TICK_CADENCE || "", - action: process.env.CLAUDE_TICK_ACTION || "", - issue: process.env.CLAUDE_TICK_ISSUE || "", - pr: process.env.CLAUDE_TICK_PR || "", - }); - process.stdout.write(line + "\n"); - ' >>"$ticks_file" 2>/dev/null || return 0 - - # ---- rotation: cap to the last $max_lines lines, atomically ------------- - node -e ' - const fs = require("fs"); - const file = process.argv[1]; - const max = parseInt(process.argv[2], 10); - const tmp = process.argv[3]; - try { - if (!Number.isFinite(max) || max <= 0) process.exit(0); - const text = fs.readFileSync(file, "utf8"); - const lines = text.split("\n"); - // drop a single trailing empty string from the final newline, if present - if (lines.length && lines[lines.length - 1] === "") lines.pop(); - if (lines.length <= max) process.exit(0); - const kept = lines.slice(lines.length - max); - fs.writeFileSync(tmp, kept.join("\n") + "\n"); - fs.renameSync(tmp, file); - } catch (e) { - process.exit(0); - } - ' "$ticks_file" "$max_lines" "$ticks_file.tmp.$$" 2>/dev/null - - return 0 -} - -echo "=== 1/4 loop-census.sh ===" -census_out="$(bash "$script_dir/loop-census.sh" "$repo")" -printf '%s\n' "$census_out" - -echo "=== 2/4 notify-poll.sh ===" -bash "$script_dir/notify-poll.sh" "$repo" - -echo "=== 3/4 merge-ready.sh ===" -bash "$script_dir/merge-ready.sh" "$repo" - -echo "=== 4/4 pr-feedback.sh ===" -feedback_out="$(bash "$script_dir/pr-feedback.sh" "$repo")" -printf '%s\n' "$feedback_out" - -echo "=== verdict ===" - -# --- Parse census telemetry needed for the verdict ------------------------- -advance_ready="$(printf '%s\n' "$census_out" | sed -n 's/^advance_ready=//p' | tail -1)" -advance_ready="${advance_ready:-none}" -in_flight_issues="$(printf '%s\n' "$census_out" | sed -n 's/^in_flight=//p')" -# Cadence (FAST/WATCH/IDLE), for the tick record (issue #85) -- census emits -# e.g. "cadence=FAST cron=* * * * *"; keep only the leading token. -cadence="$(printf '%s\n' "$census_out" | sed -n 's/^cadence=\([A-Za-z]*\).*/\1/p' | tail -1)" - -# --- Parse pr-feedback.sh's TSV (num, branch, reviewer, changes_requested_at) -- -# Lowest-numbered PR wins when several need feedback addressed. -feedback_pr="$(printf '%s\n' "$feedback_out" | awk -F'\t' 'NF>=1 && $1 ~ /^[0-9]+$/ {print $1}' | sort -n | head -1)" - -# --- Spawn lock: read + self-heal against the FRESH census above ----------- -# TTL rationale: this lock is written the instant a tick emits -# `action=advance issue=N`, before the orchestrator that will push -# `feat/issue-N-*` even exists yet. A real orchestrator reaches that push -# within at most a few minutes of being spawned. 15 minutes is comfortably -# above that, so a lock that is STILL "no branch, still advance_ready" past -# this TTL can only mean the spawn crashed (or was never launched) before -# creating a branch — self-heal by clearing it rather than wedging the issue -# forever (see INVARIANT (c) in the header comment above). -LOCK_TTL_SECONDS=900 - -state_dir="$root/.claude/state" -lock_file="$state_dir/loop-advance.lock" -flock_file="$state_dir/loop-advance.flock" -mkdir -p "$state_dir" - -# Concurrent-tick guard (issue #81 re-review, TOCTOU): two overlapping ticks -# must not both observe "no lock held for N" and both emit -# `action=advance issue=N` — exactly the double-spawn bug #81 exists to kill. -# Atomic temp+mv (below) only prevents a torn READ of the lock file; it does -# not make "read lock -> self-heal -> decide -> write lock" atomic ACROSS two -# processes. Serialize that whole critical section with a real file lock so -# only one tick at a time can be inside it (released automatically when this -# script exits and fd 9 closes). -exec 9>"$flock_file" -flock -x 9 - -lock_issue="" -if [ -f "$lock_file" ]; then - lock_issue="$(sed -n 's/^issue=\([0-9][0-9]*\).*/\1/p' "$lock_file" | head -1)" -fi - -if [ -n "$lock_issue" ]; then - still_qualifies=0 - reason="" - if printf '%s\n' "$in_flight_issues" | grep -qx "$lock_issue"; then - # (b): a branch now exists — the pre-branch window this lock guards is - # closed (a second tick would refuse to advance N anyway once in_flight). - reason="branch now exists (in_flight) — lock's purpose is served" - elif [ "$lock_issue" = "$advance_ready" ]; then - # Still no branch. Either the spawn just started (keep the lock) or it - # crashed before ever pushing a branch (clear it) — (c): use the - # recorded ts as a bounded TTL to tell the two apart. - lock_ts="$(sed -n 's/^issue=[0-9][0-9]* ts=\(.*\)$/\1/p' "$lock_file" | head -1)" - lock_epoch="$(date -u -d "$lock_ts" +%s 2>/dev/null || echo 0)" - now_epoch="$(date -u +%s)" - age=$(( now_epoch - lock_epoch )) - if [ "$lock_epoch" -eq 0 ] || [ "$age" -gt "$LOCK_TTL_SECONDS" ]; then - reason="lock is older than ${LOCK_TTL_SECONDS}s with still no branch — treating as a crashed spawn" - else - still_qualifies=1 - fi - else - # (a): no longer advance_ready and no branch -> an open PR must exist now. - reason="no longer advance_ready/in_flight — open PR exists or branch is gone" - fi - if [ "$still_qualifies" -eq 0 ]; then - echo "# lock self-heal: cleared stale spawn lock for issue=$lock_issue ($reason)" - rm -f "$lock_file" - lock_issue="" - fi -fi - -# --- Decide the verdict ----------------------------------------------------- -# The verdict string is captured into a variable (rather than echoed inline) -# so it can ALSO be persisted to the tick log below without disturbing the -# invariant that the verdict line is the LAST line of stdout. -verdict="" -if [ -n "$feedback_pr" ]; then - verdict="action=feedback pr=$feedback_pr" -elif [ "$advance_ready" != "none" ] && [ -n "$advance_ready" ]; then - if printf '%s\n' "$in_flight_issues" | grep -qx "$advance_ready"; then - echo "# advance refused: issue=$advance_ready is in_flight (a feat/issue-$advance_ready-* branch already exists with no open PR)" - verdict="action=none" - elif [ "$lock_issue" = "$advance_ready" ]; then - echo "# advance refused: spawn lock already held for issue=$advance_ready ($(cat "$lock_file" 2>/dev/null))" - verdict="action=none" - else - tmp="$(mktemp "$state_dir/.loop-advance.lock.XXXXXX")" - printf 'issue=%s ts=%s\n' "$advance_ready" "$(date -u +%FT%TZ)" > "$tmp" - mv -f "$tmp" "$lock_file" - verdict="action=advance issue=$advance_ready" - fi -else - verdict="action=none" -fi - -echo "$verdict" - -# Persist the tick record (issue #85) AFTER the verdict has been echoed, and -# writing to the FILE ONLY -- never stdout -- so the verdict line above stays -# the last line of this script's stdout. Best-effort: never allowed to affect -# the exit status set below. -write_tick_record "$verdict" "$cadence" || true diff --git a/.claude/scripts/loop-tick.test.sh b/.claude/scripts/loop-tick.test.sh deleted file mode 100755 index 5cd16e3..0000000 --- a/.claude/scripts/loop-tick.test.sh +++ /dev/null @@ -1,388 +0,0 @@ -#!/usr/bin/env bash -# loop-tick.test.sh — offline smoke test for loop-tick.sh (issue #81). -# -# loop-tick.sh's own logic is just: run its four sibling step scripts, parse -# census/pr-feedback output, and emit one verdict line (plus the spawn lock). -# So this test doesn't touch real gh/network — it builds a throwaway -# .claude/scripts/ directory containing the REAL loop-tick.sh + resolve-roots.sh -# next to FAKE loop-census.sh / notify-poll.sh / merge-ready.sh / pr-feedback.sh -# that print canned, scripted output, then asserts the final verdict line and -# the spawn-lock file behavior for each scenario. -# -# Exit 0 on success, non-zero if any assertion fails. Runnable bare: -# bash .claude/scripts/loop-tick.test.sh -set -uo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -loop_tick_src="$script_dir/loop-tick.sh" -resolve_roots_src="$script_dir/resolve-roots.sh" - -work="$(mktemp -d "${TMPDIR:-/tmp}/loop-tick-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: <fixture>/.claude/scripts/. -# fake_census / fake_feedback are the exact stdout the corresponding real -# script would print; notify-poll.sh and merge-ready.sh are stubbed to just -# print a marker line (their output is passed through, never parsed). -new_fixture() { - local name="$1" fake_census="$2" fake_feedback="$3" - local dir="$work/$name/.claude/scripts" - mkdir -p "$dir" "$work/$name/.claude/state" 2>/dev/null - rm -rf "$work/$name/.claude/state" # loop-tick.sh must mkdir -p it itself - cp "$loop_tick_src" "$dir/loop-tick.sh" - cp "$resolve_roots_src" "$dir/resolve-roots.sh" - - cat > "$dir/loop-census.sh" <<EOF -#!/usr/bin/env bash -cat <<'CENSUS' -$fake_census -CENSUS -EOF - cat > "$dir/notify-poll.sh" <<'EOF' -#!/usr/bin/env bash -echo "CURSOR=fake NOW=fake" -echo "=== fake notify-poll output ===" -EOF - cat > "$dir/merge-ready.sh" <<'EOF' -#!/usr/bin/env bash -echo "=== merge-ready: merged=0 skipped=0 ===" -EOF - cat > "$dir/pr-feedback.sh" <<EOF -#!/usr/bin/env bash -cat <<'FEEDBACK' -$fake_feedback -FEEDBACK -EOF - chmod +x "$dir"/*.sh - printf '%s\n' "$dir" -} - -run_tick() { - # $1 = fixture script_dir; repo passed explicitly so loop-tick.sh's own gh() - # (bot-gh.sh) is never invoked (bot-gh.sh doesn't even exist in the fixture). - bash "$1/loop-tick.sh" "acme/repo" -} - -last_line() { tail -1; } - -# --------------------------------------------------------------------------- -# 1. Nothing actionable -> action=none. -# --------------------------------------------------------------------------- -dir1="$(new_fixture scenario1 'open_prs=0 -feedback_prs=0 -planned_issues=0 -advance_ready=none -cadence=IDLE cron=*/15 * * * *' '')" -out1="$(run_tick "$dir1")" -check "scenario 1 (nothing actionable): verdict is action=none" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=none" ]' _ "$out1" -check "scenario 1: no spawn lock left behind" [ ! -e "$dir1/../state/loop-advance.lock" ] - -# --------------------------------------------------------------------------- -# 2. Advance-ready issue, no feedback, nothing in flight, no lock held -> -# action=advance issue=N, and the lock file is written. -# --------------------------------------------------------------------------- -dir2="$(new_fixture scenario2 'open_prs=0 -feedback_prs=0 -planned_issues=1 -issue=42 branch=none title=Do the thing -advance_ready=42 -cadence=FAST cron=* * * * *' '')" -out2="$(run_tick "$dir2")" -check "scenario 2 (advance ready): verdict is action=advance issue=42" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=advance issue=42" ]' _ "$out2" -lock2="$dir2/../state/loop-advance.lock" -check "scenario 2: spawn lock file was written" [ -f "$lock2" ] -check "scenario 2: lock file records issue=42" grep -q '^issue=42 ts=' "$lock2" - -# --------------------------------------------------------------------------- -# 3. Same fixture, SECOND tick while the lock from scenario 2's issue is still -# held -> downgraded to action=none (never re-emits action=advance for the -# same issue while a first spawn is still in flight). -# --------------------------------------------------------------------------- -out3="$(run_tick "$dir2")" -check "scenario 3 (lock already held): verdict downgrades to action=none" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=none" ]' _ "$out3" -check "scenario 3: a diagnostic line explains the refusal" bash -c 'printf "%s\n" "$1" | grep -q "spawn lock already held for issue=42"' _ "$out3" -check "scenario 3: the lock file is untouched (still issue=42)" grep -q '^issue=42 ts=' "$lock2" - -# --------------------------------------------------------------------------- -# 4. Feedback PR present takes priority over an ALSO-ready advance -> pick the -# LOWEST-numbered feedback PR, never action=advance. -# --------------------------------------------------------------------------- -dir4="$(new_fixture scenario4 'open_prs=0 -feedback_prs=1 -planned_issues=1 -issue=7 branch=none title=Some issue -advance_ready=7 -cadence=FAST cron=* * * * *' "9 feat/issue-9-x owner 2026-01-01T00:00:00Z -5 feat/issue-5-y owner 2026-01-01T00:00:00Z")" -out4="$(run_tick "$dir4")" -check "scenario 4 (feedback beats advance): verdict is action=feedback pr=5 (lowest)" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=feedback pr=5" ]' _ "$out4" -check "scenario 4: no spawn lock written (advance never attempted)" [ ! -e "$dir4/../state/loop-advance.lock" ] - -# --------------------------------------------------------------------------- -# 5. in_flight refusal: advance_ready=N but census ALSO reports N as in_flight -# (defensive check — real census never produces both for the same issue, -# but loop-tick.sh must still refuse rather than double-spawn). -# --------------------------------------------------------------------------- -dir5="$(new_fixture scenario5 'open_prs=0 -feedback_prs=0 -planned_issues=1 -issue=8 branch=feat/issue-8-x title=In flight thing -in_flight=8 -advance_ready=8 -cadence=FAST cron=* * * * *' '')" -out5="$(run_tick "$dir5")" -check "scenario 5 (in_flight): verdict downgrades to action=none" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=none" ]' _ "$out5" -check "scenario 5: diagnostic cites in_flight" bash -c 'printf "%s\n" "$1" | grep -q "in_flight"' _ "$out5" -check "scenario 5: no spawn lock written" [ ! -e "$dir5/../state/loop-advance.lock" ] - -# --------------------------------------------------------------------------- -# 6. Self-heal: a stale lock for issue 3 (no longer advance_ready/in_flight in -# the fresh census — e.g. its PR landed) must be cleared automatically, and -# a DIFFERENT now-ready issue can still be picked up in the SAME tick. -# --------------------------------------------------------------------------- -dir6="$(new_fixture scenario6 'open_prs=0 -feedback_prs=0 -planned_issues=1 -issue=9 branch=none title=Fresh issue -advance_ready=9 -cadence=FAST cron=* * * * *' '')" -lock6="$dir6/../state/loop-advance.lock" -mkdir -p "$(dirname "$lock6")" -printf 'issue=3 ts=2020-01-01T00:00:00Z\n' > "$lock6" -out6="$(run_tick "$dir6")" -check "scenario 6 (self-heal): verdict advances the NEW issue 9" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=advance issue=9" ]' _ "$out6" -check "scenario 6: self-heal diagnostic mentions the cleared stale issue=3" bash -c 'printf "%s\n" "$1" | grep -q "cleared stale spawn lock for issue=3"' _ "$out6" -check "scenario 6: lock file now records the NEW issue=9, not the stale 3" grep -q '^issue=9 ts=' "$lock6" - -# --------------------------------------------------------------------------- -# 7. All four step scripts' full output is preserved (never swallowed). -# --------------------------------------------------------------------------- -check "all four labeled step headers appear in the tick's output" bash -c ' - printf "%s\n" "$1" | grep -q "1/4 loop-census.sh" && - printf "%s\n" "$1" | grep -q "2/4 notify-poll.sh" && - printf "%s\n" "$1" | grep -q "3/4 merge-ready.sh" && - printf "%s\n" "$1" | grep -q "4/4 pr-feedback.sh" -' _ "$out1" -check "notify-poll.sh full output line passed through, not swallowed" bash -c 'printf "%s\n" "$1" | grep -qF "fake notify-poll output"' _ "$out1" -check "merge-ready.sh full output line passed through, not swallowed" bash -c 'printf "%s\n" "$1" | grep -qF "merge-ready: merged=0 skipped=0"' _ "$out1" -# census_out and feedback_out are captured into shell variables and re-printed -# via `printf '%s\n' "$census_out"` / `"$feedback_out"` (loop-tick.sh) — assert -# a BODY line from each fake fixture (not just the "N/4 ..." header banner -# above it) survives verbatim, so silently deleting either printf (which -# would swallow exactly the output a human needs to debug a wrong verdict) -# fails this test loudly. Mutation-checked: removing either printf line from -# loop-tick.sh makes the corresponding check below fail while all the header -# checks above stay green. -check "loop-census.sh full BODY line passed through, not swallowed" bash -c 'printf "%s\n" "$1" | grep -qF "cadence=IDLE cron=*/15 * * * *"' _ "$out1" -check "pr-feedback.sh full BODY line passed through, not swallowed" bash -c 'printf "%s\n" "$1" | grep -qF "9 feat/issue-9-x owner 2026-01-01T00:00:00Z"' _ "$out4" - -# --------------------------------------------------------------------------- -# 8. TTL self-heal: a lock for the SAME issue that's older than LOCK_TTL_SECONDS -# and STILL advance_ready (no branch ever showed up) must be treated as a -# crashed spawn — cleared and re-advanced — not kept forever the way a -# fresh same-issue lock correctly is (scenario 3). -# --------------------------------------------------------------------------- -dir8="$(new_fixture scenario8 'open_prs=0 -feedback_prs=0 -planned_issues=1 -issue=9 branch=none title=Fresh issue -advance_ready=9 -cadence=FAST cron=* * * * *' '')" -lock8="$dir8/../state/loop-advance.lock" -mkdir -p "$(dirname "$lock8")" -printf 'issue=9 ts=2020-01-01T00:00:00Z\n' > "$lock8" -out8="$(run_tick "$dir8")" -check "scenario 8 (TTL self-heal): stale same-issue lock past TTL is cleared and re-advanced" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=advance issue=9" ]' _ "$out8" -check "scenario 8: diagnostic cites a crashed spawn (TTL expiry), not just self-heal" bash -c 'printf "%s\n" "$1" | grep -q "crashed spawn"' _ "$out8" -check "scenario 8: lock file now has a FRESH ts, not the stale 2020 one" bash -c '! grep -q "2020-01-01" "$1"' _ "$lock8" - -# --------------------------------------------------------------------------- -# 9. Concurrent-tick TOCTOU (issue #81 re-review): two ticks fired back to -# back, before either has written the lock, must not BOTH pass the -# check-then-write and both emit action=advance for the same issue — the -# exact double-spawn bug #81 exists to kill. Fire them as real overlapping -# background processes against the SAME fixture/state dir; `flock` must -# serialize the read-check-write so exactly one advances and the other -# backs off having observed the first tick's lock. -# --------------------------------------------------------------------------- -dir9="$(new_fixture scenario9 'open_prs=0 -feedback_prs=0 -planned_issues=1 -issue=42 branch=none title=Concurrent thing -advance_ready=42 -cadence=FAST cron=* * * * *' '')" -outA_file="$work/scenario9.a.out" -outB_file="$work/scenario9.b.out" -run_tick "$dir9" > "$outA_file" & -pidA=$! -run_tick "$dir9" > "$outB_file" & -pidB=$! -wait "$pidA" -wait "$pidB" -verdictA="$(tail -1 "$outA_file")" -verdictB="$(tail -1 "$outB_file")" -advances=0 -[ "$verdictA" = "action=advance issue=42" ] && advances=$((advances + 1)) -[ "$verdictB" = "action=advance issue=42" ] && advances=$((advances + 1)) -check "scenario 9 (concurrent ticks): exactly ONE of two overlapping ticks advances issue=42" bash -c '[ "$1" -eq 1 ]' _ "$advances" -check "scenario 9: the other tick backs off with action=none instead of double-advancing" bash -c '[ "$1" = "action=none" ] || [ "$2" = "action=none" ]' _ "$verdictA" "$verdictB" - -# --------------------------------------------------------------------------- -# 10. Tick record (issue #85): every run appends exactly ONE JSONL line to -# CLAUDE_TICKS_FILE with the expected fields, and — critically — writing -# that record never disturbs the invariant that the verdict stays the -# LAST line of stdout (the daemon/tick parser reads the last line). -# --------------------------------------------------------------------------- -dir10="$(new_fixture scenario10 'open_prs=0 -feedback_prs=0 -planned_issues=1 -issue=55 branch=none title=Tick record thing -advance_ready=55 -cadence=FAST cron=* * * * *' '')" -ticks10="$work/scenario10-ticks.jsonl" -out10="$(CLAUDE_TICKS_FILE="$ticks10" run_tick "$dir10")" -check "scenario 10: verdict is still the LAST stdout line when tick recording is on" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=advance issue=55" ]' _ "$out10" -check "scenario 10: tick record file has exactly 1 line" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 1 ]' _ "$ticks10" -check "scenario 10: tick record is valid JSON with the expected fields" node -e ' - const fs = require("fs"); - const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); - if (obj.verdict !== "action=advance issue=55") throw new Error("verdict mismatch: " + JSON.stringify(obj)); - if (obj.action !== "advance") throw new Error("action mismatch: " + JSON.stringify(obj)); - if (obj.issue !== "55") throw new Error("issue mismatch: " + JSON.stringify(obj)); - if (obj.cadence !== "FAST") throw new Error("cadence mismatch: " + JSON.stringify(obj)); - if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(obj.ts)) throw new Error("ts not ISO-8601 UTC: " + obj.ts); -' "$ticks10" - -# action=none tick record: issue/pr must serialize as empty strings. -dir11="$(new_fixture scenario11 'open_prs=0 -feedback_prs=0 -planned_issues=0 -advance_ready=none -cadence=IDLE cron=*/15 * * * *' '')" -ticks11="$work/scenario11-ticks.jsonl" -out11="$(CLAUDE_TICKS_FILE="$ticks11" run_tick "$dir11")" -check "scenario 11: verdict is still the LAST stdout line for action=none" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=none" ]' _ "$out11" -check "scenario 11: action=none tick record parses issue/pr as empty" node -e ' - const fs = require("fs"); - const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); - if (obj.action !== "none") throw new Error("action mismatch: " + JSON.stringify(obj)); - if (obj.issue !== "" || obj.pr !== "") throw new Error("expected empty issue/pr, got " + JSON.stringify(obj)); -' "$ticks11" - -# action=feedback tick record: pr number captured, cadence round-trips. -dir12="$(new_fixture scenario12 'open_prs=0 -feedback_prs=1 -planned_issues=0 -advance_ready=none -cadence=WATCH cron=*/5 * * * *' "$(printf '3\tfeat/issue-3-x\towner\t2026-01-01T00:00:00Z')")" -ticks12="$work/scenario12-ticks.jsonl" -out12="$(CLAUDE_TICKS_FILE="$ticks12" run_tick "$dir12")" -check "scenario 12: verdict is still the LAST stdout line for action=feedback" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=feedback pr=3" ]' _ "$out12" -check "scenario 12: action=feedback tick record captures pr number and cadence" node -e ' - const fs = require("fs"); - const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); - if (obj.action !== "feedback") throw new Error("action mismatch: " + JSON.stringify(obj)); - if (obj.pr !== "3") throw new Error("pr mismatch: " + JSON.stringify(obj)); - if (obj.cadence !== "WATCH") throw new Error("cadence mismatch: " + JSON.stringify(obj)); -' "$ticks12" - -# --------------------------------------------------------------------------- -# 11. Rotation: LOOP_TICKS_MAX_LINES caps the tick log to the last N lines -# across repeated ticks (mirrors log-event.sh's rotation, log-event.test.sh -# lines ~87-115). -# -# Each iteration below gets a DISTINCT advance_ready/issue so every -# retained JSONL line is byte-DIFFERENT (not the same static fixture -# replayed N times) -- otherwise line-count + JSON-validity checks alone -# cannot tell "kept the last N" apart from e.g. "kept the FIRST N" or any -# other N lines. We assert the exact retained `issue` values, in order, -# mirroring log-event.test.sh's `want` array. -# --------------------------------------------------------------------------- -ticks13="$work/scenario13-ticks.jsonl" -for i in 1 2 3 4 5 6 7; do - dir13="$(new_fixture "scenario13-$i" "open_prs=0 -feedback_prs=0 -planned_issues=1 -issue=$i branch=none title=Rotation issue $i -advance_ready=$i -cadence=FAST cron=* * * * *" '')" - LOOP_TICKS_MAX_LINES=3 CLAUDE_TICKS_FILE="$ticks13" run_tick "$dir13" >/dev/null -done -check "scenario 13: rotation caps the tick log to exactly 3 lines" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 3 ]' _ "$ticks13" -check "scenario 13: every remaining line is still valid JSON after rotation" node -e ' - const fs = require("fs"); - const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); - for (const l of lines) JSON.parse(l); -' "$ticks13" -check "scenario 13: rotation keeps the LAST 3 ticks (issues 5,6,7), in order" node -e ' - const fs = require("fs"); - const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); - const issues = lines.map((l) => JSON.parse(l).issue); - const want = ["5", "6", "7"]; - if (JSON.stringify(issues) !== JSON.stringify(want)) { - throw new Error("got " + JSON.stringify(issues) + " want " + JSON.stringify(want)); - } -' "$ticks13" - -# Rotation boundary: writing EXACTLY LOOP_TICKS_MAX_LINES ticks must leave -# exactly that many lines -- i.e. rotation must not trigger (or drop -# anything) right at the boundary, only once the count exceeds the cap. -ticks13b="$work/scenario13b-ticks.jsonl" -for i in 1 2 3; do - dir13b="$(new_fixture "scenario13b-$i" "open_prs=0 -feedback_prs=0 -planned_issues=1 -issue=$i branch=none title=Boundary issue $i -advance_ready=$i -cadence=FAST cron=* * * * *" '')" - LOOP_TICKS_MAX_LINES=3 CLAUDE_TICKS_FILE="$ticks13b" run_tick "$dir13b" >/dev/null -done -check "scenario 13b: writing exactly LOOP_TICKS_MAX_LINES ticks leaves exactly that many lines" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 3 ]' _ "$ticks13b" -check "scenario 13b: boundary case keeps all 3 ticks in order (no spurious drop)" node -e ' - const fs = require("fs"); - const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); - const issues = lines.map((l) => JSON.parse(l).issue); - const want = ["1", "2", "3"]; - if (JSON.stringify(issues) !== JSON.stringify(want)) { - throw new Error("got " + JSON.stringify(issues) + " want " + JSON.stringify(want)); - } -' "$ticks13b" - -# --------------------------------------------------------------------------- -# 12. Best-effort: tick recording must never disturb the verdict or exit -# status, even when the ticks file cannot be written at all (its parent -# dir path collides with a plain file, so mkdir -p fails). -# --------------------------------------------------------------------------- -dir14="$(new_fixture scenario14 'open_prs=0 -feedback_prs=0 -planned_issues=0 -advance_ready=none -cadence=IDLE cron=*/15 * * * *' '')" -blocker="$work/scenario14-blocker" -: > "$blocker" # a plain FILE where the ticks file's PARENT DIR needs to be -out14="$(CLAUDE_TICKS_FILE="$blocker/loop-ticks.jsonl" run_tick "$dir14" 2>/dev/null)" -rc14=$? -check "scenario 14: tick-record write failure never changes the exit status" [ "$rc14" -eq 0 ] -check "scenario 14: verdict is still the LAST stdout line despite the write failure" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=none" ]' _ "$out14" - -echo "" -if [ "$fail" -eq 0 ]; then - echo "loop-tick.test.sh: PASS ($ok checks)" - exit 0 -else - echo "loop-tick.test.sh: FAIL (see FAIL lines above)" - exit 1 -fi diff --git a/.claude/scripts/merge-ready.sh b/.claude/scripts/merge-ready.sh index cee7e3f..59a3adc 100755 --- a/.claude/scripts/merge-ready.sh +++ b/.claude/scripts/merge-ready.sh @@ -1,32 +1,54 @@ #!/usr/bin/env bash -# merge-ready.sh — merge every open PR that the repo OWNER has approved and that -# is safe to merge, then delete the branch. The human Approve on GitHub is the -# ONLY gate; this script never approves anything — it just acts on approvals. +# merge-ready.sh — merge every open PR the repo OWNER has approved and that is +# safe to merge, then delete the branch. The human Approve on GitHub is the ONLY +# gate; this script never approves anything — it just acts on approvals. Pair it +# with notify-poll.sh in a cron to close the loop: review → approve → auto-merge. # # A PR is merged iff ALL hold: -# - base is the configured baseBranch (main), not a draft +# - base is the configured baseBranch (gates.json merge.baseBranch), not a draft # - latest review by the OWNER is APPROVED # - that approval was submitted at/after the PR's last commit (so it covers the -# current head — guards against new commits pushed after an approval, since a -# free private repo has no branch protection to auto-dismiss stale approvals) +# current head) — guards against commits pushed after an approval. A private +# repo on a free plan has no branch protection to auto-dismiss stale +# approvals, so we enforce "approval covers head" here instead. # - mergeable (no conflicts) -# - every CI check is green (no failing, none still pending) -# Anything else is SKIPPED with a reason. Output is JSON lines the cron summarizes. +# - every CI check is green (none failing, none still pending) +# Anything else is SKIPPED with a reason. Output is JSON lines a cron summarizes. # -# Runs as the bot (GH_BOT_TOKEN, a write collaborator) so it works headless in the -# notification cron. Merging is not approving, so the bot may merge bot-authored PRs. -# Pre-approved in .claude/settings.json as `bash .claude/scripts/merge-ready.sh`. +# Auth: ALL `gh` calls (listing, viewing, and the merge itself) run as the bot via +# bot-gh.sh — the bot is a write collaborator, so it can merge. The merge GATE is +# still the human OWNER's APPROVED review (detected below); running the merge as the +# bot does not change who authorized it. Repo is derived from the git remote; +# override with $1 (owner/repo). +# The approver defaults to the repo owner; override with $MERGE_APPROVER. +# Pre-approve `bash .claude/scripts/merge-ready.sh` in .claude/settings.json. + set -euo pipefail export PATH="$HOME/.local/bin:$PATH" -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -if [ -f "$root/.env" ]; then set -a; . "$root/.env"; set +a; fi -: "${GH_BOT_TOKEN:?GH_BOT_TOKEN not set — add it to .env (see .env.example)}" -export GH_TOKEN="$GH_BOT_TOKEN" +# 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" +# Route EVERY gh call (list/view/merge) through the bot identity (see bot-gh.sh). +gh() { bash "$script_dir/bot-gh.sh" "$@"; } +repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" + +# needs_human_flag/needs_human_clear (issue #99): the ONE shared label+notify +# seam for "PR ready-for-review" / "re-approve current head" -- see the +# per-PR loop below. Sourced AFTER the `gh` wrapper above so both functions +# call the bot identity; guarded (not a bare `&&`) so a missing file under +# `set -e` never aborts the script (see needs-human.sh's own header for why +# every statement in it is written the same defensive way). +# shellcheck source=needs-human.sh +if [ -f "$script_dir/needs-human.sh" ]; then . "$script_dir/needs-human.sh"; fi +owner="${MERGE_APPROVER:-${repo%%/*}}" # the approver whose APPROVED review authorizes a merge -repo="robercano/reDeploy" -owner="robercano" # the approver whose APPROVED review authorizes a merge -gates="$root/.claude/gates.json" +# Adapter file: honor GATES_FILE (the self-host loop points at self/gates.json), +# fall back to the shipped root adapter. Both merge.baseBranch and protectedPaths +# (issue #94 Layer 2) are read from it, so the self-adapter's permissive protectedPaths +# override applies when the loop runs self-hosted. +gates_rel="${GATES_FILE:-.claude/gates.json}" +case "$gates_rel" in /*) gates="$gates_rel";; *) gates="$root/$gates_rel";; esac base="$(node -e "try{const g=require('$gates');process.stdout.write((g.merge&&g.merge.baseBranch)||'main')}catch(e){process.stdout.write('main')}")" # Decide MERGE / SKIP:<reason> for one PR's JSON (read on stdin). @@ -66,33 +88,123 @@ decide() { ' "$base" "$owner" } -# After a merge, drop the local worktree + branch for the merged head branch. -# Implementers run in isolated worktrees under .claude/worktrees/; the remote -# branch is already gone via --delete-branch, so this clears the local residue -# so it doesn't pile up. Best-effort: locked/bind-mounted admin dirs may be -# "busy" in sandboxes — prune de-registers them and the env reclaims them later. -clean_worktree() { - local b="$1" wt="" - [ -n "$b" ] || return 0 - wt="$(git worktree list --porcelain 2>/dev/null | awk -v want="branch refs/heads/$b" ' - /^worktree /{p=substr($0,10)} $0==want{print p; exit}')" - if [ -n "$wt" ]; then - git worktree remove --force "$wt" 2>/dev/null && echo " worktree removed: $wt" - fi - git worktree prune 2>/dev/null || true - git branch -D "$b" 2>/dev/null || true +# protected_paths_check (issue #94 Layer 2): reads the adapter's protectedPaths +# globs and the PR's changed-file list (`.files[].path`, on stdin) and prints the +# protected path(s) the diff touches (comma-joined), or nothing. Empty/absent +# protectedPaths = disabled (prints nothing) — same empty-means-skip convention as +# `notify`. Deterministic (no LLM); "*" matches one path segment, "**" any depth. +protected_paths_check() { + node -e ' + const gates = process.argv[1]; + let globs = []; + try { const g = require(gates); if (Array.isArray(g.protectedPaths)) globs = g.protectedPaths; } catch (e) {} + if (!globs.length) process.exit(0); + let p; try { p = JSON.parse(require("fs").readFileSync(0, "utf8")); } catch (e) { process.exit(0); } + const files = (p.files || []).map(f => f && f.path).filter(Boolean); + function toRe(glob) { + let re = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++; } + else re += "[^/]*"; + } else if ("\\^$.|?+()[]{}".includes(c)) { re += "\\" + c; } + else re += c; + } + return new RegExp("^" + re + "$"); + } + const res = globs.map(toRe); + const hits = files.filter(f => res.some(r => r.test(f))); + if (hits.length) process.stdout.write([...new Set(hits)].join(", ")); + ' "$gates" } merged=0; skipped=0 for n in $(gh pr list -R "$repo" --base "$base" --state open --json number -q '.[].number'); do - data="$(gh pr view "$n" -R "$repo" --json number,title,isDraft,baseRefName,headRefName,mergeable,reviews,statusCheckRollup,commits)" + data="$(gh pr view "$n" -R "$repo" --json number,title,isDraft,baseRefName,headRefName,mergeable,reviews,statusCheckRollup,commits,files)" verdict="$(printf '%s' "$data" | decide)" title="$(printf '%s' "$data" | node -e 'process.stdout.write((JSON.parse(require("fs").readFileSync(0,"utf8")).title)||"")')" + head_branch="$(printf '%s' "$data" | node -e 'process.stdout.write((JSON.parse(require("fs").readFileSync(0,"utf8")).headRefName)||"")')" + + # Protected-paths guard (issue #94 Layer 2): even an owner-approved, CI-green PR + # must not auto-merge if its diff touches a path the adapter marks protected. + protected_hit="" + if [ "$verdict" = "MERGE" ]; then + protected_hit="$(printf '%s' "$data" | protected_paths_check)" + [ -n "$protected_hit" ] && verdict="SKIP:protected-paths" + fi + + # needs-human (issue #99): a PR is genuinely blocked on the OWNER for + # exactly two of decide()'s skip reasons -- no review submitted yet, or a + # stale approval that no longer covers the current head (new commits + # pushed since). Every other reason (draft/base mismatch/conflicts/CI + # pending-or-failing, and owner-review=CHANGES_REQUESTED -- that one is + # pr-feedback.sh's job to dispatch a bot fix for, not the owner's) is NOT + # an owner-blocking wait, so any earlier "ready for review" flag on this PR + # is cleared. Both calls are best-effort no-ops when the corresponding + # helper function isn't defined (needs-human.sh missing from a fixture). + case "$verdict" in + SKIP:no-owner-review|SKIP:approval-stale*) + if command -v needs_human_flag >/dev/null 2>&1; then + # Human-readable reason, not the raw "SKIP:..." verdict token (non- + # blocking re-review nit): only these two verdicts reach this branch, + # so a simple case is enough -- no need to reformat the token itself. + reason_text="not yet reviewed" + case "$verdict" in + SKIP:approval-stale*) reason_text="approval is stale -- please re-review the current head" ;; + esac + needs_human_flag "pr:$n" "pr-review" "low" \ + "PR #$n ready for your review" "$title ($reason_text)" + fi + ;; + SKIP:protected-paths) + if command -v needs_human_flag >/dev/null 2>&1; then + needs_human_flag "pr:$n" "protected-paths" "high" \ + "PR #$n touches protected paths -- human review required" \ + "$title: this PR's diff touches protected path(s): $protected_hit. Auto-merge is blocked by the protected-paths guard (issue #94 Layer 2). A human must review and merge it manually." + fi + ;; + *) + if command -v needs_human_clear >/dev/null 2>&1; then + needs_human_clear "pr:$n" "pr-review" + fi + ;; + esac + if [ "$verdict" = "MERGE" ]; then if gh pr merge "$n" -R "$repo" --merge --delete-branch >/dev/null 2>&1; then echo "{\"pr\":$n,\"action\":\"merged\",\"title\":\"$title\"}"; merged=$((merged+1)) - head="$(printf '%s' "$data" | node -e 'process.stdout.write((JSON.parse(require("fs").readFileSync(0,"utf8")).headRefName)||"")')" - clean_worktree "$head" + # needs-human (issue #99): the PR just merged -- the clearest possible + # "this block-on-owner condition just resolved" signal. Clear any + # needs-human flag on the PR itself (belt-and-suspenders; it's about to + # be closed anyway) AND on the issue it was cut from (feat/issue-N-* or + # fix/issue-N-*), since loop-tick.sh's attempt-budget/stall escalations + # both flag the ISSUE, not the PR. + if command -v needs_human_clear >/dev/null 2>&1; then + needs_human_clear "pr:$n" "pr-review" + needs_human_clear "pr:$n" "changes-requested" + merged_issue_num="$(printf '%s\n' "$head_branch" | sed -n 's/.*issue-\([0-9][0-9]*\).*/\1/p')" + if [ -n "$merged_issue_num" ]; then + needs_human_clear "issue:$merged_issue_num" "attempt-budget" + needs_human_clear "issue:$merged_issue_num" "stall" + fi + fi + # Auto-cleanup (issue #91): the merged branch's local worktree + local + # branch are now stale. worktree-cleanup.sh applies its OWN safety + # rails (worker-path naming, clean tree, fully merged into $base) and + # NEVER forces — a "skip" line from it is expected and fine, just + # tag it with the PR number and pass it through. + if [ -n "$head_branch" ]; then + while IFS= read -r cleanup_line; do + [ -z "$cleanup_line" ] && continue + printf '%s\n' "$cleanup_line" | node -e ' + const pr = process.argv[1]; + const obj = JSON.parse(require("fs").readFileSync(0,"utf8")); + obj.pr = Number(pr); + console.log(JSON.stringify(obj)); + ' "$n" + done < <(bash "$script_dir/worktree-cleanup.sh" "$base" "$head_branch") + fi else echo "{\"pr\":$n,\"action\":\"merge-failed\",\"title\":\"$title\"}" fi @@ -100,4 +212,120 @@ for n in $(gh pr list -R "$repo" --base "$base" --state open --json number -q '. echo "{\"pr\":$n,\"action\":\"skip\",\"reason\":\"${verdict#SKIP:}\",\"title\":\"$title\"}"; skipped=$((skipped+1)) fi done + +# Post-merge: fast-forward the LOCAL checkout to the freshly-merged base so the +# owner's terminal/IDE shows the latest code without a manual pull. Strictly safe: +# acts ONLY when the checkout is on the base branch with a clean tree, and only +# fast-forwards (never a merge commit, never a branch switch, never clobbers +# uncommitted work). Untracked sandbox device-node masks don't count as changes. +# Any obstacle -> skip with a reason; never force. See docs/HARDENING.md. +if [ "$merged" -gt 0 ]; then + wt="$(git rev-parse --show-toplevel 2>/dev/null || true)" + cur="$(git -C "${wt:-.}" symbolic-ref --quiet --short HEAD 2>/dev/null || echo DETACHED)" + # local_sync_ok (issue #175 review finding #1a): 1 iff the fast-forward + # below actually landed, i.e. the checkout was on $base, the WHOLE working + # tree was clean, and origin/$base fast-forwarded cleanly (so local $base + # is now verified in sync with origin/$base). The post-merge roadmap + # commit/push further below reuses this exact flag as its own precondition + # instead of re-deriving a weaker, file-scoped check -- see that block. + local_sync_ok=0 + if [ -z "$wt" ]; then + : + elif [ "$cur" != "$base" ]; then + echo "{\"local_sync\":\"skip\",\"reason\":\"checkout on '$cur', not '$base'\"}" + elif ! git -C "$wt" diff --quiet || ! git -C "$wt" diff --cached --quiet; then + echo "{\"local_sync\":\"skip\",\"reason\":\"working tree has tracked changes\"}" + elif git -C "$wt" fetch --quiet origin "$base" 2>/dev/null \ + && git -C "$wt" merge --ff-only -q "origin/$base" 2>/dev/null; then + echo "{\"local_sync\":\"ok\",\"branch\":\"$base\",\"head\":\"$(git -C "$wt" rev-parse --short HEAD)\"}" + local_sync_ok=1 + else + echo "{\"local_sync\":\"skip\",\"reason\":\"fetch or fast-forward failed (diverged/offline?)\"}" + fi + + # Post-merge roadmap regen (issue #175): docs/ROADMAP.md is a GENERATED + # snapshot of open milestones/issues/PRs (see roadmap.sh's own header + + # docs/USAGE.md's "Roadmap" section) — regenerating it right after a merge + # keeps it fresh without the owner remembering to re-run it by hand. + # + # STRICTLY best-effort and NON-FATAL: this whole block is wrapped in its own + # subshell + `|| true` so ANY failure inside it (missing roadmap.sh, a + # generator crash, no local git checkout, an unclean tree, an offline + # push) degrades to a `"roadmap_regen":"skip"` line on STDERR — it must + # NEVER cause merge-ready.sh to report a non-zero exit or unwind a merge + # that already succeeded. `[ -f "$script_dir/roadmap.sh" ]` also makes this + # a no-op when roadmap.sh isn't present at all (nothing to regenerate). + # + # Regeneration itself is invoked unconditionally (independent of the + # local_sync outcome above) so a stubbed/failing roadmap.sh is always + # exercised — see merge-ready.test.sh. The COMMIT+PUSH step, however, gates + # on `local_sync_ok` -- the SAME precondition local_sync itself required + # (checkout on $base, WHOLE working tree clean, origin/$base fast-forwarded + # in sync) -- not just a docs/ROADMAP.md-only diff: committing on top of + # unrelated WIP, or while $base is diverged/offline, would be unsafe. + # + # Change detection ignores the volatile footer line (issue #175 review + # finding #3): roadmap.sh's own footer always changes (timestamp + the + # generating commit SHA), so a raw file diff would treat every regen as a + # change and commit no-op spam straight onto $base. Comparing the committed + # vs regenerated content with that line stripped from both means a + # semantically-identical roadmap is correctly treated as "no changes" and + # never committed -- the footer itself is still written to disk unstripped. + # + # The commit/push themselves are plain `git` (the repo OWNER's auth, same + # as every other git operation in this script) — only roadmap.sh's OWN gh + # calls (issue/PR/milestone reads) go through bot-gh.sh. This is deliberate, + # not an oversight: bot-gh.sh exists so PRs are bot-authored (the owner is + # then free to approve them); a direct-to-$base commit has no PR and + # nothing for the owner to approve, so that approvability concern doesn't + # apply here -- using the owner's own git auth (already required for the + # ff-only local_sync above) is the correct choice, not a shortcut. + # + # On push failure (issue #175 review finding #1b): roll back with + # `git reset --hard origin/$base` so a rejected/offline push NEVER leaves a + # dangling local commit diverging $base from origin (which would otherwise + # wedge every future local_sync ff-only forever). Safe specifically because + # local_sync_ok guarantees the tree was clean and $base was on origin/$base + # immediately before this block ran, so resetting to origin/$base discards + # at most the regen commit just made here -- never real owner work. + if [ -f "$script_dir/roadmap.sh" ]; then + ( + set +e + regen_err="$(mktemp "${TMPDIR:-/tmp}/roadmap-regen.XXXXXX.err")" + if ! GATES_FILE="$gates_rel" bash "$script_dir/roadmap.sh" --write >/dev/null 2>"$regen_err"; then + echo "{\"roadmap_regen\":\"skip\",\"reason\":\"generator failed: $(tr '\n' ' ' <"$regen_err" | head -c 200)\"}" >&2 + rm -f "$regen_err" + exit 0 + fi + rm -f "$regen_err" + if [ "$local_sync_ok" -ne 1 ]; then + git -C "$wt" checkout -- docs/ROADMAP.md 2>/dev/null || true + echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"local $base not verified in sync with origin (see local_sync)\"}" + exit 0 + fi + old_content="$(git -C "$wt" show "HEAD:docs/ROADMAP.md" 2>/dev/null | grep -v '^_Generated ' || true)" + new_content="$(grep -v '^_Generated ' "$wt/docs/ROADMAP.md" 2>/dev/null || true)" + if [ "$old_content" = "$new_content" ]; then + # roadmap.sh --write still rewrote the file on disk (its footer + # timestamp + commit SHA always change), so even though there's + # nothing worth committing, the working tree must be restored to + # clean here -- otherwise local_sync's own clean-tree precondition + # (above) would trip on THIS file on the very next run and wedge + # roadmap regeneration off forever (issue #175 review finding #1, + # round 2). + git -C "$wt" checkout -- docs/ROADMAP.md 2>/dev/null || true + echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"no changes\"}" + exit 0 + fi + if git -C "$wt" add docs/ROADMAP.md \ + && git -C "$wt" commit -q -m "chore: regenerate docs/ROADMAP.md [skip ci]" \ + && git -C "$wt" push -q origin "HEAD:$base"; then + echo "{\"roadmap_regen\":\"generated\",\"committed\":true,\"branch\":\"$base\"}" + else + git -C "$wt" reset --hard "origin/$base" >/dev/null 2>&1 || true + echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"commit or push failed (rolled back)\"}" >&2 + fi + ) || echo "{\"roadmap_regen\":\"skip\",\"reason\":\"unexpected error\"}" >&2 + fi +fi echo "=== merge-ready: merged=$merged skipped=$skipped ===" diff --git a/.claude/scripts/notify-poll.sh b/.claude/scripts/notify-poll.sh index eb04c5f..8fc783b 100755 --- a/.claude/scripts/notify-poll.sh +++ b/.claude/scripts/notify-poll.sh @@ -1,20 +1,25 @@ #!/usr/bin/env bash -# GitHub notification poll for robercano/reDeploy: prints new issues, PR review -# comments, issue-comments on PRs, and PR reviews since the cursor, then -# advances the cursor. Designed to be invoked by the notification cron job — -# pre-approved in .claude/settings.json so it never blocks on a permission -# prompt. Output is JSON sections for the model to summarize. +# GitHub notification poll: prints new issues, PR review comments, +# issue-comments on PRs, and PR reviews since the last run, then advances the +# cursor. Intended to be invoked by a notification cron job / /loop. +# +# IMPORTANT: invoke this as `bash .claude/scripts/notify-poll.sh` and add that +# exact command to the settings.json permissions allow list. An inline compound +# command (loops, $(), redirects) never matches a permission rule, so a cron +# that polls inline will block on a permission prompt every firing. +# +# Repo is derived from the current git remote; override with $1 (owner/repo). set -euo pipefail -export PATH="$HOME/.local/bin:$PATH" -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# Authenticate as the bot (a read-capable collaborator) so the cron runs headless -# without a separate owner `gh auth login`. Same token merge-ready.sh uses. -if [ -f "$root/.env" ]; then set -a; . "$root/.env"; set +a; fi -: "${GH_BOT_TOKEN:?GH_BOT_TOKEN not set — add it to .env (see .env.example)}" -export GH_TOKEN="$GH_BOT_TOKEN" -repo="robercano/reDeploy" -state_dir="$root/.claude/state" +# 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" +# Route EVERY gh call through the bot identity (see bot-gh.sh). Defined before the +# first gh use below so the repo-derivation call already runs as the bot. +gh() { bash "$script_dir/bot-gh.sh" "$@"; } +repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +owner="${MERGE_APPROVER:-${repo%%/*}}" # the human whose APPROVED review gates a merge +state_dir="$root/.claude/state" # add .claude/state/ to .gitignore cursor_file="$state_dir/notify-cursor" mkdir -p "$state_dir" @@ -42,19 +47,19 @@ done # Standing status of EVERY open PR (cursor-independent): merge-readiness is a # state, not an event — an approval may have landed a tick ago and CI only just -# gone green. The cron uses this to decide which PRs need feedback addressed; +# gone green. A cron uses this to decide which PRs need feedback addressed; # merge-ready.sh acts on the approved+green ones. echo "=== open pr status ===" for n in $(gh pr list -R "$repo" --state open --json number -q '.[].number'); do gh pr view "$n" -R "$repo" --json number,title,author,baseRefName,isDraft,mergeable,reviews,statusCheckRollup \ - --jq '{ + --jq "{ pr: .number, title: .title, author: .author.login, base: .baseRefName, draft: .isDraft, mergeable: .mergeable, - ownerReview: ([.reviews[] | select(.author.login=="robercano")] | sort_by(.submittedAt) | last | .state // "none"), + ownerReview: ([.reviews[] | select(.author.login==\"$owner\")] | sort_by(.submittedAt) | last | .state // \"none\"), checks: ([.statusCheckRollup[]? | (.conclusion // .state)] | { - failing: (map(select(. == "FAILURE" or . == "ERROR" or . == "CANCELLED" or . == "TIMED_OUT")) | length), - pending: (map(select(. == "PENDING" or . == "QUEUED" or . == "IN_PROGRESS" or . == null)) | length), + failing: (map(select(. == \"FAILURE\" or . == \"ERROR\" or . == \"CANCELLED\" or . == \"TIMED_OUT\")) | length), + pending: (map(select(. == \"PENDING\" or . == \"QUEUED\" or . == \"IN_PROGRESS\" or . == null)) | length), total: length }) - }' + }" done echo "$now" > "$cursor_file" diff --git a/.claude/scripts/pr-feedback.sh b/.claude/scripts/pr-feedback.sh deleted file mode 100755 index 5159fba..0000000 --- a/.claude/scripts/pr-feedback.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# pr-feedback.sh — print open, bot-authored PRs that have UNADDRESSED "changes -# requested" feedback, so the notification cron can dispatch an implementer per PR -# to address it. Prints one TSV line per PR needing action: -# <number>\t<branch>\t<reviewer>\t<changes_requested_at> -# -# A PR is listed when its latest CHANGES_REQUESTED review is NEWER than the bot's -# last "<!-- claude-addressed -->" marker comment (so already-handled feedback is -# not re-dispatched even though GitHub keeps reviewDecision=CHANGES_REQUESTED until -# you re-review), AND it is not currently labeled `claude-addressing` (a guard so -# overlapping firings don't double-dispatch). The implementer posts the marker -# comment after pushing its fix, which advances the cursor past the request. -# -# Repo derived from the git remote; override with $1. Bot login via $BOT_LOGIN. -# Invoke as `bash .claude/scripts/pr-feedback.sh` (pre-approve that exact command). -set -euo 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" -# Route EVERY gh call through the bot identity (see bot-gh.sh). -gh() { bash "$script_dir/bot-gh.sh" "$@"; } -repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" -bot="${BOT_LOGIN:-robercano-ghbot}" -marker="<!-- claude-addressed -->" - -gh pr list -R "$repo" --state open \ - --json number,headRefName,author,labels \ - --jq '.[] | select(.author.login=="'"$bot"'") | [.number, .headRefName, ([.labels[].name]|join(","))] | @tsv' \ -| while IFS=$'\t' read -r num branch labels; do - case ",$labels," in *,claude-addressing,*) continue;; esac - - cr=$(gh api "repos/$repo/pulls/$num/reviews" \ - --jq '[.[]|select(.state=="CHANGES_REQUESTED")]|sort_by(.submitted_at)|last|select(.!=null)|"\(.submitted_at)\t\(.user.login)"' \ - 2>/dev/null || true) - if [ -z "$cr" ]; then continue; fi - tcr="${cr%%$'\t'*}" - reviewer="${cr#*$'\t'}" - - ta=$(gh api "repos/$repo/issues/$num/comments" \ - --jq '[.[]|select(.user.login=="'"$bot"'" and (.body|contains("'"$marker"'")))]|sort_by(.created_at)|last|.created_at // empty' \ - 2>/dev/null || true) - - if [ -z "$ta" ] || [[ "$tcr" > "$ta" ]]; then - printf '%s\t%s\t%s\t%s\n' "$num" "$branch" "$reviewer" "$tcr" - fi - done diff --git a/.claude/scripts/prepare-pr.sh b/.claude/scripts/prepare-pr.sh index b81ad7a..d3d03a9 100755 --- a/.claude/scripts/prepare-pr.sh +++ b/.claude/scripts/prepare-pr.sh @@ -1,7 +1,5 @@ #!/usr/bin/env bash # prepare-pr.sh [--phone] <pr-number> -# prepare-pr.sh --serve <pr-number> -# prepare-pr.sh --reset | --serve-main # Prepare a ready-to-run local checkout of an OPEN pull request so a human can # manually test it, WITHOUT touching the main working tree. Idempotent. # @@ -26,34 +24,13 @@ # printed at the end (humanTest.launchPhone instead of humanTest.launch) and # prints a caveat block, because launchPhone exposes the dev studio (and its # /api proxy) publicly over a cloudflared quick tunnel. -# -# --serve <pr-number>: runs the full default flow above (prepare the PR -# worktree), then repoints a REPO-LOCAL `.serve/active` symlink (NOT -# ~/.local/share/redeploy/active — that lived under $HOME, which is read-only -# in the Claude Code sandbox) at that worktree and touches the `.serve/reload` -# sentinel file. A systemd --user PATH UNIT (redeploy-app.path, installed on -# the host, watching that sentinel) is what actually restarts the redeploy-app -# service — systemd itself runs outside this script/sandbox, so it always has -# bus access even when this script doesn't. Flipping the symlink and touching -# the sentinel are both plain repo-local file operations, so the whole -# --serve/--reset flow is drivable from inside the sandbox. A direct -# `systemctl --user restart` is still attempted as a best-effort immediate -# fallback (useful from a normal terminal), but it is no longer required for -# --serve/--reset to succeed — see docs/ALWAYS-ON-TUNNEL.md. -# --reset / --serve-main: repoints `active` back at this repo's main checkout -# and touches the sentinel the same way. Does NOT require a PR number. -# If both --serve and --phone are given, --serve wins (see below). set -uo pipefail phone=0 -serve=0 -reset=0 rest=() for arg in "$@"; do case "$arg" in --phone) phone=1 ;; - --serve) serve=1 ;; - --reset|--serve-main) reset=1 ;; *) rest+=("$arg") ;; esac done @@ -61,77 +38,16 @@ if [ -n "${PHONE:-}" ] && [ "$PHONE" != "0" ]; then phone=1 fi -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -root="$(cd "$script_dir/../.." && pwd)" -cd "$root" - -# Stable symlink the always-on redeploy-app systemd --user service points its -# WorkingDirectory at (see docs/ALWAYS-ON-TUNNEL.md). --serve/--reset repoint -# it. REPO-LOCAL (not under $HOME) so it's writable from inside the Claude -# Code sandbox, where $HOME is read-only EROFS. "$root" is already the -# absolute repo root (computed above via script_dir), so this path is -# absolute and CWD-independent regardless of where this script is invoked from. -ACTIVE_LINK="$root/.serve/active" - -# Sentinel file touched on every --serve/--reset. A systemd --user PATH UNIT -# (redeploy-app.path, installed on the host, see deploy/always-on/systemd/) -# watches this file with PathModified= and triggers a restart of redeploy-app -# via redeploy-app-restart.service when it changes. This is THE primary -# restart mechanism now: touching a file is a plain filesystem write, so it -# works from inside the sandbox even when the systemd user bus is unreachable. -SERVE_SENTINEL="$root/.serve/reload" - -# Best-effort DIRECT restart, kept as an immediate fallback for anyone running -# this from a normal terminal (outside the sandbox) with bus access — but no -# longer required for --serve/--reset to succeed, since touching -# SERVE_SENTINEL above already queues the restart via redeploy-app.path. -# Guards failures instead of hard-failing: this script also runs in -# environments (CI, sandboxes) with no systemd user session at all. -restart_app_service() { - if ! command -v systemctl >/dev/null 2>&1; then - echo "ℹ️ systemctl not found here — that's fine: .serve/reload was touched, and" - echo " redeploy-app.path (running on the host) will restart redeploy-app for you." - return 0 - fi - if ! systemctl --user restart redeploy-app 2>/dev/null; then - echo "ℹ️ could not reach systemd --user from here (expected in the sandbox) — that's" - echo " fine: .serve/reload was touched, and redeploy-app.path (running on the host)" - echo " will restart redeploy-app for you." - return 0 - fi - echo "▶ restarted redeploy-app" -} - -# Print the hostname to open, if the operator has told us what it is. -print_hostname() { - if [ -n "${REDEPLOY_STUDIO_HOSTNAME:-}" ]; then - echo "Open: https://$REDEPLOY_STUDIO_HOSTNAME" - else - echo "(serving at your configured <STUDIO_HOSTNAME> — set REDEPLOY_STUDIO_HOSTNAME to have the" - echo " full URL printed here. See docs/ALWAYS-ON-TUNNEL.md.)" - fi -} - -# --reset / --serve-main: point the always-on studio back at the main checkout. -# Short-circuits BEFORE the PR-number requirement below — no PR number needed. -if [ "$reset" = "1" ]; then - mkdir -p "$(dirname "$ACTIVE_LINK")" - ln -sfn "$root" "$ACTIVE_LINK" - echo "▶ active -> $root (main checkout)" - mkdir -p "$root/.serve" - touch "$SERVE_SENTINEL" - restart_app_service - echo "" - echo "✅ Always-on studio now serving the main checkout." - print_hostname - exit 0 -fi - -pr="${rest[0]:?usage: prepare-pr.sh [--phone] <pr-number> | --serve <pr-number> | --reset}" +pr="${rest[0]:?usage: prepare-pr.sh [--phone] <pr-number>}" case "$pr" in ''|*[!0-9]*) echo "prepare-pr: PR number must be numeric (got '$pr')" >&2; exit 2 ;; esac +# 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" + gates="$root/.claude/gates.json" read_gate() { node -e "try{const g=require('$gates');process.stdout.write((g.humanTest&&g.humanTest['$1'])||'')}catch(e){process.stdout.write('')}" 2>/dev/null @@ -175,25 +91,6 @@ else echo "▶ humanTest.prepare not configured in gates.json — skipping deps/build" fi -# --serve wins over --phone: they are different modes, and serving through the -# always-on tunnel supersedes printing a local/quick-tunnel launch command. -if [ "$serve" = "1" ]; then - mkdir -p "$(dirname "$ACTIVE_LINK")" - ln -sfn "$wt" "$ACTIVE_LINK" - echo "▶ active -> $wt (PR #$pr)" - mkdir -p "$root/.serve" - touch "$SERVE_SENTINEL" - restart_app_service - echo "" - echo "✅ Always-on studio now serving PR #$pr ($sha)." - print_hostname - echo "" - echo "(The systemd redeploy-app.path unit picks up .serve/reload and restarts redeploy-app" - echo " for you — no need to run humanTest.launch yourself. To go back to the main checkout:" - echo " bash $script_dir/prepare-pr.sh --reset)" - exit 0 -fi - echo "" echo "✅ PR #$pr is ready to test. In your terminal:" echo " cd $wt" diff --git a/.claude/scripts/seed-issues.sh b/.claude/scripts/seed-issues.sh deleted file mode 100755 index f053b46..0000000 --- a/.claude/scripts/seed-issues.sh +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env bash -# seed-issues.sh — create the reDeploy ticket backlog as GitHub Issues + labels. -# Idempotent: existing labels are reused; an issue whose exact title already -# exists is skipped, so re-running won't create duplicates. -# -# Prereq: `gh auth login` completed for robercano/reDeploy. -# Usage: bash .claude/scripts/seed-issues.sh -set -uo pipefail - -command -v gh >/dev/null 2>&1 || { echo "gh not on PATH. Try: export PATH=\"\$HOME/.local/bin:\$PATH\""; exit 1; } -gh auth status >/dev/null 2>&1 || { echo "Not authenticated. Run: gh auth login"; exit 1; } - -REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || echo robercano/reDeploy)" -echo "Seeding issues into $REPO" - -# --- labels: name|color|description ----------------------------------------- -labels=( - "module:contracts|5319e7|Foundry sample/fixture contracts" - "module:core|1d76db|@redeploy/core deployment engine" - "module:config|0e8a16|@redeploy/config post-deploy configuration" - "module:verify|fbca04|@redeploy/verify verification" - "module:reader|d4c5f9|@redeploy/reader read-only state library" - "module:studio|c2e0c6|@redeploy/studio visual tool" - "type:infra|b60205|Repo-wide tooling/infra (touches root config)" - "type:feature|0052cc|Feature work scoped to one module" -) -for l in "${labels[@]}"; do - IFS='|' read -r name color desc <<<"$l" - gh label create "$name" --color "$color" --description "$desc" --force >/dev/null 2>&1 \ - && echo " label ✓ $name" || echo " label … $name (exists)" -done - -# --- helper: create an issue unless an exact-title match already exists ------ -existing="$(gh issue list --repo "$REPO" --state all --limit 500 --json title -q '.[].title' 2>/dev/null)" -mkissue() { - local title="$1" labels="$2" body="$3" - if grep -Fxq "$title" <<<"$existing"; then - echo " skip (exists): $title"; return - fi - gh issue create --repo "$REPO" --title "$title" --label "$labels" --body "$body" >/dev/null \ - && echo " created: $title" || echo " FAILED: $title" -} - -BOUND="**Module boundary:** stay within this module's path; do not edit other modules or root config (re-scope through the orchestrator if needed)." - -# ============================ TICKETS ======================================= - -mkissue "[infra] Wire TS lint (eslint) + Solidity solhint into the lint gate" "type:infra" \ -"Add eslint + typescript-eslint (flat config) for all TS packages and solhint for contracts. Update \`.claude/gates.json\` \`lint\` to run both alongside \`forge fmt --check\`. - -This is a repo-wide infra task (touches root config) — run it as a single bootstrap task, not a module worker. - -**Acceptance** -- \`pnpm -r lint\` runs eslint across packages/apps with zero warnings on the current skeleton. -- solhint runs over \`contracts/src\` and \`contracts/test\`. -- \`bash .claude/scripts/gate.sh lint\` passes and includes TS + Solidity linting." - -mkissue "[contracts] Realistic interconnected fixture contracts + forge-std tests" "module:contracts,type:feature" \ -"Replace the placeholder \`Registry\` with a small but realistic interconnected set (e.g. Token, Vault referencing Token, Registry wiring them) that exercises constructor links AND post-deploy configuration surface (setters / access-control roles). Install forge-std and migrate tests to \`forge-std/Test\`. - -$BOUND - -**Acceptance** -- forge-std installed; \`forge build\` and \`forge test\` green. -- Contracts expose both constructor dependencies and at least one post-deploy config step (setter/role). -- Tests cover the wiring and the config surface." - -mkissue "[core] Declarative deployment spec schema + validation" "module:core,type:feature" \ -"Define the TS types and a runtime validator (e.g. zod) for a deployment spec: contracts, constructor args, inter-contract links (output of A → constructor input of B), and explicit/implied ordering. - -$BOUND - -**Acceptance** -- Typed spec + validator with clear errors for cycles, missing refs, bad args. -- Unit tests covering valid specs and each failure mode; coverage ≥ 80%." - -mkissue "[core] Compile a deployment spec into a Hardhat Ignition module" "module:core,type:feature" \ -"Translate a validated spec into a Hardhat Ignition module (futures, \`m.contract\`, dependency wiring, ordering). Reuse Ignition's engine — don't reinvent ordering. - -$BOUND - -**Acceptance** -- Given a spec referencing the \`contracts\` fixtures, produce a valid Ignition module. -- Tests assert the generated module's futures/links/order match the spec." - -mkissue "[core] Idempotent resume via Ignition's journal" "module:core,type:feature" \ -"Expose a \`deploy()\` that runs the generated Ignition module and relies on Ignition's journal so an already-deployed contract is never re-deployed; a partial deployment resumes only the missing contracts. - -$BOUND - -**Acceptance** -- Against a local node (anvil/hardhat), running a partial deployment then re-running deploys only what's missing. -- Test simulates an interrupted deployment and asserts no re-deploys on resume." - -mkissue "[config] Declarative post-deployment configuration steps + schema" "module:config,type:feature" \ -"Define a typed, declarative schema for post-deploy configuration steps (e.g. setX, grantRole, wire A into B), referencing deployed contracts from a deployment. - -$BOUND - -**Acceptance** -- Typed config schema + validator (refs resolve to deployed contracts). -- Unit tests for valid configs and failure modes; coverage ≥ 80%." - -mkissue "[config] Resumable, idempotent config execution with per-step state" "module:config,type:feature" \ -"Execute config steps with per-step state tracking so re-running skips completed steps and resumes a broken/partial configuration. Mirror Ignition's journal idea for config state. - -$BOUND - -**Acceptance** -- Against a local node, interrupting mid-config then re-running completes only the remaining steps. -- Idempotent: a fully-applied config re-run is a no-op. Tested." - -mkissue "[reader] Read-only API for deployment + config state" "module:reader,type:feature" \ -"Provide a typed, read-only library that loads deployment state (addresses, constructor args, links) and per-step config status from the Ignition deployment dir / journal, and exposes it for external systems. - -$BOUND - -**Acceptance** -- Typed API returns contracts, addresses, links, and config-step status for a given deployment id. -- Tests run against a fixture deployment directory; coverage ≥ 80%." - -mkissue "[verify] Source/bytecode verification (Etherscan/Sourcify)" "module:verify,type:feature" \ -"Integrate source/bytecode verification for a deployment's contracts via Etherscan/Sourcify. - -$BOUND - -**Acceptance** -- A verify entry point that submits each deployed contract for verification. -- Tests mock the verifier API and assert correct payloads/handling of already-verified contracts." - -mkissue "[verify] On-chain configuration verification (drift detection)" "module:verify,type:feature" \ -"Assert that live on-chain configuration matches the declared config spec; report drift (expected vs actual) per step. - -$BOUND - -**Acceptance** -- Against a local node, a matching config verifies clean; a mutated value is reported as drift. -- Tests cover match and drift cases." - -mkissue "[studio] Scaffold studio app (React + Vite + React Flow)" "module:studio,type:feature" \ -"Replace the placeholder package with a React + Vite app using React Flow; wire \`build\`/\`test\`/\`typecheck\` scripts to match the gates; render an empty canvas. - -$BOUND - -**Acceptance** -- \`pnpm --filter @redeploy/studio build\` and \`test\` pass with the new toolchain. -- App renders an empty React Flow canvas; one component test." - -mkissue "[studio] Drag-and-drop authoring → reDeploy spec file" "module:studio,type:feature" \ -"Let users place contract nodes, connect an output to another contract's constructor/config input, edit config, and serialize the graph to a reDeploy spec file consumable by core/config. - -$BOUND - -**Acceptance** -- Connecting nodes and editing config produces a spec that validates against the core/config schemas. -- Round-trip test: graph → spec → (schema-valid)." - -mkissue "[studio] Deployment inspector view" "module:studio,type:feature" \ -"Load existing deployment + config state via \`@redeploy/reader\` and visualize contracts, links, and per-step config status. - -$BOUND - -**Acceptance** -- Given a fixture deployment, the inspector renders contracts, their links, and config status. -- Component test against the fixture." - -echo "Done." diff --git a/.claude/scripts/worktree.sh b/.claude/scripts/worktree.sh new file mode 100755 index 0000000..09c59c4 --- /dev/null +++ b/.claude/scripts/worktree.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# worktree.sh <setup|teardown> +# Runs the command stored at .worktree.<phase> in the adapter (gates.json) — the +# per-worktree lifecycle hook. `setup` bootstraps a freshly-created isolated +# worktree (install deps, `forge install`, link shared caches) so that EVERY gate +# is runnable in-worktree, not just in the main checkout; `teardown` runs before +# the worktree is removed (free caches, etc.). +# +# Empty/missing command => skip with exit 0 (so unconfigured repos don't block). +# Non-zero command exit => propagates (so a failed bootstrap surfaces, not hides). +# Mirrors gate.sh: honors the GATES_FILE override and resolves the repo root from +# this script's location — which, inside a worktree, IS the worktree root, so the +# hook installs into the worktree the caller is working in. +set -uo pipefail + +phase="${1:?usage: worktree.sh <setup|teardown>}" +case "$phase" in + setup|teardown) ;; + *) echo "worktree.sh: phase must be 'setup' or 'teardown' (got '$phase')"; exit 2 ;; +esac + +# 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" + +# On `setup`, link the main checkout's gitignored .env into the worktree (same +# pattern as prepare-pr.sh for test-pr worktrees): .env is never checked out, +# so gates and app code that read it fail in a fresh worktree for lack of +# credentials rather than real defects. Resolved from the CALLER's cwd, not +# $root — in the plugin-cache layout $root is the main checkout, while the +# implementer invoking this script stands inside its worktree. Best-effort and +# skip-if-present (a worktree-local .env wins); runs before the adapter checks +# below so unconfigured repos still get it. +if [ "$phase" = setup ]; then + wt="$(git rev-parse --show-toplevel 2>/dev/null || true)" + main="$(cd "${wt:-.}" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)" + if [ -n "$wt" ] && [ -n "$main" ] && [ "$main" != "$wt" ] && [ -f "$main/.env" ] && [ ! -e "$wt/.env" ]; then + ln -s "$main/.env" "$wt/.env" && echo "▶ linked $wt/.env → $main/.env" + fi +fi +# Which adapter to read. Defaults to the project adapter; set GATES_FILE to run a +# different one (e.g. GATES_FILE=self/gates.json). Relative paths resolve +# from the repo root. +gates_ref="${GATES_FILE:-.claude/gates.json}" +case "$gates_ref" in + /*) gates="$gates_ref" ;; + *) gates="$root/$gates_ref" ;; +esac + +if [ ! -f "$gates" ]; then + echo "worktree.sh: no $gates found — skipping '$phase'"; exit 0 +fi + +cmd="$(node -e "try{const g=require('$gates');process.stdout.write((g.worktree&&g.worktree['$phase'])||'')}catch(e){process.stdout.write('')}" 2>/dev/null)" + +if [ -z "$cmd" ]; then + echo "worktree.sh: '$phase' not configured in $(basename "$gates") — skipping"; exit 0 +fi + +echo "▶ worktree '$phase': $cmd" +cd "$root" && eval "$cmd" diff --git a/.claude/settings.json b/.claude/settings.json index 9b95508..c23d9c1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,29 +1,7 @@ { - "_README": "Generic harness settings. Hooks call .claude/scripts/gate.sh (commands live in .claude/gates.json). The 'allow' list pre-approves the agent command surface so parallel workers never block on prompts and the harness never has to persist a grant into this tracked file. Incidental runtime grants should land in .claude/settings.local.json (gitignored), not here.", + "_README": "Generic harness settings. Gate commands live in .claude/gates.json; the hooks that RUN them are registered by the orchestrator plugin itself (hooks/hooks.json), NOT here — see _hooks below. The 'allow' list pre-approves the agent command surface so parallel workers never block on prompts and the harness never has to persist a grant into this tracked file. Incidental runtime grants should land in .claude/settings.local.json (gitignored), not here.", - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/gate.sh\" lint" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/gate.sh\" test_affected" - } - ] - } - ] - }, + "_hooks": "REMOVED ON PURPOSE — do not re-add. This file used to declare PostToolUse(Edit|Write)->'gate.sh lint' and Stop->'gate.sh test_affected', scaffolded back when the plugin vendored .claude/scripts/ into consumer repos. Since issue #134 the plugin registers those SAME two hooks itself from hooks/hooks.json (via ${CLAUDE_PLUGIN_ROOT}/scripts/gate.sh), so keeping them here ran every gate TWICE per turn — two concurrent 'pnpm -r build' into the same dist/, which is what produced the racy 'Stop hook error: No stderr output'. The plugin's copies are the live ones; interactive-vs-loop scoping is handled in gates.json -> gates.test_affected, which both hooks funnel through.", "permissions": { "allow": [ diff --git a/.claude/systemd/claude-rc.service b/.claude/systemd/claude-rc.service index 46cd140..31c12e2 100644 --- a/.claude/systemd/claude-rc.service +++ b/.claude/systemd/claude-rc.service @@ -1,25 +1,28 @@ -# @orchestrator-managed claude-rc-service v5 -# systemd (user) unit TEMPLATE (issue #102). Installed into -# ~/.config/systemd/user/claude-rc-<repo-slug>.service by +# @orchestrator-managed claude-rc-service v6 +# systemd (user) unit TEMPLATE (issue #102; supervision fix issue #124). +# Installed into ~/.config/systemd/user/claude-rc-<repo-slug>.service by # `.claude/scripts/arm-loop.sh`, which substitutes the __PLACEHOLDER__ tokens -# below and runs `systemctl --user enable --now`. +# below and runs `systemctl --user enable` + `restart`. # -# `claude remote-control` has no documented headless mode, so this unit runs +# `claude remote-control` has no documented headless mode and renders a +# QR/status view that very likely needs a real TTY, so this unit still runs # it inside a DETACHED tmux session (`tmux new -d -s rc-<repo-slug>`): tmux -# gives systemd a stable child to track AND gives a human a local attach point -# (`tmux attach -t rc-<repo-slug>`) to see the QR/status or restart it by hand. +# gives a human a local attach point (`tmux attach -t rc-<repo-slug>`) to see +# the QR/status or restart it by hand, without gambling on TTY-independence. # -# 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-<repo-slug>`; restart with -# `systemctl --user restart claude-rc-<repo-slug>.service`. +# Type=simple + Restart=on-failure (issue #124, replacing the old +# Type=oneshot + RemainAfterExit=yes): ExecStart is now an INLINE supervisor +# that (1) starts the detached tmux session running `claude remote-control`, +# then (2) BLOCKS, polling every 10s whether the tmux session still exists +# and its pane is still alive (`tmux has-session` / `tmux list-panes ... +# #{pane_dead}`), and (3) exits non-zero the instant the session or its pane +# is gone (planner crash, `pkill -f remote-control`, a stray `tmux +# kill-session`, etc). Because ExecStart itself is now the long-lived +# process systemd actually tracks, a crash of the INNER `claude +# remote-control` process is no longer invisible to systemd: the supervisor +# loop notices within ~10s, ExecStart exits non-zero, the unit transitions to +# failed, and Restart=on-failure relaunches it automatically within +# RestartSec — no more permanently-"active" unit hiding a dead planner. # # 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 @@ -27,16 +30,17 @@ # 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) +Description=claude remote-control server for __REPO_SLUG__ (supervised tmux) After=network-online.target Wants=network-online.target [Service] -Type=oneshot -RemainAfterExit=yes +Type=simple +Restart=on-failure +RestartSec=10s WorkingDirectory=__WORKDIR__ ExecStartPre=-/usr/bin/tmux kill-session -t rc-__REPO_SLUG__ -ExecStart=/usr/bin/tmux new -d -s rc-__REPO_SLUG__ -c __WORKDIR__ /bin/bash -c 'export PATH=__CLAUDE_DIR__:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; exec __CLAUDE_BIN__ remote-control --spawn __SPAWN_MODE__ --name __RC_NAME__ --remote-control-session-name-prefix __REPO_SLUG__ --capacity __CAPACITY__ --permission-mode __PERMISSION_MODE__' +ExecStart=/bin/bash -c '/usr/bin/tmux new -d -s rc-__REPO_SLUG__ -c __WORKDIR__ /bin/bash -c "export PATH=__CLAUDE_DIR__:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; exec __CLAUDE_BIN__ remote-control --spawn __SPAWN_MODE__ --name __RC_NAME__ --remote-control-session-name-prefix __REPO_SLUG__ --capacity __CAPACITY__ --permission-mode __PERMISSION_MODE__"; while /usr/bin/tmux has-session -t rc-__REPO_SLUG__ 2>/dev/null; do dead="$(/usr/bin/tmux list-panes -t rc-__REPO_SLUG__ -F "#{pane_dead}" 2>/dev/null | head -n1)"; if [ "$dead" = "1" ]; then exit 1; fi; sleep 10; done; exit 1' ExecStop=-/usr/bin/tmux kill-session -t rc-__REPO_SLUG__ [Install] diff --git a/.claude/systemd/pr-loop.service b/.claude/systemd/pr-loop.service index 05d704e..138eab7 100644 --- a/.claude/systemd/pr-loop.service +++ b/.claude/systemd/pr-loop.service @@ -1,4 +1,4 @@ -# @orchestrator-managed pr-loop-service v1 +# @orchestrator-managed pr-loop-service v2 # systemd (user) unit TEMPLATE (issue #102). Installed into # ~/.config/systemd/user/pr-loop-<repo-slug>.service by # `.claude/scripts/arm-loop.sh`, which substitutes the __PLACEHOLDER__ tokens @@ -10,6 +10,21 @@ # 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. +# +# ExecStart resolution (issue #134): this repo no longer vendors `.claude/scripts/` into +# consumer repos, so `loop-daemon.sh` normally lives ONLY in the plugin cache, whose exact +# on-disk path (`~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/scripts/`) isn't +# knowable at ARM time (arm-loop.sh runs in a plain terminal outside Claude Code, where +# `${CLAUDE_PLUGIN_ROOT}` — a Claude-Code-session-only env var — is never set). ExecStart is +# therefore a small resolver, evaluated fresh on every unit (re)start, not a single baked +# path: (1) prefer `__WORKDIR__/.claude/scripts/loop-daemon.sh` if present — the +# self-hosting / legitimate-repo-tracked-copy case (see resolve-roots.sh); (2) else fall +# back to the newest `scripts/loop-daemon.sh` found under any installed plugin cache +# (`~/.claude/plugins/cache/*/orchestrator/*/scripts/loop-daemon.sh`, most-recently-modified +# wins, matching how `/plugin marketplace update` refreshes the cache in place). A future +# improvement would teach arm-loop.sh to resolve+bake the exact plugin cache path via a new +# placeholder instead of this runtime glob — tracked as a follow-up, not done here because +# arm-loop.sh is a separately-owned managed file (see MANIFEST.md). [Unit] Description=Cron-less autonomous PR loop daemon (__REPO_SLUG__) After=network-online.target @@ -20,7 +35,7 @@ Type=simple WorkingDirectory=__WORKDIR__ __GATES_ENV__ Environment=PATH=__PATH__ -ExecStart=/usr/bin/env bash __WORKDIR__/.claude/scripts/loop-daemon.sh +ExecStart=/usr/bin/env bash -c 'd="__WORKDIR__/.claude/scripts/loop-daemon.sh"; if [ ! -f "$d" ]; then d="$(ls -1t "$HOME"/.claude/plugins/cache/*/orchestrator/*/scripts/loop-daemon.sh 2>/dev/null | head -1)"; fi; if [ -z "$d" ] || [ ! -f "$d" ]; then echo "pr-loop.service: could not find loop-daemon.sh — neither __WORKDIR__/.claude/scripts/loop-daemon.sh nor a plugin cache copy exist; run /orchestrator:setup or enable the orchestrator plugin, then re-run arm-loop.sh" >&2; exit 1; fi; exec bash "$d"' Restart=always RestartSec=15 StandardOutput=journal diff --git a/apps/studio/src/manifest/contracts.generated.json b/apps/studio/src/manifest/contracts.generated.json index b46278b..c750f24 100644 --- a/apps/studio/src/manifest/contracts.generated.json +++ b/apps/studio/src/manifest/contracts.generated.json @@ -14,7 +14,10 @@ "inheritance": [ "Overloaded", "AccessControl", - "IAccessControl" + "ERC165", + "IERC165", + "IAccessControl", + "Context" ], "functions": [ { @@ -154,7 +157,8 @@ "inheritance": [ "PriceOracle", "AggregatorV3Interface", - "Ownable" + "Ownable", + "Context" ], "functions": [ { @@ -821,7 +825,12 @@ "inheritance": [ "VaultERC4626", "ERC4626", - "IERC4626" + "IERC4626", + "ERC20", + "IERC20Errors", + "IERC20Metadata", + "IERC20", + "Context" ], "functions": [ { @@ -1050,6 +1059,107 @@ } ], "stateMutability": "nonpayable" + }, + { + "name": "name", + "signature": "name()", + "declaredIn": "ERC20", + "inputs": [], + "stateMutability": "view" + }, + { + "name": "symbol", + "signature": "symbol()", + "declaredIn": "ERC20", + "inputs": [], + "stateMutability": "view" + }, + { + "name": "totalSupply", + "signature": "totalSupply()", + "declaredIn": "ERC20", + "inputs": [], + "stateMutability": "view" + }, + { + "name": "balanceOf", + "signature": "balanceOf(address)", + "declaredIn": "ERC20", + "inputs": [ + { + "name": "account", + "type": "address" + } + ], + "stateMutability": "view" + }, + { + "name": "transfer", + "signature": "transfer(address,uint256)", + "declaredIn": "ERC20", + "inputs": [ + { + "name": "to", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "name": "allowance", + "signature": "allowance(address,address)", + "declaredIn": "ERC20", + "inputs": [ + { + "name": "owner", + "type": "address" + }, + { + "name": "spender", + "type": "address" + } + ], + "stateMutability": "view" + }, + { + "name": "approve", + "signature": "approve(address,uint256)", + "declaredIn": "ERC20", + "inputs": [ + { + "name": "spender", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "name": "transferFrom", + "signature": "transferFrom(address,address,uint256)", + "declaredIn": "ERC20", + "inputs": [ + { + "name": "from", + "type": "address" + }, + { + "name": "to", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + } + ], + "stateMutability": "nonpayable" } ] } diff --git a/package.json b/package.json index 210e223..08df2a4 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,5 @@ "eslint": "^9.39.4", "typescript-eslint": "^8.61.0", "solhint": "^5.2.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e5a073..b697b23 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,10 @@ packages: - "packages/*" - "apps/*" + +# Moved here from package.json's "pnpm" field: pnpm 10.x no longer reads that +# field and silently ignored it ("[WARN] The 'pnpm' field in package.json is no +# longer read by pnpm"), which meant esbuild's postinstall build script was no +# longer allowlisted. See https://pnpm.io/settings +onlyBuiltDependencies: + - esbuild