forgedock 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/phases-args.test.mjs +112 -0
- package/bin/engine/phases.mjs +152 -45
- package/bin/engine/runlog.mjs +9 -0
- package/bin/engine-cli.mjs +13 -2
- package/bin/engine.mjs +144 -18
- package/bin/forgedock.mjs +2 -2
- package/bin/runner.mjs +89 -12
- package/commands/orchestrate/phase-4-execution.md +26 -7
- package/commands/orchestrate/phase-6-report.md +29 -0
- package/commands/quality-gate.md +96 -12
- package/commands/review-pr.md +75 -12
- package/commands/work-on/build/validate.md +58 -7
- package/commands/work-on/remediate.md +87 -7
- package/commands/work-on/review.md +56 -18
- package/commands/work-on.md +13 -5
- package/docs/WORK-ON-RUNTIME.md +2 -2
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +6 -1
- package/scripts/check-branch-ancestry.sh +82 -0
- package/scripts/check-branch-ancestry.test.sh +78 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/spec-no-worktree-script.test.sh +46 -0
|
@@ -104,6 +104,21 @@ fi
|
|
|
104
104
|
PARTIAL_PR_COMMENT_IDS=$(printf '%s' "$PR_COMMENTS" | jq -rs '[.[][] | select((.body // "") | startswith("<!-- FORGE:REMEDIATION -->")) | select((.body | contains("<!-- FORGE:REMEDIATION:COMPLETE -->")) | not) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | .id] | .[]' 2>/dev/null || true)
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
**Per-kind scoping of the single-attempt guard** (forge#3496): the guard is scoped per remediation kind so a base-sync round is not swallowed by an earlier `ci-gate` / `inpr-fix` remediation on the same PR. A base-sync handoff is recognised by the router's `FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER}` bound marker on the issue (the router posts it immediately before invoking this phase); a completed base-sync round is recognised by a `FORGE:REMEDIATION:COMPLETE` trail carrying the `**Base sync**: ran` line that Phase M8 posts. The base-sync round is itself bounded to one, by that marker count in `work-on/review.md` R4 and by this check:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
|
|
111
|
+
BASESYNC_DONE_N=$(printf '%s' "$PR_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:REMEDIATION -->[\s\S]*\*\*Base sync\*\*: ran[\s\S]*<!-- FORGE:REMEDIATION:COMPLETE -->' 2>/dev/null || echo "")
|
|
112
|
+
ISSUE_COMMENTS=$(gh api --paginate repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments 2>/dev/null) || ISSUE_COMMENTS=""
|
|
113
|
+
BASESYNC_BOUND_N=$(printf '%s' "$ISSUE_COMMENTS" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER} -->' 2>/dev/null || echo "")
|
|
114
|
+
# A base-sync handoff (bound marker present) with no completed base-sync round yet is a fresh attempt for ITS kind,
|
|
115
|
+
# whatever earlier ci-gate / inpr-fix trails exist. Fail closed: an unreadable count keeps the kind-agnostic guard.
|
|
116
|
+
if [ -n "$BASESYNC_DONE_N" ] && [ -n "$BASESYNC_BOUND_N" ] && [ "$BASESYNC_BOUND_N" -ge 1 ] && [ "$BASESYNC_DONE_N" -eq 0 ]; then REMEDIATION_COMPLETE_N=0; fi
|
|
117
|
+
fi
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Completed trails of other kinds are left in place (never deleted); only the interrupted-partial cleanup below deletes anything.
|
|
121
|
+
|
|
107
122
|
- If `REMEDIATION_COMPLETE_N` or `REMEDIATION_TRAIL_N` is empty (comments or `scripts/trusted-comments.sh` unreadable or unresolvable) → fail closed: EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "github-unavailable: remediation trail unreadable (comments or scripts/trusted-comments.sh unresolvable)". Never treat unreadable as "no trail" and never as `ALREADY_DONE`.
|
|
108
123
|
- If `REMEDIATION_COMPLETE_N` is at least 1 (a trusted comment that starts with `<!-- FORGE:REMEDIATION -->` and contains `<!-- FORGE:REMEDIATION:COMPLETE -->`) → EXIT `REMEDIATE_RESULT: status: ALREADY_DONE`. **Single-attempt semantics (AC5)**: once a genuine `FORGE:REMEDIATION:COMPLETE` trail comment exists for this PR, do NOT re-attempt fixes on a subsequent invocation, regardless of the prior verdict — this is what prevents an infinite remediation retry loop on a genuinely-blocked PR. A comment that only quotes the marker mid-body (review findings, an `INPR_FIX` work order, a disposition record) or comes from an untrusted author never counts.
|
|
109
124
|
- If `REMEDIATION_COMPLETE_N` is 0 and `REMEDIATION_TRAIL_N` is at least 1 → a prior attempt was interrupted mid-flight (same failure mode as the investigation phase's partial-comment case). Delete the partial comment(s) on both the PR and the issue (ids from `PARTIAL_PR_COMMENT_IDS` above and the same anchored filter over the issue's comments), then continue below as a fresh attempt:
|
|
@@ -134,7 +149,7 @@ BLOCK_COMMENTS=$(gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
|
|
|
134
149
|
```
|
|
135
150
|
|
|
136
151
|
**Classify into FIXABLE vs. UNFIXABLE**:
|
|
137
|
-
- **FIXABLE** — open `review-finding` issues (CONFIRMED/LIKELY code defects), a `VERDICT=CHANGES REQUESTED` block with concrete findings attached, a
|
|
152
|
+
- **FIXABLE** — open `review-finding` issues (CONFIRMED/LIKELY code defects), a `VERDICT=CHANGES REQUESTED` block with concrete findings attached, a base conflict (`base-conflict`: `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection — resolvable by merging `origin/{PR_BASE}` in, the base-sync step in Phase M3; `BLOCKED` alone is branch protection that a sync cannot clear and is UNFIXABLE unless a conflict state is also present), a quality-gate/build failure, or a CI-gate refusal (`ci gate not green` — the failing/cancelled/timed-out checks are listed on the linked issue; read each failing job's log with `gh run view --log-failed`, fix the cause on the PR branch, or re-run a check that failed for an infrastructure reason), or an in-PR fix request (`in-pr fix required` — review-pr §6B.6 posted a `<!-- FORGE:INPR_FIX: round=1 head=<sha> findings=<ids> -->` comment on the PR listing CONFIRMED MEDIUM findings in files this PR changed. That comment is the work order: fix exactly the listed findings, at the listed `file:line`, and nothing else. Do not file them as issues; the re-review in Phase M6 files any that remain).
|
|
138
153
|
- **UNFIXABLE (policy escalation)** — `HAS_PURPOSE_REGRESSION=true` (the PR's behavior diverges from the issue's intent — a judgment call, not a code defect), `CALIBRATION_NEEDS_HUMAN=true` (statistical trust threshold), or `TRUST_NEEDS_HUMAN=true` (provenance `NOVEL_NEEDS_HUMAN` tier, insufficient prior data — a policy gate, not a bug). None of these are mechanically "fixable" by re-editing code.
|
|
139
154
|
|
|
140
155
|
**If the block reason classifies as UNFIXABLE** (and no FIXABLE item accompanies it): do NOT attempt any fix. Skip directly to Phase M8 with verdict `UNFIXABLE`, re-affirm `needs-human` (it should already be present), and return `REMEDIATE_RESULT: status: UNFIXABLE`. This satisfies AC5 — "genuinely-blocked PRs still terminate at `needs-human`."
|
|
@@ -157,7 +172,7 @@ Do not perform this transition for an UNFIXABLE policy escalation. Any later qua
|
|
|
157
172
|
|
|
158
173
|
## Phase M2: Checkout the PR's Existing Branch
|
|
159
174
|
|
|
160
|
-
Remediation always fixes forward on top of the PR's existing head commit — never rebase
|
|
175
|
+
Remediation always fixes forward on top of the PR's existing head commit — never rebase and never force-push. The only history-affecting operation permitted is the base sync (`git merge origin/{PR_BASE}`, Phase M3), which adds a merge commit and pushes with a plain `git push`.
|
|
161
176
|
|
|
162
177
|
```bash
|
|
163
178
|
cd {REPO_PATH}
|
|
@@ -182,7 +197,39 @@ For each FIXABLE item from Phase M1: read the affected file(s) in `{WORKTREE_PAT
|
|
|
182
197
|
|
|
183
198
|
**Never delete, skip, or weaken a failing check to go green.** Removing, skipping (`skip`, `xfail`, `continue-on-error`, `if: false`, commenting out) or weakening a failing test or CI step is never a valid fix. Fix the cause in the code under test, or classify the item UNFIXABLE, add/re-affirm `needs-human`, and post a comment that names the failing tests and failing assertions (test title plus assertion text from the CI log). The only exception is a deletion justified by removal of the tested code itself (see quality-gate 2U). Enforcement is the quality gate's coverage-reduction check (2U): a `COVERAGE-1` finding is fixed by restoring the test or step, never by suppressing the finding. <!-- Added: forge#3257 -->
|
|
184
199
|
|
|
185
|
-
**
|
|
200
|
+
**Base-sync (merge-only)** <!-- Added: forge#3496 -->: runs when the block reason was a base conflict (`base-conflict`, or `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection). This step is authorized by the operator's batch dispatch and by the router's `FORGE:BASESYNC_REMEDIATION` bound; it applies to the PR's own branch in `{WORKTREE_PATH}` only. Never rebase, never force-push, never merge into or push to a shared branch.
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
cd {WORKTREE_PATH}
|
|
204
|
+
CUR_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
205
|
+
case "{HEAD_BRANCH}" in
|
|
206
|
+
"{PR_BASE}"|main|master|staging|milestone/*) echo "refusing base sync: {HEAD_BRANCH} is a shared branch"; BASESYNC_REFUSED=true ;;
|
|
207
|
+
esac
|
|
208
|
+
[ "$CUR_BRANCH" = "{HEAD_BRANCH}" ] || BASESYNC_REFUSED=true
|
|
209
|
+
if [ "${BASESYNC_REFUSED:-false}" != "true" ]; then
|
|
210
|
+
git fetch origin {PR_BASE} # allowlist:check-command-side-effects
|
|
211
|
+
git merge origin/{PR_BASE} --no-edit # allowlist:check-command-side-effects
|
|
212
|
+
BASESYNC_RAN=true
|
|
213
|
+
fi
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- Clean merge: nothing more to resolve; the merge commit is the sync.
|
|
217
|
+
- Conflicts: read BOTH sides of every conflicted hunk and the surrounding code, then write the combined result by hand. No `-X ours`/`-X theirs`, no wholesale `git checkout --ours/--theirs`, no blanket take-one-side. After resolving, `git add` the files and conclude with `git commit -s --no-edit` (the merge commit).
|
|
218
|
+
- **Unresolvable with confidence** (a hunk whose intent on either side you cannot reconcile, or the branch is refused above): capture the file list first, then abort, and park:
|
|
219
|
+
```bash
|
|
220
|
+
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
221
|
+
CONFLICT_FILES=$(git diff --name-only --diff-filter=U)
|
|
222
|
+
git merge --abort # allowlist:check-command-side-effects
|
|
223
|
+
run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
|
|
224
|
+
Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) could not be resolved with confidence. Conflicting files:
|
|
225
|
+
|
|
226
|
+
\`\`\`
|
|
227
|
+
${CONFLICT_FILES}
|
|
228
|
+
\`\`\`" # allowlist:check-command-side-effects
|
|
229
|
+
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
230
|
+
```
|
|
231
|
+
EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: unresolvable conflicts". Only that issue is parked.
|
|
232
|
+
- After a sync, the quality gate below re-runs on the merged tree, and Phase M6's re-review covers the new head (the reviewed-head guard forces a fresh review).
|
|
186
233
|
|
|
187
234
|
**Quality Gate** (same loop as Phase 3G, max 3 iterations):
|
|
188
235
|
```
|
|
@@ -201,14 +248,42 @@ If still failing after 3 iterations: post a comment, re-affirm `needs-human`, EX
|
|
|
201
248
|
|
|
202
249
|
## Phase M4: Commit, Push, and Close Addressed Findings
|
|
203
250
|
|
|
251
|
+
**Pre-push ancestry guard** (same guard as `work-on/review.md` Phase R1; reuse it, do not write a parallel check): a base-sync merge of `origin/{PR_BASE}` passes it, a merge that brings in history from outside the base fails it.
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# <Script resolution block from work-on/review.md, verbatim>
|
|
255
|
+
cd {WORKTREE_PATH}
|
|
256
|
+
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
257
|
+
git fetch origin {PR_BASE} >/dev/null 2>&1 || true
|
|
258
|
+
RESOLUTION=$(resolve_script 'check-branch-ancestry'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
|
|
259
|
+
if [ "$TIER" = "prose" ]; then
|
|
260
|
+
MERGE_COMMITS="check-branch-ancestry script not resolvable (fail closed)"; ANCESTRY_RC=2
|
|
261
|
+
else
|
|
262
|
+
MERGE_COMMITS=$(bash "$SCRIPT_PATH" {HEAD_BRANCH} origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
|
|
263
|
+
fi
|
|
264
|
+
if [ "$ANCESTRY_RC" -ne 0 ]; then
|
|
265
|
+
run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "## Pre-Push Ancestry Guard Failed
|
|
266
|
+
|
|
267
|
+
Branch \`{HEAD_BRANCH}\` has merge commits from outside \`{PR_BASE}\`, or ancestry could not be verified (rc=${ANCESTRY_RC}). Not pushing.
|
|
268
|
+
|
|
269
|
+
\`\`\`
|
|
270
|
+
${MERGE_COMMITS}
|
|
271
|
+
\`\`\`" # allowlist:check-command-side-effects
|
|
272
|
+
gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
273
|
+
# EXIT REMEDIATE_RESULT: status: BLOCKED (blocker: pre-push ancestry guard failed) - do not push
|
|
274
|
+
fi
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Then commit and push. A base-sync merge commit already exists from Phase M3; commit only additional quality-gate or fix edits on top.
|
|
278
|
+
|
|
204
279
|
```bash
|
|
205
280
|
cd {WORKTREE_PATH}
|
|
206
281
|
git add -u
|
|
207
|
-
git commit -s -m "fix(remediate): {description} (#{ISSUE_NUMBER})"
|
|
208
|
-
git push origin {HEAD_BRANCH}
|
|
282
|
+
git diff --cached --quiet || git commit -s -m "fix(remediate): {description} (#{ISSUE_NUMBER})"
|
|
283
|
+
git push origin {HEAD_BRANCH} # allowlist:check-command-side-effects
|
|
209
284
|
```
|
|
210
285
|
|
|
211
|
-
If push fails, retry with
|
|
286
|
+
If the push fails, do NOT retry with a force flag (never force-push): post a comment, add `needs-human`, EXIT `REMEDIATE_RESULT: status: BLOCKED`. A raced remote is resolved by a re-run, which fetches and merges naturally.
|
|
212
287
|
|
|
213
288
|
**Close each addressed review-finding issue directly** (this remediation fixes findings in-place on the existing PR, rather than each finding spawning its own downstream `/work-on` pipeline — leaving them open would have a future run rediscover already-fixed code). Track the closed numbers in `ADDRESSED_FINDING_NUMBERS[]` — Phase M8 reports this array in the final paper trail:
|
|
214
289
|
```bash
|
|
@@ -289,6 +364,8 @@ If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate r
|
|
|
289
364
|
|
|
290
365
|
If the retained `REVIEW_RESULT` is `BLOCKED` with a blocker mentioning the phase trail (e.g. "phase trail unreadable (rc=N)": the Phase 8 verifier exited ≥2 / 127, forge#3147): the gate could not run, so nothing is missing and nothing is re-run. Keep `needs-human` (review-pr Phase 8 already re-asserted it), do not merge, and exit `REMEDIATE_RESULT: status: BLOCKED` with the same blocker.
|
|
291
366
|
|
|
367
|
+
If the retained `REVIEW_RESULT` is `BLOCKED` with blocker "base-conflict" (forge#3496): the base moved again, or the sync did not clear the conflict. Treat it as re-escalated: the `BASESYNC_REMEDIATION` bound is already used, so do NOT sync a second time. Add `needs-human`, post a comment naming the PR and the conflicting files (`git diff --name-only --diff-filter=U` against a trial merge, or the GitHub mergeability report), and exit `REMEDIATE_RESULT: status: BLOCKED` with blocker "base conflict persists after base-sync remediation".
|
|
368
|
+
|
|
292
369
|
This re-runs the full review (domain agents → verdict → Phase 8 auto-merge gate). The FIXABLE transition above left the issue at the non-terminal `workflow:in-review` state. `review-pr.md` recognizes the in-progress `FORGE:REMEDIATION` marker posted in Phase M5 as evidence of the prior escalation, so one of two things happens inside Phase 8:
|
|
293
370
|
|
|
294
371
|
- **Re-escalated**: the re-review itself trips a fresh block (`CHANGES REQUESTED`, purpose-regression, calibration, trust, or a still-`CONFLICTING` mergeability check) → it adds `needs-human`, and this phase removes `workflow:in-review`, leaving one terminal state.
|
|
@@ -455,6 +532,9 @@ esac
|
|
|
455
532
|
# forge#3413: embed the pushed head so the orchestrator scopes a REREVIEW-REQUIRED trail to the CURRENT head only.
|
|
456
533
|
REMEDIATED_HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || true)
|
|
457
534
|
|
|
535
|
+
# forge#3496: mark a base-sync round so Phase M0 scopes the single-attempt guard per kind.
|
|
536
|
+
BASESYNC_LINE=""; [ "${BASESYNC_RAN:-false}" = "true" ] && BASESYNC_LINE="**Base sync**: ran"
|
|
537
|
+
|
|
458
538
|
REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
|
|
459
539
|
## Remediation Complete for PR #{PR_NUMBER}
|
|
460
540
|
|
|
@@ -463,7 +543,7 @@ REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
|
|
|
463
543
|
**Auto-land bar**: ${AUTO_LAND_BAR_TEXT}
|
|
464
544
|
**Re-gate outcome**: ${RE_GATE_OUTCOME} ${OUTCOME_DETAIL}
|
|
465
545
|
**Head**: ${REMEDIATED_HEAD_SHA:-unknown}
|
|
466
|
-
|
|
546
|
+
${BASESYNC_LINE}
|
|
467
547
|
<!-- FORGE:REMEDIATION:COMPLETE -->"
|
|
468
548
|
|
|
469
549
|
gh pr comment {PR_NUMBER} {GH_FLAG} --body "$REMEDIATION_BODY"
|
|
@@ -207,30 +207,66 @@ If `LAST_VERDICT_SHA` is empty (no prior CHANGES REQUESTED verdict found) or dif
|
|
|
207
207
|
|
|
208
208
|
## Phase R1: Pre-Push Ancestry Guard
|
|
209
209
|
|
|
210
|
-
Before pushing, verify the branch contains no merge commits from
|
|
210
|
+
Before pushing, verify the branch contains no merge commits that bring in history from outside the PR base. Merging `origin/{PR_BASE}` itself into the branch (a base sync) is allowed: every non-first parent of each merge in `origin/{PR_BASE}..{BRANCH}` must be an ancestor of `origin/{PR_BASE}`. This is the final defense against milestone-code-onto-staging contamination.
|
|
211
211
|
|
|
212
212
|
```bash
|
|
213
|
+
# <Script resolution block from the top of this file, verbatim>
|
|
213
214
|
cd {WORKTREE_PATH}
|
|
214
215
|
# Skip if PR_BASE does not exist on origin yet (new branch — no contamination possible)
|
|
215
216
|
if git ls-remote --exit-code origin {PR_BASE} >/dev/null 2>&1; then
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
git fetch origin {PR_BASE} >/dev/null 2>&1 || true
|
|
218
|
+
RESOLUTION=$(resolve_script 'check-branch-ancestry'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
|
|
219
|
+
if [ "$TIER" = "prose" ]; then
|
|
220
|
+
# Prose fallback: same checks inline (exit 0 clean, 1 foreign, 2 error). Fails closed: unresolvable refs or git errors give rc=2.
|
|
221
|
+
ANCESTRY_RC=0; MERGE_COMMITS=""
|
|
222
|
+
if ! git rev-parse --verify --quiet "origin/{PR_BASE}^{commit}" >/dev/null 2>&1 || ! git rev-parse --verify --quiet "{BRANCH}^{commit}" >/dev/null 2>&1; then
|
|
223
|
+
ANCESTRY_RC=2
|
|
224
|
+
elif ! MERGES=$(git rev-list --merges origin/{PR_BASE}..{BRANCH} 2>/dev/null) || ! FP=$(git rev-list --first-parent origin/{PR_BASE}..{BRANCH} 2>/dev/null); then
|
|
225
|
+
ANCESTRY_RC=2
|
|
226
|
+
else
|
|
227
|
+
for M in $MERGES; do
|
|
228
|
+
PARENTS=$(git rev-list --parents -n 1 "$M" 2>/dev/null) || { ANCESTRY_RC=2; continue; }
|
|
229
|
+
for P in $(printf '%s\n' "$PARENTS" | cut -d' ' -f3-); do
|
|
230
|
+
git merge-base --is-ancestor "$P" origin/{PR_BASE} 2>/dev/null; rc=$?
|
|
231
|
+
if [ "$rc" -eq 1 ]; then MERGE_COMMITS="${MERGE_COMMITS}${M} ${P}
|
|
232
|
+
"; [ "$ANCESTRY_RC" -eq 2 ] || ANCESTRY_RC=1
|
|
233
|
+
elif [ "$rc" -ne 0 ]; then ANCESTRY_RC=2; fi
|
|
234
|
+
done
|
|
235
|
+
done
|
|
236
|
+
# First-parent line must not carry milestone history (branch cut from a milestone, then base sync).
|
|
237
|
+
for F in $(git for-each-ref --format='%(refname)' refs/remotes/origin/milestone/ refs/heads/milestone/ 2>/dev/null); do
|
|
238
|
+
for C in $FP; do
|
|
239
|
+
git merge-base --is-ancestor "$C" "$F" 2>/dev/null; rc=$?
|
|
240
|
+
if [ "$rc" -eq 0 ]; then MERGE_COMMITS="${MERGE_COMMITS}${C} first-parent history reachable from ${F}
|
|
241
|
+
"; [ "$ANCESTRY_RC" -eq 2 ] || ANCESTRY_RC=1
|
|
242
|
+
elif [ "$rc" -ne 1 ]; then ANCESTRY_RC=2; fi
|
|
243
|
+
done
|
|
244
|
+
done
|
|
245
|
+
fi
|
|
246
|
+
else
|
|
247
|
+
MERGE_COMMITS=$(bash "$SCRIPT_PATH" {BRANCH} origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
|
|
248
|
+
fi
|
|
249
|
+
if [ "$ANCESTRY_RC" -ne 0 ]; then
|
|
250
|
+
if [ "$ANCESTRY_RC" -eq 1 ]; then
|
|
251
|
+
echo "PRE-PUSH ANCESTRY GUARD FAILED: merge commits from outside {PR_BASE} detected"
|
|
252
|
+
else
|
|
253
|
+
echo "PRE-PUSH ANCESTRY GUARD FAILED: could not verify ancestry (failing closed)"
|
|
254
|
+
fi
|
|
219
255
|
gh issue comment {NUMBER} {GH_FLAG} --body "## Pre-Push Ancestry Guard Failed
|
|
220
256
|
|
|
221
|
-
Branch \`{BRANCH}\` contains merge commits from
|
|
257
|
+
Branch \`{BRANCH}\` contains merge commits that bring in history from outside the PR base (\`{PR_BASE}\`), or ancestry could not be verified. Pushing this branch risks contaminating \`{PR_BASE}\` with unapproved code (e.g. milestone code leaking onto staging). Merges of \`{PR_BASE}\` itself are allowed and do not trigger this guard.
|
|
222
258
|
|
|
223
|
-
**Detected merge commits
|
|
259
|
+
**Detected merge commits** (merge, foreign parent, subject — or the verification error):
|
|
224
260
|
\`\`\`
|
|
225
261
|
${MERGE_COMMITS}
|
|
226
262
|
\`\`\`
|
|
227
263
|
|
|
228
|
-
Do NOT push this branch. Human review required to identify the source of the merge commits and clean the branch history (
|
|
264
|
+
Do NOT push this branch. Human review required to identify the source of the merge commits and clean the branch history (a human re-creates the branch from \`origin/{PR_BASE}\` with only the intended commits).
|
|
229
265
|
|
|
230
|
-
<!-- FORGE:PUSH_BLOCKED -->"
|
|
266
|
+
<!-- FORGE:PUSH_BLOCKED -->" # allowlist:check-command-side-effects
|
|
231
267
|
gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human" # allowlist:check-command-side-effects
|
|
232
268
|
# Return REVIEW_RESULT: status: BLOCKED — do not push
|
|
233
|
-
printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: %s\n' "pre-push ancestry guard failed: merge commits from outside {PR_BASE}"
|
|
269
|
+
printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: %s\n' "pre-push ancestry guard failed: merge commits from outside {PR_BASE} or ancestry unverifiable"
|
|
234
270
|
exit 1
|
|
235
271
|
fi
|
|
236
272
|
fi
|
|
@@ -268,12 +304,7 @@ cd {WORKTREE_PATH}
|
|
|
268
304
|
git push -u origin {BRANCH} # allowlist:check-command-side-effects
|
|
269
305
|
```
|
|
270
306
|
|
|
271
|
-
If push fails, retry with
|
|
272
|
-
```bash
|
|
273
|
-
git push -u origin {BRANCH} --force-with-lease # allowlist:check-command-side-effects
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
If still fails:
|
|
307
|
+
If the push fails, do NOT retry with a force flag (never force-push): a raced or diverged remote is a human decision, and a conflict with the base is handled by the base-sync path (`base-conflict` in Phase R4), not by overwriting the remote. Fall straight through to:
|
|
277
308
|
```bash
|
|
278
309
|
gh issue comment {NUMBER} {GH_FLAG} --body "## Push Failed
|
|
279
310
|
|
|
@@ -547,7 +578,7 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
|
|
|
547
578
|
In-PR fix round did not land; the remaining CONFIRMED MEDIUM findings are filed as issues on the next review instead of blocking the merge." # allowlist:check-command-side-effects
|
|
548
579
|
```
|
|
549
580
|
|
|
550
|
-
Persisted loop bounds for the cases above — count first, then act (`{BOUND}` is `STALE_REREVIEW`, `CI_REMEDIATION` or `
|
|
581
|
+
Persisted loop bounds for the cases above — count first, then act (`{BOUND}` is `STALE_REREVIEW`, `CI_REMEDIATION`, `INPR_REMEDIATION` or `BASESYNC_REMEDIATION`). This phase posts only the `STALE_REREVIEW` marker; `CI_REMEDIATION`, `INPR_REMEDIATION` and `BASESYNC_REMEDIATION` are posted by the router in Phase 4R, immediately before it invokes remediation (forge#3398):
|
|
551
582
|
```bash
|
|
552
583
|
[ "${DRY_RUN:-false}" = "true" ] && { echo "[DRY_RUN] bound check only"; }
|
|
553
584
|
if [ "{BOUND}" = "STALE_REREVIEW" ]; then
|
|
@@ -556,6 +587,9 @@ In-PR fix round did not land; the remaining CONFIRMED MEDIUM findings are filed
|
|
|
556
587
|
elif [ "{BOUND}" = "INPR_REMEDIATION" ]; then
|
|
557
588
|
BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
|
|
558
589
|
--jq '[.[] | select((.body | contains("FORGE:INPR_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
|
|
590
|
+
elif [ "{BOUND}" = "BASESYNC_REMEDIATION" ]; then
|
|
591
|
+
BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
|
|
592
|
+
--jq '[.[] | select((.body | contains("FORGE:BASESYNC_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
|
|
559
593
|
else
|
|
560
594
|
BOUND_COUNT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
|
|
561
595
|
--jq '[.[] | select((.body | contains("FORGE:CI_REMEDIATION:")) and (.body | contains("pr={PR_NUMBER}")))] | length' 2>/dev/null | awk '{s+=$(1)} END {print s+0}')
|
|
@@ -569,7 +603,11 @@ Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head on
|
|
|
569
603
|
```
|
|
570
604
|
`BOUND_EXHAUSTED` → take the "already ≥ 1" branch of that case.
|
|
571
605
|
|
|
572
|
-
-
|
|
606
|
+
- `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "base-conflict" (review-pr Phase 7B / Phase 8: `mergeable=CONFLICTING` or `mergeStateStatus=DIRTY`, or `BEHIND` under required-up-to-date protection, and no other blocking finding; forge#3496): do NOT attempt the manual merge below and do NOT merge by any other route. Syncing the base is pipeline work: merge `origin/{PR_BASE}` into the issue's own branch (merge only, never rebase, never force-push, never touch a shared branch). Run **one** sync round, bounded by `<!-- FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER} -->` on the issue (`{BOUND}` = `BASESYNC_REMEDIATION` below).
|
|
607
|
+
- **Bound unused**: add `needs-human` (remediation only targets `needs-human`-gated PRs, and its Phase M1 clears it as FIXABLE). Then return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: base-sync`. The router posts the bound marker (Phase 4R) and runs `work-on:remediate`, which merges the base in, re-runs the quality gate and re-reviews the new head. Never invoke `work-on:remediate` from this phase (same reason as the ci-gate case).
|
|
608
|
+
- **Bound already used** (the sync ran and the PR still conflicts, or the sync could not resolve a hunk): do not sync again. Leave `needs-human` and return `REVIEW_RESULT: status: BLOCKED` with blocker "base conflict persists after base-sync remediation". List the conflicting files in the issue comment you post, taken from the remediation's `FORGE:BASESYNC_FAILED` comment (the `git diff --name-only --diff-filter=U` list) when present.
|
|
609
|
+
|
|
610
|
+
- PR NOT MERGED (and not a phase-trail, auto-merge-gate, stale-review, ci-gate, in-pr-fix or base-conflict BLOCKED above) → attempt manual merge, **only if the PR head is still the commit named in the latest `<!-- FORGE:REVIEW -->` APPROVED verdict** (otherwise re-invoke Phase R3 instead, as for "stale review") **and only after the same CI gate**:
|
|
573
611
|
```bash
|
|
574
612
|
# CI gate (MANDATORY before any autonomous merge): merge only when every check on the PR is
|
|
575
613
|
# green. Field test: PRs merged to staging with checks pending or red (#3165), because branch
|
|
@@ -623,7 +661,7 @@ REVIEW_RESULT:
|
|
|
623
661
|
pr_url: {PR_URL}
|
|
624
662
|
merged_to: {PR_BASE}
|
|
625
663
|
next: {remediate, only when status=NEXT}
|
|
626
|
-
remediation: {ci-gate | inpr-fix, only when status=NEXT}
|
|
664
|
+
remediation: {ci-gate | inpr-fix | base-sync, only when status=NEXT}
|
|
627
665
|
blocker: {description if status=BLOCKED}
|
|
628
666
|
```
|
|
629
667
|
|
package/commands/work-on.md
CHANGED
|
@@ -381,7 +381,7 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments --jq '.[] | {id: .id, author: .u
|
|
|
381
381
|
|
|
382
382
|
**Resume preflight (MANDATORY on any resume past Phase 1)** <!-- Added: forge#3061 -->: before routing to Phase 3 (build), 4 (review) or 5 (close) from existing state, run `bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" {NUMBER} -R {GH_REPO}` (add `--docs-only` for docs-only diffs, or `--code-diff` when the diff has any non-docs file so an INVESTIGATION band cannot waive requirements), with `FORGE_ROOT` resolved by the bootstrap in "Script resolution" below. If `FORGE_ROOT` is empty or the script is missing, treat it as `PHASE_TRAIL: ERROR` (fail closed: stop and add `needs-human`; never skip the preflight). On `PHASE_TRAIL: FAIL`, go BACK and run each missing phase through its `Skill(...)` (investigate, Phase 3B classification, build contract/context/architect, validate) before continuing — never continue forward over a gap, never hand-post a missing marker, and never treat a recovered uncommitted worktree as a substitute for the skipped phases. The same verifier gates PR creation (`work-on/review.md` Phase R1.5) and auto-merge (`review-pr.md` Phase 8).
|
|
383
383
|
|
|
384
|
-
**Determine resume point**: no `FORGE:INVESTIGATOR` → Phase 1 (investigate). Investigation complete with decompose YES and no `FORGE:DECOMPOSED` → Phase 2 (decompose); with `FORGE:DECOMPOSED` → Phase 5 (`--terminal-state decomposed`). Investigation complete (decompose NO) without `FORGE:BUILDER:COMPLETE` → Phase 3 (build; a partial BUILDER comment is cleaned up by the build phase). `FORGE:BUILDER:COMPLETE` and no merged PR → Phase 4 (review), except an **unfinished remediation handoff**, which is checked first and overrides the checkpoint routing in 0B.5: the issue does NOT carry `needs-human`, and its latest `FORGE:CI_REMEDIATION: pr={PR}`/`FORGE:INPR_REMEDIATION: pr={PR}` marker (for the open PR) has no `FORGE:REMEDIATION:COMPLETE` trail comment on that PR created after it (a trail comment is one that STARTS with `<!-- FORGE:REMEDIATION -->`, contains `<!-- FORGE:REMEDIATION:COMPLETE -->` and comes from a trusted author, read with `scripts/trusted-comments.sh`; a comment that merely quotes the marker never counts, forge#3412; if the comments or the helper are unreadable, fail closed to `needs-human` instead of resuming) → first re-add `needs-human` with a comment ("resuming an interrupted remediation of PR #{PR}"), because remediation's Phase M0 only accepts `needs-human`-gated issues and its M1 clears the label again for a FIXABLE run; then Phase 4R with that marker's kind, skipping the marker post (it is already recorded). This is the silent strand: M1 cleared `needs-human` and the run was lost before M8. It cannot loop: a remediation that finishes posts `FORGE:REMEDIATION:COMPLETE` (its single-attempt guard then returns `ALREADY_DONE`), and any other exit leaves `needs-human` on the issue (Phase 4R adds it on a `BLOCKED` result), so this rule no longer matches. A run lost before M1 still has `needs-human`, so it routes to review, which finds the bound used and stops visibly at `needs-human`. Re-entering review instead would find the bound used and stop without remediation ever finishing. PR merged and issue open → Phase 5 (close). `workflow:invalid` → STOP.
|
|
384
|
+
**Determine resume point**: no `FORGE:INVESTIGATOR` → Phase 1 (investigate). Investigation complete with decompose YES and no `FORGE:DECOMPOSED` → Phase 2 (decompose); with `FORGE:DECOMPOSED` → Phase 5 (`--terminal-state decomposed`). Investigation complete (decompose NO) without `FORGE:BUILDER:COMPLETE` → Phase 3 (build; a partial BUILDER comment is cleaned up by the build phase). `FORGE:BUILDER:COMPLETE` and no merged PR → Phase 4 (review), except an **unfinished remediation handoff**, which is checked first and overrides the checkpoint routing in 0B.5: the issue does NOT carry `needs-human`, and its latest `FORGE:CI_REMEDIATION: pr={PR}`/`FORGE:INPR_REMEDIATION: pr={PR}`/`FORGE:BASESYNC_REMEDIATION: pr={PR}` marker (for the open PR) has no `FORGE:REMEDIATION:COMPLETE` trail comment on that PR created after it (a trail comment is one that STARTS with `<!-- FORGE:REMEDIATION -->`, contains `<!-- FORGE:REMEDIATION:COMPLETE -->` and comes from a trusted author, read with `scripts/trusted-comments.sh`; a comment that merely quotes the marker never counts, forge#3412; if the comments or the helper are unreadable, fail closed to `needs-human` instead of resuming) → first re-add `needs-human` with a comment ("resuming an interrupted remediation of PR #{PR}"), because remediation's Phase M0 only accepts `needs-human`-gated issues and its M1 clears the label again for a FIXABLE run; then Phase 4R with that marker's kind, skipping the marker post (it is already recorded). This is the silent strand: M1 cleared `needs-human` and the run was lost before M8. It cannot loop: a remediation that finishes posts `FORGE:REMEDIATION:COMPLETE` (its single-attempt guard then returns `ALREADY_DONE`), and any other exit leaves `needs-human` on the issue (Phase 4R adds it on a `BLOCKED` result), so this rule no longer matches. A run lost before M1 still has `needs-human`, so it routes to review, which finds the bound used and stops visibly at `needs-human`. Re-entering review instead would find the bound used and stop without remediation ever finishing. PR merged and issue open → Phase 5 (close). `workflow:invalid` → STOP.
|
|
385
385
|
|
|
386
386
|
### 0B.5: Read Phase Checkpoint (MANDATORY — executes before any phase-skip decision)
|
|
387
387
|
|
|
@@ -731,13 +731,13 @@ Post the Phase 4 heartbeat, run Lane Resolution including the lane validation, t
|
|
|
731
731
|
Skill(skill="{FORGE_SKILL_PREFIX}work-on:review", args="{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE}")
|
|
732
732
|
```
|
|
733
733
|
|
|
734
|
-
The review phase owns: ancestry and empty-branch guards, push, PR creation, `workflow:in-review`, the phase-trail preflight (and the single re-dispatch of any missing phase via `Skill`), `/review-pr --auto-merge` (CI gate and reviewed-head guard included), stale-review re-review, and the REVIEW checkpoint. It never closes the issue and never runs remediation: a red CI gate
|
|
734
|
+
The review phase owns: ancestry and empty-branch guards, push, PR creation, `workflow:in-review`, the phase-trail preflight (and the single re-dispatch of any missing phase via `Skill`), `/review-pr --auto-merge` (CI gate and reviewed-head guard included), stale-review re-review, and the REVIEW checkpoint. It never closes the issue and never runs remediation: a red CI gate, an in-PR fix request or a base conflict (`base-conflict`, merge-only base sync) comes back as `REVIEW_RESULT: status: NEXT` for Phase 4R.
|
|
735
735
|
|
|
736
736
|
| `REVIEW_RESULT` | Router action |
|
|
737
737
|
|---|---|
|
|
738
738
|
| `COMPLETE` (PR merged) | Record `PR_NUMBER` → Phase 5 with `--terminal-state merged` |
|
|
739
739
|
| `ALREADY_MERGED` | Same as COMPLETE |
|
|
740
|
-
| `NEXT`, `next: remediate` | Record `PR_NUMBER` and `remediation` (`ci-gate` or `
|
|
740
|
+
| `NEXT`, `next: remediate` | Record `PR_NUMBER` and `remediation` (`ci-gate`, `inpr-fix` or `base-sync`) → Phase 4R (review handoff to remediation) |
|
|
741
741
|
| `BLOCKED` | Terminal: the phase already added `needs-human` (or `workflow:awaiting-merge` for a deploy-gate hold) with the blocker. Do NOT merge, re-run phases, or add labels here. STOP. |
|
|
742
742
|
|
|
743
743
|
### Phase 4R: Remediation handoff from review
|
|
@@ -745,8 +745,14 @@ The review phase owns: ancestry and empty-branch guards, push, PR creation, `wor
|
|
|
745
745
|
The review phase never invokes remediation itself: remediation re-reviews through `/review-pr`, which spawns domain reviewers, so it must run one level below this router (see the Depth Budget). The review phase has checked the bound; this router posts the bound marker **immediately before** invoking remediation, so a handoff interrupted before remediation starts (compaction, crash) is retried on resume instead of being counted as used. It runs at most once per PR per kind.
|
|
746
746
|
|
|
747
747
|
```bash
|
|
748
|
-
# {REMEDIATION_KIND} is the review result's `remediation` value: ci-gate -> CI_REMEDIATION, inpr-fix -> INPR_REMEDIATION.
|
|
749
|
-
|
|
748
|
+
# {REMEDIATION_KIND} is the review result's `remediation` value: ci-gate -> CI_REMEDIATION, inpr-fix -> INPR_REMEDIATION, base-sync -> BASESYNC_REMEDIATION.
|
|
749
|
+
# An unknown kind fails closed: no marker is posted and remediation is not invoked.
|
|
750
|
+
case "{REMEDIATION_KIND}" in
|
|
751
|
+
ci-gate) BOUND_MARKER="CI_REMEDIATION" ;;
|
|
752
|
+
inpr-fix) BOUND_MARKER="INPR_REMEDIATION" ;;
|
|
753
|
+
base-sync) BOUND_MARKER="BASESYNC_REMEDIATION" ;;
|
|
754
|
+
*) echo "unknown remediation kind: {REMEDIATION_KIND}"; exit 1 ;;
|
|
755
|
+
esac
|
|
750
756
|
BOUND_BODY="<!-- FORGE:${BOUND_MARKER}: pr={PR_NUMBER} -->
|
|
751
757
|
Review handed PR #{PR_NUMBER} to remediation ({REMEDIATION_KIND}); the router is dispatching it once."
|
|
752
758
|
gh issue comment {NUMBER} {GH_FLAG} --body "$BOUND_BODY" || { echo "github-unavailable: could not post FORGE:${BOUND_MARKER}; remediation not invoked"; exit 1; } # allowlist:check-command-side-effects
|
|
@@ -765,8 +771,10 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="{PR_NUMBER} --issue {
|
|
|
765
771
|
| `status: BLOCKED`, blocker starts with `github-unavailable:` | Transient, not a decision (see Transient GitHub failures). Re-add `needs-human` with a short comment (remediation's Phase M0 only accepts `needs-human`-gated issues and its M1 clears the label for a FIXABLE run), wait 2 min (5 min before the second retry), then re-invoke `work-on:remediate` with the same args; do NOT re-post the bound marker. At most 2 retries; if the third attempt still returns `github-unavailable:`, fall through to the `status: BLOCKED` (any kind) row below. |
|
|
766
772
|
| `status: BLOCKED` (any kind) | Make sure `needs-human` is on the issue (add it with the blocker as a comment if absent: remediation can exit after its M1 cleared the label). STOP. |
|
|
767
773
|
| `status: ALREADY_DONE` (single-attempt guard: an earlier remediation already completed on this PR), `remediation: inpr-fix` | Same as any other `inpr-fix` outcome below: re-invoke Phase 4 once, which waives the in-PR gate. |
|
|
774
|
+
| `status: ALREADY_DONE`, `remediation: base-sync` | Make sure `needs-human` is on the issue (add it, with a comment naming the PR, that a base sync already ran and the conflicting files if known, if absent). STOP. Do NOT re-invoke Phase 4: a still-conflicting PR would loop. |
|
|
768
775
|
| `status: ALREADY_DONE`, `remediation: ci-gate` | Make sure `needs-human` is on the issue (add it, with a comment naming the PR and that remediation already ran, if absent). STOP. |
|
|
769
776
|
| any other outcome, `remediation: inpr-fix` | Re-invoke Phase 4 (`work-on:review`) once with the same args. Review finds its `INPR_REMEDIATION` bound used, waives the in-PR gate for the current head, files the remaining findings as issues and re-reviews. |
|
|
777
|
+
| any other outcome, `remediation: base-sync` | Terminal: make sure `needs-human` is on the issue (add it, with a comment naming the PR and the conflicting files from remediation's `FORGE:BASESYNC_FAILED` comment, if absent). STOP. Do NOT re-invoke Phase 4: the bound is used and a still-conflicting PR would loop. |
|
|
770
778
|
| any other outcome, `remediation: ci-gate` | Terminal: remediation left `needs-human` (or `workflow:awaiting-merge`) with its reason. STOP. |
|
|
771
779
|
|
|
772
780
|
Row order matters: the `github-unavailable:` row is evaluated before the generic `status: BLOCKED` (any kind) row, which only handles non-transient blockers and the exhausted retry budget (see Transient GitHub failures). <!-- Added: forge#3402 -->
|
package/docs/WORK-ON-RUNTIME.md
CHANGED
|
@@ -108,12 +108,12 @@ remediate L2, reviewers L3, same as the table.
|
|
|
108
108
|
|
|
109
109
|
| Phase result | Router action |
|
|
110
110
|
|--------------|---------------|
|
|
111
|
-
| `REVIEW_RESULT: status: NEXT, next: remediate` | Phase 4R: post the bound marker (`CI_REMEDIATION` / `INPR_REMEDIATION`) immediately before invoking `work-on:remediate`, so an interrupted handoff is retried, not counted. `AUTO-LANDED` → done (remediation already ran close). `REREVIEW_REQUIRED` → fallback re-review from the router. `ALREADY_DONE` (single-attempt guard) → `inpr-fix`: re-invoke review (waive); `ci-gate`: ensure `needs-human`, stop. `BLOCKED` → ensure `needs-human`, stop. Resume after an interrupted remediation (no `needs-human`, no later `REMEDIATION:COMPLETE`) re-adds `needs-human` and returns to 4R, not to review. Other outcome: `inpr-fix` → re-invoke review once (it waives the in-PR gate); `ci-gate` → terminal. |
|
|
111
|
+
| `REVIEW_RESULT: status: NEXT, next: remediate` | Phase 4R: post the bound marker (`CI_REMEDIATION` / `INPR_REMEDIATION` / `BASESYNC_REMEDIATION`, the last for `remediation: base-sync`, a merge-only base sync) immediately before invoking `work-on:remediate`, so an interrupted handoff is retried, not counted. `AUTO-LANDED` → done (remediation already ran close). `REREVIEW_REQUIRED` → fallback re-review from the router. `ALREADY_DONE` (single-attempt guard) → `inpr-fix`: re-invoke review (waive); `ci-gate`: ensure `needs-human`, stop. `BLOCKED` → ensure `needs-human`, stop. Resume after an interrupted remediation (no `needs-human`, no later `REMEDIATION:COMPLETE`) re-adds `needs-human` and returns to 4R, not to review. Other outcome: `inpr-fix` → re-invoke review once (it waives the in-PR gate); `ci-gate` → terminal. |
|
|
112
112
|
| `REMEDIATE_RESULT` from `--remediate` entry | unchanged (Phase 0A.1) |
|
|
113
113
|
|
|
114
114
|
## 6. What does not change
|
|
115
115
|
|
|
116
|
-
- Build still sequences its own children
|
|
116
|
+
- Build still sequences its own children (worktree, then context / architect / implement / validate). The headless engine (`bin/engine/phases.mjs`) is **not** exempt from the argument contract: every phase sub-skill takes all inputs as arguments, so each engine phase builds them via `buildArgs` (`--repo`, `--gh-flag`, `--base`, `--worktree`, `--branch`, `--pr`, `--terminal-state` as the target spec's `argument-hint` requires). The engine's phase table is investigate, decompose, build, review, remediate, close; `context` and `architect` are no longer engine phases because they need the worktree build creates. `bin/engine/phases-args.test.mjs` fails CI when a phase's args drift from its spec's `argument-hint`.
|
|
117
117
|
- `/orchestrate` still dispatches one worker `Agent` per issue (L1).
|
|
118
118
|
- Phases keep their specs, `*_RESULT` contracts and GitHub markers; phase-trail verification is unchanged.
|
|
119
119
|
- Solo `/work-on` gets one extra layer of headroom.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|
|
@@ -34,6 +34,11 @@ import { RESERVED_TYPES } from './types.js';
|
|
|
34
34
|
* "Universal Phase Dispatcher" table and `bin/engine/phases.mjs`'s `PHASES`
|
|
35
35
|
* array).
|
|
36
36
|
*
|
|
37
|
+
* `context` and `architect` are intentionally NOT engine phase ids (forge#3499):
|
|
38
|
+
* `work-on/build` creates the worktree they need as `--repo-path` and invokes
|
|
39
|
+
* them itself. Their `PHASE_MARKERS` entries below stay — the interactive
|
|
40
|
+
* SubagentStop hook still gates on those markers.
|
|
41
|
+
*
|
|
37
42
|
* `decompose` and `remediate` (forge#2379) are branch phases, not part of the
|
|
38
43
|
* six-phase linear happy path — `decompose` is reached only when `investigate`
|
|
39
44
|
* signals `DECOMPOSE:YES`, and `remediate` only when `review` escalates to
|
|
@@ -41,7 +46,7 @@ import { RESERVED_TYPES } from './types.js';
|
|
|
41
46
|
* position `bin/engine/phases.mjs`'s `PHASES` array declares them, because
|
|
42
47
|
* `scripts/check-phase-registry-drift.mjs` requires the two lists to match
|
|
43
48
|
* index-for-index — see that file's own doc comment. */
|
|
44
|
-
export const PHASE_IDS = ['investigate', 'decompose', '
|
|
49
|
+
export const PHASE_IDS = ['investigate', 'decompose', 'build', 'review', 'remediate', 'close'];
|
|
45
50
|
|
|
46
51
|
/**
|
|
47
52
|
* @typedef {Object} PhaseMarkerEntry
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# check-branch-ancestry.sh — Flag merge commits that bring in history from outside the PR base.
|
|
6
|
+
#
|
|
7
|
+
# Usage: check-branch-ancestry.sh <branch-or-HEAD> <base-ref>
|
|
8
|
+
#
|
|
9
|
+
# For every merge commit in <base-ref>..<branch>, each parent other than the first must be an
|
|
10
|
+
# ancestor of <base-ref>. Merging the base itself into the branch (a base sync) passes; merging
|
|
11
|
+
# a milestone or other foreign branch fails.
|
|
12
|
+
#
|
|
13
|
+
# Exit codes:
|
|
14
|
+
# 0 clean (no merges, or only base-sync merges)
|
|
15
|
+
# 1 foreign merge(s) found; each printed as "<merge-sha> <foreign-parent-sha> <subject>"
|
|
16
|
+
# 2 error (bad usage, unresolvable ref, git failure) — callers MUST treat as a failure, never a pass
|
|
17
|
+
#
|
|
18
|
+
# The first-parent line of <base-ref>..<branch> must also not carry foreign history: a branch cut
|
|
19
|
+
# from a milestone line (then, say, syncing the base) has no foreign non-first parent, so each
|
|
20
|
+
# first-parent commit is also checked against the milestone refs (refs/remotes/origin/milestone/*,
|
|
21
|
+
# refs/heads/milestone/*). A commit reachable from one of them but not from <base-ref> is foreign.
|
|
22
|
+
# Override the ref patterns with CHECK_BRANCH_ANCESTRY_FOREIGN_REFS (space-separated for-each-ref patterns).
|
|
23
|
+
#
|
|
24
|
+
# Known limit: foreign history on a branch with no milestone ref available locally is not detected.
|
|
25
|
+
# Portable to bash 3.2: no mapfile, no grep -P, no state accumulated inside piped subshells.
|
|
26
|
+
|
|
27
|
+
set -u
|
|
28
|
+
|
|
29
|
+
if [ "$#" -ne 2 ] || [ -z "${1:-}" ] || [ -z "${2:-}" ]; then
|
|
30
|
+
echo "usage: check-branch-ancestry.sh <branch-or-HEAD> <base-ref>" >&2
|
|
31
|
+
exit 2
|
|
32
|
+
fi
|
|
33
|
+
BRANCH="$1"
|
|
34
|
+
BASE="$2"
|
|
35
|
+
|
|
36
|
+
git rev-parse --verify --quiet "${BRANCH}^{commit}" >/dev/null 2>&1 || { echo "check-branch-ancestry: cannot resolve branch ref '${BRANCH}'" >&2; exit 2; }
|
|
37
|
+
git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1 || { echo "check-branch-ancestry: cannot resolve base ref '${BASE}'" >&2; exit 2; }
|
|
38
|
+
|
|
39
|
+
MERGES=$(git rev-list --merges "${BASE}..${BRANCH}" 2>/dev/null) || { echo "check-branch-ancestry: git rev-list failed" >&2; exit 2; }
|
|
40
|
+
|
|
41
|
+
FOREIGN=0
|
|
42
|
+
for M in $MERGES; do
|
|
43
|
+
LINE=$(git rev-list --parents -n 1 "$M" 2>/dev/null) || { echo "check-branch-ancestry: cannot read parents of $M" >&2; exit 2; }
|
|
44
|
+
PARENTS=$(printf '%s\n' "$LINE" | cut -d' ' -f3-)
|
|
45
|
+
SUBJECT=$(git log -n 1 --format=%s "$M" 2>/dev/null)
|
|
46
|
+
for P in $PARENTS; do
|
|
47
|
+
git merge-base --is-ancestor "$P" "$BASE" >/dev/null 2>&1
|
|
48
|
+
rc=$?
|
|
49
|
+
if [ "$rc" -eq 1 ]; then
|
|
50
|
+
printf '%s %s %s\n' "$M" "$P" "$SUBJECT"
|
|
51
|
+
FOREIGN=1
|
|
52
|
+
elif [ "$rc" -ne 0 ]; then
|
|
53
|
+
echo "check-branch-ancestry: merge-base --is-ancestor failed (rc=$rc) for $P" >&2
|
|
54
|
+
exit 2
|
|
55
|
+
fi
|
|
56
|
+
done
|
|
57
|
+
done
|
|
58
|
+
|
|
59
|
+
# First-parent line: every commit must be absent from every milestone ref (other than base/branch themselves).
|
|
60
|
+
BASE_SHA=$(git rev-parse "${BASE}^{commit}") || exit 2
|
|
61
|
+
BRANCH_SHA=$(git rev-parse "${BRANCH}^{commit}") || exit 2
|
|
62
|
+
FP=$(git rev-list --first-parent "${BASE}..${BRANCH}" 2>/dev/null) || { echo "check-branch-ancestry: git rev-list --first-parent failed" >&2; exit 2; }
|
|
63
|
+
# shellcheck disable=SC2086
|
|
64
|
+
REFS=$(git for-each-ref --format='%(refname)' ${CHECK_BRANCH_ANCESTRY_FOREIGN_REFS:-refs/remotes/origin/milestone/ refs/heads/milestone/} 2>/dev/null) || { echo "check-branch-ancestry: git for-each-ref failed" >&2; exit 2; }
|
|
65
|
+
for F in $REFS; do
|
|
66
|
+
F_SHA=$(git rev-parse --verify --quiet "${F}^{commit}" 2>/dev/null) || continue
|
|
67
|
+
[ "$F_SHA" = "$BASE_SHA" ] || [ "$F_SHA" = "$BRANCH_SHA" ] && continue
|
|
68
|
+
for C in $FP; do
|
|
69
|
+
git merge-base --is-ancestor "$C" "$F_SHA" >/dev/null 2>&1
|
|
70
|
+
rc=$?
|
|
71
|
+
if [ "$rc" -eq 0 ]; then
|
|
72
|
+
printf '%s %s first-parent history reachable from foreign ref %s\n' "$C" "$F_SHA" "$F"
|
|
73
|
+
FOREIGN=1
|
|
74
|
+
elif [ "$rc" -ne 1 ]; then
|
|
75
|
+
echo "check-branch-ancestry: merge-base --is-ancestor failed (rc=$rc) for $C" >&2
|
|
76
|
+
exit 2
|
|
77
|
+
fi
|
|
78
|
+
done
|
|
79
|
+
done
|
|
80
|
+
|
|
81
|
+
[ "$FOREIGN" -eq 0 ] || exit 1
|
|
82
|
+
exit 0
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# Fixtures for scripts/check-branch-ancestry.sh. Run: bash scripts/check-branch-ancestry.test.sh (bash 3.2 compatible)
|
|
6
|
+
|
|
7
|
+
set -u
|
|
8
|
+
DIR=$(cd "$(dirname "$0")" && pwd)
|
|
9
|
+
S="$DIR/check-branch-ancestry.sh"
|
|
10
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/check-branch-ancestry-test.XXXXXX")
|
|
11
|
+
trap 'rm -rf "$T"' EXIT
|
|
12
|
+
pass=0; fail=0
|
|
13
|
+
OUT=""
|
|
14
|
+
|
|
15
|
+
g() { git -C "$T/r" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
|
|
16
|
+
commit() { echo "$1" > "$T/r/$1.txt"; g add -A; g commit -m "$1"; }
|
|
17
|
+
# run <branch> <base>: sets OUT and RC
|
|
18
|
+
run() { OUT=$(cd "$T/r" && bash "$S" "$@" 2>&1); RC=$?; }
|
|
19
|
+
expect() { # expect <want_rc> <desc> [want_substring]
|
|
20
|
+
if [ "$RC" = "$1" ] && { [ -z "${3:-}" ] || case "$OUT" in *"$3"*) true ;; *) false ;; esac; }; then
|
|
21
|
+
pass=$((pass+1))
|
|
22
|
+
else
|
|
23
|
+
fail=$((fail+1)); echo "FAIL: $2 (rc=$RC, want $1)"; printf '%s\n' "$OUT" | sed 's/^/ /'
|
|
24
|
+
fi
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
mkdir "$T/r"; git -C "$T/r" init -q -b staging >/dev/null 2>&1 || { git -C "$T/r" init -q; g checkout -b staging; }
|
|
28
|
+
commit base
|
|
29
|
+
# milestone diverges from staging after staging2
|
|
30
|
+
commit staging2
|
|
31
|
+
g checkout -b milestone/m1; commit ms1
|
|
32
|
+
g checkout staging
|
|
33
|
+
# feature branch off the original base commit
|
|
34
|
+
g checkout -b feat HEAD~1; commit feat1
|
|
35
|
+
|
|
36
|
+
# 1. no merges
|
|
37
|
+
run feat staging; expect 0 "no merges passes"
|
|
38
|
+
|
|
39
|
+
# 2. base-sync merge passes (staging advances, then is merged into feat)
|
|
40
|
+
g merge --no-ff -m "Merge staging into feat" staging
|
|
41
|
+
run feat staging; expect 0 "base-sync merge passes"
|
|
42
|
+
run HEAD staging; expect 0 "HEAD form passes"
|
|
43
|
+
|
|
44
|
+
# 3. staging advances further and is synced again: still passes
|
|
45
|
+
g checkout -q staging; commit staging3; g checkout -q feat
|
|
46
|
+
g merge --no-ff -m "Merge staging again" staging
|
|
47
|
+
run feat staging; expect 0 "repeated base-sync merges pass"
|
|
48
|
+
|
|
49
|
+
# 4. foreign merge fails, foreign parent printed
|
|
50
|
+
MS=$(git -C "$T/r" rev-parse milestone/m1)
|
|
51
|
+
g merge --no-ff -m "Merge milestone into feat" milestone/m1
|
|
52
|
+
run feat staging; expect 1 "foreign merge fails" "$MS"
|
|
53
|
+
case "$OUT" in *"Merge milestone into feat"*) pass=$((pass+1)) ;; *) fail=$((fail+1)); echo "FAIL: subject not printed"; echo "$OUT" ;; esac
|
|
54
|
+
|
|
55
|
+
# 5. octopus with one foreign parent fails
|
|
56
|
+
g checkout -q -b feat2 staging; commit f2
|
|
57
|
+
g checkout -q staging; g checkout -q -b side; commit side1
|
|
58
|
+
g checkout -q feat2
|
|
59
|
+
g merge --no-ff -m "octopus" side milestone/m1
|
|
60
|
+
run feat2 staging; expect 1 "octopus with foreign parent fails" "$MS"
|
|
61
|
+
|
|
62
|
+
# 5b. branch cut from a milestone line, then syncing the base: foreign first-parent history fails
|
|
63
|
+
MS1=$(git -C "$T/r" rev-parse milestone/m1)
|
|
64
|
+
g checkout -q -b feat3 milestone/m1; commit f3
|
|
65
|
+
g merge --no-ff -m "Merge staging into feat3" staging
|
|
66
|
+
run feat3 staging; expect 1 "milestone-cut branch with base sync fails" "$MS1"
|
|
67
|
+
|
|
68
|
+
# 5c. milestone-cut branch with no merge at all also fails
|
|
69
|
+
g checkout -q -b feat4 milestone/m1; commit f4
|
|
70
|
+
run feat4 staging; expect 1 "milestone-cut branch without merges fails" "$MS1"
|
|
71
|
+
|
|
72
|
+
# 6. bad refs fail closed (exit 2)
|
|
73
|
+
run nosuchbranch staging; expect 2 "bad branch ref exits 2"
|
|
74
|
+
run feat nosuchbase; expect 2 "bad base ref exits 2"
|
|
75
|
+
run feat; expect 2 "missing arg exits 2"
|
|
76
|
+
|
|
77
|
+
echo "check-branch-ancestry tests: $pass passed, $fail failed"
|
|
78
|
+
[ "$fail" -eq 0 ]
|
|
@@ -13,7 +13,7 @@ ok() { PASS=$((PASS+1)); }
|
|
|
13
13
|
bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
|
|
14
14
|
expect() { [ "$2" = "$3" ] && ok || bad "$1 (got '$3' want '$2')"; }
|
|
15
15
|
|
|
16
|
-
SITES="commands/work-on.md commands/work-on/review.md commands/work-on/investigate.md commands/work-on/decompose.md commands/work-on/build.md commands/work-on/close.md commands/review-pr.md commands/orchestrate/phase-1-resolve.md commands/orchestrate/phase-4-execution.md"
|
|
16
|
+
SITES="commands/work-on.md commands/work-on/build/validate.md commands/quality-gate.md commands/work-on/review.md commands/work-on/investigate.md commands/work-on/decompose.md commands/work-on/build.md commands/work-on/close.md commands/review-pr.md commands/orchestrate/phase-1-resolve.md commands/orchestrate/phase-4-execution.md"
|
|
17
17
|
|
|
18
18
|
# extract the bootstrap block (comment line .. closing top-level fi), indentation stripped
|
|
19
19
|
# Block ends at the first line (after the marker) that starts with `fi` at the marker's own indentation.
|