forgedock 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/outcome-codes.mjs +13 -0
  3. package/bin/engine/phases.mjs +54 -4
  4. package/bin/engine/runlog.mjs +10 -2
  5. package/bin/engine/telemetry-seam.mjs +3 -0
  6. package/bin/engine/telemetry.test.mjs +3 -2
  7. package/bin/engine-cli.mjs +32 -2
  8. package/bin/engine.mjs +187 -10
  9. package/bin/runner.mjs +89 -13
  10. package/bin/stats/aggregate.mjs +7 -3
  11. package/bin/stats/cache.mjs +7 -3
  12. package/bin/stats/github-source.mjs +136 -28
  13. package/bin/stats/index.mjs +46 -9
  14. package/bin/stats/render.mjs +1 -1
  15. package/bin/stats/stats.test.mjs +201 -40
  16. package/commands/orchestrate/phase-4-execution.md +1 -1
  17. package/commands/review-pr-agents/protocols.md +19 -0
  18. package/commands/review-pr-staging.md +4 -4
  19. package/commands/review-pr.md +31 -20
  20. package/commands/work-on/build/architect.md +54 -1
  21. package/commands/work-on/build/implement.md +28 -1
  22. package/commands/work-on/build/validate.md +321 -29
  23. package/commands/work-on/build.md +184 -17
  24. package/commands/work-on/close.md +9 -4
  25. package/commands/work-on/investigate.md +7 -0
  26. package/commands/work-on/remediate.md +43 -17
  27. package/commands/work-on/review.md +43 -22
  28. package/commands/work-on.md +4 -4
  29. package/docs/CONFIG.md +93 -2
  30. package/docs/OUTCOME-CODES.md +5 -1
  31. package/docs/RELEASE-NOTES.md +15 -0
  32. package/docs/STATS.md +11 -4
  33. package/docs/TELEMETRY.md +5 -2
  34. package/docs/WORK-ON-RUNTIME.md +29 -3
  35. package/package.json +2 -2
  36. package/packages/protocol/src/types.js +1 -1
  37. package/scripts/acceptance-red-green.sh +90 -0
  38. package/scripts/acceptance-red-green.test.sh +74 -0
  39. package/scripts/architect-resume-match.test.sh +12 -3
  40. package/scripts/build-knowledge-index.mjs +8 -5
  41. package/scripts/builder-resume-match.test.sh +12 -3
  42. package/scripts/calibration.mjs +5 -2
  43. package/scripts/cap-findings.test.sh +2 -0
  44. package/scripts/check-contract-scope.test.sh +2 -0
  45. package/scripts/check-edge-cases.sh +143 -0
  46. package/scripts/check-edge-cases.test.sh +175 -0
  47. package/scripts/check-spec-markers.sh +4 -0
  48. package/scripts/check-test-tmp-cleanup.sh +56 -0
  49. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  50. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  51. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  52. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  53. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  54. package/scripts/ci-local/adapters/common.mjs +115 -0
  55. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  56. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  57. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  58. package/scripts/ci-local/adapters/portability.mjs +104 -0
  59. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  60. package/scripts/ci-local/cli.mjs +50 -0
  61. package/scripts/ci-local/lib.mjs +379 -0
  62. package/scripts/ci-local/plan.mjs +186 -0
  63. package/scripts/ci-local/run.mjs +451 -0
  64. package/scripts/ci-local/scoping.mjs +531 -0
  65. package/scripts/ci-local.sh +98 -0
  66. package/scripts/ci-local.test.sh +1108 -0
  67. package/scripts/classify-finding.sh +33 -7
  68. package/scripts/classify-finding.test.sh +23 -0
  69. package/scripts/classify-test-run.sh +69 -0
  70. package/scripts/classify-test-run.test.sh +90 -0
  71. package/scripts/close-parent-c3.test.sh +2 -0
  72. package/scripts/consumer-sweep.sh +569 -0
  73. package/scripts/consumer-sweep.test.sh +357 -0
  74. package/scripts/detect-append-registries.sh +106 -0
  75. package/scripts/detect-append-registries.test.sh +64 -0
  76. package/scripts/file-deferred-followups.sh +91 -30
  77. package/scripts/file-deferred-followups.test.sh +101 -8
  78. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  79. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  80. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  81. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  82. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  83. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  84. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  85. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  86. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  87. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  88. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  89. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  90. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  91. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  92. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  93. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  94. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  95. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  96. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  97. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  98. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  99. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  100. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  101. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  102. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  103. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  104. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  105. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  106. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  107. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  108. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  109. package/scripts/forge-root.test.sh +1 -1
  110. package/scripts/lib/telemetry.sh +3 -0
  111. package/scripts/remediate-approved-head.test.sh +2 -0
  112. package/scripts/remediate-basesync.test.sh +1 -1
  113. package/scripts/review-acceptance-gate.sh +2 -2
  114. package/scripts/review-acceptance-gate.test.sh +20 -0
  115. package/scripts/review-delta.sh +8 -1
  116. package/scripts/review-delta.test.sh +19 -0
  117. package/scripts/review-finalize.sh +294 -53
  118. package/scripts/review-finalize.test.sh +347 -9
  119. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  120. package/scripts/review-prepare.sh +5 -3
  121. package/scripts/review-prepare.test.sh +18 -2
  122. package/scripts/review-roster.sh +18 -5
  123. package/scripts/review-roster.test.sh +57 -1
  124. package/scripts/run-shell-tests.sh +5 -0
  125. package/scripts/selfreview-plan.sh +86 -0
  126. package/scripts/selfreview-plan.test.sh +90 -0
  127. package/scripts/spawn-depth-check.sh +5 -4
  128. package/scripts/spawn-depth-check.test.sh +3 -0
  129. package/scripts/telemetry.test.sh +10 -0
  130. package/scripts/transition-label.sh +26 -0
  131. package/scripts/trusted-comments.test.sh +2 -1
  132. package/scripts/verify-phase-trail.test.sh +14 -0
  133. package/scripts/wait-ci-green.sh +61 -9
  134. package/scripts/wait-ci-green.test.sh +41 -3
  135. package/scripts/with-run-tmpdir.sh +20 -0
@@ -31,7 +31,7 @@ background: false
31
31
 
32
32
  **Scope note**: This mode owns exactly one gap — re-driving a `needs-human` or `workflow:remediating` PR's own remediation, plus resuming an interrupted M7 auto-land from `workflow:awaiting-merge` when no `FORGE:REMEDIATION:COMPLETE` trail exists (forge#3647; M1-M6 are skipped, only M7/M8 run), finishing a merged PR whose M7 run was cut off before M8 (forge#3685), and re-entering a held resume (the `workflow:remediating` state is the non-human "autonomous remediation pending" state review sets for ci-gate, in-pr-fix and base-sync handoffs, forge#3541). It does NOT implement the `needs-human` sub-label taxonomy (#1815) and does NOT edit `review-pr.md`'s Phase 8 guard (forge#1810), whose safe-default (`workflow:awaiting-merge` on a clean re-review of a previously-escalated PR) is reused as-is; this file only adds a bar-check after it fires.
33
33
 
34
- **Engine coverage** (forge#2379, #2889): this subcommand's `command` name (`work-on/remediate`) and completion marker (`FORGE:REMEDIATION:COMPLETE`, including the `**Re-gate outcome**` field Phase M8 posts below) are registered in the headless engine's phase table — `RESERVED_TYPES.REMEDIATION` in `packages/protocol/src/types.js`, `remediate` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and a matching `remediate` entry in `bin/engine/phases.mjs`'s `PHASES` array. The non-terminal held-resume record `FORGE:REMEDIATION_HELD` (forge#3685) is NOT a completion marker; the engine's `detectOutcome` reads it as `terminalReason: "awaiting-merge"`. A blocked review is committed with `terminalReason: "needs-human"`, then the engine continues directly into remediation; the divergence guard permits this specific handoff while keeping all other `needs-human` states paused.
34
+ **Engine coverage** (forge#2379, #2889): this subcommand's `command` name (`work-on/remediate`) and completion marker (`FORGE:REMEDIATION:COMPLETE`, including the `**Re-gate outcome**` field Phase M8 posts below) are registered in the headless engine's phase table — `RESERVED_TYPES.REMEDIATION` in `packages/protocol/src/types.js`, `remediate` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and a matching `remediate` entry in `bin/engine/phases.mjs`'s `PHASES` array. The non-terminal held-resume record `FORGE:REMEDIATION_HELD` (forge#3685) is NOT a completion marker; the engine's `detectOutcome` reads it as `terminalReason: "awaiting-merge"`, except a `Hold reason` of `CI pending`, which it reads as a wait (`wait: "ci-pending"`): the engine polls CI with backoff and re-dispatches this resume (forge#4018). A blocked review is committed with `terminalReason: "needs-human"`, then the engine continues directly into remediation; the divergence guard permits this specific handoff while keeping all other `needs-human` states paused. Any `**Re-gate outcome**` outside the Output list fails the attempt; `INPR-FIX-REQUESTED` (with `**INPR round**: N`) re-arms one further in-PR fix remediation instead of parking (forge#4021).
35
35
 
36
36
  ---
37
37
 
@@ -85,7 +85,10 @@ ISSUE_LABELS=$(echo "$ISSUE_STATE" | jq -r '[.labels[].name] | join(",")')
85
85
  - **Stranded-state rule**: `workflow:remediating` must never outlive this run. Every exit that returns `BLOCKED` or `UNFIXABLE` (including exits before Phase M1; except a `RESUME_AT_M7` run, which leaves the issue at `workflow:awaiting-merge` untouched, and except a `deferred-live-run:` exit, which leaves every label untouched: a live run owns the issue) removes `workflow:remediating` and adds `needs-human`, because at that point a human really is needed:
86
86
 
87
87
  ```bash
88
- gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" --remove-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
88
+ # forge#3567: ensure the label, add first, then remove (a missing label cancels a whole edit)
89
+ gh label create "needs-human" --color "E4E669" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
90
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
91
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --remove-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
89
92
  ```
90
93
 
91
94
  **Idempotency / resume check** — the paper trail lives on **both** the PR (primary — checked by the orchestrator's item 6.4 dispatch guard) and the linked issue (mirror — keeps `/work-on`'s standard FORGE-annotation trajectory and resume logic consistent with every other phase):
@@ -132,11 +135,14 @@ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ] && [ -n "$ISSUE_COMMENTS" ]
132
135
  [.[][] | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | select((.body // "") | type == "string")] as $c
133
136
  | [$c[] | select(.body | test("\\A<!-- FORGE:(CI|INPR|BASESYNC)_REMEDIATION: pr=" + $pr + " -->")) | {t: .created_at, k: (.body | capture("FORGE:(?<k>CI|INPR|BASESYNC)_REMEDIATION").k + "_REMEDIATION")}] as $m
134
137
  | [$c[] | select(.body | test("\\A<!-- FORGE:REMEDIATION -->[\\s\\S]*<!-- FORGE:REMEDIATION:COMPLETE -->")) | .created_at] as $trails
135
- | [$trails[] as $t | ([$m[] | select(.t <= $t)] | sort_by(.t) | last | .k // "") | select(. == $kind)] | length' 2>/dev/null || echo "")
138
+ | ([$c[] | select(.body | test("\\A<!-- FORGE:INPR_FIX: round=[0-9]+ head=")) | .created_at] | max // "") as $fix
139
+ | [$trails[] as $t | select($kind != "INPR_REMEDIATION" or $fix == "" or $t > $fix) | ([$m[] | select(.t <= $t)] | sort_by(.t) | last | .k // "") | select(. == $kind)] | length' 2>/dev/null || echo "")
136
140
  if [ -n "$KIND_DONE_N" ] && [ "$KIND_DONE_N" -eq 0 ]; then REMEDIATION_COMPLETE_N=0; fi
137
141
  fi
138
142
  ```
139
143
 
144
+ **Re-armed INPR round** <!-- Added: forge#4037 -->: for `INPR_REMEDIATION` a completed trail counts only when it is newer than the newest trusted `FORGE:INPR_FIX: round=N head=` work order (the `$fix` bound above), so the re-gate's second in-PR round (a newer `INPR_FIX` than the newest trail) dispatches instead of returning `ALREADY_DONE`; with no work order the per-kind rule above is unchanged. The router bounds re-arms to one.
145
+
140
146
  Completed trails of other kinds are left in place (never deleted); only the interrupted-partial cleanup below deletes anything.
141
147
 
142
148
  - 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`.
@@ -198,7 +204,7 @@ BLOCK_COMMENTS=$(gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
198
204
  ```
199
205
 
200
206
  **Classify into FIXABLE vs. UNFIXABLE**:
201
- - **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> max_severity=<MEDIUM|HIGH|CRITICAL> findings=<ids> -->` comment on the PR listing CONFIRMED MEDIUM/HIGH/CRITICAL 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).
207
+ - **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 `degraded-gate-unreviewed` refusal (not a code fix: re-run the review so its `### Degraded Paths Reviewed` section names each degraded check), 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> max_severity=<MEDIUM|HIGH|CRITICAL> findings=<ids> -->` comment on the PR listing CONFIRMED MEDIUM/HIGH/CRITICAL 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).
202
208
  - **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.
203
209
 
204
210
  **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` (add it and remove `workflow:remediating` if the issue entered as `workflow:remediating`), and return `REMEDIATE_RESULT: status: UNFIXABLE`. This satisfies AC5 — "genuinely-blocked PRs still terminate at `needs-human`."
@@ -209,9 +215,11 @@ BLOCK_COMMENTS=$(gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
209
215
  if [ "${DRY_RUN:-false}" = "true" ]; then
210
216
  echo "DRY_RUN: would replace needs-human / workflow:remediating with workflow:in-review on issue #{ISSUE_NUMBER}"
211
217
  else
212
- gh issue edit {ISSUE_NUMBER} {GH_FLAG} \
213
- --add-label "workflow:in-review" \
214
- --remove-label "needs-human,workflow:remediating" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
218
+ # forge#3567: ensure label, add first, single-label removes
219
+ gh label create "workflow:in-review" --color "5319E7" --force {GH_FLAG} 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
220
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:in-review" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
221
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
222
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --remove-label "workflow:remediating" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
215
223
  fi
216
224
  ```
217
225
 
@@ -500,8 +508,12 @@ else:
500
508
 
501
509
  Wait for the completed child result and retain its `REVIEW_RESULT` in remediation state before continuing to Phase M7. A running/progress response is not a completion result. If the child errors or does not return a parseable `REVIEW_RESULT`, stop with `REMEDIATE_RESULT: status: BLOCKED`; do not report remediation in progress as a terminal parent result. The `Skill(...)` invocation above remains the non-OpenCode path.
502
510
 
511
+ If the re-review approved but requested a further in-PR fix round (forge#4021): the retained `REVIEW_RESULT` is not `BLOCKED` and a trusted (Bot or OWNER/MEMBER/COLLABORATOR) `<!-- FORGE:INPR_FIX: round=N head=<sha> ... -->` comment, posted on the PR after this Phase M6 started, names the live head. Not a hold or escalation: set `RE_GATE_OUTCOME="INPR-FIX-REQUESTED"` and `INPR_ROUND=N`, do NOT add `needs-human`, skip Phase M7, go to Phase M8 and return `REMEDIATE_RESULT: status: COMPLETE`. The caller dispatches one further in-PR fix remediation; a second request is bounded by the `INPR_REMEDIATION` marker and escalates. <!-- Added: forge#4021 -->
512
+
503
513
  If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate refused to merge on missing phase markers, not on a code finding. This phase does NOT re-dispatch the missing phases: it is a dispatching phase running in a fork (`docs/WORK-ON-RUNTIME.md` R1), and `work-on:review` Phase R4 is the single re-dispatch owner. Keep `needs-human`, post `<!-- FORGE:PHASE_TRAIL_FAILED -->` listing the missing markers from the `MISSING:` lines, and exit `REMEDIATE_RESULT: status: BLOCKED` with blocker "phase trail incomplete" so the router can hand the `MISSING:` lines back to the review owner. Never treat it as `HELD-AWAITING-MERGE` or `AUTO-LANDED`. A still-failing trail can also be cleared by a human `<!-- FORGE:PHASE_TRAIL_OVERRIDE -->` comment (forge#3152, see `scripts/verify-phase-trail.sh -h`); the verifier decides, this phase never posts one.
504
514
 
515
+ If the retained `REVIEW_RESULT` is `BLOCKED` with blocker `degraded-gate-unreviewed` (forge#3628): the build gate was DEGRADED and the review's `### Degraded Paths Reviewed` section does not name every degraded check. It is a review gap, not a code defect, so do not edit code for it. Keep `needs-human` (Phase 8 set it), do not merge, and exit `REMEDIATE_RESULT: status: BLOCKED` with the same blocker; `work-on:review` R4 owns the single re-review that adds the section.
516
+
505
517
  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.
506
518
 
507
519
  If the retained `REVIEW_RESULT` is `BLOCKED` with blocker "base-conflict" (forge#3496): either the base moved again during the re-review, or the sync did not clear the conflict. Tell the two apart by base head, not by attempt count:
@@ -559,6 +571,7 @@ if [ "$(printf '%s' "$PR_LIVE" | jq -r '.state // ""' 2>/dev/null)" = "MERGED" ]
559
571
  case "$TIER" in
560
572
  universal) bash "$TRANSITION_SCRIPT" {ISSUE_NUMBER} {GH_FLAG} merged ;;
561
573
  prose|*)
574
+ gh label create "workflow:remediating" --color "C5DEF5" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
562
575
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:merged" --remove-label "workflow:awaiting-merge,needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
563
576
  ;;
564
577
  esac
@@ -661,6 +674,8 @@ The strict `APPROVED_COUNT >= 2` verified-human requirement does NOT apply here:
661
674
 
662
675
  A `RESUME_AT_M7=true` run on a deploy-gate base NEVER meets this bar (`BAR_MET=false` is enforced in the `if` below): no M3 gate ran in the resume invocation, so it holds for a human. <!-- Added: forge#3684 -->
663
676
 
677
+ **CI pending is a hold (forge#4018)**: `wait-ci-green.sh` rc 3 (pending) or rc 4 (rate-limited) at the CI gate below sets `RE_GATE_OUTCOME=HELD-AWAITING-MERGE` with `HOLD_REASON="CI pending..."` and posts no refusal and no `needs-human`; only rc 1/2 and the head-binding refusal re-escalate.
678
+
664
679
  **Evaluate the base-scoped bar**:
665
680
  ```bash
666
681
  # Fresh shell: re-derive the live base (as above); :-true fail-closed default.
@@ -689,6 +704,8 @@ if [ -n "$CI_GATE_SCRIPT" ]; then CI_GATE_OUT=$(bash "$CI_GATE_SCRIPT" {PR_NUMBE
689
704
  else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
690
705
  echo "$CI_GATE_OUT"
691
706
  GATED_HEAD=$(printf '%s\n' "$CI_GATE_OUT" | sed -n 's/^CI_GATE_HEAD: //p' | head -1)
707
+ # rc 3 (TIMEOUT: checks still pending) and rc 4 (RATE_LIMITED: GitHub throttled or briefly down) are waits, not failures: captured BEFORE the local head check below reuses rc 4.
708
+ CI_WAIT=false; case "$CI_GATE_RC" in 3|4) CI_WAIT=true ;; esac
692
709
  # forge#3726 / forge#3686: on EVERY path the merge must land the exact SHA the approval was bound to. Reload the persisted head (never re-read the PR here); unset/invalid fails closed.
693
710
  _SD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"
694
711
  APPROVED_HEAD=$(head -n 1 "$_SD/remediate_approved_head_{PR_NUMBER}" 2>/dev/null || true)
@@ -698,8 +715,10 @@ if [ "$CI_GATE_RC" -eq 0 ] && { [ -z "${APPROVED_HEAD:-}" ] || [ "$GATED_HEAD" !
698
715
  CI_GATE_OUT="${CI_GATE_OUT}
699
716
  CI_GATE: head ${GATED_HEAD:-unknown} is not the approved head ${APPROVED_HEAD:-none} (fail closed)"; CI_GATE_RC=4
700
717
  fi
701
- # rc 3 = CI still running: re-run this block (up to 3 more times) before treating it as a failure.
702
- if [ "$CI_GATE_RC" -ne 0 ]; then
718
+ if [ "$CI_WAIT" = "true" ]; then
719
+ # CI pending / rate-limited: hold, never needs-human, no refusal comment. The engine polls CI (no model) and re-dispatches this resume.
720
+ RE_GATE_OUTCOME="HELD-AWAITING-MERGE"; HOLD_REASON="CI pending (gate rc=${CI_GATE_RC}); nothing to fix, the re-gate resumes once CI settles"
721
+ elif [ "$CI_GATE_RC" -ne 0 ]; then
703
722
  # Not green: do not land. Re-escalate with the gate output (counts as RE-ESCALATED below).
704
723
  CI_MSG="⛔ Remediation auto-land refused for PR #{PR_NUMBER}: CI is not green (rc=${CI_GATE_RC}).
705
724
  \`\`\`
@@ -717,6 +736,7 @@ if [ "$MERGE_STATE" = "MERGED" ]; then
717
736
  case "$TIER" in
718
737
  universal) bash "$TRANSITION_SCRIPT" {ISSUE_NUMBER} {GH_FLAG} merged ;;
719
738
  prose|*)
739
+ gh label create "workflow:remediating" --color "C5DEF5" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
720
740
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:merged" --remove-label "workflow:awaiting-merge,needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
721
741
  ;;
722
742
  esac
@@ -734,7 +754,7 @@ fi
734
754
 
735
755
  ## Phase M8: Finalize FORGE:REMEDIATION Paper Trail
736
756
 
737
- Post the completion body to **both** `{PR_NUMBER}` and `{ISSUE_NUMBER}` — this is the single idempotency marker checked by Phase M0 (this file, on future resume) and by the orchestrator's item 6.4 dispatch guard. **Exception (forge#3685)**: a `RESUME_AT_M7=true` run whose outcome is `HELD-AWAITING-MERGE` posts a `<!-- FORGE:REMEDIATION_HELD -->` record (carrying `**Re-gate outcome**` and the hold reason) and NO `FORGE:REMEDIATION:COMPLETE`, so a transient hold (CI pending, stale approval) does not strand the PR behind the single-attempt guard. AUTO-LANDED, RE-ESCALATED, UNFIXABLE, REREVIEW-REQUIRED and non-resume HELD keep COMPLETE:
757
+ Post the completion body to **both** `{PR_NUMBER}` and `{ISSUE_NUMBER}` — this is the single idempotency marker checked by Phase M0 (this file, on future resume) and by the orchestrator's item 6.4 dispatch guard. **Exception (forge#3685)**: a `RESUME_AT_M7=true` run, or a first run held on `CI pending`, whose outcome is `HELD-AWAITING-MERGE` posts a `<!-- FORGE:REMEDIATION_HELD -->` record (carrying `**Re-gate outcome**` and the hold reason) and NO `FORGE:REMEDIATION:COMPLETE`, so a transient hold (CI pending, stale approval) does not strand the PR behind the single-attempt guard. AUTO-LANDED, RE-ESCALATED, UNFIXABLE, REREVIEW-REQUIRED, INPR-FIX-REQUESTED and non-resume HELD (other than CI pending) keep COMPLETE:
738
758
 
739
759
  ```bash
740
760
  # Fresh shell: re-derive the live base (as above); :-true fail-closed default.
@@ -757,9 +777,14 @@ case "$RE_GATE_OUTCOME" in
757
777
  RE-ESCALATED) AUTO_LAND_BAR_TEXT="N/A — re-escalated before the bar was evaluated"; OUTCOME_DETAIL="at needs-human" ;;
758
778
  UNFIXABLE) AUTO_LAND_BAR_TEXT="N/A — unfixable (see Phase M1 classification)"; OUTCOME_DETAIL="at needs-human" ;;
759
779
  REREVIEW-REQUIRED) AUTO_LAND_BAR_TEXT="N/A — re-review not run (no sub-agent dispatch tool in this session)"; OUTCOME_DETAIL="at workflow:in-review, re-review required from a session with dispatch" ;;
760
- *) AUTO_LAND_BAR_TEXT="N/A"; OUTCOME_DETAIL="" ;;
780
+ INPR-FIX-REQUESTED) AUTO_LAND_BAR_TEXT="N/A — in-pr fix requested by the re-review (forge#4021)"; OUTCOME_DETAIL="at workflow:in-review, in-pr fix round ${INPR_ROUND:-1}" ;;
781
+ *) echo "remediate: unknown RE_GATE_OUTCOME '${RE_GATE_OUTCOME}' (a known state must have its own arm above)" >&2; AUTO_LAND_BAR_TEXT="N/A"; OUTCOME_DETAIL="" ;;
761
782
  esac
762
783
 
784
+ # forge#4021: machine-readable in-PR round for the engine (empty for every other outcome).
785
+ INPR_ROUND_LINE=""; [ "$RE_GATE_OUTCOME" = "INPR-FIX-REQUESTED" ] && INPR_ROUND_LINE="**INPR round**: ${INPR_ROUND:-1}
786
+ "
787
+
763
788
  # forge#3413: embed the pushed head so the orchestrator scopes a REREVIEW-REQUIRED trail to the CURRENT head only.
764
789
  REMEDIATED_HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || true)
765
790
 
@@ -794,8 +819,9 @@ if [ "$BASESYNC_KIND" = "BASESYNC_REMEDIATION" ]; then
794
819
  fi
795
820
  fi
796
821
 
797
- if [ "${RESUME_AT_M7:-false}" = "true" ] && [ "$RE_GATE_OUTCOME" = "HELD-AWAITING-MERGE" ]; then
798
- # forge#3685: a resume that ends HELD is NOT a completed attempt. Post a distinct, non-terminal record: header is
822
+ _CI_HOLD=false; case "${HOLD_REASON:-}" in "CI pending"*) _CI_HOLD=true ;; esac
823
+ if { [ "${RESUME_AT_M7:-false}" = "true" ] || [ "$_CI_HOLD" = "true" ]; } && [ "$RE_GATE_OUTCOME" = "HELD-AWAITING-MERGE" ]; then
824
+ # forge#3685: a resume (or a first-run CI-pending hold) that ends HELD is NOT a completed attempt. Post a distinct, non-terminal record: header is
799
825
  # FORGE:REMEDIATION_HELD (never "<!-- FORGE:REMEDIATION -->", so the M0 partial-trail cleanup and the COMPLETE/TRAIL
800
826
  # matchers never see it) and NO FORGE:REMEDIATION:COMPLETE, so the next invocation re-enters M7 instead of ALREADY_DONE.
801
827
  REMEDIATION_BODY="<!-- FORGE:REMEDIATION_HELD -->
@@ -803,7 +829,7 @@ if [ "${RESUME_AT_M7:-false}" = "true" ] && [ "$RE_GATE_OUTCOME" = "HELD-AWAITIN
803
829
 
804
830
  **Re-gate outcome**: ${RE_GATE_OUTCOME} ${OUTCOME_DETAIL}
805
831
  **Auto-land bar**: ${AUTO_LAND_BAR_TEXT}
806
- **Hold reason**: ${HOLD_REASON:-CI pending, approval not bound to the live head, or deploy-gate base (see Phase M7 output above)}
832
+ **Hold reason**: ${HOLD_REASON:-approval not bound to the live head, deploy-gate base, or a CI wait (set HOLD_REASON in M7; see its output above)}
807
833
  **Head**: ${REMEDIATED_HEAD_SHA:-unknown}"
808
834
  else
809
835
  REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
@@ -813,7 +839,7 @@ REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
813
839
  **Re-review verdict**: ${RE_REVIEW_VERDICT:-unknown}
814
840
  **Auto-land bar**: ${AUTO_LAND_BAR_TEXT}
815
841
  **Re-gate outcome**: ${RE_GATE_OUTCOME} ${OUTCOME_DETAIL}
816
- **Head**: ${REMEDIATED_HEAD_SHA:-unknown}
842
+ ${INPR_ROUND_LINE}**Head**: ${REMEDIATED_HEAD_SHA:-unknown}
817
843
  ${BASESYNC_LINE}
818
844
  <!-- FORGE:REMEDIATION:COMPLETE -->"
819
845
  fi
@@ -847,9 +873,9 @@ REMEDIATE_RESULT:
847
873
  status: COMPLETE | ALREADY_DONE | UNFIXABLE | BLOCKED | REREVIEW_REQUIRED
848
874
  pr_number: {PR_NUMBER}
849
875
  issue_number: {ISSUE_NUMBER}
850
- re_gate_outcome: AUTO-LANDED | HELD-AWAITING-MERGE | RE-ESCALATED | UNFIXABLE | REREVIEW-REQUIRED | N/A
876
+ re_gate_outcome: AUTO-LANDED | HELD-AWAITING-MERGE | RE-ESCALATED | UNFIXABLE | REREVIEW-REQUIRED | INPR-FIX-REQUESTED | N/A
851
877
  findings_addressed: [{finding_number}, ...]
852
878
  blocker: {description if status=BLOCKED; `deferred-live-run: ...` when M0 deferred to a live run}
853
879
  ```
854
880
 
855
- **Caller behavior**: this Skill already drives its own close phase when `re_gate_outcome: AUTO-LANDED` (see Phase M8) — the caller does not need to invoke close itself (under the headless engine, the engine verifies the issue is CLOSED + `workflow:merged` and runs close if it is missing). For `re_gate_outcome: REREVIEW-REQUIRED` (`status: REREVIEW_REQUIRED`, forge#3240) the fix is pushed but no review ran because this session has no sub-agent dispatch tool: the caller MUST run `review-pr {PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE}` from a session that has dispatch (never inline) and then drive close itself on a merge. For every other `re_gate_outcome`, this result is terminal for the current invocation (a held resume, which wrote `FORGE:REMEDIATION_HELD` and not COMPLETE, can be re-invoked by an operator later and re-enters Phase M7): the issue is left at `needs-human` or `workflow:awaiting-merge`, both already recognized as terminal states in the Universal Phase Dispatcher (see `work-on.md`).
881
+ **Caller behavior**: this Skill already drives its own close phase when `re_gate_outcome: AUTO-LANDED` (see Phase M8) — the caller does not need to invoke close itself (under the headless engine, the engine verifies the issue is CLOSED + `workflow:merged` and runs close if it is missing). For `re_gate_outcome: REREVIEW-REQUIRED` (`status: REREVIEW_REQUIRED`, forge#3240) the fix is pushed but no review ran because this session has no sub-agent dispatch tool: the caller MUST run `review-pr {PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE}` from a session that has dispatch (never inline) and then drive close itself on a merge. For `re_gate_outcome: INPR-FIX-REQUESTED` (`status: COMPLETE`, forge#4021) the caller dispatches one further in-PR fix remediation (engine kind `inpr-fix`, bounded by `INPR_REMEDIATION`) and never parks the first request at `needs-human`. For every other `re_gate_outcome`, this result is terminal for the current invocation (a held resume, which wrote `FORGE:REMEDIATION_HELD` and not COMPLETE, can be re-invoked by an operator later and re-enters Phase M7): the issue is left at `needs-human` or `workflow:awaiting-merge`, both already recognized as terminal states in the Universal Phase Dispatcher (see `work-on.md`).
@@ -188,6 +188,25 @@ If `LAST_VERDICT_SHA` is empty (no prior CHANGES REQUESTED verdict found) or dif
188
188
 
189
189
  ---
190
190
 
191
+ ## Pre-Handoff Base Sync (before the R1 ancestry guard and the first push) <!-- Added: forge#3969 -->
192
+
193
+ Never hand off a PR born stale. If `origin/{PR_BASE}` exists and is not an ancestor of `{BRANCH}`, merge it now, while the build context is warm. Merge only: no rebase, no force.
194
+
195
+ ```bash
196
+ cd {WORKTREE_PATH}
197
+ PRE_SYNC_TREE=$(git rev-parse 'HEAD^{tree}') # the tree the build-time QUALITY_GATE PASS is bound to
198
+ if git fetch origin {PR_BASE} >/dev/null 2>&1 && ! git merge-base --is-ancestor origin/{PR_BASE} HEAD 2>/dev/null; then
199
+ git merge --no-edit origin/{PR_BASE} >/dev/null 2>&1 || { echo "BASE SYNC CONFLICT: resolve in this context, then git commit; on doubt git merge --abort and return BLOCKED + needs-human"; git diff --name-only --diff-filter=U; }
200
+ fi
201
+ ```
202
+
203
+ On conflict, resolve every file, keep both sides' intent, `git add`, `git commit --no-edit`. If not resolvable with confidence: `git merge --abort`, label `needs-human`, return `REVIEW_RESULT: status: BLOCKED` with blocker "pre-handoff base sync conflict". One bounded round; resolve SHAs only after it.
204
+
205
+ **Re-gate the merged head** <!-- Added: forge#3983 -->: the `FORGE:QUALITY_GATE` PASS is bound to `HEAD^{tree}` (R1.5 `--head-tree`), so a merge that changes the tree invalidates it. After the merge and any conflict resolution, compare `PRE_SYNC_TREE` with the current `HEAD^{tree}` (fail closed if either cannot be resolved):
206
+
207
+ - Unchanged: do nothing; never re-gate.
208
+ - Changed: run the scoped quality gate on the merged head here (`work-on/build/validate` V1 loop over the PR's changed files), then post the marker via validate.md V5 Step 1 "Marker-only re-gate mode" (`**Tree**` = merged `HEAD^{tree}`, `**Commands run**` = what ran). Post the real result: on FAIL, fix and re-run within the validate bounds, else add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker "post-sync quality gate failed"; never post PASS without a run.
209
+
191
210
  ## Phase R1: Pre-Push Ancestry Guard
192
211
 
193
212
  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.
@@ -308,7 +327,7 @@ Print the block above (`status: BLOCKED`, blocker: "git push failed") and STOP.
308
327
 
309
328
  ## Phase R1.5: Phase-Trail Preflight (MANDATORY — before PR creation) <!-- Added: forge#3061 -->
310
329
 
311
- A PR must not be opened for work whose earlier phases were skipped. Run the deterministic verifier; it requires INVESTIGATOR, FAST_PATH and CONTRACT always, CONTEXT and ARCHITECT unless the band is TRIVIAL/INVESTIGATION, and a passing FORGE:QUALITY_GATE unless the diff is docs-only.
330
+ A PR must not be opened for work whose earlier phases were skipped. Run the deterministic verifier; it requires INVESTIGATOR, FAST_PATH and CONTRACT always, CONTEXT and ARCHITECT unless the band is TRIVIAL/INVESTIGATION, and a passing FORGE:QUALITY_GATE unless the diff is docs-only. The PASS must be bound to the current `HEAD^{tree}`; the Pre-Handoff Base Sync re-gates a merged head, so re-dispatch here is only a backstop. <!-- Added: forge#3983 -->
312
331
 
313
332
  ```bash
314
333
  # --no-renames: list BOTH sides of a rename so a file moved into docs/ cannot hide its source path (forge#3145).
@@ -440,7 +459,9 @@ Use the existing PR number and continue.
440
459
  RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
441
460
  case "$TIER" in
442
461
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} in-review ;;
443
- 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 ;;
462
+ prose) gh label create "workflow:remediating" --color "C5DEF5" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
463
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:in-review" 2>/dev/null || true # allowlist:check-command-side-effects
464
+ gh issue edit {NUMBER} {GH_FLAG} --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 ;;
444
465
  esac
445
466
  ```
446
467
 
@@ -547,20 +568,25 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
547
568
 
548
569
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker mentions the phase trail (any blocker containing "phase trail": "phase trail unreadable" when the Phase 8 verifier exited ≥2 / 127, "phase trail incomplete…", or "auto-merge requires --issue", forge#3147): the merge gate refused or could not run. Do NOT re-run phases (nothing is missing) and do NOT attempt the manual merge below, because that would bypass the gate. Add `needs-human` and return `REVIEW_RESULT: status: BLOCKED` with the same blocker. The PR stays open and unmerged.
549
570
 
550
- - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "stale review" (a commit landed on the PR after the verdict, or the head moved during the CI wait, so the code that would merge is not the reviewed code): do NOT merge. The re-review bound is persisted, not remembered: count `<!-- FORGE:STALE_REREVIEW: pr={PR_NUMBER} -->` comments on the issue. If the count is 0, post that marker (with the new head SHA), run the quality gate on the new head (`Skill(skill="{FORGE_SKILL_PREFIX}quality-gate", args="<changed files> --worktree {WORKTREE_PATH}")`, which posts a fresh `FORGE:QUALITY_GATE` for the code that will actually merge), then re-invoke Phase R3 once — a review of the new head (scoped by /review-pr: full, delta or carried forward). If the count is already ≥ 1 (the head keeps moving after review), add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker: "PR head keeps moving after review". <!-- forge#3188 -->
571
+ - `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), re-gate the new head per the Pre-Handoff Base Sync "Re-gate the merged head" procedure (the quality-gate skill alone posts no marker), then re-invoke Phase R3 once — a review of the new head (scoped by /review-pr: full, delta or carried forward). If the count is already ≥ 1 (the head keeps moving after review), add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker: "PR head keeps moving after review". <!-- forge#3188 -->
551
572
 
552
573
  - **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`:
553
574
  ```bash
575
+ # forge#3567: ensure the label first, add before remove
576
+ gh label create "workflow:remediating" --color "C5DEF5" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
554
577
  RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
555
578
  case "$TIER" in
556
579
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} remediating ;;
557
- 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
580
+ prose) gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
581
+ gh issue edit {NUMBER} {GH_FLAG} --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
558
582
  esac
559
583
  gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
560
584
  gh pr edit {PR_NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
561
585
  ```
562
586
 
563
- - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "checks still pending" (Phase 8 merge-gate timed out while CI was still running; nothing failed, #3919): do NOT remediate and do NOT add `needs-human`. Return that BLOCKED result unchanged so the caller retries the review phase, whose merge gate re-runs once CI finishes.
587
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "degraded-gate-unreviewed" (the build gate was DEGRADED, a check ran nothing, and no trusted `### Degraded Paths Reviewed` PR section names every degraded check; forge#3628): do NOT merge by any other route, and do NOT leave `needs-human` because of this gate on the first pass. Remove the `needs-human` that Phase 8 set, then re-review once on its own bound (forge#3996; never the `STALE_REREVIEW` bound, which other causes consume): count `<!-- FORGE:DEGRADED_REREVIEW: pr={PR_NUMBER} -->` (the `DEGRADED_REREVIEW` bound below), post the marker, and re-invoke Phase R3 once so `/review-pr` reads the degraded list from the issue's FORGE:QUALITY_GATE and adds the section. If the bound is already used, add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker "degraded-gate-unreviewed after re-review".
588
+
589
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "checks still pending" (Phase 8 merge-gate timed out while CI was still running; nothing failed, #3919): do NOT remediate and do NOT add `needs-human`. Return that BLOCKED result unchanged so the caller retries the review phase, whose merge gate re-runs once CI finishes. The engine (`forgedock run-issue`) is that caller: it recognises the blocker, polls CI with `scripts/wait-ci-green.sh` and backoff (no model calls, bounded by `engine.ci_pending_max_minutes`, default 90; a rate-limited gate, `wait-ci-green.sh` rc 4, is waited out the same way, forge#4018), then re-dispatches this review phase once; budget exhaustion ends `needs-human` with `review.ci_pending_timeout`. The router and interactive caller path is unchanged.
564
590
 
565
591
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed or were cancelled): do NOT attempt the manual merge below — that would bypass the CI gate. Fixing red CI is pipeline work, not a human decision: hand remediation to the router **once**. Bound: count `<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->` comments on the issue first; if ≥ 1, do not remediate again — remove `workflow:remediating`, label the issue `needs-human` (the bound is exhausted, so a human is now genuinely needed) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise run the remediation-pending handoff block above (sets `workflow:remediating`, removes the `needs-human` that Phase 8 set) and return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: ci-gate` (see Output). Do not post the marker here: the router posts it immediately before it invokes remediation (Phase 4R), so a handoff interrupted before remediation starts is retried instead of being counted as used. **Never invoke `work-on:remediate` from this phase** (forge#3398): remediation re-reviews through `/review-pr`, which spawns domain-reviewer sub-agents, so it must run one level below the router, not nested inside this fork. Nested here, it lands at the sub-agent depth limit under `/orchestrate` with no `Agent` tool and strands the PR at `REREVIEW-REQUIRED` (forge#3391). The router runs `work-on:remediate`, which classifies a CI-gate refusal as FIXABLE, swaps `workflow:remediating` (or a legacy `needs-human`) for `workflow:in-review`, fixes the failing jobs on the PR branch, re-runs the quality gate and re-reviews the new head (scoped by /review-pr: full, delta or carried forward), and auto-lands through the same CI gate. <!-- forge#3191 -->
566
592
 
@@ -569,32 +595,27 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
569
595
  - **Bound already used** (including the re-invocation after a remediation that did not land): first read `max_severity=` from the latest trusted `FORGE:INPR_FIX` marker on the PR (`gh pr view {PR_NUMBER} {GH_FLAG} --json comments`, author-trusted as elsewhere in this phase; absent means MEDIUM). **HIGH/CRITICAL are never waived**: remove `workflow:remediating`, label the issue `needs-human`, leave the PR unmerged and return `REVIEW_RESULT: status: BLOCKED` with blocker "in-pr fix not landed (HIGH/CRITICAL, no waiver)". The `FORGE:INPR_FIX` work order stays on the PR for the human. For MEDIUM, 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`.
570
596
  ```bash
571
597
  INPR_WAIVE_HEAD=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq .headRefOid)
572
- 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
598
+ gh label create "workflow:in-review" --color "5319E7" --force {GH_FLAG} 2>/dev/null || true # allowlist:check-command-side-effects
599
+ gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:in-review" 2>/dev/null || true # allowlist:check-command-side-effects
600
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
601
+ gh issue edit {NUMBER} {GH_FLAG} --remove-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
573
602
  gh pr comment {PR_NUMBER} {GH_FLAG} --body "<!-- FORGE:INPR_FIX_WAIVED: head=${INPR_WAIVE_HEAD} -->
574
603
  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
575
604
  ```
576
605
 
577
- 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):
606
+ Persisted loop bounds for the cases above — count first, then act (`{BOUND}` is `STALE_REREVIEW`, `DEGRADED_REREVIEW`, `CI_REMEDIATION`, `INPR_REMEDIATION` or `BASESYNC_REMEDIATION`). This phase posts only the `STALE_REREVIEW` and `DEGRADED_REREVIEW` markers; `CI_REMEDIATION`, `INPR_REMEDIATION` and `BASESYNC_REMEDIATION` are posted by the router in Phase 4R, immediately before it invokes remediation (forge#3398):
578
607
  ```bash
579
608
  [ "${DRY_RUN:-false}" = "true" ] && { echo "[DRY_RUN] bound check only"; }
580
- if [ "{BOUND}" = "STALE_REREVIEW" ]; then
581
- BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
582
- --jq '[.[] | select((.body | contains("FORGE:STALE_REREVIEW:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
583
- elif [ "{BOUND}" = "INPR_REMEDIATION" ]; then
584
- BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
585
- --jq '[.[] | select((.body | contains("FORGE:INPR_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
586
- elif [ "{BOUND}" = "BASESYNC_REMEDIATION" ]; then
587
- BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
588
- --jq '[.[] | select((.body | contains("FORGE:BASESYNC_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
589
- else
590
- BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
591
- --jq '[.[] | select((.body | contains("FORGE:CI_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
592
- fi
609
+ BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
610
+ --jq '[.[] | select((.body | contains("FORGE:{BOUND}:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
593
611
  if [ "$BOUND_COUNT" -ge 1 ]; then
594
612
  echo "BOUND_EXHAUSTED: {BOUND} already used for PR #{PR_NUMBER}"
595
613
  elif [ "{BOUND}" = "STALE_REREVIEW" ] && [ "${DRY_RUN:-false}" != "true" ]; then
596
614
  gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:STALE_REREVIEW: pr={PR_NUMBER} -->
597
615
  Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head once." 2>/dev/null || true
616
+ elif [ "{BOUND}" = "DEGRADED_REREVIEW" ] && [ "${DRY_RUN:-false}" != "true" ]; then
617
+ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DEGRADED_REREVIEW: pr={PR_NUMBER} -->
618
+ Degraded-gate review on PR #{PR_NUMBER}: re-reviewing once so the review names each degraded check." 2>/dev/null || true
598
619
  fi
599
620
  ```
600
621
  `BOUND_EXHAUSTED` → take the "already ≥ 1" branch of that case.
@@ -603,7 +624,7 @@ Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head on
603
624
  - **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).
604
625
  - **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.
605
626
 
606
- - 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**:
627
+ - PR NOT MERGED (and not a phase-trail, auto-merge-gate, stale-review, degraded-gate-unreviewed, 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**:
607
628
  ```bash
608
629
  # CI gate (MANDATORY before any autonomous merge): merge only when every check on the PR is
609
630
  # green. Field test: PRs merged to staging with checks pending or red (#3165), because branch
@@ -617,7 +638,7 @@ Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head on
617
638
  else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
618
639
  echo "$CI_GATE_OUT"
619
640
  GATED_HEAD=$(printf '%s\n' "$CI_GATE_OUT" | sed -n 's/^CI_GATE_HEAD: //p' | head -1)
620
- # rc 3 = CI still running: re-run this block (up to 3 more times) before treating it as a failure.
641
+ # rc 3 = CI still running, rc 4 = GitHub rate-limited/unavailable (forge#4018): both are waits, not failures; re-run this block (up to 3 more times) before treating either as a failure.
621
642
  # Note-disposition gate (same rule as review-pr.md Phase 8): findings without a recorded
622
643
  # §6B.5 disposition must not merge, because that step is what bounds the review-finding cascade.
623
644
  DISPO_JSON=$(gh api --paginate "repos/{GH_REPO}/issues/{PR_NUMBER}/comments" 2>/dev/null) || DISPO_JSON=""
@@ -21,7 +21,7 @@ Orchestrator for the full issue lifecycle: investigate → decompose (if needed)
21
21
 
22
22
  1. **This file is a router. Every phase runs as a forked sub-skill via `Skill(...)`; the router never executes phase logic itself.** Each phase sub-skill (`work-on:investigate`, `work-on:decompose`, `work-on:build`, `work-on:review`, `work-on:close`, `work-on:remediate`) declares `context: fork`, so Claude Code runs it in an isolated sub-agent context that holds only that phase's spec and the args you pass. The phase re-reads everything else from GitHub/git and returns exactly one `*_RESULT:` block — that block is all the router sees. Do not read phase spec files, do not re-implement a phase step here, and do not "help" a phase by posting its markers or labels. If a skill name does not resolve, STOP with "skill not found" (see Skill Name Resolution) — never run a phase inline.
23
23
 
24
- 1a. **Dispatching phases are invoked only by this router.** `work-on:review` and `work-on:remediate` spawn sub-agents (the `/review-pr` domain reviewers), and Claude Code only grants the `Agent` tool down to a fixed depth (see Depth Budget). Invoking them from here keeps them one level below the router, where `Agent` works. No phase may invoke them; a phase that needs one returns `status: NEXT` with `next: <phase>`, and the router runs it (Phase 4R). Non-dispatching phases may nest forks freely. <!-- forge#3398 -->
24
+ 1a. **Dispatching phases are invoked only by this router.** `work-on:review` and `work-on:remediate` spawn sub-agents (the `/review-pr` domain reviewers), and `work-on:build` spawns the build-time self-review reviewers (B5.6, forge#3971; it degrades to a recorded skip when `Agent` is withheld), and Claude Code only grants the `Agent` tool down to a fixed depth (see Depth Budget). Invoking them from here keeps them one level below the router, where `Agent` works. No phase may invoke them; a phase that needs one returns `status: NEXT` with `next: <phase>`, and the router runs it (Phase 4R). Non-dispatching phases may nest forks freely. <!-- forge#3398 -->
25
25
 
26
26
  2. **Route only on `*_RESULT` blocks and GitHub state.** After each phase returns, apply the routing table for that phase (below). Never infer success from a phase's prose; never continue past a `BLOCKED`/`FAILED` result.
27
27
 
@@ -103,11 +103,11 @@ Layers are counted below the main conversation (layer 0). Claude Code grants the
103
103
  |-----------------------------|-----------|-------------------|
104
104
  | 0 / 0 | solo `/work-on` router / `/orchestrate` | orchestrate: one worker per issue |
105
105
  | — / 1 | `/work-on` router (orchestrated worker) | no (`Skill` only) |
106
- | 1 / 2 | every phase invoked by the router, including `work-on:review` and `work-on:remediate` | review/remediate: `/review-pr` domain reviewers |
106
+ | 1 / 2 | every phase invoked by the router, including `work-on:build`, `work-on:review` and `work-on:remediate` | build: self-review reviewers (B5.6); review/remediate: `/review-pr` domain reviewers |
107
107
  | 2 / 3 | domain reviewers (leaves); build children (context, architect, implement, validate) | no |
108
108
  | 3 / 4 | quality gate (inside validate or review) | no |
109
109
 
110
- The only layer constraint that matters is the spawner: `work-on:review` and `work-on:remediate` must sit at layer ≤ 2, which Hard Rule 1a guarantees. The Phase 0A spawn-depth preflight checks the effective depth before any work starts.
110
+ The only layer constraint that matters is the spawner: `work-on:build`, `work-on:review` and `work-on:remediate` must sit at layer ≤ 2, which Hard Rule 1a guarantees. The Phase 0A spawn-depth preflight checks the effective depth before any work starts.
111
111
 
112
112
  ### Model and Effort Tiering — What Actually Applies
113
113
 
@@ -277,7 +277,7 @@ If detected, dispatch immediately and STOP — do NOT fall through to Phase 0B's
277
277
  Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="${REMEDIATE_PR_NUMBER} ${REMEDIATE_ISSUE_FLAG} --repo {GH_REPO} --gh-flag {GH_FLAG}")
278
278
  ```
279
279
 
280
- **If `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`** (forge#3240, fallback only since forge#3398: fix pushed, but remediation had no sub-agent dispatch tool, which cannot happen when the Phase 0A spawn-depth preflight passed and remediation is invoked from this router): run the re-review from this top-level session, which has dispatch, after the **claim pre-check** below — `Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${REMEDIATE_PR_NUMBER} --auto-merge --issue ${ISSUE} --base ${PR_BASE} --gh-flag {GH_FLAG}")` (`ISSUE` and `PR_BASE` from the result and the PR) — never review inline. If it returns `REVIEW_RESULT: status: COMPLETE` (merged), invoke `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` as remediate M8 does for `AUTO-LANDED`. If the claim cannot be recorded, the `review-pr` skill does not resolve, cannot dispatch, or returns anything other than `REVIEW_RESULT: status: COMPLETE` while the issue is still at `workflow:in-review` with no `needs-human` (no state anything will advance), apply the **terminal fallback**. Single attempt: no retry loop, no polling. Then STOP. <!-- Added: forge#3406; claim pre-check forge#3413 -->
280
+ **If `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`** (forge#3240, fallback only since forge#3398: fix pushed, but remediation had no sub-agent dispatch tool, which cannot happen when the Phase 0A spawn-depth preflight passed and remediation is invoked from this router): run the re-review from this top-level session, which has dispatch, after the **claim pre-check** below — `Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${REMEDIATE_PR_NUMBER} --auto-merge --issue ${ISSUE} --base ${PR_BASE} --gh-flag {GH_FLAG}")` (`ISSUE` and `PR_BASE` from the result and the PR) — never review inline. If it returns `REVIEW_RESULT: status: COMPLETE` (merged), invoke `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` as remediate M8 does for `AUTO-LANDED`. If the claim cannot be recorded, the `review-pr` skill does not resolve, cannot dispatch, or returns anything other than `REVIEW_RESULT: status: COMPLETE` while the issue is still at `workflow:in-review` with no `needs-human` (no state anything will advance), apply the **terminal fallback**. **Base-conflict mapping (forge#3516)**: if `review-pr` returns `REVIEW_RESULT: status: BLOCKED` with a blocker containing `base-conflict`, it added no label (the base sync is pipeline work), so this router maps it before the claim release: re-read the labels first (closed, already `needs-human`, or no longer `workflow:in-review` means skip and report that state; idempotent); unless `DRY_RUN=true` (then record "would set workflow:remediating"), run `transition-label.sh ${ISSUE} {GH_FLAG} remediating` and re-read the labels to verify `workflow:remediating`. The next router pass (Phase 4R) runs the bounded `FORGE:BASESYNC_REMEDIATION` round via `work-on:remediate`. If the transition fails or is not verified, apply the **terminal fallback** (`needs-human`) and do not release the claim as settled. Never fall through to a manual merge. A `base-conflict` combined with other blockers is reported by the script as a `needs-human` outcome, so this mapping does not fire for it. Single attempt: no retry loop, no polling. Then STOP. <!-- Added: forge#3406; claim pre-check forge#3413 -->
281
281
 
282
282
  **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):
283
283
  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.
package/docs/CONFIG.md CHANGED
@@ -481,13 +481,17 @@ engine:
481
481
  | `<key>_model` | string | No | Model alias (`sonnet`, `opus`, `haiku`) or full model id passed as `--model`. Invalid values are ignored. |
482
482
  | `<key>_max_turns` | integer | No | Positive integer passed as `--max-turns`. Invalid values are ignored. |
483
483
 
484
+ | `ci_pending_max_minutes` | integer | No | Minutes the engine waits for CI when the review phase ends BLOCKED with "checks still pending", or the remediate phase holds at its M7 re-gate on "CI pending" (the merge gate's wait window ended while CI was still running). Default: 90. Invalid values are ignored. This key is flat like the others but is not part of the `<key>_model` / `<key>_max_turns` pattern. |
485
+
486
+ **CI still pending**: the engine polls `scripts/wait-ci-green.sh` (no model calls) one read per poll on a 60 s, 2 min, 4 min, 5 min (cap) backoff with +/-20% jitter, while the lease keeps renewing, then re-dispatches the phase that was waiting, review or remediate (idempotent at an unchanged head: review re-runs the merge gate and merges; remediate resumes at its M7 re-gate). Before each poll it reads `gh api rate_limit` (not itself rate-limited) and, below 10% of the REST budget, sleeps until the window resets instead of polling; a rate-limited or transient (5xx, network) poll is retried and never re-dispatches the phase. Red CI takes the normal ci-gate path. When the budget is exhausted the run ends `needs-human` with outcome code `review.ci_pending_timeout`; `--retry` resumes. Each wait emits a `ci_pending_wait` telemetry event (`docs/TELEMETRY.md`) with the minutes waited, polls made and rate-limit sleeps.
487
+
484
488
  **Precedence** (model): `engine.<key>_model`, then the run-level model the caller passed explicitly, then omitted. `max_turns` comes only from this section. No defaults ship.
485
489
 
486
490
  **Version gate**: the flags are added only when the installed `claude --version` supports them (`--max-turns` is hidden from `claude --help`, so help text is never scraped); an unparseable version omits both flags.
487
491
 
488
492
  **Turn exhaustion**: when `--max-turns` is exhausted the run ends `engine-error` (detail `max-turns`) and the phase is never committed, so `forgedock run-issue --retry` re-enters the same phase. Raise `<key>_max_turns` before retrying. Phase runlog events record `model` and `turns` next to `usage` so a limit can be sized from measured turn counts.
489
493
 
490
- **Per-run scratch dir** (`tmp_root`): every engine child runs with `TMPDIR` and `CLAUDE_CODE_TMPDIR` set to `<tmp_root>/<issue>` so venvs and Claude session data stay off a size-limited `/tmp`. `engine.tmp_root` is an absolute or `~`-prefixed path; default `~/.forge/tmp`. When `tmp_root` is unset and the operator already exported `TMPDIR` or `CLAUDE_CODE_TMPDIR`, that choice is respected and never cleaned. The dir is removed on a terminal outcome and kept on `engine-error` so `--retry` resumes. A disk-full/quota failure (EDQUOT, ENOSPC, errno -28/-122) ends a retryable `engine-error` (outcome code `infra.disk_quota`), never `needs-human`.
494
+ **Per-run scratch dir** (`tmp_root`): every engine child runs with `TMPDIR` and `CLAUDE_CODE_TMPDIR` set to `<tmp_root>/<repo-key>/<issue>` so venvs and Claude session data stay off a size-limited `/tmp`. `engine.tmp_root` is an absolute or `~`-prefixed path; default `~/.forge/tmp`. The `<repo-key>` is the checkout's directory name plus 8 hex characters of a hash of its real path, so the same issue number in two repos never shares a dir; the dir is created `0700` and an existing symlink or dir owned by another user is not used (the child keeps the inherited tmp). Old `<tmp_root>/<issue>` dirs from earlier versions are not migrated. When `tmp_root` is unset and the operator already exported `TMPDIR` or `CLAUDE_CODE_TMPDIR`, that choice is respected and never cleaned. The dir is removed on a terminal outcome and kept on `engine-error` so `--retry` resumes. A disk-full/quota failure (EDQUOT, ENOSPC, errno -28/-122) ends a retryable `engine-error` (outcome code `infra.disk_quota`), never `needs-human`.
491
495
 
492
496
  **Commands that use this section**: CLI backend (`runCommand` in `bin/runner.mjs`)
493
497
 
@@ -736,6 +740,10 @@ verification:
736
740
  - name: "web"
737
741
  container: "acme-web-blue"
738
742
  health_url: "http://localhost:3000"
743
+
744
+ consumer_sweep:
745
+ mode: advisory # advisory (default) | blocking
746
+ budget_seconds: 60 # wall-clock budget for the sweep
739
747
  ```
740
748
 
741
749
  | Field | Type | Required | Description |
@@ -746,8 +754,91 @@ verification:
746
754
  | `services[].name` | string | **Yes (per entry)** | Human-readable service name |
747
755
  | `services[].container` | string | No | Docker container name for `docker exec` verification |
748
756
  | `services[].health_url` | string | No | Local URL for container-level health checks |
757
+ | `ci_local` | object | No | Local CI gate settings (see below) |
758
+ | `consumer_sweep.mode` | string | No | `advisory` (default): the validate consumer sweep (`scripts/consumer-sweep.sh`, V4.6) records unaddressed referencing sites and never fails. `blocking`: unaddressed sites fail validate with `consumer_unaddressed`, build runs one repair round, and sites still unaddressed are recorded as `CONSUMERS: unresolved` and passed to review as known risk (never `needs-human`). Flip to `blocking` only after `bash scripts/consumer-sweep.sh --eval-history 20` shows a mean of at most 2 flagged sites per merge and none above 10 on your repo |
759
+ | `consumer_sweep.budget_seconds` | integer | No | Wall-clock budget for the sweep (default `60`). When it runs out the sweep stops with `BUDGET_EXCEEDED`, reports the partial result, and never fails, in either mode |
760
+
761
+ **Commands that use this section**: `quality-gate`, `validate` (V2 runs `verification.commands`; V4.8 runs the local CI gate; health checks and `consumer_sweep`), `work-on`
762
+
763
+ ### `verification.ci_local` (OPTIONAL)
764
+
765
+ Validate Phase V4.8 runs `scripts/ci-local.sh` before review: it derives the repo's pull-request checks from its own CI
766
+ definitions and runs them locally in the build worktree, so deterministic CI failures are fixed in build (at most 2 fix
767
+ iterations; a failure still unfixable after that is DEGRADED by default, see `mode`). It never contacts GitHub and never triggers a remote
768
+ workflow; remote CI still runs once, at merge. Everything works with no configuration.
769
+
770
+ ```yaml
771
+ verification:
772
+ ci_local:
773
+ mode: advisory
774
+ exclude_jobs: ["gha:ci:e2e-*"]
775
+ include_jobs: []
776
+ extra_commands:
777
+ - "make lint-docs"
778
+ services: auto
779
+ budget_minutes: 15
780
+ max_parallel: 4
781
+ ```
749
782
 
750
- **Commands that use this section**: `quality-gate`, `validate`
783
+ | Field | Type | Default | Description |
784
+ |-------|------|---------|-------------|
785
+ | `mode` | `advisory` \| `blocking` | `advisory` | `advisory`: a runnable job still failing after the 2 fix iterations is DEGRADED (`DEGRADED_CHECKS+=ci-local`), recorded in `FORGE:BUILDER` under `### Local CI unresolved` and surfaced to review; the build is not blocked. `blocking` (opt-in): the build is blocked with `ci-local: <jobs>`. Flip to `blocking` only once telemetry (`ci_local` events) shows a low false-fail rate on your repo |
786
+ | `exclude_jobs` | array of globs | `[]` | Job ids or names to drop from the plan (matched against `gha:<workflow>:<job>`, `gitlab:<job>`, and the job name) |
787
+ | `include_jobs` | array of globs | `[]` | When non-empty, only matching CI jobs run (an allowlist); `extra_commands` and the portability checks (when they apply) always run |
788
+ | `extra_commands` | array of strings or `{name, command}` | `[]` | Extra commands always added to the plan. `verification.commands` is not repeated here: V2 already runs it as the fast pre-step |
789
+ | `services` | `auto` \| `off` | `auto` | `auto` starts a job's service containers with docker (same image, env, ports), tearing them down afterwards; without docker, or with `off`, such jobs are deferred |
790
+ | `budget_minutes` | number | `15` | Overall wall-time budget; jobs not finished or started within it leave the run DEGRADED (never PASS, never FAIL). One invocation is capped at 9 minutes whatever this says (the Bash tool limit is 10): beyond that the run is a partial DEGRADED result and re-running resumes from the result cache (`FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS`, default 540) |
791
+ | `max_parallel` | number | `4` | Jobs run in parallel by one run (also bounded machine-wide, see below) |
792
+
793
+ **Trusted config**: every field above is read from the main checkout's `forge.yaml` (for a plain checkout, the base ref's
794
+ copy), never from the PR's own tree, so a PR cannot exclude jobs from the gate that checks it.
795
+
796
+ **Where checks come from**: GitHub Actions (`.github/workflows`), GitLab CI, CircleCI, Azure Pipelines, Bitbucket Pipelines
797
+ and Buildkite definitions, read from the trusted base ref plus the PR's own edits (a PR can add a check but cannot delete
798
+ one). Only when the repo has NO CI definitions are its task runners used; when CI exists but its path filters leave no job
799
+ applicable the run executes nothing and records `no_applicable_jobs` (a docs-only change never triggers a full suite) (`package.json` scripts, Makefile, justfile,
800
+ Taskfile, tox/nox, pre-commit, pyproject tools, Cargo, Go, Maven, Gradle). Deterministic portability checks (Windows-invalid
801
+ filenames, case collisions, CRLF in scripts, bash 3.2 constructs and GNU-only flags on added lines, plus the repo's
802
+ `scripts/check-shell-portability.sh` when present) run only when the repo's CI definitions target macOS or Windows (a
803
+ `macos-*`/`windows-*` runner, matrix entry or executor); for a Linux-only repo they are skipped with a note, so correct
804
+ Linux-only code is never blocked on bash 3.2 rules.
805
+
806
+ **Deferred to CI** (listed in the `FORGE:BUILDER` comment under `### Deferred to CI`, never guessed): jobs that reference
807
+ `secrets.` or `environment:`, deploy/publish jobs, `pull_request_target`, schedule/dispatch-only workflows, unknown `uses:`
808
+ actions, orbs, plugins and templates, service containers without docker, macOS/Windows variants on another host, matrix
809
+ variants beyond the first host-compatible one, and jobs whose tools are not installed locally. A failed dependency install
810
+ (for example offline) defers the job instead of failing it, and so does a failure of a job whose pinned toolchain differs
811
+ from the host by a major version (`setup-node` 18 vs host 26: version drift, listed with its reason). A job timeout is
812
+ DEGRADED, never FAIL, and its clock starts when the job starts executing, not while it waits for a machine slot.
813
+
814
+ **Exact scoping** (runs only what the change can affect): job-level path filters from the CI definition (including
815
+ `dorny/paths-filter` outputs) gate whole jobs; per-file linters (shellcheck, eslint, prettier, ruff, black, flake8, ...) run
816
+ on the changed files only; tests use the ecosystem selector (`jest --findRelatedTests`, `vitest related`, `go` affected
817
+ packages with reverse dependencies, `cargo test -p` affected crates, Maven `-pl ... -amd`, `nx affected`, `turbo --filter`) or a
818
+ git-grep reference graph for file-list runners (pytest, mocha, `node --test`, bats, rspec, direct `*.test.*` invocations);
819
+ changed test files always run. **Fail-safe widening**: a lockfile, build or lint config, CI definition, unreferenced shared
820
+ fixture, an unknown file type, or an empty test selection for a changed source file widens the step to the full command (the
821
+ reason is reported, never silently narrowed). Short module names (`app/db.py`, `__init__.py`) are matched by their qualified
822
+ import forms. `${{ github.* }}` expressions in `run:` scripts are passed to the script as environment variables, never
823
+ substituted into the script text. CircleCI secondary containers run on a per-job docker network and publish only declared
824
+ (or well-known default) ports on 127.0.0.1, never `--network host`.
825
+ Passing results are cached by input hash under `~/.forge/ci-local/results` and reused across build iterations, lanes and
826
+ issues on the same base. An identical check (command, cwd, shell, env, input hash) that appears in several jobs of one invocation
827
+ runs once; the later jobs reuse its result (`cache=dedup`, a failure is shared). Machine slots are granted in FIFO ticket order
828
+ across invocations (`~/.forge/ci-local.lock/queue`; tickets of dead processes are reaped), and a slot is taken only right before a
829
+ step really executes. Job lines, `CI_LOCAL_CHECK`, `CI_LOCAL_RESULT` and the `ci_local` telemetry report `wait_ms` (time queued
830
+ for a slot) separately from `duration_ms` (execution). Ready jobs run narrow/scoped first, then cheapest first (cached durations).
831
+
832
+ **Environment** (all optional): `FORGE_CI_LOCAL_SLOTS` (machine-wide concurrent jobs across all runs, default
833
+ `max(2, min(8, cpus/2))`; slots live under `~/.forge/ci-local.lock` and stale ones are reclaimed), `FORGE_CI_LOCAL_BUDGET_MINUTES`,
834
+ `FORGE_CI_LOCAL_MAX_PARALLEL`, `FORGE_CI_LOCAL_JOB_TIMEOUT` (seconds, default min(900, budget)), `FORGE_CI_LOCAL_SERVICES=off`,
835
+ `FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS` (default 540), `FORGE_CI_LOCAL_HOME` (default `~/.forge`), `FORGE_CI_LOCAL_NO_CACHE=1`. Jobs run in a runner-like environment: an allowlist of
836
+ system and toolchain variables (PATH, HOME, locale, XDG_*, CARGO_*, GOPATH, JAVA_HOME, proxies, ...) plus `CI=true`; credentials and
837
+ ambient operator state (agent tokens, `FORGE_*`/`CLAUDE_*` settings, git hook variables) never reach them. Extra names can be
838
+ listed in `FORGE_CI_LOCAL_PASSTHROUGH` (comma separated). Requires `node`, `git`, and mikefarah `yq` v4 for YAML CI definitions (without `yq`
839
+ the gate is DEGRADED and reported in `verification_skipped`, never a silent pass).
840
+
841
+ **Commands that use this section**: `work-on` (validate V4.8)
751
842
 
752
843
  ---
753
844
 
@@ -20,7 +20,8 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
20
20
  | `review.rereview_skipped` | Re-review was skipped | reserved | No structured evidence source yet |
21
21
  | `review.blocking_issue` | Review left the PR open at an unchanged head with a filed blocking ISSUE and handed off to remediate | review | Trusted `FORGE:NOTE_DISPOSITION` on the PR with `findings_filed` > 0 and `reviewed_sha` equal to the live head |
22
22
  | `review.pr_open` | Review ended with the PR open and unmerged and no handoff signal | review | Live PR open, no `REVIEW_RESULT` remediate handoff, no `needs-human`/`workflow:remediating` label, no blocking disposition |
23
- | `build.acceptance_failed` | Newest acceptance gate marker is blocked | build | `FORGE:ACCEPTANCE_GATE:FAILED`/`:BLOCKED` or `FORGE:BUILD_BLOCKED` |
23
+ | `review.ci_pending_timeout` | CI was still pending at the merge gate after the engine's bounded wait (`engine.ci_pending_max_minutes`) ran out | engine | Review ended `REVIEW_RESULT` `status: BLOCKED` with a blocker containing `checks still pending` (or remediate held at its M7 re-gate on `CI pending`), and `scripts/wait-ci-green.sh` still reported pending when the budget expired |
24
+ | `build.acceptance_failed` | Newest acceptance gate marker is blocked | build | `FORGE:ACCEPTANCE_GATE:FAILED`/`:BLOCKED` or `FORGE:BUILD_BLOCKED` (`:DEGRADED` is a pass-class marker, not blocked) |
24
25
  | `build.acceptance_malformed` | Acceptance spec was malformed | reserved | Indistinguishable from `build.acceptance_failed` by sentinel alone |
25
26
  | `build.size_over_gate` | Retired (forge#3862): the old size gate routed a finished build to decompose; never emitted now, kept so historic runs still parse | reserved | A finished build is never routed to decompose |
26
27
  | `build.cohesive` | Retired (forge#3862): the old size gate judged an oversized diff cohesive | reserved | No size gate remains |
@@ -28,12 +29,15 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
28
29
  | `remediate.deferred_live_run` | Remediation deferred to a live earlier run | remediate, engine | Trusted `FORGE:REMEDIATION_DEFERRED` record |
29
30
  | `remediate.re_escalated` | Remediation re-escalated to a human | remediate | `**Re-gate outcome**: RE-ESCALATED` |
30
31
  | `remediate.unfixable` | Remediation judged the findings unfixable | remediate | `**Re-gate outcome**: UNFIXABLE` |
32
+ | `remediate.inpr_requested` | Re-gate approved but requested one further in-PR fix round; the run continues, never `needs-human` | remediate | `**Re-gate outcome**: INPR-FIX-REQUESTED` |
31
33
  | `ci.red` | CI failed | reserved | `REVIEW_RESULT` carries no CI field yet |
32
34
  | `ci.timeout` | CI timed out | reserved | `REVIEW_RESULT` carries no CI field yet |
33
35
  | `base.conflict` | Base-branch conflict blocked the PR | reserved | `REVIEW_RESULT` carries no conflict field yet |
34
36
  | `rate_limited` | Session or usage limit outlasted the pause budget | engine | `err.usageLimit` / `err.resetAtEpochMs` |
35
37
  | `engine.error` | Engine or tool failure (missing key/SDK, CLI crash, max turns, unresolved args, runner threw every attempt) | engine | `err.code` |
36
38
  | `infra.disk_quota` | Disk full or quota exhausted (EDQUOT/ENOSPC, errno -28/-122); retryable infra failure, never `needs-human` | engine | Runner error text |
39
+ | `infra.cli_spawn_transient` | CLI spawn failed transiently before the child ran (ETIMEDOUT, EAGAIN, ENOMEM, EMFILE); retried with backoff on a separate bounded counter that does not consume a phase attempt | engine | `err.code` `CLI_SPAWN_TRANSIENT` |
40
+ | `cli.timeout` | CLI child ran and was killed at the wall-clock limit; a normal attempt (consumes one of `maxAttempts`), usage recorded when the CLI emitted any | engine | `err.code` `CLI_TIMEOUT` |
37
41
  | `lease.held` | Another live agent holds the issue lease | engine | Foreign live lease on acquire |
38
42
  | `dependency.open` | A trusted `**Depends on**: #N` dependency is still open, so the run was refused before any state write | engine | Trusted issue body `**Depends on**` line + the dependency issue's open state |
39
43
  | `dependency.unreadable` | The issue body or a declared dependency could not be read, so the run was refused (fail closed) | engine | Failed or unparsable `gh api` read |