forgedock 1.10.3 → 1.12.0

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 (58) hide show
  1. package/bin/engine/phases-args.test.mjs +112 -0
  2. package/bin/engine/phases.mjs +152 -45
  3. package/bin/engine/runlog.mjs +9 -0
  4. package/bin/engine-cli.mjs +13 -2
  5. package/bin/engine.mjs +144 -18
  6. package/bin/forgedock.mjs +2 -2
  7. package/bin/runner.mjs +89 -12
  8. package/commands/ci-audit.md +3 -3
  9. package/commands/compat-audit.md +1 -1
  10. package/commands/deploy-pr.md +6 -6
  11. package/commands/explain.md +2 -2
  12. package/commands/fix-ci.md +2 -2
  13. package/commands/forge-stats.md +1 -1
  14. package/commands/forgedock-init.md +3 -3
  15. package/commands/optimize.md +5 -5
  16. package/commands/orchestrate/phase-1-resolve.md +2 -2
  17. package/commands/orchestrate/phase-4-execution.md +208 -48
  18. package/commands/orchestrate/phase-6-report.md +29 -0
  19. package/commands/quality-gate.md +97 -13
  20. package/commands/recover-orphans.md +6 -6
  21. package/commands/review-pr-agents/protocols.md +1 -0
  22. package/commands/review-pr-staging.md +28 -6
  23. package/commands/review-pr.md +509 -132
  24. package/commands/security-audit.md +1 -1
  25. package/commands/spec-doctor.md +2 -2
  26. package/commands/test-gate.md +7 -7
  27. package/commands/work-on/build/architect.md +21 -0
  28. package/commands/work-on/build/context.md +1 -1
  29. package/commands/work-on/build/implement.md +17 -0
  30. package/commands/work-on/build/validate.md +60 -9
  31. package/commands/work-on/build.md +98 -9
  32. package/commands/work-on/close.md +33 -11
  33. package/commands/work-on/decompose.md +19 -5
  34. package/commands/work-on/investigate.md +34 -4
  35. package/commands/work-on/remediate.md +117 -13
  36. package/commands/work-on/review.md +66 -26
  37. package/commands/work-on.md +32 -15
  38. package/docs/CONFIG.md +30 -0
  39. package/docs/RELEASE-NOTES.md +9 -0
  40. package/docs/WORK-ON-RUNTIME.md +24 -4
  41. package/docs/site/architecture.md +1 -1
  42. package/docs/spec/forge-protocol-v1.md +10 -0
  43. package/docs/spec/review-protocol.md +1 -0
  44. package/package.json +1 -1
  45. package/packages/protocol/src/phases.js +6 -1
  46. package/packages/protocol/src/types.js +17 -0
  47. package/scripts/check-branch-ancestry.sh +82 -0
  48. package/scripts/check-branch-ancestry.test.sh +78 -0
  49. package/scripts/check-spec-bash.sh +45 -2
  50. package/scripts/check-spec-bash.test.sh +24 -1
  51. package/scripts/check-spec-markers.sh +55 -0
  52. package/scripts/diff-size.sh +84 -0
  53. package/scripts/diff-size.test.sh +117 -0
  54. package/scripts/forge-root.test.sh +16 -1
  55. package/scripts/remediation-marker-match.test.sh +51 -0
  56. package/scripts/review-pr-arguments.test.sh +180 -0
  57. package/scripts/spec-no-worktree-script.test.sh +46 -0
  58. package/scripts/trusted-comments.test.sh +23 -0
@@ -76,7 +76,7 @@ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac
76
76
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
77
77
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
78
78
  # 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)
79
- _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)"
79
+ _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)"
80
80
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
81
81
  # '${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.
82
82
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -95,7 +95,7 @@ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped,
95
95
  # to search the filesystem. <!-- Added: forge#1984 -->
96
96
 
97
97
  resolve_script() {
98
- local operation="$1"
98
+ local operation="${1}"
99
99
  # Tier 2: per-repo adaptive (skip if disabled)
100
100
  if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
101
101
  echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
@@ -164,7 +164,7 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
164
164
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR") | not) | {author: .user.login, body: .body}'
165
165
  ```
166
166
 
167
- Scan non-agent comments for override signals — phrases like "do not", "do NOT", "instead", "revert", "remove this", "override", "actually", or explicit disagreement with the investigation's recommendation. If an override comment is found from a repo owner or admin (not a bot):
167
+ Ignore machine-posted marker comments (`FORGE:DIFF_SIZE`, `FORGE:SIZE_OVERRIDE`) in this scan: a `FORGE:DIFF_SIZE` comment is a plan source (below), never an owner override direction. Scan non-agent comments for override signals — phrases like "do not", "do NOT", "instead", "revert", "remove this", "override", "actually", or explicit disagreement with the investigation's recommendation. If an override comment is found from a repo owner or admin (not a bot):
168
168
 
169
169
  1. **Document the override**: Note which direction the owner is steering (e.g., "remove the feature" vs. investigation's "keep with warnings")
170
170
  2. **Re-derive sub-issue scopes**: Derive sub-issue titles, bodies, and file scope from the override direction — NOT from the original investigation recommendation. The investigation's Decomposition Assessment may list sub-issues that are now stale or contradictory with the override.
@@ -177,6 +177,20 @@ Scan non-agent comments for override signals — phrases like "do not", "do NOT"
177
177
 
178
178
  Extract from investigation report:
179
179
  - Decomposition Assessment section: list of proposed sub-issues with titles and dependencies
180
+
181
+ **Size-gate plan source** <!-- Added: forge#3450 -->: when the build's diff-size gate routed here, the investigation said no decomposition, so its assessment lists no sub-issues. Read the latest TRUSTED `FORGE:DIFF_SIZE` comment (anchored, through `scripts/trusted-comments.sh`; the Script resolution block applies) and use it when it has `result: OVER`:
182
+
183
+ ```bash
184
+ # <Script resolution block, verbatim>
185
+ set -o pipefail
186
+ TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
187
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ]; then
188
+ SIZE_PLAN=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE' | jq -r 'select(type == "string")' ) \
189
+ && echo "SIZE_PLAN_READ=ok" || echo "SIZE_PLAN_READ=FAILED"
190
+ else echo "SIZE_PLAN_READ=FAILED"; fi
191
+ ```
192
+
193
+ A `FAILED` read or a comment without `result: OVER` means there is no size-gate plan (the assessment rules below then apply unchanged). Its `### Split Proposal` lines (`- **{title}** — {files}`) are the proposed sub-issues, in dependency order, for Phase D2.
180
194
  - Milestone (from issue metadata)
181
195
  - Priority label (P0/P1/P2) from issue labels
182
196
 
@@ -203,7 +217,7 @@ If `PARENT_REF` is non-empty → BLOCKED, blocker: "Issue #{NUMBER} is a sub-iss
203
217
 
204
218
  **Guard 2 — investigation report present**: If the FORGE:INVESTIGATOR comment is absent → BLOCKED, blocker: "No investigation report found — run investigate first".
205
219
 
206
- **Guard 3 — decomposition plan present**: If the investigation report has no Decomposition Assessment section, OR the assessment does not list any sub-issues → BLOCKED, blocker: "Investigation report has no decomposition plan — re-run investigate with explicit decomposition scope".
220
+ **Guard 3 — decomposition plan present**: If the investigation report has no Decomposition Assessment section, OR the assessment does not list any sub-issues, AND there is no trusted `FORGE:DIFF_SIZE` comment with `result: OVER` and a `### Split Proposal` (the size-gate plan source from D0) → BLOCKED, blocker: "Investigation report has no decomposition plan — re-run investigate with explicit decomposition scope".
207
221
 
208
222
  **Resume check**:
209
223
 
@@ -246,7 +260,7 @@ If `GIST_URLS` is non-empty, a `## Prior Investigation` section will be appended
246
260
 
247
261
  ## Phase D2: Design Sub-Issues
248
262
 
249
- From the Decomposition Assessment in the investigation report (adjusted for any owner override detected in Phase D0), extract:
263
+ From the Decomposition Assessment in the investigation report (adjusted for any owner override detected in Phase D0), or, when the assessment lists none, from the size-gate `### Split Proposal` in the trusted `FORGE:DIFF_SIZE` comment (Phase D0), extract:
250
264
  1. Sub-issue titles (in dependency order — independent issues first)
251
265
  2. Dependencies between sub-issues (if issue B depends on issue A, A is created first)
252
266
  3. Brief description for each sub-issue body
@@ -91,7 +91,7 @@ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac
91
91
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
92
92
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
93
93
  # 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)
94
- _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)"
94
+ _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)"
95
95
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
96
96
  # '${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.
97
97
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -110,7 +110,7 @@ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped,
110
110
  # to search the filesystem. <!-- Added: forge#1984 -->
111
111
 
112
112
  resolve_script() {
113
- local operation="$1"
113
+ local operation="${1}"
114
114
  # Tier 2: per-repo adaptive (skip if disabled)
115
115
  if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
116
116
  echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
@@ -581,6 +581,26 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
581
581
  > **Scope-Gap Warning**: The issue spec lists `{PRIMARY_FILE}` but the same pattern exists in `{SIBLING_FILE}:{LINE}`. These were likely introduced together. Recommend widening scope to fix all callers in this PR, or creating follow-up issues for the other files before proceeding.
582
582
 
583
583
  Do NOT silently exclude sibling matches. The appropriate output when sibling files have the same bug is to flag them explicitly — even if the issue spec's silence appears intentional. The fix-approach validation step (step 8) will confirm whether to widen scope or create follow-ups. <!-- Added: forge#383 -->
584
+ 7.6. **Finding Pattern Sweep** *(conditional — eligible when the issue has label `review-finding`, or its body carries a `FORGE:PATTERN` or `FORGE:PATTERN-CLASS` tag)*: Review findings are instances of a defect class. Fixing only the cited instance lets sibling instances resurface in the next review, so sweep the whole repo for the class before settling the affected-file list. For eligible issues this step replaces step 7.5; step 7.5 runs for non-eligible issues, and as the fallback when 7.6 is skipped or degrades. <!-- Added: forge#3449 -->
585
+ ```bash
586
+ REPO="{GH_REPO}"; NUMBER="{NUMBER}"
587
+ BODY=$(gh issue view "$NUMBER" -R "$REPO" --json body --jq '.body') || BODY=""
588
+ # Slug comes from model-written text: validate before ANY shell or jq use.
589
+ # Class-level issues carry `<!-- FORGE:PATTERN: slug -->` too (review-pr Phase 6C), so one extractor covers both.
590
+ SLUG=$(printf '%s\n' "$BODY" | grep -o 'FORGE:PATTERN: [A-Za-z0-9_-]*' | head -1 | sed 's/^FORGE:PATTERN: //')
591
+ if ! [[ "$SLUG" =~ ^[a-z0-9-]+$ ]]; then
592
+ echo "PATTERN SWEEP SKIPPED: no valid FORGE:PATTERN slug (expected ^[a-z0-9-]+$) — fall back to step 7.5"
593
+ SLUG=""
594
+ fi
595
+ ```
596
+ When `SLUG` is non-empty:
597
+ - **Derive queries** from: the slug itself, the finding's **Prevention** sentence (extract its key identifiers), every path under **Files**, and the symbol, call, or condition cited at the defect site. Use 2-5 queries; each query is a fixed string, not a regex. A query must match `^[A-Za-z0-9_.:/-]{3,80}$` (identifier-like, no spaces, quotes, backticks, `$`, `|`); drop any query that does not, and never run an empty query (`-F ""` matches every line).
598
+ - **Search the whole repo** (not just the affected directory), capturing the exit status so a failure is visible instead of masked by a pipe: `OUT=$(timeout 30 git grep -n -F -- "$QUERY"); RC=$?` (use `timeout` only if `command -v timeout` succeeds; stock macOS lacks it). `RC=0` means hits, `RC=1` means no match (0 hits is a valid result), any other `RC` (including 124, timeout) means the query failed. Quote the variable, always pass `-F` and `--`. Then cap: `printf '%s\n' "$OUT" | head -31`.
599
+ - **Hit cap**: record at most 30 hits per query. If a query returns more, record the first 30 and mark the query `truncated at 30` — never silently drop hits.
600
+ - **Disposition per hit** (MANDATORY, no unlabeled hits): `fix` (same defect class, must be changed in this PR) or `not-affected` with a one-line reason (e.g. already guarded, different semantics). The cited instance is always a `fix` row. Record each hit as `file:line` only (never the matched line text), one table row per hit.
601
+ - **Scope cap**: if more than 25 `fix` rows result, do not grow one PR to fit them. Record the cited instance plus the rows in the same subsystem as `fix`, mark the rest `fix-deferred`, and recommend decomposition in the report (set `decompose: YES` if the remaining scope is too large for one PR).
602
+ - **Degrade, never block**: if any query fails (`RC` other than 0 or 1), write `Pattern sweep skipped: {reason}` in the `### Pattern Sweep` section, fall back to step 7.5, and continue. A failed query must never be recorded as 0 hits. The sweep is advisory evidence, not a gate.
603
+ - Every `fix` row MUST appear in `### Affected Files`, and the acceptance spec MUST include a class-wide coverage check plus a check that the added test exercises more than the reviewer's single repro (see Phase 1C).
584
604
  8. **Fix-approach validation** — if the issue proposes a fix, don't adopt it as spec. Trace through the target system's middleware, auth, routing, config. Cross-domain: if fix in domain A interacts with domain B, read domain B's files too.
585
605
 
586
606
  ---
@@ -622,7 +642,7 @@ ANNOTATION_BODY=$(node packages/protocol/src/cli.js emit INVESTIGATOR \
622
642
  Before posting, resolve the attribution annotation link from `forge.yaml`:
623
643
 
624
644
  ```bash
625
- ATTRIBUTION_ANNOTATION_LINK=$(grep -A5 "^attribution:" forge.yaml 2>/dev/null | grep "annotation_link:" | awk '{print $2}' | tr -d '"' || echo "false")
645
+ ATTRIBUTION_ANNOTATION_LINK=$(grep -A5 "^attribution:" forge.yaml 2>/dev/null | grep "annotation_link:" | awk '{print $(2)}' | tr -d '"' || echo "false")
626
646
  ANNOTATION_LINK_FOOTER=""
627
647
  if [ "$ATTRIBUTION_ANNOTATION_LINK" = "true" ]; then
628
648
  ANNOTATION_LINK_FOOTER="
@@ -632,6 +652,7 @@ if [ "$ATTRIBUTION_ANNOTATION_LINK" = "true" ]; then
632
652
  fi
633
653
  ```
634
654
 
655
+ <!-- allowlist:check-spec-bash -->
635
656
  ```bash
636
657
  gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:INVESTIGATOR -->
637
658
  ## Investigation Report
@@ -651,7 +672,14 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:INVESTIGATOR -->
651
672
  {specific root cause, with file:line references where applicable}
652
673
 
653
674
  ### Affected Files
654
- {numbered list of files that need changes}
675
+ {numbered list of files that need changes. For \`review-finding\` issues, include every file with a \`fix\` row from the Pattern Sweep below.}
676
+
677
+ ### Pattern Sweep
678
+ {Emit for \`review-finding\` issues (step 7.6). Omit this section for other issues. One row per hit, hit as \`file:line\` only. A query with no hits gets one row with Hit \`none\`. On skip, write \`Pattern sweep skipped: {reason}\`. Queries are pre-validated identifier-like text (step 7.6), so they are safe to place in this template.}
679
+
680
+ | Query | Hit | Disposition |
681
+ |-------|-----|-------------|
682
+ | {validated fixed-string query} | {file:line, \`none\`, or \`truncated at 30\`} | {fix \| not-affected — reason} |
655
683
 
656
684
  ### Evidence
657
685
  {specific findings — function names, line numbers, behavior observed}
@@ -696,6 +724,8 @@ ACCEPTANCE_CHECK: id=ac-4 type=command target="grep -qE '(>= ?2|2\+)' commands/o
696
724
 
697
725
  **Self-defeating pipe guideline**: do NOT chain a `-q`/`--quiet` command into a downstream pipe consumer (e.g. `grep -q ... | grep ...`). A `-q` flag suppresses all stdout, so the next command in the pipe always receives empty input and the check can never pass regardless of the actual file content. If a check needs to verify two conditions against the same output, sequence them instead — e.g. `grep -qE 'first' file && grep -qE 'second' file` — or capture the output once and grep the captured variable.
698
726
 
727
+ **Class-wide checks (review-finding issues)**: when a Pattern Sweep was recorded, emit at least one `ACCEPTANCE_CHECK` that verifies the fix covers every `fix` row (for example a `command` check that the defective pattern no longer matches anywhere in the swept paths) and one that verifies the added test covers a representative set of instances rather than the reviewer's single repro. <!-- Added: forge#3449 -->
728
+
699
729
  **Skipping**: if the issue has no verifiable acceptance criteria and none can be derived from the recommendation, emit a single sentinel: `ACCEPTANCE_CHECK: id=ac-skip type=skipped target="none" matcher="none" description=No machine-checkable criteria available — human review required`
700
730
  ${ANNOTATION_LINK_FOOTER}
701
731
  ${INVESTIGATION_SENTINEL}"
@@ -84,15 +84,48 @@ ISSUE_LABELS=$(echo "$ISSUE_STATE" | jq -r '[.labels[].name] | join(",")')
84
84
  **Idempotency / resume check** — the paper trail lives on **both** the PR (primary — checked by the orchestrator's item 6.4 dispatch guard) and the linked issue (mirror — keeps `/work-on`'s standard FORGE-annotation trajectory and resume logic consistent with every other phase):
85
85
 
86
86
  ```bash
87
- PR_REMEDIATION_COMMENT=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
88
- --jq '[.[] | select(.body | contains("FORGE:REMEDIATION"))] | last')
87
+ # Trust predicate: ONE shared copy (scripts/trusted-comments.sh). Markers are matched as the comment's LEADING
88
+ # text (^ anchors at the start of the body, not of a line), never with a bare contains(): a review comment or
89
+ # an INPR_FIX work order that merely QUOTES FORGE:REMEDIATION:COMPLETE must not read as a remediation trail (forge#3412).
90
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
91
+ TRUSTED_SCRIPT=""
92
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l"; do
93
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
94
+ done
95
+ PR_COMMENTS=$(gh api --paginate repos/{GH_REPO}/issues/{PR_NUMBER}/comments 2>/dev/null) || PR_COMMENTS=""
96
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
97
+ # Real trail comments (M5 interim, M8 final) START with <!-- FORGE:REMEDIATION -->; M8 also carries <!-- FORGE:REMEDIATION:COMPLETE -->.
98
+ REMEDIATION_COMPLETE_N=$(printf '%s' "$PR_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:REMEDIATION -->[\s\S]*<!-- FORGE:REMEDIATION:COMPLETE -->' 2>/dev/null || echo "")
99
+ REMEDIATION_TRAIL_N=$(printf '%s' "$PR_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:REMEDIATION -->' 2>/dev/null || echo "")
100
+ else
101
+ REMEDIATION_COMPLETE_N=""; REMEDIATION_TRAIL_N=""
102
+ fi
103
+ # Partial (interim, no COMPLETE) comment ids for cleanup: anchored leading marker, no COMPLETE marker, same bot/association trust.
104
+ PARTIAL_PR_COMMENT_IDS=$(printf '%s' "$PR_COMMENTS" | jq -rs '[.[][] | select((.body // "") | startswith("<!-- FORGE:REMEDIATION -->")) | select((.body | contains("<!-- FORGE:REMEDIATION:COMPLETE -->")) | not) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | .id] | .[]' 2>/dev/null || true)
105
+ ```
106
+
107
+ **Per-kind scoping of the single-attempt guard** (forge#3496): the guard is scoped per remediation kind so a base-sync round is not swallowed by an earlier `ci-gate` / `inpr-fix` remediation on the same PR. A base-sync handoff is recognised by the router's `FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER}` bound marker on the issue (the router posts it immediately before invoking this phase); a completed base-sync round is recognised by a `FORGE:REMEDIATION:COMPLETE` trail carrying the `**Base sync**: ran` line that Phase M8 posts. The base-sync round is itself bounded to one, by that marker count in `work-on/review.md` R4 and by this check:
108
+
109
+ ```bash
110
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
111
+ BASESYNC_DONE_N=$(printf '%s' "$PR_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:REMEDIATION -->[\s\S]*\*\*Base sync\*\*: ran[\s\S]*<!-- FORGE:REMEDIATION:COMPLETE -->' 2>/dev/null || echo "")
112
+ ISSUE_COMMENTS=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null) || ISSUE_COMMENTS=""
113
+ BASESYNC_BOUND_N=$(printf '%s' "$ISSUE_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER} -->' 2>/dev/null || echo "")
114
+ # A base-sync handoff (bound marker present) with no completed base-sync round yet is a fresh attempt for ITS kind,
115
+ # whatever earlier ci-gate / inpr-fix trails exist. Fail closed: an unreadable count keeps the kind-agnostic guard.
116
+ if [ -n "$BASESYNC_DONE_N" ] && [ -n "$BASESYNC_BOUND_N" ] && [ "$BASESYNC_BOUND_N" -ge 1 ] && [ "$BASESYNC_DONE_N" -eq 0 ]; then REMEDIATION_COMPLETE_N=0; fi
117
+ fi
89
118
  ```
90
119
 
91
- - If a comment is found AND its body contains `FORGE:REMEDIATION:COMPLETE` → EXIT `REMEDIATE_RESULT: status: ALREADY_DONE`. **Single-attempt semantics (AC5)**: once a `FORGE:REMEDIATION:COMPLETE` marker exists for this PR, do NOT re-attempt fixes on a subsequent invocation, regardless of the prior verdict — this is what prevents an infinite remediation retry loop on a genuinely-blocked PR.
92
- - If a comment is found WITHOUT `:COMPLETE` → a prior attempt was interrupted mid-flight (same failure mode as the investigation phase's partial-comment case). Delete the partial comment(s) on both the PR and the issue, then continue below as a fresh attempt:
120
+ Completed trails of other kinds are left in place (never deleted); only the interrupted-partial cleanup below deletes anything.
121
+
122
+ - If `REMEDIATION_COMPLETE_N` or `REMEDIATION_TRAIL_N` is empty (comments or `scripts/trusted-comments.sh` unreadable or unresolvable) → fail closed: EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "github-unavailable: remediation trail unreadable (comments or scripts/trusted-comments.sh unresolvable)". Never treat unreadable as "no trail" and never as `ALREADY_DONE`.
123
+ - If `REMEDIATION_COMPLETE_N` is at least 1 (a trusted comment that starts with `<!-- FORGE:REMEDIATION -->` and contains `<!-- FORGE:REMEDIATION:COMPLETE -->`) → EXIT `REMEDIATE_RESULT: status: ALREADY_DONE`. **Single-attempt semantics (AC5)**: once a genuine `FORGE:REMEDIATION:COMPLETE` trail comment exists for this PR, do NOT re-attempt fixes on a subsequent invocation, regardless of the prior verdict — this is what prevents an infinite remediation retry loop on a genuinely-blocked PR. A comment that only quotes the marker mid-body (review findings, an `INPR_FIX` work order, a disposition record) or comes from an untrusted author never counts.
124
+ - If `REMEDIATION_COMPLETE_N` is 0 and `REMEDIATION_TRAIL_N` is at least 1 → a prior attempt was interrupted mid-flight (same failure mode as the investigation phase's partial-comment case). Delete the partial comment(s) on both the PR and the issue (ids from `PARTIAL_PR_COMMENT_IDS` above and the same anchored filter over the issue's comments), then continue below as a fresh attempt:
93
125
  ```bash
94
- gh api repos/{GH_REPO}/issues/comments/{PARTIAL_PR_COMMENT_ID} -X DELETE 2>/dev/null || true
95
- gh api repos/{GH_REPO}/issues/comments/{PARTIAL_ISSUE_COMMENT_ID} -X DELETE 2>/dev/null || true
126
+ for _id in $PARTIAL_PR_COMMENT_IDS; do gh api repos/{GH_REPO}/issues/comments/$_id -X DELETE 2>/dev/null || true; done
127
+ ISSUE_PARTIAL_IDS=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null | jq -rs '[.[][] | select((.body // "") | startswith("<!-- FORGE:REMEDIATION -->")) | select((.body | contains("<!-- FORGE:REMEDIATION:COMPLETE -->")) | not) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | .id] | .[]' 2>/dev/null || true)
128
+ for _id in $ISSUE_PARTIAL_IDS; do gh api repos/{GH_REPO}/issues/comments/$_id -X DELETE 2>/dev/null || true; done
96
129
  ```
97
130
  - If no comment is found → fresh attempt, continue below.
98
131
 
@@ -116,7 +149,7 @@ BLOCK_COMMENTS=$(gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
116
149
  ```
117
150
 
118
151
  **Classify into FIXABLE vs. UNFIXABLE**:
119
- - **FIXABLE** — open `review-finding` issues (CONFIRMED/LIKELY code defects), a `VERDICT=CHANGES REQUESTED` block with concrete findings attached, a mergeability guard failure (`CONFLICTING`/`DIRTY`/`BLOCKED` — resolvable by rebasing onto `{PR_BASE}`), a quality-gate/build failure, or a CI-gate refusal (`ci gate not green` — the failing/cancelled/timed-out checks are listed on the linked issue; read each failing job's log with `gh run view --log-failed`, fix the cause on the PR branch, or re-run a check that failed for an infrastructure reason), or an in-PR fix request (`in-pr fix required` — review-pr §6B.6 posted a `<!-- FORGE:INPR_FIX: round=1 head=<sha> findings=<ids> -->` comment on the PR listing CONFIRMED MEDIUM findings in files this PR changed. That comment is the work order: fix exactly the listed findings, at the listed `file:line`, and nothing else. Do not file them as issues; the re-review in Phase M6 files any that remain).
152
+ - **FIXABLE** — open `review-finding` issues (CONFIRMED/LIKELY code defects), a `VERDICT=CHANGES REQUESTED` block with concrete findings attached, a base conflict (`base-conflict`: `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection — resolvable by merging `origin/{PR_BASE}` in, the base-sync step in Phase M3; `BLOCKED` alone is branch protection that a sync cannot clear and is UNFIXABLE unless a conflict state is also present), a quality-gate/build failure, or a CI-gate refusal (`ci gate not green` — the failing/cancelled/timed-out checks are listed on the linked issue; read each failing job's log with `gh run view --log-failed`, fix the cause on the PR branch, or re-run a check that failed for an infrastructure reason), or an in-PR fix request (`in-pr fix required` — review-pr §6B.6 posted a `<!-- FORGE:INPR_FIX: round=1 head=<sha> findings=<ids> -->` comment on the PR listing CONFIRMED MEDIUM findings in files this PR changed. That comment is the work order: fix exactly the listed findings, at the listed `file:line`, and nothing else. Do not file them as issues; the re-review in Phase M6 files any that remain).
120
153
  - **UNFIXABLE (policy escalation)** — `HAS_PURPOSE_REGRESSION=true` (the PR's behavior diverges from the issue's intent — a judgment call, not a code defect), `CALIBRATION_NEEDS_HUMAN=true` (statistical trust threshold), or `TRUST_NEEDS_HUMAN=true` (provenance `NOVEL_NEEDS_HUMAN` tier, insufficient prior data — a policy gate, not a bug). None of these are mechanically "fixable" by re-editing code.
121
154
 
122
155
  **If the block reason classifies as UNFIXABLE** (and no FIXABLE item accompanies it): do NOT attempt any fix. Skip directly to Phase M8 with verdict `UNFIXABLE`, re-affirm `needs-human` (it should already be present), and return `REMEDIATE_RESULT: status: UNFIXABLE`. This satisfies AC5 — "genuinely-blocked PRs still terminate at `needs-human`."
@@ -139,7 +172,7 @@ Do not perform this transition for an UNFIXABLE policy escalation. Any later qua
139
172
 
140
173
  ## Phase M2: Checkout the PR's Existing Branch
141
174
 
142
- Remediation always fixes forward on top of the PR's existing head commit — never rebase onto a different base and never force-push over the PR's history unless a fix genuinely requires it (e.g. resolving a merge conflict per the mergeability guard case, in which case use `git rebase`/`git merge` onto `origin/{PR_BASE}` exactly as the branch's own commit history would, then `--force-with-lease`).
175
+ Remediation always fixes forward on top of the PR's existing head commit — never rebase and never force-push. The only history-affecting operation permitted is the base sync (`git merge origin/{PR_BASE}`, Phase M3), which adds a merge commit and pushes with a plain `git push`.
143
176
 
144
177
  ```bash
145
178
  cd {REPO_PATH}
@@ -164,7 +197,39 @@ For each FIXABLE item from Phase M1: read the affected file(s) in `{WORKTREE_PAT
164
197
 
165
198
  **Never delete, skip, or weaken a failing check to go green.** Removing, skipping (`skip`, `xfail`, `continue-on-error`, `if: false`, commenting out) or weakening a failing test or CI step is never a valid fix. Fix the cause in the code under test, or classify the item UNFIXABLE, add/re-affirm `needs-human`, and post a comment that names the failing tests and failing assertions (test title plus assertion text from the CI log). The only exception is a deletion justified by removal of the tested code itself (see quality-gate 2U). Enforcement is the quality gate's coverage-reduction check (2U): a `COVERAGE-1` finding is fixed by restoring the test or step, never by suppressing the finding. <!-- Added: forge#3257 -->
166
199
 
167
- **If the block reason was a mergeability conflict** (`CONFLICTING`/`DIRTY`/`BLOCKED`): resolve it by rebasing `{HEAD_BRANCH}` onto `origin/{PR_BASE}` (or merging `{PR_BASE}` in, whichever preserves a clean, reviewable history) — resolve conflicts manually, do not blindly take "ours"/"theirs".
200
+ **Base-sync (merge-only)** <!-- Added: forge#3496 -->: runs when the block reason was a base conflict (`base-conflict`, or `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection). This step is authorized by the operator's batch dispatch and by the router's `FORGE:BASESYNC_REMEDIATION` bound; it applies to the PR's own branch in `{WORKTREE_PATH}` only. Never rebase, never force-push, never merge into or push to a shared branch.
201
+
202
+ ```bash
203
+ cd {WORKTREE_PATH}
204
+ CUR_BRANCH=$(git rev-parse --abbrev-ref HEAD)
205
+ case "{HEAD_BRANCH}" in
206
+ "{PR_BASE}"|main|master|staging|milestone/*) echo "refusing base sync: {HEAD_BRANCH} is a shared branch"; BASESYNC_REFUSED=true ;;
207
+ esac
208
+ [ "$CUR_BRANCH" = "{HEAD_BRANCH}" ] || BASESYNC_REFUSED=true
209
+ if [ "${BASESYNC_REFUSED:-false}" != "true" ]; then
210
+ git fetch origin {PR_BASE} # allowlist:check-command-side-effects
211
+ git merge origin/{PR_BASE} --no-edit # allowlist:check-command-side-effects
212
+ BASESYNC_RAN=true
213
+ fi
214
+ ```
215
+
216
+ - Clean merge: nothing more to resolve; the merge commit is the sync.
217
+ - Conflicts: read BOTH sides of every conflicted hunk and the surrounding code, then write the combined result by hand. No `-X ours`/`-X theirs`, no wholesale `git checkout --ours/--theirs`, no blanket take-one-side. After resolving, `git add` the files and conclude with `git commit -s --no-edit` (the merge commit).
218
+ - **Unresolvable with confidence** (a hunk whose intent on either side you cannot reconcile, or the branch is refused above): capture the file list first, then abort, and park:
219
+ ```bash
220
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
221
+ CONFLICT_FILES=$(git diff --name-only --diff-filter=U)
222
+ git merge --abort # allowlist:check-command-side-effects
223
+ run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
224
+ Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) could not be resolved with confidence. Conflicting files:
225
+
226
+ \`\`\`
227
+ ${CONFLICT_FILES}
228
+ \`\`\`" # allowlist:check-command-side-effects
229
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
230
+ ```
231
+ EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: unresolvable conflicts". Only that issue is parked.
232
+ - After a sync, the quality gate below re-runs on the merged tree, and Phase M6's re-review covers the new head (the reviewed-head guard forces a fresh review).
168
233
 
169
234
  **Quality Gate** (same loop as Phase 3G, max 3 iterations):
170
235
  ```
@@ -183,14 +248,42 @@ If still failing after 3 iterations: post a comment, re-affirm `needs-human`, EX
183
248
 
184
249
  ## Phase M4: Commit, Push, and Close Addressed Findings
185
250
 
251
+ **Pre-push ancestry guard** (same guard as `work-on/review.md` Phase R1; reuse it, do not write a parallel check): a base-sync merge of `origin/{PR_BASE}` passes it, a merge that brings in history from outside the base fails it.
252
+
253
+ ```bash
254
+ # <Script resolution block from work-on/review.md, verbatim>
255
+ cd {WORKTREE_PATH}
256
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
257
+ git fetch origin {PR_BASE} >/dev/null 2>&1 || true
258
+ RESOLUTION=$(resolve_script 'check-branch-ancestry'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
259
+ if [ "$TIER" = "prose" ]; then
260
+ MERGE_COMMITS="check-branch-ancestry script not resolvable (fail closed)"; ANCESTRY_RC=2
261
+ else
262
+ MERGE_COMMITS=$(bash "$SCRIPT_PATH" {HEAD_BRANCH} origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
263
+ fi
264
+ if [ "$ANCESTRY_RC" -ne 0 ]; then
265
+ run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "## Pre-Push Ancestry Guard Failed
266
+
267
+ Branch \`{HEAD_BRANCH}\` has merge commits from outside \`{PR_BASE}\`, or ancestry could not be verified (rc=${ANCESTRY_RC}). Not pushing.
268
+
269
+ \`\`\`
270
+ ${MERGE_COMMITS}
271
+ \`\`\`" # allowlist:check-command-side-effects
272
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
273
+ # EXIT REMEDIATE_RESULT: status: BLOCKED (blocker: pre-push ancestry guard failed) - do not push
274
+ fi
275
+ ```
276
+
277
+ Then commit and push. A base-sync merge commit already exists from Phase M3; commit only additional quality-gate or fix edits on top.
278
+
186
279
  ```bash
187
280
  cd {WORKTREE_PATH}
188
281
  git add -u
189
- git commit -s -m "fix(remediate): {description} (#{ISSUE_NUMBER})"
190
- git push origin {HEAD_BRANCH}
282
+ git diff --cached --quiet || git commit -s -m "fix(remediate): {description} (#{ISSUE_NUMBER})"
283
+ git push origin {HEAD_BRANCH} # allowlist:check-command-side-effects
191
284
  ```
192
285
 
193
- If push fails, retry with `--force-with-lease` (expected when M3 rebased to resolve a conflict). If it still fails: post a comment, add `needs-human`, EXIT `REMEDIATE_RESULT: status: BLOCKED`.
286
+ If the push fails, do NOT retry with a force flag (never force-push): post a comment, add `needs-human`, EXIT `REMEDIATE_RESULT: status: BLOCKED`. A raced remote is resolved by a re-run, which fetches and merges naturally.
194
287
 
195
288
  **Close each addressed review-finding issue directly** (this remediation fixes findings in-place on the existing PR, rather than each finding spawning its own downstream `/work-on` pipeline — leaving them open would have a future run rediscover already-fixed code). Track the closed numbers in `ADDRESSED_FINDING_NUMBERS[]` — Phase M8 reports this array in the final paper trail:
196
289
  ```bash
@@ -249,6 +342,8 @@ else:
249
342
  Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="{PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE} --gh-flag {GH_FLAG}")
250
343
  ```
251
344
 
345
+ **Reviewer ownership (spawn-site rule, `docs/WORK-ON-RUNTIME.md` R6)**: `/review-pr` runs inline in this fork. Under async `Agent` dispatch its domain reviewers are attributed to the root session, so their completion notifications never reach this phase: do not end the turn to wait for them. `/review-pr` Phase 4 waits on the reviewers' current-SHA `FORGE:REVIEW-AGENT` PR comments (bounded), and that GitHub comment, not a notification, is the completion signal. If the call returns with no parseable `REVIEW_RESULT` (empty, running or backgrounded), first re-read the PR and issue: a complete current-SHA reviewer panel plus a `FORGE:REVIEW` verdict means consume that state; otherwise re-invoke `/review-pr` once more with the same args (idempotent: it reuses the current-SHA reviewer comments already posted and dispatches only missing domains). Never review inline.
346
+
252
347
  **OpenCode joined-child contract**: When `FORGE_RUNTIME=opencode` (or an OpenCode runtime marker is present), run this required re-review through one native foreground `task`:
253
348
 
254
349
  ```
@@ -269,6 +364,8 @@ If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate r
269
364
 
270
365
  If the retained `REVIEW_RESULT` is `BLOCKED` with a blocker mentioning the phase trail (e.g. "phase trail unreadable (rc=N)": the Phase 8 verifier exited ≥2 / 127, forge#3147): the gate could not run, so nothing is missing and nothing is re-run. Keep `needs-human` (review-pr Phase 8 already re-asserted it), do not merge, and exit `REMEDIATE_RESULT: status: BLOCKED` with the same blocker.
271
366
 
367
+ If the retained `REVIEW_RESULT` is `BLOCKED` with blocker "base-conflict" (forge#3496): the base moved again, or the sync did not clear the conflict. Treat it as re-escalated: the `BASESYNC_REMEDIATION` bound is already used, so do NOT sync a second time. Add `needs-human`, post a comment naming the PR and the conflicting files (`git diff --name-only --diff-filter=U` against a trial merge, or the GitHub mergeability report), and exit `REMEDIATE_RESULT: status: BLOCKED` with blocker "base conflict persists after base-sync remediation".
368
+
272
369
  This re-runs the full review (domain agents → verdict → Phase 8 auto-merge gate). The FIXABLE transition above left the issue at the non-terminal `workflow:in-review` state. `review-pr.md` recognizes the in-progress `FORGE:REMEDIATION` marker posted in Phase M5 as evidence of the prior escalation, so one of two things happens inside Phase 8:
273
370
 
274
371
  - **Re-escalated**: the re-review itself trips a fresh block (`CHANGES REQUESTED`, purpose-regression, calibration, trust, or a still-`CONFLICTING` mergeability check) → it adds `needs-human`, and this phase removes `workflow:in-review`, leaving one terminal state.
@@ -432,6 +529,12 @@ case "$RE_GATE_OUTCOME" in
432
529
  *) AUTO_LAND_BAR_TEXT="N/A"; OUTCOME_DETAIL="" ;;
433
530
  esac
434
531
 
532
+ # forge#3413: embed the pushed head so the orchestrator scopes a REREVIEW-REQUIRED trail to the CURRENT head only.
533
+ REMEDIATED_HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || true)
534
+
535
+ # forge#3496: mark a base-sync round so Phase M0 scopes the single-attempt guard per kind.
536
+ BASESYNC_LINE=""; [ "${BASESYNC_RAN:-false}" = "true" ] && BASESYNC_LINE="**Base sync**: ran"
537
+
435
538
  REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
436
539
  ## Remediation Complete for PR #{PR_NUMBER}
437
540
 
@@ -439,7 +542,8 @@ REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
439
542
  **Re-review verdict**: ${RE_REVIEW_VERDICT:-unknown}
440
543
  **Auto-land bar**: ${AUTO_LAND_BAR_TEXT}
441
544
  **Re-gate outcome**: ${RE_GATE_OUTCOME} ${OUTCOME_DETAIL}
442
-
545
+ **Head**: ${REMEDIATED_HEAD_SHA:-unknown}
546
+ ${BASESYNC_LINE}
443
547
  <!-- FORGE:REMEDIATION:COMPLETE -->"
444
548
 
445
549
  gh pr comment {PR_NUMBER} {GH_FLAG} --body "$REMEDIATION_BODY"
@@ -86,7 +86,7 @@ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac
86
86
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
87
87
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
88
88
  # 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)
89
- _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)"
89
+ _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)"
90
90
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
91
91
  # '${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.
92
92
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -105,7 +105,7 @@ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped,
105
105
  # to search the filesystem. <!-- Added: forge#1984 -->
106
106
 
107
107
  resolve_script() {
108
- local operation="$1"
108
+ local operation="${1}"
109
109
  # Tier 2: per-repo adaptive (skip if disabled)
110
110
  if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
111
111
  echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
@@ -207,30 +207,66 @@ If `LAST_VERDICT_SHA` is empty (no prior CHANGES REQUESTED verdict found) or dif
207
207
 
208
208
  ## Phase R1: Pre-Push Ancestry Guard
209
209
 
210
- Before pushing, verify the branch contains no merge commits from branches outside the PR base ancestry. This is the final defense against milestone-code-onto-staging contamination.
210
+ Before pushing, verify the branch contains no merge commits that bring in history from outside the PR base. Merging `origin/{PR_BASE}` itself into the branch (a base sync) is allowed: every non-first parent of each merge in `origin/{PR_BASE}..{BRANCH}` must be an ancestor of `origin/{PR_BASE}`. This is the final defense against milestone-code-onto-staging contamination.
211
211
 
212
212
  ```bash
213
+ # <Script resolution block from the top of this file, verbatim>
213
214
  cd {WORKTREE_PATH}
214
215
  # Skip if PR_BASE does not exist on origin yet (new branch — no contamination possible)
215
216
  if git ls-remote --exit-code origin {PR_BASE} >/dev/null 2>&1; then
216
- MERGE_COMMITS=$(git log --merges {BRANCH} ^origin/{PR_BASE} 2>/dev/null)
217
- if [ -n "$MERGE_COMMITS" ]; then
218
- echo "PRE-PUSH ANCESTRY GUARD FAILED: merge commits from outside {PR_BASE} detected"
217
+ git fetch origin {PR_BASE} >/dev/null 2>&1 || true
218
+ RESOLUTION=$(resolve_script 'check-branch-ancestry'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
219
+ if [ "$TIER" = "prose" ]; then
220
+ # Prose fallback: same checks inline (exit 0 clean, 1 foreign, 2 error). Fails closed: unresolvable refs or git errors give rc=2.
221
+ ANCESTRY_RC=0; MERGE_COMMITS=""
222
+ if ! git rev-parse --verify --quiet "origin/{PR_BASE}^{commit}" >/dev/null 2>&1 || ! git rev-parse --verify --quiet "{BRANCH}^{commit}" >/dev/null 2>&1; then
223
+ ANCESTRY_RC=2
224
+ elif ! MERGES=$(git rev-list --merges origin/{PR_BASE}..{BRANCH} 2>/dev/null) || ! FP=$(git rev-list --first-parent origin/{PR_BASE}..{BRANCH} 2>/dev/null); then
225
+ ANCESTRY_RC=2
226
+ else
227
+ for M in $MERGES; do
228
+ PARENTS=$(git rev-list --parents -n 1 "$M" 2>/dev/null) || { ANCESTRY_RC=2; continue; }
229
+ for P in $(printf '%s\n' "$PARENTS" | cut -d' ' -f3-); do
230
+ git merge-base --is-ancestor "$P" origin/{PR_BASE} 2>/dev/null; rc=$?
231
+ if [ "$rc" -eq 1 ]; then MERGE_COMMITS="${MERGE_COMMITS}${M} ${P}
232
+ "; [ "$ANCESTRY_RC" -eq 2 ] || ANCESTRY_RC=1
233
+ elif [ "$rc" -ne 0 ]; then ANCESTRY_RC=2; fi
234
+ done
235
+ done
236
+ # First-parent line must not carry milestone history (branch cut from a milestone, then base sync).
237
+ for F in $(git for-each-ref --format='%(refname)' refs/remotes/origin/milestone/ refs/heads/milestone/ 2>/dev/null); do
238
+ for C in $FP; do
239
+ git merge-base --is-ancestor "$C" "$F" 2>/dev/null; rc=$?
240
+ if [ "$rc" -eq 0 ]; then MERGE_COMMITS="${MERGE_COMMITS}${C} first-parent history reachable from ${F}
241
+ "; [ "$ANCESTRY_RC" -eq 2 ] || ANCESTRY_RC=1
242
+ elif [ "$rc" -ne 1 ]; then ANCESTRY_RC=2; fi
243
+ done
244
+ done
245
+ fi
246
+ else
247
+ MERGE_COMMITS=$(bash "$SCRIPT_PATH" {BRANCH} origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
248
+ fi
249
+ if [ "$ANCESTRY_RC" -ne 0 ]; then
250
+ if [ "$ANCESTRY_RC" -eq 1 ]; then
251
+ echo "PRE-PUSH ANCESTRY GUARD FAILED: merge commits from outside {PR_BASE} detected"
252
+ else
253
+ echo "PRE-PUSH ANCESTRY GUARD FAILED: could not verify ancestry (failing closed)"
254
+ fi
219
255
  gh issue comment {NUMBER} {GH_FLAG} --body "## Pre-Push Ancestry Guard Failed
220
256
 
221
- Branch \`{BRANCH}\` contains merge commits from branches outside the PR base (\`{PR_BASE}\`). Pushing this branch risks contaminating \`{PR_BASE}\` with unapproved code (e.g. milestone code leaking onto staging).
257
+ Branch \`{BRANCH}\` contains merge commits that bring in history from outside the PR base (\`{PR_BASE}\`), or ancestry could not be verified. Pushing this branch risks contaminating \`{PR_BASE}\` with unapproved code (e.g. milestone code leaking onto staging). Merges of \`{PR_BASE}\` itself are allowed and do not trigger this guard.
222
258
 
223
- **Detected merge commits**:
259
+ **Detected merge commits** (merge, foreign parent, subject — or the verification error):
224
260
  \`\`\`
225
261
  ${MERGE_COMMITS}
226
262
  \`\`\`
227
263
 
228
- Do NOT push this branch. Human review required to identify the source of the merge commits and clean the branch history (e.g. via \`git rebase\` to replay only the intended commits onto \`origin/{PR_BASE}\`).
264
+ Do NOT push this branch. Human review required to identify the source of the merge commits and clean the branch history (a human re-creates the branch from \`origin/{PR_BASE}\` with only the intended commits).
229
265
 
230
- <!-- FORGE:PUSH_BLOCKED -->"
266
+ <!-- FORGE:PUSH_BLOCKED -->" # allowlist:check-command-side-effects
231
267
  gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human" # allowlist:check-command-side-effects
232
268
  # Return REVIEW_RESULT: status: BLOCKED — do not push
233
- printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: %s\n' "pre-push ancestry guard failed: merge commits from outside {PR_BASE}"
269
+ printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: %s\n' "pre-push ancestry guard failed: merge commits from outside {PR_BASE} or ancestry unverifiable"
234
270
  exit 1
235
271
  fi
236
272
  fi
@@ -268,12 +304,7 @@ cd {WORKTREE_PATH}
268
304
  git push -u origin {BRANCH} # allowlist:check-command-side-effects
269
305
  ```
270
306
 
271
- If push fails, retry with `--force-with-lease`:
272
- ```bash
273
- git push -u origin {BRANCH} --force-with-lease # allowlist:check-command-side-effects
274
- ```
275
-
276
- If still fails:
307
+ If the push fails, do NOT retry with a force flag (never force-push): a raced or diverged remote is a human decision, and a conflict with the base is handled by the base-sync path (`base-conflict` in Phase R4), not by overwriting the remote. Fall straight through to:
277
308
  ```bash
278
309
  gh issue comment {NUMBER} {GH_FLAG} --body "## Push Failed
279
310
 
@@ -312,7 +343,7 @@ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac
312
343
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
313
344
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
314
345
  # 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)
315
- _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)"
346
+ _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)"
316
347
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
317
348
  # '${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.
318
349
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -376,7 +407,7 @@ Derive from issue title:
376
407
  Before creating the PR, check `forge.yaml → attribution.pr_footer`:
377
408
 
378
409
  ```bash
379
- ATTRIBUTION_PR_FOOTER=$(grep -A5 "^attribution:" forge.yaml 2>/dev/null | grep "pr_footer:" | awk '{print $2}' | tr -d '"' || echo "false")
410
+ ATTRIBUTION_PR_FOOTER=$(grep -A5 "^attribution:" forge.yaml 2>/dev/null | grep "pr_footer:" | awk '{print $(2)}' | tr -d '"' || echo "false")
380
411
  ```
381
412
 
382
413
  If `ATTRIBUTION_PR_FOOTER` is `true`, append the following footer to the PR body (once — never duplicate on retries):
@@ -480,6 +511,8 @@ else:
480
511
  Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="{PR_NUMBER} --auto-merge --issue {NUMBER} --base {PR_BASE} --gh-flag {GH_FLAG}")
481
512
  ```
482
513
 
514
+ **Reviewer ownership (spawn-site rule, `docs/WORK-ON-RUNTIME.md` R6)**: `/review-pr` runs inline in this fork. Under async `Agent` dispatch its domain reviewers are attributed to the root session, so their completion notifications never reach this phase: do not end the turn to wait for them. `/review-pr` Phase 4 waits on the reviewers' current-SHA `FORGE:REVIEW-AGENT` PR comments (bounded), and that GitHub comment, not a notification, is the completion signal. If the call returns with no parseable `REVIEW_RESULT` (empty, running or backgrounded), first re-read the PR and issue: a complete current-SHA reviewer panel plus a `FORGE:REVIEW` verdict means consume that state; otherwise re-invoke `/review-pr` once more with the same args (idempotent: it reuses the current-SHA reviewer comments already posted and dispatches only missing domains). Never review inline.
515
+
483
516
  **OpenCode joined-child contract**: When `FORGE_RUNTIME=opencode` (or an OpenCode runtime marker is present), invoke this load-bearing review through one native foreground `task` instead of treating the `Skill(...)` line as an asynchronous handoff:
484
517
 
485
518
  ```
@@ -545,18 +578,21 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
545
578
  In-PR fix round did not land; the remaining CONFIRMED MEDIUM findings are filed as issues on the next review instead of blocking the merge." # allowlist:check-command-side-effects
546
579
  ```
547
580
 
548
- Persisted loop bounds for the cases above — count first, then act (`{BOUND}` is `STALE_REREVIEW`, `CI_REMEDIATION` or `INPR_REMEDIATION`). This phase posts only the `STALE_REREVIEW` marker; `CI_REMEDIATION` and `INPR_REMEDIATION` are posted by the router in Phase 4R, immediately before it invokes remediation (forge#3398):
581
+ Persisted loop bounds for the cases above — count first, then act (`{BOUND}` is `STALE_REREVIEW`, `CI_REMEDIATION`, `INPR_REMEDIATION` or `BASESYNC_REMEDIATION`). This phase posts only the `STALE_REREVIEW` marker; `CI_REMEDIATION`, `INPR_REMEDIATION` and `BASESYNC_REMEDIATION` are posted by the router in Phase 4R, immediately before it invokes remediation (forge#3398):
549
582
  ```bash
550
583
  [ "${DRY_RUN:-false}" = "true" ] && { echo "[DRY_RUN] bound check only"; }
551
584
  if [ "{BOUND}" = "STALE_REREVIEW" ]; then
552
585
  BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
553
- --jq '[.[] | select((.body | contains("FORGE:STALE_REREVIEW:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$1} END {print s+0}')
586
+ --jq '[.[] | select((.body | contains("FORGE:STALE_REREVIEW:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
554
587
  elif [ "{BOUND}" = "INPR_REMEDIATION" ]; then
555
588
  BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
556
- --jq '[.[] | select((.body | contains("FORGE:INPR_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$1} END {print s+0}')
589
+ --jq '[.[] | select((.body | contains("FORGE:INPR_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
590
+ elif [ "{BOUND}" = "BASESYNC_REMEDIATION" ]; then
591
+ BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
592
+ --jq '[.[] | select((.body | contains("FORGE:BASESYNC_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
557
593
  else
558
594
  BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
559
- --jq '[.[] | select((.body | contains("FORGE:CI_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$1} END {print s+0}')
595
+ --jq '[.[] | select((.body | contains("FORGE:CI_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
560
596
  fi
561
597
  if [ "$BOUND_COUNT" -ge 1 ]; then
562
598
  echo "BOUND_EXHAUSTED: {BOUND} already used for PR #{PR_NUMBER}"
@@ -567,7 +603,11 @@ Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head on
567
603
  ```
568
604
  `BOUND_EXHAUSTED` → take the "already ≥ 1" branch of that case.
569
605
 
570
- - PR NOT MERGED (and not a phase-trail, auto-merge-gate, stale-review, ci-gate or in-pr-fix BLOCKED above) → attempt manual merge, **only if the PR head is still the commit named in the latest `<!-- FORGE:REVIEW -->` APPROVED verdict** (otherwise re-invoke Phase R3 instead, as for "stale review") **and only after the same CI gate**:
606
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "base-conflict" (review-pr Phase 7B / Phase 8: `mergeable=CONFLICTING` or `mergeStateStatus=DIRTY`, or `BEHIND` under required-up-to-date protection, and no other blocking finding; forge#3496): do NOT attempt the manual merge below and do NOT merge by any other route. Syncing the base is pipeline work: merge `origin/{PR_BASE}` into the issue's own branch (merge only, never rebase, never force-push, never touch a shared branch). Run **one** sync round, bounded by `<!-- FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER} -->` on the issue (`{BOUND}` = `BASESYNC_REMEDIATION` below).
607
+ - **Bound unused**: add `needs-human` (remediation only targets `needs-human`-gated PRs, and its Phase M1 clears it as FIXABLE). Then return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: base-sync`. The router posts the bound marker (Phase 4R) and runs `work-on:remediate`, which merges the base in, re-runs the quality gate and re-reviews the new head. Never invoke `work-on:remediate` from this phase (same reason as the ci-gate case).
608
+ - **Bound already used** (the sync ran and the PR still conflicts, or the sync could not resolve a hunk): do not sync again. Leave `needs-human` and return `REVIEW_RESULT: status: BLOCKED` with blocker "base conflict persists after base-sync remediation". List the conflicting files in the issue comment you post, taken from the remediation's `FORGE:BASESYNC_FAILED` comment (the `git diff --name-only --diff-filter=U` list) when present.
609
+
610
+ - PR NOT MERGED (and not a phase-trail, auto-merge-gate, stale-review, ci-gate, in-pr-fix or base-conflict BLOCKED above) → attempt manual merge, **only if the PR head is still the commit named in the latest `<!-- FORGE:REVIEW -->` APPROVED verdict** (otherwise re-invoke Phase R3 instead, as for "stale review") **and only after the same CI gate**:
571
611
  ```bash
572
612
  # CI gate (MANDATORY before any autonomous merge): merge only when every check on the PR is
573
613
  # green. Field test: PRs merged to staging with checks pending or red (#3165), because branch
@@ -590,7 +630,7 @@ Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head on
590
630
  # FORGE_TRAIL_TRUSTED_LOGINS. A GitHub App bot always has author_association NONE, so an association-only filter drops the pipeline's own disposition.
591
631
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
592
632
  TRUSTED_SCRIPT=""
593
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$PWD"; do
633
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
594
634
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
595
635
  done
596
636
  if [ -n "$TRUSTED_SCRIPT" ]; then DISPOSITION_COUNT=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:NOTE_DISPOSITION' 2>/dev/null || echo ""); else DISPOSITION_COUNT=""; fi
@@ -621,7 +661,7 @@ REVIEW_RESULT:
621
661
  pr_url: {PR_URL}
622
662
  merged_to: {PR_BASE}
623
663
  next: {remediate, only when status=NEXT}
624
- remediation: {ci-gate | inpr-fix, only when status=NEXT}
664
+ remediation: {ci-gate | inpr-fix | base-sync, only when status=NEXT}
625
665
  blocker: {description if status=BLOCKED}
626
666
  ```
627
667