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