forgedock 1.13.1 → 1.14.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 (34) hide show
  1. package/bin/engine/phases.mjs +160 -18
  2. package/bin/engine-cli.mjs +18 -5
  3. package/bin/engine.mjs +109 -6
  4. package/bin/forgedock.mjs +4 -1
  5. package/commands/orchestrate/phase-3-dependency.md +5 -2
  6. package/commands/orchestrate/phase-4-execution.md +144 -24
  7. package/commands/pipeline-health/phase-2-metrics.md +5 -3
  8. package/commands/review-pr-staging.md +289 -39
  9. package/commands/review-pr.md +604 -42
  10. package/commands/validate.md +11 -3
  11. package/commands/work-on/build/architect.md +11 -8
  12. package/commands/work-on/build.md +27 -6
  13. package/commands/work-on/close.md +5 -4
  14. package/commands/work-on/decompose.md +7 -2
  15. package/commands/work-on/investigate.md +11 -0
  16. package/commands/work-on/remediate.md +135 -74
  17. package/commands/work-on/review.md +3 -3
  18. package/docs/CONFIG.md +14 -1
  19. package/docs/RELEASE-NOTES.md +8 -0
  20. package/package.json +1 -1
  21. package/scripts/architect-resume-match.test.sh +53 -0
  22. package/scripts/check-spec-bash.sh +73 -3
  23. package/scripts/check-spec-bash.test.sh +50 -0
  24. package/scripts/checkbox-sections.test.sh +19 -0
  25. package/scripts/classify-finding.sh +96 -1
  26. package/scripts/classify-finding.test.sh +47 -0
  27. package/scripts/forge-root.test.sh +48 -1
  28. package/scripts/orchestrate-soft-edge.test.sh +58 -0
  29. package/scripts/remediate-basesync.test.sh +62 -3
  30. package/scripts/review-delta.sh +3 -2
  31. package/scripts/review-delta.test.sh +1 -1
  32. package/scripts/review-pr-delta-wiring.test.sh +266 -0
  33. package/scripts/review-pr-staging-scope.test.sh +60 -0
  34. package/scripts/trusted-comments.test.sh +11 -0
@@ -29,7 +29,7 @@ background: false
29
29
  **Agent model policy**: Default `model: "sonnet"`. If Sonnet is rate-limited, fall back to `model: "opus"`.
30
30
  **NEVER use plan mode (EnterPlanMode).**
31
31
 
32
- **Scope note**: This mode owns exactly one gap — re-driving a `needs-human` or `workflow:remediating` PR's own remediation (the latter is the non-human "autonomous remediation pending" state review sets for ci-gate, in-pr-fix and base-sync handoffs, forge#3541). It does NOT implement the `needs-human` sub-label taxonomy (#1815's scope) and it does NOT edit `review-pr.md`'s Phase 8 guard (forge#1810) — that guard's existing safe-default (`workflow:awaiting-merge` on any clean re-review of a previously-escalated PR) is reused as-is; this file only adds a bar-check *after* that guard has already fired.
32
+ **Scope note**: This mode owns exactly one gap — re-driving a `needs-human` or `workflow:remediating` PR's own remediation, plus resuming an interrupted M7 auto-land from `workflow:awaiting-merge` when no `FORGE:REMEDIATION:COMPLETE` trail exists (forge#3647; M1-M6 are skipped, only M7/M8 run) (the `workflow:remediating` state is the non-human "autonomous remediation pending" state review sets for ci-gate, in-pr-fix and base-sync handoffs, forge#3541). It does NOT implement the `needs-human` sub-label taxonomy (#1815's scope) and it does NOT edit `review-pr.md`'s Phase 8 guard (forge#1810) — that guard's existing safe-default (`workflow:awaiting-merge` on any clean re-review of a previously-escalated PR) is reused as-is; this file only adds a bar-check *after* that guard has already fired.
33
33
 
34
34
  **Engine coverage** (forge#2379, #2889): this subcommand's `command` name (`work-on/remediate`) and completion marker (`FORGE:REMEDIATION:COMPLETE`, including the `**Re-gate outcome**` field Phase M8 posts below) are registered in the headless engine's phase table — `RESERVED_TYPES.REMEDIATION` in `packages/protocol/src/types.js`, `remediate` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and a matching `remediate` entry in `bin/engine/phases.mjs`'s `PHASES` array. A blocked review is committed with `terminalReason: "needs-human"`, then the engine continues directly into remediation; the divergence guard permits this specific handoff while keeping all other `needs-human` states paused.
35
35
 
@@ -79,8 +79,9 @@ ISSUE_STATE=$(gh issue view {ISSUE_NUMBER} {GH_FLAG} --json labels,state,body,mi
79
79
  ISSUE_LABELS=$(echo "$ISSUE_STATE" | jq -r '[.labels[].name] | join(",")')
80
80
  ```
81
81
 
82
- - If neither `needs-human` nor `workflow:remediating` is among `ISSUE_LABELS` → EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "issue #{ISSUE_NUMBER} is neither `needs-human` nor `workflow:remediating` — remediation mode only targets PRs in one of those two states; use the normal `/work-on {ISSUE_NUMBER}` resume path instead." This keeps blast radius scoped to exactly the gap this mode fills — it is not a general-purpose re-review trigger. `workflow:remediating` is the non-human state review sets when it hands an autonomous ci-gate / in-pr-fix / base-sync fix to this phase (forge#3541); a bare `needs-human` (older runs, genuine escalations being re-driven) is still accepted. <!-- Added: forge#3541 -->
83
- - **Stranded-state rule**: `workflow:remediating` must never outlive this run. Every exit that returns `BLOCKED` or `UNFIXABLE` (including exits before Phase M1) removes `workflow:remediating` and adds `needs-human`, because at that point a human really is needed:
82
+ - If none of `needs-human`, `workflow:remediating` or `workflow:awaiting-merge` is among `ISSUE_LABELS` → EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "issue #{ISSUE_NUMBER} is none of `needs-human`, `workflow:remediating` or `workflow:awaiting-merge` — remediation mode only targets PRs in one of those states; use the normal `/work-on {ISSUE_NUMBER}` resume path instead." This keeps blast radius scoped to exactly the gap this mode fills — it is not a general-purpose re-review trigger. `workflow:remediating` is the non-human state review sets when it hands an autonomous ci-gate / in-pr-fix / base-sync fix to this phase (forge#3541); a bare `needs-human` (older runs, genuine escalations being re-driven) is still accepted. <!-- Added: forge#3541 -->
83
+ - **Resume-at-M7 (forge#3647, hardened forge#3684)**: `workflow:awaiting-merge` is admitted ONLY as a resume of an interrupted M7 auto-land: the PR is OPEN (the PR state guard above already exited otherwise), `needs-human` and `workflow:remediating` are both absent, the idempotency check below finds NO completed `FORGE:REMEDIATION:COMPLETE` trail (`REMEDIATION_COMPLETE_N` = 0), AND there is evidence the PR was engine-parked for the M7 bar rather than parked for a human: the live base is non-empty, non-`null` and non-`main`, and the LATEST trusted (OWNER/MEMBER/COLLABORATOR or Bot) `review-pr.md` Phase 8 park comment on the issue is the non-`main` "parked at `workflow:awaiting-merge` for the remediation auto-land bar" comment (not the deploy-gate "held ... for a human merge decision" comment). When all hold, set `RESUME_AT_M7=true`: skip Phases M1-M6 entirely (no checkout, no fixes, no re-review) and continue at Phase M7, which re-verifies the bar from scratch. Without park evidence, return `BLOCKED` (blocker: "awaiting-merge PR is not engine-parked for the remediation auto-land bar"); the issue stays at `workflow:awaiting-merge`. If a COMPLETE trail exists the single-attempt `ALREADY_DONE` guard below still wins. On a BLOCKED exit from this path the issue stays at `workflow:awaiting-merge` (it never gains `workflow:remediating`; the stranded-state rule below applies only to `workflow:remediating`).
84
+ - **Stranded-state rule**: `workflow:remediating` must never outlive this run. Every exit that returns `BLOCKED` or `UNFIXABLE` (including exits before Phase M1; except a `RESUME_AT_M7` run, which leaves the issue at `workflow:awaiting-merge` untouched) removes `workflow:remediating` and adds `needs-human`, because at that point a human really is needed:
84
85
 
85
86
  ```bash
86
87
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" --remove-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
@@ -114,16 +115,19 @@ fi
114
115
  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)
115
116
  ```
116
117
 
117
- **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 router handoff is bounded to one per PR (the marker count in `work-on/review.md` R4, unchanged) and this check; inside remediation the sync is bounded per distinct base head and hard-capped at `BASESYNC_MAX_ROUNDS` pushed sync merges on the branch, counted from git rather than shell state (see Phase M3 and M6):
118
+ **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); the guard is relaxed only when the NEWEST bound marker (any of `CI` / `INPR` / `BASESYNC`) is the base-sync one, and a completed base-sync round is recognised by a `FORGE:REMEDIATION:COMPLETE` trail carrying the `**Base sync**: ran` or `**Base sync**: attempted` line that Phase M8 posts (a round that merged nothing is still recorded as `attempted`). The router handoff is bounded to one per PR (the marker count in `work-on/review.md` R4, unchanged) and this check; inside remediation the sync is bounded per distinct base head and hard-capped at `BASESYNC_MAX_ROUNDS` pushed sync merges on the branch, counted from git rather than shell state (see Phase M3 and M6):
118
119
 
119
120
  ```bash
120
121
  if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
121
- 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 "")
122
+ BASESYNC_DONE_N=$(printf '%s' "$PR_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:REMEDIATION -->[\s\S]*\*\*Base sync\*\*: (ran|attempted)[\s\S]*<!-- FORGE:REMEDIATION:COMPLETE -->' 2>/dev/null || echo "")
122
123
  ISSUE_COMMENTS=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null) || ISSUE_COMMENTS=""
123
- BASESYNC_BOUND_N=$(printf '%s' "$ISSUE_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER} -->' 2>/dev/null || echo "")
124
- # A base-sync handoff (bound marker present) with no completed base-sync round yet is a fresh attempt for ITS kind,
125
- # whatever earlier ci-gate / inpr-fix trails exist. Fail closed: an unreadable count keeps the kind-agnostic guard.
126
- 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
124
+ # The override applies only when the CURRENT handoff is base-sync. The current kind is the NEWEST trusted bound marker
125
+ # (`bodies` is oldest-first, so tail -1): the router posts it immediately before invoking this phase. A base-sync handoff with no
126
+ # completed base-sync round (a `ran` or `attempted` trail) is a fresh attempt for ITS kind, whatever earlier ci-gate / inpr-fix
127
+ # trails exist. Fail closed: an unreadable count or kind, or any other kind (including a standalone --remediate with no bound
128
+ # marker), keeps the kind-agnostic guard.
129
+ CURRENT_KIND=$(printf '%s' "$ISSUE_COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:(CI|INPR|BASESYNC)_REMEDIATION: pr={PR_NUMBER} -->' 2>/dev/null | tail -1 | grep -oE '(CI|INPR|BASESYNC)_REMEDIATION' | head -1) || CURRENT_KIND=""
130
+ if [ "$CURRENT_KIND" = "BASESYNC_REMEDIATION" ] && [ -n "$BASESYNC_DONE_N" ] && [ "$BASESYNC_DONE_N" -eq 0 ]; then REMEDIATION_COMPLETE_N=0; fi
127
131
  fi
128
132
  ```
129
133
 
@@ -138,11 +142,25 @@ Completed trails of other kinds are left in place (never deleted); only the inte
138
142
  for _id in $ISSUE_PARTIAL_IDS; do gh api repos/{GH_REPO}/issues/comments/$_id -X DELETE 2>/dev/null || true; done
139
143
  ```
140
144
  - If no comment is found → fresh attempt, continue below.
145
+ - If the issue is at `workflow:awaiting-merge` (and neither `needs-human` nor `workflow:remediating`) and `REMEDIATION_COMPLETE_N` is 0 → require engine-park evidence (see Resume-at-M7 above); only then `RESUME_AT_M7=true`: skip to Phase M7. Any other label combination keeps the normal flow.
146
+
147
+ ```bash
148
+ # forge#3684: admit the resume only for a PR the engine parked for the M7 bar. Fail closed.
149
+ PARK_BASE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
150
+ PARK_LAST=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null | jq -rs '[.[][] | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | select((.body // "") | test("for the remediation auto-land bar|for a human merge decision"))] | last | .body // ""' 2>/dev/null || echo "")
151
+ if [ -n "$PARK_BASE" ] && [ "$PARK_BASE" != "null" ] && [ "$PARK_BASE" != "main" ] && printf '%s' "$PARK_LAST" | grep -qF 'for the remediation auto-land bar'; then
152
+ RESUME_AT_M7=true
153
+ else
154
+ RESUME_AT_M7=false # BLOCKED, issue left at workflow:awaiting-merge
155
+ fi
156
+ ```
141
157
 
142
158
  ---
143
159
 
144
160
  ## Phase M1: Load Prior Findings & Classify the Block Reason
145
161
 
162
+ **If `RESUME_AT_M7=true`** (forge#3647): skip Phases M1-M6 and go straight to Phase M7.
163
+
146
164
  Gather everything that caused (or is still causing) `needs-human`:
147
165
 
148
166
  **M1a — Open review-finding issues spawned from this PR** (same title-match precedent as `review-pr.md` Phase 8B/9A):
@@ -224,36 +242,43 @@ if ! { git diff --quiet && git diff --cached --quiet; }; then
224
242
  fi
225
243
  git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
226
244
  # Durable sync state (forge#3517): every block runs in a fresh shell, so never carry rounds or the base head in shell vars.
227
- # Rounds = merge commits on the PUSHED branch's first-parent chain that are not on the base (a round counts only once pushed;
228
- # a manual merge counts too, fail closed). Recorded base head = the newest such merge's second parent, when that is a base commit.
229
- # An unreadable count is the cap.
245
+ # Rounds = SYNC merges on the PUSHED branch's first-parent chain: merges not on the base whose second parent is an ancestor of
246
+ # origin/{PR_BASE} (a round counts only once pushed; a manual "Update branch" or feature-branch merge is not a sync and does not
247
+ # burn the cap). Recorded base head = the newest sync merge's second parent. An unreadable count is the cap.
230
248
  BASESYNC_MAX_ROUNDS=2 # hard cap on sync rounds per PR (M3 plus M6 re-syncs)
231
- BASESYNC_ROUNDS=$(git rev-list --first-parent --merges --count origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null) || BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS
232
- _lm=$(git rev-list --first-parent --merges -1 origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null || true); _p2=""
233
- [ -n "$_lm" ] && _p2=$(git rev-parse -q --verify "$_lm^2" 2>/dev/null) && git merge-base --is-ancestor "$_p2" origin/{PR_BASE} 2>/dev/null || _p2=""
249
+ BASESYNC_ROUNDS=0; _p2=""
250
+ if _rl=$(git rev-list --first-parent --merges origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null); then
251
+ for _m in $_rl; do _q=$(git rev-parse -q --verify "$_m^2" 2>/dev/null) && git merge-base --is-ancestor "$_q" origin/{PR_BASE} 2>/dev/null && { BASESYNC_ROUNDS=$((BASESYNC_ROUNDS+1)); [ -n "$_p2" ] || _p2="$_q"; }; done
252
+ else BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS; fi
234
253
  BASESYNC_BASE_SHA="$_p2"
235
254
  if [ "$BASESYNC_ROUNDS" -ge "$BASESYNC_MAX_ROUNDS" ]; then
236
255
  echo "refusing base sync: ${BASESYNC_ROUNDS} sync round(s) already on {HEAD_BRANCH} (cap ${BASESYNC_MAX_ROUNDS})"; BASESYNC_REFUSED=true
237
256
  fi
238
257
  if [ "${BASESYNC_REFUSED:-false}" != "true" ]; then
239
- BASESYNC_BASE_SHA=$(git rev-parse origin/{PR_BASE}) # the base head this round merges; persisted by M8 as `Base sync**: ran (base=<sha>)`
258
+ BASESYNC_BASE_SHA=$(git rev-parse origin/{PR_BASE}) # the base head this round merges
240
259
  MERGE_OUT=$(git merge origin/{PR_BASE} --no-edit 2>&1); MERGE_RC=$? # allowlist:check-command-side-effects
241
260
  echo "$MERGE_OUT"
242
261
  # rc alone cannot tell a conflict from a refusal (both exit 1); MERGE_HEAD can.
243
- if [ "$MERGE_RC" -eq 0 ]; then
244
- BASESYNC_RAN=true
245
- elif git rev-parse -q --verify MERGE_HEAD >/dev/null; then
246
- BASESYNC_CONFLICTED=true # merge in progress with conflicts: resolve or park below
247
- else
248
- BASESYNC_MERGE_REFUSED=true # rc!=0 and no MERGE_HEAD: refused, nothing was merged
262
+ # Outcomes are NOT carried to later fences as shell flags: clean = rc 0; conflicts = MERGE_HEAD present (git state).
263
+ if [ "$MERGE_RC" -ne 0 ] && ! git rev-parse -q --verify MERGE_HEAD >/dev/null; then
264
+ # rc!=0 and no MERGE_HEAD: refused, nothing was merged. Park HERE, while MERGE_RC/MERGE_OUT are still in scope.
265
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
266
+ run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
267
+ Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) was refused by git (rc=$MERGE_RC); no merge was started. Git output:
268
+
269
+ \`\`\`
270
+ ${MERGE_OUT}
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
+ echo "BLOCKED: base-sync: merge refused (rc=$MERGE_RC)"
249
274
  fi
250
275
  fi
251
276
  ```
252
277
 
253
- Exactly one outcome applies. `BASESYNC_RAN=true` is set only on a clean merge (or after a conflicted merge is concluded below); a refused merge is never treated as synced.
278
+ Exactly one outcome applies, and it is read from git, never from shell flags (each fence is a fresh shell). A refused merge is never treated as synced.
254
279
 
255
280
  - Clean merge (`MERGE_RC=0`): nothing more to resolve; the merge commit is the sync.
256
- - Conflicts (`MERGE_RC` nonzero, `MERGE_HEAD` present): 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), then set `BASESYNC_RAN=true`.
281
+ - Conflicts (`MERGE_RC` nonzero, `MERGE_HEAD` present): 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). The merge commit on the branch is the durable record of the sync; Phase M8 derives the trail line from git.
257
282
  - **Unresolvable with confidence** (a hunk whose intent on either side you cannot reconcile, or the branch/tree is refused above): capture the file list first, then abort only if a merge is in progress, and park:
258
283
  ```bash
259
284
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
@@ -269,20 +294,9 @@ Exactly one outcome applies. `BASESYNC_RAN=true` is set only on a clean merge (o
269
294
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
270
295
  ```
271
296
  EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: unresolvable conflicts". Only that issue is parked.
272
- - **Refused merge** (`MERGE_RC` nonzero and no `MERGE_HEAD`, i.e. `BASESYNC_MERGE_REFUSED=true`): git declined to start the merge (for example local changes would be overwritten). This is not a conflict and not a sync. There is nothing to abort. Park with the git output:
273
- ```bash
274
- run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
275
- run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
276
- Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) was refused by git (rc=${MERGE_RC}); no merge was started. Git output:
277
-
278
- \`\`\`
279
- ${MERGE_OUT}
280
- \`\`\`" # allowlist:check-command-side-effects
281
- gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
282
- ```
283
- EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: merge refused (rc=N)" (N = `MERGE_RC`). Never fall through to M4 as synced.
284
- - **Re-entry (per distinct base head)**: Phase M3's sync is re-enterable from Phase M6, but only when the base advanced past `BASESYNC_BASE_SHA` and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS`. A re-sync round runs this same block on the new head with the same guards (shared-branch refusal, clean tree, merge-only, no force-push, resolve-or-park, `FORGE:BASESYNC_FAILED`). The block derives `BASESYNC_ROUNDS` and `BASESYNC_BASE_SHA` from git on every entry (pushed sync merges on the branch), never from shell state, and refuses at the cap; an unreadable count is the cap. `BASESYNC_RAN=true` is still set only on a clean or concluded merge.
285
- - 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).
297
+ - **Refused merge** (the sync fence printed `BLOCKED: base-sync: merge refused (rc=N)`): git declined to start the merge (for example local changes would be overwritten). This is not a conflict and not a sync, and there is nothing to abort. The sync fence has already posted `FORGE:BASESYNC_FAILED` (with the rc and git output) and added `needs-human` from inside that fence, because `MERGE_RC`/`MERGE_OUT` do not survive into a later fence. EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: merge refused (rc=N)" (N from the printed line). Never fall through to M4 as synced.
298
+ - **Re-entry (per distinct base head)**: Phase M3's sync is re-enterable from Phase M6, but only when the base advanced past `BASESYNC_BASE_SHA` and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS`. A re-sync round runs this same block on the new head with the same guards (shared-branch refusal, clean tree, merge-only, no force-push, resolve-or-park, `FORGE:BASESYNC_FAILED`). The block derives `BASESYNC_ROUNDS` and `BASESYNC_BASE_SHA` from git on every entry (pushed sync merges on the branch), never from shell state, and refuses at the cap; an unreadable count is the cap.
299
+ - 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 of that head; `/review-pr` then scopes it: a head that is only a clean base merge is carried forward from the last fully reviewed head without a new panel, and a head with ordinary fix commits is reviewed as a delta).
286
300
 
287
301
  **Quality Gate** (same loop as Phase 3G, max 3 iterations):
288
302
  ```
@@ -394,9 +408,10 @@ If no dispatch tool resolves, a missing tool is not a human decision, so do NOT
394
408
  cd {WORKTREE_PATH}
395
409
  git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
396
410
  BASESYNC_MAX_ROUNDS=2
397
- BASESYNC_ROUNDS=$(git rev-list --first-parent --merges --count origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null) || BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS
398
- _lm=$(git rev-list --first-parent --merges -1 origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null || true); _p2=""
399
- [ -n "$_lm" ] && _p2=$(git rev-parse -q --verify "$_lm^2" 2>/dev/null) && git merge-base --is-ancestor "$_p2" origin/{PR_BASE} 2>/dev/null || _p2=""
411
+ BASESYNC_ROUNDS=0; _p2=""
412
+ if _rl=$(git rev-list --first-parent --merges origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null); then
413
+ for _m in $_rl; do _q=$(git rev-parse -q --verify "$_m^2" 2>/dev/null) && git merge-base --is-ancestor "$_q" origin/{PR_BASE} 2>/dev/null && { BASESYNC_ROUNDS=$((BASESYNC_ROUNDS+1)); [ -n "$_p2" ] || _p2="$_q"; }; done
414
+ else BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS; fi
400
415
  BASESYNC_BASE_SHA="$_p2"
401
416
  NOW_BASE_SHA=$(git rev-parse origin/{PR_BASE})
402
417
  FRESH_MERGED=false
@@ -461,17 +476,18 @@ CUR_BASE_SHA=$(git rev-parse origin/{PR_BASE})
461
476
  # Recorded head and rounds come from the pushed branch's sync merges (same derivation as M3), never from shell vars or
462
477
  # the M8 trail line, which is written after this phase (forge#3517). No recorded head is treated as advanced, but only below the cap.
463
478
  BASESYNC_MAX_ROUNDS=2
464
- BASESYNC_ROUNDS=$(git rev-list --first-parent --merges --count origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null) || BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS
465
- _lm=$(git rev-list --first-parent --merges -1 origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null || true); _p2=""
466
- [ -n "$_lm" ] && _p2=$(git rev-parse -q --verify "$_lm^2" 2>/dev/null) && git merge-base --is-ancestor "$_p2" origin/{PR_BASE} 2>/dev/null || _p2=""
479
+ BASESYNC_ROUNDS=0; _p2=""
480
+ if _rl=$(git rev-list --first-parent --merges origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null); then
481
+ for _m in $_rl; do _q=$(git rev-parse -q --verify "$_m^2" 2>/dev/null) && git merge-base --is-ancestor "$_q" origin/{PR_BASE} 2>/dev/null && { BASESYNC_ROUNDS=$((BASESYNC_ROUNDS+1)); [ -n "$_p2" ] || _p2="$_q"; }; done
482
+ else BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS; fi
467
483
  BASESYNC_BASE_SHA="$_p2"
468
484
  echo "recorded=${BASESYNC_BASE_SHA:-none} current=${CUR_BASE_SHA} rounds=${BASESYNC_ROUNDS}/${BASESYNC_MAX_ROUNDS}"
469
485
  ```
470
486
 
471
- - **Base advanced** (`CUR_BASE_SHA` differs from the recorded `BASESYNC_BASE_SHA`) and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS` (2 pushed sync merges on the branch): this is a new conflict a human did not cause. Re-run the Phase M3 base-sync on the new head (same resolve-or-park rules), then the Phase M4 quality gate, ancestry guard and push, the M5 trail, and this Phase M6 once more. The pushed merge raises the derived count and moves the recorded head, so nothing is carried in shell state. Do not post a second `FORGE:REMEDIATION:COMPLETE`; only M8 does, once.
487
+ - **Base advanced** (`CUR_BASE_SHA` differs from the recorded `BASESYNC_BASE_SHA`) and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS` (2 pushed sync merges on the branch; only merges whose second parent is a base ancestor count): this is a new conflict a human did not cause. Re-run the Phase M3 base-sync on the new head (same resolve-or-park rules), then the Phase M4 quality gate, ancestry guard and push, the M5 trail, and this Phase M6 once more. The pushed merge raises the derived count and moves the recorded head, so nothing is carried in shell state. Do not post a second `FORGE:REMEDIATION:COMPLETE`; only M8 does, once.
472
488
  - **Base unchanged** (the sync did not clear the conflict) or `BASESYNC_ROUNDS` has reached `BASESYNC_MAX_ROUNDS` (a base that never settles): park. Add `needs-human`, post a comment naming the PR, the base SHA(s) tried (recorded and current) 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".
473
489
 
474
- 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:
490
+ This re-reviews the new head (full, delta-scoped or carried forward, as `/review-pr` decides; then 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:
475
491
 
476
492
  - **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.
477
493
  - **Clean re-review**: `VERDICT=APPROVED`-equivalent, mergeable, and the "Previously-escalated re-review guard" (forge#1810) fires — setting `workflow:awaiting-merge` and removing `workflow:in-review`, *without* auto-merging (that guard's own safe default, left untouched by this file).
@@ -493,6 +509,36 @@ RE_REVIEW_VERDICT=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
493
509
 
494
510
  ## Phase M7: Compute the #1809 Q1 Auto-Land Bar
495
511
 
512
+ **Merged-first check (forge#3647)**: before anything else, re-read the live PR. If it is already merged (a prior run merged it, then was cut off before M8), do NOT report HELD: set `RE_GATE_OUTCOME="AUTO-LANDED"`, run the same post-merge label bookkeeping as the merge branch below (idempotent), and skip to Phase M8.
513
+
514
+ ```bash
515
+ PR_LIVE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state,mergedAt,headRefOid,commits 2>/dev/null || echo "")
516
+ if [ "$(printf '%s' "$PR_LIVE" | jq -r '.state // ""' 2>/dev/null)" = "MERGED" ] || [ -n "$(printf '%s' "$PR_LIVE" | jq -r '.mergedAt // empty' 2>/dev/null)" ]; then
517
+ RE_GATE_OUTCOME="AUTO-LANDED" # then the post-merge transition-label block below, then M8
518
+ fi
519
+ ```
520
+
521
+ **Live base (computed BEFORE the resume substitution, forge#3684)**: re-derive the base fresh — do NOT reuse `$PR_BASE` for this decision (forge#2624): `$PR_BASE` was resolved in Phase M0 and a caller-supplied `--base` wins over the PR's actual live base. This is a security-relevant decision point, so re-query the PR's live base independent of `$PR_BASE`. Fail closed: only a KNOWN, non-empty, non-`null`, non-`main` live base reconciles to the fast lane (forge#2570); empty, the literal string `null` (jq stringifies JSON null, forge#2625) or `main` is the deploy gate (strict).
522
+
523
+ ```bash
524
+ LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
525
+ if [ -n "$LIVE_BASE_REF" ] && [ "$LIVE_BASE_REF" != "null" ] && [ "$LIVE_BASE_REF" != "main" ]; then IS_DEPLOY_GATE=false; else IS_DEPLOY_GATE=true; fi
526
+ ```
527
+
528
+ **Resume path (`RESUME_AT_M7=true`)**: no M3 quality gate ran in this invocation, so `GATE_PASSED` is not set. Substitute fresh evidence that the PR is approved at its CURRENT head: a collaborator or Bot comment whose body starts with `APPROVED:` (anchored, case-sensitive, so `NOT APPROVED:` and quoted text never match) created AFTER the `committedDate` of the commit whose `oid` equals `headRefOid`. Anything unreadable, stale, or a `headRefOid` that matches no commit means `GATE_PASSED=false` (hold). The substitution applies to non-`main` bases only: when `RESUME_AT_M7=true` and `IS_DEPLOY_GATE=true`, `GATE_PASSED=false` is forced and the bar below holds (`BAR_MET=false`) in code, so a `main` (deploy-gate) base never lands from the resume path. CI is still verified by the mandatory CI gate below.
529
+
530
+ ```bash
531
+ if [ "${RESUME_AT_M7:-false}" = "true" ]; then
532
+ HEAD_OID=$(printf '%s' "$PR_LIVE" | jq -r '.headRefOid // ""' 2>/dev/null)
533
+ HEAD_COMMITTED=""
534
+ [ -n "$HEAD_OID" ] && HEAD_COMMITTED=$(printf '%s' "$PR_LIVE" | jq -r --arg head "$HEAD_OID" '[.commits[] | select(.oid == $head)] | first | .committedDate // ""' 2>/dev/null)
535
+ RESUME_APPROVED_N=$(gh api --paginate repos/{GH_REPO}/issues/{PR_NUMBER}/comments 2>/dev/null | jq -rs --arg t "$HEAD_COMMITTED" '[.[][] | select((.body // "") | test("^APPROVED:")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | select($t != "" and .created_at > $t)] | length' 2>/dev/null || echo 0)
536
+ if [ "$IS_DEPLOY_GATE" = "true" ]; then GATE_PASSED=false # main / empty / null base: resume never lands
537
+ elif [ "${RESUME_APPROVED_N:-0}" -ge 1 ]; then GATE_PASSED=true
538
+ else GATE_PASSED=false; fi
539
+ fi
540
+ ```
541
+
496
542
  Re-read the issue's current labels after M6:
497
543
  ```bash
498
544
  POST_REVIEW_LABELS=$(gh issue view {ISSUE_NUMBER} {GH_FLAG} --json labels --jq '[.labels[].name] | join(",")')
@@ -520,27 +566,7 @@ APPROVED_COUNT=$(echo "$REVIEW_BODIES" | grep -cE 'APPROVED:' 2>/dev/null || tru
520
566
 
521
567
  **Auto-land bar — base-branch scoped** (forge#2570): the condition the PR must clear to auto-land depends on its target branch. This reconciles the remediation bar with the normal `/work-on` fast-lane merge bar for the *same target branch*, while keeping the strict human-verified bar only where a human is genuinely in the loop (the `staging → main` deploy gate).
522
568
 
523
- **Re-derive the base fresh — do NOT reuse `$PR_BASE` for this decision** (forge#2624): `$PR_BASE` was resolved in Phase M0 as `PR_BASE="${PR_BASE:-$(... .baseRefName)}"` — a caller-supplied `--base` wins over the PR's actual live base whenever non-empty. That is fine for M0's own purposes (worktree base, display text), but this is a security-relevant decision point: a wrong/stale `--base staging` on a PR that actually targets `main` must never be allowed to relax the strict deploy-gate bar. Mirror `review-pr.md`'s sibling guard (`GUARD_BASE`, which always re-fetches `baseRefName` fresh at its own decision point) by re-querying the PR's live base here, independent of whatever `$PR_BASE` currently holds:
524
-
525
- ```bash
526
- # forge#2624: re-fetch baseRefName fresh from the PR itself — never trust the
527
- # M0-resolved $PR_BASE for this decision, since M0 prefers a caller-supplied
528
- # --base over the live value. This is the one line in this phase that makes
529
- # a trust/security decision, so it must be immune to a caller-overridable input.
530
- LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
531
-
532
- # forge#2570: `main` (and any deploy-gate base) keeps the strict #1809 Q1 verified-human bar;
533
- # every other base (staging, milestone/* — the reversible integration branches) reconciles to
534
- # the fast-lane bar. Key on "is the deploy gate", NOT the literal string "staging", so milestone
535
- # branches reconcile too. Fail closed: only a KNOWN, non-empty, non-`main` LIVE base reconciles
536
- # to the fast lane; an empty/unresolved fetch is treated as the deploy gate (strict) so a base-
537
- # resolution failure (or a caller passing a stale/incorrect --base) can never accidentally
538
- # relax the bar.
539
- # forge#2625: `jq -r '.baseRefName'` stringifies a JSON null to the literal text "null" (not an
540
- # empty string), so the `-n` check alone does not catch it — add an explicit `!= "null"` check
541
- # so a literal-string "null" is treated the same as an empty/unresolved base (strict).
542
- if [ -n "$LIVE_BASE_REF" ] && [ "$LIVE_BASE_REF" != "null" ] && [ "$LIVE_BASE_REF" != "main" ]; then IS_DEPLOY_GATE=false; else IS_DEPLOY_GATE=true; fi
543
- ```
569
+ (`LIVE_BASE_REF` / `IS_DEPLOY_GATE` were computed above, before the resume substitution, and are not re-derived here. forge#2570 / #2624 / #2625 rationale applies unchanged.)
544
570
 
545
571
  **Non-`main` base (`IS_DEPLOY_GATE=false` — staging / milestone)** — reconcile to the fast-lane bar. `review-pr.md`'s Phase 8 guard only parks a PR at `workflow:awaiting-merge` after a clean, mergeable `APPROVED` re-review (the same bot-`APPROVED` signal the normal fast lane auto-merges on), so the only additional condition is this remediation's own quality gate:
546
572
  1. `GATE_PASSED = true` from this remediation's Phase M3 quality-gate loop.
@@ -551,11 +577,14 @@ The strict `APPROVED_COUNT >= 2` verified-human requirement does NOT apply here:
551
577
  1. `APPROVED_COUNT >= 2` — at least two distinct adversarial `APPROVED:` review comments from repo collaborators (`OWNER`/`MEMBER`/`COLLABORATOR` authorAssociation only — see trust filter above; same counting convention as `work-on/close.md` C4).
552
578
  2. `GATE_PASSED = true` from this remediation's own Phase M3 quality-gate loop.
553
579
 
580
+ A `RESUME_AT_M7=true` run on a deploy-gate base NEVER meets this bar (`BAR_MET=false` is enforced in the `if` below): no M3 gate ran in the resume invocation, so it holds for a human. <!-- Added: forge#3684 -->
581
+
554
582
  **Evaluate the base-scoped bar**:
555
583
  ```bash
556
584
  if [ "$IS_DEPLOY_GATE" = "true" ]; then
557
- # main / deploy gate — strict verified-human bar
558
- { [ "${APPROVED_COUNT:-0}" -ge 2 ] && [ "${GATE_PASSED:-false}" = "true" ]; } && BAR_MET=true || BAR_MET=false
585
+ # main / deploy gate — strict verified-human bar; a resume (no in-run M3 gate) never lands here (forge#3684)
586
+ if [ "${RESUME_AT_M7:-false}" = "true" ]; then BAR_MET=false
587
+ else { [ "${APPROVED_COUNT:-0}" -ge 2 ] && [ "${GATE_PASSED:-false}" = "true" ]; } && BAR_MET=true || BAR_MET=false; fi
559
588
  else
560
589
  # staging / milestone — fast-lane bar (bot APPROVED already implied by workflow:awaiting-merge)
561
590
  [ "${GATE_PASSED:-false}" = "true" ] && BAR_MET=true || BAR_MET=false
@@ -637,12 +666,44 @@ esac
637
666
  # forge#3413: embed the pushed head so the orchestrator scopes a REREVIEW-REQUIRED trail to the CURRENT head only.
638
667
  REMEDIATED_HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || true)
639
668
 
640
- # forge#3496: mark a base-sync round so Phase M0 scopes the single-attempt guard per kind.
641
- # Persist the merged base head so it survives across blocks: fall back to the merge's second parent when the shell var is unset.
669
+ # forge#3496 / forge#3518: mark a base-sync round so Phase M0 scopes the single-attempt guard per kind. This block runs in a fresh
670
+ # shell, so it derives the record from durable state (pushed git history + trusted bound markers), never from a shell variable.
671
+ # 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.
672
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
673
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
674
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
675
+ [ -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)"
676
+ done
677
+ TRUSTED_SCRIPT=""
678
+ while IFS= read -r _c; do
679
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
680
+ done <<< "$_tc"
681
+ cd {WORKTREE_PATH}
682
+ git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
683
+ BASESYNC_MAX_ROUNDS=2
684
+ BASESYNC_ROUNDS=0; _p2=""
685
+ if _rl=$(git rev-list --first-parent --merges origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null); then
686
+ for _m in $_rl; do _q=$(git rev-parse -q --verify "$_m^2" 2>/dev/null) && git merge-base --is-ancestor "$_q" origin/{PR_BASE} 2>/dev/null && { BASESYNC_ROUNDS=$((BASESYNC_ROUNDS+1)); [ -n "$_p2" ] || _p2="$_q"; }; done
687
+ else BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS; fi
688
+ BASESYNC_BASE_SHA="$_p2"
689
+ ISSUE_COMMENTS=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null) || ISSUE_COMMENTS=""
690
+ # Current kind = the NEWEST trusted bound marker (bodies are oldest-first, so tail -1). Unreadable or non-base-sync: no line (fail closed).
691
+ BASESYNC_KIND=""
692
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$ISSUE_COMMENTS" ]; then
693
+ BASESYNC_KIND=$(printf '%s' "$ISSUE_COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:(CI|INPR|BASESYNC)_REMEDIATION: pr={PR_NUMBER} -->' 2>/dev/null | tail -1 | grep -oE '(CI|INPR|BASESYNC)_REMEDIATION' | head -1) || BASESYNC_KIND=""
694
+ fi
642
695
  BASESYNC_LINE=""
643
- if [ "${BASESYNC_RAN:-false}" = "true" ]; then
644
- BASESYNC_BASE_SHA="${BASESYNC_BASE_SHA:-$(git -C {WORKTREE_PATH} rev-parse -q --verify 'HEAD^2' 2>/dev/null || true)}"
645
- BASESYNC_LINE="**Base sync**: ran (base=${BASESYNC_BASE_SHA:-unknown})"
696
+ if [ "$BASESYNC_KIND" = "BASESYNC_REMEDIATION" ]; then
697
+ # A sync merge is this round's when its base head is not already recorded by an earlier trusted `Base sync: ran` trail.
698
+ _seen=""
699
+ [ -n "$BASESYNC_BASE_SHA" ] && _seen=$(printf '%s' "$ISSUE_COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '\*\*Base sync\*\*: ran \(base='"$BASESYNC_BASE_SHA" 2>/dev/null || true)
700
+ if [ -n "$BASESYNC_BASE_SHA" ] && [ -z "$_seen" ]; then
701
+ BASESYNC_LINE="**Base sync**: ran (base=${BASESYNC_BASE_SHA})"
702
+ else
703
+ # No merge pushed this round (refused, capped, nothing to merge): still a durable completion record, so M0 counts the round
704
+ # as done and the per-kind override cannot re-arm for every later invocation.
705
+ BASESYNC_LINE="**Base sync**: attempted (no merge)"
706
+ fi
646
707
  fi
647
708
 
648
709
  REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
@@ -529,7 +529,7 @@ else:
529
529
 
530
530
  Wait for that task's completed result before Phase R4. Propagate its `REVIEW_RESULT` as this module's child state; do not return `REVIEW_RESULT`, report progress, release an orchestrator slot, or begin close work while the child is running. If the child errors or returns no parseable `REVIEW_RESULT`, return `REVIEW_RESULT: status: BLOCKED` with the child failure as the blocker. The normal `Skill(...)` invocation above remains the non-OpenCode path.
531
531
 
532
- /review-pr handles: full domain-agent review → post findings as separate issues (non-blocking) → merge the PR. It does NOT close the issue or clean up the worktree — `work-on:close` does both.
532
+ /review-pr handles: the domain-agent review (it picks FULL, DELTA or BASE_SYNC_ONLY itself: a head that only merged the base is carried forward without reviewers) → post findings as separate issues (non-blocking) → merge the PR. It does NOT close the issue or clean up the worktree — `work-on:close` does both.
533
533
 
534
534
  If `review-pr` is not found under `{FORGE_SKILL_PREFIX}review-pr` or `review-pr`, print `REVIEW_RESULT: status: BLOCKED`, blocker: "skill not found: review-pr" and STOP. Never review inline.
535
535
 
@@ -564,7 +564,7 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
564
564
 
565
565
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker mentions the phase trail (any blocker containing "phase trail": "phase trail unreadable" when the Phase 8 verifier exited ≥2 / 127, "phase trail incomplete…", or "auto-merge requires --issue", forge#3147): the merge gate refused or could not run. Do NOT re-run phases (nothing is missing) and do NOT attempt the manual merge below, because that would bypass the gate. Add `needs-human` and return `REVIEW_RESULT: status: BLOCKED` with the same blocker. The PR stays open and unmerged.
566
566
 
567
- - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "stale review" (a commit landed on the PR after the verdict, or the head moved during the CI wait, so the code that would merge is not the reviewed code): do NOT merge. The re-review bound is persisted, not remembered: count `<!-- FORGE:STALE_REREVIEW: pr={PR_NUMBER} -->` comments on the issue. If the count is 0, post that marker (with the new head SHA), run the quality gate on the new head (`Skill(skill="{FORGE_SKILL_PREFIX}quality-gate", args="<changed files> --worktree {WORKTREE_PATH}")`, which posts a fresh `FORGE:QUALITY_GATE` for the code that will actually merge), then re-invoke Phase R3 once — a full review of the new head. If the count is already ≥ 1 (the head keeps moving after review), add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker: "PR head keeps moving after review". <!-- forge#3188 -->
567
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "stale review" (a commit landed on the PR after the verdict, or the head moved during the CI wait, so the code that would merge is not the reviewed code): do NOT merge. The re-review bound is persisted, not remembered: count `<!-- FORGE:STALE_REREVIEW: pr={PR_NUMBER} -->` comments on the issue. If the count is 0, post that marker (with the new head SHA), run the quality gate on the new head (`Skill(skill="{FORGE_SKILL_PREFIX}quality-gate", args="<changed files> --worktree {WORKTREE_PATH}")`, which posts a fresh `FORGE:QUALITY_GATE` for the code that will actually merge), then re-invoke Phase R3 once — a review of the new head (scoped by /review-pr: full, delta or carried forward). If the count is already ≥ 1 (the head keeps moving after review), add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker: "PR head keeps moving after review". <!-- forge#3188 -->
568
568
 
569
569
  - **Remediation-pending handoff (shared by the ci-gate, in-pr-fix and base-conflict cases below)** <!-- Added: forge#3541 -->: these three cases are autonomous pipeline work, not a human decision, so the issue must NOT carry `needs-human` while remediation is pending. `needs-human` is a human-gate signal (operators alert on it, and orchestrate holds DAG dependents as GATED on it). Mark the handoff with the non-human state `workflow:remediating` instead, and clear any `needs-human` that review-pr Phase 8 already set for the ci-gate refusal. `work-on:remediate` accepts either label (a bare `needs-human` from an older run still works), clears `workflow:remediating` on every exit, and re-asserts `needs-human` only when it cannot fix the PR. Run this block, then return `REVIEW_RESULT: status: NEXT`:
570
570
  ```bash
@@ -577,7 +577,7 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
577
577
  gh pr edit {PR_NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
578
578
  ```
579
579
 
580
- - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed, were cancelled, or stayed pending past the gate retries): do NOT attempt the manual merge below — that would bypass the CI gate. Fixing red CI is pipeline work, not a human decision: hand remediation to the router **once**. Bound: count `<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->` comments on the issue first; if ≥ 1, do not remediate again — remove `workflow:remediating`, label the issue `needs-human` (the bound is exhausted, so a human is now genuinely needed) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise run the remediation-pending handoff block above (sets `workflow:remediating`, removes the `needs-human` that Phase 8 set) and return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: ci-gate` (see Output). Do not post the marker here: the router posts it immediately before it invokes remediation (Phase 4R), so a handoff interrupted before remediation starts is retried instead of being counted as used. **Never invoke `work-on:remediate` from this phase** (forge#3398): remediation re-reviews through `/review-pr`, which spawns domain-reviewer sub-agents, so it must run one level below the router, not nested inside this fork. Nested here, it lands at the sub-agent depth limit under `/orchestrate` with no `Agent` tool and strands the PR at `REREVIEW-REQUIRED` (forge#3391). The router runs `work-on:remediate`, which classifies a CI-gate refusal as FIXABLE, swaps `workflow:remediating` (or a legacy `needs-human`) for `workflow:in-review`, fixes the failing jobs on the PR branch, re-runs the quality gate and a full review, and auto-lands through the same CI gate. <!-- forge#3191 -->
580
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed, were cancelled, or stayed pending past the gate retries): do NOT attempt the manual merge below — that would bypass the CI gate. Fixing red CI is pipeline work, not a human decision: hand remediation to the router **once**. Bound: count `<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->` comments on the issue first; if ≥ 1, do not remediate again — remove `workflow:remediating`, label the issue `needs-human` (the bound is exhausted, so a human is now genuinely needed) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise run the remediation-pending handoff block above (sets `workflow:remediating`, removes the `needs-human` that Phase 8 set) and return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: ci-gate` (see Output). Do not post the marker here: the router posts it immediately before it invokes remediation (Phase 4R), so a handoff interrupted before remediation starts is retried instead of being counted as used. **Never invoke `work-on:remediate` from this phase** (forge#3398): remediation re-reviews through `/review-pr`, which spawns domain-reviewer sub-agents, so it must run one level below the router, not nested inside this fork. Nested here, it lands at the sub-agent depth limit under `/orchestrate` with no `Agent` tool and strands the PR at `REREVIEW-REQUIRED` (forge#3391). The router runs `work-on:remediate`, which classifies a CI-gate refusal as FIXABLE, swaps `workflow:remediating` (or a legacy `needs-human`) for `workflow:in-review`, fixes the failing jobs on the PR branch, re-runs the quality gate and re-reviews the new head (scoped by /review-pr: full, delta or carried forward), and auto-lands through the same CI gate. <!-- forge#3191 -->
581
581
 
582
582
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "in-pr fix required" (review-pr §6B.6: CONFIRMED MEDIUM findings in files this PR changed, requested on the PR in a `FORGE:INPR_FIX` comment at the current head): do NOT merge and do NOT leave the issue at `needs-human` because of this gate. Run **one** fix round, bounded by `<!-- FORGE:INPR_REMEDIATION: pr={PR_NUMBER} -->` on the issue (`{BOUND}` = `INPR_REMEDIATION` below).
583
583
  - **Bound unused**: run the remediation-pending handoff block above (set `workflow:remediating`, no `needs-human`; remediation accepts that state and its Phase M1 clears it as FIXABLE). Then return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: inpr-fix` (the router posts the bound marker, as in the ci-gate case) — never invoke `work-on:remediate` from this phase (same reason as the ci-gate case). The router runs remediation, which reads the `FORGE:INPR_FIX` work order, fixes exactly those findings, re-reviews the new head and auto-lands. On the re-review, any finding still present is filed as an issue (the round is used). If remediation does not land, the router re-invokes this phase, which then finds the bound used and takes the branch below.
package/docs/CONFIG.md CHANGED
@@ -496,6 +496,15 @@ build:
496
496
  # Changed-line ceiling. Default: 1000. 0 disables the gate.
497
497
  threshold: 1000
498
498
 
499
+ # Pre-build estimate (investigate): an estimate above threshold x factor routes
500
+ # straight to decompose without building. Default: 1.2. Skipped when threshold is 0.
501
+ estimate_factor: 1.2
502
+
503
+ # Pre-build estimate (investigate): a batch with at least this many independent
504
+ # seams defaults to one sub-issue per seam unless the estimate is at or under
505
+ # half the threshold. Default: 3.
506
+ batch_seam_min: 3
507
+
499
508
  # Extra exclusion globs, ADDITIVE to the built-in defaults below.
500
509
  exclude_globs:
501
510
  - "docs/api/*"
@@ -505,11 +514,15 @@ build:
505
514
  | Field | Type | Required | Description |
506
515
  |-------|------|----------|-------------|
507
516
  | `diff_size.threshold` | integer | No | Changed lines allowed before the gate trips. Default: 1000. `0` disables the gate |
517
+ | `diff_size.estimate_factor` | number | No | Multiplier on `threshold` for the pre-build estimate: the investigator forces a decomposition when `ESTIMATED_DIFF_LINES` > `threshold` x `estimate_factor`. The engine's fail-toward-decompose net (`isDecomposed`) does not read this setting: it always uses the fixed default 1.2, even if the verdict says NO. Default: 1.2. Skipped when `threshold` is `0` |
518
+ | `diff_size.batch_seam_min` | integer | No | Independent seams (batch members) at which an issue defaults to one sub-issue per seam, unless `ESTIMATED_DIFF_LINES` is at or under half of `threshold`. Default: 3. Skipped when `threshold` is `0` |
508
519
  | `diff_size.exclude_globs` | list of strings | No | Globs excluded from the measurement, in addition to the built-in defaults. Default: `[]` |
509
520
 
510
521
  **Built-in default exclusions** (always applied, defined once in `scripts/diff-size.sh`): lockfiles (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `poetry.lock`, `uv.lock`, `go.sum`, `Gemfile.lock`, `composer.lock`), `*.min.*`, `*.snap`, and the directories `dist/`, `build/`, `generated/`, `fixtures/`, `vendor/` (anchored to the repo root, so first-party directories such as `commands/work-on/build/` are counted) plus `__generated__/` and `__snapshots__/` (any depth). The diff is measured against the merge base of `HEAD` and `origin/<base>`, so commits that landed on the base after the branch point are not counted. A configured glob ending in `/` matches that directory at any depth; any other glob is matched against the file basename and the full path. Globs are treated as data, never evaluated.
511
522
 
512
- **Over the threshold**: the build posts a `FORGE:DIFF_SIZE` comment with a split proposal and exits `BUILD_RESULT: status: NEEDS_DECOMPOSE`; the router then runs `work-on:decompose`. To build the oversized diff anyway, post a `FORGE:SIZE_OVERRIDE` comment whose body has a non-empty justification on the line after the marker (only comments from trusted authors count). An issue that is already a decomposed child, or that already has `FORGE:DECOMPOSED`, is not decomposed again: the build blocks until an override is posted. The measured `diff_lines` is recorded in the `FORGE:TRAJECTORY` comment.
523
+ **Over the threshold**: the build posts a `FORGE:DIFF_SIZE` comment with a split proposal and exits `BUILD_RESULT: status: NEEDS_DECOMPOSE`; the router then runs `work-on:decompose`. To build the oversized diff anyway, post a `FORGE:SIZE_OVERRIDE` comment whose body has a non-empty justification on the line after the marker (only comments from trusted authors count). An issue that is already a decomposed child, or that already has `FORGE:DECOMPOSED`, is not decomposed again: the build blocks until an override is posted. The staged diff is also persisted before the exit (pushed ref `forge/wip/{NUMBER}`, or a patch file when the push fails) and each split-proposal line links its hunks, so they survive worktree cleanup; decompose copies the link into each sub-issue. The measured `diff_lines` is recorded in the `FORGE:TRAJECTORY` comment.
524
+
525
+ **Pre-build estimate**: `work-on:investigate` emits `ESTIMATED_DIFF_LINES`, `INDEPENDENT_SEAMS` and `GATE_THRESHOLD` inside `### Decomposition Assessment`. An estimate above `threshold` x `estimate_factor` (or a batch of `batch_seam_min`+ seams whose estimate is not well under the gate) answers `**YES**`, so the issue is decomposed before any build. The estimate rule is skipped when `threshold` is `0`.
513
526
 
514
527
  **Commands that use this section**: `work-on` (build phase B5.5, `decompose`, `close`)
515
528
 
@@ -2,6 +2,14 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## Cross-head and cross-PR re-flag dedup (#3641)
6
+
7
+ `scripts/classify-finding.sh` takes `--prior-findings <TSV>` (file, line-start, line-end, title, prior-disposition, issue#) plus `--title`, `--line-start`/`--line-end` and an optional `--diff-map`. A finding that re-raises one already posted or noted earlier (same file AND line IoU >= 0.6 or >= 3 shared title tokens) becomes `NOTE reflag-filed #N` or `NOTE reflag-note` instead of a new issue. HIGH/CRITICAL are never demoted, and a prior row whose issue is closed is ignored so a regression is not swallowed.
8
+
9
+ `/review-pr` §6B.5 builds the TSV from trusted reviewer comments of earlier heads and earlier `FORGE:NOTE_DISPOSITION` records; `/review-pr-staging` §7B.5 builds it from every bundle member PR. Both read comments only through `scripts/trusted-comments.sh`. In the staging spec §7B.6 (provenance gates) now comes before §7B.5. The `FORGE:NOTE_DISPOSITION` marker gains `notes_reflag=` (and `/review-pr` also `reviewed_sha=`); consumers match the marker prefix only.
10
+
11
+ Action: none. Records posted before this change lack `reviewed_sha=` and are not read as prior findings by `/review-pr`.
12
+
5
13
  ## Review damper: LOW needs keyword + CONFIRMED; a missing classifier blocks the review (#3639)
6
14
 
7
15
  `scripts/classify-finding.sh` is stricter for LOW findings. A safety keyword now files a LOW finding only when it is also CONFIRMED (LOW LIKELY/POSSIBLE with a keyword is a NOTE), and a dedicated domain agent (Auth, Billing, Concurrency, Database) rescues MEDIUM findings only, no longer LOW CONFIRMED. MEDIUM behaviour is unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.13.1",
3
+ "version": "1.14.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # architect-resume-match.test.sh — the anchored, trusted FORGE:ARCHITECT resume selector (forge#3648)
3
+ # Exercises the exact jq used by commands/work-on/build/architect.md Resume Check. No network.
4
+ # Usage: bash scripts/architect-resume-match.test.sh
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+
8
+ set -uo pipefail
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
11
+ SPEC="$ROOT/commands/work-on/build/architect.md"
12
+
13
+ PASS=0; FAILN=0
14
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
15
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
16
+ eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
17
+
18
+ SELECT_JQ='[.[][] | select((.body // "") | startswith("<!-- FORGE:ARCHITECT -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:ARCHITECT:COMPLETE -->")), other: (.body | test("<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|BUILDER)"))}]'
19
+ DELETE_JQ='[.[] | select(.complete | not)] | if length == 1 and (.[0].other | not) then .[0].id else empty end'
20
+
21
+ # c <id> <assoc> <type> <body>
22
+ c() { jq -nc --argjson i "$1" --arg a "$2" --arg t "$3" --arg b "$4" '{id:$i,author_association:$a,user:{type:$t},body:$b}'; }
23
+ arch() { printf '%s' "$1" | jq -sc . | jq -s "$SELECT_JQ"; }
24
+ n_complete() { arch "$1" | jq '[.[] | select(.complete)] | length'; }
25
+ del_id() { arch "$1" | jq -r "$DELETE_JQ"; }
26
+
27
+ PLAN=$'<!-- FORGE:ARCHITECT -->\n## Plan\n\n<!-- FORGE:ARCHITECT:COMPLETE -->'
28
+ PARTIAL=$'<!-- FORGE:ARCHITECT -->\n## Plan (in progress)'
29
+ INVEST=$'<!-- FORGE:INVESTIGATOR -->\nThe architect phase posts FORGE:ARCHITECT and FORGE:ARCHITECT:COMPLETE later.'
30
+ CTX=$'<!-- FORGE:CONTEXT -->\nmentions <!-- FORGE:ARCHITECT -->'
31
+ MIXED=$'<!-- FORGE:ARCHITECT -->\nquoted <!-- FORGE:INVESTIGATOR -->'
32
+
33
+ eq "INVESTIGATOR mentioning FORGE:ARCHITECT is not selected" "$(arch "$(c 1 NONE Bot "$INVEST")" | jq length)" 0
34
+ eq "INVESTIGATOR mentioning FORGE:ARCHITECT is never deleted" "$(del_id "$(c 1 NONE Bot "$INVEST")")" ""
35
+ eq "CONTEXT mentioning the marker mid-body is not selected" "$(arch "$(c 2 NONE Bot "$CTX")" | jq length)" 0
36
+ eq "complete plan by bot is complete" "$(n_complete "$(c 3 NONE Bot "$PLAN")")" 1
37
+ eq "complete plan by MEMBER is complete" "$(n_complete "$(c 3 MEMBER User "$PLAN")")" 1
38
+ eq "single partial plan -> delete its id" "$(del_id "$(c 4 NONE Bot "$PARTIAL")")" 4
39
+ eq "two partial plans -> delete nothing" "$(del_id "$(c 4 NONE Bot "$PARTIAL")"$'\n'"$(c 5 NONE Bot "$PARTIAL")")" ""
40
+ eq "untrusted author partial ignored" "$(del_id "$(c 6 NONE User "$PARTIAL")")" ""
41
+ eq "untrusted author complete ignored" "$(n_complete "$(c 6 NONE User "$PLAN")")" 0
42
+ eq "partial carrying another FORGE marker is not deleted" "$(del_id "$(c 7 NONE Bot "$MIXED")")" ""
43
+ eq "investigator + partial plan -> only the plan id" "$(del_id "$(c 1 NONE Bot "$INVEST")"$'\n'"$(c 8 NONE Bot "$PARTIAL")")" 8
44
+ eq "paginated concatenated arrays" "$(printf '[%s]\n[%s]' "$(c 1 NONE Bot "$INVEST")" "$(c 9 NONE Bot "$PLAN")" | jq -s "$SELECT_JQ" | jq '[.[] | select(.complete)] | length')" 1
45
+
46
+ # Spec drift guard: the spec carries this exact predicate and no unanchored selector.
47
+ ! grep -qF 'contains("FORGE:ARCHITECT")' "$SPEC" && ok "architect.md has no unanchored contains(FORGE:ARCHITECT)" || bad "architect.md still uses unanchored contains"
48
+ grep -qF 'startswith("<!-- FORGE:ARCHITECT -->")' "$SPEC" && ok "architect.md anchors with startswith" || bad "architect.md lacks the startswith anchor"
49
+ grep -qF "$SELECT_JQ" "$SPEC" && ok "architect.md uses the tested select jq" || bad "architect.md select jq drifted from this test"
50
+ grep -qF "$DELETE_JQ" "$SPEC" && ok "architect.md uses the tested delete jq" || bad "architect.md delete jq drifted from this test"
51
+
52
+ echo "passed=$PASS failed=$FAILN"
53
+ [ "$FAILN" -eq 0 ]