#!/usr/bin/env bash # dev-agent.sh โ€” Autonomous developer agent for a single issue # # Usage: ./dev-agent.sh # # Lifecycle: # 1. Fetch issue, check dependencies (preflight) # 2. Claim issue (label: in-progress, remove backlog) # 3. Create worktree + branch # 4. Run claude -p with implementation prompt # 5. Commit + push + create PR # 6. Wait for CI + AI review # 7. Feed review back via claude -p -c (continues session) # 8. On APPROVE โ†’ merge, delete branch, clean labels, close issue # # Preflight JSON output: # {"status": "ready"} # {"status": "unmet_dependency", "blocked_by": [315, 316], "suggestion": 317} # {"status": "too_large", "reason": "..."} # {"status": "already_done", "reason": "..."} # # Peek: cat /tmp/dev-agent-status # Log: tail -f ~/scripts/harb-dev/dev-agent.log set -euo pipefail # Load shared environment source "$(dirname "$0")/../lib/env.sh" # --- Config --- ISSUE="${1:?Usage: dev-agent.sh }" REPO="${CODEBERG_REPO}" REPO_ROOT="${HARB_REPO_ROOT}" API="${CODEBERG_API}" LOCKFILE="/tmp/dev-agent.lock" STATUSFILE="/tmp/dev-agent-status" LOGFILE="${FACTORY_ROOT}/dev/dev-agent.log" PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json" BRANCH="fix/issue-${ISSUE}" WORKTREE="/tmp/harb-worktree-${ISSUE}" REVIEW_POLL_INTERVAL=300 # 5 min between review checks MAX_REVIEW_ROUNDS=5 CLAUDE_TIMEOUT=7200 # --- Logging --- log() { printf '[%s] #%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE" } status() { printf '[%s] dev-agent #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" > "$STATUSFILE" log "$*" } notify() { openclaw system event --text "๐Ÿ”ง dev-agent #${ISSUE}: $*" --mode now 2>/dev/null || true } cleanup_worktree() { cd "$REPO_ROOT" git worktree remove "$WORKTREE" --force 2>/dev/null || true rm -rf "$WORKTREE" # Clear Claude Code session history for this worktree to prevent hallucinated "already done" CLAUDE_PROJECT_DIR="$HOME/.claude/projects/$(echo "$WORKTREE" | sed 's|/|-|g; s|^-||')" rm -rf "$CLAUDE_PROJECT_DIR" 2>/dev/null || true } cleanup_labels() { curl -sf -X DELETE \ -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${ISSUE}/labels/in-progress" >/dev/null 2>&1 || true } CLAIMED=false cleanup() { rm -f "$LOCKFILE" "$STATUSFILE" # If we claimed the issue but never created a PR, unclaim it if [ "$CLAIMED" = true ] && [ -z "${PR_NUMBER:-}" ]; then log "cleanup: unclaiming issue (no PR created)" curl -sf -X DELETE \ -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${ISSUE}/labels/in-progress" >/dev/null 2>&1 || true curl -sf -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/issues/${ISSUE}/labels" \ -d '{"labels":["backlog"]}' >/dev/null 2>&1 || true fi } trap cleanup EXIT # --- Log rotation --- if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then mv "$LOGFILE" "$LOGFILE.old" log "Log rotated" fi # --- Memory guard --- AVAIL_MB=$(awk '/MemAvailable/ {printf "%d", $2/1024}' /proc/meminfo) if [ "$AVAIL_MB" -lt 2000 ]; then log "SKIP: only ${AVAIL_MB}MB available (need 2000MB)" exit 0 fi # --- Concurrency lock --- if [ -f "$LOCKFILE" ]; then LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "") if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then log "SKIP: another dev-agent running (PID ${LOCK_PID})" exit 0 fi log "Removing stale lock (PID ${LOCK_PID:-?})" rm -f "$LOCKFILE" fi echo $$ > "$LOCKFILE" # --- Fetch issue --- status "fetching issue" ISSUE_JSON=$(curl -s -H "Authorization: token ${CODEBERG_TOKEN}" "${API}/issues/${ISSUE}") || true if [ -z "$ISSUE_JSON" ] || ! echo "$ISSUE_JSON" | jq -e '.id' >/dev/null 2>&1; then log "ERROR: failed to fetch issue #${ISSUE} (API down or invalid response)" exit 1 fi ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.title') ISSUE_BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""') ISSUE_STATE=$(echo "$ISSUE_JSON" | jq -r '.state') if [ "$ISSUE_STATE" != "open" ]; then log "SKIP: issue #${ISSUE} is ${ISSUE_STATE}" echo '{"status":"already_done","reason":"issue is closed"}' > "$PREFLIGHT_RESULT" exit 0 fi log "Issue: ${ISSUE_TITLE}" # ============================================================================= # PREFLIGHT: Check dependencies before doing any work # ============================================================================= status "preflight check" # Extract dependency references from issue body # Formats supported: # - Depends on #315 # - depends on #315, #316 # - Blocked by #315 # - Requires #315 # - ## Dependencies\n- #315\n- #316 DEP_NUMBERS=$(echo "$ISSUE_BODY" | \ grep -ioP '(?:depends on|blocked by|requires|after)\s+#\K[0-9]+|(?:^|\n)\s*-\s*#\K[0-9]+' | \ sort -un || true) # Also extract from a ## Dependencies section (lines starting with - #NNN or - Depends on #NNN) DEP_SECTION=$(echo "$ISSUE_BODY" | sed -n '/^## Dependencies/,/^## /p' | sed '1d;$d') if [ -n "$DEP_SECTION" ]; then SECTION_DEPS=$(echo "$DEP_SECTION" | grep -oP '#\K[0-9]+' | sort -un || true) DEP_NUMBERS=$(printf '%s\n%s' "$DEP_NUMBERS" "$SECTION_DEPS" | sort -un | grep -v '^$' || true) fi BLOCKED_BY=() if [ -n "$DEP_NUMBERS" ]; then while IFS= read -r dep_num; do [ -z "$dep_num" ] && continue # Check if dependency issue is closed (= satisfied) DEP_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${dep_num}" | jq -r '.state // "unknown"') if [ "$DEP_STATE" != "closed" ]; then BLOCKED_BY+=("$dep_num") log "dependency #${dep_num} is ${DEP_STATE} (not satisfied)" else log "dependency #${dep_num} is closed (satisfied)" fi done <<< "$DEP_NUMBERS" fi if [ "${#BLOCKED_BY[@]}" -gt 0 ]; then # Find a suggestion: look for the first blocker that itself has no unmet deps SUGGESTION="" for blocker in "${BLOCKED_BY[@]}"; do BLOCKER_BODY=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${blocker}" | jq -r '.body // ""') BLOCKER_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${blocker}" | jq -r '.state') if [ "$BLOCKER_STATE" != "open" ]; then continue fi # Check if this blocker has its own unmet deps BLOCKER_DEPS=$(echo "$BLOCKER_BODY" | \ grep -ioP '(?:depends on|blocked by|requires|after)\s+#\K[0-9]+' | sort -un || true) BLOCKER_SECTION=$(echo "$BLOCKER_BODY" | sed -n '/^## Dependencies/,/^## /p' | sed '1d;$d') if [ -n "$BLOCKER_SECTION" ]; then BLOCKER_SECTION_DEPS=$(echo "$BLOCKER_SECTION" | grep -oP '#\K[0-9]+' | sort -un || true) BLOCKER_DEPS=$(printf '%s\n%s' "$BLOCKER_DEPS" "$BLOCKER_SECTION_DEPS" | sort -un | grep -v '^$' || true) fi BLOCKER_BLOCKED=false if [ -n "$BLOCKER_DEPS" ]; then while IFS= read -r bd; do [ -z "$bd" ] && continue BD_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${bd}" | jq -r '.state // "unknown"') if [ "$BD_STATE" != "closed" ]; then BLOCKER_BLOCKED=true break fi done <<< "$BLOCKER_DEPS" fi if [ "$BLOCKER_BLOCKED" = false ]; then SUGGESTION="$blocker" break fi done # Write preflight result BLOCKED_JSON=$(printf '%s\n' "${BLOCKED_BY[@]}" | jq -R 'tonumber' | jq -sc '.') if [ -n "$SUGGESTION" ]; then jq -n --argjson blocked "$BLOCKED_JSON" --argjson suggestion "$SUGGESTION" \ '{"status":"unmet_dependency","blocked_by":$blocked,"suggestion":$suggestion}' > "$PREFLIGHT_RESULT" else jq -n --argjson blocked "$BLOCKED_JSON" \ '{"status":"unmet_dependency","blocked_by":$blocked,"suggestion":null}' > "$PREFLIGHT_RESULT" fi # Post comment ONLY if last comment isn't already an unmet dependency notice BLOCKED_LIST=$(printf '#%s, ' "${BLOCKED_BY[@]}" | sed 's/, $//') LAST_COMMENT_IS_BLOCK=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${ISSUE}/comments?limit=1" | \ jq -r '.[0].body // ""' | grep -c 'Dev-agent: Unmet dependency' || true) if [ "$LAST_COMMENT_IS_BLOCK" -eq 0 ]; then BLOCK_COMMENT="๐Ÿšง **Dev-agent: Unmet dependency** ### Blocked by open issues This issue depends on ${BLOCKED_LIST}, which $(if [ "${#BLOCKED_BY[@]}" -eq 1 ]; then echo "is"; else echo "are"; fi) not yet closed." if [ -n "$SUGGESTION" ]; then BLOCK_COMMENT="${BLOCK_COMMENT} **Suggestion:** Work on #${SUGGESTION} first." fi BLOCK_COMMENT="${BLOCK_COMMENT} --- *Automated assessment by dev-agent ยท $(date -u '+%Y-%m-%d %H:%M UTC')*" printf '%s' "$BLOCK_COMMENT" > /tmp/block-comment.txt jq -Rs '{body: .}' < /tmp/block-comment.txt > /tmp/block-comment.json curl -sf -o /dev/null -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/issues/${ISSUE}/comments" \ --data-binary @/tmp/block-comment.json 2>/dev/null || true rm -f /tmp/block-comment.txt /tmp/block-comment.json else log "skipping duplicate dependency comment" fi log "BLOCKED: unmet dependencies: ${BLOCKED_BY[*]}$(if [ -n "$SUGGESTION" ]; then echo ", suggest #${SUGGESTION}"; fi)" notify "blocked by unmet dependencies: ${BLOCKED_BY[*]}" exit 0 fi # Bash preflight passed (no explicit unmet deps) log "bash preflight passed โ€” no explicit unmet dependencies" # ============================================================================= # CLAIM ISSUE (tentative โ€” will unclaim if claude refuses) # ============================================================================= curl -sf -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/issues/${ISSUE}/labels" \ -d '{"labels":["in-progress"]}' >/dev/null 2>&1 || true curl -sf -X DELETE \ -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${ISSUE}/labels/backlog" >/dev/null 2>&1 || true curl -sf -X DELETE \ -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${ISSUE}/labels/backlog" >/dev/null 2>&1 || true CLAIMED=true # ============================================================================= # CHECK FOR EXISTING PR (recovery mode) # ============================================================================= EXISTING_PR="" EXISTING_BRANCH="" RECOVERY_MODE=false BODY_PR=$(echo "$ISSUE_BODY" | grep -oP 'Existing PR:\s*#\K[0-9]+' | head -1) || true if [ -n "$BODY_PR" ]; then PR_CHECK=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls/${BODY_PR}" | jq -r '{state, head_ref: .head.ref}') PR_CHECK_STATE=$(echo "$PR_CHECK" | jq -r '.state') if [ "$PR_CHECK_STATE" = "open" ]; then EXISTING_PR="$BODY_PR" EXISTING_BRANCH=$(echo "$PR_CHECK" | jq -r '.head_ref') log "found existing PR #${EXISTING_PR} on branch ${EXISTING_BRANCH} (from issue body)" fi fi if [ -z "$EXISTING_PR" ]; then # Priority 1: match by branch name (most reliable) FOUND_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls?state=open&limit=20" | \ jq -r --arg branch "$BRANCH" \ '.[] | select(.head.ref == $branch) | "\(.number) \(.head.ref)"' | head -1) || true if [ -n "$FOUND_PR" ]; then EXISTING_PR=$(echo "$FOUND_PR" | awk '{print $1}') EXISTING_BRANCH=$(echo "$FOUND_PR" | awk '{print $2}') log "found existing PR #${EXISTING_PR} on branch ${EXISTING_BRANCH} (from branch match)" fi fi if [ -z "$EXISTING_PR" ]; then # Priority 2: match "Fixes #NNN" or "fixes #NNN" in PR body (stricter: word boundary) FOUND_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls?state=open&limit=20" | \ jq -r --arg issue "ixes #${ISSUE}\\b" \ '.[] | select(.body | test($issue; "i")) | "\(.number) \(.head.ref)"' | head -1) || true if [ -n "$FOUND_PR" ]; then EXISTING_PR=$(echo "$FOUND_PR" | awk '{print $1}') EXISTING_BRANCH=$(echo "$FOUND_PR" | awk '{print $2}') log "found existing PR #${EXISTING_PR} on branch ${EXISTING_BRANCH} (from body match)" fi fi # Priority 3: check CLOSED PRs for prior art (don't redo work from scratch) PRIOR_ART_DIFF="" if [ -z "$EXISTING_PR" ]; then CLOSED_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls?state=closed&limit=30" | \ jq -r --arg issue "#${ISSUE}" \ '.[] | select(.merged != true) | select((.title | contains($issue)) or (.body // "" | test("ixes " + $issue + "\\b"; "i"))) | "\(.number) \(.head.ref)"' | head -1) || true if [ -n "$CLOSED_PR" ]; then CLOSED_PR_NUM=$(echo "$CLOSED_PR" | awk '{print $1}') log "found closed (unmerged) PR #${CLOSED_PR_NUM} as prior art" # Fetch the diff for claude to reference PRIOR_ART_DIFF=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls/${CLOSED_PR_NUM}.diff" | head -500) || true if [ -n "$PRIOR_ART_DIFF" ]; then log "captured prior art diff from PR #${CLOSED_PR_NUM} ($(echo "$PRIOR_ART_DIFF" | wc -l) lines)" fi fi fi if [ -n "$EXISTING_PR" ]; then RECOVERY_MODE=true PR_NUMBER="$EXISTING_PR" BRANCH="$EXISTING_BRANCH" log "RECOVERY MODE: adopting PR #${PR_NUMBER} on branch ${BRANCH}" PR_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha') PENDING_REVIEW=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${PR_NUMBER}/comments?limit=50" | \ jq -r --arg sha "$PR_SHA" \ '[.[] | select(.body | contains(" ### Changes made: ${CHANGE_SUMMARY} --- *Addressed at \`$(git rev-parse HEAD | head -c 7)\` ยท automated by dev-agent (recovery mode)*" printf '%s' "$DEV_COMMENT" > /tmp/dev-comment-body.txt jq -Rs '{body: .}' < /tmp/dev-comment-body.txt > /tmp/dev-comment.json curl -sf -o /dev/null -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/issues/${PR_NUMBER}/comments" \ --data-binary @/tmp/dev-comment.json 2>/dev/null || \ log "WARNING: failed to post dev-response comment" rm -f /tmp/dev-comment-body.txt /tmp/dev-comment.json fi else log "no unaddressed review found โ€” PR exists, entering review loop to wait" cd "$REPO_ROOT" git fetch origin "$BRANCH" 2>/dev/null # Reuse existing worktree if on the right branch (preserves .claude session) if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then WT_BRANCH=$(cd "$WORKTREE" && git rev-parse --abbrev-ref HEAD 2>/dev/null || true) if [ "$WT_BRANCH" = "$BRANCH" ]; then log "reusing existing worktree (preserves claude session)" cd "$WORKTREE" git pull --ff-only origin "$BRANCH" 2>/dev/null || git reset --hard "origin/${BRANCH}" 2>/dev/null || true else cleanup_worktree git worktree add "$WORKTREE" "origin/${BRANCH}" -B "$BRANCH" 2>&1 || { log "ERROR: worktree setup failed for recovery" exit 1 } cd "$WORKTREE" git submodule update --init --recursive 2>/dev/null || true fi else cleanup_worktree git worktree add "$WORKTREE" "origin/${BRANCH}" -B "$BRANCH" 2>&1 || { log "ERROR: worktree setup failed for recovery" exit 1 } cd "$WORKTREE" git submodule update --init --recursive 2>/dev/null || true fi fi else # ============================================================================= # NORMAL MODE: implement from scratch # ============================================================================= status "creating worktree" cd "$REPO_ROOT" # Ensure repo is in clean state (abort stale rebases, checkout master) if [ -d "$REPO_ROOT/.git/rebase-merge" ] || [ -d "$REPO_ROOT/.git/rebase-apply" ]; then log "WARNING: stale rebase detected in main repo โ€” aborting" git rebase --abort 2>/dev/null || true fi CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") if [ "$CURRENT_BRANCH" != "master" ]; then log "WARNING: main repo on '$CURRENT_BRANCH' instead of master โ€” switching" git checkout master 2>/dev/null || true fi git fetch origin master 2>/dev/null git pull --ff-only origin master 2>/dev/null || true cleanup_worktree git worktree add "$WORKTREE" origin/master -B "$BRANCH" 2>&1 || { log "ERROR: worktree creation failed" git worktree add "$WORKTREE" origin/master -B "$BRANCH" 2>&1 | while read -r wt_line; do log " $wt_line"; done || true cleanup_labels exit 1 } cd "$WORKTREE" git checkout -B "$BRANCH" origin/master 2>/dev/null git submodule update --init --recursive 2>/dev/null || true # Write STATE.md entry โ€” will be included in the first commit write_state_entry "in-progress" # Symlink lib node_modules from main repo (submodule init doesn't run npm install) for lib_dir in "$REPO_ROOT"/onchain/lib/*/; do lib_name=$(basename "$lib_dir") if [ -d "$lib_dir/node_modules" ] && [ ! -d "$WORKTREE/onchain/lib/$lib_name/node_modules" ]; then ln -s "$lib_dir/node_modules" "$WORKTREE/onchain/lib/$lib_name/node_modules" 2>/dev/null || true fi done # --- Build the unified prompt: implement OR refuse --- # Gather open issue list for context (so claude can suggest alternatives) OPEN_ISSUES_SUMMARY=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues?state=open&labels=backlog&limit=20&type=issues" | \ jq -r '.[] | "#\(.number) \(.title)"' 2>/dev/null || echo "(could not fetch)") PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}. You have been assigned issue #${ISSUE} for the harb DeFi protocol. ## Issue: ${ISSUE_TITLE} ${ISSUE_BODY} ## Other open issues labeled 'backlog' (for context if you need to suggest alternatives): ${OPEN_ISSUES_SUMMARY} $(if [ -n "$PRIOR_ART_DIFF" ]; then echo "## Prior Art (closed PR โ€” DO NOT start from scratch) A previous PR attempted this issue but was closed without merging. Review the diff below and reuse as much as possible. Fix whatever caused it to fail (merge conflicts, CI errors, review findings). \`\`\`diff ${PRIOR_ART_DIFF} \`\`\`"; fi) ## Instructions **Before implementing, assess whether you should proceed.** You have two options: ### Option A: Implement If the issue is clear, dependencies are met, and scope is reasonable: 1. Read AGENTS.md in this repo for project context and coding conventions. 2. Implement the changes described in the issue. 3. Run lint and tests before you're done (see AGENTS.md for commands). 4. Commit your changes with message: fix: ${ISSUE_TITLE} (#${ISSUE}) 5. Do NOT push or create PRs โ€” the orchestrator handles that. 6. When finished, output a summary of what you changed and why. ### Option B: Refuse (output JSON only) If you cannot or should not implement this issue, output ONLY a JSON object (no other text) with one of these structures: **Unmet dependency** โ€” required code/infrastructure doesn't exist in the repo yet: \`\`\` {\"status\": \"unmet_dependency\", \"blocked_by\": \"short explanation of what's missing\", \"suggestion\": } \`\`\` **Too large** โ€” issue needs to be split, spec is too vague, or scope exceeds a single session: \`\`\` {\"status\": \"too_large\", \"reason\": \"what makes it too large and how to split it\"} \`\`\` **Already done** โ€” the work described is already implemented in the codebase: \`\`\` {\"status\": \"already_done\", \"reason\": \"where the existing implementation is\"} \`\`\` ### How to decide - Read the issue carefully. Check if files/functions it references actually exist in the repo. - If it depends on other issues, check if those issues' deliverables are present in the codebase. - If the issue spec is vague or requires designing multiple new systems, refuse as too_large. - If another open issue should be done first, suggest it. - When in doubt, implement. Only refuse if there's a clear, specific reason. **Do NOT invent dependencies that aren't real.** If the code compiles and tests pass, that's ready." status "claude assessing + implementing" IMPL_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \ claude -p --model sonnet --dangerously-skip-permissions "$PROMPT" 2>&1) || { EXIT_CODE=$? if [ "$EXIT_CODE" -eq 124 ]; then log "TIMEOUT: claude took longer than ${CLAUDE_TIMEOUT}s" notify "timed out during implementation" else log "ERROR: claude exited with code ${EXIT_CODE}" notify "claude failed (exit ${EXIT_CODE})" fi cleanup_labels cleanup_worktree exit 1 } log "claude finished ($(printf '%s' "$IMPL_OUTPUT" | wc -c) bytes)" printf '%s' "$IMPL_OUTPUT" > /tmp/dev-agent-last-output.txt # --- Check if claude refused (JSON response) vs implemented (commits) --- REFUSAL_JSON="" # Check for refusal: try to parse output as JSON with a status field # First try raw output if printf '%s' "$IMPL_OUTPUT" | jq -e '.status' > /dev/null 2>&1; then REFUSAL_JSON="$IMPL_OUTPUT" else # Try extracting from code fence EXTRACTED=$(printf '%s' "$IMPL_OUTPUT" | sed -n '/^```/,/^```$/p' | sed '1d;$d') if [ -n "$EXTRACTED" ] && printf '%s' "$EXTRACTED" | jq -e '.status' > /dev/null 2>&1; then REFUSAL_JSON="$EXTRACTED" else # Try extracting first { ... } block (handles preamble text before JSON) EXTRACTED=$(printf '%s' "$IMPL_OUTPUT" | grep -Pzo '\{[^{}]*"status"[^{}]*\}' 2>/dev/null | tr '\0' '\n' | head -1 || true) if [ -n "$EXTRACTED" ] && printf '%s' "$EXTRACTED" | jq -e '.status' > /dev/null 2>&1; then REFUSAL_JSON="$EXTRACTED" fi fi fi # But only treat as refusal if there are NO commits (claude might output JSON-like text AND commit) cd "$WORKTREE" AHEAD=$(git rev-list origin/master..HEAD --count 2>/dev/null || echo "0") HAS_CHANGES=$(git status --porcelain) if [ -n "$REFUSAL_JSON" ] && [ "$AHEAD" -eq 0 ] && [ -z "$HAS_CHANGES" ]; then # Claude refused โ€” parse and handle REFUSAL_STATUS=$(printf '%s' "$REFUSAL_JSON" | jq -r '.status') log "claude refused: ${REFUSAL_STATUS}" # Write preflight result for dev-poll.sh printf '%s' "$REFUSAL_JSON" > "$PREFLIGHT_RESULT" # Unclaim issue (restore backlog label, remove in-progress) cleanup_labels curl -sf -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/issues/${ISSUE}/labels" \ -d '{"labels":["backlog"]}' >/dev/null 2>&1 || true # --- Post refusal comment on the issue (deduplicated) --- post_refusal_comment() { local emoji="$1" title="$2" body="$3" # Skip if last comment already has same title (prevent spam) local last_has_title last_has_title=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${ISSUE}/comments?limit=1" | \ jq -r --arg t "Dev-agent: ${title}" '.[0].body // "" | contains($t)') || true if [ "$last_has_title" = "true" ]; then log "skipping duplicate refusal comment: ${title}" return 0 fi local comment="${emoji} **Dev-agent: ${title}** ${body} --- *Automated assessment by dev-agent ยท $(date -u '+%Y-%m-%d %H:%M UTC')*" printf '%s' "$comment" > "${TMPDIR}/refusal-comment.txt" jq -Rs '{body: .}' < "${TMPDIR}/refusal-comment.txt" > "${TMPDIR}/refusal-comment.json" curl -sf -o /dev/null -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/issues/${ISSUE}/comments" \ --data-binary @"${TMPDIR}/refusal-comment.json" 2>/dev/null || \ log "WARNING: failed to post refusal comment" rm -f "${TMPDIR}/refusal-comment.txt" "${TMPDIR}/refusal-comment.json" } case "$REFUSAL_STATUS" in unmet_dependency) BLOCKED_BY=$(printf '%s' "$REFUSAL_JSON" | jq -r '.blocked_by // "unknown"') SUGGESTION=$(printf '%s' "$REFUSAL_JSON" | jq -r '.suggestion // empty') log "unmet dependency: ${BLOCKED_BY}. suggestion: ${SUGGESTION:-none}" notify "refused #${ISSUE}: unmet dependency โ€” ${BLOCKED_BY}" COMMENT_BODY="### Blocked by unmet dependency ${BLOCKED_BY}" if [ -n "$SUGGESTION" ] && [ "$SUGGESTION" != "null" ]; then COMMENT_BODY="${COMMENT_BODY} **Suggestion:** Work on #${SUGGESTION} first." fi post_refusal_comment "๐Ÿšง" "Unmet dependency" "$COMMENT_BODY" ;; too_large) REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"') log "too large: ${REASON}" notify "refused #${ISSUE}: too large โ€” ${REASON}" post_refusal_comment "๐Ÿ“" "Too large for single session" "### Why this can't be implemented as-is ${REASON} ### Next steps A maintainer should split this issue or add more detail to the spec." # Label as underspecified curl -sf -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/issues/${ISSUE}/labels" \ -d '{"labels":["underspecified"]}' >/dev/null 2>&1 || true curl -sf -X DELETE \ -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${ISSUE}/labels/backlog" >/dev/null 2>&1 || true ;; already_done) REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"') log "already done: ${REASON}" notify "refused #${ISSUE}: already done โ€” ${REASON}" post_refusal_comment "โœ…" "Already implemented" "### Existing implementation ${REASON} This issue may be ready to close." ;; *) log "unknown refusal status: ${REFUSAL_STATUS}" notify "refused #${ISSUE}: unknown reason" post_refusal_comment "โ“" "Unable to proceed" "The dev-agent could not process this issue. Raw response: \`\`\`json $(printf '%s' "$REFUSAL_JSON" | head -c 2000) \`\`\`" ;; esac cleanup_worktree exit 0 fi # --- Claude implemented (has commits or changes) --- # Write ready status for dev-poll.sh echo '{"status":"ready"}' > "$PREFLIGHT_RESULT" if [ -z "$HAS_CHANGES" ] && [ "$AHEAD" -eq 0 ]; then log "ERROR: no changes and no refusal JSON" notify "no changes made, aborting" cleanup_labels cleanup_worktree exit 1 fi if [ -n "$HAS_CHANGES" ]; then status "committing changes" git add -A git commit --no-verify -m "fix: ${ISSUE_TITLE} (#${ISSUE})" 2>&1 | tail -2 else log "claude already committed (${AHEAD} commits ahead)" fi log "HEAD: $(git log --oneline -1)" status "pushing branch" if ! git push origin "$BRANCH" --force 2>&1 | tail -3; then log "ERROR: git push failed" notify "failed to push branch ${BRANCH}" cleanup_labels cleanup_worktree exit 1 fi log "pushed ${BRANCH}" status "creating PR" IMPL_SUMMARY=$(echo "$IMPL_OUTPUT" | tail -40 | head -c 4000) # Build PR body safely via file (avoids command-line arg size limits) printf 'Fixes #%s\n\n## Changes\n%s' "$ISSUE" "$IMPL_SUMMARY" > /tmp/pr-body-${ISSUE}.txt jq -n \ --arg title "fix: ${ISSUE_TITLE} (#${ISSUE})" \ --rawfile body "/tmp/pr-body-${ISSUE}.txt" \ --arg head "$BRANCH" \ --arg base "master" \ '{title: $title, body: $body, head: $head, base: $base}' > /tmp/pr-request-${ISSUE}.json PR_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/pulls" \ --data-binary @/tmp/pr-request-${ISSUE}.json) PR_HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1) PR_RESPONSE=$(echo "$PR_RESPONSE" | sed '$d') rm -f /tmp/pr-body-${ISSUE}.txt /tmp/pr-request-${ISSUE}.json if [ "$PR_HTTP_CODE" != "201" ] && [ "$PR_HTTP_CODE" != "200" ]; then log "ERROR: PR creation failed (HTTP ${PR_HTTP_CODE}): $(echo "$PR_RESPONSE" | head -3)" notify "failed to create PR (HTTP ${PR_HTTP_CODE})" cleanup_labels cleanup_worktree exit 1 fi PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number') if [ "$PR_NUMBER" = "null" ] || [ -z "$PR_NUMBER" ]; then log "ERROR: failed to create PR: $(echo "$PR_RESPONSE" | head -5)" notify "failed to create PR" cleanup_labels cleanup_worktree exit 1 fi log "created PR #${PR_NUMBER}" notify "PR #${PR_NUMBER} created for issue #${ISSUE}: ${ISSUE_TITLE}" fi # ============================================================================= # STATE.MD APPEND # ============================================================================= # Write STATE.md entry in the worktree BEFORE implementation starts. # This ensures the STATE.md update is part of the first commit, not a separate # push that would dismiss stale approvals. # Format: - [YYYY-MM-DD] description (#ISSUE) write_state_entry() { local status_word="${1:-in-progress}" # "in-progress" or "done" local target="${WORKTREE:-$REPO_ROOT}" local state_file="${target}/STATE.md" local today today=$(date -u +%Y-%m-%d) local description description=$(echo "$ISSUE_TITLE" | sed 's/^feat:\s*//i;s/^fix:\s*//i;s/^refactor:\s*//i') local line="- [${today}] [${status_word}] ${description} (#${ISSUE})" if [ ! -f "$state_file" ]; then printf '# STATE.md โ€” What harb currently is and does\n\n' > "$state_file" fi echo "$line" >> "$state_file" log "STATE.md: ${line}" } # Alias for backward compat append_state_log() { write_state_entry "done" } # MERGE HELPER # ============================================================================= do_merge() { local sha="$1" for m in $(seq 1 20); do local ci ci=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/commits/${sha}/status" | jq -r '.state // "unknown"') [ "$ci" = "success" ] && break sleep 30 done # Check if PR is mergeable โ€” rebase if not local mergeable mergeable=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls/${PR_NUMBER}" | jq -r '.mergeable // true') if [ "$mergeable" = "false" ]; then log "PR #${PR_NUMBER} has merge conflicts โ€” attempting rebase" local work_dir="${WORKTREE:-$REPO_ROOT}" if (cd "$work_dir" && git fetch origin master && git rebase origin/master 2>&1); then log "rebase succeeded โ€” force pushing" (cd "$work_dir" && git push origin "${BRANCH}" --force-with-lease 2>&1) || true # Wait for CI on the new commit sha=$(cd "$work_dir" && git rev-parse HEAD) log "waiting for CI on rebased commit ${sha:0:7}" for r in $(seq 1 20); do ci=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/commits/${sha}/status" | jq -r '.state // "unknown"') [ "$ci" = "success" ] && break [ "$ci" = "failure" ] || [ "$ci" = "error" ] && { log "CI failed after rebase"; notify "PR #${PR_NUMBER} CI failed after rebase. Needs manual fix."; exit 0; } sleep 30 done else log "rebase failed โ€” aborting and escalating" (cd "$work_dir" && git rebase --abort 2>/dev/null) || true notify "PR #${PR_NUMBER} has merge conflicts that need manual resolution." exit 0 fi fi local http_code http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/pulls/${PR_NUMBER}/merge" \ -d '{"Do":"merge","delete_branch_after_merge":true}') if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then log "PR #${PR_NUMBER} merged!" # Update STATE.md on master (pull merged changes first) (cd "$REPO_ROOT" && git checkout master 2>/dev/null && git pull --ff-only origin master 2>/dev/null) || true append_state_log || log "WARNING: STATE.md update failed (non-fatal)" curl -sf -X DELETE \ -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/branches/${BRANCH}" >/dev/null 2>&1 || true curl -sf -X PATCH \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/issues/${ISSUE}" \ -d '{"state":"closed"}' >/dev/null 2>&1 || true cleanup_labels notify "โœ… PR #${PR_NUMBER} merged! Issue #${ISSUE} done." cleanup_worktree exit 0 else log "merge failed (HTTP ${http_code})" notify "PR #${PR_NUMBER} approved but merge failed (HTTP ${http_code}). Please merge manually." exit 0 fi } # ============================================================================= # REVIEW LOOP # ============================================================================= REVIEW_ROUND=0 CI_RETRY_COUNT=0 CI_FIX_COUNT=0 while [ "$REVIEW_ROUND" -lt "$MAX_REVIEW_ROUNDS" ]; do status "waiting for CI + review on PR #${PR_NUMBER} (round $((REVIEW_ROUND + 1)))" CI_DONE=false for i in $(seq 1 60); do sleep 30 CURRENT_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha') CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/commits/${CURRENT_SHA}/status" | jq -r '.state // "unknown"') if [ "$CI_STATE" = "success" ] || [ "$CI_STATE" = "failure" ] || [ "$CI_STATE" = "error" ]; then log "CI: ${CI_STATE}" CI_DONE=true # Reset CI fix budget on success โ€” each phase gets fresh attempts if [ "$CI_STATE" = "success" ]; then CI_FIX_COUNT=0 fi break fi done if ! $CI_DONE; then log "TIMEOUT: CI didn't complete in 30min" notify "CI timeout on PR #${PR_NUMBER}" exit 1 fi # --- Handle CI failure --- if [ "$CI_STATE" = "failure" ] || [ "$CI_STATE" = "error" ]; then PIPELINE_NUM=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/commits/${CURRENT_SHA}/status" | jq -r '.statuses[0].target_url // ""' | grep -oP 'pipeline/\K[0-9]+' | head -1 || true) FAILED_STEP="" FAILED_EXIT="" if [ -n "$PIPELINE_NUM" ]; then FAILED_INFO=$(curl -sf \ -H "Authorization: Bearer ${WOODPECKER_TOKEN}" \ "http://localhost:8000/api/repos/2/pipelines/${PIPELINE_NUM}" | \ jq -r '.workflows[]?.children[]? | select(.state=="failure") | "\(.name)|\(.exit_code)"' | head -1) FAILED_STEP=$(echo "$FAILED_INFO" | cut -d'|' -f1) FAILED_EXIT=$(echo "$FAILED_INFO" | cut -d'|' -f2) fi log "CI failed: step=${FAILED_STEP:-unknown} exit=${FAILED_EXIT:-?}" IS_INFRA=false case "${FAILED_STEP}" in git*) IS_INFRA=true ;; esac case "${FAILED_EXIT}" in 128|137) IS_INFRA=true ;; esac if [ "$IS_INFRA" = true ] && [ "${CI_RETRY_COUNT:-0}" -lt 1 ]; then CI_RETRY_COUNT=$(( ${CI_RETRY_COUNT:-0} + 1 )) log "infra failure โ€” retrigger CI (retry ${CI_RETRY_COUNT})" cd "$WORKTREE" git commit --allow-empty -m "ci: retrigger after infra failure" --no-verify 2>&1 | tail -1 git push origin "$BRANCH" --force 2>&1 | tail -3 continue fi CI_FIX_COUNT=$(( ${CI_FIX_COUNT:-0} + 1 )) if [ "$CI_FIX_COUNT" -gt 2 ]; then log "CI failure not recoverable after ${CI_FIX_COUNT} fix attempts" # Escalate to supervisor โ€” write marker for factory-poll.sh to pick up echo "{\"issue\":${ISSUE},\"pr\":${PR_NUMBER},\"reason\":\"ci_exhausted\",\"step\":\"${FAILED_STEP:-unknown}\",\"attempts\":${CI_FIX_COUNT},\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \ >> "${FACTORY_ROOT}/factory/escalations.jsonl" log "escalated to supervisor via escalations.jsonl" break fi CI_ERROR_LOG="" if [ -n "$PIPELINE_NUM" ]; then CI_ERROR_LOG=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures "$PIPELINE_NUM" 2>/dev/null | tail -80 | head -c 8000 || echo "") fi log "CI code failure โ€” feeding back to claude (attempt ${CI_FIX_COUNT})" status "claude fixing CI failure (attempt ${CI_FIX_COUNT})" CI_FIX_PROMPT="CI failed on your PR for issue #${ISSUE}: ${ISSUE_TITLE} You are in worktree ${WORKTREE} on branch ${BRANCH}. ## CI Debug Tool \`\`\`bash bash "${FACTORY_ROOT}/lib/ci-debug.sh" status ${PIPELINE_NUM:-0} bash "${FACTORY_ROOT}/lib/ci-debug.sh" logs ${PIPELINE_NUM:-0} bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures ${PIPELINE_NUM:-0} \`\`\` ## Failed step: ${FAILED_STEP:-unknown} (exit code ${FAILED_EXIT:-?}, pipeline #${PIPELINE_NUM:-?}) ## Error snippet: \`\`\` ${CI_ERROR_LOG:-No logs available. Use ci-debug.sh to query the pipeline.} \`\`\` ## Instructions 1. Run ci-debug.sh failures to get full error output. 2. Read the failing test file(s) โ€” understand what the tests EXPECT. 3. Read AGENTS.md for conventions. 4. Fix the root cause โ€” do NOT weaken tests. 5. Run lint/typecheck if applicable. Commit your fix. 6. Output a SHORT bullet-list summary." CI_FIX_OUTPUT="" if [ "$CI_FIX_COUNT" -eq 1 ] && [ "$REVIEW_ROUND" -eq 0 ]; then CI_FIX_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \ claude -p -c --model sonnet --dangerously-skip-permissions "$CI_FIX_PROMPT" 2>&1) || { CI_FIX_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \ claude -p --model sonnet --dangerously-skip-permissions "$CI_FIX_PROMPT" 2>&1) || true } else CI_FIX_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \ claude -p -c --model sonnet --dangerously-skip-permissions "$CI_FIX_PROMPT" 2>&1) || { CI_FIX_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \ claude -p --model sonnet --dangerously-skip-permissions "$CI_FIX_PROMPT" 2>&1) || true } fi log "claude finished CI fix attempt ${CI_FIX_COUNT}" cd "$WORKTREE" if [ -n "$(git status --porcelain)" ]; then git add -A git commit --no-verify -m "fix: CI failure in ${FAILED_STEP:-build} (#${ISSUE})" 2>&1 | tail -2 fi REMOTE_SHA=$(git ls-remote origin "$BRANCH" 2>/dev/null | awk '{print $1}') LOCAL_SHA=$(git rev-parse HEAD) if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then git push origin "$BRANCH" --force 2>&1 | tail -3 log "pushed CI fix (attempt ${CI_FIX_COUNT})" notify "PR #${PR_NUMBER}: pushed CI fix attempt ${CI_FIX_COUNT} (${FAILED_STEP:-build})" else log "no changes after CI fix attempt โ€” bailing" notify "โŒ PR #${PR_NUMBER}: claude couldn't fix CI failure in ${FAILED_STEP:-unknown}. Needs human attention." break fi continue fi # --- Wait for review --- REVIEW_TEXT="" for i in $(seq 1 36); do sleep "$REVIEW_POLL_INTERVAL" CURRENT_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha') REVIEW_COMMENT=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/issues/${PR_NUMBER}/comments?limit=50" | \ jq -r --arg sha "$CURRENT_SHA" \ '[.[] | select(.body | contains(" ### Changes made: ${CHANGE_SUMMARY} --- *Addressed at \`$(git rev-parse HEAD | head -c 7)\` ยท automated by dev-agent*" curl -sf -o /dev/null -X POST \ -H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Content-Type: application/json" \ "${API}/issues/${PR_NUMBER}/comments" \ -d "$(jq -n --arg body "$DEV_COMMENT" '{body: $body}')" 2>/dev/null || \ log "WARNING: failed to post dev-response comment" fi done if [ "$REVIEW_ROUND" -ge "$MAX_REVIEW_ROUNDS" ]; then log "hit max review rounds (${MAX_REVIEW_ROUNDS})" notify "PR #${PR_NUMBER}: hit ${MAX_REVIEW_ROUNDS} review rounds, needs human attention" fi cleanup_labels # Keep worktree if PR is still open (recovery can reuse session context) if [ -n "${PR_NUMBER:-}" ]; then PR_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ "${API}/pulls/${PR_NUMBER}" | jq -r '.state // "unknown"') || true if [ "$PR_STATE" = "open" ]; then log "keeping worktree (PR #${PR_NUMBER} still open, session preserved for recovery)" else cleanup_worktree fi else cleanup_worktree fi log "dev-agent finished for issue #${ISSUE}"