diff --git a/.gitignore b/.gitignore index 2badc8fb..d4baeb50 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ tests/genai/.genai_cache/ # Activity logs (local-only, generated by session_activity_logger) .claude/logs/ +# Headless run logs (drain-backlog.sh / triage-and-implement.sh) +logs/drain/ +logs/scheduled/ + # Python venv/ *.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index b28dfeee..be05a0ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## [Unreleased] ### Added +- **`/implement` pre-flight check — fast-fail on already-merged issues** (Issue #936): `/implement --issues #N` now exits immediately with `BLOCKED` + `exit 1` when the target issue is already CLOSED or when any of the last 20 commits references `#N`. Previously the full pipeline ran to completion before discovering the issue was already done, wasting up to 10 minutes per occurrence. Two independent checks fire: (1) issue state check via the already-fetched `gh issue view` response (free; skipped silently when `gh` is unavailable per AC #5), (2) recent-commit grep via `git log --oneline -n 20 --grep "#N\b"` (always runs, even without `gh`). Pass `--force` to bypass both checks for intentional re-implementation of a closed issue. Free-text invocations without an issue reference are unaffected. `docs/PIPELINE-MODES.md` Mode Selection table updated to document the pre-flight behavior and `--force` override. 4 new structural regression tests in `tests/unit/commands/test_implement_preflight_issue_check.py`. + - **`install.sh --uninstall` — shell-only uninstall path** (Issue #951): Previously, the only documented removal path was `/sync --uninstall` (Python orchestrator), which requires a working Claude CLI. When Claude Code itself is broken or hooks are bricked, `/sync` cannot run. `install.sh` now accepts `--uninstall` (remove hooks and global registration), `--dry-run` (preview plan without mutating anything), and `--repos ` (also strip autonomous-dev hooks from named per-repo `.claude/settings.json` files). Two companion scripts — `plugins/autonomous-dev/scripts/uninstall_strip_repo_hooks.py` and `plugins/autonomous-dev/scripts/uninstall_unregister_plugin.py` — handle the per-repo hook stripping and plugin-registry cleanup respectively; both are self-contained (no imports from the plugin's lib tree) so they work even in a partially-broken install. A timestamped backup is created at `~/.claude/backups/uninstall-YYYYMMDD-HHMMSS/` before any mutation. `plugins/autonomous-dev/docs/TROUBLESHOOTING.md` gains an "Uninstalling autonomous-dev" section with a comparison table. `README.md` Uninstall section updated to document both paths. 15 new tests across `tests/unit/scripts/test_uninstall_strip_repo_hooks.py` (6), `tests/unit/scripts/test_uninstall_unregister_plugin.py` (5), and `tests/integration/test_install_sh_uninstall.py` (4). ### Security diff --git a/docs/PIPELINE-MODES.md b/docs/PIPELINE-MODES.md index 8f93aece..8262624e 100644 --- a/docs/PIPELINE-MODES.md +++ b/docs/PIPELINE-MODES.md @@ -21,6 +21,8 @@ covers: | **Batch (issues)** | `--issues ` | Process GitHub issues with auto-worktree per issue | | **Resume** | `--resume ` | Recover from auto-compact / crash mid-pipeline | +**Pre-flight check (Issue #936)**: When ARGUMENTS contains a single issue reference (`#N`), STEP 0 exits with `BLOCKED` and `exit 1` if the issue is already CLOSED or if a recent commit (last 20) references `#N`. This prevents burning pipeline time on already-merged work. Pass `--force` to skip the check (rare: intentional re-implementation of a closed issue). Free-text invocations (no `#N`) and `gh`-unavailable cases are unaffected. + **Auto-detection**: If no mode flag is given, `/implement` scans the feature description for signals: - Fix keywords (`failing test`, `broken test`, `flaky test`) → suggests `--fix` - Light keywords or file paths (`*.md`, `*.json`, `*.yaml`, docs-only) not matching security patterns → suggests `--light` diff --git a/plugins/autonomous-dev/commands/implement.md b/plugins/autonomous-dev/commands/implement.md index 14ade353..41489dc1 100644 --- a/plugins/autonomous-dev/commands/implement.md +++ b/plugins/autonomous-dev/commands/implement.md @@ -212,16 +212,39 @@ If ARGUMENTS contains an issue reference (`#NNN` or issue number), fetch the iss ```bash ISSUE_NUMBER=$(echo "ARGUMENTS" | grep -oE '#?([0-9]+)' | head -1 | tr -d '#') if [ -n "$ISSUE_NUMBER" ]; then - ISSUE_DATA=$(gh issue view "$ISSUE_NUMBER" --json title,body 2>/dev/null) + ISSUE_DATA=$(gh issue view "$ISSUE_NUMBER" --json title,body,state 2>/dev/null) if [ $? -eq 0 ]; then ISSUE_TITLE=$(echo "$ISSUE_DATA" | python3 -c "import sys,json; print(json.load(sys.stdin).get('title',''))") ISSUE_BODY=$(echo "$ISSUE_DATA" | python3 -c "import sys,json; print(json.load(sys.stdin).get('body',''))") + ISSUE_STATE=$(echo "$ISSUE_DATA" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))") fi fi ``` Store `ISSUE_BODY` and `ISSUE_TITLE` as pipeline context. If `gh issue view` fails, proceed without issue body (ISSUE_BODY remains empty). Do NOT block the pipeline on fetch failure. +**Pre-flight: skip already-merged or already-addressed issues** (Issue #936): + +```bash +# Pre-flight: skip if issue is already closed or recently referenced in commits. +# Issue #936: prevent burning pipeline time on already-merged work. +if [ -n "$ISSUE_NUMBER" ] && ! echo "ARGUMENTS" | grep -q -- "--force"; then + # Check 1: issue state (free — uses already-fetched ISSUE_STATE; AC #5: gh failures leave ISSUE_STATE empty, skip silently) + if [ -n "$ISSUE_STATE" ] && [ "$ISSUE_STATE" != "OPEN" ]; then + echo "BLOCKED: Issue #${ISSUE_NUMBER} is ${ISSUE_STATE}, not OPEN. Use --force to override (rare: re-implement intentionally)." + exit 1 + fi + # Check 2: recent commit references (always runs, even if gh unavailable; AC #5) + RECENT_FIX=$(git log --oneline -n 20 --grep "#${ISSUE_NUMBER}\b" 2>/dev/null | head -3) + if [ -n "$RECENT_FIX" ]; then + echo "BLOCKED: Issue #${ISSUE_NUMBER} appears already addressed in recent commits:" + echo "$RECENT_FIX" + echo "Use --force to override." + exit 1 + fi +fi +``` + Activate pipeline state: ```bash # Garbage-collect stale state files from prior crashed runs (Issue #1048) diff --git a/scripts/drain-all.sh b/scripts/drain-all.sh new file mode 100644 index 00000000..e7ff1418 --- /dev/null +++ b/scripts/drain-all.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Serial single-issue drain of the open-issue backlog. +# +# Lists open issues, excludes epics + already-PR'd, runs /implement N for each +# in headless mode with full harness. Resumable: re-running picks up where it +# left off by skipping issues already attempted. +# +# Usage: +# bash scripts/drain-all.sh # drain everything +# bash scripts/drain-all.sh --phase1 # only the top auto-improvement cluster (~36 issues) +# bash scripts/drain-all.sh --dry # print queue, don't run +# bash scripts/drain-all.sh --label LABEL # filter by GH label +# bash scripts/drain-all.sh --limit 10 # stop after N issues +# +# Background-friendly: run with `nohup bash scripts/drain-all.sh &` and tail +# logs/drain-all/progress.log to watch. + +set -euo pipefail +cd "$(dirname "$0")/.." + +LOG_DIR="logs/drain-all" +STATE_DIR="$LOG_DIR/state" +PROGRESS_LOG="$LOG_DIR/progress.log" +mkdir -p "$STATE_DIR" + +REPO="akaszubski/autonomous-dev" +DRY=0 +PHASE1=0 +LABEL_FILTER="" +LIMIT=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry) DRY=1; shift ;; + --phase1) PHASE1=1; shift ;; + --label) LABEL_FILTER="$2"; shift 2 ;; + --limit) LIMIT="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + -h|--help) sed -n '1,20p' "$0"; exit 0 ;; + *) echo "unknown flag: $1"; exit 2 ;; + esac +done + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$PROGRESS_LOG"; } + +# Build the queue. +QUEUE_FILE="$LOG_DIR/queue.txt" + +if (( PHASE1 )); then + log "Phase 1: using /triage top cluster" + python3 -c " +import sys, os, json +for _p in ('.claude/lib', 'plugins/autonomous-dev/lib', os.path.expanduser('~/.claude/lib')): + if os.path.isdir(_p): + sys.path.insert(0, _p); break +from issue_triage_analyzer import main +import io, contextlib +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + main(['--auto-improvement', '--repo', '$REPO', '--json']) +data = json.loads(buf.getvalue()) +clusters = sorted(data, key=lambda c: -c['rank_score']) +print('\n'.join(str(n) for n in clusters[0]['issue_numbers'])) +" > "$QUEUE_FILE" +else + log "Full drain: listing all open issues (excluding epics + PR-linked)" + GH_ARGS=(--repo "$REPO" --state open --limit 300 --json number,labels,closedByPullRequestsReferences) + if [[ -n "$LABEL_FILTER" ]]; then GH_ARGS+=(--label "$LABEL_FILTER"); fi + gh issue list "${GH_ARGS[@]}" \ + --jq '.[] | select((.labels | map(.name) | contains(["epic"])) | not) | select((.closedByPullRequestsReferences | length) == 0) | .number' \ + | sort -n > "$QUEUE_FILE" +fi + +TOTAL=$(wc -l < "$QUEUE_FILE" | tr -d ' ') +log "Queue: $TOTAL issues" + +if (( LIMIT > 0 )) && (( LIMIT < TOTAL )); then + head -n "$LIMIT" "$QUEUE_FILE" > "$QUEUE_FILE.limited" && mv "$QUEUE_FILE.limited" "$QUEUE_FILE" + TOTAL="$LIMIT" + log "Limited to first $LIMIT" +fi + +if (( DRY )); then + log "DRY run — queue contents:" + cat "$QUEUE_FILE" + exit 0 +fi + +# Pre-flight checks. +if ! git diff --quiet || ! git diff --cached --quiet; then + log "ABORT: working tree dirty" + git status --short + exit 1 +fi + +if ! command -v claude >/dev/null; then + log "ABORT: claude CLI not on PATH" + exit 1 +fi + +# Drain loop. +i=0 +DONE=0 +FAILED=0 +SKIPPED=0 + +while read -r n; do + i=$((i+1)) + marker="$STATE_DIR/done-$n" + fail_marker="$STATE_DIR/failed-$n" + + if [[ -f "$marker" ]]; then + SKIPPED=$((SKIPPED+1)) + log "[$i/$TOTAL] #$n SKIP (already done)" + continue + fi + if [[ -f "$fail_marker" ]]; then + SKIPPED=$((SKIPPED+1)) + log "[$i/$TOTAL] #$n SKIP (previously failed; rm $fail_marker to retry)" + continue + fi + + log "[$i/$TOTAL] #$n START" + ts="$(date +%Y%m%d-%H%M%S)" + events="$LOG_DIR/${ts}-${n}.events.json" + + # Refresh state: working tree must be clean before each iteration. + if ! git diff --quiet || ! git diff --cached --quiet; then + log " abort: tree became dirty between iterations — stopping" + exit 1 + fi + + rm -f /tmp/implement_pipeline_state.json /tmp/implement_pipeline_state.lock 2>/dev/null || true + + if claude \ + --print \ + --permission-mode acceptEdits \ + --output-format stream-json \ + --include-hook-events \ + --verbose \ + --name "drain-all-${n}" \ + --setting-sources user,project,local \ + "/implement $n" \ + > "$events" 2>>"$PROGRESS_LOG"; then + touch "$marker" + DONE=$((DONE+1)) + log "[$i/$TOTAL] #$n OK" + else + touch "$fail_marker" + FAILED=$((FAILED+1)) + log "[$i/$TOTAL] #$n FAIL (events: $events)" + fi + + log " progress: $DONE done, $FAILED failed, $SKIPPED skipped, $((TOTAL - i)) remaining" +done < "$QUEUE_FILE" + +log "===" +log "Drain complete. done=$DONE failed=$FAILED skipped=$SKIPPED total=$TOTAL" +log "Failed markers in $STATE_DIR/failed-*" +log "Retry failures by removing markers: rm $STATE_DIR/failed-*" diff --git a/scripts/drain-backlog.sh b/scripts/drain-backlog.sh new file mode 100644 index 00000000..23aebcf8 --- /dev/null +++ b/scripts/drain-backlog.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Headless drainer for /implement --batch — uses the full harness. +# +# Each batch runs in its own `claude -p` process: fresh context, full hook stack, +# all agents, all enforcement. No --bare, no dangerously-skip-permissions. +# Hooks decide what's allowed; we just don't pause for permission prompts on edits. +# +# Usage: +# bash scripts/drain-backlog.sh # run all batches in order +# bash scripts/drain-backlog.sh --dry # print plan only +# bash scripts/drain-backlog.sh --from 5 # resume from batch index 5 +# bash scripts/drain-backlog.sh --only m0 # run a single named batch +# bash scripts/drain-backlog.sh --budget 25 # USD cap per batch (default 50) +# +# Output: +# logs/drain/--.log human-readable session log +# logs/drain/--.events.json stream-json event stream +# logs/drain/state.json resume marker + +set -euo pipefail + +cd "$(dirname "$0")/.." + +LOG_DIR="logs/drain" +STATE_FILE="$LOG_DIR/state.json" +mkdir -p "$LOG_DIR" + +# Ordered batches: name:csv-issues. Security first, big epics excluded. +BATCHES=( + "security:958,1040,1111,918,984,978,1018" + "ordering:935,957,936,940,973,1090,1106,1107,1109,1110" + "hook-fps:1001,1002,1031,1032,1038,917,1055" + "coordinator:980,987,988,990,1010,1095" + "plan-critic:903,1006,1073,920,873" + "install:945,952,894,1036,895,977,1108" + "m0:1045,1046,1047,1048" + "m2:1058,1059,1060,1014,1015,961,962,963" + "m3:974,975,976,1025,1027" + "m4m5:964,1022,1026,1044" + "periodic:1099,1100" + "intent:1070,1072,1074,1076,1078,1079" + "refactor:1000,1013,1016,1034" + "doc-drift:900,1061,915" + "misc:924,931,932,981,983,1009,955,956,959,965,912,913,914,916" + "new-feat:909,910,911,979" +) + +DRY=0 +FROM=0 +ONLY="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dry) DRY=1; shift ;; + --from) FROM="$2"; shift 2 ;; + --only) ONLY="$2"; shift 2 ;; + --budget) shift 2 ;; # accepted but ignored — subscription auth, no $ cap + -h|--help) sed -n '1,30p' "$0"; exit 0 ;; + *) echo "unknown flag: $1"; exit 2 ;; + esac +done + +# Pre-flight: working tree must be clean (harness commits per batch). +if [[ -z "${SKIP_GIT_CHECK:-}" ]] && ! git diff --quiet || ! git diff --cached --quiet; then + echo "ERROR: working tree dirty. Commit/stash first, or set SKIP_GIT_CHECK=1." + git status --short + exit 1 +fi + +# Pre-flight: plugin must be loaded. +if ! claude plugin list 2>/dev/null | grep -q autonomous-dev; then + echo "WARN: autonomous-dev plugin not in 'claude plugin list'. Hooks may not fire." +fi + +run_batch() { + local idx="$1" name="$2" issues="$3" + local ts; ts="$(date +%Y%m%d-%H%M%S)" + local base="$LOG_DIR/${ts}-${idx}-${name}" + local log="${base}.log" + local events="${base}.events.json" + + echo "[$idx/${#BATCHES[@]}] batch=$name issues=$issues -> $log" + + # Clear any stale pipeline state from a prior crashed run. + rm -f /tmp/implement_pipeline_state.json /tmp/implement_pipeline_state.lock 2>/dev/null || true + + # Run the harness via headless claude. Hooks + agents + gates all active. + # --permission-mode acceptEdits: auto-approve file edits BUT hook decisions still block. + # --output-format stream-json + --include-hook-events: full diagnostic trail. + claude \ + --print \ + --permission-mode acceptEdits \ + --output-format stream-json \ + --include-hook-events \ + --verbose \ + --name "drain-${name}-${ts}" \ + --setting-sources user,project,local \ + "/implement --batch $issues" \ + > "$events" 2> "$log" || { + echo " batch $name FAILED — see $log" + jq -n --arg name "$name" --arg ts "$ts" --arg log "$log" --arg status failed \ + '{ts:$ts,name:$name,status:$status,log:$log}' \ + >> "$LOG_DIR/state.json" + return 0 # don't abort the queue + } + + # Extract last result line for summary. + jq -n --arg name "$name" --arg ts "$ts" --arg log "$log" --arg status ok \ + '{ts:$ts,name:$name,status:$status,log:$log}' \ + >> "$LOG_DIR/state.json" + echo " ok" +} + +i=0 +for entry in "${BATCHES[@]}"; do + i=$((i+1)) + name="${entry%%:*}" + issues="${entry#*:}" + + if [[ -n "$ONLY" && "$ONLY" != "$name" ]]; then continue; fi + if (( i <= FROM )); then continue; fi + + if (( DRY )); then + echo "[$i/${#BATCHES[@]}] DRY: claude -p '/implement --batch $issues'" + continue + fi + + run_batch "$i" "$name" "$issues" +done + +echo +echo "Done. Logs: $LOG_DIR/ State: $STATE_FILE" diff --git a/scripts/launchd/README.md b/scripts/launchd/README.md new file mode 100644 index 00000000..b2c293e3 --- /dev/null +++ b/scripts/launchd/README.md @@ -0,0 +1,76 @@ +# Scheduled triage-and-implement on macOS + +Two ways to wire `scripts/triage-and-implement.sh` to a daily 02:30 local run. + +## Option A — launchd (recommended) + +Native macOS. Survives reboots. No logged-in Terminal required. + +```bash +# Install +cp scripts/launchd/com.autonomous-dev.triage.plist ~/Library/LaunchAgents/ +launchctl load ~/Library/LaunchAgents/com.autonomous-dev.triage.plist + +# Verify it's loaded +launchctl list | grep autonomous-dev + +# Run once manually (useful for testing the wiring) +launchctl start com.autonomous-dev.triage + +# Tail logs +tail -f logs/scheduled/launchd.{out,err}.log + +# Disable temporarily +launchctl unload ~/Library/LaunchAgents/com.autonomous-dev.triage.plist + +# Permanently remove +launchctl unload ~/Library/LaunchAgents/com.autonomous-dev.triage.plist +rm ~/Library/LaunchAgents/com.autonomous-dev.triage.plist +``` + +The schedule is `02:30` system-local (in `StartCalendarInterval`). Edit those fields to change it; `launchctl unload && load` to apply. + +## Option B — crontab + +Simpler but more fragile on macOS (Catalina+ blocks `cron` from many paths unless you grant Full Disk Access in System Settings → Privacy & Security → Full Disk Access → `/usr/sbin/cron`). + +```bash +crontab -e +``` + +Append: + +``` +30 2 * * * cd /Users/akaszubski/Dev/autonomous-dev && /bin/bash scripts/triage-and-implement.sh --max-issues 6 --budget 25 >> logs/scheduled/cron.log 2>&1 +``` + +Save. Verify: + +```bash +crontab -l +``` + +## What the scheduled run does + +1. Aborts if working tree is dirty (cron-safe; never destroys uncommitted work). +2. Runs `/triage --auto-improvement --json` to rank the queue. +3. Picks up to 6 issues from the top cluster. +4. Filters out issues already closed or with open PRs. +5. Runs `claude -p "/implement --batch "` headless — full harness, all hooks, all agents, all gates. `--permission-mode acceptEdits` only (no `--bare`, no `dangerously-skip-permissions`). +6. /implement commits and (if STEP 13 is configured) pushes. +7. Logs to `logs/scheduled/.{log,events.json,picked.txt}`. + +Budget cap per run: $25. Adjust in the plist or crontab line. + +## Sanity checks before enabling + +```bash +# Triage runs without Claude +python3 -c "import sys, os; sys.path.insert(0, 'plugins/autonomous-dev/lib'); from issue_triage_analyzer import main; sys.exit(main(['--auto-improvement', '--json']))" | jq '.[0].root_cause_tag, .[0].members[:3]' + +# Dry-run the full pipeline (no claude invocation) +bash scripts/triage-and-implement.sh --dry + +# Make sure gh, claude, python3 are on the PATH launchd will use +/bin/bash -lc 'which claude gh python3 git' +``` diff --git a/scripts/launchd/com.autonomous-dev.triage.plist b/scripts/launchd/com.autonomous-dev.triage.plist new file mode 100644 index 00000000..772fbad6 --- /dev/null +++ b/scripts/launchd/com.autonomous-dev.triage.plist @@ -0,0 +1,43 @@ + + + + + Label + com.autonomous-dev.triage + + ProgramArguments + + /bin/bash + -lc + cd /Users/akaszubski/Dev/autonomous-dev && bash scripts/triage-and-implement.sh --max-issues 6 + + + StartCalendarInterval + + Hour + 2 + Minute + 30 + + + StandardOutPath + /Users/akaszubski/Dev/autonomous-dev/logs/scheduled/launchd.out.log + + StandardErrorPath + /Users/akaszubski/Dev/autonomous-dev/logs/scheduled/launchd.err.log + + WorkingDirectory + /Users/akaszubski/Dev/autonomous-dev + + + RunAtLoad + + + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + diff --git a/scripts/triage-and-implement.sh b/scripts/triage-and-implement.sh new file mode 100644 index 00000000..5098649c --- /dev/null +++ b/scripts/triage-and-implement.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Recurring triage-driven implementer. +# +# Designed for /schedule or cron. Workflow: +# 1. Run /triage --auto-improvement --json to rank the queue by root cause. +# 2. Pick the top cluster's first N issues (configurable; default 8). +# 3. Skip issues already merged or with PRs open. +# 4. Run /implement --batch in headless mode with full harness. +# 5. Commit + push happen inside /implement (STEP 13). +# +# Idempotent on a clean queue: if the top cluster has nothing actionable, +# exits 0 without doing work. Safe to run hourly. +# +# Usage: +# bash scripts/triage-and-implement.sh # default: 8 issues +# bash scripts/triage-and-implement.sh --max-issues 4 +# bash scripts/triage-and-implement.sh --cluster security # restrict by tag prefix +# bash scripts/triage-and-implement.sh --dry # plan only +# +# Output: logs/scheduled/.{log,events.json,picked.txt} + +set -euo pipefail + +cd "$(dirname "$0")/.." + +LOG_DIR="logs/scheduled" +mkdir -p "$LOG_DIR" + +MAX_ISSUES=8 +CLUSTER_FILTER="" +DRY=0 +REPO="akaszubski/autonomous-dev" + +while [[ $# -gt 0 ]]; do + case "$1" in + --max-issues) MAX_ISSUES="$2"; shift 2 ;; + --cluster) CLUSTER_FILTER="$2"; shift 2 ;; + --budget) shift 2 ;; # accepted for backwards-compat; ignored (subscription auth) + --dry) DRY=1; shift ;; + --repo) REPO="$2"; shift 2 ;; + *) echo "unknown flag: $1"; exit 2 ;; + esac +done + +TS="$(date +%Y%m%d-%H%M%S)" +TRIAGE_JSON="$LOG_DIR/${TS}-triage.json" +PICKED_FILE="$LOG_DIR/${TS}-picked.txt" +LOG_FILE="$LOG_DIR/${TS}.log" +EVENTS_FILE="$LOG_DIR/${TS}.events.json" + +echo "=== triage-and-implement run $TS ===" | tee -a "$LOG_FILE" + +# Pre-flight: clean tree (cron-safe abort if dirty). +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "ABORT: working tree dirty — skipping this tick." | tee -a "$LOG_FILE" + git status --short | tee -a "$LOG_FILE" + exit 0 +fi + +# 1. Run triage analyzer directly (no Claude needed for the rank step). +python3 -c " +import sys, os +for _p in ('.claude/lib', 'plugins/autonomous-dev/lib', os.path.expanduser('~/.claude/lib')): + if os.path.isdir(_p): + sys.path.insert(0, _p); break +from issue_triage_analyzer import main +sys.exit(main(['--auto-improvement', '--repo', '$REPO', '--json'])) +" > "$TRIAGE_JSON" 2>>"$LOG_FILE" || { + echo "ABORT: triage analyzer failed" | tee -a "$LOG_FILE" + exit 1 +} + +# 2. Pick top cluster's issues (filtered by --cluster prefix if given). +PICKED=$(python3 - "$TRIAGE_JSON" "$MAX_ISSUES" "$CLUSTER_FILTER" <<'PY' +import json, sys +path, max_n, cluster_filter = sys.argv[1], int(sys.argv[2]), sys.argv[3] +data = json.load(open(path)) +clusters = data if isinstance(data, list) else data.get("clusters", []) +clusters = sorted(clusters, key=lambda c: -c.get("rank_score", 0)) +chosen = [] +for c in clusters: + tag = c.get("root_cause_tag", "") + if cluster_filter and not tag.lower().startswith(cluster_filter.lower()): + continue + nums = c.get("issue_numbers", []) + for n in nums[:max_n]: + chosen.append(str(n)) + if chosen: + break +print(",".join(chosen[:max_n])) +PY +) + +if [[ -z "$PICKED" ]]; then + echo "Nothing actionable — queue empty or all clusters filtered out." | tee -a "$LOG_FILE" + exit 0 +fi + +# 3. Filter out closed issues or those already linked to a closing PR. +FINAL="" +for n in ${PICKED//,/ }; do + view=$(gh issue view "$n" --repo "$REPO" --json state,closedByPullRequestsReferences 2>/dev/null || echo '{"state":"CLOSED"}') + state=$(echo "$view" | python3 -c "import json,sys; print(json.load(sys.stdin).get('state','CLOSED'))") + pr_count=$(echo "$view" | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('closedByPullRequestsReferences') or []))") + if [[ "$state" == "OPEN" && "$pr_count" == "0" ]]; then + FINAL="${FINAL:+$FINAL,}$n" + fi +done + +if [[ -z "$FINAL" ]]; then + echo "All top-cluster issues already closed or have open PRs." | tee -a "$LOG_FILE" + exit 0 +fi + +echo "$FINAL" > "$PICKED_FILE" +echo "Picked: $FINAL" | tee -a "$LOG_FILE" + +if (( DRY )); then + echo "DRY: would run /implement for each of: $FINAL" | tee -a "$LOG_FILE" + exit 0 +fi + +# Run /implement one issue at a time — avoids /implement --batch's worktree +# strategy, which fails on this repo because .claude/* is gitignored and +# `git worktree add` skips untracked files (hooks → empty → PreToolUse deadlock). +# Single-issue mode runs in-place on master with full harness, no worktree. +SUCCESSES=() +FAILURES=() +for n in ${FINAL//,/ }; do + echo "--- /implement $n ---" | tee -a "$LOG_FILE" + # Clear stale per-issue pipeline state. + rm -f /tmp/implement_pipeline_state.json /tmp/implement_pipeline_state.lock 2>/dev/null || true + + ISSUE_EVENTS="$LOG_DIR/${TS}-${n}.events.json" + if claude \ + --print \ + --permission-mode acceptEdits \ + --output-format stream-json \ + --include-hook-events \ + --verbose \ + --name "scheduled-${TS}-${n}" \ + --setting-sources user,project,local \ + "/implement $n" \ + > "$ISSUE_EVENTS" 2>>"$LOG_FILE"; then + SUCCESSES+=("$n") + echo " ok: $n" | tee -a "$LOG_FILE" + else + FAILURES+=("$n") + echo " FAILED: $n (see $ISSUE_EVENTS)" | tee -a "$LOG_FILE" + fi +done + +echo "Done. ok=${#SUCCESSES[@]} fail=${#FAILURES[@]} log=$LOG_FILE" | tee -a "$LOG_FILE" +echo " successes: ${SUCCESSES[*]:-none}" | tee -a "$LOG_FILE" +echo " failures: ${FAILURES[*]:-none}" | tee -a "$LOG_FILE" diff --git a/tests/unit/commands/test_implement_preflight_issue_check.py b/tests/unit/commands/test_implement_preflight_issue_check.py new file mode 100644 index 00000000..0dd53bdd --- /dev/null +++ b/tests/unit/commands/test_implement_preflight_issue_check.py @@ -0,0 +1,88 @@ +"""Regression tests for Issue #936: Coordinator pre-flight verify issue not already merged. + +Validates that implement.md contains a pre-flight block that skips work on issues which +are already closed or have been referenced in recent commits, preventing the pipeline +from burning agent time on already-merged work. + +These are structural tests against the canonical source (`plugins/autonomous-dev/commands/implement.md`). +""" + +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +IMPLEMENT_MD = PROJECT_ROOT / "plugins/autonomous-dev/commands/implement.md" + + +@pytest.fixture(scope="module") +def implement_content() -> str: + """Read implement.md once for the test module.""" + return IMPLEMENT_MD.read_text() + + +def test_preflight_block_present(implement_content: str) -> None: + """implement.md must carry the Issue #936 marker comment for the pre-flight block. + + The marker is the canonical anchor so that future audits / refactors can confirm + the pre-flight guard is still wired in, not silently deleted. + """ + marker = "Issue #936: prevent burning pipeline time on already-merged work" + assert marker in implement_content, ( + "implement.md must contain the Issue #936 marker comment. " + f"Expected substring: {marker!r}. " + "This anchors the pre-flight skip logic to the originating issue." + ) + + +def test_preflight_checks_issue_state_not_open(implement_content: str) -> None: + """Pre-flight must check ISSUE_STATE against OPEN, and the gh json call must fetch state. + + Two coupled requirements: + 1. The gh issue view call MUST include `state` in --json fields (otherwise ISSUE_STATE + is never populated). + 2. There MUST be a conditional that blocks when ISSUE_STATE is set but not 'OPEN'. + """ + # Requirement 1: gh must fetch state alongside title and body. + assert "title,body,state" in implement_content, ( + "implement.md `gh issue view` call must request `state` in --json fields. " + "Expected substring 'title,body,state' so ISSUE_STATE is populated." + ) + + # Requirement 2: a conditional that checks ISSUE_STATE != "OPEN". + assert "ISSUE_STATE" in implement_content, ( + "implement.md must reference ISSUE_STATE in the pre-flight block." + ) + assert '!= "OPEN"' in implement_content, ( + "implement.md must contain the conditional `!= \"OPEN\"` to skip non-open issues. " + "Without this, CLOSED/MERGED issues are not detected before pipeline launch." + ) + + +def test_preflight_checks_git_log_grep(implement_content: str) -> None: + """Pre-flight must scan recent commits for references to the issue number. + + Even when `gh` is unavailable or the issue state cannot be fetched, the commit-log + check MUST run so already-addressed issues are still caught (AC #5). + """ + expected = 'git log --oneline -n 20 --grep "#${ISSUE_NUMBER}' + assert expected in implement_content, ( + f"implement.md must contain a git log scan for issue references. " + f"Expected substring: {expected!r}. " + "This is the fallback check that works even when `gh` is unavailable." + ) + + +def test_preflight_respects_force_and_empty_issue(implement_content: str) -> None: + """Pre-flight must skip both checks when ISSUE_NUMBER is empty OR --force is supplied. + + The single guard expression handles two acceptance criteria at once: + - AC #3: --force in ARGUMENTS skips both checks (operator override). + - AC #4: empty ISSUE_NUMBER (free-text invocation) skips both checks. + """ + guard = '[ -n "$ISSUE_NUMBER" ] && ! echo "ARGUMENTS" | grep -q -- "--force"' + assert guard in implement_content, ( + f"implement.md must contain the pre-flight guard expression. " + f"Expected substring: {guard!r}. " + "Without this, free-text invocations and --force overrides are blocked unexpectedly." + )