forgedock 1.11.0 → 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.
@@ -104,6 +104,21 @@ fi
104
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
105
  ```
106
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
118
+ ```
119
+
120
+ Completed trails of other kinds are left in place (never deleted); only the interrupted-partial cleanup below deletes anything.
121
+
107
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`.
108
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.
109
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:
@@ -134,7 +149,7 @@ BLOCK_COMMENTS=$(gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
134
149
  ```
135
150
 
136
151
  **Classify into FIXABLE vs. UNFIXABLE**:
137
- - **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).
138
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.
139
154
 
140
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`."
@@ -157,7 +172,7 @@ Do not perform this transition for an UNFIXABLE policy escalation. Any later qua
157
172
 
158
173
  ## Phase M2: Checkout the PR's Existing Branch
159
174
 
160
- 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`.
161
176
 
162
177
  ```bash
163
178
  cd {REPO_PATH}
@@ -182,7 +197,39 @@ For each FIXABLE item from Phase M1: read the affected file(s) in `{WORKTREE_PAT
182
197
 
183
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 -->
184
199
 
185
- **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).
186
233
 
187
234
  **Quality Gate** (same loop as Phase 3G, max 3 iterations):
188
235
  ```
@@ -201,14 +248,42 @@ If still failing after 3 iterations: post a comment, re-affirm `needs-human`, EX
201
248
 
202
249
  ## Phase M4: Commit, Push, and Close Addressed Findings
203
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
+
204
279
  ```bash
205
280
  cd {WORKTREE_PATH}
206
281
  git add -u
207
- git commit -s -m "fix(remediate): {description} (#{ISSUE_NUMBER})"
208
- 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
209
284
  ```
210
285
 
211
- 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.
212
287
 
213
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:
214
289
  ```bash
@@ -289,6 +364,8 @@ If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate r
289
364
 
290
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.
291
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
+
292
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:
293
370
 
294
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.
@@ -455,6 +532,9 @@ esac
455
532
  # forge#3413: embed the pushed head so the orchestrator scopes a REREVIEW-REQUIRED trail to the CURRENT head only.
456
533
  REMEDIATED_HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || true)
457
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
+
458
538
  REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
459
539
  ## Remediation Complete for PR #{PR_NUMBER}
460
540
 
@@ -463,7 +543,7 @@ REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
463
543
  **Auto-land bar**: ${AUTO_LAND_BAR_TEXT}
464
544
  **Re-gate outcome**: ${RE_GATE_OUTCOME} ${OUTCOME_DETAIL}
465
545
  **Head**: ${REMEDIATED_HEAD_SHA:-unknown}
466
-
546
+ ${BASESYNC_LINE}
467
547
  <!-- FORGE:REMEDIATION:COMPLETE -->"
468
548
 
469
549
  gh pr comment {PR_NUMBER} {GH_FLAG} --body "$REMEDIATION_BODY"
@@ -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
 
@@ -547,7 +578,7 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
547
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
548
579
  ```
549
580
 
550
- 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):
551
582
  ```bash
552
583
  [ "${DRY_RUN:-false}" = "true" ] && { echo "[DRY_RUN] bound check only"; }
553
584
  if [ "{BOUND}" = "STALE_REREVIEW" ]; then
@@ -556,6 +587,9 @@ In-PR fix round did not land; the remaining CONFIRMED MEDIUM findings are filed
556
587
  elif [ "{BOUND}" = "INPR_REMEDIATION" ]; then
557
588
  BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
558
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}')
559
593
  else
560
594
  BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
561
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}')
@@ -569,7 +603,11 @@ Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head on
569
603
  ```
570
604
  `BOUND_EXHAUSTED` → take the "already ≥ 1" branch of that case.
571
605
 
572
- - 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**:
573
611
  ```bash
574
612
  # CI gate (MANDATORY before any autonomous merge): merge only when every check on the PR is
575
613
  # green. Field test: PRs merged to staging with checks pending or red (#3165), because branch
@@ -623,7 +661,7 @@ REVIEW_RESULT:
623
661
  pr_url: {PR_URL}
624
662
  merged_to: {PR_BASE}
625
663
  next: {remediate, only when status=NEXT}
626
- remediation: {ci-gate | inpr-fix, only when status=NEXT}
664
+ remediation: {ci-gate | inpr-fix | base-sync, only when status=NEXT}
627
665
  blocker: {description if status=BLOCKED}
628
666
  ```
629
667
 
@@ -381,7 +381,7 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments --jq '.[] | {id: .id, author: .u
381
381
 
382
382
  **Resume preflight (MANDATORY on any resume past Phase 1)** <!-- Added: forge#3061 -->: before routing to Phase 3 (build), 4 (review) or 5 (close) from existing state, run `bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" {NUMBER} -R {GH_REPO}` (add `--docs-only` for docs-only diffs, or `--code-diff` when the diff has any non-docs file so an INVESTIGATION band cannot waive requirements), with `FORGE_ROOT` resolved by the bootstrap in "Script resolution" below. If `FORGE_ROOT` is empty or the script is missing, treat it as `PHASE_TRAIL: ERROR` (fail closed: stop and add `needs-human`; never skip the preflight). On `PHASE_TRAIL: FAIL`, go BACK and run each missing phase through its `Skill(...)` (investigate, Phase 3B classification, build contract/context/architect, validate) before continuing — never continue forward over a gap, never hand-post a missing marker, and never treat a recovered uncommitted worktree as a substitute for the skipped phases. The same verifier gates PR creation (`work-on/review.md` Phase R1.5) and auto-merge (`review-pr.md` Phase 8).
383
383
 
384
- **Determine resume point**: no `FORGE:INVESTIGATOR` → Phase 1 (investigate). Investigation complete with decompose YES and no `FORGE:DECOMPOSED` → Phase 2 (decompose); with `FORGE:DECOMPOSED` → Phase 5 (`--terminal-state decomposed`). Investigation complete (decompose NO) without `FORGE:BUILDER:COMPLETE` → Phase 3 (build; a partial BUILDER comment is cleaned up by the build phase). `FORGE:BUILDER:COMPLETE` and no merged PR → Phase 4 (review), except an **unfinished remediation handoff**, which is checked first and overrides the checkpoint routing in 0B.5: the issue does NOT carry `needs-human`, and its latest `FORGE:CI_REMEDIATION: pr={PR}`/`FORGE:INPR_REMEDIATION: pr={PR}` marker (for the open PR) has no `FORGE:REMEDIATION:COMPLETE` trail comment on that PR created after it (a trail comment is one that STARTS with `<!-- FORGE:REMEDIATION -->`, contains `<!-- FORGE:REMEDIATION:COMPLETE -->` and comes from a trusted author, read with `scripts/trusted-comments.sh`; a comment that merely quotes the marker never counts, forge#3412; if the comments or the helper are unreadable, fail closed to `needs-human` instead of resuming) → first re-add `needs-human` with a comment ("resuming an interrupted remediation of PR #{PR}"), because remediation's Phase M0 only accepts `needs-human`-gated issues and its M1 clears the label again for a FIXABLE run; then Phase 4R with that marker's kind, skipping the marker post (it is already recorded). This is the silent strand: M1 cleared `needs-human` and the run was lost before M8. It cannot loop: a remediation that finishes posts `FORGE:REMEDIATION:COMPLETE` (its single-attempt guard then returns `ALREADY_DONE`), and any other exit leaves `needs-human` on the issue (Phase 4R adds it on a `BLOCKED` result), so this rule no longer matches. A run lost before M1 still has `needs-human`, so it routes to review, which finds the bound used and stops visibly at `needs-human`. Re-entering review instead would find the bound used and stop without remediation ever finishing. PR merged and issue open → Phase 5 (close). `workflow:invalid` → STOP.
384
+ **Determine resume point**: no `FORGE:INVESTIGATOR` → Phase 1 (investigate). Investigation complete with decompose YES and no `FORGE:DECOMPOSED` → Phase 2 (decompose); with `FORGE:DECOMPOSED` → Phase 5 (`--terminal-state decomposed`). Investigation complete (decompose NO) without `FORGE:BUILDER:COMPLETE` → Phase 3 (build; a partial BUILDER comment is cleaned up by the build phase). `FORGE:BUILDER:COMPLETE` and no merged PR → Phase 4 (review), except an **unfinished remediation handoff**, which is checked first and overrides the checkpoint routing in 0B.5: the issue does NOT carry `needs-human`, and its latest `FORGE:CI_REMEDIATION: pr={PR}`/`FORGE:INPR_REMEDIATION: pr={PR}`/`FORGE:BASESYNC_REMEDIATION: pr={PR}` marker (for the open PR) has no `FORGE:REMEDIATION:COMPLETE` trail comment on that PR created after it (a trail comment is one that STARTS with `<!-- FORGE:REMEDIATION -->`, contains `<!-- FORGE:REMEDIATION:COMPLETE -->` and comes from a trusted author, read with `scripts/trusted-comments.sh`; a comment that merely quotes the marker never counts, forge#3412; if the comments or the helper are unreadable, fail closed to `needs-human` instead of resuming) → first re-add `needs-human` with a comment ("resuming an interrupted remediation of PR #{PR}"), because remediation's Phase M0 only accepts `needs-human`-gated issues and its M1 clears the label again for a FIXABLE run; then Phase 4R with that marker's kind, skipping the marker post (it is already recorded). This is the silent strand: M1 cleared `needs-human` and the run was lost before M8. It cannot loop: a remediation that finishes posts `FORGE:REMEDIATION:COMPLETE` (its single-attempt guard then returns `ALREADY_DONE`), and any other exit leaves `needs-human` on the issue (Phase 4R adds it on a `BLOCKED` result), so this rule no longer matches. A run lost before M1 still has `needs-human`, so it routes to review, which finds the bound used and stops visibly at `needs-human`. Re-entering review instead would find the bound used and stop without remediation ever finishing. PR merged and issue open → Phase 5 (close). `workflow:invalid` → STOP.
385
385
 
386
386
  ### 0B.5: Read Phase Checkpoint (MANDATORY — executes before any phase-skip decision)
387
387
 
@@ -731,13 +731,13 @@ Post the Phase 4 heartbeat, run Lane Resolution including the lane validation, t
731
731
  Skill(skill="{FORGE_SKILL_PREFIX}work-on:review", args="{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE}")
732
732
  ```
733
733
 
734
- The review phase owns: ancestry and empty-branch guards, push, PR creation, `workflow:in-review`, the phase-trail preflight (and the single re-dispatch of any missing phase via `Skill`), `/review-pr --auto-merge` (CI gate and reviewed-head guard included), stale-review re-review, and the REVIEW checkpoint. It never closes the issue and never runs remediation: a red CI gate or an in-PR fix request comes back as `REVIEW_RESULT: status: NEXT` for Phase 4R.
734
+ The review phase owns: ancestry and empty-branch guards, push, PR creation, `workflow:in-review`, the phase-trail preflight (and the single re-dispatch of any missing phase via `Skill`), `/review-pr --auto-merge` (CI gate and reviewed-head guard included), stale-review re-review, and the REVIEW checkpoint. It never closes the issue and never runs remediation: a red CI gate, an in-PR fix request or a base conflict (`base-conflict`, merge-only base sync) comes back as `REVIEW_RESULT: status: NEXT` for Phase 4R.
735
735
 
736
736
  | `REVIEW_RESULT` | Router action |
737
737
  |---|---|
738
738
  | `COMPLETE` (PR merged) | Record `PR_NUMBER` → Phase 5 with `--terminal-state merged` |
739
739
  | `ALREADY_MERGED` | Same as COMPLETE |
740
- | `NEXT`, `next: remediate` | Record `PR_NUMBER` and `remediation` (`ci-gate` or `inpr-fix`) → Phase 4R (review handoff to remediation) |
740
+ | `NEXT`, `next: remediate` | Record `PR_NUMBER` and `remediation` (`ci-gate`, `inpr-fix` or `base-sync`) → Phase 4R (review handoff to remediation) |
741
741
  | `BLOCKED` | Terminal: the phase already added `needs-human` (or `workflow:awaiting-merge` for a deploy-gate hold) with the blocker. Do NOT merge, re-run phases, or add labels here. STOP. |
742
742
 
743
743
  ### Phase 4R: Remediation handoff from review
@@ -745,8 +745,14 @@ The review phase owns: ancestry and empty-branch guards, push, PR creation, `wor
745
745
  The review phase never invokes remediation itself: remediation re-reviews through `/review-pr`, which spawns domain reviewers, so it must run one level below this router (see the Depth Budget). The review phase has checked the bound; this router posts the bound marker **immediately before** invoking remediation, so a handoff interrupted before remediation starts (compaction, crash) is retried on resume instead of being counted as used. It runs at most once per PR per kind.
746
746
 
747
747
  ```bash
748
- # {REMEDIATION_KIND} is the review result's `remediation` value: ci-gate -> CI_REMEDIATION, inpr-fix -> INPR_REMEDIATION.
749
- BOUND_MARKER="CI_REMEDIATION"; [ "{REMEDIATION_KIND}" = "inpr-fix" ] && BOUND_MARKER="INPR_REMEDIATION"
748
+ # {REMEDIATION_KIND} is the review result's `remediation` value: ci-gate -> CI_REMEDIATION, inpr-fix -> INPR_REMEDIATION, base-sync -> BASESYNC_REMEDIATION.
749
+ # An unknown kind fails closed: no marker is posted and remediation is not invoked.
750
+ case "{REMEDIATION_KIND}" in
751
+ ci-gate) BOUND_MARKER="CI_REMEDIATION" ;;
752
+ inpr-fix) BOUND_MARKER="INPR_REMEDIATION" ;;
753
+ base-sync) BOUND_MARKER="BASESYNC_REMEDIATION" ;;
754
+ *) echo "unknown remediation kind: {REMEDIATION_KIND}"; exit 1 ;;
755
+ esac
750
756
  BOUND_BODY="<!-- FORGE:${BOUND_MARKER}: pr={PR_NUMBER} -->
751
757
  Review handed PR #{PR_NUMBER} to remediation ({REMEDIATION_KIND}); the router is dispatching it once."
752
758
  gh issue comment {NUMBER} {GH_FLAG} --body "$BOUND_BODY" || { echo "github-unavailable: could not post FORGE:${BOUND_MARKER}; remediation not invoked"; exit 1; } # allowlist:check-command-side-effects
@@ -765,8 +771,10 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="{PR_NUMBER} --issue {
765
771
  | `status: BLOCKED`, blocker starts with `github-unavailable:` | Transient, not a decision (see Transient GitHub failures). Re-add `needs-human` with a short comment (remediation's Phase M0 only accepts `needs-human`-gated issues and its M1 clears the label for a FIXABLE run), wait 2 min (5 min before the second retry), then re-invoke `work-on:remediate` with the same args; do NOT re-post the bound marker. At most 2 retries; if the third attempt still returns `github-unavailable:`, fall through to the `status: BLOCKED` (any kind) row below. |
766
772
  | `status: BLOCKED` (any kind) | Make sure `needs-human` is on the issue (add it with the blocker as a comment if absent: remediation can exit after its M1 cleared the label). STOP. |
767
773
  | `status: ALREADY_DONE` (single-attempt guard: an earlier remediation already completed on this PR), `remediation: inpr-fix` | Same as any other `inpr-fix` outcome below: re-invoke Phase 4 once, which waives the in-PR gate. |
774
+ | `status: ALREADY_DONE`, `remediation: base-sync` | Make sure `needs-human` is on the issue (add it, with a comment naming the PR, that a base sync already ran and the conflicting files if known, if absent). STOP. Do NOT re-invoke Phase 4: a still-conflicting PR would loop. |
768
775
  | `status: ALREADY_DONE`, `remediation: ci-gate` | Make sure `needs-human` is on the issue (add it, with a comment naming the PR and that remediation already ran, if absent). STOP. |
769
776
  | any other outcome, `remediation: inpr-fix` | Re-invoke Phase 4 (`work-on:review`) once with the same args. Review finds its `INPR_REMEDIATION` bound used, waives the in-PR gate for the current head, files the remaining findings as issues and re-reviews. |
777
+ | any other outcome, `remediation: base-sync` | Terminal: make sure `needs-human` is on the issue (add it, with a comment naming the PR and the conflicting files from remediation's `FORGE:BASESYNC_FAILED` comment, if absent). STOP. Do NOT re-invoke Phase 4: the bound is used and a still-conflicting PR would loop. |
770
778
  | any other outcome, `remediation: ci-gate` | Terminal: remediation left `needs-human` (or `workflow:awaiting-merge`) with its reason. STOP. |
771
779
 
772
780
  Row order matters: the `github-unavailable:` row is evaluated before the generic `status: BLOCKED` (any kind) row, which only handles non-transient blockers and the exhausted retry budget (see Transient GitHub failures). <!-- Added: forge#3402 -->
@@ -108,12 +108,12 @@ remediate L2, reviewers L3, same as the table.
108
108
 
109
109
  | Phase result | Router action |
110
110
  |--------------|---------------|
111
- | `REVIEW_RESULT: status: NEXT, next: remediate` | Phase 4R: post the bound marker (`CI_REMEDIATION` / `INPR_REMEDIATION`) immediately before invoking `work-on:remediate`, so an interrupted handoff is retried, not counted. `AUTO-LANDED` → done (remediation already ran close). `REREVIEW_REQUIRED` → fallback re-review from the router. `ALREADY_DONE` (single-attempt guard) → `inpr-fix`: re-invoke review (waive); `ci-gate`: ensure `needs-human`, stop. `BLOCKED` → ensure `needs-human`, stop. Resume after an interrupted remediation (no `needs-human`, no later `REMEDIATION:COMPLETE`) re-adds `needs-human` and returns to 4R, not to review. Other outcome: `inpr-fix` → re-invoke review once (it waives the in-PR gate); `ci-gate` → terminal. |
111
+ | `REVIEW_RESULT: status: NEXT, next: remediate` | Phase 4R: post the bound marker (`CI_REMEDIATION` / `INPR_REMEDIATION` / `BASESYNC_REMEDIATION`, the last for `remediation: base-sync`, a merge-only base sync) immediately before invoking `work-on:remediate`, so an interrupted handoff is retried, not counted. `AUTO-LANDED` → done (remediation already ran close). `REREVIEW_REQUIRED` → fallback re-review from the router. `ALREADY_DONE` (single-attempt guard) → `inpr-fix`: re-invoke review (waive); `ci-gate`: ensure `needs-human`, stop. `BLOCKED` → ensure `needs-human`, stop. Resume after an interrupted remediation (no `needs-human`, no later `REMEDIATION:COMPLETE`) re-adds `needs-human` and returns to 4R, not to review. Other outcome: `inpr-fix` → re-invoke review once (it waives the in-PR gate); `ci-gate` → terminal. |
112
112
  | `REMEDIATE_RESULT` from `--remediate` entry | unchanged (Phase 0A.1) |
113
113
 
114
114
  ## 6. What does not change
115
115
 
116
- - Build still sequences its own children; the headless engine (`bin/engine/phases.mjs`) is unaffected.
116
+ - Build still sequences its own children (worktree, then context / architect / implement / validate). The headless engine (`bin/engine/phases.mjs`) is **not** exempt from the argument contract: every phase sub-skill takes all inputs as arguments, so each engine phase builds them via `buildArgs` (`--repo`, `--gh-flag`, `--base`, `--worktree`, `--branch`, `--pr`, `--terminal-state` as the target spec's `argument-hint` requires). The engine's phase table is investigate, decompose, build, review, remediate, close; `context` and `architect` are no longer engine phases because they need the worktree build creates. `bin/engine/phases-args.test.mjs` fails CI when a phase's args drift from its spec's `argument-hint`.
117
117
  - `/orchestrate` still dispatches one worker `Agent` per issue (L1).
118
118
  - Phases keep their specs, `*_RESULT` contracts and GitHub markers; phase-trail verification is unchanged.
119
119
  - Solo `/work-on` gets one extra layer of headroom.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.11.0",
3
+ "version": "1.12.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"
@@ -34,6 +34,11 @@ import { RESERVED_TYPES } from './types.js';
34
34
  * "Universal Phase Dispatcher" table and `bin/engine/phases.mjs`'s `PHASES`
35
35
  * array).
36
36
  *
37
+ * `context` and `architect` are intentionally NOT engine phase ids (forge#3499):
38
+ * `work-on/build` creates the worktree they need as `--repo-path` and invokes
39
+ * them itself. Their `PHASE_MARKERS` entries below stay — the interactive
40
+ * SubagentStop hook still gates on those markers.
41
+ *
37
42
  * `decompose` and `remediate` (forge#2379) are branch phases, not part of the
38
43
  * six-phase linear happy path — `decompose` is reached only when `investigate`
39
44
  * signals `DECOMPOSE:YES`, and `remediate` only when `review` escalates to
@@ -41,7 +46,7 @@ import { RESERVED_TYPES } from './types.js';
41
46
  * position `bin/engine/phases.mjs`'s `PHASES` array declares them, because
42
47
  * `scripts/check-phase-registry-drift.mjs` requires the two lists to match
43
48
  * index-for-index — see that file's own doc comment. */
44
- export const PHASE_IDS = ['investigate', 'decompose', 'context', 'architect', 'build', 'review', 'remediate', 'close'];
49
+ export const PHASE_IDS = ['investigate', 'decompose', 'build', 'review', 'remediate', 'close'];
45
50
 
46
51
  /**
47
52
  * @typedef {Object} PhaseMarkerEntry
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-branch-ancestry.sh — Flag merge commits that bring in history from outside the PR base.
6
+ #
7
+ # Usage: check-branch-ancestry.sh <branch-or-HEAD> <base-ref>
8
+ #
9
+ # For every merge commit in <base-ref>..<branch>, each parent other than the first must be an
10
+ # ancestor of <base-ref>. Merging the base itself into the branch (a base sync) passes; merging
11
+ # a milestone or other foreign branch fails.
12
+ #
13
+ # Exit codes:
14
+ # 0 clean (no merges, or only base-sync merges)
15
+ # 1 foreign merge(s) found; each printed as "<merge-sha> <foreign-parent-sha> <subject>"
16
+ # 2 error (bad usage, unresolvable ref, git failure) — callers MUST treat as a failure, never a pass
17
+ #
18
+ # The first-parent line of <base-ref>..<branch> must also not carry foreign history: a branch cut
19
+ # from a milestone line (then, say, syncing the base) has no foreign non-first parent, so each
20
+ # first-parent commit is also checked against the milestone refs (refs/remotes/origin/milestone/*,
21
+ # refs/heads/milestone/*). A commit reachable from one of them but not from <base-ref> is foreign.
22
+ # Override the ref patterns with CHECK_BRANCH_ANCESTRY_FOREIGN_REFS (space-separated for-each-ref patterns).
23
+ #
24
+ # Known limit: foreign history on a branch with no milestone ref available locally is not detected.
25
+ # Portable to bash 3.2: no mapfile, no grep -P, no state accumulated inside piped subshells.
26
+
27
+ set -u
28
+
29
+ if [ "$#" -ne 2 ] || [ -z "${1:-}" ] || [ -z "${2:-}" ]; then
30
+ echo "usage: check-branch-ancestry.sh <branch-or-HEAD> <base-ref>" >&2
31
+ exit 2
32
+ fi
33
+ BRANCH="$1"
34
+ BASE="$2"
35
+
36
+ git rev-parse --verify --quiet "${BRANCH}^{commit}" >/dev/null 2>&1 || { echo "check-branch-ancestry: cannot resolve branch ref '${BRANCH}'" >&2; exit 2; }
37
+ git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1 || { echo "check-branch-ancestry: cannot resolve base ref '${BASE}'" >&2; exit 2; }
38
+
39
+ MERGES=$(git rev-list --merges "${BASE}..${BRANCH}" 2>/dev/null) || { echo "check-branch-ancestry: git rev-list failed" >&2; exit 2; }
40
+
41
+ FOREIGN=0
42
+ for M in $MERGES; do
43
+ LINE=$(git rev-list --parents -n 1 "$M" 2>/dev/null) || { echo "check-branch-ancestry: cannot read parents of $M" >&2; exit 2; }
44
+ PARENTS=$(printf '%s\n' "$LINE" | cut -d' ' -f3-)
45
+ SUBJECT=$(git log -n 1 --format=%s "$M" 2>/dev/null)
46
+ for P in $PARENTS; do
47
+ git merge-base --is-ancestor "$P" "$BASE" >/dev/null 2>&1
48
+ rc=$?
49
+ if [ "$rc" -eq 1 ]; then
50
+ printf '%s %s %s\n' "$M" "$P" "$SUBJECT"
51
+ FOREIGN=1
52
+ elif [ "$rc" -ne 0 ]; then
53
+ echo "check-branch-ancestry: merge-base --is-ancestor failed (rc=$rc) for $P" >&2
54
+ exit 2
55
+ fi
56
+ done
57
+ done
58
+
59
+ # First-parent line: every commit must be absent from every milestone ref (other than base/branch themselves).
60
+ BASE_SHA=$(git rev-parse "${BASE}^{commit}") || exit 2
61
+ BRANCH_SHA=$(git rev-parse "${BRANCH}^{commit}") || exit 2
62
+ FP=$(git rev-list --first-parent "${BASE}..${BRANCH}" 2>/dev/null) || { echo "check-branch-ancestry: git rev-list --first-parent failed" >&2; exit 2; }
63
+ # shellcheck disable=SC2086
64
+ REFS=$(git for-each-ref --format='%(refname)' ${CHECK_BRANCH_ANCESTRY_FOREIGN_REFS:-refs/remotes/origin/milestone/ refs/heads/milestone/} 2>/dev/null) || { echo "check-branch-ancestry: git for-each-ref failed" >&2; exit 2; }
65
+ for F in $REFS; do
66
+ F_SHA=$(git rev-parse --verify --quiet "${F}^{commit}" 2>/dev/null) || continue
67
+ [ "$F_SHA" = "$BASE_SHA" ] || [ "$F_SHA" = "$BRANCH_SHA" ] && continue
68
+ for C in $FP; do
69
+ git merge-base --is-ancestor "$C" "$F_SHA" >/dev/null 2>&1
70
+ rc=$?
71
+ if [ "$rc" -eq 0 ]; then
72
+ printf '%s %s first-parent history reachable from foreign ref %s\n' "$C" "$F_SHA" "$F"
73
+ FOREIGN=1
74
+ elif [ "$rc" -ne 1 ]; then
75
+ echo "check-branch-ancestry: merge-base --is-ancestor failed (rc=$rc) for $C" >&2
76
+ exit 2
77
+ fi
78
+ done
79
+ done
80
+
81
+ [ "$FOREIGN" -eq 0 ] || exit 1
82
+ exit 0
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/check-branch-ancestry.sh. Run: bash scripts/check-branch-ancestry.test.sh (bash 3.2 compatible)
6
+
7
+ set -u
8
+ DIR=$(cd "$(dirname "$0")" && pwd)
9
+ S="$DIR/check-branch-ancestry.sh"
10
+ T=$(mktemp -d "${TMPDIR:-/tmp}/check-branch-ancestry-test.XXXXXX")
11
+ trap 'rm -rf "$T"' EXIT
12
+ pass=0; fail=0
13
+ OUT=""
14
+
15
+ g() { git -C "$T/r" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
16
+ commit() { echo "$1" > "$T/r/$1.txt"; g add -A; g commit -m "$1"; }
17
+ # run <branch> <base>: sets OUT and RC
18
+ run() { OUT=$(cd "$T/r" && bash "$S" "$@" 2>&1); RC=$?; }
19
+ expect() { # expect <want_rc> <desc> [want_substring]
20
+ if [ "$RC" = "$1" ] && { [ -z "${3:-}" ] || case "$OUT" in *"$3"*) true ;; *) false ;; esac; }; then
21
+ pass=$((pass+1))
22
+ else
23
+ fail=$((fail+1)); echo "FAIL: $2 (rc=$RC, want $1)"; printf '%s\n' "$OUT" | sed 's/^/ /'
24
+ fi
25
+ }
26
+
27
+ mkdir "$T/r"; git -C "$T/r" init -q -b staging >/dev/null 2>&1 || { git -C "$T/r" init -q; g checkout -b staging; }
28
+ commit base
29
+ # milestone diverges from staging after staging2
30
+ commit staging2
31
+ g checkout -b milestone/m1; commit ms1
32
+ g checkout staging
33
+ # feature branch off the original base commit
34
+ g checkout -b feat HEAD~1; commit feat1
35
+
36
+ # 1. no merges
37
+ run feat staging; expect 0 "no merges passes"
38
+
39
+ # 2. base-sync merge passes (staging advances, then is merged into feat)
40
+ g merge --no-ff -m "Merge staging into feat" staging
41
+ run feat staging; expect 0 "base-sync merge passes"
42
+ run HEAD staging; expect 0 "HEAD form passes"
43
+
44
+ # 3. staging advances further and is synced again: still passes
45
+ g checkout -q staging; commit staging3; g checkout -q feat
46
+ g merge --no-ff -m "Merge staging again" staging
47
+ run feat staging; expect 0 "repeated base-sync merges pass"
48
+
49
+ # 4. foreign merge fails, foreign parent printed
50
+ MS=$(git -C "$T/r" rev-parse milestone/m1)
51
+ g merge --no-ff -m "Merge milestone into feat" milestone/m1
52
+ run feat staging; expect 1 "foreign merge fails" "$MS"
53
+ case "$OUT" in *"Merge milestone into feat"*) pass=$((pass+1)) ;; *) fail=$((fail+1)); echo "FAIL: subject not printed"; echo "$OUT" ;; esac
54
+
55
+ # 5. octopus with one foreign parent fails
56
+ g checkout -q -b feat2 staging; commit f2
57
+ g checkout -q staging; g checkout -q -b side; commit side1
58
+ g checkout -q feat2
59
+ g merge --no-ff -m "octopus" side milestone/m1
60
+ run feat2 staging; expect 1 "octopus with foreign parent fails" "$MS"
61
+
62
+ # 5b. branch cut from a milestone line, then syncing the base: foreign first-parent history fails
63
+ MS1=$(git -C "$T/r" rev-parse milestone/m1)
64
+ g checkout -q -b feat3 milestone/m1; commit f3
65
+ g merge --no-ff -m "Merge staging into feat3" staging
66
+ run feat3 staging; expect 1 "milestone-cut branch with base sync fails" "$MS1"
67
+
68
+ # 5c. milestone-cut branch with no merge at all also fails
69
+ g checkout -q -b feat4 milestone/m1; commit f4
70
+ run feat4 staging; expect 1 "milestone-cut branch without merges fails" "$MS1"
71
+
72
+ # 6. bad refs fail closed (exit 2)
73
+ run nosuchbranch staging; expect 2 "bad branch ref exits 2"
74
+ run feat nosuchbase; expect 2 "bad base ref exits 2"
75
+ run feat; expect 2 "missing arg exits 2"
76
+
77
+ echo "check-branch-ancestry tests: $pass passed, $fail failed"
78
+ [ "$fail" -eq 0 ]
@@ -84,6 +84,8 @@ MARKER_REGISTRY="
84
84
  AUTOPILOT_IMPACT
85
85
  BATCHABLE
86
86
  BATCH_ID
87
+ BASESYNC_FAILED
88
+ BASESYNC_REMEDIATION
87
89
  BATCH_MEMBERS
88
90
  BENCH_SCORECARD
89
91
  BLOCKED_ON_HUMAN_MERGE
@@ -13,7 +13,7 @@ ok() { PASS=$((PASS+1)); }
13
13
  bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
14
  expect() { [ "$2" = "$3" ] && ok || bad "$1 (got '$3' want '$2')"; }
15
15
 
16
- SITES="commands/work-on.md commands/work-on/review.md commands/work-on/investigate.md commands/work-on/decompose.md commands/work-on/build.md commands/work-on/close.md commands/review-pr.md commands/orchestrate/phase-1-resolve.md commands/orchestrate/phase-4-execution.md"
16
+ SITES="commands/work-on.md commands/work-on/build/validate.md commands/quality-gate.md commands/work-on/review.md commands/work-on/investigate.md commands/work-on/decompose.md commands/work-on/build.md commands/work-on/close.md commands/review-pr.md commands/orchestrate/phase-1-resolve.md commands/orchestrate/phase-4-execution.md"
17
17
 
18
18
  # extract the bootstrap block (comment line .. closing top-level fi), indentation stripped
19
19
  # Block ends at the first line (after the marker) that starts with `fi` at the marker's own indentation.