forgedock 1.11.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/engine/phases-args.test.mjs +113 -0
  2. package/bin/engine/phases.mjs +433 -109
  3. package/bin/engine/runlog.mjs +21 -1
  4. package/bin/engine-cli.mjs +14 -2
  5. package/bin/engine.mjs +150 -19
  6. package/bin/forgedock.mjs +2 -2
  7. package/bin/hooks/interactive-engine.mjs +3 -0
  8. package/bin/labels.json +5 -0
  9. package/bin/orchestrate-preflight.mjs +1 -1
  10. package/bin/runner.mjs +111 -12
  11. package/bin/watch-utils.mjs +1 -0
  12. package/commands/cleanup.md +1 -1
  13. package/commands/orchestrate/phase-4-execution.md +58 -24
  14. package/commands/orchestrate/phase-6-report.md +29 -0
  15. package/commands/quality-gate.md +96 -12
  16. package/commands/recover-orphans.md +5 -5
  17. package/commands/review-pr-agents/protocols.md +1 -1
  18. package/commands/review-pr-agents.md +11 -11
  19. package/commands/review-pr.md +227 -33
  20. package/commands/work-on/build/implement.md +1 -1
  21. package/commands/work-on/build/validate.md +58 -7
  22. package/commands/work-on/build.md +11 -4
  23. package/commands/work-on/decompose.md +2 -2
  24. package/commands/work-on/investigate.md +14 -4
  25. package/commands/work-on/remediate.md +109 -19
  26. package/commands/work-on/review.md +79 -25
  27. package/commands/work-on.md +23 -15
  28. package/docs/WORK-ON-RUNTIME.md +2 -2
  29. package/docs/spec/label-state-machine.md +6 -1
  30. package/docs/spec/review-protocol.md +1 -1
  31. package/package.json +1 -1
  32. package/packages/protocol/src/phases.js +27 -1
  33. package/scripts/check-branch-ancestry.sh +82 -0
  34. package/scripts/check-branch-ancestry.test.sh +78 -0
  35. package/scripts/check-spec-markers.sh +2 -0
  36. package/scripts/forge-root.test.sh +36 -15
  37. package/scripts/review-pr-domain-map.test.sh +87 -0
  38. package/scripts/spec-no-worktree-script.test.sh +46 -0
  39. package/scripts/transition-label.sh +3 -1
@@ -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
 
@@ -443,7 +474,7 @@ Use the existing PR number and continue.
443
474
  RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
444
475
  case "$TIER" in
445
476
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} in-review ;;
446
- prose) gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:in-review" --remove-label "workflow:investigating" --remove-label "workflow:ready-to-build" --remove-label "workflow:building" --remove-label "workflow:awaiting-merge" --remove-label "workflow:merged" --remove-label "workflow:invalid" --remove-label "workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects ;;
477
+ prose) gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:in-review" --remove-label "workflow:investigating" --remove-label "workflow:ready-to-build" --remove-label "workflow:building" --remove-label "workflow:awaiting-merge" --remove-label "workflow:remediating" --remove-label "workflow:merged" --remove-label "workflow:invalid" --remove-label "workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects ;;
447
478
  esac
448
479
  ```
449
480
 
@@ -535,19 +566,30 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
535
566
 
536
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 -->
537
568
 
538
- - `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 — leave `needs-human` and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise 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, clears `needs-human`, 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 -->
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
+ ```bash
571
+ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
572
+ case "$TIER" in
573
+ adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} remediating ;;
574
+ prose) gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:remediating" --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true ;; # allowlist:check-command-side-effects
575
+ esac
576
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
577
+ gh pr edit {PR_NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
578
+ ```
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 -->
539
581
 
540
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).
541
- - **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: 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.
542
- - **Bound already used** (including the re-invocation after a remediation that did not land): waive the gate for the current head. Remove `needs-human`, post `<!-- FORGE:INPR_FIX_WAIVED: head=<current head SHA> -->` on the **PR**, then re-invoke Phase R3 once under the `STALE_REREVIEW` bound. That review files the remaining findings as issues and merges as it would have before this gate. Only if `STALE_REREVIEW` is also exhausted, add `needs-human`.
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.
584
+ - **Bound already used** (including the re-invocation after a remediation that did not land): waive the gate for the current head. Remove `needs-human` and `workflow:remediating` (restoring `workflow:in-review`), post `<!-- FORGE:INPR_FIX_WAIVED: head=<current head SHA> -->` on the **PR**, then re-invoke Phase R3 once under the `STALE_REREVIEW` bound. That review files the remaining findings as issues and merges as it would have before this gate. Only if `STALE_REREVIEW` is also exhausted, label the issue `needs-human`.
543
585
  ```bash
544
586
  INPR_WAIVE_HEAD=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq .headRefOid)
545
- gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
587
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human,workflow:remediating" --add-label "workflow:in-review" 2>/dev/null || true # allowlist:check-command-side-effects
546
588
  gh pr comment {PR_NUMBER} {GH_FLAG} --body "<!-- FORGE:INPR_FIX_WAIVED: head=${INPR_WAIVE_HEAD} -->
547
589
  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
590
  ```
549
591
 
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):
592
+ 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
593
  ```bash
552
594
  [ "${DRY_RUN:-false}" = "true" ] && { echo "[DRY_RUN] bound check only"; }
553
595
  if [ "{BOUND}" = "STALE_REREVIEW" ]; then
@@ -556,6 +598,9 @@ In-PR fix round did not land; the remaining CONFIRMED MEDIUM findings are filed
556
598
  elif [ "{BOUND}" = "INPR_REMEDIATION" ]; then
557
599
  BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
558
600
  --jq '[.[] | select((.body | contains("FORGE:INPR_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
601
+ elif [ "{BOUND}" = "BASESYNC_REMEDIATION" ]; then
602
+ BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
603
+ --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
604
  else
560
605
  BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
561
606
  --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 +614,11 @@ Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head on
569
614
  ```
570
615
  `BOUND_EXHAUSTED` → take the "already ≥ 1" branch of that case.
571
616
 
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**:
617
+ - `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).
618
+ - **Bound unused**: run the remediation-pending handoff block above (set `workflow:remediating`, no `needs-human`; a pure base sync needs no human). 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).
619
+ - **Bound already used** (the sync ran and the PR still conflicts, or the sync could not resolve a hunk): do not sync again. Remove `workflow:remediating`, label the issue `needs-human` (a genuine human decision now) 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.
620
+
621
+ - 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
622
  ```bash
574
623
  # CI gate (MANDATORY before any autonomous merge): merge only when every check on the PR is
575
624
  # green. Field test: PRs merged to staging with checks pending or red (#3165), because branch
@@ -590,11 +639,16 @@ Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head on
590
639
  FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test("<!-- FINDING:"))] | length' 2>/dev/null || echo "")
591
640
  # Trust predicate: ONE shared copy (scripts/trusted-comments.sh, same as verify-phase-trail.sh): trusted association, Bot account, or
592
641
  # FORGE_TRAIL_TRUSTED_LOGINS. A GitHub App bot always has author_association NONE, so an association-only filter drops the pipeline's own disposition.
642
+ # 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.
593
643
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
644
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
645
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
646
+ [ -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)"
647
+ done
594
648
  TRUSTED_SCRIPT=""
595
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
649
+ while IFS= read -r _c; do
596
650
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
597
- done
651
+ done <<< "$_tc"
598
652
  if [ -n "$TRUSTED_SCRIPT" ]; then DISPOSITION_COUNT=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:NOTE_DISPOSITION' 2>/dev/null || echo ""); else DISPOSITION_COUNT=""; fi
599
653
  if [ -z "$DISPO_JSON" ] || [ -z "$FINDING_COUNT" ] || [ -z "$DISPOSITION_COUNT" ]; then
600
654
  echo "REVIEW_RESULT: status: BLOCKED, blocker: note disposition unreadable (comments or scripts/trusted-comments.sh unresolvable; fail closed)"
@@ -623,7 +677,7 @@ REVIEW_RESULT:
623
677
  pr_url: {PR_URL}
624
678
  merged_to: {PR_BASE}
625
679
  next: {remediate, only when status=NEXT}
626
- remediation: {ci-gate | inpr-fix, only when status=NEXT}
680
+ remediation: {ci-gate | inpr-fix | base-sync, only when status=NEXT}
627
681
  blocker: {description if status=BLOCKED}
628
682
  ```
629
683
 
@@ -31,7 +31,7 @@ Orchestrator for the full issue lifecycle: investigate → decompose (if needed)
31
31
 
32
32
  4. **PRs NEVER target `main`.** Target `staging` (fast lane) or `milestone/{slug}` (feature lane). The router computes and validates the target (Lane Resolution) and passes it to the phases as `--base`.
33
33
 
34
- 5. **`needs-human` is for genuine decisions and external actions only.** A phase that can fix its own problem (failing checks, review findings, a moved PR head, a missing marker it can produce) does so inside its own fork; the router never adds `needs-human` for a condition a phase reported as fixable.
34
+ 5. **`needs-human` is for genuine decisions and external actions only.** A phase that can fix its own problem (failing checks, review findings, a moved PR head, a missing marker it can produce) does so inside its own fork; the router never adds `needs-human` for a condition a phase reported as fixable. A fix handed to remediation (ci-gate, in-pr-fix, base-sync) is marked with the non-terminal, non-human state `workflow:remediating`, never `needs-human` (forge#3541).
35
35
 
36
36
  ### Skill Name Resolution (`{FORGE_SKILL_PREFIX}`)
37
37
 
@@ -298,17 +298,17 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="${REMEDIATE_PR_NUMBER
298
298
 
299
299
  **Claim pre-check (forge#3413; one per-head marker shared with the orchestrator)**: the orchestrator's re-review handoff (`commands/orchestrate/phase-4-execution.md` Step 1/2) dedupes on `<!-- FORGE:REREVIEW_DISPATCHED -->`, so the router MUST write the SAME marker before `review-pr`, or the orchestrator re-dispatches after the router already re-reviewed. In order, failing closed (any failure = no dispatch, terminal fallback):
300
300
  1. **Labels first**: read the issue's state and labels (`gh issue view ${ISSUE} {GH_FLAG} --json labels,state`). If the issue is closed, already has `needs-human`, or lacks `workflow:in-review`, do NOT dispatch and do NOT apply the fallback (something else already advanced it); STOP.
301
- 2. **Existing claim**: list the PR's comments and apply the trusted-author, leading-marker predicate. Bash blocks are independent executions and a standalone router session has none of the orchestrator's shell functions, so every claim, read-back, classification and release block MUST paste verbatim, at its top, the **required helper set** from `phase-4-execution.md`: `trail_cache_dir` and `resolve_orch_login` (defined earlier in that file; transitive deps of `rereview_trusted_rows`), then the Step 1 SHARED HELPERS `rereview_trusted_rows`, `REREVIEW_CLAIMS`, `REREVIEW_DONE_FILTER`, `REREVIEW_LEASE_SECS` / `REREVIEW_MAX_SECS`, `REREVIEW_LEASE_FILTER`, `REREVIEW_FIRST_LEASE_FILTER` and `REREVIEW_RELEASED_FILTER`. Never improvise the predicate: too loose lets an untrusted commenter suppress a re-review, too strict drops the router's own bot-authored claim. If a trusted claim (lowest comment id wins) already embeds the PR's current head SHA (`headRefOid`), classify it with the single definition in `phase-4-execution.md` Step 1 (forge#3428: a `FORGE:REREVIEW_RELEASED` marker means the claimant finished; an unexpired or renewed lease, or an expired lease with liveness such as a recent trusted `FORGE:REVIEW*` comment or running checks, means another actor is live; a merged/closed PR is done): for released, live or done do NOT dispatch; STOP without the fallback. Only an expired lease with no liveness and an open PR (`STALE`, which includes an unparseable `Lease:`) goes to the terminal fallback with reason `stale re-review claim`; a failed liveness lookup fails closed to STOP (never `STALE`). Do not duplicate the predicate here (reference and paste, never re-write).
301
+ 2. **Existing claim**: list the PR's comments and apply the trusted-author, leading-marker predicate. Bash blocks are independent executions and a standalone router session has none of the orchestrator's shell functions, so every claim, read-back, classification and release block MUST paste verbatim, at its top, the **required helper set** from `phase-4-execution.md`: `trail_cache_dir` and `resolve_orch_login` (defined earlier in that file; transitive deps of `rereview_trusted_rows`), then the Step 1 SHARED HELPERS `rereview_trusted_rows`, `REREVIEW_CLAIMS`, `REREVIEW_DONE_FILTER`, `REREVIEW_LEASE_SECS` / `REREVIEW_MAX_SECS`, `REREVIEW_LEASE_FILTER`, `REREVIEW_FIRST_LEASE_FILTER` and `REREVIEW_RELEASED_FILTER`. Never improvise the predicate: too loose lets an untrusted commenter suppress a re-review, too strict drops the router's own bot-authored claim. If a trusted claim (lowest comment id wins) already embeds the PR's current head SHA (`headRefOid`), classify it with the single definition in `phase-4-execution.md` Step 1 (forge#3428, forge#3486: an unexpired or renewed lease, or an expired lease with liveness such as a recent trusted `FORGE:REVIEW*` comment or running checks, means another actor is live (`CLAIMED`); a merged/closed PR or an issue advanced past in-review is done (`NONE`)): for live or done do NOT dispatch; STOP without the fallback. A `FORGE:REREVIEW_RELEASED` marker (`Outcome: SETTLED`) is not a stand-down: step 1 guarantees the issue is still at `workflow:in-review`, so a SETTLED release reads `STALE`, as does an expired lease with no liveness and an open PR (including an unparseable `Lease:`); `STALE` goes to the terminal fallback with reason `stale re-review claim`; a failed liveness lookup fails closed to STOP (never `STALE`). Do not duplicate the predicate here (reference and paste, never re-write).
302
302
  2b. **Unresolved login (fail closed, before the claim)**: with the helpers pasted, run `resolve_orch_login`. If it prints an empty login (and `FORGE_BOT_LOGIN` is unset), the router's own claim could never be verified: do NOT post the claim and do NOT dispatch. Treat it as `UNREADABLE` (writes nothing, no terminal fallback write, never `STALE`), report the unresolved login in the operator-visible report line, and STOP; the orchestrator re-checks next cycle (mirrors `phase-4-execution.md` Step 1 SEC-8).
303
303
  3. **Claim**: unless `DRY_RUN=true` (then record "would claim and re-review" and STOP), post the marker with `Actor: router`, the head SHA, a unique `Claim:` id and a UTC `Lease:` timestamp, in the exact format defined in `phase-4-execution.md` Step 1 (`<!-- FORGE:REREVIEW_DISPATCHED -->` as the first line, then `Head: <sha>`, `Actor: router`, `Claim: router-<epoch>-<pid>`, `Lease: <UTC ISO-8601>`), with the `gh pr comment` call checked (a failed post is a failure, never ignored).
304
- 4. **Read back** the PR comments (trusted rows only, projecting the comment `id`, after a short settle delay): the lowest-comment-id trusted claim for this head (`.[0]` of the id-sorted `REREVIEW_CLAIMS`) must carry this router's `Claim:` id; re-read once after verifying and treat a late lower-id claim as `LOST`. A missing row, an API failure, or a different earliest claim (`LOST`) means do NOT dispatch, and none of them stands down blindly: re-run the shared Step 1 classification from `phase-4-execution.md` (forge#3431; the same required helper set from step 2, including `trail_cache_dir` and `resolve_orch_login`, pasted at the top of the block, no router-local lease helper or constant) for this head and map the printed `REREVIEW_STATE`: `STALE` (an expired lease with no liveness and an open PR, a lease past `REREVIEW_MAX_SECS`, or an unparseable `Lease:` on the winning claim; a crashed earlier claimant) goes to the terminal fallback with reason `stale re-review claim`; `CLAIMED` (another actor's live claim) and `NONE` (released or done) stand down without the fallback; `UNREADABLE` writes nothing (fail closed, never `STALE`); `PENDING` goes to the fallback only for the missing-row / API-failure case. For the missing-row / API-failure case only, the printed `FIRST_CLAIM_ID` (the winning `.[0]` claim) equal to this router's own `Claim:` id goes to the fallback (our own unverified claim must not stand the router down on itself), while another actor's id is classified like `LOST`. On `LOST` the winner is by definition another actor's claim, so a `STALE` winner is never excused as "owned by another actor".
305
- Only after the read-back verifies does the router invoke `review-pr`. After `review-pr` returns (any result), the router posts a checked, DRY_RUN-guarded `<!-- FORGE:REREVIEW_RELEASED -->` marker (`Head: <sha>`, `Actor: router`, `Claim: <its Claim id>`) in the format defined in `phase-4-execution.md` Step 2 (Renew and release; the release block also pastes the same required helper set), so the orchestrator reads `NONE` rather than aging the claim into `STALE`; the router may also renew the lease between phases by re-posting the claim with the same `Claim:` id and a fresh `Lease:`.
304
+ 4. **Read back** the PR comments (trusted rows only, projecting the comment `id`, after a short settle delay): the lowest-comment-id trusted claim for this head (`.[0]` of the id-sorted `REREVIEW_CLAIMS`) must carry this router's `Claim:` id; re-read once after verifying and treat a late lower-id claim as `LOST`. A missing row, an API failure, or a different earliest claim (`LOST`) means do NOT dispatch, and none of them stands down blindly: re-run the shared Step 1 classification from `phase-4-execution.md` (forge#3431; the same required helper set from step 2, including `trail_cache_dir` and `resolve_orch_login`, pasted at the top of the block, no router-local lease helper or constant) for this head and map the printed `REREVIEW_STATE`: `STALE` (an expired lease with no liveness and an open PR, a lease past `REREVIEW_MAX_SECS`, or an unparseable `Lease:` on the winning claim; a crashed earlier claimant) goes to the terminal fallback with reason `stale re-review claim`; `CLAIMED` (another actor's live claim) and `NONE` (merged, closed, or advanced past in-review) stand down without the fallback; `UNREADABLE` writes nothing (fail closed, never `STALE`); `PENDING` goes to the fallback only for the missing-row / API-failure case. For the missing-row / API-failure case only, the printed `FIRST_CLAIM_ID` (the winning `.[0]` claim) equal to this router's own `Claim:` id goes to the fallback (our own unverified claim must not stand the router down on itself), while another actor's id is classified like `LOST`. On `LOST` the winner is by definition another actor's claim, so a `STALE` winner is never excused as "owned by another actor".
305
+ Only after the read-back verifies does the router invoke `review-pr`. After `review-pr` returns (any result) and the outcome is settled (a labelled terminal state, or the terminal fallback's label write verified; never before, forge#3486), the router posts a checked, DRY_RUN-guarded `<!-- FORGE:REREVIEW_RELEASED -->` marker (`Head: <sha>`, `Actor: router`, `Claim: <its Claim id>`, `Outcome: SETTLED`) in the format defined in `phase-4-execution.md` Step 2 (Renew and release; the release block also pastes the same required helper set). A release found while the issue is still at `workflow:in-review` is not terminal and reads `STALE`, so the fallback re-applies; the router may also renew the lease between phases by re-posting the claim with the same `Claim:` id and a fresh `Lease:`.
306
306
 
307
307
  **Terminal fallback** (canonical definition; Phase 4R and `commands/orchestrate/phase-4-execution.md` item 6.4 reference it, so the review and remediate paths share one): a re-review that cannot run must never leave the issue at `workflow:in-review` with nothing to advance it. First re-read the issue's labels: if `needs-human` is already set and `workflow:in-review` is absent, the fallback has already been applied (or something else escalated it), so skip the edit and report that state (idempotent; forge#3413). Otherwise post a comment naming the PR and the reason (e.g. `review-pr could not be dispatched: <reason>`), then in ONE `gh issue edit` add `needs-human` and remove `workflow:in-review` (never one without the other), and re-read the labels to verify the write. If the label write failed, report that failure as the outcome instead of treating the fallback as done. Before commenting, re-check the PR state and liveness (forge#3428): a merged or closed PR, or another actor's claimant with recent activity per `phase-4-execution.md` Step 1 (this actor's own `Claim:` id never counts as that claimant, or an own-claim fallback would cancel itself), means skip the escalation and report that state. Never review inline, and never re-nest remediation to retry.
308
308
 
309
- **After `REMEDIATE_RESULT` returns (any other status), STOP unconditionally** — do not run any further Phase 0–7 logic in this file. `work-on/remediate.md` is self-contained: a FIXABLE remediation replaces `needs-human` with the active `workflow:in-review` state only while it is running, then ends at `workflow:merged`, `workflow:awaiting-merge`, or a newly asserted `needs-human` label. When `re_gate_outcome: AUTO-LANDED`, it drives its own close phase internally (Phase M8 invokes `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` directly) before returning. For every other outcome (`HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `BLOCKED`, `ALREADY_DONE`), the issue is already at a terminal state (`workflow:awaiting-merge` or `needs-human`, or already closed) per the Universal Phase Dispatcher — nothing further to do.
309
+ **After `REMEDIATE_RESULT` returns (any other status), STOP unconditionally** — do not run any further Phase 0–7 logic in this file. `work-on/remediate.md` is self-contained: a FIXABLE remediation replaces `needs-human` / `workflow:remediating` with the active `workflow:in-review` state only while it is running, then ends at `workflow:merged`, `workflow:awaiting-merge`, or a newly asserted `needs-human` label. When `re_gate_outcome: AUTO-LANDED`, it drives its own close phase internally (Phase M8 invokes `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` directly) before returning. For every other outcome (`HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `BLOCKED`, `ALREADY_DONE`), the issue is already at a terminal state (`workflow:awaiting-merge` or `needs-human`, or already closed) per the Universal Phase Dispatcher — nothing further to do.
310
310
 
311
- This mode is reachable both standalone (a human or script running `/work-on <pr> --remediate` directly) and via the orchestrator (`commands/orchestrate/phase-4-execution.md` item 6.4 auto-dispatches the identical `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{PR} --remediate --issue {N} ...')` invocation against a `needs-human`-gated predecessor's own PR).
311
+ This mode is reachable both standalone (a human or script running `/work-on <pr> --remediate` directly) and via the orchestrator (`commands/orchestrate/phase-4-execution.md` item 6.4 auto-dispatches the identical `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{PR} --remediate --issue {N} ...')` invocation against a `needs-human`-gated or `workflow:remediating` predecessor's own PR).
312
312
 
313
313
  **Skip this entire section if `--remediate` is absent from `$ARGUMENTS`** — proceed to the normal parse below.
314
314
 
@@ -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` (it may carry `workflow:remediating`), 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 set `workflow:remediating` (the non-human "autonomous remediation pending" state, forge#3541: `transition-label.sh {NUMBER} {GH_FLAG} remediating`, with a comment "resuming an interrupted remediation of PR #{PR}"), because remediation's Phase M0 only accepts `needs-human` or `workflow:remediating` issues and its M1 swaps the label for `workflow:in-review` on a FIXABLE run; never add `needs-human` for a resume; then Phase 4R with that marker's kind, skipping the marker post (it is already recorded). This is the silent strand: M1 cleared the pending label 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 (remediation's stranded-state rule and Phase 4R both add it on a `BLOCKED` result), so this rule no longer matches. A run lost before M1 still carries `workflow:remediating` (or a legacy `needs-human`): `workflow:remediating` re-enters Phase 4R by this same rule, a legacy `needs-human` 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
@@ -761,12 +767,14 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="{PR_NUMBER} --issue {
761
767
  | `REMEDIATE_RESULT` | Router action |
762
768
  |---|---|
763
769
  | `re_gate_outcome: AUTO-LANDED` | Merged. Remediation's Phase M8 already ran `work-on:close`; if the issue is still open, run Phase 5 with `--terminal-state merged`. Then done. |
764
- | `status: REREVIEW_REQUIRED` | Fallback only (should not occur at this depth): run the re-review from this router as in 0A.1 (labels checked and the per-head `FORGE:REREVIEW_DISPATCHED` claim posted and read back first, then `FORGE:REREVIEW_RELEASED` posted after `review-pr` returns), then Phase 5 on `REVIEW_RESULT: status: COMPLETE`. If the re-review cannot run or returns anything else, apply the terminal fallback defined in 0A.1 (labels re-read first, comment with reason, add `needs-human`, remove `workflow:in-review`), then STOP; do not poll. |
765
- | `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
- | `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. |
770
+ | `status: REREVIEW_REQUIRED` | Fallback only (should not occur at this depth): run the re-review from this router as in 0A.1 (labels checked and the per-head `FORGE:REREVIEW_DISPATCHED` claim posted and read back first, then `FORGE:REREVIEW_RELEASED` with `Outcome: SETTLED` posted only after the outcome is settled, forge#3486), then Phase 5 on `REVIEW_RESULT: status: COMPLETE`. If the re-review cannot run or returns anything else, apply the terminal fallback defined in 0A.1 (labels re-read first, comment with reason, add `needs-human`, remove `workflow:in-review`), then STOP; do not poll. |
771
+ | `status: BLOCKED`, blocker starts with `github-unavailable:` | Transient, not a decision (see Transient GitHub failures). Re-assert `workflow:remediating` with a short comment (remediation's Phase M0 only accepts `needs-human` or `workflow:remediating` issues and its M1 swaps the label for `workflow:in-review` on a FIXABLE run; a transient outage is not a human decision, so do not add `needs-human`), 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. |
772
+ | `status: BLOCKED` (any kind) | Make sure `needs-human` is on the issue and `workflow:remediating` is not (add/remove accordingly, 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. |
768
- | `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. |
774
+ | `status: ALREADY_DONE`, `remediation: base-sync` | Make sure `needs-human` is on the issue and `workflow:remediating` is not (add/remove accordingly, 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. |
775
+ | `status: ALREADY_DONE`, `remediation: ci-gate` | Make sure `needs-human` is on the issue and `workflow:remediating` is not (add/remove accordingly, 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 and `workflow:remediating` is not (add/remove accordingly, 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.
@@ -14,6 +14,7 @@ See also: [FORGE Annotation Protocol §6](forge-protocol-v1.md#6-label-state-mac
14
14
  | `workflow:ready-to-build` | Investigation complete, build not started | Investigator agent |
15
15
  | `workflow:building` | Build phase active | Builder agent |
16
16
  | `workflow:in-review` | PR created, review active | Orchestrator agent |
17
+ | `workflow:remediating` | Autonomous remediation pending: review handed a ci-gate refusal, an in-PR fix request or a pure base conflict to `work-on:remediate`, which fixes it with no human action. NOT a human gate, NOT terminal; orchestrate classifies it IN_PROGRESS (never GATED) and `work-on:remediate` clears it on every exit | Review phase (`work-on/review.md` R4); cleared by `work-on/remediate.md` |
17
18
  | `workflow:awaiting-merge` | Remediated + re-reviewed, awaiting a human merge decision | Review-pr Phase 8 (auto-merge guard) |
18
19
  | `workflow:merged` | PR merged, issue closed | Close phase agent |
19
20
  | `workflow:invalid` | Issue closed as invalid | Investigator agent |
@@ -21,6 +22,8 @@ See also: [FORGE Annotation Protocol §6](forge-protocol-v1.md#6-label-state-mac
21
22
  | `needs-human` | Pipeline blocked, human intervention required | Any agent on error |
22
23
  | `workflow:engine-error` | Pipeline blocked by the engine/tool itself breaking (CLI crash, missing SDK/API key, exhausted retries with no attempt ever reaching a real outcome) — not a genuine content-level judgment call | `terminate()` in `bin/engine.mjs` |
23
24
 
25
+ `workflow:remediating` vs `needs-human` (forge#3541): `needs-human` is reserved for genuine human decisions and external actions (an exhausted remediation bound, an unresolvable conflict, a policy gate, spend, real-environment validation). Work the pipeline fixes by itself (a pure base-sync, a red CI gate, an in-PR finding round) is marked `workflow:remediating` instead, so operators are not alerted and orchestrate does not hold DAG dependents as GATED. `work-on:remediate` accepts either label (a bare `needs-human` from an older run still works), swaps it for `workflow:in-review` on a FIXABLE run, and on every BLOCKED/UNFIXABLE exit clears `workflow:remediating` and asserts `needs-human`.
26
+
24
27
  `workflow:awaiting-merge` vs `needs-human`: both are terminal (the pipeline stops advancing the issue automatically), but they mean different things. `needs-human` means the pipeline hit a condition it cannot resolve on its own (genuinely blocked — conflicting PR, failed verdict, calibration/trust escalation, etc.) and a human must diagnose and act. `workflow:awaiting-merge` means the opposite: the PR was previously escalated to `needs-human`, has since been remediated and re-reviewed to a clean `APPROVED` verdict with no mergeability blockers, but does not yet meet the automated auto-land bar (see forge#1809 Q1) — a human only needs to click merge, not diagnose a problem. `scripts/transition-label.sh` only clears `needs-human` (best-effort) when the target state is `workflow:awaiting-merge` — every other forward transition leaves a pre-existing `needs-human` label untouched, preserving its sticky/terminal semantics.
25
28
 
26
29
  `workflow:engine-error` vs `needs-human` (taxonomy split — commit `90376f5` / #2261): before this commit, `terminate()` wrote `needs-human` for *every* exhausted-retry blocked outcome, including cases where the engine/CLI tool itself was breaking (crash, missing API key, no SDK) rather than a genuine human-judgment call. As of `90376f5`, `runPhaseWithRetry()` tags an outcome `engine-error` when every exhausted attempt failed via a thrown runner exception (never reached `detectOutcome()`) — the engine/tool is broken, not the issue content — and `terminate()` writes `workflow:engine-error` instead of `needs-human` for that case. A genuine content-level block (unmerged PR, unresolved branch, a fixed-point zero-commits case) still writes `needs-human` unchanged. **Consequence for downstream consumers**: any dashboard, query, or health metric that counts `needs-human` issues computed a different, larger population before `90376f5` than after — a subset of what used to be `needs-human` now shows up as `workflow:engine-error` instead. This is a one-time taxonomy discontinuity, not a real change in pipeline reliability; consumers tracking `needs-human` counts over time should also track `workflow:engine-error` from this commit forward to get a like-for-like comparison. `bin/labels.json` already includes `workflow:engine-error` in this repo; syncing it to consumer/satellite repos is tracked separately (see forge#2346).
@@ -33,6 +36,8 @@ See also: [FORGE Annotation Protocol §6](forge-protocol-v1.md#6-label-state-mac
33
36
  → workflow:ready-to-build [Phase 2: Investigation complete]
34
37
  → workflow:building [Phase 3: Build started]
35
38
  → workflow:in-review [Phase 4: PR created]
39
+ → workflow:remediating [Review R4: autonomous ci-gate / in-pr-fix / base-sync handoff; no human needed]
40
+ → workflow:in-review [Remediate M1: fix under way] (workflow:in-review → workflow:remediating → workflow:in-review)
36
41
  → workflow:awaiting-merge [TERMINAL] [Phase 5/review-pr: re-reviewed after needs-human, awaiting human merge]
37
42
  → workflow:merged [TERMINAL] [Phase 5: PR merged]
38
43
  → workflow:invalid [TERMINAL] [Any phase: closed as invalid]
@@ -63,7 +68,7 @@ At most one `workflow:*` label should be active on an issue at any time. When tr
63
68
  ```bash
64
69
  gh issue edit {NUMBER} -R {REPO} \
65
70
  --add-label "workflow:building" \
66
- --remove-label "workflow:investigating,workflow:ready-to-build,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed"
71
+ --remove-label "workflow:investigating,workflow:ready-to-build,workflow:in-review,workflow:remediating,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed"
67
72
  ```
68
73
 
69
74
  This is enforced by `scripts/transition-label.sh` — use `resolve_script 'transition-label'` rather than calling `gh issue edit` directly when possible.
@@ -146,7 +146,7 @@ Append this block at the very end of your comment (after the `---` footer line,
146
146
  6. **Summary**: Concise one-line description (no pipe `|` characters in summary)
147
147
  7. **Empty block**: If no findings at all, include just the START/END markers
148
148
  8. **HTML comments**: The block is invisible in rendered markdown but parseable by the review system
149
- 9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the lowercase dispatched domain.
149
+ 9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the marker domain the orchestrator passes you (the canonical value from the roster-to-marker table in `review-pr.md`, e.g. `infra` and `scraper`; never a lowercased display name such as `infrastructure` or `scraping`).
150
150
  10. **Reviewed SHA**: Include exactly one line `Reviewed-SHA: [REVIEW_SHA]` (the full 40-hex head you were pinned to) in the persisted body, outside the marker. `/review-pr` scopes its completion wait and idempotent re-entry to this line, so a comment without the current head does not count as a completed review. Do NOT put the SHA inside the `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker.
151
151
 
152
152
  ### Domain Prefixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.11.0",
3
+ "version": "1.12.1",
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
@@ -49,10 +54,19 @@ export const PHASE_IDS = ['investigate', 'decompose', 'context', 'architect', 'b
49
54
  * whose presence means this phase has committed.
50
55
  * @property {string} [invalidMarker] - (investigate only) marks the issue INVALID.
51
56
  * @property {string} [decomposedMarker] - (investigate only) marks the issue DECOMPOSED.
57
+ * investigate.md emits it as the HTML comment `<!-- DECOMPOSE:YES -->`.
58
+ * @property {string} [notDecomposedMarker] - (investigate only) the explicit not-decomposed
59
+ * verdict, emitted as `<!-- DECOMPOSE:NO -->`.
60
+ * @property {string} [sizeGateMarker] - (build only) bare annotation-opener of the diff-size gate record.
61
+ * @property {string} [sizeOverrideMarker] - (build only) bare annotation-opener of the size-gate override.
52
62
  * @property {string} [partialMarker] - sentinel for an interrupted/partial annotation.
53
63
  * @property {string} [presenceMarker] - bare annotation-opener substring, used only by
54
64
  * phases whose completion is non-critical (see `context` below) — presence alone
55
65
  * (without `:COMPLETE`) is enough to report a soft/visible skip rather than a hard fail.
66
+ * @property {string} [header] - the typed-comment header (without `<!-- ` / ` -->`) a genuine
67
+ * phase comment must START with (e.g. `FORGE:INVESTIGATOR`). The engine matches the
68
+ * sentinel only inside a trusted comment that leads with this header (forge#3542);
69
+ * additive, the bare marker strings above are unchanged.
56
70
  * @property {string} [completionLabel] - a GitHub issue *label* (not a comment marker)
57
71
  * whose presence means this phase has committed. Only `close` uses this form.
58
72
  */
@@ -61,17 +75,21 @@ export const PHASE_IDS = ['investigate', 'decompose', 'context', 'architect', 'b
61
75
  export const PHASE_MARKERS = {
62
76
  investigate: {
63
77
  completionMarker: RESERVED_TYPES.INVESTIGATOR.completionSentinel, // 'INVESTIGATION:COMPLETE'
78
+ header: 'FORGE:INVESTIGATOR',
64
79
  invalidMarker: 'INVESTIGATION:INVALID',
65
80
  decomposedMarker: 'DECOMPOSE:YES',
81
+ notDecomposedMarker: 'DECOMPOSE:NO',
66
82
  },
67
83
  // forge#2379: `decompose` actually runs work-on/decompose (sub-issue
68
84
  // fan-out) once `investigate` hands off on `DECOMPOSE:YES` — see
69
85
  // bin/engine/phases.mjs's `decompose` phase entry for the handoff mechanics.
70
86
  decompose: {
71
87
  completionMarker: RESERVED_TYPES.DECOMPOSED.completionSentinel, // 'FORGE:DECOMPOSED:COMPLETE'
88
+ header: 'FORGE:DECOMPOSED',
72
89
  },
73
90
  context: {
74
91
  completionMarker: RESERVED_TYPES.CONTEXT.completionSentinel, // 'FORGE:CONTEXT:COMPLETE'
92
+ header: 'FORGE:CONTEXT',
75
93
  partialMarker: RESERVED_TYPES.CONTEXT.partialSentinel, // 'FORGE:CONTEXT:PARTIAL'
76
94
  // Bare presence marker: context is documented as non-critical (spec §7) — a
77
95
  // missing completion marker is a visible skip, not a hard fail. Only
@@ -82,10 +100,17 @@ export const PHASE_MARKERS = {
82
100
  },
83
101
  architect: {
84
102
  completionMarker: RESERVED_TYPES.ARCHITECT.completionSentinel, // 'FORGE:ARCHITECT:COMPLETE'
103
+ header: 'FORGE:ARCHITECT',
85
104
  partialMarker: RESERVED_TYPES.ARCHITECT.partialSentinel, // 'FORGE:ARCHITECT:PARTIAL'
86
105
  },
87
106
  build: {
88
107
  completionMarker: RESERVED_TYPES.BUILDER.completionSentinel, // 'FORGE:BUILDER:COMPLETE'
108
+ header: 'FORGE:BUILDER',
109
+ // Size-gate (B5.5) records. Neither is a completion sentinel: the gate's
110
+ // NEEDS_DECOMPOSE exit posts no FORGE:BUILDER:COMPLETE, so the engine reads
111
+ // these to route an oversized build to decompose instead of retrying it.
112
+ sizeGateMarker: `FORGE:${RESERVED_TYPES.DIFF_SIZE.type}`, // 'FORGE:DIFF_SIZE'
113
+ sizeOverrideMarker: `FORGE:${RESERVED_TYPES.SIZE_OVERRIDE.type}`, // 'FORGE:SIZE_OVERRIDE'
89
114
  },
90
115
  review: {
91
116
  // No RESERVED_TYPES entry defines this sentinel (REVIEWER has no
@@ -97,6 +122,7 @@ export const PHASE_MARKERS = {
97
122
  // `remediate` phase reads it off the issue, consistent with every other phase here.
98
123
  remediate: {
99
124
  completionMarker: RESERVED_TYPES.REMEDIATION.completionSentinel, // 'FORGE:REMEDIATION:COMPLETE'
125
+ header: 'FORGE:REMEDIATION',
100
126
  },
101
127
  close: {
102
128
  // A GitHub label, not a comment marker — see `completionLabel` above.
@@ -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