diff --git a/.github/scripts/extract-actionable-comments.sh b/.github/scripts/extract-actionable-comments.sh
new file mode 100755
index 00000000..729e61f9
--- /dev/null
+++ b/.github/scripts/extract-actionable-comments.sh
@@ -0,0 +1,560 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: Apache-2.0
+# © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
+
+set -euo pipefail
+
+usage() {
+  cat <<'EOF'
+Usage:
+  .github/scripts/extract-actionable-comments.sh <PR_NUMBER> [--repo OWNER/REPO] [--out <path>] [--full]
+  .github/scripts/extract-actionable-comments.sh <PR_NUMBER> [--repo OWNER/REPO] [--out <path>] [--full] [--all-sources]
+  .github/scripts/extract-actionable-comments.sh <PR_NUMBER> [--repo OWNER/REPO] [--out <path>] [--full] [--include-conversation] [--include-reviews]
+
+Purpose:
+  Extract actionable PR feedback (CodeRabbitAI + humans) from:
+  - PR review threads (inline review comments; diff-positioned, can become outdated)
+  - optionally PR conversation comments (issue comments)
+  - optionally PR review summaries (review bodies, e.g. "changes requested")
+
+  Review threads are grouped by staleness; all sources support a lightweight ack
+  convention via: ✅ Addressed in commit <sha>
+
+Outputs:
+  - Writes a Markdown report to stdout by default.
+  - Also writes raw JSON + intermediate artifacts to /tmp.
+
+Options:
+  --repo OWNER/REPO        Override repo (default: current repo via `gh repo view`)
+  --out <path>             Write the Markdown report to <path> (also prints to stdout)
+  --full                   Include full comment bodies for actionable comments
+  --include-conversation   Also include PR conversation (issue) comments
+  --include-reviews        Also include PR review summaries (approve/request-changes bodies)
+  --all-sources            Equivalent to: --include-conversation --include-reviews
+
+Examples:
+  .github/scripts/extract-actionable-comments.sh 176
+  .github/scripts/extract-actionable-comments.sh 176 --out /tmp/pr-176-report.md
+  .github/scripts/extract-actionable-comments.sh 176 --full
+  .github/scripts/extract-actionable-comments.sh 176 --all-sources
+EOF
+}
+
+require_cmd() {
+  local cmd="$1"
+  command -v "$cmd" >/dev/null 2>&1 || { echo "Missing dependency: $cmd" >&2; exit 2; }
+}
+
+fetch_paginated_json() {
+  local api_path="$1"
+  local out_json="$2"
+  local out_err="$3"
+
+  local attempt=1
+  local delay_s=1
+  while true; do
+    if gh api "$api_path" --paginate > "$out_json" 2> "$out_err"; then
+      break
+    fi
+
+    if [[ "$attempt" -ge 4 ]]; then
+      echo "Error: Failed to fetch GitHub API '${api_path}' after ${attempt} attempts." >&2
+      echo "Troubleshooting:" >&2
+      echo "- Run: gh auth status" >&2
+      echo "- Check rate limits / token scopes (GH_TOKEN) and retry later" >&2
+      echo "- Verify repo/PR access permissions" >&2
+      echo "- Check network connectivity" >&2
+      echo >&2
+      echo "gh api stderr (last attempt):" >&2
+      sed -n '1,200p' "$out_err" >&2
+      exit 1
+    fi
+
+    sleep "$delay_s"
+    delay_s="$((delay_s * 2))"
+    attempt="$((attempt + 1))"
+  done
+
+  if ! jq -e . "$out_json" >/dev/null 2>&1; then
+    echo "Error: GitHub API returned invalid JSON in: ${out_json}" >&2
+    echo "gh api stderr:" >&2
+    sed -n '1,200p' "$out_err" >&2
+    exit 1
+  fi
+}
+
+PR_NUMBER="${1:-}"
+shift || true # Prevent set -e exit when $1 is absent (no remaining args to shift).
+if [[ -z "${PR_NUMBER}" ]]; then
+  usage >&2
+  exit 2
+fi
+if ! [[ "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then
+  echo "Error: PR_NUMBER must be numeric, got: ${PR_NUMBER}" >&2
+  usage >&2
+  exit 2
+fi
+
+REPO=""
+OUT=""
+FULL=0
+INCLUDE_CONVERSATION=0
+INCLUDE_REVIEWS=0
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --repo)
+      if [[ -z "${2:-}" || "${2:-}" == -* ]]; then
+        echo "Error: --repo requires a value in the form OWNER/REPO" >&2
+        usage >&2
+        exit 2
+      fi
+      REPO="$2"
+      shift 2
+      ;;
+    --out)
+      if [[ -z "${2:-}" || "${2:-}" == -* ]]; then
+        echo "Error: --out requires a filesystem path" >&2
+        usage >&2
+        exit 2
+      fi
+      OUT="$2"
+      shift 2
+      ;;
+    --full)
+      FULL=1
+      shift
+      ;;
+    --include-conversation)
+      INCLUDE_CONVERSATION=1
+      shift
+      ;;
+    --include-reviews)
+      INCLUDE_REVIEWS=1
+      shift
+      ;;
+    --all-sources)
+      INCLUDE_CONVERSATION=1
+      INCLUDE_REVIEWS=1
+      shift
+      ;;
+    -h|--help)
+      usage
+      exit 0
+      ;;
+    *)
+      echo "Unknown argument: $1" >&2
+      usage >&2
+      exit 2
+      ;;
+  esac
+done
+
+require_cmd gh
+require_cmd jq
+
+if [[ -z "$REPO" ]]; then
+  REPO="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')"
+fi
+
+OWNER="${REPO%/*}"
+NAME="${REPO#*/}"
+if [[ "$REPO" != */* || "$REPO" == */*/* || -z "$OWNER" || -z "$NAME" || "$OWNER" == "$NAME" ]]; then
+  echo "Error: Invalid repo format '${REPO}'. Expected OWNER/REPO." >&2
+  exit 2
+fi
+
+HEAD_SHA="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')"
+if [[ -z "$HEAD_SHA" || ! "$HEAD_SHA" =~ ^[0-9a-f]{7,}$ ]]; then
+  echo "Error: Failed to determine PR head commit SHA for ${REPO}#${PR_NUMBER}" >&2
+  exit 1
+fi
+HEAD7="${HEAD_SHA:0:7}"
+
+TS="$(date +%s)-$$"
+RAW_COMMITS="/tmp/pr-${PR_NUMBER}-commits-${TS}.json"
+RAW_COMMITS_ERR="/tmp/pr-${PR_NUMBER}-commits-${TS}.err"
+RAW_REVIEW="/tmp/pr-${PR_NUMBER}-review-comments-${TS}.json"
+RAW_REVIEW_ERR="/tmp/pr-${PR_NUMBER}-review-comments-${TS}.err"
+LATEST_REVIEW="/tmp/pr-${PR_NUMBER}-review-latest-${TS}.json"
+RAW_CONVERSATION="/tmp/pr-${PR_NUMBER}-conversation-comments-${TS}.json"
+RAW_CONVERSATION_ERR="/tmp/pr-${PR_NUMBER}-conversation-comments-${TS}.err"
+LATEST_CONVERSATION="/tmp/pr-${PR_NUMBER}-conversation-latest-${TS}.json"
+RAW_REVIEWS="/tmp/pr-${PR_NUMBER}-reviews-${TS}.json"
+RAW_REVIEWS_ERR="/tmp/pr-${PR_NUMBER}-reviews-${TS}.err"
+LATEST_REVIEWS="/tmp/pr-${PR_NUMBER}-reviews-latest-${TS}.json"
+LATEST_ALL="/tmp/pr-${PR_NUMBER}-latest-${TS}.json"
+REPORT="/tmp/pr-${PR_NUMBER}-report-${TS}.md"
+
+fetch_paginated_json "repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/commits" "$RAW_COMMITS" "$RAW_COMMITS_ERR"
+VALID_COMMITS_JSON="$(jq -c '[ .[] | (.sha // "")[0:7] | select(length == 7) ] | unique' "$RAW_COMMITS")"
+if [[ -z "$VALID_COMMITS_JSON" || "$VALID_COMMITS_JSON" == "[]" ]]; then
+  VALID_COMMITS_JSON="$(jq -nc --arg head "$HEAD7" '[ $head ]')"
+fi
+
+fetch_paginated_json "repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/comments" "$RAW_REVIEW" "$RAW_REVIEW_ERR"
+if [[ "$INCLUDE_CONVERSATION" -eq 1 ]]; then
+  fetch_paginated_json "repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments" "$RAW_CONVERSATION" "$RAW_CONVERSATION_ERR"
+fi
+if [[ "$INCLUDE_REVIEWS" -eq 1 ]]; then
+  fetch_paginated_json "repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews" "$RAW_REVIEWS" "$RAW_REVIEWS_ERR"
+fi
+
+# Normalize review-thread comments (top-level only) and detect ack markers in replies.
+FILTER_COMMON="/tmp/pr-${PR_NUMBER}-jq-common-${TS}.jq"
+FILTER_REVIEW="/tmp/pr-${PR_NUMBER}-jq-review-thread-${TS}.jq"
+FILTER_CONVERSATION="/tmp/pr-${PR_NUMBER}-jq-conversation-${TS}.jq"
+FILTER_REVIEWS="/tmp/pr-${PR_NUMBER}-jq-review-summaries-${TS}.jq"
+FILTER_REVIEW_FULL="/tmp/pr-${PR_NUMBER}-jq-review-thread-full-${TS}.jq"
+FILTER_CONVERSATION_FULL="/tmp/pr-${PR_NUMBER}-jq-conversation-full-${TS}.jq"
+FILTER_REVIEWS_FULL="/tmp/pr-${PR_NUMBER}-jq-review-summaries-full-${TS}.jq"
+
+cat > "$FILTER_COMMON" <<'JQ'
+def is_bot_user(u):
+  (u | type) == "object"
+  and (
+    (u.type // "") == "Bot"
+    or ((u.login // "") | endswith("[bot]"))
+  );
+
+# An ack marker is considered valid only when:
+# - authored by a non-bot user (prevents false positives from CodeRabbit templates), and
+# - includes a commit SHA that is actually part of the PR (reduces accidental matches).
+def ack_commit(body):
+  if (body | type) != "string" then null
+  else
+    (try
+      (body
+        | capture("(?m)^[\\s>]*✅ Addressed in commit (?<commit>[0-9a-f]{7,40})\\b")
+        | .commit
+        | ascii_downcase
+        | .[0:7]
+      )
+    catch null)
+  end;
+
+def has_ack_marker(body; user):
+  (is_bot_user(user) | not)
+  and (ack_commit(body) as $c
+    | $c != null
+    and ($valid_commits | index($c)) != null
+  );
+
+def normalize_title(body):
+  ((body
+    | split("\n")
+    | map(select(. != ""))
+    | .[0] // "UNTITLED"
+  )
+  | gsub("\\*\\*"; "")
+  | .[0:80]);
+
+def priority_from_body(body):
+  if (body | test("\\bP0\\b|badge/P0-|🔴|Critical"; "i")) then "P0"
+  elif (body | test("\\bP1\\b|badge/P1-|🟠|Major"; "i")) then "P1"
+  elif (body | test("\\bP2\\b|badge/P2-|🟡|Minor"; "i")) then "P2"
+  else "P3"
+  end;
+
+def likely_actionable(body):
+  (body | type) == "string"
+  and (body | test("\\bP[0-3]\\b|\\bTODO\\b|\\bFIXME\\b|\\bnit\\b|suggest|\\bshould\\b|\\bconsider\\b|blocker|\\bbug\\b|error|fail|typo|rename|missing|clarify|doc(s|ument)?|\\btests?\\b|panic|crash|security|\\bdetermin"; "i"));
+JQ
+
+cat > "$FILTER_REVIEW" <<'JQ'
+# Replies are returned in the same list, with `in_reply_to_id` set.
+def ack_by_reply:
+  reduce .[] as $c ({}; if ($c.in_reply_to_id != null and has_ack_marker($c.body; $c.user)) then .[($c.in_reply_to_id | tostring)] = true else . end);
+
+(ack_by_reply) as $replies |
+[ .[]
+  | select(.in_reply_to_id == null)
+  | {
+      id,
+      author: (.user.login // "unknown"),
+      author_is_bot: (
+        (.user.type // "") == "Bot"
+        or ((.user.login // "") | endswith("[bot]"))
+      ),
+      url: .html_url,
+      source: "review_thread",
+      path,
+      line,
+      position,
+      original_position,
+      head_commit: $head,
+      comment_commit: (.commit_id[0:7]),
+      original_commit: (.original_commit_id[0:7]),
+      is_on_head: (.commit_id[0:7] == $head),
+      is_visible_on_head_diff: (.position != null),
+      is_outdated: (.position == null),
+      is_moved: (.commit_id != .original_commit_id),
+      has_ack: ($replies[(.id | tostring)] // false),
+      is_actionable: true,
+      priority: priority_from_body(.body),
+      title: normalize_title(.body),
+      body: .body
+    }
+]
+JQ
+
+cat "$FILTER_COMMON" "$FILTER_REVIEW" > "$FILTER_REVIEW_FULL"
+jq --arg head "$HEAD7" --argjson valid_commits "$VALID_COMMITS_JSON" -f "$FILTER_REVIEW_FULL" "$RAW_REVIEW" > "$LATEST_REVIEW"
+
+if [[ "$INCLUDE_CONVERSATION" -eq 1 ]]; then
+  cat > "$FILTER_CONVERSATION" <<'JQ'
+def is_html_comment(body):
+  (body | type) == "string"
+  and (body | test("^\\s*<!--"));
+
+[ .[]
+  | select((.body // "") | test("\\S"))
+  | select(is_html_comment(.body) | not)
+  | (.user // {}) as $u
+  | (
+      ($u.type // "") == "Bot"
+      or (($u.login // "") | endswith("[bot]"))
+    ) as $is_bot
+  | {
+      id,
+      author: ($u.login // "unknown"),
+      author_is_bot: $is_bot,
+      url: .html_url,
+      source: "conversation",
+      path: null,
+      line: null,
+      position: null,
+      original_position: null,
+      head_commit: null,
+      comment_commit: null,
+      original_commit: null,
+      is_on_head: false,
+      is_visible_on_head_diff: false,
+      is_outdated: false,
+      is_moved: false,
+      has_ack: has_ack_marker(.body; $u),
+      is_actionable: (($is_bot | not) and likely_actionable(.body)),
+      priority: priority_from_body(.body),
+      title: normalize_title(.body),
+      body: .body
+    }
+]
+JQ
+  cat "$FILTER_COMMON" "$FILTER_CONVERSATION" > "$FILTER_CONVERSATION_FULL"
+  jq --argjson valid_commits "$VALID_COMMITS_JSON" -f "$FILTER_CONVERSATION_FULL" "$RAW_CONVERSATION" > "$LATEST_CONVERSATION"
+else
+  printf '%s\n' '[]' > "$LATEST_CONVERSATION"
+fi
+
+if [[ "$INCLUDE_REVIEWS" -eq 1 ]]; then
+  cat > "$FILTER_REVIEWS" <<'JQ'
+[ .[]
+  | select((.body // "") | test("\\S"))
+  | (.user // {}) as $u
+  | (
+      ($u.type // "") == "Bot"
+      or (($u.login // "") | endswith("[bot]"))
+    ) as $is_bot
+  | {
+      id,
+      author: ($u.login // "unknown"),
+      author_is_bot: $is_bot,
+      url: .html_url,
+      source: "review_summary",
+      review_state: (.state // "UNKNOWN"),
+      path: null,
+      line: null,
+      position: null,
+      original_position: null,
+      head_commit: null,
+      comment_commit: null,
+      original_commit: null,
+      is_on_head: false,
+      is_visible_on_head_diff: false,
+      is_outdated: false,
+      is_moved: false,
+      has_ack: has_ack_marker(.body; $u),
+      is_actionable: (
+        (($is_bot | not) and ((.state // "") == "CHANGES_REQUESTED"))
+        or (($is_bot | not) and likely_actionable(.body))
+      ),
+      priority: priority_from_body(.body),
+      title: normalize_title(.body),
+      body: .body
+    }
+]
+JQ
+  cat "$FILTER_COMMON" "$FILTER_REVIEWS" > "$FILTER_REVIEWS_FULL"
+  jq --argjson valid_commits "$VALID_COMMITS_JSON" -f "$FILTER_REVIEWS_FULL" "$RAW_REVIEWS" > "$LATEST_REVIEWS"
+else
+  printf '%s\n' '[]' > "$LATEST_REVIEWS"
+fi
+
+jq -s 'add' "$LATEST_REVIEW" "$LATEST_CONVERSATION" "$LATEST_REVIEWS" > "$LATEST_ALL"
+
+if ! IFS=$'\t' read -r \
+  total_count \
+  total_actionable_count \
+  needs_attention_count \
+  needs_attention_human_count \
+  needs_attention_bot_count \
+  on_head_attention_count \
+  outdated_attention_count \
+  conversation_attention_count \
+  review_summary_attention_count \
+  unclassified_count \
+  moved_count \
+  ack_count \
+  < <(
+    jq -r '
+      [
+        length,
+        ([.[] | select(.is_actionable == true)] | length),
+        ([.[] | select(.is_actionable == true and .has_ack == false)] | length),
+        ([.[] | select(.is_actionable == true and .has_ack == false and .author_is_bot == false)] | length),
+        ([.[] | select(.is_actionable == true and .has_ack == false and .author_is_bot == true)] | length),
+        ([.[] | select(.source == "review_thread" and .is_visible_on_head_diff == true and .is_actionable == true and .has_ack == false)] | length),
+        ([.[] | select(.source == "review_thread" and .is_outdated == true and .is_actionable == true and .has_ack == false)] | length),
+        ([.[] | select(.source == "conversation" and .is_actionable == true and .has_ack == false)] | length),
+        ([.[] | select(.source == "review_summary" and .is_actionable == true and .has_ack == false)] | length),
+        ([.[] | select(.is_actionable == false and .has_ack == false)] | length),
+        ([.[] | select(.source == "review_thread" and .is_moved == true)] | length),
+        ([.[] | select(.has_ack == true)] | length)
+      ] | @tsv
+    ' "$LATEST_ALL"
+  ); then
+  echo "Error: Failed to compute report counts from: ${LATEST_ALL}" >&2
+  exit 1
+fi
+
+{
+  echo "# PR Review Actionables — PR #${PR_NUMBER}"
+  echo
+  echo "- Repo: \`${REPO}\`"
+  echo "- PR head: \`${HEAD7}\`"
+  echo "- Generated: \`$(date -u +"%Y-%m-%dT%H:%M:%SZ")\`"
+  echo
+
+  echo "## Summary"
+  echo
+  echo "- Total extracted items: **${total_count}**"
+  echo "- Total actionable items: **${total_actionable_count}**"
+  echo "- Needs attention (actionable + unacknowledged): **${needs_attention_count}**"
+  echo "  - Human reviewers: **${needs_attention_human_count}**"
+  echo "  - Bots (including CodeRabbitAI): **${needs_attention_bot_count}**"
+  echo "  - Review threads (on head diff): **${on_head_attention_count}**"
+  echo "  - Review threads (outdated): **${outdated_attention_count}**"
+  echo "  - PR conversation: **${conversation_attention_count}**"
+  echo "  - Review summaries: **${review_summary_attention_count}**"
+  echo "- Unclassified (unacknowledged): **${unclassified_count}**"
+  echo "- Acknowledged (✅ Addressed): **${ack_count}**"
+  echo "- Moved by GitHub (review threads only; commit_id != original_commit_id): **${moved_count}**"
+  echo
+
+  echo "## Needs Attention (Review Threads — On Head Diff)"
+  echo
+  jq -r '
+    .[]
+    | select(.source == "review_thread" and .is_visible_on_head_diff == true and .is_actionable == true and .has_ack == false)
+    | "- [ ] [\(.priority)] \(.path):\(.line // 1) — \(.title) (by @\(.author)) [id=\(.id)]"
+  ' "$LATEST_ALL"
+  echo
+
+  echo "## Needs Attention (Review Threads — Outdated / Earlier Commits)"
+  echo
+  jq -r '
+    .[]
+    | select(.source == "review_thread" and .is_outdated == true and .is_actionable == true and .has_ack == false)
+    | "- [ ] [\(.priority)] \(.path):\(.line // 1) — \(.title) (by @\(.author), comment commit: \(.comment_commit)) [id=\(.id)]"
+  ' "$LATEST_ALL"
+  echo
+
+  echo "## Needs Attention (PR Conversation)"
+  echo
+  jq -r '
+    .[]
+    | select(.source == "conversation" and .is_actionable == true and .has_ack == false)
+    | "- [ ] [\(.priority)] PR conversation — \(.title) (by @\(.author)) [id=\(.id)]"
+  ' "$LATEST_ALL"
+  echo
+
+  echo "## Needs Attention (Review Summaries)"
+  echo
+  jq -r '
+    .[]
+    | select(.source == "review_summary" and .is_actionable == true and .has_ack == false)
+    | "- [ ] [\(.priority)] Review \((.review_state // "UNKNOWN")) — \(.title) (by @\(.author)) [id=\(.id)]"
+  ' "$LATEST_ALL"
+  echo
+
+  echo "## Unclassified (Conversation + Review Summaries)"
+  echo
+  jq -r '
+    .[]
+    | select((.source == "conversation" or .source == "review_summary") and .is_actionable == false and .has_ack == false)
+    | "- [ ] [\(.priority)] \(.source) — \(.title) (by @\(.author)) [id=\(.id)]"
+  ' "$LATEST_ALL"
+  echo
+
+  echo "## Acknowledged"
+  echo
+  jq -r '
+    .[]
+    | select(.has_ack == true)
+    | "- [ ] \(.source) — \(.title) (by @\(.author), acknowledged) [id=\(.id)]"
+  ' "$LATEST_ALL"
+  echo
+
+  echo "## Notes"
+  echo
+  echo "- \"Outdated\" means the review thread comment is no longer visible on the current head diff; it may still be actionable."
+  echo "- Use \`✅ Addressed in commit <sha>\` replies (or edits) to close the loop and keep future extraction cheap."
+  echo "- Conversation comments + review summaries are only included when requested; they are not diff-positioned like review threads."
+  echo
+
+  if [[ "$FULL" -eq 1 ]]; then
+    echo "## Full Comment Bodies (Needs Attention)"
+    echo
+    jq -r '
+      def pnum(p):
+        if p == "P0" then 0
+        elif p == "P1" then 1
+        elif p == "P2" then 2
+        else 3
+        end;
+
+      [ .[]
+        | select(.is_actionable == true and .has_ack == false)
+      ]
+      | sort_by([.source, (.is_outdated | if . then 1 else 0 end), pnum(.priority), .author, (.path // ""), (.line // 0)])
+      | .[]
+      | "### [\(.priority)] \(.source) — \((.path // "PR")):\(.line // 1) [id=\(.id)]\n\n- Author: @\(.author)\n- URL: \(.url // "unknown")\n- Review state: \((.review_state // "n/a"))\n- Visible on head diff: \(.is_visible_on_head_diff)\n- Outdated: \(.is_outdated)\n- Comment commit: \((.comment_commit // "n/a"))\n\n````\n\(.body)\n````\n"
+    ' "$LATEST_ALL"
+  else
+    echo "## Next Step"
+    echo
+    echo "Run with \`--full\` to include full comment bodies for the needs-attention set."
+  fi
+
+  echo
+  echo "## Artifacts"
+  echo
+  echo "- Review-thread raw: \`${RAW_REVIEW}\`"
+  echo "- Review-thread filtered: \`${LATEST_REVIEW}\`"
+  if [[ "$INCLUDE_CONVERSATION" -eq 1 ]]; then
+    echo "- Conversation raw: \`${RAW_CONVERSATION}\`"
+    echo "- Conversation filtered: \`${LATEST_CONVERSATION}\`"
+  fi
+  if [[ "$INCLUDE_REVIEWS" -eq 1 ]]; then
+    echo "- Review summaries raw: \`${RAW_REVIEWS}\`"
+    echo "- Review summaries filtered: \`${LATEST_REVIEWS}\`"
+  fi
+  echo "- Combined latest: \`${LATEST_ALL}\`"
+  echo "- Report: \`${REPORT}\`"
+  echo "- Note: artifacts are intentionally left in \`/tmp\` for debugging; your OS typically cleans \`/tmp\` periodically."
+} | tee "$REPORT"
+
+if [[ -n "$OUT" ]]; then
+  mkdir -p "$(dirname "$OUT")"
+  cp "$REPORT" "$OUT"
+fi
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ce046506..1e8abf91 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -37,6 +37,7 @@ Echo is a deterministic, renderer-agnostic engine. We prioritize:
 - Keep `main` pristine. Create feature branches like `echo/<feature>` or `timeline/<experiment>`.
 - Before starting work, ensure `git status` is clean. If not, resolve or coordinate with the human operator.
 - Before each session, update the “Today’s Intent” section in `docs/execution-plan.md` so future collaborators can follow the timeline.
+- PR review loops are procedural: follow `docs/procedures/PR-SUBMISSION-REVIEW-LOOP.md` and use `docs/procedures/EXTRACT-PR-COMMENTS.md` to extract actionable CodeRabbitAI feedback per round.
 
 ## Testing Expectations
 - Write tests before or alongside code changes.
diff --git a/docs/decision-log.md b/docs/decision-log.md
index 823e63a8..e9d2f8c6 100644
--- a/docs/decision-log.md
+++ b/docs/decision-log.md
@@ -6,8 +6,12 @@
 
 | Date | Context | Decision | Rationale | Consequence |
 | ---- | ------- | -------- | --------- | ----------- |
-| 2026-01-02 | Deterministic trig audit oracle (issue #177) | Replace the ignored trig “error budget” test’s platform-libm reference with a deterministic oracle using the pure-Rust `libm` crate, and pin explicit thresholds (max abs error vs f64 oracle + max ULP vs f32-rounded oracle for |ref| ≥ 0.25). | Cross-platform determinism requires that CI measurements never depend on host libc/libm behavior. Separating absolute-error budgeting (good near zero) from a gated ULP budget (meaningful away from zero) yields stable, interpretable guardrails for the LUT-backed trig backend. | The trig audit test is no longer `#[ignore]` and runs in CI; changes to the trig backend that worsen accuracy will fail fast with a concrete pinned budget. |
+| 2026-01-02 | PR hygiene: actionable review extraction | Include comment author attribution (human vs bot) and optionally include PR conversation comments + review summaries in actionable PR feedback reports so issues raised outside CodeRabbit review threads are surfaced and triaged. | Review loops often involve multiple voices and multiple comment surfaces; if the report only covers inline review threads (or only frames as “CodeRabbit”), human feedback and high-level review summaries can be missed. Attribution + optional wider extraction keeps triage deterministic and makes “who asked for what” explicit. | Reports can be triaged per reviewer/source; needs-attention counts are broken out by human vs bot and by source; full reports include URLs for quick thread navigation. |
+| 2026-01-02 | PR hygiene: non-thread noise control | For non-diff sources (PR conversation comments and review summaries), treat bot-authored items as “Unclassified” (context only) rather than “Needs attention”; reserve “Needs attention” for human-authored items on those surfaces. | Bot review summaries/status comments are not reliably ackable (you can’t reply-inline the way you can with review threads), and they often duplicate information already present in the review-thread extraction. Classifying them as “Needs attention” creates persistent noise and hides the real, ackable work. | When `--all-sources` is enabled, the report still shows bot summaries for context, but the “Needs attention” list stays focused on actionable human feedback + review threads. |
+| 2026-01-02 | PR hygiene: ack marker reliability | Treat “✅ Addressed in commit …” as acknowledged only when posted by a non-bot user and referencing a commit SHA that is part of the PR; for review threads, only count acks in replies (not inside the original review comment body). | Simple substring matching is unsafe because review bots can include the marker as a template/example in their own text. Commit validation prevents accidental matches and keeps the “ack” mechanism deterministic and trustworthy. | The extractor’s “Acknowledged” count can’t silently mask unresolved actionables; teams must explicitly ack each thread with a real commit reference to close the loop. |
+| 2026-01-02 | Deterministic trig audit oracle (issue #177) | Replace the ignored trig “error budget” test’s platform-libm reference with a deterministic oracle using the pure-Rust `libm` crate, and pin explicit thresholds (max abs error vs f64 oracle + max ULP vs f32-rounded oracle for abs(ref) ≥ 0.25). | Cross-platform determinism requires that CI measurements never depend on host libc/libm behavior. Separating absolute-error budgeting (good near zero) from a gated ULP budget (meaningful away from zero) yields stable, interpretable guardrails for the LUT-backed trig backend. | The trig audit test is no longer `#[ignore]` and runs in CI; changes to the trig backend that worsen accuracy will fail fast with a concrete pinned budget. |
 | 2026-01-02 | Demo tooling CI coverage (issue #215) | Add a Playwright CI job that runs the Session Dashboard smoke test and uploads the Playwright report + `test-results` artifacts (without mutating tracked docs screenshots). | The embedded dashboard is demo-critical and easy to regress; Rust-only CI can’t catch breaks in embedded HTML/CSS routing, runtime wiring, or browser rendering. Publishing artifacts makes failures actionable without requiring local repro. | PRs/pushes now gate on the dashboard smoke path; failures include an attached screenshot + report bundle for rapid debugging. |
+| 2026-01-01 | PR hygiene: review-loop determinism | Standardize CodeRabbitAI review triage by pinning “actionable comments” extraction to the PR head commit, and automate it with a script that groups stale vs fresh feedback. | GitHub carries review comments forward, which makes it easy to waste time on already-fixed feedback. Treating comment extraction as a procedure (and generating a report) keeps review loops deterministic and prevents ambiguity across rounds. | PR fix batches start from a clean, reproducible actionables list; stale comments are verified against current code before any work is done; review iteration cost drops. |
 | 2026-01-01 | Paper VI + roadmap hygiene (issue #180) | Add and maintain a Capability Ownership Matrix doc as a first-class artifact, alongside time-travel determinism notes, to keep ownership/determinism/provenance boundaries explicit. | As Echo grows, “who owns what” drifts silently; writing it down early prevents nondeterminism from leaking into the kernel and keeps tool/replay requirements grounded. | Future design and implementation work references the matrix for boundary decisions; specs and tooling tasks can be triaged against explicit determinism/provenance expectations. |
 | 2026-01-01 | WVP demo hardening: review nits follow-up | Demo 1 — Tighten loopback/publish tests to be defensively correct (overflow-safe packet sizing and explicit non-empty graph assertion) in response to CodeRabbit review feedback. | Even test-only code should not encode obvious footguns (unchecked `len + 32`) or rely on undocumented invariants without asserting them. | The test suite remains robust under adversarial framing and continues to document WVP demo assumptions explicitly. |
 | 2026-01-01 | WVP demo hardening (issue #169) | Demo 1 — Add loopback tests (service + viewer) that pin snapshot-first publish behavior, gapless epoch monotonicity, and forbidden/epoch-gap rejection. | The WVP “happy path” is demo-critical and easy to regress during refactors. Loopback tests provide fast feedback without requiring a real Unix socket listener or a browser/GUI harness. | `cargo test` now catches protocol regressions (authority, snapshot required, epoch gaps) and viewer publish-state bugs before they make the demo path flaky. |
diff --git a/docs/execution-plan.md b/docs/execution-plan.md
index 9022bd52..d6bba992 100644
--- a/docs/execution-plan.md
+++ b/docs/execution-plan.md
@@ -48,6 +48,17 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s
 - Exit criteria: PR is open + green (new CI job passes); artifacts are present on failure; `ci.yml` change is documented in `docs/decision-log.md`.
 - Tracking: GitHub issue #215.
 
+> 2026-01-02 — PR triage pipeline: start with PR #179 (IN PROGRESS)
+
+- Goal: sync to PR #179 and validate the tooling for pulling PR review comments and extracting actionable issues (including human reviewer notes), so we can systematically close review feedback across the open PR queue and merge cleanly once approved.
+- Scope:
+  - Checkout PR #179’s branch locally.
+  - Identify where the tool pulls PR comments from (GitHub API / `gh` CLI / local refs) and what comment types it includes (issue comments, review comments, review summaries).
+  - Ensure the report is attributable (comment author is included) so non-CodeRabbit actionables are not lost.
+  - Ensure “✅ Addressed in commit …” ack markers cannot be spoofed by templated bot text (require a human-authored ack with a real PR commit SHA).
+  - Run the tool against at least one PR to confirm output format and any required auth/config.
+- Exit criteria: documented “how to run” steps for the tool; confidence that we can repeatably extract issues from PR comments for subsequent PRs.
+
 > 2026-01-02 — Issue #177: deterministic trig audit oracle + pinned error budgets (IN PROGRESS)
 
 - Goal: un-ignore the trig “error budget” test by replacing its platform-libm reference with a deterministic oracle, then pin explicit accuracy thresholds so CI can catch regressions in the LUT-backed trig backend.
@@ -69,6 +80,14 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s
 - Exit criteria: matrix + notes are concrete enough to guide near-term implementation choices and future tool UX.
 - Tracking: GitHub issue #180.
 
+> 2026-01-01 — PR hygiene: standardize CodeRabbitAI review triage (COMPLETED)
+
+- Goal: make CodeRabbitAI review loops cheap and unambiguous by codifying how we extract actionable comments from the current PR head diff.
+- Scope:
+  - Add mandatory procedures under `docs/procedures/` for PR submission and review comment extraction.
+  - Add a helper script `.github/scripts/extract-actionable-comments.sh` to automate review comment bucketing and produce a Markdown report.
+- Exit criteria: a contributor can run one command and get a clean actionables list without re-reading the entire PR history.
+
 > 2026-01-01 — T2 (#168): embedded session dashboard baseline (COMPLETED)
 
 - Goal: keep the “run a binary, open a page” dashboard workflow stable while standardizing styling and keeping docs screenshots honest.
diff --git a/docs/procedures/EXTRACT-PR-COMMENTS.md b/docs/procedures/EXTRACT-PR-COMMENTS.md
new file mode 100644
index 00000000..c683a4cb
--- /dev/null
+++ b/docs/procedures/EXTRACT-PR-COMMENTS.md
@@ -0,0 +1,300 @@
+<!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
+<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
+# Procedure: Extract Actionable Comments from PR Review Threads (CodeRabbitAI + Humans)
+
+This procedure is part of the required PR workflow for this repo.
+
+GitHub carries forward review comments across commits, so you must extract only the **currently actionable** feedback (not already-fixed or stale comments) before starting another fix batch.
+
+---
+
+## Expected Workflow Context (Where This Fits)
+
+When you finish work:
+
+1. Push a branch and open a PR.
+2. Wait for CI + CodeRabbitAI.
+3. Extract actionable comments (this doc).
+4. Fix issues in small commits + push.
+5. Repeat until CodeRabbitAI (and any required human reviewer) approves.
+
+---
+
+## Prerequisites
+
+- `gh` installed and authenticated
+- `jq` installed (minimum: `jq >= 1.6`)
+- Repo access to view PRs
+
+---
+
+## Procedure
+
+### Quick Recommendation
+
+Prefer the repo automation whenever possible:
+
+```bash
+.github/scripts/extract-actionable-comments.sh <PR_NUMBER> --full
+```
+
+It is designed to:
+
+- include review comments from **all** authors (CodeRabbitAI *and* human reviewers),
+- include “outdated” comments that are not visible on the current head diff,
+- detect `✅ Addressed in commit ...` markers in replies (authored by a human),
+- and produce a deterministic Markdown report.
+
+To widen the net beyond inline review threads, you can include:
+
+- PR conversation comments (top-level PR timeline discussion), and
+- review summaries (approve / changes-requested review bodies).
+
+```bash
+.github/scripts/extract-actionable-comments.sh <PR_NUMBER> --all-sources --full
+```
+
+Note:
+- Conversation comments and review summaries are not diff-positioned like review threads, so the script applies a simple “likely actionable” heuristic and emits a separate “Unclassified” bucket for anything that doesn’t match.
+- For those non-thread sources, the script treats **human-authored** items as actionable; bot-authored summaries/status comments are included for context but are not counted as “Needs attention” (they can’t be reliably acked the same way review-thread replies can).
+
+### Step 1: Identify the PR head commit (the current diff)
+
+```bash
+PR_NUMBER="<PR_NUMBER>"
+LATEST_COMMIT="$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid[0:7]')"
+echo "PR head commit: $LATEST_COMMIT"
+```
+
+Why: comment staleness is measured relative to the current PR head.
+
+---
+
+### Step 2: Fetch all review comments (PR review threads)
+
+```bash
+OWNER="<OWNER>"
+REPO="<REPO>"
+TMPFILE="/tmp/pr-${PR_NUMBER}-comments-$(date +%s).json"
+gh api "repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/comments" --paginate > "$TMPFILE"
+```
+
+Note:
+- This endpoint returns PR review comments authored by anyone (humans, bots, CodeRabbitAI, etc.).
+
+---
+
+### Step 3: Extract top-level review comments (including “outdated”)
+
+Important:
+
+- GitHub’s review comments API (`/pulls/:number/comments`) keeps each comment’s `commit_id` fixed to the commit it was authored on.
+- When the PR head moves, older unresolved comments usually become **outdated** rather than being re-bound to the new head.
+- If you filter only to `commit_id == PR_HEAD`, you can incorrectly report “0 actionables” while older threads remain open.
+
+```bash
+cat "$TMPFILE" | jq --arg head "$LATEST_COMMIT" '
+  .[] |
+  select(.in_reply_to_id == null) |
+  {
+    id,
+    line,
+    path,
+    position,
+    head_commit: $head,
+    comment_commit: .commit_id[0:7],
+    original_commit: .original_commit_id[0:7],
+    is_visible_on_head_diff: (.position != null),
+    is_outdated: (.position == null),
+    is_moved: (.commit_id != .original_commit_id),
+    created_at,
+    body_preview: (.body[0:200])
+  }
+' | jq -s '.' > /tmp/comments-latest.json
+```
+
+---
+
+### Step 4: Bucket on-head vs outdated (and verify against current code)
+
+```bash
+cat /tmp/comments-latest.json | jq '
+  group_by(.is_outdated) |
+  map({
+    category: (if .[0].is_outdated then "OUTDATED (earlier commit)" else "ON_HEAD" end),
+    count: length,
+    comments: map({id, line, path, position, comment_commit, original_commit})
+  })
+'
+```
+
+Key insight:
+- “Outdated” means “not visible on the current head diff”, **not** “fixed”.
+- Always verify against current code before acting (see Step 7).
+
+---
+
+### Step 5: Detect “Already Addressed” markers
+
+The “✅ Addressed in commit …” marker is a lightweight ack convention used to prevent re-triaging the same comments.
+
+Important: **do not** treat a bare substring match as reliable.
+
+- Review bots (including CodeRabbitAI) may include the exact string as a template/example in their own review text.
+- If you count those as “acknowledged”, you can incorrectly report “0 actionables” and miss real work.
+
+For **review threads**, prefer a human-authored reply containing a commit SHA that is actually part of the PR.
+
+For **PR conversation comments** and **review summaries**, you may also use the marker in your own comment body (or edit), but only treat it as acknowledged when the marker includes a real PR commit SHA.
+
+If you want reliable ack detection, prefer the repo script:
+
+```bash
+.github/scripts/extract-actionable-comments.sh <PR_NUMBER>
+```
+
+```bash
+cat "$TMPFILE" | jq '
+  # Very rough, but safer than substring matching:
+  # - only count replies (in_reply_to_id != null)
+  # - only count markers that start a line and include a hex SHA
+  [ .[]
+    | select(.in_reply_to_id != null)
+    | select(.body | test("(?m)^\\s*✅ Addressed in commit [0-9a-f]{7,40}\\b"))
+    | { in_reply_to_id, reply_id: .id, user: (.user.login // "unknown"), body: (.body[0:80]) }
+  ]
+'
+```
+
+Key insight:
+- Explicit acks are only useful when they can’t be accidentally “spoofed” by templated bot text.
+
+---
+
+### Step 6: Categorize by priority (optional)
+
+This is only useful if CodeRabbitAI uses explicit priority markers in comment bodies.
+
+```bash
+cat "$TMPFILE" | jq --arg head "$LATEST_COMMIT" '
+  .[] |
+  select(
+    .in_reply_to_id == null
+  ) |
+  {
+    id,
+    line,
+    path,
+    priority: (
+      if (.body | test("\\bP0\\b|badge/P0-|🔴|Critical"; "i")) then "P0"
+      elif (.body | test("\\bP1\\b|badge/P1-|🟠|Major"; "i")) then "P1"
+      elif (.body | test("\\bP2\\b|badge/P2-|🟡|Minor"; "i")) then "P2"
+      else "P3"
+      end
+    ),
+    is_visible_on_head_diff: (.position != null),
+    is_outdated: (.position == null),
+    body
+  }
+' | jq -s '.' > /tmp/prioritized-comments.json
+```
+
+---
+
+### Step 7: Verify outdated comments against current code (critical step)
+
+Do not trust `is_outdated` alone. Verify by mapping fields from the comment object to concrete commands.
+
+Suggested mapping:
+
+- `path` → file path
+- `line` → line number (use a small context window, e.g. ±5 lines)
+
+Example:
+
+```bash
+# Suppose you have a single comment object (e.g., from /tmp/comments-latest.json):
+COMMENT_PATH="docs/decision-log.md"
+COMMENT_LINE=42
+
+# Clamp the start line to 1 (sed doesn't like 0/negative ranges).
+START=$((COMMENT_LINE - 5))
+if [[ "$START" -lt 1 ]]; then START=1; fi
+END=$((COMMENT_LINE + 5))
+
+# 1) Inspect current state around the line
+git show "HEAD:${COMMENT_PATH}" | sed -n "${START},${END}p"
+
+# 2) Scan recent history for related fixes
+git log --all --oneline -- "${COMMENT_PATH}" | head -20
+
+# 3) If the comment mentions a specific identifier (function/struct name), search by token
+git log -p --all -S"SomeIdentifierFromComment" -- "${COMMENT_PATH}" | head -80
+```
+
+If the comment is outdated (not visible on head diff), it may refer to old line numbers. In that case:
+
+- search by keyword/token rather than trusting the line number, and
+- look up the original code context via `original_commit_id` if needed.
+
+```bash
+# Use the repo script if you want the comment bodies included:
+.github/scripts/extract-actionable-comments.sh <PR_NUMBER> --full
+```
+
+---
+
+### Step 8: Produce an issue report (batch)
+
+Create a batch checklist and work top-down:
+
+```bash
+cat > /tmp/batch-N-issues.md << 'EOF'
+# Batch N - CodeRabbitAI Issues
+
+## Outdated (Verify / Already Fixed)
+- [ ] Line XXX - Issue description (Fixed in: COMMIT_SHA)
+
+## P0 Critical
+- [ ] Line XXX - Issue description
+
+## P1 Major
+- [ ] Line XXX - Issue description
+
+## P2 Minor
+- [ ] Line XXX - Issue description
+
+## P3 Trivial
+- [ ] Line XXX - Issue description
+EOF
+```
+
+---
+
+### Step 9: Save full bodies for needs-attention issues
+
+Prefer the helper script, which understands ack markers in replies and can print full bodies:
+
+```bash
+.github/scripts/extract-actionable-comments.sh <PR_NUMBER> --full
+```
+
+---
+
+## When CodeRabbitAI approval doesn’t unblock GitHub
+
+If CodeRabbitAI approved but GitHub still shows “changes requested”, nudge the bot:
+
+```text
+@coderabbitai Please re-review the latest commit and submit a new approval to update the review status.
+```
+
+Note: CodeRabbitAI can’t “dismiss” its own prior review; it updates the PR status by submitting a new review after re-reviewing the latest commit.
+
+---
+
+## Automation
+
+Use the helper script:
+
+- `.github/scripts/extract-actionable-comments.sh`
diff --git a/docs/procedures/PR-SUBMISSION-REVIEW-LOOP.md b/docs/procedures/PR-SUBMISSION-REVIEW-LOOP.md
new file mode 100644
index 00000000..09dcb352
--- /dev/null
+++ b/docs/procedures/PR-SUBMISSION-REVIEW-LOOP.md
@@ -0,0 +1,127 @@
+<!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
+<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
+# Procedure: PR Submission + CodeRabbitAI Review Loop
+
+This document defines the required end-to-end submission workflow for this repo.
+
+It is deliberately operational: follow it step-by-step and avoid “interpretation drift”.
+
+---
+
+## Rules (Non‑Negotiable)
+
+1. No direct-to-`main` commits.
+2. No admin bypass merges to skip required reviews.
+3. CI green is required but not sufficient — review approval is a separate gate.
+4. Iterate in small commits to reduce review ambiguity.
+5. Every PR must reference a GitHub Issue in the PR body with closing keywords (e.g., `Closes #123`).
+
+---
+
+## Workflow (Branch → PR → Review → Fix → Merge)
+
+### Step 0 — Start on a branch
+
+Prefer a clear prefix:
+
+- `docs/...` for docs-only changes
+- `feat/...` for features
+- `fix/...` for bug fixes
+- `chore/...` for tooling/maintenance
+
+```bash
+git checkout -b <branch-name>
+```
+
+---
+
+### Step 1 — Push and open a PR
+
+```bash
+git push -u origin <branch-name>
+gh pr create --base main --head <branch-name>
+```
+
+---
+
+### Step 2 — Wait for CI and CodeRabbitAI
+
+Watch checks:
+
+```bash
+gh pr checks <PR_NUMBER> --watch
+```
+
+Then wait for CodeRabbitAI to comment. Do not merge “because CI is green”.
+
+If CodeRabbitAI doesn’t respond within a reasonable time (or you see a failing status like “Review rate limit exceeded”):
+
+1. Check PR checks for rate limit/error details.
+2. Post `@coderabbitai review` on the PR to re-trigger.
+3. If it still fails, wait and retry (or escalate to repo admins if it persists).
+
+---
+
+### Step 3 — Extract actionable review feedback (required)
+
+Use:
+
+- `docs/procedures/EXTRACT-PR-COMMENTS.md`
+
+The outcome of this step should be a bucketed list of actionable items (P0/P1/P2/P3).
+
+---
+
+### Step 4 — Fix issues in batches (commit + push)
+
+Work one bucket at a time:
+
+- P0: correctness / determinism / security (CodeRabbitAI: 🔴 Critical)
+- P1: major design/API drift (CodeRabbitAI: 🟠 Major)
+- P2: minor issues / maintainability (CodeRabbitAI: 🟡 Minor)
+- P3: nits (CodeRabbitAI: 🔵 Trivial / nitpicks)
+
+For each batch:
+
+```bash
+git commit -m "fix: <description>"
+git push
+```
+
+When replying in threads, prefer:
+
+> ✅ Addressed in commit `abc1234`
+
+This reduces stale-comment confusion in later rounds.
+
+---
+
+### Step 5 — Repeat until approved
+
+Repeat Steps 2–4 until:
+
+- CI checks are green, and
+- CodeRabbitAI is satisfied (approved or no unresolved actionables), and
+- any required human reviewer has approved.
+
+---
+
+### Step 6 — Merge only when approved
+
+If branch protection requires it, enable auto-merge:
+
+```bash
+gh pr merge <PR_NUMBER> --auto --merge
+```
+
+---
+
+## If CodeRabbitAI approved but GitHub stays blocked
+
+Sometimes the “changes requested” status lingers even after an approving review.
+
+Post this comment on the PR:
+
+```text
+@coderabbitai Please review the latest commit and clear the "changes requested" status since you have already approved the changes.
+```
