forgedock 1.13.1 → 1.15.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.
- package/bin/engine/phases-args.test.mjs +11 -0
- package/bin/engine/phases.mjs +268 -25
- package/bin/engine/phases.test.mjs +24 -0
- package/bin/engine-cli.mjs +18 -5
- package/bin/engine.mjs +149 -12
- package/bin/forgedock.mjs +4 -1
- package/commands/issue.md +17 -2
- package/commands/orchestrate/phase-3-dependency.md +5 -2
- package/commands/orchestrate/phase-4-execution.md +154 -27
- package/commands/pipeline-health/phase-2-metrics.md +5 -3
- package/commands/review-pr-staging.md +554 -54
- package/commands/review-pr.md +662 -51
- package/commands/validate.md +11 -3
- package/commands/work-on/build/architect.md +11 -8
- package/commands/work-on/build/implement.md +15 -1
- package/commands/work-on/build.md +34 -10
- package/commands/work-on/close.md +5 -4
- package/commands/work-on/decompose.md +7 -2
- package/commands/work-on/investigate.md +13 -0
- package/commands/work-on/remediate.md +332 -91
- package/commands/work-on/review.md +3 -3
- package/docs/CONFIG.md +14 -1
- package/docs/RELEASE-NOTES.md +20 -0
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +6 -0
- package/scripts/architect-resume-match.test.sh +53 -0
- package/scripts/check-spec-bash.sh +73 -3
- package/scripts/check-spec-bash.test.sh +50 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/checkbox-sections.test.sh +19 -0
- package/scripts/classify-finding.sh +103 -1
- package/scripts/classify-finding.test.sh +56 -0
- package/scripts/forge-root.test.sh +86 -2
- package/scripts/orchestrate-soft-edge.test.sh +58 -0
- package/scripts/remediate-approved-head.test.sh +43 -0
- package/scripts/remediate-basesync.test.sh +62 -3
- package/scripts/remediation-marker-match.test.sh +6 -0
- package/scripts/resolve-deploy-gate.sh +51 -0
- package/scripts/resolve-deploy-gate.test.sh +59 -0
- package/scripts/review-delta.sh +3 -2
- package/scripts/review-delta.test.sh +1 -1
- package/scripts/review-pr-delta-wiring.test.sh +274 -0
- package/scripts/review-pr-domain-map.test.sh +10 -0
- package/scripts/review-pr-staging-scope.test.sh +159 -0
- package/scripts/trusted-comments.test.sh +11 -0
|
@@ -29,9 +29,9 @@ background: false
|
|
|
29
29
|
**Agent model policy**: Default `model: "sonnet"`. If Sonnet is rate-limited, fall back to `model: "opus"`.
|
|
30
30
|
**NEVER use plan mode (EnterPlanMode).**
|
|
31
31
|
|
|
32
|
-
**Scope note**: This mode owns exactly one gap — re-driving a `needs-human` or `workflow:remediating` PR's own remediation (the
|
|
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 (a `FORGE:REMEDIATION_HELD` record, not COMPLETE, is posted when a resume ends HELD) (the `workflow:remediating` state is the non-human "autonomous remediation pending" state review sets for ci-gate, in-pr-fix and base-sync handoffs, forge#3541). It does NOT implement the `needs-human` sub-label taxonomy (#1815's scope) and it does NOT edit `review-pr.md`'s Phase 8 guard (forge#1810) — that guard's existing safe-default (`workflow:awaiting-merge` on any clean re-review of a previously-escalated PR) is reused as-is; this file only adds a bar-check *after* that guard has already fired.
|
|
33
33
|
|
|
34
|
-
**Engine coverage** (forge#2379, #2889): this subcommand's `command` name (`work-on/remediate`) and completion marker (`FORGE:REMEDIATION:COMPLETE`, including the `**Re-gate outcome**` field Phase M8 posts below) are registered in the headless engine's phase table — `RESERVED_TYPES.REMEDIATION` in `packages/protocol/src/types.js`, `remediate` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and a matching `remediate` entry in `bin/engine/phases.mjs`'s `PHASES` array. A blocked review is committed with `terminalReason: "needs-human"`, then the engine continues directly into remediation; the divergence guard permits this specific handoff while keeping all other `needs-human` states paused.
|
|
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.
|
|
35
35
|
|
|
36
36
|
---
|
|
37
37
|
|
|
@@ -59,7 +59,7 @@ PR_BODY=$(echo "$PR_STATE" | jq -r '.body')
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
**PR state guard**:
|
|
62
|
-
- `PR_OPEN_STATE = MERGED` →
|
|
62
|
+
- `PR_OPEN_STATE = MERGED` → do NOT exit blindly (forge#3685): a PR merged by an M7 run that was cut off before M8 still needs its post-merge bookkeeping, trail and close. Set `PR_ALREADY_MERGED=true` and continue; the merged-PR routing step after the idempotency check below exits `ALREADY_DONE` only when a trusted `FORGE:REMEDIATION:COMPLETE` trail exists or the issue is already CLOSED with `workflow:merged`, and otherwise routes straight to Phase M7's exclusive merged-first branch (M1-M6 skipped). The `needs-human` / `workflow:remediating` / `workflow:awaiting-merge` label requirement below and the park-evidence requirement do not apply to a merged PR. <!-- Added: forge#3685 -->
|
|
63
63
|
- `PR_OPEN_STATE = CLOSED` (not merged) → EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "PR #{PR_NUMBER} is closed, not merged — nothing to remediate."
|
|
64
64
|
|
|
65
65
|
**Resolve the linked issue** (`--issue` flag takes precedence; else parse from the PR body — anchored, matching the `"Closes #N" in:body` precedent from forge#1634/#1646, never a bare-number scan):
|
|
@@ -79,8 +79,9 @@ ISSUE_STATE=$(gh issue view {ISSUE_NUMBER} {GH_FLAG} --json labels,state,body,mi
|
|
|
79
79
|
ISSUE_LABELS=$(echo "$ISSUE_STATE" | jq -r '[.labels[].name] | join(",")')
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
- If
|
|
83
|
-
- **
|
|
82
|
+
- If `PR_ALREADY_MERGED` is not `true` and none of `needs-human`, `workflow:remediating` or `workflow:awaiting-merge` is among `ISSUE_LABELS` → EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "issue #{ISSUE_NUMBER} is none of `needs-human`, `workflow:remediating` or `workflow:awaiting-merge` — remediation mode only targets PRs in one of those states; use the normal `/work-on {ISSUE_NUMBER}` resume path instead." This keeps blast radius scoped to exactly the gap this mode fills — it is not a general-purpose re-review trigger. `workflow:remediating` is the non-human state review sets when it hands an autonomous ci-gate / in-pr-fix / base-sync fix to this phase (forge#3541); a bare `needs-human` (older runs, genuine escalations being re-driven) is still accepted. <!-- Added: forge#3541 -->
|
|
83
|
+
- **Resume-at-M7 (forge#3647, hardened forge#3684)**: `workflow:awaiting-merge` is admitted ONLY as a resume of an interrupted M7 auto-land: the PR is OPEN (the PR state guard above already exited otherwise), `needs-human` and `workflow:remediating` are both absent, the idempotency check below finds NO completed `FORGE:REMEDIATION:COMPLETE` trail (`REMEDIATION_COMPLETE_N` = 0), AND there is evidence the PR was engine-parked for the M7 bar rather than parked for a human: the live base is non-empty, non-`null` and not the deploy gate (literal `main`, the GitHub `default_branch`, or the default branch's committed `branches.default`; never read from the PR worktree, forge#3753), and the LATEST trusted (OWNER/MEMBER/COLLABORATOR or Bot) `review-pr.md` Phase 8 park comment on the issue is the non-default-branch "parked at `workflow:awaiting-merge` for the remediation auto-land bar" comment AND names this PR (`PR #{PR_NUMBER} was previously escalated`, so a park for a different PR never admits the resume; M7 then binds approval to the live head SHA, not a timestamp) (not the deploy-gate "held ... for a human merge decision" comment). When all hold, set `RESUME_AT_M7=true`: skip Phases M1-M6 entirely (no checkout, no fixes, no re-review) and continue at Phase M7, which re-verifies the bar from scratch. Without park evidence, return `BLOCKED` (blocker: "awaiting-merge PR is not engine-parked for the remediation auto-land bar"); the issue stays at `workflow:awaiting-merge`. If a COMPLETE trail exists the single-attempt `ALREADY_DONE` guard below still wins. A resume that ends `HELD-AWAITING-MERGE` (CI pending, stale or mismatched approval head, deploy-gate base) writes NO `FORGE:REMEDIATION:COMPLETE` (Phase M8 posts a `FORGE:REMEDIATION_HELD` record instead), so the next invocation re-enters this same resume path rather than exiting `ALREADY_DONE` (forge#3685). On a BLOCKED exit from this path the issue stays at `workflow:awaiting-merge` (it never gains `workflow:remediating`; the stranded-state rule below applies only to `workflow:remediating`).
|
|
84
|
+
- **Stranded-state rule**: `workflow:remediating` must never outlive this run. Every exit that returns `BLOCKED` or `UNFIXABLE` (including exits before Phase M1; except a `RESUME_AT_M7` run, which leaves the issue at `workflow:awaiting-merge` untouched) removes `workflow:remediating` and adds `needs-human`, because at that point a human really is needed:
|
|
84
85
|
|
|
85
86
|
```bash
|
|
86
87
|
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" --remove-label "workflow:remediating" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
@@ -114,16 +115,19 @@ fi
|
|
|
114
115
|
PARTIAL_PR_COMMENT_IDS=$(printf '%s' "$PR_COMMENTS" | jq -rs '[.[][] | select((.body // "") | startswith("<!-- FORGE:REMEDIATION -->")) | select((.body | contains("<!-- FORGE:REMEDIATION:COMPLETE -->")) | not) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | .id] | .[]' 2>/dev/null || true)
|
|
115
116
|
```
|
|
116
117
|
|
|
117
|
-
**Per-kind scoping of the single-attempt guard** (forge#3496): the guard is scoped per remediation kind so a base-sync round is not swallowed by an earlier `ci-gate` / `inpr-fix` remediation on the same PR. A base-sync handoff is recognised by the router's `FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER}` bound marker on the issue (the router posts it immediately before invoking this phase); a completed base-sync round is recognised by a `FORGE:REMEDIATION:COMPLETE` trail carrying the `**Base sync**: ran` line that Phase M8 posts. The router handoff is bounded to one per PR (the marker count in `work-on/review.md` R4, unchanged) and this check; inside remediation the sync is bounded per distinct base head and hard-capped at `BASESYNC_MAX_ROUNDS` pushed sync merges on the branch, counted from git rather than shell state (see Phase M3 and M6):
|
|
118
|
+
**Per-kind scoping of the single-attempt guard** (forge#3496): the guard is scoped per remediation kind so a base-sync round is not swallowed by an earlier `ci-gate` / `inpr-fix` remediation on the same PR. A base-sync handoff is recognised by the router's `FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER}` bound marker on the issue (the router posts it immediately before invoking this phase); the guard is relaxed only when the NEWEST bound marker (any of `CI` / `INPR` / `BASESYNC`) is the base-sync one, and a completed base-sync round is recognised by a `FORGE:REMEDIATION:COMPLETE` trail carrying the `**Base sync**: ran` or `**Base sync**: attempted` line that Phase M8 posts (a round that merged nothing is still recorded as `attempted`). The router handoff is bounded to one per PR (the marker count in `work-on/review.md` R4, unchanged) and this check; inside remediation the sync is bounded per distinct base head and hard-capped at `BASESYNC_MAX_ROUNDS` pushed sync merges on the branch, counted from git rather than shell state (see Phase M3 and M6):
|
|
118
119
|
|
|
119
120
|
```bash
|
|
120
121
|
if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
|
|
121
|
-
BASESYNC_DONE_N=$(printf '%s' "$PR_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:REMEDIATION -->[\s\S]*\*\*Base sync\*\*: ran[\s\S]*<!-- FORGE:REMEDIATION:COMPLETE -->' 2>/dev/null || echo "")
|
|
122
|
+
BASESYNC_DONE_N=$(printf '%s' "$PR_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:REMEDIATION -->[\s\S]*\*\*Base sync\*\*: (ran|attempted)[\s\S]*<!-- FORGE:REMEDIATION:COMPLETE -->' 2>/dev/null || echo "")
|
|
122
123
|
ISSUE_COMMENTS=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null) || ISSUE_COMMENTS=""
|
|
123
|
-
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
|
|
124
|
+
# The override applies only when the CURRENT handoff is base-sync. The current kind is the NEWEST trusted bound marker
|
|
125
|
+
# (`bodies` is oldest-first, so tail -1): the router posts it immediately before invoking this phase. A base-sync handoff with no
|
|
126
|
+
# completed base-sync round (a `ran` or `attempted` trail) is a fresh attempt for ITS kind, whatever earlier ci-gate / inpr-fix
|
|
127
|
+
# trails exist. Fail closed: an unreadable count or kind, or any other kind (including a standalone --remediate with no bound
|
|
128
|
+
# marker), keeps the kind-agnostic guard.
|
|
129
|
+
CURRENT_KIND=$(printf '%s' "$ISSUE_COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:(CI|INPR|BASESYNC)_REMEDIATION: pr={PR_NUMBER} -->' 2>/dev/null | tail -1 | grep -oE '(CI|INPR|BASESYNC)_REMEDIATION' | head -1) || CURRENT_KIND=""
|
|
130
|
+
if [ "$CURRENT_KIND" = "BASESYNC_REMEDIATION" ] && [ -n "$BASESYNC_DONE_N" ] && [ "$BASESYNC_DONE_N" -eq 0 ]; then REMEDIATION_COMPLETE_N=0; fi
|
|
127
131
|
fi
|
|
128
132
|
```
|
|
129
133
|
|
|
@@ -131,6 +135,7 @@ Completed trails of other kinds are left in place (never deleted); only the inte
|
|
|
131
135
|
|
|
132
136
|
- 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`.
|
|
133
137
|
- If `REMEDIATION_COMPLETE_N` is at least 1 (a trusted comment that starts with `<!-- FORGE:REMEDIATION -->` and contains `<!-- FORGE:REMEDIATION:COMPLETE -->`) → EXIT `REMEDIATE_RESULT: status: ALREADY_DONE`. **Single-attempt semantics (AC5)**: once a genuine `FORGE:REMEDIATION:COMPLETE` trail comment exists for this PR, do NOT re-attempt fixes on a subsequent invocation, regardless of the prior verdict — this is what prevents an infinite remediation retry loop on a genuinely-blocked PR. A comment that only quotes the marker mid-body (review findings, an `INPR_FIX` work order, a disposition record) or comes from an untrusted author never counts.
|
|
138
|
+
- **Merged-PR routing (forge#3685)**: if `PR_ALREADY_MERGED=true` and `REMEDIATION_COMPLETE_N` is 0 (the fail-closed unreadable-trail exit above already ran, so the count is known): if the linked issue is already `CLOSED` with the `workflow:merged` label (`ISSUE_STATE`), EXIT `REMEDIATE_RESULT: status: ALREADY_DONE` (landed and finished, nothing to do). Otherwise (not yet `workflow:merged`/CLOSED, no completed trail) set `RESUME_AT_M7=true` and `PR_ALREADY_MERGED=true`: skip Phases M1-M6 and continue at Phase M7, whose merged-first branch is exclusive, runs the idempotent `merged` label bookkeeping, sets `AUTO-LANDED` and goes to M8, which posts the trail and drives close. No park evidence is required. A `FORGE:REMEDIATION_HELD` record never counts toward `REMEDIATION_COMPLETE_N`.
|
|
134
139
|
- If `REMEDIATION_COMPLETE_N` is 0 and `REMEDIATION_TRAIL_N` is at least 1 → a prior attempt was interrupted mid-flight (same failure mode as the investigation phase's partial-comment case). Delete the partial comment(s) on both the PR and the issue (ids from `PARTIAL_PR_COMMENT_IDS` above and the same anchored filter over the issue's comments), then continue below as a fresh attempt:
|
|
135
140
|
```bash
|
|
136
141
|
for _id in $PARTIAL_PR_COMMENT_IDS; do gh api repos/{GH_REPO}/issues/comments/$_id -X DELETE 2>/dev/null || true; done
|
|
@@ -138,11 +143,49 @@ Completed trails of other kinds are left in place (never deleted); only the inte
|
|
|
138
143
|
for _id in $ISSUE_PARTIAL_IDS; do gh api repos/{GH_REPO}/issues/comments/$_id -X DELETE 2>/dev/null || true; done
|
|
139
144
|
```
|
|
140
145
|
- If no comment is found → fresh attempt, continue below.
|
|
146
|
+
- If `PR_ALREADY_MERGED` is not `true`, the issue is at `workflow:awaiting-merge` (and neither `needs-human` nor `workflow:remediating`) and `REMEDIATION_COMPLETE_N` is 0 → require engine-park evidence (see Resume-at-M7 above); only then `RESUME_AT_M7=true`: skip to Phase M7. Any other label combination keeps the normal flow.
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# forge#3684: admit the resume only for a PR the engine parked for the M7 bar. Fail closed.
|
|
150
|
+
PARK_BASE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
151
|
+
PARK_PR_MARK="PR #{PR_NUMBER} was previously escalated" # fixed string: PR #1 never matches PR #12
|
|
152
|
+
PARK_LAST=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null | jq -rs '[.[][] | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | select((.body // "") | test("for the remediation auto-land bar|for a human merge decision"))] | last | .body // ""' 2>/dev/null || echo "")
|
|
153
|
+
# forge#3753: trusted resolver only (GitHub default_branch + committed default-branch config); never forge.yaml from $PWD (the PR worktree is author-controlled). Install roots only; unresolved script => strict.
|
|
154
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
155
|
+
FORGE_ROOT=""
|
|
156
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
157
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
158
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
159
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
160
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
161
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
162
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
163
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
164
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
165
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
166
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
167
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
168
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
169
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
170
|
+
while IFS= read -r _c; do
|
|
171
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
172
|
+
done <<< "$_k"
|
|
173
|
+
fi
|
|
174
|
+
GATE_SCRIPT=""; [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/resolve-deploy-gate.sh" ] && GATE_SCRIPT="$FORGE_ROOT/scripts/resolve-deploy-gate.sh"
|
|
175
|
+
if [ -n "$GATE_SCRIPT" ]; then PARK_GATE=$(bash "$GATE_SCRIPT" "$PARK_BASE" {GH_REPO} 2>/dev/null || echo true); else PARK_GATE=true; fi; [ "$PARK_GATE" = "false" ] || PARK_GATE=true
|
|
176
|
+
if [ "$PARK_GATE" = "false" ] && printf '%s' "$PARK_LAST" | grep -qF 'for the remediation auto-land bar' && printf '%s' "$PARK_LAST" | grep -qF "$PARK_PR_MARK"; then
|
|
177
|
+
RESUME_AT_M7=true
|
|
178
|
+
else
|
|
179
|
+
RESUME_AT_M7=false # BLOCKED, issue left at workflow:awaiting-merge
|
|
180
|
+
fi
|
|
181
|
+
```
|
|
141
182
|
|
|
142
183
|
---
|
|
143
184
|
|
|
144
185
|
## Phase M1: Load Prior Findings & Classify the Block Reason
|
|
145
186
|
|
|
187
|
+
**If `RESUME_AT_M7=true`** (forge#3647; includes the merged-PR route, forge#3685): skip Phases M1-M6 and go straight to Phase M7.
|
|
188
|
+
|
|
146
189
|
Gather everything that caused (or is still causing) `needs-human`:
|
|
147
190
|
|
|
148
191
|
**M1a — Open review-finding issues spawned from this PR** (same title-match precedent as `review-pr.md` Phase 8B/9A):
|
|
@@ -224,36 +267,43 @@ if ! { git diff --quiet && git diff --cached --quiet; }; then
|
|
|
224
267
|
fi
|
|
225
268
|
git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
|
|
226
269
|
# Durable sync state (forge#3517): every block runs in a fresh shell, so never carry rounds or the base head in shell vars.
|
|
227
|
-
# Rounds =
|
|
228
|
-
# a
|
|
229
|
-
# An unreadable count is the cap.
|
|
270
|
+
# Rounds = SYNC merges on the PUSHED branch's first-parent chain: merges not on the base whose second parent is an ancestor of
|
|
271
|
+
# origin/{PR_BASE} (a round counts only once pushed; a manual "Update branch" or feature-branch merge is not a sync and does not
|
|
272
|
+
# burn the cap). Recorded base head = the newest sync merge's second parent. An unreadable count is the cap.
|
|
230
273
|
BASESYNC_MAX_ROUNDS=2 # hard cap on sync rounds per PR (M3 plus M6 re-syncs)
|
|
231
|
-
BASESYNC_ROUNDS
|
|
232
|
-
|
|
233
|
-
|
|
274
|
+
BASESYNC_ROUNDS=0; _p2=""
|
|
275
|
+
if _rl=$(git rev-list --first-parent --merges origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null); then
|
|
276
|
+
for _m in $_rl; do _q=$(git rev-parse -q --verify "$_m^2" 2>/dev/null) && git merge-base --is-ancestor "$_q" origin/{PR_BASE} 2>/dev/null && { BASESYNC_ROUNDS=$((BASESYNC_ROUNDS+1)); [ -n "$_p2" ] || _p2="$_q"; }; done
|
|
277
|
+
else BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS; fi
|
|
234
278
|
BASESYNC_BASE_SHA="$_p2"
|
|
235
279
|
if [ "$BASESYNC_ROUNDS" -ge "$BASESYNC_MAX_ROUNDS" ]; then
|
|
236
280
|
echo "refusing base sync: ${BASESYNC_ROUNDS} sync round(s) already on {HEAD_BRANCH} (cap ${BASESYNC_MAX_ROUNDS})"; BASESYNC_REFUSED=true
|
|
237
281
|
fi
|
|
238
282
|
if [ "${BASESYNC_REFUSED:-false}" != "true" ]; then
|
|
239
|
-
BASESYNC_BASE_SHA=$(git rev-parse origin/{PR_BASE}) # the base head this round merges
|
|
283
|
+
BASESYNC_BASE_SHA=$(git rev-parse origin/{PR_BASE}) # the base head this round merges
|
|
240
284
|
MERGE_OUT=$(git merge origin/{PR_BASE} --no-edit 2>&1); MERGE_RC=$? # allowlist:check-command-side-effects
|
|
241
285
|
echo "$MERGE_OUT"
|
|
242
286
|
# rc alone cannot tell a conflict from a refusal (both exit 1); MERGE_HEAD can.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
287
|
+
# Outcomes are NOT carried to later fences as shell flags: clean = rc 0; conflicts = MERGE_HEAD present (git state).
|
|
288
|
+
if [ "$MERGE_RC" -ne 0 ] && ! git rev-parse -q --verify MERGE_HEAD >/dev/null; then
|
|
289
|
+
# rc!=0 and no MERGE_HEAD: refused, nothing was merged. Park HERE, while MERGE_RC/MERGE_OUT are still in scope.
|
|
290
|
+
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
291
|
+
run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
|
|
292
|
+
Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) was refused by git (rc=$MERGE_RC); no merge was started. Git output:
|
|
293
|
+
|
|
294
|
+
\`\`\`
|
|
295
|
+
${MERGE_OUT}
|
|
296
|
+
\`\`\`" # allowlist:check-command-side-effects
|
|
297
|
+
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
298
|
+
echo "BLOCKED: base-sync: merge refused (rc=$MERGE_RC)"
|
|
249
299
|
fi
|
|
250
300
|
fi
|
|
251
301
|
```
|
|
252
302
|
|
|
253
|
-
Exactly one outcome applies
|
|
303
|
+
Exactly one outcome applies, and it is read from git, never from shell flags (each fence is a fresh shell). A refused merge is never treated as synced.
|
|
254
304
|
|
|
255
305
|
- Clean merge (`MERGE_RC=0`): nothing more to resolve; the merge commit is the sync.
|
|
256
|
-
- Conflicts (`MERGE_RC` nonzero, `MERGE_HEAD` present): read BOTH sides of every conflicted hunk and the surrounding code, then write the combined result by hand. No `-X ours`/`-X theirs`, no wholesale `git checkout --ours/--theirs`, no blanket take-one-side. After resolving, `git add` the files and conclude with `git commit -s --no-edit` (the merge commit)
|
|
306
|
+
- Conflicts (`MERGE_RC` nonzero, `MERGE_HEAD` present): read BOTH sides of every conflicted hunk and the surrounding code, then write the combined result by hand. No `-X ours`/`-X theirs`, no wholesale `git checkout --ours/--theirs`, no blanket take-one-side. After resolving, `git add` the files and conclude with `git commit -s --no-edit` (the merge commit). The merge commit on the branch is the durable record of the sync; Phase M8 derives the trail line from git.
|
|
257
307
|
- **Unresolvable with confidence** (a hunk whose intent on either side you cannot reconcile, or the branch/tree is refused above): capture the file list first, then abort only if a merge is in progress, and park:
|
|
258
308
|
```bash
|
|
259
309
|
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
@@ -269,20 +319,9 @@ Exactly one outcome applies. `BASESYNC_RAN=true` is set only on a clean merge (o
|
|
|
269
319
|
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
270
320
|
```
|
|
271
321
|
EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: unresolvable conflicts". Only that issue is parked.
|
|
272
|
-
- **Refused merge** (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
|
|
276
|
-
Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) was refused by git (rc=${MERGE_RC}); no merge was started. Git output:
|
|
277
|
-
|
|
278
|
-
\`\`\`
|
|
279
|
-
${MERGE_OUT}
|
|
280
|
-
\`\`\`" # allowlist:check-command-side-effects
|
|
281
|
-
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
282
|
-
```
|
|
283
|
-
EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: merge refused (rc=N)" (N = `MERGE_RC`). Never fall through to M4 as synced.
|
|
284
|
-
- **Re-entry (per distinct base head)**: Phase M3's sync is re-enterable from Phase M6, but only when the base advanced past `BASESYNC_BASE_SHA` and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS`. A re-sync round runs this same block on the new head with the same guards (shared-branch refusal, clean tree, merge-only, no force-push, resolve-or-park, `FORGE:BASESYNC_FAILED`). The block derives `BASESYNC_ROUNDS` and `BASESYNC_BASE_SHA` from git on every entry (pushed sync merges on the branch), never from shell state, and refuses at the cap; an unreadable count is the cap. `BASESYNC_RAN=true` is still set only on a clean or concluded merge.
|
|
285
|
-
- After a sync, the quality gate below re-runs on the merged tree, and Phase M6's re-review covers the new head (the reviewed-head guard forces a fresh review).
|
|
322
|
+
- **Refused merge** (the sync fence printed `BLOCKED: base-sync: merge refused (rc=N)`): git declined to start the merge (for example local changes would be overwritten). This is not a conflict and not a sync, and there is nothing to abort. The sync fence has already posted `FORGE:BASESYNC_FAILED` (with the rc and git output) and added `needs-human` from inside that fence, because `MERGE_RC`/`MERGE_OUT` do not survive into a later fence. EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: merge refused (rc=N)" (N from the printed line). Never fall through to M4 as synced.
|
|
323
|
+
- **Re-entry (per distinct base head)**: Phase M3's sync is re-enterable from Phase M6, but only when the base advanced past `BASESYNC_BASE_SHA` and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS`. A re-sync round runs this same block on the new head with the same guards (shared-branch refusal, clean tree, merge-only, no force-push, resolve-or-park, `FORGE:BASESYNC_FAILED`). The block derives `BASESYNC_ROUNDS` and `BASESYNC_BASE_SHA` from git on every entry (pushed sync merges on the branch), never from shell state, and refuses at the cap; an unreadable count is the cap.
|
|
324
|
+
- After a sync, the quality gate below re-runs on the merged tree, and Phase M6's re-review covers the new head (the reviewed-head guard forces a fresh review of that head; `/review-pr` then scopes it: a head that is only a clean base merge is carried forward from the last fully reviewed head without a new panel, and a head with ordinary fix commits is reviewed as a delta).
|
|
286
325
|
|
|
287
326
|
**Quality Gate** (same loop as Phase 3G, max 3 iterations):
|
|
288
327
|
```
|
|
@@ -394,9 +433,10 @@ If no dispatch tool resolves, a missing tool is not a human decision, so do NOT
|
|
|
394
433
|
cd {WORKTREE_PATH}
|
|
395
434
|
git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
|
|
396
435
|
BASESYNC_MAX_ROUNDS=2
|
|
397
|
-
BASESYNC_ROUNDS
|
|
398
|
-
|
|
399
|
-
|
|
436
|
+
BASESYNC_ROUNDS=0; _p2=""
|
|
437
|
+
if _rl=$(git rev-list --first-parent --merges origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null); then
|
|
438
|
+
for _m in $_rl; do _q=$(git rev-parse -q --verify "$_m^2" 2>/dev/null) && git merge-base --is-ancestor "$_q" origin/{PR_BASE} 2>/dev/null && { BASESYNC_ROUNDS=$((BASESYNC_ROUNDS+1)); [ -n "$_p2" ] || _p2="$_q"; }; done
|
|
439
|
+
else BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS; fi
|
|
400
440
|
BASESYNC_BASE_SHA="$_p2"
|
|
401
441
|
NOW_BASE_SHA=$(git rev-parse origin/{PR_BASE})
|
|
402
442
|
FRESH_MERGED=false
|
|
@@ -461,17 +501,18 @@ CUR_BASE_SHA=$(git rev-parse origin/{PR_BASE})
|
|
|
461
501
|
# Recorded head and rounds come from the pushed branch's sync merges (same derivation as M3), never from shell vars or
|
|
462
502
|
# the M8 trail line, which is written after this phase (forge#3517). No recorded head is treated as advanced, but only below the cap.
|
|
463
503
|
BASESYNC_MAX_ROUNDS=2
|
|
464
|
-
BASESYNC_ROUNDS
|
|
465
|
-
|
|
466
|
-
|
|
504
|
+
BASESYNC_ROUNDS=0; _p2=""
|
|
505
|
+
if _rl=$(git rev-list --first-parent --merges origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null); then
|
|
506
|
+
for _m in $_rl; do _q=$(git rev-parse -q --verify "$_m^2" 2>/dev/null) && git merge-base --is-ancestor "$_q" origin/{PR_BASE} 2>/dev/null && { BASESYNC_ROUNDS=$((BASESYNC_ROUNDS+1)); [ -n "$_p2" ] || _p2="$_q"; }; done
|
|
507
|
+
else BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS; fi
|
|
467
508
|
BASESYNC_BASE_SHA="$_p2"
|
|
468
509
|
echo "recorded=${BASESYNC_BASE_SHA:-none} current=${CUR_BASE_SHA} rounds=${BASESYNC_ROUNDS}/${BASESYNC_MAX_ROUNDS}"
|
|
469
510
|
```
|
|
470
511
|
|
|
471
|
-
- **Base advanced** (`CUR_BASE_SHA` differs from the recorded `BASESYNC_BASE_SHA`) and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS` (2 pushed sync merges on the branch): this is a new conflict a human did not cause. Re-run the Phase M3 base-sync on the new head (same resolve-or-park rules), then the Phase M4 quality gate, ancestry guard and push, the M5 trail, and this Phase M6 once more. The pushed merge raises the derived count and moves the recorded head, so nothing is carried in shell state. Do not post a second `FORGE:REMEDIATION:COMPLETE`; only M8 does, once.
|
|
512
|
+
- **Base advanced** (`CUR_BASE_SHA` differs from the recorded `BASESYNC_BASE_SHA`) and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS` (2 pushed sync merges on the branch; only merges whose second parent is a base ancestor count): this is a new conflict a human did not cause. Re-run the Phase M3 base-sync on the new head (same resolve-or-park rules), then the Phase M4 quality gate, ancestry guard and push, the M5 trail, and this Phase M6 once more. The pushed merge raises the derived count and moves the recorded head, so nothing is carried in shell state. Do not post a second `FORGE:REMEDIATION:COMPLETE`; only M8 does, once.
|
|
472
513
|
- **Base unchanged** (the sync did not clear the conflict) or `BASESYNC_ROUNDS` has reached `BASESYNC_MAX_ROUNDS` (a base that never settles): park. Add `needs-human`, post a comment naming the PR, the base SHA(s) tried (recorded and current) and the conflicting files (`git diff --name-only --diff-filter=U` against a trial merge, or the GitHub mergeability report), and exit `REMEDIATE_RESULT: status: BLOCKED` with blocker "base conflict persists after base-sync remediation".
|
|
473
514
|
|
|
474
|
-
This re-
|
|
515
|
+
This re-reviews the new head (full, delta-scoped or carried forward, as `/review-pr` decides; then verdict → Phase 8 auto-merge gate). The FIXABLE transition above left the issue at the non-terminal `workflow:in-review` state. `review-pr.md` recognizes the in-progress `FORGE:REMEDIATION` marker posted in Phase M5 as evidence of the prior escalation, so one of two things happens inside Phase 8:
|
|
475
516
|
|
|
476
517
|
- **Re-escalated**: the re-review itself trips a fresh block (`CHANGES REQUESTED`, purpose-regression, calibration, trust, or a still-`CONFLICTING` mergeability check) → it adds `needs-human`, and this phase removes `workflow:in-review`, leaving one terminal state.
|
|
477
518
|
- **Clean re-review**: `VERDICT=APPROVED`-equivalent, mergeable, and the "Previously-escalated re-review guard" (forge#1810) fires — setting `workflow:awaiting-merge` and removing `workflow:in-review`, *without* auto-merging (that guard's own safe default, left untouched by this file).
|
|
@@ -493,6 +534,120 @@ RE_REVIEW_VERDICT=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
|
|
|
493
534
|
|
|
494
535
|
## Phase M7: Compute the #1809 Q1 Auto-Land Bar
|
|
495
536
|
|
|
537
|
+
**Merged-first check (forge#3647, exclusive branch forge#3685)**: before anything else, re-read the live PR. If it is already merged (a prior run merged it, then was cut off before M8, or Phase M0 routed a merged PR here), this is an **exclusive** branch: do NOT report HELD and do NOT fall through. Run the idempotent post-merge label bookkeeping, set `RE_GATE_OUTCOME="AUTO-LANDED"`, **skip every later M7 block** (the live-base and resume-approval derivation, the `REREVIEW-REQUIRED`, `needs-human` and `workflow:awaiting-merge` blocks, the bar evaluation and the CI-gated merge), and go straight to Phase M8. A merged PR is never RE-ESCALATED or HELD, whatever labels or approval evidence it carries (after a merge the head has moved, so `RESUME_APPROVED_N=0` is expected). Every later `RE_GATE_OUTCOME=` assignment in this phase is guarded by "only if not already `AUTO-LANDED`".
|
|
538
|
+
|
|
539
|
+
```bash
|
|
540
|
+
PR_LIVE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state,mergedAt,headRefOid 2>/dev/null || echo "")
|
|
541
|
+
if [ "$(printf '%s' "$PR_LIVE" | jq -r '.state // ""' 2>/dev/null)" = "MERGED" ] || [ -n "$(printf '%s' "$PR_LIVE" | jq -r '.mergedAt // empty' 2>/dev/null)" ]; then
|
|
542
|
+
RE_GATE_OUTCOME="AUTO-LANDED"
|
|
543
|
+
# Idempotent post-merge bookkeeping (same case as the merge branch below; harmless when already applied).
|
|
544
|
+
RESOLUTION=$(resolve_script 'transition-label')
|
|
545
|
+
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
|
|
546
|
+
case "$TIER" in
|
|
547
|
+
adaptive|universal) bash "$SCRIPT_PATH" {ISSUE_NUMBER} {GH_FLAG} merged ;;
|
|
548
|
+
prose)
|
|
549
|
+
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
|
|
550
|
+
;;
|
|
551
|
+
esac
|
|
552
|
+
# PR_ALREADY_MERGED=true: SKIP the rest of Phase M7 (needs-human, awaiting-merge and bar blocks) and go to Phase M8.
|
|
553
|
+
else
|
|
554
|
+
# forge#3686: capture the approved head ONCE, here, for the normal (non-resume) path. Never re-read it later: a re-read after the push window would absorb the very push the merge guard must catch. Persisted because every fence is a fresh shell.
|
|
555
|
+
_SD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"; mkdir -p "$_SD" 2>/dev/null || true
|
|
556
|
+
APPROVED_HEAD=$(printf '%s' "$PR_LIVE" | jq -r '.headRefOid // ""' 2>/dev/null)
|
|
557
|
+
case "$APPROVED_HEAD" in *[!0-9a-f]*|"") APPROVED_HEAD="" ;; esac
|
|
558
|
+
[ "${#APPROVED_HEAD}" -eq 40 ] || APPROVED_HEAD=""
|
|
559
|
+
printf '%s\n' "$APPROVED_HEAD" > "$_SD/remediate_approved_head_{PR_NUMBER}" 2>/dev/null || true
|
|
560
|
+
fi
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**If `RE_GATE_OUTCOME` is already `AUTO-LANDED` after the check above, skip the remainder of Phase M7 and go to Phase M8.** Everything below applies only to a PR that is still open.
|
|
564
|
+
|
|
565
|
+
**Approved head on every path (forge#3686)**: for a PR that is still open, the fence above captures `APPROVED_HEAD` (the live `headRefOid` that M6's re-review parked) once at M7 start and persists it to `${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/remediate_approved_head_{PR_NUMBER}`; the resume path overwrites it with `RESUME_APPROVED_HEAD`. The CI-gated merge reloads it (never re-reads the PR) and refuses with `CI_GATE_RC=4` when it is empty or differs from `GATED_HEAD`, so a commit pushed after approval cannot merge. Residual window: a push between M6's verdict and the M7 capture is treated as approved.
|
|
566
|
+
|
|
567
|
+
**Live base (computed BEFORE the resume substitution, forge#3684)**: re-derive the base fresh — do NOT reuse `$PR_BASE` for this decision (forge#2624): `$PR_BASE` was resolved in Phase M0 and a caller-supplied `--base` wins over the PR's actual live base. This is a security-relevant decision point, so re-query the PR's live base independent of `$PR_BASE`. Fail closed: only a KNOWN, non-empty, non-`null` live base that is not the deploy gate (literal `main`, the GitHub `default_branch`, or the default branch's committed `branches.default`; never read from the PR worktree, forge#3753) reconciles to the fast lane (forge#2570); empty, the literal string `null` (jq stringifies JSON null, forge#2625) or the default branch is the deploy gate (strict).
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
571
|
+
# forge#3753: trusted resolver only (GitHub default_branch + committed default-branch config); never forge.yaml from $PWD (the PR worktree is author-controlled). Install roots only; unresolved script => strict.
|
|
572
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
573
|
+
FORGE_ROOT=""
|
|
574
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
575
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
576
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
577
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
578
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
579
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
580
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
581
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
582
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
583
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
584
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
585
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
586
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
587
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
588
|
+
while IFS= read -r _c; do
|
|
589
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
590
|
+
done <<< "$_k"
|
|
591
|
+
fi
|
|
592
|
+
GATE_SCRIPT=""; [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/resolve-deploy-gate.sh" ] && GATE_SCRIPT="$FORGE_ROOT/scripts/resolve-deploy-gate.sh"
|
|
593
|
+
if [ -n "$GATE_SCRIPT" ]; then IS_DEPLOY_GATE=$(bash "$GATE_SCRIPT" "$LIVE_BASE_REF" {GH_REPO} 2>/dev/null || echo true); else IS_DEPLOY_GATE=true; fi; [ "${IS_DEPLOY_GATE:-true}" = "false" ] || IS_DEPLOY_GATE=true
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Resume path (`RESUME_AT_M7=true`)**: no M3 quality gate ran in this invocation, so `GATE_PASSED` is not set. Substitute fresh evidence that the PR is approved at its CURRENT head: a trusted (Bot or OWNER/MEMBER/COLLABORATOR) verdict whose body starts with `APPROVED: commit <sha>` (anchored, case-sensitive, so `NOT APPROVED:` and quoted text never match), where `<sha>` is a 7-40 hex prefix of the live `headRefOid` (or the body starts with `APPROVED:` and carries a `Reviewed-SHA: <headRefOid>` line; a `Reviewed-SHA:` line without the `APPROVED:` prefix never counts, forge#3738). The binding is to the commit SHA, never to a timestamp: commit dates are author-controlled. Both the PR reviews (where `review-pr.md` posts the verdict via `gh pr review --comment`; a review's `commit_id` must also equal the head) and the PR issue-comments are read. An empty `headRefOid`, unreadable endpoint, or no SHA-matching verdict means `GATE_PASSED=false` (hold). The CI-gated merge below additionally refuses when `GATED_HEAD` differs from the approved SHA (`APPROVED_HEAD`, which on this path is `RESUME_APPROVED_HEAD`). The substitution applies to non-default-branch bases only: when `RESUME_AT_M7=true` and `IS_DEPLOY_GATE=true`, `GATE_PASSED=false` is forced and the bar below holds (`BAR_MET=false`) in code, so a default-branch (deploy-gate) base never lands from the resume path. CI is still verified by the mandatory CI gate below.
|
|
597
|
+
|
|
598
|
+
```bash
|
|
599
|
+
# Fresh shell per Bash call: re-derive the live base here (same derivation as above) and re-fetch PR_LIVE if empty. Unresolved => strict.
|
|
600
|
+
LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
601
|
+
# forge#3753: trusted resolver only (GitHub default_branch + committed default-branch config); never forge.yaml from $PWD (the PR worktree is author-controlled). Install roots only; unresolved script => strict.
|
|
602
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
603
|
+
FORGE_ROOT=""
|
|
604
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
605
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
606
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
607
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
608
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
609
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
610
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
611
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
612
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
613
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
614
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
615
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
616
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
617
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
618
|
+
while IFS= read -r _c; do
|
|
619
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
620
|
+
done <<< "$_k"
|
|
621
|
+
fi
|
|
622
|
+
GATE_SCRIPT=""; [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/resolve-deploy-gate.sh" ] && GATE_SCRIPT="$FORGE_ROOT/scripts/resolve-deploy-gate.sh"
|
|
623
|
+
if [ -n "$GATE_SCRIPT" ]; then IS_DEPLOY_GATE=$(bash "$GATE_SCRIPT" "$LIVE_BASE_REF" {GH_REPO} 2>/dev/null || echo true); else IS_DEPLOY_GATE=true; fi; [ "${IS_DEPLOY_GATE:-true}" = "false" ] || IS_DEPLOY_GATE=true
|
|
624
|
+
[ -n "${PR_LIVE:-}" ] || PR_LIVE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state,mergedAt,headRefOid 2>/dev/null || echo "")
|
|
625
|
+
if [ "${RESUME_AT_M7:-false}" = "true" ]; then
|
|
626
|
+
HEAD_OID=$(printf '%s' "$PR_LIVE" | jq -r '.headRefOid // ""' 2>/dev/null)
|
|
627
|
+
# forge#3726: bind approval to the head SHA (never to author-controlled committedDate). Fail closed.
|
|
628
|
+
RESUME_APPROVED_HEAD=""
|
|
629
|
+
RESUME_APPROVED_N=0
|
|
630
|
+
if [ -n "$HEAD_OID" ]; then
|
|
631
|
+
# Shared filter: trusted author AND (anchored "APPROVED: commit <7-40 hex>" that prefixes the head OR a body that STARTS with "APPROVED:" and carries a "Reviewed-SHA: <head>" line; a Reviewed-SHA line alone is never approval, every reviewer-agent comment carries one, forge#3738).
|
|
632
|
+
# Values come in via --arg only; no regex is built from untrusted text. A review with a commit_id must be at the head.
|
|
633
|
+
_JQ='def sha: ((.body // "") | ((capture("^APPROVED: commit (?<s>[0-9a-f]{7,40})(\\s|$)") | .s)? // "")) as $s | ($s != "" and ($head | startswith($s)));
|
|
634
|
+
def line: ((.body // "") | startswith("APPROVED:")) and (((.body // "") | split("\n") | index("Reviewed-SHA: " + $head)) != null);
|
|
635
|
+
[.[][] | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")))
|
|
636
|
+
| select((.commit_id // $head) == $head) | select(sha or line)] | length'
|
|
637
|
+
_REV=$(gh api --paginate repos/{GH_REPO}/pulls/{PR_NUMBER}/reviews 2>/dev/null | jq -rs --arg head "$HEAD_OID" "$_JQ" 2>/dev/null || echo 0)
|
|
638
|
+
_CMT=$(gh api --paginate repos/{GH_REPO}/issues/{PR_NUMBER}/comments 2>/dev/null | jq -rs --arg head "$HEAD_OID" "$_JQ" 2>/dev/null || echo 0)
|
|
639
|
+
RESUME_APPROVED_N=$(( ${_REV:-0} + ${_CMT:-0} )) 2>/dev/null || RESUME_APPROVED_N=0
|
|
640
|
+
[ "${RESUME_APPROVED_N:-0}" -ge 1 ] && RESUME_APPROVED_HEAD="$HEAD_OID"
|
|
641
|
+
fi
|
|
642
|
+
if [ "${IS_DEPLOY_GATE:-true}" = "true" ]; then GATE_PASSED=false # default-branch / empty / null base: resume never lands
|
|
643
|
+
elif [ "${RESUME_APPROVED_N:-0}" -ge 1 ]; then GATE_PASSED=true
|
|
644
|
+
else GATE_PASSED=false; fi
|
|
645
|
+
# forge#3686: on resume the approved head is the SHA the approval evidence was bound to (overrides the M7-start capture).
|
|
646
|
+
_SD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"; mkdir -p "$_SD" 2>/dev/null || true
|
|
647
|
+
printf '%s\n' "${RESUME_APPROVED_HEAD:-}" > "$_SD/remediate_approved_head_{PR_NUMBER}" 2>/dev/null || true
|
|
648
|
+
fi
|
|
649
|
+
```
|
|
650
|
+
|
|
496
651
|
Re-read the issue's current labels after M6:
|
|
497
652
|
```bash
|
|
498
653
|
POST_REVIEW_LABELS=$(gh issue view {ISSUE_NUMBER} {GH_FLAG} --json labels --jq '[.labels[].name] | join(",")')
|
|
@@ -500,7 +655,7 @@ POST_REVIEW_LABELS=$(gh issue view {ISSUE_NUMBER} {GH_FLAG} --json labels --jq '
|
|
|
500
655
|
|
|
501
656
|
**If `RE_GATE_OUTCOME="REREVIEW-REQUIRED"`** (forge#3240, set in M6): the bar does not apply — no review ran. Skip to Phase M8.
|
|
502
657
|
|
|
503
|
-
**If `needs-human` is present** (re-escalated case): the bar does not apply — nothing to compute. `RE_GATE_OUTCOME="RE-ESCALATED"
|
|
658
|
+
**If `needs-human` is present** (re-escalated case): the bar does not apply — nothing to compute. `RE_GATE_OUTCOME="RE-ESCALATED"` (only if not already `AUTO-LANDED`). Skip to Phase M8.
|
|
504
659
|
|
|
505
660
|
**If `workflow:awaiting-merge` is present** (clean re-review case — the only branch where `review-pr.md`'s guard has already safely parked this PR): compute the bar.
|
|
506
661
|
|
|
@@ -518,44 +673,52 @@ REVIEW_BODIES=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
|
|
|
518
673
|
APPROVED_COUNT=$(echo "$REVIEW_BODIES" | grep -cE 'APPROVED:' 2>/dev/null || true); APPROVED_COUNT=${APPROVED_COUNT:-0}
|
|
519
674
|
```
|
|
520
675
|
|
|
521
|
-
**Auto-land bar — base-branch scoped** (forge#2570): the condition the PR must clear to auto-land depends on its target branch. This reconciles the remediation bar with the normal `/work-on` fast-lane merge bar for the *same target branch*, while keeping the strict human-verified bar only where a human is genuinely in the loop (the `staging →
|
|
522
|
-
|
|
523
|
-
**Re-derive the base fresh — do NOT reuse `$PR_BASE` for this decision** (forge#2624): `$PR_BASE` was resolved in Phase M0 as `PR_BASE="${PR_BASE:-$(... .baseRefName)}"` — a caller-supplied `--base` wins over the PR's actual live base whenever non-empty. That is fine for M0's own purposes (worktree base, display text), but this is a security-relevant decision point: a wrong/stale `--base staging` on a PR that actually targets `main` must never be allowed to relax the strict deploy-gate bar. Mirror `review-pr.md`'s sibling guard (`GUARD_BASE`, which always re-fetches `baseRefName` fresh at its own decision point) by re-querying the PR's live base here, independent of whatever `$PR_BASE` currently holds:
|
|
524
|
-
|
|
525
|
-
```bash
|
|
526
|
-
# forge#2624: re-fetch baseRefName fresh from the PR itself — never trust the
|
|
527
|
-
# M0-resolved $PR_BASE for this decision, since M0 prefers a caller-supplied
|
|
528
|
-
# --base over the live value. This is the one line in this phase that makes
|
|
529
|
-
# a trust/security decision, so it must be immune to a caller-overridable input.
|
|
530
|
-
LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
676
|
+
**Auto-land bar — base-branch scoped** (forge#2570): the condition the PR must clear to auto-land depends on its target branch. This reconciles the remediation bar with the normal `/work-on` fast-lane merge bar for the *same target branch*, while keeping the strict human-verified bar only where a human is genuinely in the loop (the `staging → default branch` deploy gate).
|
|
531
677
|
|
|
532
|
-
|
|
533
|
-
# every other base (staging, milestone/* — the reversible integration branches) reconciles to
|
|
534
|
-
# the fast-lane bar. Key on "is the deploy gate", NOT the literal string "staging", so milestone
|
|
535
|
-
# branches reconcile too. Fail closed: only a KNOWN, non-empty, non-`main` LIVE base reconciles
|
|
536
|
-
# to the fast lane; an empty/unresolved fetch is treated as the deploy gate (strict) so a base-
|
|
537
|
-
# resolution failure (or a caller passing a stale/incorrect --base) can never accidentally
|
|
538
|
-
# relax the bar.
|
|
539
|
-
# forge#2625: `jq -r '.baseRefName'` stringifies a JSON null to the literal text "null" (not an
|
|
540
|
-
# empty string), so the `-n` check alone does not catch it — add an explicit `!= "null"` check
|
|
541
|
-
# so a literal-string "null" is treated the same as an empty/unresolved base (strict).
|
|
542
|
-
if [ -n "$LIVE_BASE_REF" ] && [ "$LIVE_BASE_REF" != "null" ] && [ "$LIVE_BASE_REF" != "main" ]; then IS_DEPLOY_GATE=false; else IS_DEPLOY_GATE=true; fi
|
|
543
|
-
```
|
|
678
|
+
(`LIVE_BASE_REF` / `IS_DEPLOY_GATE` are first computed above, before the resume substitution. Every Bash call is a fresh shell, so the resume fence and the bar-evaluation fence re-derive them idempotently with the identical derivation, and every read uses `${IS_DEPLOY_GATE:-true}` so an unset value is strict. forge#2570 / #2624 / #2625 rationale applies unchanged.)
|
|
544
679
|
|
|
545
|
-
**Non
|
|
680
|
+
**Non-default-branch base (`IS_DEPLOY_GATE=false` — staging / milestone)** — reconcile to the fast-lane bar. `review-pr.md`'s Phase 8 guard only parks a PR at `workflow:awaiting-merge` after a clean, mergeable `APPROVED` re-review (the same bot-`APPROVED` signal the normal fast lane auto-merges on), so the only additional condition is this remediation's own quality gate:
|
|
546
681
|
1. `GATE_PASSED = true` from this remediation's Phase M3 quality-gate loop.
|
|
547
682
|
|
|
548
683
|
The strict `APPROVED_COUNT >= 2` verified-human requirement does NOT apply here: it is structurally unsatisfiable for bot-only pipeline review (bot reviews are `authorAssociation=NONE`), and `staging` is reversible — the real human gate is `staging → main`, which no agent performs. This makes the remediation bar identical to the normal fast-lane bar for the same target branch (the issue's core ask). The `authorAssociation` trust filter above is **unchanged** — the relaxation is scoped by *target branch only*, never by *who* may approve (forge#1976/#2519).
|
|
549
684
|
|
|
550
|
-
|
|
685
|
+
**Default-branch base (`IS_DEPLOY_GATE=true` — deploy gate)** — keep the strict #1809 Q1 bar, BOTH conditions required:
|
|
551
686
|
1. `APPROVED_COUNT >= 2` — at least two distinct adversarial `APPROVED:` review comments from repo collaborators (`OWNER`/`MEMBER`/`COLLABORATOR` authorAssociation only — see trust filter above; same counting convention as `work-on/close.md` C4).
|
|
552
687
|
2. `GATE_PASSED = true` from this remediation's own Phase M3 quality-gate loop.
|
|
553
688
|
|
|
689
|
+
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 -->
|
|
690
|
+
|
|
554
691
|
**Evaluate the base-scoped bar**:
|
|
555
692
|
```bash
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
693
|
+
# Fresh shell per Bash call: re-derive the live base in this fence (same derivation as above); read with the :-true fail-closed default.
|
|
694
|
+
LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
695
|
+
# forge#3753: trusted resolver only (GitHub default_branch + committed default-branch config); never forge.yaml from $PWD (the PR worktree is author-controlled). Install roots only; unresolved script => strict.
|
|
696
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
697
|
+
FORGE_ROOT=""
|
|
698
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
699
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
700
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
701
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
702
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
703
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
704
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
705
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
706
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
707
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
708
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
709
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
710
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
711
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
712
|
+
while IFS= read -r _c; do
|
|
713
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
714
|
+
done <<< "$_k"
|
|
715
|
+
fi
|
|
716
|
+
GATE_SCRIPT=""; [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/resolve-deploy-gate.sh" ] && GATE_SCRIPT="$FORGE_ROOT/scripts/resolve-deploy-gate.sh"
|
|
717
|
+
if [ -n "$GATE_SCRIPT" ]; then IS_DEPLOY_GATE=$(bash "$GATE_SCRIPT" "$LIVE_BASE_REF" {GH_REPO} 2>/dev/null || echo true); else IS_DEPLOY_GATE=true; fi; [ "${IS_DEPLOY_GATE:-true}" = "false" ] || IS_DEPLOY_GATE=true
|
|
718
|
+
if [ "${IS_DEPLOY_GATE:-true}" = "true" ]; then
|
|
719
|
+
# default branch / deploy gate — strict verified-human bar; a resume (no in-run M3 gate) never lands here (forge#3684)
|
|
720
|
+
if [ "${RESUME_AT_M7:-false}" = "true" ]; then BAR_MET=false
|
|
721
|
+
else { [ "${APPROVED_COUNT:-0}" -ge 2 ] && [ "${GATE_PASSED:-false}" = "true" ]; } && BAR_MET=true || BAR_MET=false; fi
|
|
559
722
|
else
|
|
560
723
|
# staging / milestone — fast-lane bar (bot APPROVED already implied by workflow:awaiting-merge)
|
|
561
724
|
[ "${GATE_PASSED:-false}" = "true" ] && BAR_MET=true || BAR_MET=false
|
|
@@ -576,6 +739,15 @@ if [ -n "$CI_GATE_SCRIPT" ]; then CI_GATE_OUT=$(bash "$CI_GATE_SCRIPT" {PR_NUMBE
|
|
|
576
739
|
else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
|
|
577
740
|
echo "$CI_GATE_OUT"
|
|
578
741
|
GATED_HEAD=$(printf '%s\n' "$CI_GATE_OUT" | sed -n 's/^CI_GATE_HEAD: //p' | head -1)
|
|
742
|
+
# 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.
|
|
743
|
+
_SD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"
|
|
744
|
+
APPROVED_HEAD=$(head -n 1 "$_SD/remediate_approved_head_{PR_NUMBER}" 2>/dev/null || true)
|
|
745
|
+
case "$APPROVED_HEAD" in *[!0-9a-f]*) APPROVED_HEAD="" ;; esac
|
|
746
|
+
[ "${#APPROVED_HEAD}" -eq 40 ] || APPROVED_HEAD=""
|
|
747
|
+
if [ "$CI_GATE_RC" -eq 0 ] && { [ -z "${APPROVED_HEAD:-}" ] || [ "$GATED_HEAD" != "$APPROVED_HEAD" ]; }; then
|
|
748
|
+
CI_GATE_OUT="${CI_GATE_OUT}
|
|
749
|
+
CI_GATE: head ${GATED_HEAD:-unknown} is not the approved head ${APPROVED_HEAD:-none} (fail closed)"; CI_GATE_RC=4
|
|
750
|
+
fi
|
|
579
751
|
# rc 3 = CI still running: re-run this block (up to 3 more times) before treating it as a failure.
|
|
580
752
|
if [ "$CI_GATE_RC" -ne 0 ]; then
|
|
581
753
|
# Not green: do not land. Re-escalate with the gate output (counts as RE-ESCALATED below).
|
|
@@ -595,34 +767,58 @@ if [ "$MERGE_STATE" = "MERGED" ]; then
|
|
|
595
767
|
case "$TIER" in
|
|
596
768
|
adaptive|universal) bash "$SCRIPT_PATH" {ISSUE_NUMBER} {GH_FLAG} merged ;;
|
|
597
769
|
prose)
|
|
598
|
-
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:merged"
|
|
599
|
-
--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
|
|
770
|
+
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
|
|
600
771
|
;;
|
|
601
772
|
esac
|
|
602
773
|
RE_GATE_OUTCOME="AUTO-LANDED"
|
|
603
774
|
else
|
|
604
|
-
RE_GATE_OUTCOME="HELD-AWAITING-MERGE"
|
|
775
|
+
[ "${RE_GATE_OUTCOME:-}" = "AUTO-LANDED" ] || RE_GATE_OUTCOME="HELD-AWAITING-MERGE"
|
|
605
776
|
# gh pr merge reported success but the PR isn't actually MERGED — leave workflow:awaiting-merge
|
|
606
777
|
# in place (unchanged) and let a human merge manually rather than retrying automatically.
|
|
607
778
|
fi
|
|
608
779
|
```
|
|
609
780
|
|
|
610
|
-
**If the bar is NOT met** (`BAR_MET=false`): leave the issue at `workflow:awaiting-merge` exactly as `review-pr.md`'s guard set it — do NOT attempt a merge. `RE_GATE_OUTCOME="HELD-AWAITING-MERGE"
|
|
781
|
+
**If the bar is NOT met** (`BAR_MET=false`): leave the issue at `workflow:awaiting-merge` exactly as `review-pr.md`'s guard set it — do NOT attempt a merge. `RE_GATE_OUTCOME="HELD-AWAITING-MERGE"` (only if not already `AUTO-LANDED`). Fail-safe direction: any doubt about the bar (including an unresolved or unexpected `LIVE_BASE_REF`, which leaves `IS_DEPLOY_GATE=true`-equivalent strict handling) defaults to holding, matching `review-pr.md`'s own existing default for every other caller.
|
|
611
782
|
|
|
612
783
|
---
|
|
613
784
|
|
|
614
785
|
## Phase M8: Finalize FORGE:REMEDIATION Paper Trail
|
|
615
786
|
|
|
616
|
-
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:
|
|
787
|
+
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:
|
|
617
788
|
|
|
618
789
|
```bash
|
|
790
|
+
# Fresh shell per Bash call: re-derive the live base in this fence (same derivation as above); read with the :-true fail-closed default.
|
|
791
|
+
LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
|
|
792
|
+
# forge#3753: trusted resolver only (GitHub default_branch + committed default-branch config); never forge.yaml from $PWD (the PR worktree is author-controlled). Install roots only; unresolved script => strict.
|
|
793
|
+
# FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
|
|
794
|
+
FORGE_ROOT=""
|
|
795
|
+
# Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
|
|
796
|
+
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
797
|
+
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
798
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
799
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
800
|
+
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
801
|
+
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
802
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
803
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
804
|
+
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
805
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
806
|
+
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
807
|
+
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
808
|
+
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
809
|
+
while IFS= read -r _c; do
|
|
810
|
+
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
811
|
+
done <<< "$_k"
|
|
812
|
+
fi
|
|
813
|
+
GATE_SCRIPT=""; [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/resolve-deploy-gate.sh" ] && GATE_SCRIPT="$FORGE_ROOT/scripts/resolve-deploy-gate.sh"
|
|
814
|
+
if [ -n "$GATE_SCRIPT" ]; then IS_DEPLOY_GATE=$(bash "$GATE_SCRIPT" "$LIVE_BASE_REF" {GH_REPO} 2>/dev/null || echo true); else IS_DEPLOY_GATE=true; fi; [ "${IS_DEPLOY_GATE:-true}" = "false" ] || IS_DEPLOY_GATE=true
|
|
619
815
|
case "$RE_GATE_OUTCOME" in
|
|
620
816
|
AUTO-LANDED)
|
|
621
|
-
# forge#2570: the bar that was met differs by target branch. Non
|
|
817
|
+
# forge#2570: the bar that was met differs by target branch. Non-default-branch (staging/milestone)
|
|
622
818
|
# lands on the fast-lane bar (clean re-review APPROVED + quality-gate pass), identical to the
|
|
623
|
-
# normal /work-on path;
|
|
819
|
+
# normal /work-on path; default-branch/deploy-gate lands only on the strict ≥2 verified-human bar.
|
|
624
820
|
if [ "${IS_DEPLOY_GATE:-true}" = "false" ]; then
|
|
625
|
-
AUTO_LAND_BAR_TEXT="MET — fast-lane bar for non
|
|
821
|
+
AUTO_LAND_BAR_TEXT="MET — fast-lane bar for non-default-branch base (clean re-review APPROVED + quality gate pass), matching the normal /work-on merge bar for the same target branch"
|
|
626
822
|
else
|
|
627
823
|
AUTO_LAND_BAR_TEXT="MET (${APPROVED_COUNT:-0} APPROVED: reviews + quality gate pass)"
|
|
628
824
|
fi
|
|
@@ -637,14 +833,58 @@ esac
|
|
|
637
833
|
# forge#3413: embed the pushed head so the orchestrator scopes a REREVIEW-REQUIRED trail to the CURRENT head only.
|
|
638
834
|
REMEDIATED_HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || true)
|
|
639
835
|
|
|
640
|
-
# forge#3496: mark a base-sync round so Phase M0 scopes the single-attempt guard per kind.
|
|
641
|
-
#
|
|
836
|
+
# forge#3496 / forge#3518: mark a base-sync round so Phase M0 scopes the single-attempt guard per kind. This block runs in a fresh
|
|
837
|
+
# shell, so it derives the record from durable state (pushed git history + trusted bound markers), never from a shell variable.
|
|
838
|
+
# 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.
|
|
839
|
+
_l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
|
|
840
|
+
_tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
|
|
841
|
+
for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
|
|
842
|
+
[ -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)"
|
|
843
|
+
done
|
|
844
|
+
TRUSTED_SCRIPT=""
|
|
845
|
+
while IFS= read -r _c; do
|
|
846
|
+
case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
|
|
847
|
+
done <<< "$_tc"
|
|
848
|
+
cd {WORKTREE_PATH}
|
|
849
|
+
git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
|
|
850
|
+
BASESYNC_MAX_ROUNDS=2
|
|
851
|
+
BASESYNC_ROUNDS=0; _p2=""
|
|
852
|
+
if _rl=$(git rev-list --first-parent --merges origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null); then
|
|
853
|
+
for _m in $_rl; do _q=$(git rev-parse -q --verify "$_m^2" 2>/dev/null) && git merge-base --is-ancestor "$_q" origin/{PR_BASE} 2>/dev/null && { BASESYNC_ROUNDS=$((BASESYNC_ROUNDS+1)); [ -n "$_p2" ] || _p2="$_q"; }; done
|
|
854
|
+
else BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS; fi
|
|
855
|
+
BASESYNC_BASE_SHA="$_p2"
|
|
856
|
+
ISSUE_COMMENTS=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null) || ISSUE_COMMENTS=""
|
|
857
|
+
# Current kind = the NEWEST trusted bound marker (bodies are oldest-first, so tail -1). Unreadable or non-base-sync: no line (fail closed).
|
|
858
|
+
BASESYNC_KIND=""
|
|
859
|
+
if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$ISSUE_COMMENTS" ]; then
|
|
860
|
+
BASESYNC_KIND=$(printf '%s' "$ISSUE_COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:(CI|INPR|BASESYNC)_REMEDIATION: pr={PR_NUMBER} -->' 2>/dev/null | tail -1 | grep -oE '(CI|INPR|BASESYNC)_REMEDIATION' | head -1) || BASESYNC_KIND=""
|
|
861
|
+
fi
|
|
642
862
|
BASESYNC_LINE=""
|
|
643
|
-
if [ "$
|
|
644
|
-
|
|
645
|
-
|
|
863
|
+
if [ "$BASESYNC_KIND" = "BASESYNC_REMEDIATION" ]; then
|
|
864
|
+
# A sync merge is this round's when its base head is not already recorded by an earlier trusted `Base sync: ran` trail.
|
|
865
|
+
_seen=""
|
|
866
|
+
[ -n "$BASESYNC_BASE_SHA" ] && _seen=$(printf '%s' "$ISSUE_COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '\*\*Base sync\*\*: ran \(base='"$BASESYNC_BASE_SHA" 2>/dev/null || true)
|
|
867
|
+
if [ -n "$BASESYNC_BASE_SHA" ] && [ -z "$_seen" ]; then
|
|
868
|
+
BASESYNC_LINE="**Base sync**: ran (base=${BASESYNC_BASE_SHA})"
|
|
869
|
+
else
|
|
870
|
+
# No merge pushed this round (refused, capped, nothing to merge): still a durable completion record, so M0 counts the round
|
|
871
|
+
# as done and the per-kind override cannot re-arm for every later invocation.
|
|
872
|
+
BASESYNC_LINE="**Base sync**: attempted (no merge)"
|
|
873
|
+
fi
|
|
646
874
|
fi
|
|
647
875
|
|
|
876
|
+
if [ "${RESUME_AT_M7:-false}" = "true" ] && [ "$RE_GATE_OUTCOME" = "HELD-AWAITING-MERGE" ]; then
|
|
877
|
+
# forge#3685: a resume that ends HELD is NOT a completed attempt. Post a distinct, non-terminal record: header is
|
|
878
|
+
# FORGE:REMEDIATION_HELD (never "<!-- FORGE:REMEDIATION -->", so the M0 partial-trail cleanup and the COMPLETE/TRAIL
|
|
879
|
+
# matchers never see it) and NO FORGE:REMEDIATION:COMPLETE, so the next invocation re-enters M7 instead of ALREADY_DONE.
|
|
880
|
+
REMEDIATION_BODY="<!-- FORGE:REMEDIATION_HELD -->
|
|
881
|
+
## Remediation Resume Held for PR #{PR_NUMBER}
|
|
882
|
+
|
|
883
|
+
**Re-gate outcome**: ${RE_GATE_OUTCOME} ${OUTCOME_DETAIL}
|
|
884
|
+
**Auto-land bar**: ${AUTO_LAND_BAR_TEXT}
|
|
885
|
+
**Hold reason**: ${HOLD_REASON:-CI pending, approval not bound to the live head, or deploy-gate base (see Phase M7 output above)}
|
|
886
|
+
**Head**: ${REMEDIATED_HEAD_SHA:-unknown}"
|
|
887
|
+
else
|
|
648
888
|
REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
|
|
649
889
|
## Remediation Complete for PR #{PR_NUMBER}
|
|
650
890
|
|
|
@@ -655,6 +895,7 @@ REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
|
|
|
655
895
|
**Head**: ${REMEDIATED_HEAD_SHA:-unknown}
|
|
656
896
|
${BASESYNC_LINE}
|
|
657
897
|
<!-- FORGE:REMEDIATION:COMPLETE -->"
|
|
898
|
+
fi
|
|
658
899
|
|
|
659
900
|
gh pr comment {PR_NUMBER} {GH_FLAG} --body "$REMEDIATION_BODY"
|
|
660
901
|
gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "$REMEDIATION_BODY"
|
|
@@ -672,7 +913,7 @@ Skill("{FORGE_SKILL_PREFIX}work-on:close", args="{ISSUE_NUMBER} --repo {GH_REPO}
|
|
|
672
913
|
|
|
673
914
|
**If the outcome was `REREVIEW-REQUIRED`** (forge#3240): do not invoke close and do not add `needs-human`. Leave the worktree and `workflow:in-review` in place and return `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`; the caller runs the re-review and, on `AUTO-LANDED`, drives close itself; if the re-review cannot run, the caller applies the terminal fallback (`commands/work-on.md` Phase 0A.1).
|
|
674
915
|
|
|
675
|
-
**If the outcome was `HELD-AWAITING-MERGE`, `RE-ESCALATED`, or `UNFIXABLE
|
|
916
|
+
**If the outcome was `HELD-AWAITING-MERGE`, `RE-ESCALATED`, or `UNFIXABLE`** (a held resume leaves `workflow:awaiting-merge` in place and is re-enterable): leave the worktree in place (a human may need it for manual inspection/merge) and return the structured result below without invoking close. Do not close the issue.
|
|
676
917
|
|
|
677
918
|
---
|
|
678
919
|
|
|
@@ -690,4 +931,4 @@ REMEDIATE_RESULT:
|
|
|
690
931
|
blocker: {description if status=BLOCKED}
|
|
691
932
|
```
|
|
692
933
|
|
|
693
|
-
**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: 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`). Whether invoked standalone (`/work-on <pr> --remediate`) or via the orchestrator's item 6.4 dispatch, no further action is required from the caller.
|
|
934
|
+
**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`). Whether invoked standalone (`/work-on <pr> --remediate`) or via the orchestrator's item 6.4 dispatch, no further action is required from the caller.
|