From d3bf74a89eeeba49feff01c5753ed68ee507cf49 Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
Date: Mon, 1 Jun 2026 16:24:22 +1000
Subject: [PATCH 1/2] feat: pr-review-iterations

---
 README.md             |  10 +-
 devloop               | 359 +++++++++++++++++++++++++++++++++++---
 install.sh            |   1 +
 skill_helpers.sh      |  74 ++++++++
 tests/devloop_test.sh | 396 +++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 812 insertions(+), 28 deletions(-)

diff --git a/README.md b/README.md
index 4b23c21..2d6c3c5 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ By default, Codex makes the change, Claude Code reviews it, and Codex retries un
 
 Required dependencies: Bash, git, Homebrew, `codex`, `claude`, `gum`, and `fzf`.
 `install.sh` installs missing `gum` and `fzf` with Homebrew. Install the Codex and Claude Code CLIs before running a loop, then verify everything with `devloop doctor`.
+The GitHub CLI is optional. Install `gh` and run `gh auth login` only when you want PR-backed loops.
 
 ```sh
 git clone https://github.com/satyaborg/devloop.git
@@ -59,12 +60,14 @@ devloop .specs/change.md
 
 In an interactive terminal, `devloop` opens a shell in the generated worktree after the run finishes. Exiting that shell returns `devloop`'s final accepted/stalled/failure status. Use `--no-shell` when you want the command to return immediately instead.
 
-Open a PR after an accepted run:
+Open and maintain a draft PR during the loop:
 
 ```sh
 devloop --create-pr .specs/change.md
 ```
 
+A plain non-interactive `devloop <spec>` remains local-only. This mode opens and maintains a draft PR during the loop. With `--create-pr`, the PR is canonical for review history; local `.codex/reviews/*.md` files are execution cache.
+
 See tracked runs and cleanup candidates:
 
 ```sh
@@ -124,7 +127,7 @@ devloop [options] <spec.md> [max=5]
 | `--report-format <format>` | Choose `html` or `markdown` |
 | `--timeout-minutes <n>` | Cap one run, default `30` |
 | `--in-place` | Run in the current worktree |
-| `--create-pr`, `--pr` | Push the accepted branch and open a GitHub PR |
+| `--create-pr`, `--pr` | Opens and maintains a draft PR during the loop |
 | `--no-strict` | Weaken strict review gates |
 | `--no-shell`, `--stay` | Do not open a shell in the generated worktree after completion |
 | `--shell`, `--enter-worktree` | Open a shell in the generated worktree after completion |
@@ -150,7 +153,7 @@ Nested menu screens keep `Back` as the final option, and Esc/cancel also returns
 - Runs up to 5 passes by default, clamped between 1 and 10.
 - Stops a run after the configured timeout, default 30 minutes.
 - Uses isolated sibling git worktrees by default.
-- Runs a small preflight before agents start: git identity, agent CLIs, installed skills, and `gh` when PR creation is enabled.
+- Runs a small preflight before agents start: git identity, agent CLIs, installed skills, and GitHub PR readiness when PR creation is enabled.
 - Lints specs for a title, valid frontmatter type when frontmatter exists, and strict acceptance criteria.
 - Commits eligible changes after each coder pass.
 - Runs `.devloop/verify` after each coder pass when it exists and is executable. In strict mode, a failing hook blocks acceptance.
@@ -158,6 +161,7 @@ Nested menu screens keep `Back` as the final option, and Esc/cancel also returns
 - Leaves generated worktrees and branches in place for inspection.
 - Drops you into the generated worktree shell after interactive runs, unless `--no-shell` is set.
 - Never pushes or opens a PR unless you pass `--create-pr`.
+- Treats the PR as the durable review trail when `--create-pr` is active. Local `.codex/` artifacts remain disposable cache.
 
 ## Security
 
diff --git a/devloop b/devloop
index 4071dd0..3dc1432 100755
--- a/devloop
+++ b/devloop
@@ -194,7 +194,7 @@ Options:
   --timeout-minutes N           cap one run, default 30
   --no-strict                   weaken strict review gates
   --in-place                    run in the current worktree
-  --create-pr, --pr             push accepted branch and open a PR
+  --create-pr, --pr             open and maintain a draft PR during the loop
   --no-shell, --stay            do not open a shell in the run worktree after completion
   --shell, --enter-worktree     open a shell in the run worktree after completion
   -V, --version                 show version
@@ -217,7 +217,7 @@ welcome_tui() {
   printf '  %-42s %s\n' "devloop menu" "open the guided UI"
   printf '  %-42s %s\n' 'devloop spec "add retry behavior"' "generate a spec"
   printf '  %-42s %s\n' "devloop .specs/change.md" "run a spec"
-  printf '  %-42s %s\n' "devloop --create-pr .specs/change.md" "open a PR after acceptance"
+  printf '  %-42s %s\n' "devloop --create-pr .specs/change.md" "open and maintain a draft PR during the loop"
   printf '\n'
   gum style --foreground "$UI_ACCENT_COLOR" --bold "Options"
   printf '  %-30s %s\n' "--tui" "force terminal UI output"
@@ -228,7 +228,7 @@ welcome_tui() {
   printf '  %-30s %s\n' "--timeout-minutes N" "cap one run, default 30"
   printf '  %-30s %s\n' "--no-strict" "weaken strict review gates"
   printf '  %-30s %s\n' "--in-place" "run in the current worktree"
-  printf '  %-30s %s\n' "--create-pr, --pr" "push accepted branch and open a PR"
+  printf '  %-30s %s\n' "--create-pr, --pr" "open and maintain a draft PR during the loop"
   printf '  %-30s %s\n' "--no-shell, --stay" "do not open a shell after completion"
   printf '  %-30s %s\n' "--shell, --enter-worktree" "open a shell after completion"
   printf '  %-30s %s\n' "-V, --version" "show version"
@@ -799,8 +799,20 @@ ui_choose() {
 
 ui_confirm() {
   local prompt="$1"
+  local default="${2:-false}"
   local answer
   if ui_has_gum; then
+    if [ "$default" = true ]; then
+      gum confirm \
+        --default \
+        --prompt.foreground "$UI_ACCENT_COLOR" \
+        --selected.foreground "$UI_REC_COLOR" \
+        --selected.background "" \
+        --unselected.foreground "$UI_DIM_COLOR" \
+        --unselected.background "" \
+        "$prompt"
+      return $?
+    fi
     gum confirm \
       --prompt.foreground "$UI_ACCENT_COLOR" \
       --selected.foreground "$UI_REC_COLOR" \
@@ -811,12 +823,23 @@ ui_confirm() {
     return $?
   fi
   if [ "$USE_TUI" = true ] && [ -t 0 ]; then
-    printf '%s [y/N] ' "$prompt" >&2
+    if [ "$default" = true ]; then
+      printf '%s [Y/n] ' "$prompt" >&2
+    else
+      printf '%s [y/N] ' "$prompt" >&2
+    fi
     read -r answer || return 1
     case "$answer" in
       y|Y|yes|YES) return 0 ;;
+      n|N|no|NO) return 1 ;;
+      "")
+        [ "$default" = true ]
+        return $?
+        ;;
     esac
+    return 1
   fi
+  if [ "$default" = true ]; then return 0; fi
   return 1
 }
 
@@ -1245,6 +1268,24 @@ interactive_settings() {
   done
 }
 
+interactive_create_pr_choice() {
+  local repo="$1"
+  if github_pr_prompt_default "$repo"; then
+    if ui_confirm "Create a draft PR during this loop?" true; then
+      printf '%s\n' "true"
+    else
+      printf '%s\n' "false"
+    fi
+    return 0
+  fi
+  UI_NOTICE="GitHub PR mode is unavailable in this repo; continuing local-only."
+  if ui_confirm "GitHub PR mode is unavailable. Continue local-only?" true; then
+    printf '%s\n' "false"
+    return 0
+  fi
+  return 130
+}
+
 interactive_run_setup() {
   local spec="$1"
   local report_format="html"
@@ -1287,9 +1328,7 @@ interactive_run_setup() {
     fi
     case "$choice" in
       "Start run")
-        if [ "$create_pr" = true ] && ! ui_confirm "Push the accepted branch and open a PR after acceptance?"; then
-          continue
-        fi
+        create_pr="$(interactive_create_pr_choice "$PWD")" || continue
         run_header "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes"
         run_devloop "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes"
         code=$?
@@ -2049,21 +2088,45 @@ run_devloop() {
 
   init_track "$repo/$TRACK" "$run_spec" "$spec" "$PWD" "$SOURCE_REPO" "$repo" "$base" "$source_branch" "$run_branch" "$max" "$report_format" "$strict" "$coder" "$reviewer" "$WORK_TYPE" "$WORK_BREAKING" "$create_pr" "$timeout_minutes"
 
+  if [ "$create_pr" = true ]; then
+    event_step "pull-request-lookup" "find existing PR"
+    if lookup_pull_request "$repo" "$run_branch"; then
+      event_done "pull-request-lookup" true "${PULL_REQUEST:-none}"
+    else
+      STATUS="pr-error"
+      event_done "pull-request-lookup" false "$PULL_REQUEST_ERROR"
+    fi
+  fi
+
   local pass prior_hash
   prior_hash=""
   if [ "$start_pass" -gt 1 ] && [ -f "$repo/.codex/reviews/$slug-r$((start_pass - 1)).md" ]; then
     prior_hash="$(findings_hash "$repo/.codex/reviews/$slug-r$((start_pass - 1)).md")"
   fi
+  if [ "$STATUS" != "pr-error" ]; then
   for pass in $(seq "$start_pass" "$max"); do
     if run_deadline_reached; then
       STATUS="timeout"
       break
     fi
     PASSES="$pass"
+    local pr_review_context=""
+    if [ "$create_pr" = true ] && [ "$pass" -gt 1 ] && [ -n "$PULL_REQUEST" ]; then
+      local pr_context_id="pr-review-context-$pass"
+      event_step "$pr_context_id" "load latest PR review"
+      if pr_review_context="$(latest_pr_review_comment "$repo" "$PULL_REQUEST")" && [ -n "$pr_review_context" ]; then
+        event_done "$pr_context_id" true "loaded"
+      else
+        STATUS="pr-error"
+        if [ -z "$PULL_REQUEST_ERROR" ]; then PULL_REQUEST_ERROR="PR review lookup failed: no devloop round review comment found"; fi
+        event_done "$pr_context_id" false "$PULL_REQUEST_ERROR"
+        break
+      fi
+    fi
     local coder_log=".codex/logs/$slug-r$pass-coder.log"
     local coder_id="coder-$pass"
     event_step "$coder_id" "pass $pass/$max $(agent_label "$coder") implementation"
-    if run_agent "$coder" "$repo" "$repo/$coder_session" "$repo/$coder_log" "$(coder_prompt "$run_spec" "$TRACK" "$pass" "$strict" ".codex/reviews/$slug-r$((pass - 1)).md" "$criteria_file")" "$coder_id"; then
+    if run_agent "$coder" "$repo" "$repo/$coder_session" "$repo/$coder_log" "$(coder_prompt "$run_spec" "$TRACK" "$pass" "$strict" ".codex/reviews/$slug-r$((pass - 1)).md" "$criteria_file" "$pr_review_context")" "$coder_id"; then
       event_done "$coder_id" true "completed"
     else
       if [ "$RUN_TIMED_OUT" = true ]; then STATUS="timeout"; else STATUS="coder-error"; fi
@@ -2094,6 +2157,18 @@ run_devloop() {
       break
     fi
 
+    if [ "$create_pr" = true ] && [ -n "$PASS_COMMIT" ]; then
+      local pr_id="pull-request-$pass"
+      event_step "$pr_id" "push branch and ensure draft PR"
+      if create_pull_request "$repo" "$FINAL_BRANCH" "$base"; then
+        event_done "$pr_id" true "$PULL_REQUEST"
+      else
+        STATUS="pr-error"
+        event_done "$pr_id" false "$PULL_REQUEST_ERROR"
+        break
+      fi
+    fi
+
     local verify_id="verify-$pass"
     if verify_hook_configured "$repo" "$SOURCE_REPO"; then
       event_step "$verify_id" "pass $pass/$max verification"
@@ -2134,6 +2209,18 @@ run_devloop() {
       break
     fi
 
+    if [ "$create_pr" = true ] && [ -n "$PULL_REQUEST" ]; then
+      local pr_comment_id="pr-review-comment-$pass"
+      event_step "$pr_comment_id" "post round review to PR"
+      if post_pr_round_review "$repo" "$PULL_REQUEST" "$repo/$review" "$pass" "$slug"; then
+        event_done "$pr_comment_id" true "posted"
+      else
+        STATUS="pr-error"
+        event_done "$pr_comment_id" false "$PULL_REQUEST_ERROR"
+        break
+      fi
+    fi
+
     local verdict
     verdict="$(parse_verdict "$repo/$review")"
     if [ "$verdict" = "ACCEPT" ]; then
@@ -2164,22 +2251,24 @@ run_devloop() {
       break
     fi
   done
+  fi
 
   if [ "$PASSES" -gt "$max" ]; then PASSES="$max"; fi
 
-  if [ "$create_pr" = true ] && [ "$STATUS" = "accepted" ]; then
-    if create_pull_request "$repo" "$FINAL_BRANCH" "$base"; then
-      event_gate "pull request" 1 "${PULL_REQUEST:-origin/$FINAL_BRANCH}"
+  CODER_SESSION_ID="$(read_first_line "$repo/$coder_session")"
+  REVIEWER_SESSION_ID="$(read_first_line "$repo/$reviewer_session")"
+  synthesize_report "$repo" "$slug" "$reviewer" "$run_spec" "$spec" "$spec_text" "$SOURCE_REPO" "$repo" "$TRACK" "$REPORT" "$STATUS" "$PASSES" "$max" "$base" "$source_branch" "$FINAL_BRANCH" "$FINAL_COMMIT" "$FINAL_COMMIT_MESSAGE" "$PULL_REQUEST" "$PULL_REQUEST_ERROR" "$coder" "$repo/$reviewer_session" "$CODER_SESSION_ID" "$REVIEWER_SESSION_ID" "$report_format"
+
+  if [ "$create_pr" = true ] && [ -n "$PULL_REQUEST" ] && [ "$STATUS" != "pr-error" ]; then
+    event_step "pr-final-report" "post final report to PR"
+    if post_pr_final_report "$repo" "$PULL_REQUEST" "$slug" "$STATUS" "$PASSES" "$max" "$FINAL_BRANCH" "$REPORT" "$report_format"; then
+      event_done "pr-final-report" true "posted"
     else
       STATUS="pr-error"
-      event_gate "pull request" 0 "$PULL_REQUEST_ERROR"
+      event_done "pr-final-report" false "$PULL_REQUEST_ERROR"
     fi
   fi
 
-  CODER_SESSION_ID="$(read_first_line "$repo/$coder_session")"
-  REVIEWER_SESSION_ID="$(read_first_line "$repo/$reviewer_session")"
-  synthesize_report "$repo" "$slug" "$reviewer" "$run_spec" "$spec" "$spec_text" "$SOURCE_REPO" "$repo" "$TRACK" "$REPORT" "$STATUS" "$PASSES" "$max" "$base" "$source_branch" "$FINAL_BRANCH" "$FINAL_COMMIT" "$FINAL_COMMIT_MESSAGE" "$PULL_REQUEST" "$PULL_REQUEST_ERROR" "$coder" "$repo/$reviewer_session" "$CODER_SESSION_ID" "$REVIEWER_SESSION_ID" "$report_format"
-
   print_result
   rm -f "$criteria_file" "$initial_dirty"
   return 0
@@ -2298,10 +2387,43 @@ preflight_run() {
   if ! preflight_agent "$reviewer"; then return 1; fi
   if ! preflight_agent_skills "$coder"; then return 1; fi
   if ! preflight_agent_skills "$reviewer"; then return 1; fi
-  if [ "$create_pr" = true ] && ! command -v gh >/dev/null 2>&1; then
+  if [ "$create_pr" = true ] && ! preflight_github_pr "$repo"; then return 1; fi
+}
+
+gh_error_detail() {
+  local text="$1"
+  local detail
+  detail="$(printf '%s\n' "$text" | sed '/^[[:space:]]*$/d' | tail -n 1)"
+  if [ -z "$detail" ]; then detail="unknown gh error"; fi
+  printf '%s\n' "$detail"
+}
+
+github_pr_prompt_default() {
+  local repo="$1"
+  command -v gh >/dev/null 2>&1 || return 1
+  gh auth status >/dev/null 2>&1 || return 1
+  git -C "$repo" remote get-url origin >/dev/null 2>&1 || return 1
+}
+
+preflight_github_pr() {
+  local repo="$1"
+  local out
+  if ! command -v gh >/dev/null 2>&1; then
     PREFLIGHT_ERROR="missing command: gh"
     return 1
   fi
+  if ! out="$(gh auth status 2>&1)"; then
+    PREFLIGHT_ERROR="gh auth status failed: $(gh_error_detail "$out")"
+    return 1
+  fi
+  if ! out="$(git -C "$repo" remote get-url origin 2>&1)"; then
+    PREFLIGHT_ERROR="missing origin remote: $(gh_error_detail "$out")"
+    return 1
+  fi
+  if ! out="$(cd "$repo" >/dev/null 2>&1 && gh repo view 2>&1)"; then
+    PREFLIGHT_ERROR="GitHub repo lookup failed: $(gh_error_detail "$out")"
+    return 1
+  fi
 }
 
 preflight_agent() {
@@ -3063,6 +3185,7 @@ coder_prompt() {
   local strict="$4"
   local previous="$5"
   local criteria_file="$6"
+  local pr_review_context="${7:-}"
   local criteria
   criteria="$(criteria_block "$criteria_file")"
   local strict_block=""
@@ -3095,17 +3218,24 @@ Constraints:
 - Do not revert unrelated dirty files.
 EOF
   else
+    local review_context
+    if [ -n "$pr_review_context" ]; then
+      review_context="Latest durable PR review comment (canonical when --create-pr is active):
+$pr_review_context"
+    else
+      review_context="Review: $previous"
+    fi
     cat <<EOF
 Fix only the findings in the review. Do not refactor unrelated code.
 
 Spec: $spec
 Track: $track
-Review: $previous
+$review_context
 Pass: $pass
 Acceptance criteria:
 $criteria$strict_block
 Tasks:
-1. Read the review file.
+1. Read the review context.
 2. Fix each finding or explain why it is wrong in the track.
 3. Re-run relevant tests.
 4. Append "## Pass $pass - fix" to $track with per-finding outcomes.
@@ -3405,16 +3535,40 @@ create_pull_request() {
   local repo="$1"
   local branch="$2"
   local base="$3"
+  ensure_pull_request "$repo" "$branch" "$base"
+}
+
+push_pull_request_branch() {
+  local repo="$1"
+  local branch="$2"
   local remote="origin"
   local out
   if ! run_compact_command "$repo" "push branch" git push -u "$remote" "$branch"; then
     out="$RUN_OUTPUT"
-    PULL_REQUEST_ERROR="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)"
+    PULL_REQUEST_ERROR="branch push failed: $(gh_error_detail "$out")"
     return 1
   fi
-  if ! run_compact_command "$repo" "open pull request" gh pr create --fill --base "$base" --head "$branch"; then
+}
+
+lookup_pull_request() {
+  local repo="$1"
+  local branch="$2"
+  local out
+  if ! out="$(cd "$repo" >/dev/null 2>&1 && gh pr list --head "$branch" --state open --json url --jq '.[0].url // ""' 2>&1)"; then
+    PULL_REQUEST_ERROR="PR lookup failed: $(gh_error_detail "$out")"
+    return 1
+  fi
+  PULL_REQUEST="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | head -n 1)"
+}
+
+create_draft_pull_request() {
+  local repo="$1"
+  local branch="$2"
+  local base="$3"
+  local out
+  if ! run_compact_command "$repo" "open draft pull request" gh pr create --draft --fill --base "$base" --head "$branch"; then
     out="$RUN_OUTPUT"
-    PULL_REQUEST_ERROR="pushed $remote/$branch, but PR creation failed: $(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)"
+    PULL_REQUEST_ERROR="PR creation failed: $(gh_error_detail "$out")"
     return 1
   fi
   out="$RUN_OUTPUT"
@@ -3422,6 +3576,167 @@ create_pull_request() {
   if [ -z "$PULL_REQUEST" ]; then PULL_REQUEST="$(printf '%s\n' "$out" | tail -n 1)"; fi
 }
 
+ensure_pull_request() {
+  local repo="$1"
+  local branch="$2"
+  local base="$3"
+  PULL_REQUEST_ERROR=""
+  if ! push_pull_request_branch "$repo" "$branch"; then return 1; fi
+  if ! lookup_pull_request "$repo" "$branch"; then return 1; fi
+  if [ -n "$PULL_REQUEST" ]; then return 0; fi
+  create_draft_pull_request "$repo" "$branch" "$base"
+}
+
+round_review_comment_body() {
+  local review_file="$1"
+  local pass="$2"
+  local slug="$3"
+  local verdict
+  verdict="$(parse_verdict "$review_file")"
+  if [ -z "$verdict" ]; then verdict="UNCLEAR"; fi
+  cat <<EOF
+# Devloop Review Round $pass
+
+- Verdict: $verdict
+- Local cache: \`.codex/reviews/$slug-r$pass.md\`
+
+$(cat "$review_file")
+EOF
+}
+
+post_pr_round_review() {
+  local repo="$1"
+  local pr="$2"
+  local review_file="$3"
+  local pass="$4"
+  local slug="$5"
+  local body_file out
+  body_file="$(mktemp "${TMPDIR:-/tmp}/devloop-pr-review.XXXXXX")"
+  round_review_comment_body "$review_file" "$pass" "$slug" > "$body_file"
+  if ! out="$(cd "$repo" >/dev/null 2>&1 && gh pr comment "$pr" --body-file "$body_file" 2>&1)"; then
+    PULL_REQUEST_ERROR="PR comment failed: $(gh_error_detail "$out")"
+    rm -f "$body_file"
+    return 1
+  fi
+  rm -f "$body_file"
+}
+
+latest_pr_review_comment() {
+  local repo="$1"
+  local pr="$2"
+  local out
+  local query='[.comments[].body | select(contains("# Devloop Review Round "))] | last // ""'
+  if ! out="$(cd "$repo" >/dev/null 2>&1 && gh pr view "$pr" --json comments --jq "$query" 2>&1)"; then
+    PULL_REQUEST_ERROR="PR review lookup failed: $(gh_error_detail "$out")"
+    return 1
+  fi
+  printf '%s\n' "$out"
+}
+
+review_section() {
+  local file="$1"
+  local heading="$2"
+  [ -f "$file" ] || return 1
+  awk -v heading="$heading" '
+    BEGIN { inside = 0 }
+    $0 == "## " heading { inside = 1; print; next }
+    inside && /^##[[:space:]]+/ { exit }
+    inside { print }
+  ' "$file"
+}
+
+final_pr_report_body() {
+  local repo="$1"
+  local slug="$2"
+  local status="$3"
+  local pass="$4"
+  local max="$5"
+  local pr="$6"
+  local branch="$7"
+  local report="$8"
+  local report_format="$9"
+  local latest_review="$repo/.codex/reviews/$slug-r$pass.md"
+  local final_verdict acceptance engineering html_path risk
+  final_verdict=""
+  if [ -f "$latest_review" ]; then final_verdict="$(parse_verdict "$latest_review")"; fi
+  if [ -z "$final_verdict" ]; then final_verdict="none"; fi
+  acceptance="$(review_section "$latest_review" "Acceptance matrix" || true)"
+  engineering="$(review_section "$latest_review" "Engineering quality matrix" || true)"
+  if [ -z "$acceptance" ]; then acceptance="- Not available."; fi
+  if [ -z "$engineering" ]; then engineering="- Not available."; fi
+  html_path="Not generated."
+  if [ "$report_format" = "html" ] && [ -f "$repo/$report" ]; then
+    html_path="$repo/$report"
+  fi
+  case "$status" in
+    accepted) risk="No blocking residual risk was recorded by the final review." ;;
+    *) risk="The run ended as $status. Inspect the latest findings and missing tests before merging." ;;
+  esac
+  cat <<EOF
+# Devloop Final Report
+
+| Field | Value |
+| --- | --- |
+| Final status | $status |
+| Pass count | $pass / $max |
+| Final verdict | $final_verdict |
+| PR URL | $pr |
+| Branch | $branch |
+
+## Acceptance Matrix Summary
+
+$acceptance
+
+## Engineering Quality Summary
+
+$engineering
+
+## Implementation Summary
+
+- Final branch: \`$branch\`
+- Final commit: \`${FINAL_COMMIT:-none}\`
+- Commit message: ${FINAL_COMMIT_MESSAGE:-none}
+
+## Commit References
+
+$(commit_lines)
+
+## Tests Run
+
+- Verification hook log: ${VERIFY_LOG:-not configured}
+- Review test evidence: see the acceptance matrix summary above.
+
+## Residual Risk
+
+- $risk
+
+## Local Report
+
+- HTML report path: $html_path
+EOF
+}
+
+post_pr_final_report() {
+  local repo="$1"
+  local pr="$2"
+  local slug="$3"
+  local status="$4"
+  local pass="$5"
+  local max="$6"
+  local branch="$7"
+  local report="$8"
+  local report_format="$9"
+  local body_file out
+  body_file="$(mktemp "${TMPDIR:-/tmp}/devloop-pr-final.XXXXXX")"
+  final_pr_report_body "$repo" "$slug" "$status" "$pass" "$max" "$pr" "$branch" "$report" "$report_format" > "$body_file"
+  if ! out="$(cd "$repo" >/dev/null 2>&1 && gh pr comment "$pr" --body-file "$body_file" 2>&1)"; then
+    PULL_REQUEST_ERROR="PR final report comment failed: $(gh_error_detail "$out")"
+    rm -f "$body_file"
+    return 1
+  fi
+  rm -f "$body_file"
+}
+
 synthesize_report() {
   local repo="$1"
   local slug="$2"
diff --git a/install.sh b/install.sh
index b596fa4..520f5d7 100755
--- a/install.sh
+++ b/install.sh
@@ -64,6 +64,7 @@ ln -sfn "$SOURCE" "$TARGET"
 echo "installed devloop -> $SOURCE"
 install_required_ui_tools || TOOL_STATUS=$?
 devloop_install_skills "$ROOT" || SKILL_STATUS=$?
+echo "optional for PR-backed loops: install GitHub CLI and run gh auth login"
 
 case ":${PATH:-}:" in
   *":$BIN_DIR:"*) ;;
diff --git a/skill_helpers.sh b/skill_helpers.sh
index 9ffaaf5..7cd74fa 100644
--- a/skill_helpers.sh
+++ b/skill_helpers.sh
@@ -224,6 +224,79 @@ devloop_doctor_skills_in_dir() {
   return "$status"
 }
 
+devloop_doctor_github_line() {
+  local status="$1"
+  local label="$2"
+  local detail="$3"
+  printf '[%s] %s: %s\n' "$status" "$label" "$detail"
+}
+
+devloop_doctor_github() {
+  local ready=0
+  local gh_path=""
+  local repo=""
+  local out=""
+  local has_gh=false
+  local has_auth=false
+  local has_origin=false
+
+  printf '\nGitHub PR integration\n'
+  gh_path="$(command -v gh 2>/dev/null || true)"
+  if [ -n "$gh_path" ]; then
+    has_gh=true
+    devloop_doctor_github_line "PASS" "gh installed" "$gh_path"
+  else
+    ready=1
+    devloop_doctor_github_line "FAIL" "gh installed" "missing command: gh"
+  fi
+
+  if [ "$has_gh" = true ]; then
+    if out="$(gh auth status 2>&1)"; then
+      has_auth=true
+      devloop_doctor_github_line "PASS" "gh authenticated" "ok"
+    else
+      ready=1
+      out="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)"
+      devloop_doctor_github_line "FAIL" "gh authenticated" "${out:-gh auth status failed}"
+    fi
+  else
+    devloop_doctor_github_line "N/A" "gh authenticated" "gh unavailable"
+  fi
+
+  if repo="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null)"; then
+    if out="$(git -C "$repo" remote get-url origin 2>&1)"; then
+      has_origin=true
+      devloop_doctor_github_line "PASS" "current repo has origin" "$out"
+    else
+      ready=1
+      out="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)"
+      devloop_doctor_github_line "FAIL" "current repo has origin" "${out:-origin remote missing}"
+    fi
+  else
+    ready=1
+    devloop_doctor_github_line "N/A" "current repo has origin" "not inside a git repo"
+  fi
+
+  if [ "$has_gh" = true ] && [ "$has_auth" = true ] && [ "$has_origin" = true ]; then
+    if out="$(cd "$repo" >/dev/null 2>&1 && gh repo view 2>&1)"; then
+      devloop_doctor_github_line "PASS" "current repo resolves on GitHub" "$(printf '%s\n' "$out" | sed -n '1p')"
+    else
+      ready=1
+      out="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)"
+      devloop_doctor_github_line "FAIL" "current repo resolves on GitHub" "${out:-gh repo view failed}"
+    fi
+  else
+    devloop_doctor_github_line "N/A" "current repo resolves on GitHub" "prerequisite unavailable"
+  fi
+
+  if [ "$ready" -eq 0 ]; then
+    printf '%s\n' "PR-backed loop readiness available"
+  else
+    printf '%s\n' "PR-backed loop readiness unavailable"
+  fi
+  return 0
+}
+
 devloop_doctor() {
   local root="$1"
   local status=0
@@ -238,6 +311,7 @@ devloop_doctor() {
   devloop_doctor_command fzf || status=1
   printf '\nSkills\n'
   devloop_doctor_skills "$root" || status=1
+  devloop_doctor_github
 
   if [ "$status" -eq 0 ]; then
     printf 'devloop doctor: ready\n'
diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh
index 0446591..60dc242 100755
--- a/tests/devloop_test.sh
+++ b/tests/devloop_test.sh
@@ -54,6 +54,7 @@ contains "$help" "devloop reports" "help"
 contains "$help" "devloop status" "help"
 contains "$help" "devloop clean" "help"
 contains "$help" "--create-pr" "help"
+contains "$help" "draft PR during the loop" "help"
 contains "$help" "--no-shell" "help"
 contains "$help" "--enter-worktree" "help"
 contains "$help" "--version" "help"
@@ -65,6 +66,13 @@ skill_path="$("$REPO_ROOT/devloop" spec --skill-path)"
 contains "$("$REPO_ROOT/devloop" spec --print-skill)" "name: devloop-spec" "spec skill"
 ok "spec skill path"
 
+contains "$(cat "$REPO_ROOT/README.md")" "opens and maintains a draft PR during the loop" "README PR mode"
+contains "$(cat "$REPO_ROOT/README.md")" "plain non-interactive" "README local-only"
+contains "$(cat "$REPO_ROOT/README.md")" "remains local-only" "README local-only"
+contains "$(cat "$REPO_ROOT/README.md")" "PR is canonical" "README PR canonical"
+contains "$(cat "$REPO_ROOT/README.md")" "gh auth login" "README optional gh auth"
+ok "README PR guidance"
+
 for skill in "$REPO_ROOT"/skills/*/SKILL.md; do
   name="$(sed -n 's/^name: *//p' "$skill" | head -n 1)"
   description="$(sed -n 's/^description: *//p' "$skill" | head -n 1)"
@@ -243,6 +251,7 @@ git -C "$branch_repo" branch feat/chat-retry
 equals "$(next_branch "$branch_repo" feat false chat-retry "")" "feat/chat-retry-2" "next_branch suffix"
 PULL_REQUEST_ERROR=""
 if create_pull_request "$branch_repo" "feat/chat-retry" "main" >/dev/null 2>&1; then fail "pull request creation unexpectedly passed without remote"; fi
+contains "$PULL_REQUEST_ERROR" "branch push failed" "pull request push failure"
 contains "$PULL_REQUEST_ERROR" "repository exists" "pull request push failure"
 mkdir -p "$branch_repo/.codex/reports" "$branch_repo/.codex/tracks" "$branch_repo/.codex/reviews"
 printf '%s\n' "# Report" > "$branch_repo/.codex/reports/chat-retry.md"
@@ -522,6 +531,7 @@ install_path="$tool_bin:/usr/bin:/bin:/usr/sbin:/sbin"
 DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" PATH="$install_path" "$REPO_ROOT/install.sh" >/tmp/devloop-install-test.out
 [[ -x "$REPO_ROOT/devloop" ]] || fail "devloop is not executable"
 [[ -L "$bin_dir/devloop" ]] || fail "installer did not create symlink"
+contains "$(cat /tmp/devloop-install-test.out)" "gh auth login" "installer optional gh auth"
 PATH="$install_path" command -v gum >/dev/null 2>&1 || fail "installer did not make gum available"
 PATH="$install_path" command -v fzf >/dev/null 2>&1 || fail "installer did not make fzf available"
 [[ -f "$install_home/.agents/skills/devloop-spec/SKILL.md" ]] || fail "installer did not install Codex spec skill"
@@ -548,6 +558,113 @@ fake_bin="$work/fake-bin"
 mkdir -p "$fake_bin"
 printf '#!/usr/bin/env bash\nexit 0\n' > "$fake_bin/codex"
 printf '#!/usr/bin/env bash\nexit 0\n' > "$fake_bin/claude"
+cat > "$fake_bin/gh" <<'GH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+state="${DEVLOOP_GH_STATE:-${TMPDIR:-/tmp}/devloop-gh-state}"
+mkdir -p "$state/comments"
+if [ -n "${DEVLOOP_GH_LOG:-}" ]; then
+  printf 'gh %s\n' "$*" >> "$DEVLOOP_GH_LOG"
+fi
+
+case "${1:-}" in
+  auth)
+    if [ "${2:-}" != "status" ]; then exit 1; fi
+    if [ "${DEVLOOP_GH_AUTH_FAIL:-0}" = "1" ]; then
+      printf '%s\n' "gh auth exploded" >&2
+      exit 1
+    fi
+    printf '%s\n' "Logged in to github.com"
+    ;;
+  repo)
+    if [ "${2:-}" != "view" ]; then exit 1; fi
+    if [ "${DEVLOOP_GH_REPO_FAIL:-0}" = "1" ]; then
+      printf '%s\n' "gh repo exploded" >&2
+      exit 1
+    fi
+    printf '%s\n' "satyaborg/devloop"
+    ;;
+  pr)
+    shift
+    case "${1:-}" in
+      list)
+        if [ "${DEVLOOP_GH_LOOKUP_FAIL:-0}" = "1" ]; then
+          printf '%s\n' "gh pr lookup exploded" >&2
+          exit 1
+        fi
+        if [ -f "$state/pr_url" ]; then cat "$state/pr_url"; fi
+        ;;
+      create)
+        if [ "${DEVLOOP_GH_CREATE_FAIL:-0}" = "1" ]; then
+          printf '%s\n' "gh pr create exploded" >&2
+          exit 1
+        fi
+        head=""
+        while [ "$#" -gt 0 ]; do
+          case "$1" in
+            --head)
+              shift
+              head="${1:-}"
+              ;;
+          esac
+          shift || true
+        done
+        if [ -n "$head" ] && ! git ls-remote --heads origin "$head" | grep -q .; then
+          printf 'head branch was not pushed before PR creation: %s\n' "$head" >&2
+          exit 1
+        fi
+        url="${DEVLOOP_GH_PR_URL:-https://github.com/satyaborg/devloop/pull/123}"
+        printf '%s\n' "$url" > "$state/pr_url"
+        printf '%s\n' "$url"
+        ;;
+      comment)
+        if [ "${DEVLOOP_GH_COMMENT_FAIL:-0}" = "1" ]; then
+          printf '%s\n' "gh comment exploded" >&2
+          exit 1
+        fi
+        body_file=""
+        while [ "$#" -gt 0 ]; do
+          case "$1" in
+            --body-file)
+              shift
+              body_file="${1:-}"
+              ;;
+          esac
+          shift || true
+        done
+        [ -n "$body_file" ] || exit 1
+        count="$(find "$state/comments" -type f | wc -l | tr -d ' ')"
+        body="$state/comments/comment-$((count + 1)).md"
+        cp "$body_file" "$body"
+        if grep -q '^# Devloop Review Round ' "$body"; then
+          round_count="$(find "$state/comments" -name 'round-*.md' | wc -l | tr -d ' ')"
+          cp "$body" "$state/comments/round-$((round_count + 1)).md"
+          cp "$body" "$state/latest_round_comment"
+        elif grep -q '^# Devloop Final Report' "$body"; then
+          final_count="$(find "$state/comments" -name 'final-*.md' | wc -l | tr -d ' ')"
+          cp "$body" "$state/comments/final-$((final_count + 1)).md"
+        fi
+        printf '%s\n' "commented"
+        ;;
+      view)
+        if [ "${DEVLOOP_GH_VIEW_FAIL:-0}" = "1" ]; then
+          printf '%s\n' "gh pr view exploded" >&2
+          exit 1
+        fi
+        if [ -f "$state/latest_round_comment" ]; then cat "$state/latest_round_comment"; fi
+        ;;
+      *)
+        exit 1
+        ;;
+    esac
+    ;;
+  *)
+    exit 1
+    ;;
+esac
+GH
+chmod +x "$fake_bin/gh"
 chmod +x "$fake_bin/codex" "$fake_bin/claude"
 doctor_output="$(HOME="$install_home" PATH="$bin_dir:$tool_bin:$fake_bin:$PATH" "$bin_dir/devloop" doctor 2>&1)"
 contains "$doctor_output" "devloop doctor: ready" "doctor"
@@ -557,11 +674,27 @@ contains "$doctor_output" "[ok] claude:" "doctor"
 contains "$doctor_output" "[ok] skill devloop-spec" "doctor"
 contains "$doctor_output" "[ok] gum:" "doctor"
 contains "$doctor_output" "[ok] fzf:" "doctor"
+contains "$doctor_output" "GitHub PR integration" "doctor"
+contains "$doctor_output" "[PASS] gh installed" "doctor"
+contains "$doctor_output" "[PASS] gh authenticated" "doctor"
+contains "$doctor_output" "[PASS] current repo has origin" "doctor"
+contains "$doctor_output" "[PASS] current repo resolves on GitHub" "doctor"
 not_contains "$doctor_output" "Optional UI" "doctor"
 contains "$doctor_output" "$install_home/.agents/skills/devloop-spec" "doctor Codex skill"
 contains "$doctor_output" "$install_home/.claude/skills/devloop-spec" "doctor Claude skill"
 ok "doctor"
 
+no_gh_bin="$work/no-gh-bin"
+mkdir -p "$no_gh_bin"
+printf '#!/usr/bin/env bash\nexit 0\n' > "$no_gh_bin/codex"
+printf '#!/usr/bin/env bash\nexit 0\n' > "$no_gh_bin/claude"
+chmod +x "$no_gh_bin/codex" "$no_gh_bin/claude"
+doctor_no_gh_output="$(HOME="$install_home" PATH="$bin_dir:$tool_bin:$no_gh_bin:/usr/bin:/bin" "$bin_dir/devloop" doctor 2>&1)" || fail "doctor failed when gh was unavailable"
+contains "$doctor_no_gh_output" "devloop doctor: ready" "doctor no gh"
+contains "$doctor_no_gh_output" "[FAIL] gh installed" "doctor no gh"
+contains "$doctor_no_gh_output" "PR-backed loop readiness unavailable" "doctor no gh"
+ok "doctor optional GitHub readiness"
+
 agent="$work/spec-agent"
 cat > "$agent" <<'AGENT'
 #!/usr/bin/env bash
@@ -580,9 +713,7 @@ old_use_tui="$USE_TUI"
 USE_TUI=false
 (
   cd "$repo"
-  HOME="$spec_home"
-  export HOME
-  main spec --agent "$agent" "Keep devloop as Bash." >/tmp/devloop-spec-test.out
+  HOME="$spec_home" main spec --agent "$agent" "Keep devloop as Bash." >/tmp/devloop-spec-test.out
 )
 USE_TUI="$old_use_tui"
 contains "$(cat /tmp/devloop-spec-test.out)" "spec:" "spec command"
@@ -603,6 +734,16 @@ fi
 pass="$(printf '%s\n' "$prompt" | sed -nE 's/^Pass: ([0-9]+).*/\1/p' | head -n 1)"
 track="$(printf '%s\n' "$prompt" | sed -nE 's/^Track: (.+)$/\1/p' | head -n 1)"
 mode="${DEVLOOP_FAKE_MODE:-accept}"
+if [ -n "${DEVLOOP_AGENT_LOG:-}" ]; then
+  printf 'agent coder %s\n' "${pass:-1}" >> "$DEVLOOP_AGENT_LOG"
+  if [ "${pass:-1}" = "2" ]; then
+    if printf '%s\n' "$prompt" | grep -q "# Devloop Review Round 1"; then
+      printf '%s\n' "coder-pr-prior:yes" >> "$DEVLOOP_AGENT_LOG"
+    else
+      printf '%s\n' "coder-pr-prior:no" >> "$DEVLOOP_AGENT_LOG"
+    fi
+  fi
+fi
 case "$mode" in
   no-changes) ;;
   *) printf 'pass %s\n' "${pass:-1}" >> result.txt ;;
@@ -630,6 +771,9 @@ fi
 output="$(printf '%s\n' "$prompt" | sed -nE 's/^Output path: (.+)$/\1/p' | head -n 1)"
 pass="$(printf '%s\n' "$prompt" | sed -nE 's/^Pass: ([0-9]+).*/\1/p' | head -n 1)"
 mode="${DEVLOOP_FAKE_MODE:-accept}"
+if [ -n "${DEVLOOP_AGENT_LOG:-}" ] && [ -n "$pass" ]; then
+  printf 'agent reviewer %s\n' "$pass" >> "$DEVLOOP_AGENT_LOG"
+fi
 if [ "$mode" = "missing-review" ]; then exit 0; fi
 verdict="ACCEPT"
 ac_status="PASS"
@@ -736,6 +880,17 @@ pr: null
 MARKDOWN
 }
 
+add_origin_remote() {
+  local repo_path="$1"
+  local remote_path="$2"
+  local branch
+  git init -q --bare "$remote_path"
+  branch="$(git -C "$repo_path" branch --show-current)"
+  git -C "$repo_path" remote add origin "$remote_path"
+  git -C "$repo_path" push -q -u origin "$branch"
+  git -C "$remote_path" symbolic-ref HEAD "refs/heads/$branch"
+}
+
 naming_repo="$work/naming-repo"
 mkdir -p "$naming_repo"
 naming_spec="$naming_repo/partial.md"
@@ -845,8 +1000,60 @@ continue_track_with_fake_agents() {
   return "$code"
 }
 
+preflight_pr_repo="$work/preflight-pr-repo"
+make_loop_repo "$preflight_pr_repo" "preflight-pr" "Preflight PR"
+add_origin_remote "$preflight_pr_repo" "$work/preflight-pr-remote.git"
+old_home="$HOME"
+old_path="$PATH"
+HOME="$install_home"
+PATH="$no_gh_bin:$bin_dir:$tool_bin:/usr/bin:/bin"
+export HOME PATH
+if preflight_run "$preflight_pr_repo" codex claude true >/dev/null 2>&1; then fail "PR preflight accepted missing gh"; fi
+contains "$PREFLIGHT_ERROR" "missing command: gh" "PR preflight missing gh"
+PATH="$fake_bin:$bin_dir:$tool_bin:$old_path"
+DEVLOOP_GH_AUTH_FAIL=1
+export DEVLOOP_GH_AUTH_FAIL
+if preflight_run "$preflight_pr_repo" codex claude true >/dev/null 2>&1; then fail "PR preflight accepted failed gh auth"; fi
+contains "$PREFLIGHT_ERROR" "gh auth status failed: gh auth exploded" "PR preflight auth"
+unset DEVLOOP_GH_AUTH_FAIL
+preflight_no_origin="$work/preflight-no-origin"
+make_loop_repo "$preflight_no_origin" "preflight-no-origin" "Preflight No Origin"
+if preflight_run "$preflight_no_origin" codex claude true >/dev/null 2>&1; then fail "PR preflight accepted missing origin"; fi
+contains "$PREFLIGHT_ERROR" "missing origin remote" "PR preflight origin"
+DEVLOOP_GH_REPO_FAIL=1
+export DEVLOOP_GH_REPO_FAIL
+if preflight_run "$preflight_pr_repo" codex claude true >/dev/null 2>&1; then fail "PR preflight accepted failed repo lookup"; fi
+contains "$PREFLIGHT_ERROR" "GitHub repo lookup failed: gh repo exploded" "PR preflight repo"
+unset DEVLOOP_GH_REPO_FAIL
+PATH="$old_path"
+HOME="$old_home"
+export HOME PATH
+ok "PR preflight failures"
+
+interactive_pr_repo="$work/interactive-pr-repo"
+make_loop_repo "$interactive_pr_repo" "interactive-pr" "Interactive PR"
+add_origin_remote "$interactive_pr_repo" "$work/interactive-pr-remote.git"
+old_home="$HOME"
+old_path="$PATH"
+old_use_tui="$USE_TUI"
+HOME="$install_home"
+PATH="$fake_bin:$bin_dir:$tool_bin:$PATH"
+USE_TUI=false
+export HOME PATH
+equals "$(cd "$interactive_pr_repo" && interactive_create_pr_choice "$interactive_pr_repo")" "true" "interactive PR prompt defaults yes when ready"
+PATH="$no_gh_bin:$bin_dir:$tool_bin:/usr/bin:/bin"
+equals "$(cd "$interactive_pr_repo" && interactive_create_pr_choice "$interactive_pr_repo")" "false" "interactive PR prompt falls back local-only when unavailable"
+PATH="$old_path"
+HOME="$old_home"
+USE_TUI="$old_use_tui"
+export HOME PATH
+ok "interactive PR prompt"
+
 loop_repo="$work/loop-accept"
 make_loop_repo "$loop_repo" "e2e-accept" "E2E Accept"
+no_pr_gh_log="$work/no-pr-gh.log"
+DEVLOOP_GH_LOG="$no_pr_gh_log"
+export DEVLOOP_GH_LOG
 mkdir -p "$loop_repo/.devloop"
 cat > "$loop_repo/.devloop/verify" <<'VERIFY'
 #!/usr/bin/env bash
@@ -871,6 +1078,8 @@ fi
 contains "$continue_output" "accepted" "continue run"
 contains "$(run_repo_main "$loop_repo" reports)" ".codex/reports/e2e-accept" "reports command"
 contains "$(run_repo_main "$loop_repo" continue)" ".codex/tracks/e2e-accept.md" "continue command lists tracks"
+if grep -Eq '^gh pr (create|comment|list|view)' "$no_pr_gh_log" 2>/dev/null; then fail "local-only loop touched PR commands"; fi
+unset DEVLOOP_GH_LOG
 ok "e2e accept and verify"
 
 loop_repo="$work/loop-retry"
@@ -883,6 +1092,187 @@ contains "$retry_output" "accepted" "retry loop"
 contains "$retry_output" "2 / 2" "retry loop passes"
 ok "e2e reject then accept"
 
+pr_repo="$work/loop-pr-accept"
+make_loop_repo "$pr_repo" "e2e-pr-accept" "E2E PR Accept"
+add_origin_remote "$pr_repo" "$work/loop-pr-accept-remote.git"
+pr_state="$work/gh-pr-accept"
+pr_log="$work/gh-pr-accept.log"
+rm -rf "$pr_state"
+mkdir -p "$pr_state"
+DEVLOOP_GH_STATE="$pr_state"
+DEVLOOP_GH_LOG="$pr_log"
+DEVLOOP_AGENT_LOG="$pr_log"
+export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG
+if ! pr_accept_output="$(run_loop "$pr_repo" "e2e-pr-accept" accept 1 "--create-pr" 2>&1)"; then
+  printf '%s\n' "$pr_accept_output" >&2
+  fail "PR accept loop failed"
+fi
+contains "$pr_accept_output" "accepted" "PR accept loop"
+contains "$pr_accept_output" "pr:" "PR accept loop"
+create_line="$(grep -n 'gh pr create' "$pr_log" | cut -d: -f1 | head -n 1)"
+review_line="$(grep -n 'agent reviewer 1' "$pr_log" | cut -d: -f1 | head -n 1)"
+[[ -n "$create_line" && -n "$review_line" && "$create_line" -lt "$review_line" ]] || fail "PR was not created before reviewer pass 1"
+equals "$(find "$pr_state/comments" -name 'round-*.md' | wc -l | tr -d ' ')" "1" "one round PR comment"
+equals "$(find "$pr_state/comments" -name 'final-*.md' | wc -l | tr -d ' ')" "1" "one final PR comment"
+round_body="$(cat "$pr_state/comments/round-1.md")"
+contains "$round_body" "# Devloop Review Round 1" "round PR comment"
+contains "$round_body" "Verdict: ACCEPT" "round PR comment"
+contains "$round_body" "## Acceptance matrix" "round PR comment"
+contains "$round_body" "| AC1 | PASS |" "round PR comment"
+contains "$round_body" "## Engineering quality matrix" "round PR comment"
+contains "$round_body" "| Security | N/A |" "round PR comment"
+contains "$round_body" "## Review flags" "round PR comment"
+contains "$round_body" "## Findings" "round PR comment"
+contains "$round_body" "## Missing tests" "round PR comment"
+contains "$round_body" "## Fix instructions" "round PR comment"
+contains "$round_body" "## Notes" "round PR comment"
+final_body="$(cat "$pr_state/comments/final-1.md")"
+contains "$final_body" "# Devloop Final Report" "final PR comment"
+contains "$final_body" "Final status" "final PR comment"
+contains "$final_body" "Pass count" "final PR comment"
+contains "$final_body" "Final verdict" "final PR comment"
+contains "$final_body" "Acceptance Matrix Summary" "final PR comment"
+contains "$final_body" "Engineering Quality Summary" "final PR comment"
+contains "$final_body" "Implementation Summary" "final PR comment"
+contains "$final_body" "Tests Run" "final PR comment"
+contains "$final_body" "Residual Risk" "final PR comment"
+contains "$final_body" "PR URL" "final PR comment"
+contains "$final_body" "Branch" "final PR comment"
+contains "$final_body" "Commit References" "final PR comment"
+contains "$final_body" ".codex/reports/e2e-pr-accept.html" "final PR comment"
+if printf '%s\n' "$final_body" | grep -Eq '<(html|script|style)'; then fail "final PR comment embedded standalone HTML"; fi
+unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG
+ok "PR-backed accept comments"
+
+pr_repo="$work/loop-pr-terminal"
+make_loop_repo "$pr_repo" "e2e-pr-terminal" "E2E PR Terminal"
+add_origin_remote "$pr_repo" "$work/loop-pr-terminal-remote.git"
+pr_state="$work/gh-pr-terminal"
+pr_log="$work/gh-pr-terminal.log"
+rm -rf "$pr_state"
+mkdir -p "$pr_state"
+DEVLOOP_GH_STATE="$pr_state"
+DEVLOOP_GH_LOG="$pr_log"
+DEVLOOP_AGENT_LOG="$pr_log"
+export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG
+if pr_terminal_output="$(run_loop "$pr_repo" "e2e-pr-terminal" bad-ac 1 "--create-pr" 2>&1)"; then
+  printf '%s\n' "$pr_terminal_output" >&2
+  fail "PR terminal failure loop unexpectedly passed"
+fi
+contains "$pr_terminal_output" "unclear" "PR terminal failure"
+equals "$(find "$pr_state/comments" -name 'round-*.md' | wc -l | tr -d ' ')" "1" "terminal round PR comment"
+equals "$(find "$pr_state/comments" -name 'final-*.md' | wc -l | tr -d ' ')" "1" "terminal final PR comment"
+contains "$(cat "$pr_state/comments/final-1.md")" "| Final status | unclear |" "terminal final PR comment"
+unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG
+ok "PR-backed terminal final comment"
+
+pr_repo="$work/loop-pr-existing"
+make_loop_repo "$pr_repo" "e2e-pr-existing" "E2E PR Existing"
+add_origin_remote "$pr_repo" "$work/loop-pr-existing-remote.git"
+pr_state="$work/gh-pr-existing"
+pr_log="$work/gh-pr-existing.log"
+rm -rf "$pr_state"
+mkdir -p "$pr_state"
+printf '%s\n' "https://github.com/satyaborg/devloop/pull/456" > "$pr_state/pr_url"
+DEVLOOP_GH_STATE="$pr_state"
+DEVLOOP_GH_LOG="$pr_log"
+DEVLOOP_AGENT_LOG="$pr_log"
+export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG
+if ! pr_existing_output="$(run_loop "$pr_repo" "e2e-pr-existing" accept 1 "--create-pr" 2>&1)"; then
+  printf '%s\n' "$pr_existing_output" >&2
+  fail "existing PR loop failed"
+fi
+contains "$pr_existing_output" "https://github.com/satyaborg/devloop/pull/456" "existing PR loop"
+if grep -q 'gh pr create' "$pr_log"; then fail "existing PR loop created a duplicate PR"; fi
+contains "$(cat "$pr_log")" "gh pr list" "existing PR lookup"
+equals "$(find "$pr_state/comments" -name 'round-*.md' | wc -l | tr -d ' ')" "1" "existing PR round comment"
+unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG
+ok "PR-backed existing PR reuse"
+
+pr_repo="$work/loop-pr-retry"
+make_loop_repo "$pr_repo" "e2e-pr-retry" "E2E PR Retry"
+add_origin_remote "$pr_repo" "$work/loop-pr-retry-remote.git"
+pr_state="$work/gh-pr-retry"
+pr_log="$work/gh-pr-retry.log"
+rm -rf "$pr_state"
+mkdir -p "$pr_state"
+DEVLOOP_GH_STATE="$pr_state"
+DEVLOOP_GH_LOG="$pr_log"
+DEVLOOP_AGENT_LOG="$pr_log"
+export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG
+if ! pr_retry_output="$(run_loop "$pr_repo" "e2e-pr-retry" reject-then-accept 2 "--create-pr" 2>&1)"; then
+  printf '%s\n' "$pr_retry_output" >&2
+  fail "PR retry loop failed"
+fi
+contains "$pr_retry_output" "accepted" "PR retry loop"
+contains "$(cat "$pr_log")" "coder-pr-prior:yes" "PR retry prior review"
+equals "$(find "$pr_state/comments" -name 'round-*.md' | wc -l | tr -d ' ')" "2" "two round PR comments"
+unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG
+ok "PR-backed retry uses durable PR review"
+
+pr_repo="$work/loop-pr-comment-fail"
+make_loop_repo "$pr_repo" "e2e-pr-comment-fail" "E2E PR Comment Fail"
+add_origin_remote "$pr_repo" "$work/loop-pr-comment-fail-remote.git"
+pr_state="$work/gh-pr-comment-fail"
+pr_log="$work/gh-pr-comment-fail.log"
+rm -rf "$pr_state"
+mkdir -p "$pr_state"
+DEVLOOP_GH_STATE="$pr_state"
+DEVLOOP_GH_LOG="$pr_log"
+DEVLOOP_AGENT_LOG="$pr_log"
+DEVLOOP_GH_COMMENT_FAIL=1
+export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_COMMENT_FAIL
+if pr_comment_fail_output="$(run_loop "$pr_repo" "e2e-pr-comment-fail" accept 1 "--create-pr" 2>&1)"; then
+  printf '%s\n' "$pr_comment_fail_output" >&2
+  fail "PR comment failure loop unexpectedly passed"
+fi
+contains "$pr_comment_fail_output" "pr-error" "PR comment failure"
+contains "$pr_comment_fail_output" "PR comment failed: gh comment exploded" "PR comment failure"
+unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_COMMENT_FAIL
+ok "PR comment failure handling"
+
+pr_repo="$work/loop-pr-create-fail"
+make_loop_repo "$pr_repo" "e2e-pr-create-fail" "E2E PR Create Fail"
+add_origin_remote "$pr_repo" "$work/loop-pr-create-fail-remote.git"
+pr_state="$work/gh-pr-create-fail"
+pr_log="$work/gh-pr-create-fail.log"
+rm -rf "$pr_state"
+mkdir -p "$pr_state"
+DEVLOOP_GH_STATE="$pr_state"
+DEVLOOP_GH_LOG="$pr_log"
+DEVLOOP_AGENT_LOG="$pr_log"
+DEVLOOP_GH_CREATE_FAIL=1
+export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_CREATE_FAIL
+if pr_create_fail_output="$(run_loop "$pr_repo" "e2e-pr-create-fail" accept 1 "--create-pr" 2>&1)"; then
+  printf '%s\n' "$pr_create_fail_output" >&2
+  fail "PR creation failure loop unexpectedly passed"
+fi
+contains "$pr_create_fail_output" "pr-error" "PR creation failure"
+contains "$pr_create_fail_output" "PR creation failed: gh pr create exploded" "PR creation failure"
+unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_CREATE_FAIL
+ok "PR creation failure handling"
+
+pr_repo="$work/loop-pr-lookup-fail"
+make_loop_repo "$pr_repo" "e2e-pr-lookup-fail" "E2E PR Lookup Fail"
+add_origin_remote "$pr_repo" "$work/loop-pr-lookup-fail-remote.git"
+pr_state="$work/gh-pr-lookup-fail"
+pr_log="$work/gh-pr-lookup-fail.log"
+rm -rf "$pr_state"
+mkdir -p "$pr_state"
+DEVLOOP_GH_STATE="$pr_state"
+DEVLOOP_GH_LOG="$pr_log"
+DEVLOOP_AGENT_LOG="$pr_log"
+DEVLOOP_GH_LOOKUP_FAIL=1
+export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_LOOKUP_FAIL
+if pr_lookup_fail_output="$(run_loop "$pr_repo" "e2e-pr-lookup-fail" accept 1 "--create-pr" 2>&1)"; then
+  printf '%s\n' "$pr_lookup_fail_output" >&2
+  fail "PR lookup failure loop unexpectedly passed"
+fi
+contains "$pr_lookup_fail_output" "pr-error" "PR lookup failure"
+contains "$pr_lookup_fail_output" "PR lookup failed: gh pr lookup exploded" "PR lookup failure"
+unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_LOOKUP_FAIL
+ok "PR lookup failure handling"
+
 loop_repo="$work/loop-bad-ac"
 make_loop_repo "$loop_repo" "e2e-bad-ac" "E2E Bad AC"
 if bad_ac_output="$(run_loop "$loop_repo" "e2e-bad-ac" bad-ac 1 2>&1)"; then

From cda2477d38e0a4886c300d56a4f2c24b6629fd6e Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
Date: Mon, 1 Jun 2026 17:47:39 +1000
Subject: [PATCH 2/2] fix: clarify pr mode docs

---
 README.md             | 2 +-
 tests/devloop_test.sh | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 2d6c3c5..8a7b03f 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,7 @@ Open and maintain a draft PR during the loop:
 devloop --create-pr .specs/change.md
 ```
 
-A plain non-interactive `devloop <spec>` remains local-only. This mode opens and maintains a draft PR during the loop. With `--create-pr`, the PR is canonical for review history; local `.codex/reviews/*.md` files are execution cache.
+A plain non-interactive `devloop <spec>` remains local-only. With `--create-pr`, `devloop` opens and maintains a draft PR during the loop, and the PR is canonical for review history; local `.codex/reviews/*.md` files are execution cache.
 
 See tracked runs and cleanup candidates:
 
diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh
index 60dc242..85af3aa 100755
--- a/tests/devloop_test.sh
+++ b/tests/devloop_test.sh
@@ -69,6 +69,8 @@ ok "spec skill path"
 contains "$(cat "$REPO_ROOT/README.md")" "opens and maintains a draft PR during the loop" "README PR mode"
 contains "$(cat "$REPO_ROOT/README.md")" "plain non-interactive" "README local-only"
 contains "$(cat "$REPO_ROOT/README.md")" "remains local-only" "README local-only"
+contains "$(cat "$REPO_ROOT/README.md")" "With \`--create-pr\`, \`devloop\` opens and maintains a draft PR during the loop" "README PR mode"
+not_contains "$(tr '\n' ' ' < "$REPO_ROOT/README.md")" "A plain non-interactive \`devloop <spec>\` remains local-only. This mode opens and maintains a draft PR during the loop." "README local-only coherence"
 contains "$(cat "$REPO_ROOT/README.md")" "PR is canonical" "README PR canonical"
 contains "$(cat "$REPO_ROOT/README.md")" "gh auth login" "README optional gh auth"
 ok "README PR guidance"
