forgedock 1.11.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/engine/phases-args.test.mjs +113 -0
  2. package/bin/engine/phases.mjs +433 -109
  3. package/bin/engine/runlog.mjs +21 -1
  4. package/bin/engine-cli.mjs +14 -2
  5. package/bin/engine.mjs +150 -19
  6. package/bin/forgedock.mjs +2 -2
  7. package/bin/hooks/interactive-engine.mjs +3 -0
  8. package/bin/labels.json +5 -0
  9. package/bin/orchestrate-preflight.mjs +1 -1
  10. package/bin/runner.mjs +111 -12
  11. package/bin/watch-utils.mjs +1 -0
  12. package/commands/cleanup.md +1 -1
  13. package/commands/orchestrate/phase-4-execution.md +58 -24
  14. package/commands/orchestrate/phase-6-report.md +29 -0
  15. package/commands/quality-gate.md +96 -12
  16. package/commands/recover-orphans.md +5 -5
  17. package/commands/review-pr-agents/protocols.md +1 -1
  18. package/commands/review-pr-agents.md +11 -11
  19. package/commands/review-pr.md +227 -33
  20. package/commands/work-on/build/implement.md +1 -1
  21. package/commands/work-on/build/validate.md +58 -7
  22. package/commands/work-on/build.md +11 -4
  23. package/commands/work-on/decompose.md +2 -2
  24. package/commands/work-on/investigate.md +14 -4
  25. package/commands/work-on/remediate.md +109 -19
  26. package/commands/work-on/review.md +79 -25
  27. package/commands/work-on.md +23 -15
  28. package/docs/WORK-ON-RUNTIME.md +2 -2
  29. package/docs/spec/label-state-machine.md +6 -1
  30. package/docs/spec/review-protocol.md +1 -1
  31. package/package.json +1 -1
  32. package/packages/protocol/src/phases.js +27 -1
  33. package/scripts/check-branch-ancestry.sh +82 -0
  34. package/scripts/check-branch-ancestry.test.sh +78 -0
  35. package/scripts/check-spec-markers.sh +2 -0
  36. package/scripts/forge-root.test.sh +36 -15
  37. package/scripts/review-pr-domain-map.test.sh +87 -0
  38. package/scripts/spec-no-worktree-script.test.sh +46 -0
  39. package/scripts/transition-label.sh +3 -1
@@ -176,7 +176,7 @@ Append this block at the very end of your comment (after the `---` footer line,
176
176
  6. **Summary**: Concise one-line description (no pipe `|` characters in summary)
177
177
  7. **Empty block**: If no findings at all, include just the START/END markers
178
178
  8. **HTML comments**: The block is invisible in rendered markdown but parseable by the review system
179
- 9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the lowercase dispatched domain.
179
+ 9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the marker domain the orchestrator passes you (the canonical value from the roster-to-marker table in `review-pr.md`, e.g. `infra` and `scraper`; never a lowercased display name such as `infrastructure` or `scraping`).
180
180
  10. **Reviewed SHA**: Include exactly one line `Reviewed-SHA: [REVIEW_SHA]` (the full 40-hex head you were pinned to) in the persisted body, outside the marker. `/review-pr` scopes its completion wait and idempotent re-entry to this line, so a comment without the current head does not count as a completed review. Do NOT put the SHA inside the `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker.
181
181
 
182
182
  ### Domain Prefixes
@@ -46,17 +46,17 @@ For each agent selected in Phase 3B, read **only** the files relevant to that ag
46
46
 
47
47
  2. **Then read the persona file for each selected agent**:
48
48
 
49
- | Agent | Trigger | File |
50
- |-------|---------|------|
51
- | General Security & Quality Scan | ALWAYS RUNS | `review-pr-agents/security.md` |
52
- | Auth Conventions Auditor | AUTH domain | `review-pr-agents/auth.md` |
53
- | Billing Integrity Auditor | BILLING domain | `review-pr-agents/billing.md` |
54
- | Concurrency & Race Condition Auditor | CONCURRENCY or BILLING domain | `review-pr-agents/concurrency.md` |
55
- | Domain Logic Auditor | SCRAPING domain (requires `review.domains.scraping` in forge.yaml) | `review-pr-agents/scraper.md` |
56
- | Frontend Quality Auditor | WEB service touched | `review-pr-agents/frontend.md` |
57
- | API Design & Consistency Auditor | New/modified routers or SDK/OpenAPI files | `review-pr-agents/api.md` |
58
- | Database & Migration Auditor | DATABASE domain | `review-pr-agents/database.md` |
59
- | Infrastructure & Deploy Safety Auditor | INFRA service touched | `review-pr-agents/infra.md` |
49
+ | Agent | Trigger | Marker domain | File |
50
+ |-------|---------|---------------|------|
51
+ | General Security & Quality Scan | ALWAYS RUNS | `security` | `review-pr-agents/security.md` |
52
+ | Auth Conventions Auditor | AUTH domain | `auth` | `review-pr-agents/auth.md` |
53
+ | Billing Integrity Auditor | BILLING domain | `billing` | `review-pr-agents/billing.md` |
54
+ | Concurrency & Race Condition Auditor | CONCURRENCY or BILLING domain | `concurrency` | `review-pr-agents/concurrency.md` |
55
+ | Domain Logic Auditor | SCRAPING domain (requires `review.domains.scraping` in forge.yaml) | `scraper` | `review-pr-agents/scraper.md` |
56
+ | Frontend Quality Auditor | WEB service touched | `frontend` | `review-pr-agents/frontend.md` |
57
+ | API Design & Consistency Auditor | New/modified routers or SDK/OpenAPI files | `api` | `review-pr-agents/api.md` |
58
+ | Database & Migration Auditor | DATABASE domain | `database` | `review-pr-agents/database.md` |
59
+ | Infrastructure & Deploy Safety Auditor | INFRA service touched | `infra` | `review-pr-agents/infra.md` |
60
60
 
61
61
  ## Domain Prefixes (for Structured Findings)
62
62
 
@@ -538,6 +538,27 @@ fi
538
538
  ```
539
539
 
540
540
  ```bash
541
+ # Install-root-only classifier resolution (no cwd-relative `scripts/` fallback: the PR under review is author-controlled).
542
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
543
+ FORGE_ROOT=""
544
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
545
+ _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
546
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
547
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
548
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
549
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
550
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
551
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
552
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
553
+ # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
554
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
555
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
556
+ # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
557
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
558
+ while IFS= read -r _c; do
559
+ case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
560
+ done <<< "$_k"
561
+ fi
541
562
  # Check each configured language for a test command
542
563
  for lang in python typescript go rust; do
543
564
  TEST_CMD=$(yq ".verification.commands.${lang}.test // \"\"" forge.yaml 2>/dev/null || echo '')
@@ -550,9 +571,8 @@ for lang in python typescript go rust; do
550
571
  # If ALL failing test names appear in $QUARANTINED_TESTS, suppress the block.
551
572
  # Without per-test granularity from the test runner, fall back to treating
552
573
  # any failure as blocking unless the classifier script is available.
553
- CLASSIFIER="${FORGEDOCK_SCRIPTS:-scripts}/flaky-quarantine.sh"
554
- [ -f "$CLASSIFIER" ] || CLASSIFIER="scripts/flaky-quarantine.sh"
555
- if [ -f "$CLASSIFIER" ]; then
574
+ CLASSIFIER="${FORGE_ROOT:+$FORGE_ROOT/scripts/flaky-quarantine.sh}"
575
+ if [ -n "$CLASSIFIER" ] && [ -f "$CLASSIFIER" ]; then
556
576
  CL_RESULT=$(bash "$CLASSIFIER" \
557
577
  --test "$TEST_CMD" \
558
578
  --base "$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null | sed 's|origin/||' || echo main)" \
@@ -1152,11 +1172,47 @@ echo "=== BASELINE ROSTER (top domains): $SELECTED_AGENTS ==="
1152
1172
  | Cross-critical domains | `SCORE_AUTH >= 2` AND `SCORE_DATABASE >= 3` | Auth + Database |
1153
1173
  | Cross-critical domains | `SCORE_BILLING >= 2` AND `SCORE_CONCURRENCY >= 2` | Billing + Concurrency + Database |
1154
1174
 
1175
+ **Roster label to marker domain (single source of truth)**: roster labels come in three spellings (score keys such as `INFRA`/`SCRAPING`, display names such as `Infrastructure`/`Scraping`, persona files such as `infra`/`scraper`). The marker domain in `<!-- FORGE:REVIEW-AGENT:{domain} -->` is always the canonical value below, never the lowercased roster label. Phase 4 stays fail closed on that canonical marker: no alias is accepted. <!-- Added: forge#3485 -->
1176
+
1177
+ | Roster label (any case) | Marker domain | Persona file |
1178
+ |-------------------------|---------------|--------------|
1179
+ | `Security` | `security` | `security.md` |
1180
+ | `Auth` | `auth` | `auth.md` |
1181
+ | `Billing` | `billing` | `billing.md` |
1182
+ | `Concurrency` | `concurrency` | `concurrency.md` |
1183
+ | `Database` | `database` | `database.md` |
1184
+ | `INFRA`, `Infrastructure` | `infra` | `infra.md` |
1185
+ | `SCRAPING`, `Scraping`, `Scraper` | `scraper` | `scraper.md` |
1186
+ | `FRONTEND`, `Frontend`, `Web` | `frontend` | `frontend.md` |
1187
+ | `API` | `api` | `api.md` |
1188
+
1189
+ A label outside the table falls back to its lowercase form. The `agent_domain()` helper is defined inline in every Bash block that needs it (each Bash call is a fresh shell); all copies must stay identical, which `scripts/review-pr-domain-map.test.sh` enforces.
1190
+
1155
1191
  ```bash
1156
1192
  # Apply escalation triggers
1193
+ # BEGIN review-pr-agent-domain
1194
+ agent_domain() {
1195
+ case "$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]')" in
1196
+ security) echo security ;;
1197
+ auth) echo auth ;;
1198
+ billing) echo billing ;;
1199
+ concurrency) echo concurrency ;;
1200
+ database) echo database ;;
1201
+ infra|infrastructure) echo infra ;;
1202
+ scraper|scraping) echo scraper ;;
1203
+ frontend|web) echo frontend ;;
1204
+ api) echo api ;;
1205
+ *) printf '%s' "${1}" | tr '[:upper:]' '[:lower:]' ;;
1206
+ esac
1207
+ }
1208
+ # END review-pr-agent-domain
1157
1209
  add_agent() {
1158
- local AGENT="${1}"
1159
- echo "$SELECTED_AGENTS" | grep -qw "$AGENT" || SELECTED_AGENTS="$SELECTED_AGENTS $AGENT"
1210
+ local AGENT CUR HAVE=""
1211
+ AGENT=$(agent_domain "${1}")
1212
+ for CUR in $SELECTED_AGENTS; do
1213
+ [ "$(agent_domain "$CUR")" = "$AGENT" ] && HAVE=1
1214
+ done
1215
+ [ -n "$HAVE" ] || SELECTED_AGENTS="$SELECTED_AGENTS $AGENT"
1160
1216
  }
1161
1217
 
1162
1218
  [ "$SCORE_AUTH" -ge 3 ] && add_agent "Auth"
@@ -1392,6 +1448,34 @@ if [ "$THOROUGH" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; then
1392
1448
  fi
1393
1449
  ```
1394
1450
 
1451
+ **Roster canonicalization (final step of 3B, mandatory)**: rewrite `SELECTED_AGENTS` to canonical marker domains with one entry per persona, so `INFRA` and `Infrastructure` (or `SCRAPING` and `Scraping`) dispatch once and `SELECTED_AGENT_COUNT` counts each persona once. Pass the rewritten value to Phase 3C and Phase 4 as `{SELECTED_AGENTS}`. <!-- Added: forge#3485 -->
1452
+
1453
+ ```bash
1454
+ # BEGIN review-pr-agent-domain
1455
+ agent_domain() {
1456
+ case "$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]')" in
1457
+ security) echo security ;;
1458
+ auth) echo auth ;;
1459
+ billing) echo billing ;;
1460
+ concurrency) echo concurrency ;;
1461
+ database) echo database ;;
1462
+ infra|infrastructure) echo infra ;;
1463
+ scraper|scraping) echo scraper ;;
1464
+ frontend|web) echo frontend ;;
1465
+ api) echo api ;;
1466
+ *) printf '%s' "${1}" | tr '[:upper:]' '[:lower:]' ;;
1467
+ esac
1468
+ }
1469
+ # END review-pr-agent-domain
1470
+ CANON_ROSTER=""
1471
+ for AGENT in $SELECTED_AGENTS; do
1472
+ D=$(agent_domain "$AGENT")
1473
+ case " $CANON_ROSTER " in *" $D "*) ;; *) CANON_ROSTER="$CANON_ROSTER $D" ;; esac
1474
+ done
1475
+ SELECTED_AGENTS="${CANON_ROSTER# }"
1476
+ echo "=== CANONICAL ROSTER: $SELECTED_AGENTS ==="
1477
+ ```
1478
+
1395
1479
  **Why cross-critical domain pairs always escalate**: A 2-file PR touching both `services/api/app/core/auth.py` and `services/api/app/routers/billing.py` creates interaction bugs that single-domain reviewers cannot catch. Never rely on a single agent for multi-domain risk.
1396
1480
 
1397
1481
  **General Security agent ALWAYS runs.** If BILLING is selected, Concurrency is always added. If SHARED module is touched, add agents for all importing services.
@@ -1670,14 +1754,35 @@ if [ "$REVIEW_SHA_OK" != "true" ]; then
1670
1754
  fi
1671
1755
  WAIT_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/forge-review-wait-$(id -u)" # absolute and identical in every Bash call (cwd is not)
1672
1756
  rm -f "${WAIT_DIR}/${PR_NUMBER}_${REVIEW_SHA}_review-wait-deadline" # a (re)dispatch always starts a fresh wait window
1757
+ # TRUSTED_SCRIPT resolver (canonical; byte-identical across specs, guarded by scripts/forge-root.test.sh): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude. Never the working directory (author-controlled, #3400). The cache scan matters because the plugin-root placeholder is not always substituted in forked runs.
1673
1758
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
1759
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
1760
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
1761
+ [ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
1762
+ done
1674
1763
  TRUSTED_SCRIPT=""
1675
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
1764
+ while IFS= read -r _c; do
1676
1765
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1677
- done
1766
+ done <<< "$_tc"
1678
1767
  PENDING_AGENTS=""
1768
+ # BEGIN review-pr-agent-domain
1769
+ agent_domain() {
1770
+ case "$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]')" in
1771
+ security) echo security ;;
1772
+ auth) echo auth ;;
1773
+ billing) echo billing ;;
1774
+ concurrency) echo concurrency ;;
1775
+ database) echo database ;;
1776
+ infra|infrastructure) echo infra ;;
1777
+ scraper|scraping) echo scraper ;;
1778
+ frontend|web) echo frontend ;;
1779
+ api) echo api ;;
1780
+ *) printf '%s' "${1}" | tr '[:upper:]' '[:lower:]' ;;
1781
+ esac
1782
+ }
1783
+ # END review-pr-agent-domain
1679
1784
  for AGENT in $SELECTED_AGENTS; do
1680
- AGENT_DOMAIN=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
1785
+ AGENT_DOMAIN=$(agent_domain "$AGENT")
1681
1786
  HAVE=""
1682
1787
  if [ -n "$TRUSTED_SCRIPT" ]; then
1683
1788
  HAVE=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
@@ -1689,7 +1794,7 @@ done
1689
1794
  echo "PENDING_AGENTS:${PENDING_AGENTS:- (none)}"
1690
1795
  ```
1691
1796
 
1692
- **CRITICAL**: Launch ALL agents in `PENDING_AGENTS` in a SINGLE message using multiple `{DISPATCH_TOOL}` calls. If `PENDING_AGENTS` is empty, every selected reviewer already posted for this head: launch nothing and go straight to Phase 4. Each agent must persist its finalized body before posting it with `gh pr comment --body-file`, start the body with `<!-- FORGE:REVIEW-AGENT:{lowercase-domain} -->` followed by a `Reviewed-SHA: ${REVIEW_SHA}` line (the full head SHA, outside the marker), and return its verdict and findings to the orchestrator independently of GitHub delivery.
1797
+ **CRITICAL**: Launch ALL agents in `PENDING_AGENTS` in a SINGLE message using multiple `{DISPATCH_TOOL}` calls. If `PENDING_AGENTS` is empty, every selected reviewer already posted for this head: launch nothing and go straight to Phase 4. Each agent must persist its finalized body before posting it with `gh pr comment --body-file`, start the body with `<!-- FORGE:REVIEW-AGENT:{marker-domain} -->` (`{marker-domain}` is the canonical value from the roster-to-marker table, i.e. the `AGENT_DOMAIN` computed above for that agent; pass it to each persona explicitly) followed by a `Reviewed-SHA: ${REVIEW_SHA}` line (the full head SHA, outside the marker), and return its verdict and findings to the orchestrator independently of GitHub delivery.
1693
1798
 
1694
1799
  **Dispatch failure and partial-panel guard (MANDATORY):** Count the selected roster before dispatch. If any launch fails, including from pool exhaustion, do not continue with the agents that did launch as a sufficient panel. Immediately run the **Panel hard-stop block** below with `ACTUAL_AGENT_COUNT=0` and exit without a verdict. After reviewers complete (joined `Task`/`task` return) or, under async `Agent`, after the Phase 4 bounded wait ends, independently compare their posted trusted current-SHA `FORGE:REVIEW-AGENT` markers with the selected count; a smaller count is the same hard stop. This catches a reviewer that accepted dispatch but failed before posting. Under async `Agent` the comparison runs only after the wait, so a reviewer that is merely late is not a false degraded stop.
1695
1800
 
@@ -1788,16 +1893,37 @@ if [ "$NOW_SHA" != "$REVIEW_SHA" ]; then
1788
1893
  echo "PANEL_STATUS: STOP"; exit 0
1789
1894
  fi
1790
1895
 
1896
+ # TRUSTED_SCRIPT resolver (canonical; byte-identical across specs, guarded by scripts/forge-root.test.sh): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude. Never the working directory (author-controlled, #3400). The cache scan matters because the plugin-root placeholder is not always substituted in forked runs.
1791
1897
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
1898
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
1899
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
1900
+ [ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
1901
+ done
1792
1902
  TRUSTED_SCRIPT=""
1793
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
1903
+ while IFS= read -r _c; do
1794
1904
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1795
- done
1905
+ done <<< "$_tc"
1796
1906
  COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
1797
1907
  MISSING_AGENT_COMMENTS=""
1798
1908
  ACTUAL_AGENT_COUNT=0
1909
+ # BEGIN review-pr-agent-domain
1910
+ agent_domain() {
1911
+ case "$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]')" in
1912
+ security) echo security ;;
1913
+ auth) echo auth ;;
1914
+ billing) echo billing ;;
1915
+ concurrency) echo concurrency ;;
1916
+ database) echo database ;;
1917
+ infra|infrastructure) echo infra ;;
1918
+ scraper|scraping) echo scraper ;;
1919
+ frontend|web) echo frontend ;;
1920
+ api) echo api ;;
1921
+ *) printf '%s' "${1}" | tr '[:upper:]' '[:lower:]' ;;
1922
+ esac
1923
+ }
1924
+ # END review-pr-agent-domain
1799
1925
  for AGENT in $SELECTED_AGENTS; do
1800
- AGENT_DOMAIN=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
1926
+ AGENT_DOMAIN=$(agent_domain "$AGENT")
1801
1927
  N=""
1802
1928
  if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$COMMENTS_JSON" ]; then
1803
1929
  N=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
@@ -1840,11 +1966,16 @@ case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA
1840
1966
  # CURRENT head. The head SHA is public, so the SHA line alone proves nothing: a bare contains() selection would let any
1841
1967
  # commenter forge FINDING markers that get filed as issues or inflate the agent count. Earlier-head comments are excluded
1842
1968
  # so a re-entry or head move does not re-file already-fixed findings. Uses jq scan() (POSIX-portable, no PCRE grep).
1969
+ # TRUSTED_SCRIPT resolver (canonical; byte-identical across specs, guarded by scripts/forge-root.test.sh): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude. Never the working directory (author-controlled, #3400). The cache scan matters because the plugin-root placeholder is not always substituted in forked runs.
1843
1970
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
1971
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
1972
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
1973
+ [ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
1974
+ done
1844
1975
  TRUSTED_SCRIPT=""
1845
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
1976
+ while IFS= read -r _c; do
1846
1977
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1847
- done
1978
+ done <<< "$_tc"
1848
1979
  COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
1849
1980
  AGENT_RE="^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)"
1850
1981
  if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then
@@ -1884,11 +2015,16 @@ case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA
1884
2015
  # Only TRUSTED bodies for the CURRENT head count (scripts/trusted-comments.sh + anchored marker + Reviewed-SHA line):
1885
2016
  # findings from earlier heads are already fixed or re-reviewed, and an untrusted commenter must never be able to inject
1886
2017
  # findings or post a forged synthesis block that makes the review read clean (the head SHA is public).
2018
+ # TRUSTED_SCRIPT resolver (canonical; byte-identical across specs, guarded by scripts/forge-root.test.sh): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude. Never the working directory (author-controlled, #3400). The cache scan matters because the plugin-root placeholder is not always substituted in forked runs.
1887
2019
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
2020
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
2021
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
2022
+ [ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
2023
+ done
1888
2024
  TRUSTED_SCRIPT=""
1889
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
2025
+ while IFS= read -r _c; do
1890
2026
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1891
- done
2027
+ done <<< "$_tc"
1892
2028
  COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
1893
2029
  if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then
1894
2030
  echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read clean (fail closed)"; exit 0
@@ -1958,11 +2094,16 @@ fi
1958
2094
  # fix round on this PR. Any trusted FORGE:INPR_FIX* marker already on the PR means the round is used.
1959
2095
  INPR_DIFF_FILE=""
1960
2096
  if [ "${AUTO_MERGE:-false}" = "true" ] && [ -n "${MERGE_ISSUE:-}" ]; then
2097
+ # TRUSTED_SCRIPT resolver (canonical; byte-identical across specs, guarded by scripts/forge-root.test.sh): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude. Never the working directory (author-controlled, #3400). The cache scan matters because the plugin-root placeholder is not always substituted in forked runs.
1961
2098
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
2099
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
2100
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
2101
+ [ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
2102
+ done
1962
2103
  TRUSTED_SCRIPT=""
1963
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
2104
+ while IFS= read -r _c; do
1964
2105
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1965
- done
2106
+ done <<< "$_tc"
1966
2107
  if [ -n "$TRUSTED_SCRIPT" ]; then INPR_MARKERS=$(gh api --paginate "repos/{GH_REPO}/issues/{PR_NUMBER}/comments" 2>/dev/null \
1967
2108
  | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:INPR_FIX' 2>/dev/null || echo ""); else INPR_MARKERS=""; fi
1968
2109
  if [ "$INPR_MARKERS" = "0" ]; then
@@ -2449,21 +2590,37 @@ Verdict determined by standard blocking criteria.
2449
2590
  1. Phase 2 automated checks failed (build error, type error, test failure)
2450
2591
  2. Agent finding is CONFIRMED at HIGH or CRITICAL severity
2451
2592
  3. **[Milestone PRs only]** Phase 7A Purpose Regression Gate flagged `HAS_PURPOSE_REGRESSION=true` for this finding — regardless of whether it causes a runtime error
2452
- 4. `MERGE_HEALTH == "CONFLICTING"` OR `MERGE_HEALTH_STATE` in {`DIRTY`, `BLOCKED`} — PR cannot be merged cleanly into its base branch <!-- Added: forge#194 -->
2453
- - Verdict: CHANGES REQUESTED. Message: "Merge conflict with `{base}`. Rebase `{head}` onto `origin/{base}`, resolve the conflicting files, then re-run /review-pr."
2593
+ 4. **Base conflict** — `BASE_CONFLICT=true` (computed in the block below): `MERGE_HEALTH == "CONFLICTING"`, OR `MERGE_HEALTH_STATE == "DIRTY"`, OR `MERGE_HEALTH_STATE == "BEHIND"` while the base branch requires up-to-date branches — PR cannot be merged cleanly into its base branch <!-- Added: forge#194, forge#3496 -->
2594
+ - Verdict: CHANGES REQUESTED. Message: "Base conflict with `{base}`. Merge `origin/{base}` into `{head}` (merge only; no rebase, no force-push), resolve the conflicting files by reading both sides, then re-run /review-pr."
2595
+ - A merely-`BEHIND` branch on a base that does NOT require up-to-date branches is not a conflict and needs no sync: GitHub merges it fine.
2596
+ - `MERGE_HEALTH_STATE == "BLOCKED"` alone (branch protection not satisfied: missing required review or check) is NOT a base conflict: syncing the base cannot clear it. Emit a WARNING line in the verdict body and leave `HAS_MERGE_CONFLICT=false`; the Phase 8 pre-merge guard handles it as a protection abort.
2454
2597
  - If `MERGE_HEALTH == "UNKNOWN"` after retries: emit a WARNING in the verdict body (do NOT treat as a block — GitHub may still be computing it).
2455
2598
  5. A CONFIRMED coverage reduction (deleted test, removed test case, or disabled/removed workflow test step) in a PR that responds to a red check, or that has no justification tying it to removal of the tested code <!-- Added: forge#3257 -->
2456
2599
  - Verdict: CHANGES REQUESTED. Message: "Coverage reduction: a deleted test or removed workflow test step is not a fix for a red check. Restore it and fix the code under test, or escalate naming the failing assertions." Detection follows quality-gate 2U (`COVERAGE-1`); never dedup this finding against the PR's own issue.
2457
2600
 
2458
2601
  ```bash
2459
2602
  # Determine if mergeability is a blocker (MERGE_HEALTH/MERGE_HEALTH_STATE set in Phase 1A; BASE/HEAD set in Phase 0 Mode 3)
2603
+ # BASE_CONFLICT is the single source of truth for "a base sync would fix this"; the Phase 8 guards read it. <!-- Added: forge#3496 -->
2460
2604
  HAS_MERGE_CONFLICT=false
2605
+ BASE_CONFLICT=false
2461
2606
  MERGE_CONFLICT_MSG=""
2462
- if [ "$MERGE_HEALTH" = "CONFLICTING" ] || [ "$MERGE_HEALTH_STATE" = "DIRTY" ] || [ "$MERGE_HEALTH_STATE" = "BLOCKED" ]; then
2607
+ MERGE_PROTECTION_WARNING=""
2608
+ BASE_REQUIRES_UPTODATE=false
2609
+ if [ "$MERGE_HEALTH_STATE" = "BEHIND" ]; then
2610
+ # Unreadable protection (404 / 403 / no admin access) is treated as "not required": no sync, and a later failed merge lands in the existing needs-human path.
2611
+ [ "$(gh api "repos/${REPO}/branches/${BASE}/protection" --jq '.required_status_checks.strict // false' 2>/dev/null)" = "true" ] && BASE_REQUIRES_UPTODATE=true
2612
+ fi
2613
+ if [ "$MERGE_HEALTH" = "CONFLICTING" ] || [ "$MERGE_HEALTH_STATE" = "DIRTY" ] || { [ "$MERGE_HEALTH_STATE" = "BEHIND" ] && [ "$BASE_REQUIRES_UPTODATE" = "true" ]; }; then
2463
2614
  HAS_MERGE_CONFLICT=true
2464
- MERGE_CONFLICT_MSG="Merge conflict with \`${BASE}\`. Rebase \`${HEAD}\` onto \`origin/${BASE}\`, resolve the conflicting files, then re-run /review-pr."
2615
+ BASE_CONFLICT=true
2616
+ MERGE_CONFLICT_MSG="Base conflict with \`${BASE}\`. Merge \`origin/${BASE}\` into \`${HEAD}\` (merge only, no rebase, no force-push), resolve the conflicting files by reading both sides, then re-run /review-pr."
2617
+ elif [ "$MERGE_HEALTH_STATE" = "BLOCKED" ]; then
2618
+ MERGE_PROTECTION_WARNING="Merge state is BLOCKED: branch protection is not satisfied (required review or check). Syncing the base cannot clear this."
2465
2619
  fi
2466
2620
 
2621
+ # ONLY_BASE_CONFLICT is NOT computed here: CALIBRATION_NEEDS_HUMAN is only set later (Phase 7B.5) and TRUST_NEEDS_HUMAN in Phase 3B.5,
2622
+ # so it is evaluated at the top of the Phase 8 guard, after every human gate has been set. <!-- forge#3496 -->
2623
+
2467
2624
  # Resolve attribution footer (forge.yaml → attribution.pr_footer)
2468
2625
  ATTRIBUTION_PR_FOOTER=$(grep -A5 "^attribution:" forge.yaml 2>/dev/null | grep "pr_footer:" | awk '{print $(2)}' | tr -d '"' || echo "false")
2469
2626
  ATTRIBUTION_FOOTER_LINE=""
@@ -2490,7 +2647,9 @@ $([ "$MERGE_HEALTH" = "UNKNOWN" ] && echo "
2490
2647
  gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "CHANGES REQUESTED: commit $REVIEW_SHA_SHORT — [N] blocking issues found. See GitHub issues.
2491
2648
  ${TRUST_ANNOTATION}
2492
2649
  $([ "$HAS_MERGE_CONFLICT" = "true" ] && echo "
2493
- 🔴 Merge Conflict: ${MERGE_CONFLICT_MSG}")
2650
+ 🔴 Base Conflict: ${MERGE_CONFLICT_MSG}")
2651
+ $([ -n "$MERGE_PROTECTION_WARNING" ] && echo "
2652
+ ⚠ ${MERGE_PROTECTION_WARNING}")
2494
2653
  $([ "$HAS_PURPOSE_REGRESSION" = "true" ] && echo "
2495
2654
  ⚠ Purpose Regression: [N] finding(s) contradict the milestone's stated goal and are automatically blocking regardless of runtime impact. See: ${PURPOSE_REGRESSION_FINDINGS[@]}")${ATTRIBUTION_FOOTER_LINE}"
2496
2655
  ```
@@ -2599,11 +2758,16 @@ fi
2599
2758
  FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test("<!-- FINDING:"))] | length')
2600
2759
  # Trust predicate: ONE shared copy (scripts/trusted-comments.sh, same as verify-phase-trail.sh): trusted association, Bot account, or
2601
2760
  # FORGE_TRAIL_TRUSTED_LOGINS. A GitHub App bot always has author_association NONE, so an association-only filter drops the pipeline's own markers.
2761
+ # TRUSTED_SCRIPT resolver (canonical; byte-identical across specs, guarded by scripts/forge-root.test.sh): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude. Never the working directory (author-controlled, #3400). The cache scan matters because the plugin-root placeholder is not always substituted in forked runs.
2602
2762
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
2763
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
2764
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
2765
+ [ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
2766
+ done
2603
2767
  TRUSTED_SCRIPT=""
2604
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
2768
+ while IFS= read -r _c; do
2605
2769
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
2606
- done
2770
+ done <<< "$_tc"
2607
2771
  if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$FINDING_COUNT" ]; then
2608
2772
  echo "NOTE DISPOSITION: scripts/trusted-comments.sh unresolvable or findings unreadable — refusing to merge (fail closed)" >&2
2609
2773
  # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "note disposition unreadable".
@@ -2747,7 +2911,19 @@ If the preflight failed, skip the rest of Phase 8. On exit code 1, return `REVIE
2747
2911
  # These vars are set in Phase 7A/7B/7B.5/3B.5 earlier in the same agent session.
2748
2912
  # An unset/empty VERDICT is safe — it evaluates to "" which does not equal "CHANGES REQUESTED".
2749
2913
  # TRUST_NEEDS_HUMAN: set to true by Phase 3B.5 when INTENSITY_TIER=NOVEL_NEEDS_HUMAN AND shadow mode is off.
2750
- if [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "${TRUST_NEEDS_HUMAN:-false}" = "true" ]; then
2914
+ # ONLY_BASE_CONFLICT: the base conflict is the ONLY reason the verdict is CHANGES REQUESTED. Computed HERE (after Phase 7B.5 and 3B.5) so the
2915
+ # calibration and trust human gates are already set and can never be skipped for a base-conflict PR. <!-- forge#3496 -->
2916
+ # Set OTHER_BLOCKING=true when ANY other blocking criterion (1, 2, 3 or 5 in Phase 7B) fired for this PR. When true, route to needs-human below.
2917
+ ONLY_BASE_CONFLICT=false
2918
+ if [ "${BASE_CONFLICT:-false}" = "true" ] && [ "${OTHER_BLOCKING:-false}" != "true" ] && [ "${HAS_PURPOSE_REGRESSION:-false}" != "true" ] && [ "${CALIBRATION_NEEDS_HUMAN:-false}" != "true" ] && [ "${TRUST_NEEDS_HUMAN:-false}" != "true" ]; then
2919
+ ONLY_BASE_CONFLICT=true
2920
+ fi
2921
+ if [ "${ONLY_BASE_CONFLICT:-false}" = "true" ] && [ "$VERDICT" = "CHANGES REQUESTED" ]; then
2922
+ # Pure base conflict (set in Phase 7B: the ONLY blocker): route to the base-sync path, NOT needs-human. <!-- Added: forge#3496 -->
2923
+ # work-on/review.md R4 sets workflow:remediating (not needs-human) when it hands off to remediate; a standalone /review-pr caller just sees the blocker.
2924
+ gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "⛔ Auto-merge aborted for PR #{PR_NUMBER}: base conflict with \`{MERGE_BASE}\` (the only blocking reason). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/{MERGE_BASE}\` into the PR branch, merge only, no rebase, no force-push), re-running the quality gate, then re-reviewing the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
2925
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "base-conflict". Do NOT add needs-human and do NOT attempt gh pr merge.
2926
+ elif [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "${TRUST_NEEDS_HUMAN:-false}" = "true" ]; then
2751
2927
  BLOCK_REASON=""
2752
2928
  [ "$VERDICT" = "CHANGES REQUESTED" ] && BLOCK_REASON="review verdict is CHANGES REQUESTED (blocking finding confirmed by Phase 7B)"
2753
2929
  [ "$HAS_PURPOSE_REGRESSION" = "true" ] && BLOCK_REASON="${BLOCK_REASON:+${BLOCK_REASON}; }purpose regression detected by Phase 7A (\`HAS_PURPOSE_REGRESSION=true\`)"
@@ -2765,10 +2941,23 @@ PRE_MERGE_RESULT=$(gh pr view {PR_NUMBER} {MERGE_GH_FLAG} --json mergeable,merge
2765
2941
  PRE_MERGE_HEALTH=${PRE_MERGE_RESULT%%|*}
2766
2942
  PRE_MERGE_HEALTH_STATE=${PRE_MERGE_RESULT##*|}
2767
2943
 
2768
- if [ "$PRE_MERGE_HEALTH" = "CONFLICTING" ] || [ "$PRE_MERGE_HEALTH_STATE" = "DIRTY" ] || [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then
2769
- gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "⛔ Auto-merge aborted for PR #{PR_NUMBER}: PR is not mergeable (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). Rebase the branch onto \`{MERGE_BASE}\` and resolve conflicts, then re-run /review-pr."
2944
+ PRE_MERGE_BASE_CONFLICT=false
2945
+ if [ "$PRE_MERGE_HEALTH" = "CONFLICTING" ] || [ "$PRE_MERGE_HEALTH_STATE" = "DIRTY" ]; then
2946
+ PRE_MERGE_BASE_CONFLICT=true
2947
+ elif [ "$PRE_MERGE_HEALTH_STATE" = "BEHIND" ]; then
2948
+ # BEHIND is a conflict only under required-up-to-date protection; unreadable protection counts as "not required". <!-- Added: forge#3496 -->
2949
+ [ "$(gh api "repos/${REPO}/branches/{MERGE_BASE}/protection" --jq '.required_status_checks.strict // false' 2>/dev/null)" = "true" ] && PRE_MERGE_BASE_CONFLICT=true
2950
+ fi
2951
+
2952
+ if [ "$PRE_MERGE_BASE_CONFLICT" = "true" ]; then
2953
+ # Base moved during review: route to the base-sync path, NOT needs-human. <!-- Added: forge#3496 -->
2954
+ gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "⛔ Auto-merge aborted for PR #{PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/{MERGE_BASE}\` into the PR branch, merge only, no rebase, no force-push) and then re-reviewing. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
2955
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "base-conflict". Do NOT add needs-human and do NOT attempt gh pr merge.
2956
+ elif [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then
2957
+ # Branch protection not satisfied: syncing the base cannot clear it, so this is NOT a base-conflict blocker.
2958
+ gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "⛔ Auto-merge aborted for PR #{PR_NUMBER}: branch protection is not satisfied (\`mergeStateStatus=BLOCKED\`); syncing the base cannot clear it. Satisfy the required review/checks, then re-run /review-pr."
2770
2959
  gh issue edit {MERGE_ISSUE} {MERGE_GH_FLAG} --add-label "needs-human" 2>/dev/null || true
2771
- # STOP — do not attempt gh pr merge on a CONFLICTING/DIRTY PR
2960
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "branch protection not satisfied" (never "base-conflict")
2772
2961
  else
2773
2962
 
2774
2963
  # Previously-escalated re-review guard <!-- Added: forge#1810; base-scoped: forge#2570 -->
@@ -2789,7 +2978,7 @@ else
2789
2978
  # bot-only re-review (authorAssociation=NONE) could never satisfy the strict ≥2 verified-
2790
2979
  # human bar and so stranded staging PRs the fast lane would auto-merge.
2791
2980
  PREVIOUSLY_ESCALATED=$(gh issue view {MERGE_ISSUE} {MERGE_GH_FLAG} --json labels,comments \
2792
- --jq '([.labels[].name | . == "needs-human"] + [.comments[].body | contains("FORGE:REMEDIATION")]) | any' 2>/dev/null || echo "false")
2981
+ --jq '([.labels[].name | . == "needs-human" or . == "workflow:remediating"] + [.comments[].body | contains("FORGE:REMEDIATION")]) | any' 2>/dev/null || echo "false")
2793
2982
  GUARD_BASE=$(gh pr view {PR_NUMBER} {MERGE_GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
2794
2983
 
2795
2984
  if [ "$PREVIOUSLY_ESCALATED" = "true" ]; then
@@ -2813,7 +3002,7 @@ if [ "$PREVIOUSLY_ESCALATED" = "true" ]; then
2813
3002
  adaptive|universal) bash "$SCRIPT_PATH" {MERGE_ISSUE} {MERGE_GH_FLAG} awaiting-merge ;;
2814
3003
  prose)
2815
3004
  gh issue edit {MERGE_ISSUE} {MERGE_GH_FLAG} --add-label "workflow:awaiting-merge" \
2816
- --remove-label "needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
3005
+ --remove-label "needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
2817
3006
  ;;
2818
3007
  esac
2819
3008
  # STOP — do not attempt gh pr merge here. The merge decision belongs to a human for a
@@ -3049,11 +3238,16 @@ A degraded run that skipped Task-based agent dispatch must be visible from this
3049
3238
 
3050
3239
  ```bash
3051
3240
  # Same trust rule as Phase 3C/4: only trusted comments whose body starts with the marker count.
3241
+ # TRUSTED_SCRIPT resolver (canonical; byte-identical across specs, guarded by scripts/forge-root.test.sh): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude. Never the working directory (author-controlled, #3400). The cache scan matters because the plugin-root placeholder is not always substituted in forked runs.
3052
3242
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
3243
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
3244
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
3245
+ [ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
3246
+ done
3053
3247
  TRUSTED_SCRIPT=""
3054
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
3248
+ while IFS= read -r _c; do
3055
3249
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
3056
- done
3250
+ done <<< "$_tc"
3057
3251
  ACTUAL_AGENT_DOMAINS=""
3058
3252
  if [ -n "$TRUSTED_SCRIPT" ]; then
3059
3253
  ACTUAL_AGENT_DOMAINS=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
@@ -42,7 +42,7 @@ Parse from $ARGUMENTS:
42
42
 
43
43
  ---
44
44
 
45
- - `--fix-acceptance "<failed checks>"` — optional. Set only by build B6.5 after a failed acceptance gate. In this mode, skip I1–I2 planning: read the listed failing `ACCEPTANCE_CHECK` ids from the FORGE:INVESTIGATOR comment, change the code in `{WORKTREE_PATH}` until each check's target/matcher holds (do not edit the checks), update the BUILDER comment's `### Changes` list, and return `IMPLEMENT_RESULT: status: COMPLETE`. If a check is impossible as written (contradicts the contract), return `status: BLOCKED` with that reason.
45
+ - `--fix-acceptance "<failed checks>"` — optional. Set only by build B6.5 after a failed acceptance gate. In this mode, skip I1–I2 planning: read the listed failing `ACCEPTANCE_CHECK` ids from the FORGE:INVESTIGATOR comment, change the code in `{WORKTREE_PATH}` until each check's target/matcher holds (do not edit the checks), update the BUILDER comment's `### Changes` list, and return `IMPLEMENT_RESULT: status: COMPLETE`. If a check is impossible as written (contradicts the contract), return `status: BLOCKED` with that reason. **Decomposition refusal** <!-- Added: forge#3543 -->: never implement work that the FORGE:INVESTIGATOR `### Decomposition Assessment` (`**YES**`) assigns to a separate sub-issue, even if a listed check targets it; return `status: BLOCKED` with blocker "check <id> belongs to decomposed item <N>" instead.
46
46
 
47
47
  ## Phase I1: Load Context from GitHub
48
48
 
@@ -559,21 +559,72 @@ Reference `#{NUMBER}` in the message. This is the **only** commit for this build
559
559
 
560
560
  ### V5 Step 3: Post-Commit Ancestry Audit (MANDATORY)
561
561
 
562
- After committing, run the ancestry audit to detect merge commits from unrelated branches before the branch is pushed. If `origin/{PR_BASE}` does not exist yet (new branch), skip this check — no contamination is possible from a non-existent base.
562
+ After committing, run the ancestry audit to detect merge commits that bring in history from outside the PR base before the branch is pushed. Merging `origin/{PR_BASE}` itself into the branch (a base sync) is allowed: every non-first parent of each merge must be an ancestor of `origin/{PR_BASE}`. If `origin/{PR_BASE}` does not exist yet (new branch), skip this check — no contamination is possible from a non-existent base.
563
563
 
564
564
  ```bash
565
565
  cd {WORKTREE_PATH}
566
566
  if git ls-remote --exit-code origin {PR_BASE} >/dev/null 2>&1; then
567
567
  git fetch origin {PR_BASE} >/dev/null 2>&1 || true
568
- MERGE_COMMITS=$(git log --merges HEAD ^origin/{PR_BASE} 2>/dev/null)
569
- if [ -n "$MERGE_COMMITS" ]; then
570
- echo "ANCESTRY AUDIT FAILED: merge commits from unrelated branches detected on this branch:"
568
+ # Same install-root-only resolution as work-on/review.md R1 (resolve_script 'check-branch-ancestry'): the script gates this branch's own push, so it is NEVER taken from the worktree (the PR branch under audit) or a cwd-relative path. Unresolvable -> prose fallback below, which fails closed.
569
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
570
+ FORGE_ROOT=""
571
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
572
+ _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
573
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
574
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
575
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
576
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
577
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
578
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
579
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
580
+ # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
581
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
582
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
583
+ # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
584
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
585
+ while IFS= read -r _c; do
586
+ case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
587
+ done <<< "$_k"
588
+ fi
589
+ ANCESTRY_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-branch-ancestry.sh}"; [ -f "$ANCESTRY_SCRIPT" ] || ANCESTRY_SCRIPT=""
590
+ if [ -n "$ANCESTRY_SCRIPT" ]; then
591
+ MERGE_COMMITS=$(bash "$ANCESTRY_SCRIPT" HEAD origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
592
+ else
593
+ # Prose fallback: same checks inline (exit 0 clean, 1 foreign, 2 error). Fails closed: unresolvable refs or git errors give rc=2.
594
+ ANCESTRY_RC=0; MERGE_COMMITS=""
595
+ if ! git rev-parse --verify --quiet "origin/{PR_BASE}^{commit}" >/dev/null 2>&1 || ! git rev-parse --verify --quiet "HEAD^{commit}" >/dev/null 2>&1; then
596
+ ANCESTRY_RC=2
597
+ elif ! MERGES=$(git rev-list --merges origin/{PR_BASE}..HEAD 2>/dev/null) || ! FP=$(git rev-list --first-parent origin/{PR_BASE}..HEAD 2>/dev/null); then
598
+ ANCESTRY_RC=2
599
+ else
600
+ for M in $MERGES; do
601
+ PARENTS=$(git rev-list --parents -n 1 "$M" 2>/dev/null) || { ANCESTRY_RC=2; continue; }
602
+ for P in $(printf '%s\n' "$PARENTS" | cut -d' ' -f3-); do
603
+ git merge-base --is-ancestor "$P" origin/{PR_BASE} 2>/dev/null; rc=$?
604
+ if [ "$rc" -eq 1 ]; then MERGE_COMMITS="${MERGE_COMMITS}${M} ${P}
605
+ "; [ "$ANCESTRY_RC" -eq 2 ] || ANCESTRY_RC=1
606
+ elif [ "$rc" -ne 0 ]; then ANCESTRY_RC=2; fi
607
+ done
608
+ done
609
+ # First-parent line must not carry milestone history (branch cut from a milestone, then base sync).
610
+ for F in $(git for-each-ref --format='%(refname)' refs/remotes/origin/milestone/ refs/heads/milestone/ 2>/dev/null); do
611
+ for C in $FP; do
612
+ git merge-base --is-ancestor "$C" "$F" 2>/dev/null; rc=$?
613
+ if [ "$rc" -eq 0 ]; then MERGE_COMMITS="${MERGE_COMMITS}${C} first-parent history reachable from ${F}
614
+ "; [ "$ANCESTRY_RC" -eq 2 ] || ANCESTRY_RC=1
615
+ elif [ "$rc" -ne 1 ]; then ANCESTRY_RC=2; fi
616
+ done
617
+ done
618
+ fi
619
+ fi
620
+ if [ "$ANCESTRY_RC" -ne 0 ]; then
621
+ echo "ANCESTRY AUDIT FAILED (rc=$ANCESTRY_RC): merge commits from outside {PR_BASE} detected, or ancestry could not be verified:"
571
622
  echo "$MERGE_COMMITS"
572
623
  ANCESTRY_BODY="## Ancestry Audit Failed
573
624
 
574
- Branch \`{BRANCH}\` contains merge commits from branches outside the PR base (\`{PR_BASE}\`). This is a staging contamination risk — these commits may carry code from milestone branches that has not been approved for \`{PR_BASE}\`.
625
+ Branch \`{BRANCH}\` contains merge commits that bring in history from outside the PR base (\`{PR_BASE}\`), or ancestry could not be verified (failing closed). This is a staging contamination risk — these commits may carry code from milestone branches that has not been approved for \`{PR_BASE}\`. Merges of \`{PR_BASE}\` itself are allowed and do not trigger this audit.
575
626
 
576
- **Detected merge commits**:
627
+ **Detected merge commits** (merge, foreign parent, subject — or the verification error):
577
628
  \`\`\`
578
629
  ${MERGE_COMMITS}
579
630
  \`\`\`
@@ -590,7 +641,7 @@ else
590
641
  fi
591
642
  ```
592
643
 
593
- If the output contains `ANCESTRY_FAILED=1`: do NOT append `:COMPLETE` and do NOT push. Print `VALIDATE_RESULT` with `gate_passed: false` and `blocker: ancestry audit failed — merge commits from unrelated branches` as the final reply, and STOP.
644
+ If the output contains `ANCESTRY_FAILED=1`: do NOT append `:COMPLETE` and do NOT push. Print `VALIDATE_RESULT` with `gate_passed: false` and `blocker: ancestry audit failed — merge commits from outside the PR base` as the final reply, and STOP.
594
645
 
595
646
  ### V5 Step 4: Mark Build Complete and Record Verification Status (MANDATORY)
596
647