forgedock 1.10.3 → 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.
Files changed (58) hide show
  1. package/bin/engine/phases-args.test.mjs +112 -0
  2. package/bin/engine/phases.mjs +152 -45
  3. package/bin/engine/runlog.mjs +9 -0
  4. package/bin/engine-cli.mjs +13 -2
  5. package/bin/engine.mjs +144 -18
  6. package/bin/forgedock.mjs +2 -2
  7. package/bin/runner.mjs +89 -12
  8. package/commands/ci-audit.md +3 -3
  9. package/commands/compat-audit.md +1 -1
  10. package/commands/deploy-pr.md +6 -6
  11. package/commands/explain.md +2 -2
  12. package/commands/fix-ci.md +2 -2
  13. package/commands/forge-stats.md +1 -1
  14. package/commands/forgedock-init.md +3 -3
  15. package/commands/optimize.md +5 -5
  16. package/commands/orchestrate/phase-1-resolve.md +2 -2
  17. package/commands/orchestrate/phase-4-execution.md +208 -48
  18. package/commands/orchestrate/phase-6-report.md +29 -0
  19. package/commands/quality-gate.md +97 -13
  20. package/commands/recover-orphans.md +6 -6
  21. package/commands/review-pr-agents/protocols.md +1 -0
  22. package/commands/review-pr-staging.md +28 -6
  23. package/commands/review-pr.md +509 -132
  24. package/commands/security-audit.md +1 -1
  25. package/commands/spec-doctor.md +2 -2
  26. package/commands/test-gate.md +7 -7
  27. package/commands/work-on/build/architect.md +21 -0
  28. package/commands/work-on/build/context.md +1 -1
  29. package/commands/work-on/build/implement.md +17 -0
  30. package/commands/work-on/build/validate.md +60 -9
  31. package/commands/work-on/build.md +98 -9
  32. package/commands/work-on/close.md +33 -11
  33. package/commands/work-on/decompose.md +19 -5
  34. package/commands/work-on/investigate.md +34 -4
  35. package/commands/work-on/remediate.md +117 -13
  36. package/commands/work-on/review.md +66 -26
  37. package/commands/work-on.md +32 -15
  38. package/docs/CONFIG.md +30 -0
  39. package/docs/RELEASE-NOTES.md +9 -0
  40. package/docs/WORK-ON-RUNTIME.md +24 -4
  41. package/docs/site/architecture.md +1 -1
  42. package/docs/spec/forge-protocol-v1.md +10 -0
  43. package/docs/spec/review-protocol.md +1 -0
  44. package/package.json +1 -1
  45. package/packages/protocol/src/phases.js +6 -1
  46. package/packages/protocol/src/types.js +17 -0
  47. package/scripts/check-branch-ancestry.sh +82 -0
  48. package/scripts/check-branch-ancestry.test.sh +78 -0
  49. package/scripts/check-spec-bash.sh +45 -2
  50. package/scripts/check-spec-bash.test.sh +24 -1
  51. package/scripts/check-spec-markers.sh +55 -0
  52. package/scripts/diff-size.sh +84 -0
  53. package/scripts/diff-size.test.sh +117 -0
  54. package/scripts/forge-root.test.sh +16 -1
  55. package/scripts/remediation-marker-match.test.sh +51 -0
  56. package/scripts/review-pr-arguments.test.sh +180 -0
  57. package/scripts/spec-no-worktree-script.test.sh +46 -0
  58. package/scripts/trusted-comments.test.sh +23 -0
@@ -58,6 +58,8 @@ When `IS_OPENCODE_RUNTIME=true`, lowercase native `task` is the preferred isolat
58
58
  3. **Neither tool is available**: this is a genuine setup defect, not a routing decision — HARD STOP, post a PR/issue comment explaining that no sub-agent dispatch tool is available, add `needs-human`, and exit without posting a verdict. Do NOT fall back to reviewing inline in the orchestrator's own context — inline self-review is strictly weaker than an isolated fresh-context reviewer and is never a substitute for a missing dispatch tool.
59
59
  4. **Dispatch pool exhausted or dispatch call fails**: this is distinct from tool absence. If any selected reviewer cannot be launched because the runtime reports a sub-agent/session/pool limit (or any dispatch call fails), HARD STOP immediately. Do not review that domain inline, do not silently reduce the panel, and do not merge. Mark the PR `review-degraded`, add `needs-human` to the linked issue, post `<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->` with the selected and completed reviewer counts, then exit without a `FORGE:REVIEW` verdict. A later fresh session must re-run the full selected panel.
60
60
 
61
+ **Reviewer completion model (spawn-site ownership)**: `Task` and OpenCode `task` calls are joined, so their return is the completion signal. `Agent` is async-only (no wait parameter): the call returns `Async agent launched`, and when this skill runs inside a forked phase (`work-on:review` / `work-on:remediate`) the reviewers' completion notifications are attributed to the root session, not to this phase (see `docs/WORK-ON-RUNTIME.md` R6). With `{DISPATCH_TOOL}=Agent`, never end the turn waiting for notifications and never treat a missing notification as a missing review: the completion signal is each reviewer's `FORGE:REVIEW-AGENT:{domain}` PR comment for the reviewed head SHA, read by the bounded wait in Phase 4.
62
+
61
63
  **Do not halt to ask the operator which tool to use.** Steps 1–2 are deterministic and fully resolve the common case; only step 3 (both absent) requires a stop, and even then the action is HARD STOP + `needs-human`, not a question back to the operator.
62
64
 
63
65
  Everywhere this file (and `review-pr-agents.md` / the `review-pr-agents/*.md` persona files) says `Task(...)`, read it as `{DISPATCH_TOOL}(...)` using the value resolved here.
@@ -106,7 +108,100 @@ If `$ARGUMENTS` contains `--auto-merge`, this review was invoked from `/work-on`
106
108
  Example: "3126 --auto-merge --issue 3124 --base staging --gh-flag -R $GH_REPO --worktree /path/to/worktree"
107
109
  ```
108
110
 
109
- Extract: `PR_NUMBER`, `AUTO_MERGE=true`, `MERGE_ISSUE`, `MERGE_BASE`, `MERGE_GH_FLAG`, `MERGE_WORKTREE` (optional — the absolute path to the git worktree to clean up after merge)
111
+ Extract: `PR_NUMBER`, `AUTO_MERGE=true`, `MERGE_ISSUE`, `MERGE_BASE`, `MERGE_GH_FLAG`, `MERGE_WORKTREE` (optional — the absolute path to the git worktree to clean up after merge) using the **Argument Parse** block below.
112
+
113
+ **`$ARGUMENTS` is the whole argument string, never a PR number.** Every `gh` call in this spec uses `"$PR_NUMBER"` (and `-R "$REPO"`), bound once by the block below. Shell state does not persist between Bash calls, so any later Bash block that reads `PR_NUMBER`, `REPO`, `REPO_FLAG` or `MERGE_*` must re-run this block first. <!-- Added: forge#3404 -->
114
+
115
+ #### Argument Parse
116
+
117
+ ```bash
118
+ # BEGIN review-pr-arg-parse
119
+ # Tokenize the argument string without eval. First token is the PR ref (number or .../pull/N URL)
120
+ # or a mode keyword; the rest are flags.
121
+ # The loader substitutes the argument text into this block BEFORE bash parses it, so it is read from a
122
+ # quoted heredoc (inert for quotes, $(...), backticks and backslashes) and never from a quoted assignment.
123
+ # NONCE RULE: before running, replace the word NONCE in BOTH delimiter lines below with a fresh random hex string
124
+ # (e.g. `openssl rand -hex 16`) that does not occur in the argument text. Never run the literal NONCE delimiter. <!-- Added: forge#3466 -->
125
+ IFS= read -r -d '' ARGS_RAW <<'FORGE_ARGS_EOF_NONCE'
126
+ $ARGUMENTS
127
+ FORGE_ARGS_EOF_NONCE
128
+ ARGS_RAW="${ARGS_RAW%$'\n'}"
129
+ # Fail closed: a string holding a quote, backtick, dollar sign, backslash, newline or tab is rejected whole.
130
+ # Nothing is parsed from it, so no PR number, repo, merge value or auto-merge flag survives.
131
+ case "$ARGS_RAW" in
132
+ *'"'*|*'`'*|*'$'*|*'\'*|*$'\n'*|*$'\t'*)
133
+ echo "review-pr: rejected argument string (contains a quote, backtick, dollar sign, backslash, newline or tab) - nothing parsed, auto-merge not honoured" >&2
134
+ ARGS_RAW="" ;;
135
+ esac
136
+ read -r -a ARG_TOKENS <<< "$ARGS_RAW"
137
+ PR_REF="${ARG_TOKENS[0]:-}"
138
+ PR_NUMBER=""
139
+ case "$PR_REF" in
140
+ *[!0-9]*) PR_NUMBER=$(printf '%s' "$PR_REF" | sed -nE 's#^https?://[^ ]*/pull/([0-9]+).*$#\1#p') ;;
141
+ "") ;;
142
+ *) PR_NUMBER="$PR_REF" ;;
143
+ esac
144
+ AUTO_MERGE=false; THOROUGH=false
145
+ MERGE_ISSUE=""; MERGE_BASE=""; MERGE_WORKTREE=""; MERGE_GH_FLAG=""; REPO_FLAG=""; REPO=""
146
+ arg_i=1
147
+ case "$PR_REF" in -*) PR_NUMBER=""; arg_i=0 ;; esac # leading flag, no PR ref
148
+ while [ "$arg_i" -lt "${#ARG_TOKENS[@]}" ]; do
149
+ arg_t="${ARG_TOKENS[$arg_i]}"
150
+ case "$arg_t" in
151
+ --auto-merge) AUTO_MERGE=true ;; # flag parse only, no side effect <!-- allowlist:check-command-side-effects -->
152
+ --thorough) THOROUGH=true ;;
153
+ --issue|--base|--worktree)
154
+ # A value that is empty or starts with "-" is another flag: treat the value as missing, do not consume it
155
+ arg_v="${ARG_TOKENS[$((arg_i + 1))]:-}"
156
+ case "$arg_v" in
157
+ ""|-*) echo "review-pr: $arg_t needs a value, got '${arg_v}' - ignoring" >&2 ;;
158
+ *) arg_i=$((arg_i + 1))
159
+ case "$arg_t" in
160
+ --issue) MERGE_ISSUE="$arg_v" ;;
161
+ --base) MERGE_BASE="$arg_v" ;;
162
+ --worktree) MERGE_WORKTREE="$arg_v" ;;
163
+ esac ;;
164
+ esac ;;
165
+ --gh-flag)
166
+ # Value is -R owner/repo, unquoted (a double quote rejects the whole string above; single quotes are
167
+ # stripped for older callers): consume flag + repo tokens
168
+ arg_f=$(printf '%s' "${ARG_TOKENS[$((arg_i + 1))]:-}" | tr -d "\"'")
169
+ if [ "$arg_f" = "-R" ] || [ "$arg_f" = "--repo" ]; then
170
+ arg_i=$((arg_i + 1))
171
+ arg_v=$(printf '%s' "${ARG_TOKENS[$((arg_i + 1))]:-}" | tr -d "\"'")
172
+ case "$arg_v" in
173
+ ""|-*) echo "review-pr: --gh-flag needs owner/repo, got '${arg_v}' - ignoring" >&2 ;;
174
+ *) arg_i=$((arg_i + 1)); REPO="$arg_v" ;;
175
+ esac
176
+ else
177
+ echo "review-pr: --gh-flag expects -R owner/repo - ignoring" >&2
178
+ fi ;;
179
+ esac
180
+ arg_i=$((arg_i + 1))
181
+ done
182
+ # Shape-check parsed values before anything substitutes them into shell or jq; invalid values are
183
+ # cleared (fail closed: a merge request without a valid issue number returns BLOCKED in Phase 8).
184
+ if [ -n "$MERGE_ISSUE" ] && ! printf '%s' "$MERGE_ISSUE" | grep -qE '^[0-9]+$'; then
185
+ echo "review-pr: --issue must be numeric - ignoring '$MERGE_ISSUE'" >&2; MERGE_ISSUE=""
186
+ fi
187
+ if [ -n "$REPO" ] && ! printf '%s' "$REPO" | grep -qE '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'; then
188
+ echo "review-pr: --gh-flag repo must be owner/repo - ignoring '$REPO'" >&2; REPO=""
189
+ fi
190
+ if [ -n "$MERGE_BASE" ] && ! printf '%s' "$MERGE_BASE" | grep -qE '^[A-Za-z0-9._/-]+$'; then
191
+ echo "review-pr: --base must be a branch name - ignoring '$MERGE_BASE'" >&2; MERGE_BASE=""
192
+ fi
193
+ if [ -n "$MERGE_WORKTREE" ] && ! printf '%s' "$MERGE_WORKTREE" | grep -qE '^/[A-Za-z0-9._/@+=,:-]+$'; then
194
+ echo "review-pr: --worktree must be an absolute path without shell metacharacters - ignoring '$MERGE_WORKTREE'" >&2; MERGE_WORKTREE=""
195
+ fi
196
+ if [ -n "$REPO" ]; then MERGE_GH_FLAG="-R $REPO"; REPO_FLAG="-R $REPO"; fi
197
+ if [ -n "$PR_NUMBER" ] && [ -z "$REPO" ]; then
198
+ REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || echo "")
199
+ [ -n "$REPO" ] && REPO_FLAG="-R $REPO"
200
+ fi
201
+ # END review-pr-arg-parse
202
+ ```
203
+
204
+ **Argument injection hardening** <!-- Added: forge#3466 -->: the loader substitutes `$ARGUMENTS` textually, so a quoted assignment (`ARGS_RAW="..."`) can be broken out of by any caller that forwards text it does not control. The block therefore reads the string from a quoted heredoc and rejects any string containing a quote, backtick, dollar sign, backslash, newline or tab: it prints `review-pr: rejected argument string`, parses nothing, and binds `PR_NUMBER`, `REPO` and `MERGE_*` empty with `AUTO_MERGE=false`, so `--auto-merge` is never honoured from a rejected string (a run with no PR number stops in Phase -1 before any `gh pr view`). The heredoc delimiter is not a constant: the spec carries the placeholder `FORGE_ARGS_EOF_NONCE` and the executor MUST replace `NONCE` with a fresh random hex string before running, so a caller cannot pre-compute a delimiter line (a newline followed by the literal placeholder or any earlier public token stays inert text and is then rejected by the newline check). Residual risk: this depends on the executor following the NONCE rule; the complete fix is for the loader to pass arguments out of band. Callers must pass `--gh-flag -R owner/repo` unquoted.
110
205
 
111
206
  **`--auto-merge` requires `--issue`** <!-- Added: forge#3102, forge#3124 -->: the phase-trail gate in Phase 8 verifies the trail of the linked issue, so `--issue` is mandatory whenever `AUTO_MERGE=true`. Do NOT infer `MERGE_ISSUE` from the PR: the PR body's closing reference and the head-branch suffix are both author-controlled (and the closing-issue reference list is empty for PRs based on a non-default branch such as `staging`), so neither is a trustworthy binding. If `AUTO_MERGE=true` and `MERGE_ISSUE` is empty, Phase 8 does not merge and returns the documented `REVIEW_RESULT: status: BLOCKED`, blocker: "auto-merge requires --issue". The PR stays open for a human or a re-run with `--issue`.
112
207
 
@@ -114,11 +209,11 @@ If `--auto-merge` is NOT present, `AUTO_MERGE=false` — Phase 8 (Auto-Merge) wi
114
209
 
115
210
  ### Thoroughness Flag
116
211
 
117
- If `$ARGUMENTS` contains `--thorough`, set `THOROUGH=true`. This restores full union-dispatch (all matched domain agents run) for release-critical PRs. Default is `THOROUGH=false` — risk-scaled dispatch (2-3 agents).
212
+ If the arguments contain `--thorough` (parsed above), set `THOROUGH=true`. This restores full union-dispatch (all matched domain agents run) for release-critical PRs. Default is `THOROUGH=false` — risk-scaled dispatch (2-3 agents).
118
213
 
119
214
  ```bash
120
- THOROUGH=false
121
- echo "$ARGUMENTS" | grep -q "\-\-thorough" && THOROUGH=true
215
+ # THOROUGH is bound by the Argument Parse block (default false)
216
+ echo "THOROUGH=$THOROUGH"
122
217
  ```
123
218
 
124
219
  ---
@@ -130,18 +225,24 @@ echo "$ARGUMENTS" | grep -q "\-\-thorough" && THOROUGH=true
130
225
  Resolve the PR number and post a routing marker immediately. This creates an audit trail — if a PR has no `FORGE:REVIEW_ROUTE` comment after a review command was run, the review was bypassed or never started.
131
226
 
132
227
  ```bash
133
- # Determine REVIEW_MODE from $ARGUMENTS before any routing decision
134
- REVIEW_MODE_RAW="$ARGUMENTS"
135
- if echo "$ARGUMENTS" | grep -qE '^(staging|feature|staging:feature)$'; then
228
+ # Determine REVIEW_MODE from the first token (PR_REF, bound by Argument Parse) before any routing decision
229
+ REVIEW_MODE_RAW="$ARGS_RAW"
230
+ if echo "$PR_REF" | grep -qE '^(staging|feature|staging:feature)$'; then
136
231
  REVIEW_MODE="staging-keyword"
137
232
  ROUTE_PR_NUMBER="(resolved by staging sub-command)"
138
- elif echo "$ARGUMENTS" | grep -qE '^(open|all)$'; then
233
+ elif echo "$PR_REF" | grep -qE '^(open|all)$'; then
139
234
  REVIEW_MODE="multi-pr"
140
235
  ROUTE_PR_NUMBER="(list mode)"
141
236
  else
142
- # Single PR number or URL — resolve HEAD/BASE now
143
- PR_ROUTE_INFO=$(gh pr view $ARGUMENTS --json number,baseRefName,headRefName --jq '{number:.number,base:.baseRefName,head:.headRefName}')
237
+ # Single PR number or URL. An empty PR_NUMBER (rejected or unparseable argument string) must stop here:
238
+ # `gh pr view ""` falls back to the current branch's PR and would review the wrong PR. <!-- Added: forge#3466 -->
239
+ if [ -z "$PR_NUMBER" ]; then
240
+ echo "review-pr: no PR number resolved from the argument string - stopping (nothing reviewed, nothing posted)" >&2
241
+ exit 1
242
+ fi
243
+ PR_ROUTE_INFO=$(gh pr view "$PR_NUMBER" ${REPO_FLAG} --json number,baseRefName,headRefName --jq '{number:.number,base:.baseRefName,head:.headRefName}')
144
244
  ROUTE_PR_NUMBER=$(echo "$PR_ROUTE_INFO" | jq -r '.number')
245
+ PR_NUMBER="$ROUTE_PR_NUMBER"
145
246
  ROUTE_HEAD=$(echo "$PR_ROUTE_INFO" | jq -r '.head')
146
247
  ROUTE_BASE=$(echo "$PR_ROUTE_INFO" | jq -r '.base')
147
248
  if [ "$ROUTE_HEAD" = "staging" ] && [ "$ROUTE_BASE" = "main" ] || [ "$ROUTE_HEAD" = "feature" ] && [ "$ROUTE_BASE" = "main" ]; then
@@ -151,7 +252,7 @@ else
151
252
  fi
152
253
  fi
153
254
 
154
- REVIEW_SHA_ROUTE=$(gh pr view ${ROUTE_PR_NUMBER:-$ARGUMENTS} --json headRefOid --jq '.headRefOid' 2>/dev/null | cut -c1-7 || echo "n/a")
255
+ REVIEW_SHA_ROUTE=$(gh pr view "${ROUTE_PR_NUMBER:-$PR_NUMBER}" ${REPO_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null | cut -c1-7 || echo "n/a")
155
256
 
156
257
  # spec-evolution guard — MUST run before posting FORGE:REVIEW_ROUTE <!-- Added: forge#1742 -->
157
258
  # spec-evolution PRs are produced by /spec-doctor and require eval-gate + human review.
@@ -164,10 +265,10 @@ if [ "$REVIEW_MODE" = "single-pr" ] && [ -n "$ROUTE_PR_NUMBER" ] && [ "$ROUTE_PR
164
265
  if [ "$SPEC_EVOL_CHECK" = "true" ]; then
165
266
  IS_SPEC_EVOLUTION=true
166
267
  AUTO_MERGE=false
167
- # Add needs-human label to the associated issue (if --issue was passed in $ARGUMENTS)
168
- SPEC_EVOL_ISSUE=$(echo "$ARGUMENTS" | grep -oP '(?<=--issue )\d+' || echo "")
268
+ # Add needs-human label to the associated issue (if --issue was passed)
269
+ SPEC_EVOL_ISSUE="$MERGE_ISSUE"
169
270
  if [ -n "$SPEC_EVOL_ISSUE" ]; then
170
- GH_FLAG_SPEC=$(echo "$ARGUMENTS" | grep -oP '(?<=-R )\S+' | head -1 | sed 's/^/-R /' || echo "")
271
+ GH_FLAG_SPEC="$MERGE_GH_FLAG"
171
272
  gh issue edit "$SPEC_EVOL_ISSUE" $GH_FLAG_SPEC --add-label "needs-human" 2>/dev/null || true
172
273
  fi
173
274
  echo "SPEC-EVOLUTION GUARD: PR #${ROUTE_PR_NUMBER} carries 'spec-evolution' label."
@@ -205,10 +306,10 @@ Check input to determine which mode:
205
306
 
206
307
  ### MODE 1: Staging Review
207
308
 
208
- If `$ARGUMENTS` is "staging", "feature", or "staging:feature":
309
+ If `$ARGUMENTS` is "staging", "feature", or "staging:feature" (forward the validated `ARGS_RAW` from the Argument Parse block, never a rejected string):
209
310
 
210
311
  ```
211
- >>> INVOKE: Skill("{FORGE_SKILL_PREFIX}review-pr-staging", "$ARGUMENTS")
312
+ >>> INVOKE: Skill("{FORGE_SKILL_PREFIX}review-pr-staging", "{ARGS_RAW}")
212
313
  >>> THEN STOP — the staging command handles the full flow.
213
314
  ```
214
315
 
@@ -224,14 +325,15 @@ Show list, ask user which to review, then loop through each with full review.
224
325
 
225
326
  **Auto-detect staging mode:**
226
327
  ```bash
227
- PR_INFO=$(gh pr view $ARGUMENTS --json baseRefName,headRefName,additions,deletions,title)
328
+ [ -n "$PR_NUMBER" ] || { echo "review-pr: no PR number - stopping" >&2; exit 1; } # never let gh pick the current-branch PR <!-- Added: forge#3466 -->
329
+ PR_INFO=$(gh pr view "$PR_NUMBER" ${REPO_FLAG} --json baseRefName,headRefName,additions,deletions,title)
228
330
  HEAD=$(echo $PR_INFO | jq -r '.headRefName')
229
331
  BASE=$(echo $PR_INFO | jq -r '.baseRefName')
230
332
  ```
231
333
 
232
334
  If `HEAD = "staging" AND BASE = "main"` OR `HEAD = "feature" AND BASE = "main"`:
233
335
  ```
234
- >>> INVOKE: Skill("{FORGE_SKILL_PREFIX}review-pr-staging", "$ARGUMENTS")
336
+ >>> INVOKE: Skill("{FORGE_SKILL_PREFIX}review-pr-staging", "{ARGS_RAW}")
235
337
  >>> THEN STOP.
236
338
  ```
237
339
 
@@ -251,22 +353,22 @@ Otherwise → proceed to Phase 1.
251
353
 
252
354
  ### 1A: Fetch PR Data
253
355
  ```bash
254
- gh pr view $ARGUMENTS --json number,title,body,author,baseRefName,headRefName,files,additions,deletions
255
- REVIEW_SHA=$(gh pr view $ARGUMENTS --json headRefOid --jq '.headRefOid')
356
+ gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,body,author,baseRefName,headRefName,files,additions,deletions
357
+ REVIEW_SHA=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq '.headRefOid')
256
358
  REVIEW_SHA_SHORT=$(echo "$REVIEW_SHA" | cut -c1-7)
257
- gh pr diff $ARGUMENTS --name-only
258
- gh pr diff $ARGUMENTS
359
+ gh pr diff "$PR_NUMBER" -R "$REPO" --name-only
360
+ gh pr diff "$PR_NUMBER" -R "$REPO"
259
361
 
260
362
  # Mergeability check — GitHub computes this asynchronously; retry up to 3× on UNKNOWN
261
363
  # <!-- Added: forge#194 -->
262
- MERGE_HEALTH_RESULT=$(gh pr view $ARGUMENTS --json mergeable,mergeStateStatus --jq '"\\(.mergeable)|\\(.mergeStateStatus)"')
364
+ MERGE_HEALTH_RESULT=$(gh pr view "$PR_NUMBER" -R "$REPO" --json mergeable,mergeStateStatus --jq '"\\(.mergeable)|\\(.mergeStateStatus)"')
263
365
  MERGE_HEALTH=${MERGE_HEALTH_RESULT%%|*}
264
366
  MERGE_HEALTH_STATE=${MERGE_HEALTH_RESULT##*|}
265
367
  MERGE_RETRY=0
266
368
  while [ "$MERGE_HEALTH" = "UNKNOWN" ] && [ "$MERGE_RETRY" -lt 3 ]; do
267
369
  MERGE_RETRY=$((MERGE_RETRY + 1))
268
370
  sleep 5
269
- MERGE_HEALTH_RESULT=$(gh pr view $ARGUMENTS --json mergeable,mergeStateStatus --jq '"\\(.mergeable)|\\(.mergeStateStatus)"')
371
+ MERGE_HEALTH_RESULT=$(gh pr view "$PR_NUMBER" -R "$REPO" --json mergeable,mergeStateStatus --jq '"\\(.mergeable)|\\(.mergeStateStatus)"')
270
372
  MERGE_HEALTH=${MERGE_HEALTH_RESULT%%|*}
271
373
  MERGE_HEALTH_STATE=${MERGE_HEALTH_RESULT##*|}
272
374
  done
@@ -274,12 +376,12 @@ done
274
376
  # MERGE_HEALTH_STATE: CLEAN | DIRTY | BLOCKED | UNSTABLE | UNKNOWN
275
377
 
276
378
  # Resolve repo name early — used in Phases 5, 6, 8B, 9A (clean-review skip path bypasses Phase 6A) <!-- Added: forge#820 -->
277
- REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
379
+ REPO="${REPO:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}"
278
380
 
279
381
  # First-time contributor detection <!-- Added: forge#960 -->
280
382
  # Requires REPO to be set (line above). Checks whether the PR author has any prior merged PRs in this repo.
281
383
  # IS_FIRST_TIME_CONTRIBUTOR is consumed in Phase 9 to conditionally post an onboarding welcome comment.
282
- PR_AUTHOR=$(gh pr view "$ARGUMENTS" -R "$REPO" --json author --jq '.author.login')
384
+ PR_AUTHOR=$(gh pr view "$PR_NUMBER" -R "$REPO" --json author --jq '.author.login')
283
385
  PRIOR_MERGED_COUNT=$(gh pr list -R "$REPO" --author "$PR_AUTHOR" --state merged --limit 1 --json number --jq 'length' 2>/dev/null || echo "1")
284
386
  IS_FIRST_TIME_CONTRIBUTOR="false"
285
387
  [ "$PRIOR_MERGED_COUNT" -eq 0 ] && IS_FIRST_TIME_CONTRIBUTOR="true"
@@ -287,8 +389,8 @@ IS_FIRST_TIME_CONTRIBUTOR="false"
287
389
 
288
390
  ### 1B: Classify
289
391
  ```bash
290
- FILES=$(gh pr diff $ARGUMENTS --name-only)
291
- DIFF=$(gh pr diff $ARGUMENTS)
392
+ FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
393
+ DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO")
292
394
 
293
395
  echo "=== SERVICES ==="
294
396
  # Service detection is path-agnostic — matches project-specific and generic structures
@@ -348,7 +450,7 @@ else
348
450
  fi
349
451
 
350
452
  # Always run compile check on changed Python files (fast, language-universal)
351
- gh pr diff $ARGUMENTS --name-only | grep '\.py$' | while IFS= read -r f; do
453
+ gh pr diff "$PR_NUMBER" -R "$REPO" --name-only | grep '\.py$' | while IFS= read -r f; do
352
454
  python3 -m py_compile "$f" 2>&1
353
455
  done
354
456
  ```
@@ -392,19 +494,19 @@ Covered by `PYTHON_TYPECHECK` command in 2A above. If `verification.commands.pyt
392
494
 
393
495
  ### 2D: Environment Variable Audit
394
496
  ```bash
395
- gh pr diff $ARGUMENTS | grep -E "os\.getenv|os\.environ|process\.env" | head -30
497
+ gh pr diff "$PR_NUMBER" -R "$REPO" | grep -E "os\.getenv|os\.environ|process\.env" | head -30
396
498
  ```
397
499
  Flag if new env vars not in `.env.example`.
398
500
 
399
501
  ### 2E: Secrets Detection (CRITICAL — BLOCKING if found)
400
502
  ```bash
401
- gh pr diff $ARGUMENTS | grep -iE "(api[_-]?key|secret[_-]?key|password|token|credential|private[_-]?key)" | grep -vE "(#|//|\.example|placeholder|PLACEHOLDER|YOUR_|<|>)" | head -20
402
- gh pr diff $ARGUMENTS | grep -oE "['\"][A-Za-z0-9+/=]{40,}['\"]" | head -10
503
+ gh pr diff "$PR_NUMBER" -R "$REPO" | grep -iE "(api[_-]?key|secret[_-]?key|password|token|credential|private[_-]?key)" | grep -vE "(#|//|\.example|placeholder|PLACEHOLDER|YOUR_|<|>)" | head -20
504
+ gh pr diff "$PR_NUMBER" -R "$REPO" | grep -oE "['\"][A-Za-z0-9+/=]{40,}['\"]" | head -10
403
505
  ```
404
506
 
405
507
  ### 2F: SQL Migration Validation (if *.sql changed)
406
508
  ```bash
407
- gh pr diff $ARGUMENTS --name-only | grep "\.sql$" | while IFS= read -r sql_file; do
509
+ gh pr diff "$PR_NUMBER" -R "$REPO" --name-only | grep "\.sql$" | while IFS= read -r sql_file; do
408
510
  grep -E "FOR UPDATE" "$sql_file" | grep -qE "(SUM|COUNT|AVG|MIN|MAX)\s*\(" && echo "ERROR: FOR UPDATE with aggregate"
409
511
  grep -qE "DROP (TABLE|COLUMN|INDEX)" "$sql_file" && ! grep -qE "IF EXISTS" "$sql_file" && echo "WARNING: DROP without IF EXISTS"
410
512
  grep -qE "ALTER TABLE.*ADD COLUMN.*NOT NULL" "$sql_file" && ! grep -qE "DEFAULT" "$sql_file" && echo "WARNING: NOT NULL without DEFAULT"
@@ -436,6 +538,27 @@ fi
436
538
  ```
437
539
 
438
540
  ```bash
541
+ # Install-root-only classifier resolution (no cwd-relative `scripts/` fallback: the PR under review is author-controlled).
542
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
543
+ FORGE_ROOT=""
544
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
545
+ _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
546
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
547
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
548
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
549
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
550
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
551
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
552
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
553
+ # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
554
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
555
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
556
+ # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
557
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
558
+ while IFS= read -r _c; do
559
+ case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
560
+ done <<< "$_k"
561
+ fi
439
562
  # Check each configured language for a test command
440
563
  for lang in python typescript go rust; do
441
564
  TEST_CMD=$(yq ".verification.commands.${lang}.test // \"\"" forge.yaml 2>/dev/null || echo '')
@@ -448,15 +571,14 @@ for lang in python typescript go rust; do
448
571
  # If ALL failing test names appear in $QUARANTINED_TESTS, suppress the block.
449
572
  # Without per-test granularity from the test runner, fall back to treating
450
573
  # any failure as blocking unless the classifier script is available.
451
- CLASSIFIER="${FORGEDOCK_SCRIPTS:-scripts}/flaky-quarantine.sh"
452
- [ -f "$CLASSIFIER" ] || CLASSIFIER="scripts/flaky-quarantine.sh"
453
- if [ -f "$CLASSIFIER" ]; then
574
+ CLASSIFIER="${FORGE_ROOT:+$FORGE_ROOT/scripts/flaky-quarantine.sh}"
575
+ if [ -n "$CLASSIFIER" ] && [ -f "$CLASSIFIER" ]; then
454
576
  CL_RESULT=$(bash "$CLASSIFIER" \
455
577
  --test "$TEST_CMD" \
456
578
  --base "$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null | sed 's|origin/||' || echo main)" \
457
579
  --retries 3 2>&1)
458
580
  echo "$CL_RESULT"
459
- CL=$(echo "$CL_RESULT" | grep '^CLASSIFICATION:' | awk '{print $2}')
581
+ CL=$(echo "$CL_RESULT" | grep '^CLASSIFICATION:' | awk '{print $(2)}')
460
582
  if [ "$CL" = "PRE_BROKEN" ] || [ "$CL" = "FLAKY" ]; then
461
583
  echo "ADVISORY (not blocking): ${lang} tests classified ${CL} — quarantined, does not block this review"
462
584
  else
@@ -481,7 +603,7 @@ fi
481
603
  ### 2I: Build Verification (MANDATORY for staging→main AND milestone→staging)
482
604
 
483
605
  ```bash
484
- CHANGED_FILES=$(gh pr diff $ARGUMENTS --name-only)
606
+ CHANGED_FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
485
607
  HAS_TS=$(echo "$CHANGED_FILES" | grep -E '\.(tsx?|jsx?)$' | head -1)
486
608
  HAS_PY=$(echo "$CHANGED_FILES" | grep -E '\.py$' | head -1)
487
609
  # Use POSIX-portable if/else (avoid bash-only [[ ]])
@@ -498,7 +620,7 @@ if [ "$IS_STAGING_TO_MAIN" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]
498
620
  Read `forge.yaml → verification.commands.typescript.typecheck` and `.build`:
499
621
 
500
622
  ```bash
501
- gh pr checkout $ARGUMENTS --detach 2>/dev/null
623
+ gh pr checkout "$PR_NUMBER" -R "$REPO" --detach 2>/dev/null
502
624
 
503
625
  TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
504
626
  TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
@@ -531,7 +653,7 @@ If `BUILD_EXIT != 0`: **CONFIRMED blocking** — build/prerender failure.
531
653
  Read `forge.yaml → verification.commands.python.format` and `.build`:
532
654
 
533
655
  ```bash
534
- gh pr checkout $ARGUMENTS --detach 2>/dev/null
656
+ gh pr checkout "$PR_NUMBER" -R "$REPO" --detach 2>/dev/null
535
657
 
536
658
  # Compile-check all changed Python files (language-universal — no config needed)
537
659
  echo "$CHANGED_FILES" | grep '\.py$' | while IFS= read -r f; do python3 -m py_compile "$f" 2>&1; done
@@ -556,10 +678,10 @@ Check whether the PR's actual changes match what the builder committed to in its
556
678
 
557
679
  ```bash
558
680
  # Find the contract comment on the linked issue
559
- ISSUE_NUM=$(gh pr view $ARGUMENTS --json body --jq '.body | gsub("(?s).*?(?:Closes #|#)(?<n>[0-9]+).*"; "\(.n)") // empty' 2>/dev/null | head -1)
681
+ ISSUE_NUM=$(gh pr view "$PR_NUMBER" -R "$REPO" --json body --jq '.body | gsub("(?s).*?(?:Closes #|#)(?<n>[0-9]+).*"; "\(.n)") // empty' 2>/dev/null | head -1)
560
682
  if [ -n "$ISSUE_NUM" ]; then
561
683
  CONTRACT_FILES=$(gh api repos/${REPO}/issues/${ISSUE_NUM}/comments --jq '[.[] | select(.body | contains("FORGE:CONTRACT"))] | .[0].body' 2>/dev/null | grep -E '`[^`]+\.(py|tsx?|sql|sh|yml|yaml|json)`' | grep -oE '`[^`]+\.(py|tsx?|sql|sh|yml|yaml|json)`' | tr -d '`' | sort -u)
562
- PR_FILES=$(gh pr diff $ARGUMENTS --name-only | sort -u)
684
+ PR_FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only | sort -u)
563
685
 
564
686
  # Files in PR but NOT in contract
565
687
  SCOPE_CREEP=$(comm -23 <(echo "$PR_FILES") <(echo "$CONTRACT_FILES") 2>/dev/null | grep -vE '\.(md|txt|example)$')
@@ -629,7 +751,7 @@ For each changed file, execute the relevant checks using the standalone verifica
629
751
  **Platform note**: The verify-*.sh scripts require bash and standard POSIX tools. On Windows without bash (Git Bash / WSL / MSYS2), these checks are skipped with an explicit message — the review continues without them.
630
752
 
631
753
  ```bash
632
- CHANGED_FILES=$(gh pr diff $ARGUMENTS --name-only)
754
+ CHANGED_FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
633
755
  REPO_ROOT="." # Assumes cwd is the repo root
634
756
 
635
757
  # Resolve the verify-*.sh scripts source directory with the same deterministic
@@ -663,7 +785,7 @@ if [ "$BASH_AVAILABLE" = "true" ]; then
663
785
  CHANGED_FILES_TMP="/tmp/forge-review-changed-$$.tmp"
664
786
  DIFF_TMP="/tmp/forge-review-diff-$$.tmp"
665
787
  echo "$CHANGED_FILES" > "$CHANGED_FILES_TMP"
666
- gh pr diff $ARGUMENTS > "$DIFF_TMP"
788
+ gh pr diff "$PR_NUMBER" -R "$REPO" > "$DIFF_TMP"
667
789
 
668
790
  # --- Script-based checks (reusable, testable, deterministic) ---
669
791
  # Each script exits 0 (pass), 1 (blocking findings), or 2 (warnings only).
@@ -702,7 +824,7 @@ if [ "$BASH_AVAILABLE" = "true" ]; then
702
824
  | grep -E '^\s*-\s+' \
703
825
  | sed 's/^\s*-\s*//' \
704
826
  | tr -d '"'"'" \
705
- | awk 'NR>1{printf "|"}{printf $0}END{print ""}')
827
+ | awk 'NR>1{printf "|"}{printf "%s", $(0)}END{print ""}')
706
828
  fi
707
829
  export FORGE_INTERNAL_PATTERNS
708
830
  echo "=== Running: verify-host-headers.sh ==="
@@ -731,7 +853,7 @@ echo "$CHANGED_FILES" | grep -E '\.py$' | while IFS= read -r f; do
731
853
  # Find function-scoped imports (indented import statements)
732
854
  grep -nE "^\s+import [a-z]" "$f" 2>/dev/null | while read line; do
733
855
  LINENO=$(echo "$line" | cut -d: -f1)
734
- MODULE=$(echo "$line" | grep -oE "import [a-z_]+" | awk '{print $2}')
856
+ MODULE=$(echo "$line" | grep -oE "import [a-z_]+" | awk '{print $(2)}')
735
857
  # Check if the same module is used BEFORE this line in the same function
736
858
  # (simplified check — agents should do full scope analysis)
737
859
  [ -n "$MODULE" ] && head -n $((LINENO-1)) "$f" 2>/dev/null | grep -qE "^\s+.*\b${MODULE}\." && \
@@ -784,8 +906,8 @@ if [ -n "$WORKFLOW_FILES" ] || [ "$IS_STAGING_PR" = "true" ]; then
784
906
  # patterns simultaneously. The flag form sets p=1 on the header line,
785
907
  # prints body lines while p=1, and clears p when the next sibling job
786
908
  # header (same indentation, lowercase start) is seen. <!-- Added: forge#310 -->
787
- CI_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
788
- DEPLOY_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
909
+ CI_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
910
+ DEPLOY_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
789
911
 
790
912
  # Check for PYTHONPATH specifically — the exact var that caused the #11356 failure
791
913
  CI_PYPATH=$(echo "$CI_ENVS" | grep "PYTHONPATH" || echo "(not set)")
@@ -798,16 +920,16 @@ if [ -n "$WORKFLOW_FILES" ] || [ "$IS_STAGING_PR" = "true" ]; then
798
920
  fi
799
921
 
800
922
  # Check for dependency installation steps that exist in one but not the other
801
- CI_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
802
- DEPLOY_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
923
+ CI_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
924
+ DEPLOY_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
803
925
  if [ "$CI_INSTALLS" != "$DEPLOY_INSTALLS" ]; then
804
926
  echo " WARNING: Different number of dependency install steps in '$JOB' — ci.yml has $CI_INSTALLS, deploy has $DEPLOY_INSTALLS"
805
927
  echo " ACTION: Read both files and verify all dependencies needed by tests are installed in both workflows."
806
928
  fi
807
929
 
808
930
  # Check step names — if CI has a step that deploy doesn't, flag it
809
- CI_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
810
- DEPLOY_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
931
+ CI_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
932
+ DEPLOY_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
811
933
  MISSING_IN_DEPLOY=$(comm -23 <(echo "$CI_STEPS") <(echo "$DEPLOY_STEPS") 2>/dev/null || true)
812
934
  if [ -n "$MISSING_IN_DEPLOY" ]; then
813
935
  echo " WARNING: Steps in ci.yml '$JOB' missing from deploy-production.yml:"
@@ -835,8 +957,8 @@ Format: `INTEG-N|CONFIRMED|HIGH|file:line|Changed code may be unreachable: {reas
835
957
  **NEVER scale review depth by line count.** A 5-line shell script processing LLM output is more dangerous than a 500-line React component.
836
958
 
837
959
  ```bash
838
- DIFF=$(gh pr diff $ARGUMENTS)
839
- FILES=$(gh pr diff $ARGUMENTS --name-only)
960
+ DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO")
961
+ FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
840
962
  echo "=== RISK SIGNALS ==="
841
963
  echo "$DIFF" | grep -cE "subprocess|exec|eval|system\(|popen|heredoc" && echo " UNTRUSTED_INPUT_PROCESSING" || true
842
964
  echo "$DIFF" | grep -cE "\.sh$|bash|shell|cron" && echo " SHELL_SCRIPT" || true
@@ -985,7 +1107,7 @@ echo "AUTH=$SCORE_AUTH BILLING=$SCORE_BILLING CONCURRENCY=$SCORE_CONCURRENCY DAT
985
1107
 
986
1108
  ```bash
987
1109
  CONTRACT_RISK_FLAGS=""
988
- ISSUE_NUM=$(gh pr view $ARGUMENTS --json body --jq '.body | gsub("(?s).*?(?:Closes #|#)(?<n>[0-9]+).*"; "\(.n)") // empty' 2>/dev/null | head -1)
1110
+ ISSUE_NUM=$(gh pr view "$PR_NUMBER" -R "$REPO" --json body --jq '.body | gsub("(?s).*?(?:Closes #|#)(?<n>[0-9]+).*"; "\(.n)") // empty' 2>/dev/null | head -1)
989
1111
  if [ -n "$ISSUE_NUM" ]; then
990
1112
  CONTRACT_BODY=$(gh api repos/${REPO}/issues/${ISSUE_NUM}/comments \
991
1113
  --jq '[.[] | select(.body | contains("FORGE:CONTRACT"))] | .[0].body' 2>/dev/null || echo "")
@@ -1026,7 +1148,7 @@ Pick the top 1-2 domain agents by score:
1026
1148
  DOMAIN_SCORES="$SCORE_AUTH:AUTH $SCORE_BILLING:BILLING $SCORE_CONCURRENCY:CONCURRENCY $SCORE_DATABASE:DATABASE $SCORE_INFRA:INFRA $SCORE_SCRAPING:SCRAPING $SCORE_FRONTEND:FRONTEND $SCORE_API:API"
1027
1149
 
1028
1150
  # Sort descending by score, pick top 2 with score > 0
1029
- TOP_DOMAINS=$(echo "$DOMAIN_SCORES" | tr ' ' '\n' | sort -t: -k1 -rn | head -2 | awk -F: '$1 > 0 {print $2}' | tr '\n' ' ')
1151
+ TOP_DOMAINS=$(echo "$DOMAIN_SCORES" | tr ' ' '\n' | sort -t: -k1 -rn | head -2 | awk -F: '$(1) > 0 {print $(2)}' | tr '\n' ' ')
1030
1152
  for DOMAIN in $TOP_DOMAINS; do
1031
1153
  SELECTED_AGENTS="$SELECTED_AGENTS $DOMAIN"
1032
1154
  done
@@ -1053,7 +1175,7 @@ echo "=== BASELINE ROSTER (top domains): $SELECTED_AGENTS ==="
1053
1175
  ```bash
1054
1176
  # Apply escalation triggers
1055
1177
  add_agent() {
1056
- local AGENT="$1"
1178
+ local AGENT="${1}"
1057
1179
  echo "$SELECTED_AGENTS" | grep -qw "$AGENT" || SELECTED_AGENTS="$SELECTED_AGENTS $AGENT"
1058
1180
  }
1059
1181
 
@@ -1065,7 +1187,7 @@ add_agent() {
1065
1187
  [ "$SCORE_SCRAPING" -ge 3 ] && [ -n "$DOMAIN_CONTEXT_SCRAPING" ] && add_agent "Scraping"
1066
1188
  [ "$CHURN_ESCALATION" = "true" ] && {
1067
1189
  # Add the top-scoring domain for deeper churn scrutiny if not already selected
1068
- TOP_CHURN_DOMAIN=$(echo "$DOMAIN_SCORES" | tr ' ' '\n' | sort -t: -k1 -rn | head -1 | awk -F: '{print $2}')
1190
+ TOP_CHURN_DOMAIN=$(echo "$DOMAIN_SCORES" | tr ' ' '\n' | sort -t: -k1 -rn | head -1 | awk -F: '{print $(2)}')
1069
1191
  [ -n "$TOP_CHURN_DOMAIN" ] && add_agent "$TOP_CHURN_DOMAIN"
1070
1192
  }
1071
1193
  echo "$CONTRACT_RISK_FLAGS" | grep -q "HIGH_RISK" && {
@@ -1151,7 +1273,7 @@ if [ "$PROVENANCE_TABLE_LOADED" = "true" ]; then
1151
1273
  done | sort -u | tr '\n' '|' | sed 's/|$//')
1152
1274
 
1153
1275
  # Determine task type from FORGE:INVESTIGATOR annotation on the linked issue (if available)
1154
- ISSUE_NUM_FOR_TRUST=$(gh pr view "$ARGUMENTS" --json body \
1276
+ ISSUE_NUM_FOR_TRUST=$(gh pr view "$PR_NUMBER" -R "$REPO" --json body \
1155
1277
  --jq '.body | gsub("(?s).*?(?:Closes #|#)(?<n>[0-9]+).*"; "\(.n)") // ""' 2>/dev/null | head -1)
1156
1278
  TASK_TYPE_FOR_TRUST=""
1157
1279
  if [ -n "$ISSUE_NUM_FOR_TRUST" ]; then
@@ -1161,7 +1283,7 @@ if [ "$PROVENANCE_TABLE_LOADED" = "true" ]; then
1161
1283
  fi
1162
1284
  # Fall back to title-based inference if INVESTIGATOR comment absent
1163
1285
  if [ -z "$TASK_TYPE_FOR_TRUST" ]; then
1164
- PR_TITLE=$(gh pr view "$ARGUMENTS" --json title --jq '.title' 2>/dev/null || echo "")
1286
+ PR_TITLE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json title --jq '.title' 2>/dev/null || echo "")
1165
1287
  case "$PR_TITLE" in
1166
1288
  fix*|Fix*) TASK_TYPE_FOR_TRUST="Bug Fix" ;;
1167
1289
  feat*|Feat*) TASK_TYPE_FOR_TRUST="Feature" ;;
@@ -1364,7 +1486,7 @@ if [[ "$TEMPLATE_SOURCE" == "none" ]]; then
1364
1486
  fi
1365
1487
  ```
1366
1488
 
1367
- **If `TEMPLATE_SOURCE` is `none`**: HARD STOP. Post a PR comment explaining the setup is broken (`gh pr comment $ARGUMENTS --body "..."`) instructing the user to run `npx forgedock update` to repair the install, add `needs-human`, and exit Phase 3 without posting any findings or a `FORGE:REVIEW` verdict. **NEVER perform the review inline in the main agent context as a substitute.** A degraded solo review that presents itself as complete is worse than no review — missing templates must fail loudly, not silently.
1489
+ **If `TEMPLATE_SOURCE` is `none`**: HARD STOP. Post a PR comment explaining the setup is broken (`gh pr comment "$PR_NUMBER" -R "$REPO" --body "..."`) instructing the user to run `npx forgedock update` to repair the install, add `needs-human`, and exit Phase 3 without posting any findings or a `FORGE:REVIEW` verdict. **NEVER perform the review inline in the main agent context as a substitute.** A degraded solo review that presents itself as complete is worse than no review — missing templates must fail loudly, not silently.
1368
1490
 
1369
1491
  **If `TEMPLATE_SOURCE` is `plugin_root`, `forge_home` or `repo_path`** (the normal cases — behavior unchanged from before this guard existed):
1370
1492
  ```
@@ -1420,10 +1542,10 @@ fi
1420
1542
  # Build domain-scoped index slices for each selected agent's domain
1421
1543
  # Replace {DOMAIN} with the agent's domain label (auth, billing, database, api, frontend, etc.)
1422
1544
  build_index_slice() {
1423
- local domain="$1"
1545
+ local domain="${1}"
1424
1546
  if [[ -x "$CODE_INDEX_SCRIPT" ]]; then
1425
1547
  local files
1426
- files=$(bash "$CODE_INDEX_SCRIPT" query --domain "$domain" --repo-path "$REPO_PATH" 2>/dev/null | awk -F'\t' '{print $1}' | head -30 | tr '\n' ' ')
1548
+ files=$(bash "$CODE_INDEX_SCRIPT" query --domain "$domain" --repo-path "$REPO_PATH" 2>/dev/null | awk -F'\t' '{print $(1)}' | head -30 | tr '\n' ' ')
1427
1549
  echo "Domain files (${domain}): ${files:-none}"
1428
1550
  else
1429
1551
  echo "Code index not available — agent will use grep exploration"
@@ -1447,8 +1569,8 @@ Each domain agent receives only the diff slice relevant to its domain, not the f
1447
1569
 
1448
1570
  ```bash
1449
1571
  # Full diff fetched once — agents do NOT re-fetch it
1450
- FULL_DIFF=$(gh pr diff $ARGUMENTS)
1451
- FULL_FILES=$(gh pr diff $ARGUMENTS --name-only)
1572
+ FULL_DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO")
1573
+ FULL_FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
1452
1574
 
1453
1575
  # --- Security agent: always receives the full diff (cross-cutting domain) ---
1454
1576
  DIFF_SLICE_SECURITY="$FULL_DIFF"
@@ -1523,7 +1645,7 @@ DIFF_SLICE_SCRAPER=$(echo "$FULL_DIFF" | awk '
1523
1645
  ```bash
1524
1646
  # Truncate any slice exceeding 100K chars before passing to agent
1525
1647
  truncate_slice() {
1526
- local slice="$1"
1648
+ local slice="${1}"
1527
1649
  local limit=102400
1528
1650
  if [ "${#slice}" -gt "$limit" ]; then
1529
1651
  echo "${slice:0:$limit}"
@@ -1554,30 +1676,64 @@ The `protocols.md` file contains the Evidence-Based Review Protocol, Structured
1554
1676
  8. If Phase 2.5 found broken assumptions, append them to the agent's prompt as "Pre-found integration issues to verify"
1555
1677
  9. Launch via the resolved `{DISPATCH_TOOL}` (see Sub-Agent Dispatch Tool Resolution above) with `model: "{SUBAGENT_MODEL}"` (forge.yaml `agents.subagent_model`, else `agents.default_model`, else `"sonnet"`; fallback `"opus"` if rate-limited). Under OpenCode, emit a top-level `subagent_type: "general"` or `"explore"` in the native `task` argument object and use `background: false` for each reviewer.
1556
1678
 
1557
- **CRITICAL**: Launch ALL selected agents in a SINGLE message using multiple `{DISPATCH_TOOL}` calls. Each agent must persist its finalized body before posting it with `gh pr comment --body-file`, include `<!-- FORGE:REVIEW-AGENT:{lowercase-domain} -->`, and return its verdict and findings to the orchestrator independently of GitHub delivery.
1679
+ **Reviewer-comment trust and shape (applies to every check below and in Phase 4)**: a reviewer comment counts only when it is from a trusted author (`scripts/trusted-comments.sh`, the same predicate as the in-PR fix gate and `verify-phase-trail.sh`: trusted association, Bot account, or `FORGE_TRAIL_TRUSTED_LOGINS`), its body **starts with** `<!-- FORGE:REVIEW-AGENT:{domain} -->`, and it carries a line `Reviewed-SHA: ${REVIEW_SHA}` for the current head. The head SHA is public, so the SHA line alone proves nothing: an untrusted commenter must never be able to suppress dispatch or satisfy the panel guard. If the trust script cannot be resolved, or its count is not an integer, the reviewer is treated as **not posted** (fail closed: re-dispatch in 3C, missing in Phase 4).
1558
1680
 
1559
- **Dispatch failure and partial-panel guard (MANDATORY):** Count the selected roster before dispatch. If any launch fails, including from pool exhaustion, do not continue with the agents that did launch as a sufficient panel. Immediately create the managed label if necessary, label the PR `review-degraded`, add `needs-human` to the linked issue, post `FORGE:GATE_FAILURE`, and exit without a verdict. After all foreground reviewers return, independently compare their posted `FORGE:REVIEW-AGENT` markers with the selected count; a smaller count is the same hard stop. This catches a reviewer that accepted dispatch but failed before posting.
1681
+ **Idempotent re-entry (before launching)**: a re-invoked review must reuse reviewers that already posted for the current head instead of re-dispatching the full panel. For each domain in `$SELECTED_AGENTS`, check for an existing trusted comment of the shape above; launch only the domains without one (possibly none), then continue to Phase 4. Comments for a different SHA never count. The selected roster for the panel guard stays the full `$SELECTED_AGENTS` set.
1560
1682
 
1561
1683
  ```bash
1562
- SELECTED_AGENT_COUNT=$(echo "$SELECTED_AGENTS" | tr ' ' '\n' | grep -c '.')
1563
- ACTUAL_AGENT_COUNT=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
1564
- --jq '[.[].body | scan("<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | length' 2>/dev/null || echo 0)
1684
+ # Each Bash call is a fresh shell: re-declare the state this block needs with the orchestrator's values.
1685
+ REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; REVIEW_SHA="{REVIEW_SHA}"; SELECTED_AGENTS="{SELECTED_AGENTS}"
1686
+ case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
1687
+ if [ "$REVIEW_SHA_OK" != "true" ]; then
1688
+ # An empty or malformed SHA would make the Reviewed-SHA match accept any head (fail open): refuse instead.
1689
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA '${REVIEW_SHA}' is not a 40/64-hex commit id; re-run /review-pr"; exit 0
1690
+ fi
1691
+ WAIT_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/forge-review-wait-$(id -u)" # absolute and identical in every Bash call (cwd is not)
1692
+ rm -f "${WAIT_DIR}/${PR_NUMBER}_${REVIEW_SHA}_review-wait-deadline" # a (re)dispatch always starts a fresh wait window
1693
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
1694
+ TRUSTED_SCRIPT=""
1695
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
1696
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1697
+ done
1698
+ PENDING_AGENTS=""
1699
+ for AGENT in $SELECTED_AGENTS; do
1700
+ AGENT_DOMAIN=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
1701
+ HAVE=""
1702
+ if [ -n "$TRUSTED_SCRIPT" ]; then
1703
+ HAVE=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
1704
+ | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
1705
+ fi
1706
+ case "$HAVE" in ''|*[!0-9]*) HAVE=0 ;; esac # fail closed: unreadable or untrusted counts as not posted
1707
+ [ "$HAVE" -lt 1 ] && PENDING_AGENTS="${PENDING_AGENTS} ${AGENT}"
1708
+ done
1709
+ echo "PENDING_AGENTS:${PENDING_AGENTS:- (none)}"
1710
+ ```
1711
+
1712
+ **CRITICAL**: Launch ALL agents in `PENDING_AGENTS` in a SINGLE message using multiple `{DISPATCH_TOOL}` calls. If `PENDING_AGENTS` is empty, every selected reviewer already posted for this head: launch nothing and go straight to Phase 4. Each agent must persist its finalized body before posting it with `gh pr comment --body-file`, start the body with `<!-- FORGE:REVIEW-AGENT:{lowercase-domain} -->` followed by a `Reviewed-SHA: ${REVIEW_SHA}` line (the full head SHA, outside the marker), and return its verdict and findings to the orchestrator independently of GitHub delivery.
1713
+
1714
+ **Dispatch failure and partial-panel guard (MANDATORY):** Count the selected roster before dispatch. If any launch fails, including from pool exhaustion, do not continue with the agents that did launch as a sufficient panel. Immediately run the **Panel hard-stop block** below with `ACTUAL_AGENT_COUNT=0` and exit without a verdict. After reviewers complete (joined `Task`/`task` return) or, under async `Agent`, after the Phase 4 bounded wait ends, independently compare their posted trusted current-SHA `FORGE:REVIEW-AGENT` markers with the selected count; a smaller count is the same hard stop. This catches a reviewer that accepted dispatch but failed before posting. Under async `Agent` the comparison runs only after the wait, so a reviewer that is merely late is not a false degraded stop.
1715
+
1716
+ **Panel hard-stop block** (run as its own Bash call from 3C on a launch failure and from Phase 4 on a timeout or shortfall; it is self-contained because each Bash call is a fresh shell and nothing defined in another block survives):
1565
1717
 
1566
- if [ "$DISPATCH_FAILED" = "true" ] || [ "$ACTUAL_AGENT_COUNT" -lt "$SELECTED_AGENT_COUNT" ]; then
1567
- gh label create "review-degraded" --color "E4E669" --description "PR review panel was incomplete; re-review required before deployment. Managed by ForgeDock." --force -R "$REPO" 2>/dev/null || true
1568
- gh pr edit "$PR_NUMBER" -R "$REPO" --add-label "review-degraded" --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
1569
- gh pr comment "$PR_NUMBER" -R "$REPO" --body "<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->
1718
+ ```bash
1719
+ REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; MERGE_ISSUE="{MERGE_ISSUE}" # MERGE_ISSUE empty when no --issue
1720
+ SELECTED_AGENT_COUNT=$(printf '%s' "{SELECTED_AGENTS}" | tr ' ' '\n' | grep -c '.' || true)
1721
+ ACTUAL_AGENT_COUNT="{ACTUAL_AGENT_COUNT}" # 0 on a launch failure; the Phase 4 count otherwise
1722
+ gh label create "review-degraded" --color "E4E669" --description "PR review panel was incomplete; re-review required before deployment. Managed by ForgeDock." --force -R "$REPO" 2>/dev/null || true
1723
+ gh pr edit "$PR_NUMBER" -R "$REPO" --add-label "review-degraded" --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
1724
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body "<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->
1570
1725
  ## Review Blocked: Incomplete Isolated Review Panel
1571
1726
 
1572
1727
  **Selected isolated reviewers**: ${SELECTED_AGENT_COUNT}
1573
- **Completed isolated reviewers**: ${ACTUAL_AGENT_COUNT}
1728
+ **Completed isolated reviewers**: ${ACTUAL_AGENT_COUNT:-0}
1574
1729
 
1575
- At least one reviewer could not be dispatched or complete, commonly because the per-session sub-agent pool was exhausted. No inline substitution was performed. Re-run the full panel in a fresh session before merging."
1576
- [ -n "${MERGE_ISSUE:-}" ] && gh issue edit "$MERGE_ISSUE" {MERGE_GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
1577
- exit 1
1578
- fi
1730
+ At least one reviewer could not be dispatched or complete, commonly because the per-session sub-agent pool was exhausted or the bounded wait for current-SHA reviewer comments expired. No inline substitution was performed. Re-run the full panel in a fresh session before merging."
1731
+ if [ -n "$MERGE_ISSUE" ]; then gh issue edit "$MERGE_ISSUE" {MERGE_GH_FLAG} --add-label "needs-human" 2>/dev/null || true; fi # allowlist:check-command-side-effects
1732
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: incomplete review panel (${ACTUAL_AGENT_COUNT:-0}/${SELECTED_AGENT_COUNT})"
1579
1733
  ```
1580
1734
 
1735
+ After this block prints its `REVIEW_RESULT` line, stop: return that result with no verdict.
1736
+
1581
1737
  #### Domain Diff Slicing
1582
1738
 
1583
1739
  Each agent's prompt receives `[FILE_LIST]` scoped to its domain-relevant files (computed in Phase 3B as `DOMAIN_FILES_*`). This reduces per-agent token cost — each agent reads only the diff slice relevant to its domain, not the full changeset.
@@ -1602,32 +1758,90 @@ When substituting `[FILE_LIST]` in each agent's template:
1602
1758
 
1603
1759
  ## Phase 4: Wait for Agents
1604
1760
 
1761
+ Reviewer completion is read from GitHub, never from notifications (see the "Reviewer completion model" under Sub-Agent Dispatch Tool Resolution). A reviewer counts as complete only when a **trusted** comment (see the trust rule in Phase 3C) starting with `<!-- FORGE:REVIEW-AGENT:{domain} -->` also carries `Reviewed-SHA: <current head>`.
1762
+
1763
+ - **Joined tools** (`Task`, OpenCode `task`): the reviewers already returned, so one pass decides: anything missing is a delivery failure.
1764
+ - **Async `Agent`** (and any tool not known to be joined): bounded wait, **one poll pass per Bash call**. A Bash call is capped at 120s by default (600s max), so a single 900s sleep loop would be killed mid-wait and the hard stop would never run. The first pass records an epoch deadline in a private, absolute state file keyed by PR and head SHA (`$XDG_RUNTIME_DIR`/`$TMPDIR`, never the cwd, which changes between calls); each later pass re-reads it, so the deadline survives across calls. Every exit path removes it, and Phase 3C removes it before (re)dispatch, so a re-review never inherits an expired window. Re-run the block while it prints `PANEL_STATUS: WAITING` (it sleeps `REVIEWER_POLL_SECS`, at most 60s, before exiting).
1765
+ - Each pass re-reads the PR head. If it moved off `REVIEW_SHA`, stop: the panel reviewed a stale head, so the block returns the documented `REVIEW_RESULT` BLOCKED (stale review). If the head probe itself fails, that is a BLOCKED result too (fail closed), never a pass.
1766
+ - `PANEL_STATUS: TIMEOUT` or `PANEL_STATUS: SHORTFALL`, or a poll call that was killed or timed out, is the hard stop: run the **Panel hard-stop block** from Phase 3C with `ACTUAL_AGENT_COUNT` set to the count the block printed (0 if the call was killed). Never review the missing domain inline.
1767
+
1605
1768
  ```bash
1606
- gh pr view $ARGUMENTS --json comments --jq '.comments | length'
1607
- gh api repos/{owner}/{repo}/issues/$ARGUMENTS/comments --jq '.[-10:] | .[].body[:100]'
1769
+ # Each Bash call is a fresh shell: re-declare state with the orchestrator's values (never rely on Phase 3C variables).
1770
+ REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; REVIEW_SHA="{REVIEW_SHA}"; SELECTED_AGENTS="{SELECTED_AGENTS}"
1771
+ DISPATCH_TOOL="{DISPATCH_TOOL}" # Task | Agent | task (OpenCode)
1772
+ case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
1773
+ if [ "$REVIEW_SHA_OK" != "true" ]; then
1774
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA '${REVIEW_SHA}' is not a 40/64-hex commit id; re-run /review-pr"
1775
+ echo "PANEL_STATUS: STOP"; exit 0
1776
+ fi
1777
+ # Deadline state is keyed by PR AND head SHA, so a re-review of a new head never inherits an expired deadline.
1778
+ WAIT_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/forge-review-wait-$(id -u)" # absolute and identical in every Bash call (cwd is not)
1779
+ mkdir -m 700 -p "$WAIT_DIR" 2>/dev/null
1780
+ if [ ! -d "$WAIT_DIR" ] || [ ! -O "$WAIT_DIR" ] || [ -L "$WAIT_DIR" ]; then
1781
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: reviewer-wait state dir ${WAIT_DIR} is not a private directory owned by this user"
1782
+ echo "PANEL_STATUS: STOP"; exit 0
1783
+ fi
1784
+ DEADLINE_FILE="${WAIT_DIR}/${PR_NUMBER}_${REVIEW_SHA}_review-wait-deadline"
1785
+
1786
+ REVIEWER_WAIT_SECS="${REVIEWER_WAIT_SECS:-900}"
1787
+ case "$REVIEWER_WAIT_SECS" in ''|*[!0-9]*) REVIEWER_WAIT_SECS=900 ;; esac
1788
+ [ "$REVIEWER_WAIT_SECS" -gt 3600 ] && REVIEWER_WAIT_SECS=3600
1789
+ REVIEWER_POLL_SECS=30
1790
+ SELECTED_AGENT_COUNT=$(printf '%s' "$SELECTED_AGENTS" | tr ' ' '\n' | grep -c '.' || true)
1791
+
1792
+ # Joined tools return only when the reviewer is done: no waiting. Everything else polls to the deadline.
1793
+ case "$DISPATCH_TOOL" in Task|task) JOINED=true ;; *) JOINED=false ;; esac
1794
+ NOW=$(date +%s)
1795
+ [ -s "$DEADLINE_FILE" ] || echo $(( NOW + REVIEWER_WAIT_SECS )) > "$DEADLINE_FILE"
1796
+ DEADLINE=$(cat "$DEADLINE_FILE" 2>/dev/null)
1797
+ case "$DEADLINE" in ''|*[!0-9]*) DEADLINE=$NOW ;; esac
1798
+
1799
+ NOW_SHA=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
1800
+ if [ -z "$NOW_SHA" ]; then
1801
+ rm -f "$DEADLINE_FILE"
1802
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: head probe failed during reviewer wait; re-run /review-pr"
1803
+ echo "PANEL_STATUS: STOP"; exit 0
1804
+ fi
1805
+ if [ "$NOW_SHA" != "$REVIEW_SHA" ]; then
1806
+ rm -f "$DEADLINE_FILE"
1807
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: stale review (head moved from ${REVIEW_SHA} to ${NOW_SHA} during the reviewer wait); re-run /review-pr on the new head"
1808
+ echo "PANEL_STATUS: STOP"; exit 0
1809
+ fi
1608
1810
 
1609
- # Every dispatched agent must have delivered its own persisted review to GitHub.
1610
- # Do not infer a clean review from a missing comment: its return text may contain
1611
- # findings that could not be posted because GitHub writes were throttled.
1811
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
1812
+ TRUSTED_SCRIPT=""
1813
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
1814
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1815
+ done
1816
+ COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
1612
1817
  MISSING_AGENT_COMMENTS=""
1818
+ ACTUAL_AGENT_COUNT=0
1613
1819
  for AGENT in $SELECTED_AGENTS; do
1614
- AGENT_DOMAIN=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
1615
- AGENT_COMMENT_COUNT=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
1616
- --jq "[.[] | select(.body | contains(\"<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->\"))] | length" \
1617
- 2>/dev/null || printf '0')
1618
- if [ "${AGENT_COMMENT_COUNT:-0}" -lt 1 ]; then
1619
- MISSING_AGENT_COMMENTS="${MISSING_AGENT_COMMENTS} ${AGENT_DOMAIN}"
1620
- fi
1820
+ AGENT_DOMAIN=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
1821
+ N=""
1822
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$COMMENTS_JSON" ]; then
1823
+ N=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
1824
+ fi
1825
+ case "$N" in ''|*[!0-9]*) N=0 ;; esac # fail closed
1826
+ if [ "$N" -lt 1 ]; then MISSING_AGENT_COMMENTS="${MISSING_AGENT_COMMENTS} ${AGENT_DOMAIN}"; else ACTUAL_AGENT_COUNT=$(( ACTUAL_AGENT_COUNT + 1 )); fi
1621
1827
  done
1622
1828
 
1623
- if [ -n "$MISSING_AGENT_COMMENTS" ]; then
1624
- echo "REVIEW DELIVERY FAILURE: missing findings comment(s) for:${MISSING_AGENT_COMMENTS}"
1625
- echo "Use each agent's returned verdict, findings, and durable body path to recover the review."
1626
- exit 1
1829
+ if [ -z "$MISSING_AGENT_COMMENTS" ] && [ "$ACTUAL_AGENT_COUNT" -ge "$SELECTED_AGENT_COUNT" ]; then
1830
+ rm -f "$DEADLINE_FILE"; echo "PANEL_STATUS: COMPLETE (${ACTUAL_AGENT_COUNT}/${SELECTED_AGENT_COUNT})"
1831
+ elif [ "$JOINED" = "true" ] || [ "$(date +%s)" -ge "$DEADLINE" ]; then
1832
+ rm -f "$DEADLINE_FILE"
1833
+ echo "REVIEW DELIVERY FAILURE: missing trusted current-SHA findings comment(s) for:${MISSING_AGENT_COMMENTS}"
1834
+ echo "Use each agent's returned verdict, findings, and durable body path to recover the review."
1835
+ [ "$JOINED" = "true" ] && echo "PANEL_STATUS: SHORTFALL (ACTUAL_AGENT_COUNT=${ACTUAL_AGENT_COUNT})" || echo "PANEL_STATUS: TIMEOUT (ACTUAL_AGENT_COUNT=${ACTUAL_AGENT_COUNT})"
1836
+ else
1837
+ echo "PANEL_STATUS: WAITING (${ACTUAL_AGENT_COUNT}/${SELECTED_AGENT_COUNT}; missing:${MISSING_AGENT_COMMENTS})"
1838
+ sleep "$REVIEWER_POLL_SECS"
1627
1839
  fi
1628
1840
  ```
1629
1841
 
1630
- **Do NOT proceed until ALL launched agent comments are visible on the PR.** Under OpenCode, each foreground `task(..., background: false)` returns only when that reviewer is complete; verify its corresponding structured PR comment before continuing. A missing comment is an explicit delivery failure: do not synthesize a verdict or treat it as a clean result.
1842
+ On `PANEL_STATUS: STOP`, return the printed `REVIEW_RESULT` and do nothing else (no hard stop, no verdict, no label change: the panel is intact, only the head moved).
1843
+
1844
+ **Do NOT proceed until ALL selected agents' current-SHA comments are visible on the PR.** Under OpenCode, each foreground `task(..., background: false)` returns only when that reviewer is complete; verify its corresponding structured PR comment before continuing. A missing comment after the wait is an explicit delivery failure: do not synthesize a verdict or treat it as a clean result. Do not infer a clean review from a missing comment: a reviewer's return text may contain findings that could not be posted because GitHub writes were throttled.
1631
1845
 
1632
1846
  ---
1633
1847
 
@@ -1636,11 +1850,32 @@ fi
1636
1850
  **Skip if**: Only 1 agent OR total findings ≤ 3.
1637
1851
 
1638
1852
  ```bash
1853
+ # Each Bash call is a fresh shell: re-declare state with the orchestrator's values.
1854
+ REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; REVIEW_SHA="{REVIEW_SHA}"
1855
+ case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
1856
+ [ "$REVIEW_SHA_OK" = "true" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA is not a commit id (an empty SHA would count every head's findings)"; exit 0; }
1639
1857
  # Extract structured finding IDs from FINDING HTML comments.
1640
- # Uses jq's scan() (POSIX-portable, no grep -oP required).
1641
- ALL_FINDINGS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
1642
- --jq '[.[].body | scan("<!-- FINDING:([^>]+) -->") | .[0]] | join("\n")')
1643
- AGENT_COUNT=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --jq '[.[] | select(.body | test("REVIEW-FINDINGS-START"))] | length')
1858
+ # Only TRUSTED reviewer bodies count (scripts/trusted-comments.sh: trusted association, Bot account, or
1859
+ # FORGE_TRAIL_TRUSTED_LOGINS), each must START with the FORGE:REVIEW-AGENT marker and carry a Reviewed-SHA line for the
1860
+ # CURRENT head. The head SHA is public, so the SHA line alone proves nothing: a bare contains() selection would let any
1861
+ # commenter forge FINDING markers that get filed as issues or inflate the agent count. Earlier-head comments are excluded
1862
+ # so a re-entry or head move does not re-file already-fixed findings. Uses jq scan() (POSIX-portable, no PCRE grep).
1863
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
1864
+ TRUSTED_SCRIPT=""
1865
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
1866
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1867
+ done
1868
+ COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
1869
+ AGENT_RE="^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)"
1870
+ if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then
1871
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read reviewer findings (fail closed)"; exit 0
1872
+ fi
1873
+ AGENT_BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$AGENT_RE" 2>/dev/null) \
1874
+ || { echo "REVIEW_RESULT: status: BLOCKED, blocker: trusted reviewer comments unreadable (fail closed)"; exit 0; }
1875
+ ALL_FINDINGS=$(printf '%s\n' "$AGENT_BODIES" | jq -r 'scan("<!-- FINDING:([^>]+) -->") | .[0]' 2>/dev/null)
1876
+ # Agent bodies start with the REVIEW-AGENT marker and carry REVIEW-FINDINGS-START inside, so count it with a lookahead.
1877
+ AGENT_COUNT=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
1878
+ case "$AGENT_COUNT" in ''|*[!0-9]*) AGENT_COUNT=0 ;; esac # fail closed
1644
1879
  FINDING_COUNT=$(echo "$ALL_FINDINGS" | grep -c '.' || echo 0)
1645
1880
  ```
1646
1881
 
@@ -1648,7 +1883,7 @@ If synthesis needed, launch a `general-purpose` Task (model: `"{SUBAGENT_MODEL}"
1648
1883
  - Deduplicate findings by file + line range ±5 (keep higher confidence)
1649
1884
  - Resolve contradictions by reading disputed code
1650
1885
  - Dismiss false positives with evidence
1651
- - Post synthesis comment with `<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->` block
1886
+ - Post synthesis comment whose body **starts with** `<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->` (first line, nothing before it), followed by a `Reviewed-SHA: ${REVIEW_SHA}` line on its own line (Phase 6A only reads trusted synthesized blocks that open with the marker and match the current head; a body with a preamble is ignored and Phase 6A falls back to the raw agent bodies)
1652
1887
  - Do NOT add new findings — only triage existing ones
1653
1888
 
1654
1889
  **IMPORTANT**: When synthesized block exists, Phase 6 MUST use it instead of raw findings.
@@ -1662,17 +1897,39 @@ If synthesis needed, launch a `general-purpose` Task (model: `"{SUBAGENT_MODEL}"
1662
1897
  ### 6A: Extract Findings
1663
1898
 
1664
1899
  ```bash
1665
- HAS_SYNTHESIS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --jq '.[].body' | grep -c 'REVIEW-FINDINGS-SYNTHESIZED-START' || echo 0)
1900
+ # Each Bash call is a fresh shell: re-declare state with the orchestrator's values.
1901
+ REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; REVIEW_SHA="{REVIEW_SHA}"
1902
+ case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
1903
+ [ "$REVIEW_SHA_OK" = "true" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA is not a commit id (an empty SHA would count every head's findings)"; exit 0; }
1904
+ # Only TRUSTED bodies for the CURRENT head count (scripts/trusted-comments.sh + anchored marker + Reviewed-SHA line):
1905
+ # findings from earlier heads are already fixed or re-reviewed, and an untrusted commenter must never be able to inject
1906
+ # findings or post a forged synthesis block that makes the review read clean (the head SHA is public).
1907
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
1908
+ TRUSTED_SCRIPT=""
1909
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
1910
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1911
+ done
1912
+ COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
1913
+ if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then
1914
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read clean (fail closed)"; exit 0
1915
+ fi
1916
+ SYNTH_RE="^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)"
1917
+ AGENT_RE="^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)"
1918
+ HAS_SYNTHESIS=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "$SYNTH_RE" 2>/dev/null || echo "")
1919
+ case "$HAS_SYNTHESIS" in ''|*[!0-9]*) echo "REVIEW_RESULT: status: BLOCKED, blocker: synthesis count unreadable (fail closed)"; exit 0 ;; esac
1666
1920
 
1667
1921
  if [ "$HAS_SYNTHESIS" -gt 0 ]; then
1668
- # Extract finding IDs from synthesized block using jq scan() — no grep -oP needed
1669
- FINDINGS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
1670
- --jq '[.[] | select(.body | test("REVIEW-FINDINGS-SYNTHESIZED-START")) | .body | scan("<!-- FINDING:([^>]+) -->") | .[0]] | join("\n")')
1922
+ # Extract finding IDs from the trusted current-head synthesized block using jq scan() (no PCRE grep needed)
1923
+ SOURCE_BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$SYNTH_RE" 2>/dev/null) \
1924
+ || { echo "REVIEW_RESULT: status: BLOCKED, blocker: trusted synthesis unreadable (fail closed)"; exit 0; }
1671
1925
  else
1672
- # Extract finding IDs from all agent comments using jq scan() — portable, no grep -oP
1673
- FINDINGS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
1674
- --jq '[.[].body | scan("<!-- FINDING:([^>]+) -->") | .[0]] | join("\n")')
1926
+ # Extract finding IDs from the trusted current-head agent comments using jq scan() (portable, no PCRE grep)
1927
+ SOURCE_BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$AGENT_RE" 2>/dev/null) \
1928
+ || { echo "REVIEW_RESULT: status: BLOCKED, blocker: trusted reviewer comments unreadable (fail closed)"; exit 0; }
1929
+ # The panel guard guarantees at least one trusted reviewer body for this head; none means a misread, never "clean".
1930
+ [ -n "$SOURCE_BODIES" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: no trusted reviewer comments for head ${REVIEW_SHA} (fail closed)"; exit 0; }
1675
1931
  fi
1932
+ FINDINGS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r 'scan("<!-- FINDING:([^>]+) -->") | .[0]' 2>/dev/null)
1676
1933
  ```
1677
1934
 
1678
1935
  Also include any `INTEG-N` findings from Phase 2.5 that weren't already covered by agents.
@@ -1706,7 +1963,7 @@ _cands="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_H
1706
1963
  for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
1707
1964
  [ -n "$_cfg" ] || continue
1708
1965
  _cands="$_cands
1709
- $(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)"
1966
+ $(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)"
1710
1967
  done
1711
1968
  _cands="$_cands
1712
1969
  $PWD"
@@ -1723,7 +1980,7 @@ INPR_DIFF_FILE=""
1723
1980
  if [ "${AUTO_MERGE:-false}" = "true" ] && [ -n "${MERGE_ISSUE:-}" ]; then
1724
1981
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
1725
1982
  TRUSTED_SCRIPT=""
1726
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$PWD"; do
1983
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
1727
1984
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1728
1985
  done
1729
1986
  if [ -n "$TRUSTED_SCRIPT" ]; then INPR_MARKERS=$(gh api --paginate "repos/{GH_REPO}/issues/{PR_NUMBER}/comments" 2>/dev/null \
@@ -1897,6 +2154,73 @@ fi
1897
2154
  - Open `review-finding` issue at same file with similar title (3+ shared keywords) → **skip** (likely same finding despite line drift)
1898
2155
  - Closed `review-finding` at same file within ±5 lines → create with regression warning, elevate to `priority:P1`
1899
2156
 
2157
+ **Pattern recurrence check (MANDATORY before creating, after the dedup above):** <!-- Added: forge#3449 -->
2158
+
2159
+ Title and line-range dedup is instance-keyed, so the same defect class re-filed in a sibling file slips through and fix chains grow. Key recurrence on the `FORGE:PATTERN` slug plus subsystem. **Subsystem** = the directory of the finding's primary file, truncated to its first two segments (`commands/work-on/build/x.md` -> `commands/work-on`; `commands/review-pr.md` -> `commands`). A file at the repo root has no subsystem and skips the check. This step runs only for findings that survived the note-disposition classifier above, so it never re-admits a finding the cascade damper already suppressed.
2160
+
2161
+ **Exemptions are decided FIRST, before any lookup or consolidation.** A finding is never consolidated away, and skips this check entirely (file it as a normal instance issue), when ANY of these hold: category `Security`, a REGRESSION (closed-issue match above), severity `HIGH` or `CRITICAL`, or priority `P0`/`P1`. Consolidation exists to damp repeated low-severity noise; it must never lower the `review-finding` counts the Phase 6 cascade damper and breaker read for severe findings.
2162
+
2163
+ ```bash
2164
+ REPO="{GH_REPO}" # placeholder, not an env var: review-pr never exports GH_REPO
2165
+ PATTERN_SLUG="pattern-slug" # the finding's FORGE:PATTERN value
2166
+ PRIMARY_FILE="path/to/file.py" # the finding's primary file
2167
+ FINDING_EXEMPT="false" # set true for Security / REGRESSION / HIGH / CRITICAL / P0 / P1
2168
+ CONSOLIDATE="false"; PRIOR_NUMS=""; SUBSYSTEM=""
2169
+ # Slug and path are model-written text: validate before any shell or jq use.
2170
+ # A bash pattern test (not per-line grep) so a multi-line value cannot pass on one valid line.
2171
+ if [ "$FINDING_EXEMPT" = "true" ]; then
2172
+ echo "RECURRENCE: exempt (severity/category/regression) - filing normally"
2173
+ elif [ -z "$REPO" ]; then
2174
+ echo "RECURRENCE: skipped - repo not resolved, filing normally"
2175
+ elif ! [[ "$PATTERN_SLUG" =~ ^[a-z0-9-]+$ ]]; then
2176
+ echo "RECURRENCE: skipped - invalid slug, filing normally"
2177
+ else
2178
+ SUBSYSTEM=$(dirname -- "$PRIMARY_FILE" | cut -d/ -f1-2)
2179
+ if [ "$SUBSYSTEM" = "." ] || ! [[ "$SUBSYSTEM" =~ ^[A-Za-z0-9._/-]+$ ]]; then
2180
+ echo "RECURRENCE: skipped - no valid subsystem, filing normally"
2181
+ else
2182
+ SELF="${MERGE_ISSUE:-0}"; [[ "$SELF" =~ ^[0-9]+$ ]] || SELF=0
2183
+ # Narrow server-side by the exact slug tag (the slug is validated, so safe in --search), then
2184
+ # filter in jq. Excludes class issues, false-positive findings and issues closed as not planned.
2185
+ PRIOR=$(gh issue list -R "$REPO" --state all --label review-finding --limit 200 \
2186
+ --search "FORGE:PATTERN: ${PATTERN_SLUG} in:body" \
2187
+ --json number,body,labels,stateReason 2>/dev/null) || PRIOR="__LOOKUP_FAILED__"
2188
+ if [ "$PRIOR" = "__LOOKUP_FAILED__" ]; then
2189
+ # Fail closed on lookup errors: file the finding normally, never drop it.
2190
+ echo "RECURRENCE: lookup failed - filing as a normal instance issue"
2191
+ else
2192
+ [ "$(printf '%s' "$PRIOR" | jq 'length')" -ge 200 ] && echo "RECURRENCE: WARNING lookup hit the 200-result cap, older priors may be missed"
2193
+ PRIOR_NUMS=$(printf '%s' "$PRIOR" | jq -r --arg slug "$PATTERN_SLUG" --arg sub "$SUBSYSTEM" --argjson self "$SELF" '
2194
+ ($sub | gsub("\\."; "\\.")) as $esc
2195
+ | [.[] | select(.number != $self)
2196
+ | select(.stateReason != "NOT_PLANNED")
2197
+ | select((.labels | map(.name)) as $l | (($l | index("pattern-class")) or ($l | index("false-positive"))) | not)
2198
+ | select(.body | contains("<!-- FORGE:PATTERN: " + $slug + " -->"))
2199
+ | select(.body | test("(^|[^A-Za-z0-9._-])" + $esc + "/"))
2200
+ | .number] | join(" ")')
2201
+ if [ -n "$PRIOR_NUMS" ]; then
2202
+ CONSOLIDATE="true"
2203
+ echo "RECURRENCE: $PATTERN_SLUG seen before in $SUBSYSTEM (#${PRIOR_NUMS// /, #}) - consolidating"
2204
+ fi
2205
+ fi
2206
+ fi
2207
+ fi
2208
+ ```
2209
+
2210
+ Each bash call is a fresh shell. Run the consolidation commands below in the SAME call as the block above, or re-declare `REPO`, `PATTERN_SLUG`, `SUBSYSTEM`, `PRIOR_NUMS` and `CONSOLIDATE` first. When `CONSOLIDATE=true` (this is the 2nd or later occurrence), consolidate instead of filing another instance issue. `CLASS_DONE` stays `false` unless every step below succeeds:
2211
+
2212
+ ```bash
2213
+ CLASS_DONE="false"; CLASS_MARKER="<!-- FORGE:PATTERN-CLASS: ${PATTERN_SLUG} ${SUBSYSTEM} -->"
2214
+ find_class() { gh issue list -R "$REPO" --state open --label pattern-class --limit 100 --json number,body 2>/dev/null \
2215
+ | jq -r --arg m "$CLASS_MARKER" '[.[] | select(.body | contains($m)) | .number] | sort | .[0] // empty'; }
2216
+ CLASS_NUM=$(find_class) || CLASS_NUM="__LOOKUP_FAILED__" # lookup error: stop consolidating, file normally
2217
+ ```
2218
+
2219
+ 1. If the lookup failed, stop consolidating and file normally (fail closed). Otherwise, if `CLASS_NUM` is set, add one comment to that issue listing this finding (file, line, PR), unless a comment already contains the marker `<!-- FORGE:PATTERN-CLASS-ITEM: PR #${PR_NUMBER} ${PRIMARY_FILE}:${LINE} -->` (check it with `jq --arg` and an exact `contains`, so `PR #12` never matches `PR #123`). Set `CLASS_DONE=true` on success.
2220
+ 2. If `CLASS_NUM` is empty, ensure the label exists (`gh label create pattern-class -R "$REPO" --color ededed --description "Class-level consolidation of a recurring finding pattern" --force`), re-run `find_class` immediately before creating (tie-break for parallel reviewers: if more than one class issue exists, the lowest number wins and a duplicate you created is closed as a duplicate of it), then create ONE class-level issue through the `/issue` programmatic invocation contract (same as the per-finding path below), titled `fix: class-wide ${PATTERN_SLUG} in ${SUBSYSTEM}`. Its body MUST carry BOTH `${CLASS_MARKER}` and `<!-- FORGE:PATTERN: ${PATTERN_SLUG} -->`, the Prevention text, and links to every prior instance and this finding. Labels: `review-finding`, `pattern-class`, plus the highest priority among the linked issues. The `FORGE:PATTERN` tag plus `review-finding` label are what make investigate step 7.6 sweep it, and they keep it counted by pipeline-health Phase 4A (two instances plus the class issue reach its 3-distinct-issue threshold). The fix for it must follow the investigate Pattern Sweep. Set `CLASS_DONE=true` only if the create returned an issue number.
2221
+ 3. Only when `CLASS_DONE=true`: comment on this PR with the class-level issue link (marker `<!-- FORGE:PATTERN-CLASS-LINK: ${PATTERN_SLUG} ${SUBSYSTEM} PR #${PR_NUMBER} -->`; skip if a PR comment already has it) and skip creating the instance issue. **If class create or comment failed for any reason, do NOT skip the instance issue: file it normally** so the finding is never lost.
2222
+ 4. Consolidation links findings; it does not drop them. A finding that is exempt (see above) is filed as normal and may add the class-level link to its body.
2223
+
1900
2224
  **For each finding** (that passes dedup), create issue through the `/issue` create-hook's programmatic invocation contract (see `commands/issue.md` § "Programmatic Invocation Contract") — this preserves the bespoke line-range/title dedup above as a precise pre-check, while `/issue`'s own Phase 2D dedup runs as a coarser second pass:
1901
2225
  ```bash
1902
2226
  FINDING_ISSUE_TITLE="fix: [summary] (review finding — PR #${PR_NUMBER})"
@@ -2145,23 +2469,39 @@ Verdict determined by standard blocking criteria.
2145
2469
  1. Phase 2 automated checks failed (build error, type error, test failure)
2146
2470
  2. Agent finding is CONFIRMED at HIGH or CRITICAL severity
2147
2471
  3. **[Milestone PRs only]** Phase 7A Purpose Regression Gate flagged `HAS_PURPOSE_REGRESSION=true` for this finding — regardless of whether it causes a runtime error
2148
- 4. `MERGE_HEALTH == "CONFLICTING"` OR `MERGE_HEALTH_STATE` in {`DIRTY`, `BLOCKED`} — PR cannot be merged cleanly into its base branch <!-- Added: forge#194 -->
2149
- - Verdict: CHANGES REQUESTED. Message: "Merge conflict with `{base}`. Rebase `{head}` onto `origin/{base}`, resolve the conflicting files, then re-run /review-pr."
2472
+ 4. **Base conflict** — `BASE_CONFLICT=true` (computed in the block below): `MERGE_HEALTH == "CONFLICTING"`, OR `MERGE_HEALTH_STATE == "DIRTY"`, OR `MERGE_HEALTH_STATE == "BEHIND"` while the base branch requires up-to-date branches — PR cannot be merged cleanly into its base branch <!-- Added: forge#194, forge#3496 -->
2473
+ - Verdict: CHANGES REQUESTED. Message: "Base conflict with `{base}`. Merge `origin/{base}` into `{head}` (merge only; no rebase, no force-push), resolve the conflicting files by reading both sides, then re-run /review-pr."
2474
+ - A merely-`BEHIND` branch on a base that does NOT require up-to-date branches is not a conflict and needs no sync: GitHub merges it fine.
2475
+ - `MERGE_HEALTH_STATE == "BLOCKED"` alone (branch protection not satisfied: missing required review or check) is NOT a base conflict: syncing the base cannot clear it. Emit a WARNING line in the verdict body and leave `HAS_MERGE_CONFLICT=false`; the Phase 8 pre-merge guard handles it as a protection abort.
2150
2476
  - If `MERGE_HEALTH == "UNKNOWN"` after retries: emit a WARNING in the verdict body (do NOT treat as a block — GitHub may still be computing it).
2151
2477
  5. A CONFIRMED coverage reduction (deleted test, removed test case, or disabled/removed workflow test step) in a PR that responds to a red check, or that has no justification tying it to removal of the tested code <!-- Added: forge#3257 -->
2152
2478
  - Verdict: CHANGES REQUESTED. Message: "Coverage reduction: a deleted test or removed workflow test step is not a fix for a red check. Restore it and fix the code under test, or escalate naming the failing assertions." Detection follows quality-gate 2U (`COVERAGE-1`); never dedup this finding against the PR's own issue.
2153
2479
 
2154
2480
  ```bash
2155
2481
  # Determine if mergeability is a blocker (MERGE_HEALTH/MERGE_HEALTH_STATE set in Phase 1A; BASE/HEAD set in Phase 0 Mode 3)
2482
+ # BASE_CONFLICT is the single source of truth for "a base sync would fix this"; the Phase 8 guards read it. <!-- Added: forge#3496 -->
2156
2483
  HAS_MERGE_CONFLICT=false
2484
+ BASE_CONFLICT=false
2157
2485
  MERGE_CONFLICT_MSG=""
2158
- if [ "$MERGE_HEALTH" = "CONFLICTING" ] || [ "$MERGE_HEALTH_STATE" = "DIRTY" ] || [ "$MERGE_HEALTH_STATE" = "BLOCKED" ]; then
2486
+ MERGE_PROTECTION_WARNING=""
2487
+ BASE_REQUIRES_UPTODATE=false
2488
+ if [ "$MERGE_HEALTH_STATE" = "BEHIND" ]; then
2489
+ # Unreadable protection (404 / 403 / no admin access) is treated as "not required": no sync, and a later failed merge lands in the existing needs-human path.
2490
+ [ "$(gh api "repos/${REPO}/branches/${BASE}/protection" --jq '.required_status_checks.strict // false' 2>/dev/null)" = "true" ] && BASE_REQUIRES_UPTODATE=true
2491
+ fi
2492
+ if [ "$MERGE_HEALTH" = "CONFLICTING" ] || [ "$MERGE_HEALTH_STATE" = "DIRTY" ] || { [ "$MERGE_HEALTH_STATE" = "BEHIND" ] && [ "$BASE_REQUIRES_UPTODATE" = "true" ]; }; then
2159
2493
  HAS_MERGE_CONFLICT=true
2160
- MERGE_CONFLICT_MSG="Merge conflict with \`${BASE}\`. Rebase \`${HEAD}\` onto \`origin/${BASE}\`, resolve the conflicting files, then re-run /review-pr."
2494
+ BASE_CONFLICT=true
2495
+ MERGE_CONFLICT_MSG="Base conflict with \`${BASE}\`. Merge \`origin/${BASE}\` into \`${HEAD}\` (merge only, no rebase, no force-push), resolve the conflicting files by reading both sides, then re-run /review-pr."
2496
+ elif [ "$MERGE_HEALTH_STATE" = "BLOCKED" ]; then
2497
+ MERGE_PROTECTION_WARNING="Merge state is BLOCKED: branch protection is not satisfied (required review or check). Syncing the base cannot clear this."
2161
2498
  fi
2162
2499
 
2500
+ # ONLY_BASE_CONFLICT is NOT computed here: CALIBRATION_NEEDS_HUMAN is only set later (Phase 7B.5) and TRUST_NEEDS_HUMAN in Phase 3B.5,
2501
+ # so it is evaluated at the top of the Phase 8 guard, after every human gate has been set. <!-- forge#3496 -->
2502
+
2163
2503
  # Resolve attribution footer (forge.yaml → attribution.pr_footer)
2164
- ATTRIBUTION_PR_FOOTER=$(grep -A5 "^attribution:" forge.yaml 2>/dev/null | grep "pr_footer:" | awk '{print $2}' | tr -d '"' || echo "false")
2504
+ ATTRIBUTION_PR_FOOTER=$(grep -A5 "^attribution:" forge.yaml 2>/dev/null | grep "pr_footer:" | awk '{print $(2)}' | tr -d '"' || echo "false")
2165
2505
  ATTRIBUTION_FOOTER_LINE=""
2166
2506
  if [ "$ATTRIBUTION_PR_FOOTER" = "true" ]; then
2167
2507
  ATTRIBUTION_FOOTER_LINE="
@@ -2174,19 +2514,21 @@ fi
2174
2514
  TRUST_ANNOTATION="**Intensity tier**: ${INTENSITY_TIER:-SHADOW} | **Cell**: \`${TABLE_CELL_KEY:-(unavailable)}\` | **Shadow mode**: ${TRUST_SHADOW_MODE:-true}"
2175
2515
 
2176
2516
  # Stale review:
2177
- gh pr review $ARGUMENTS --comment --body "Review of commit $REVIEW_SHA_SHORT is stale — PR HEAD changed. Re-run /review-pr."
2517
+ gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "Review of commit $REVIEW_SHA_SHORT is stale — PR HEAD changed. Re-run /review-pr."
2178
2518
 
2179
2519
  # Clean (no blocking issues):
2180
- gh pr review $ARGUMENTS --comment --body "APPROVED: commit $REVIEW_SHA_SHORT after context-aware review ([N] agents: [names]). [M] findings created as issues. Safe to merge.
2520
+ gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "APPROVED: commit $REVIEW_SHA_SHORT after context-aware review ([N] agents: [names]). [M] findings created as issues. Safe to merge.
2181
2521
  ${TRUST_ANNOTATION}
2182
2522
  $([ "$MERGE_HEALTH" = "UNKNOWN" ] && echo "
2183
2523
  ⚠ Mergeability: GitHub is still computing merge state (UNKNOWN after retries). Verify manually before merging.")${ATTRIBUTION_FOOTER_LINE}"
2184
2524
 
2185
2525
  # Blocking issues (including merge conflicts and purpose regressions):
2186
- gh pr review $ARGUMENTS --comment --body "CHANGES REQUESTED: commit $REVIEW_SHA_SHORT — [N] blocking issues found. See GitHub issues.
2526
+ gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "CHANGES REQUESTED: commit $REVIEW_SHA_SHORT — [N] blocking issues found. See GitHub issues.
2187
2527
  ${TRUST_ANNOTATION}
2188
2528
  $([ "$HAS_MERGE_CONFLICT" = "true" ] && echo "
2189
- 🔴 Merge Conflict: ${MERGE_CONFLICT_MSG}")
2529
+ 🔴 Base Conflict: ${MERGE_CONFLICT_MSG}")
2530
+ $([ -n "$MERGE_PROTECTION_WARNING" ] && echo "
2531
+ ⚠ ${MERGE_PROTECTION_WARNING}")
2190
2532
  $([ "$HAS_PURPOSE_REGRESSION" = "true" ] && echo "
2191
2533
  ⚠ Purpose Regression: [N] finding(s) contradict the milestone's stated goal and are automatically blocking regardless of runtime impact. See: ${PURPOSE_REGRESSION_FINDINGS[@]}")${ATTRIBUTION_FOOTER_LINE}"
2192
2534
  ```
@@ -2297,7 +2639,7 @@ FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test(
2297
2639
  # FORGE_TRAIL_TRUSTED_LOGINS. A GitHub App bot always has author_association NONE, so an association-only filter drops the pipeline's own markers.
2298
2640
  _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
2299
2641
  TRUSTED_SCRIPT=""
2300
- for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$PWD"; do
2642
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
2301
2643
  case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
2302
2644
  done
2303
2645
  if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$FINDING_COUNT" ]; then
@@ -2358,7 +2700,7 @@ else
2358
2700
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
2359
2701
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
2360
2702
  # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
2361
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
2703
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
2362
2704
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
2363
2705
  # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
2364
2706
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -2443,7 +2785,19 @@ If the preflight failed, skip the rest of Phase 8. On exit code 1, return `REVIE
2443
2785
  # These vars are set in Phase 7A/7B/7B.5/3B.5 earlier in the same agent session.
2444
2786
  # An unset/empty VERDICT is safe — it evaluates to "" which does not equal "CHANGES REQUESTED".
2445
2787
  # TRUST_NEEDS_HUMAN: set to true by Phase 3B.5 when INTENSITY_TIER=NOVEL_NEEDS_HUMAN AND shadow mode is off.
2446
- if [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "${TRUST_NEEDS_HUMAN:-false}" = "true" ]; then
2788
+ # ONLY_BASE_CONFLICT: the base conflict is the ONLY reason the verdict is CHANGES REQUESTED. Computed HERE (after Phase 7B.5 and 3B.5) so the
2789
+ # calibration and trust human gates are already set and can never be skipped for a base-conflict PR. <!-- forge#3496 -->
2790
+ # Set OTHER_BLOCKING=true when ANY other blocking criterion (1, 2, 3 or 5 in Phase 7B) fired for this PR. When true, route to needs-human below.
2791
+ ONLY_BASE_CONFLICT=false
2792
+ if [ "${BASE_CONFLICT:-false}" = "true" ] && [ "${OTHER_BLOCKING:-false}" != "true" ] && [ "${HAS_PURPOSE_REGRESSION:-false}" != "true" ] && [ "${CALIBRATION_NEEDS_HUMAN:-false}" != "true" ] && [ "${TRUST_NEEDS_HUMAN:-false}" != "true" ]; then
2793
+ ONLY_BASE_CONFLICT=true
2794
+ fi
2795
+ if [ "${ONLY_BASE_CONFLICT:-false}" = "true" ] && [ "$VERDICT" = "CHANGES REQUESTED" ]; then
2796
+ # Pure base conflict (set in Phase 7B: the ONLY blocker): route to the base-sync path, NOT needs-human. <!-- Added: forge#3496 -->
2797
+ # work-on/review.md R4 adds needs-human only when it hands off to remediate; a standalone /review-pr caller just sees the blocker.
2798
+ gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "⛔ Auto-merge aborted for PR #{PR_NUMBER}: base conflict with \`{MERGE_BASE}\` (the only blocking reason). Next step is a base sync: merge \`origin/{MERGE_BASE}\` into the PR branch (merge only, no rebase, no force-push), resolve conflicts by reading both sides, re-run the quality gate, then re-run /review-pr on the new head." # <!-- allowlist:check-command-side-effects -->
2799
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "base-conflict". Do NOT add needs-human and do NOT attempt gh pr merge.
2800
+ elif [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "${TRUST_NEEDS_HUMAN:-false}" = "true" ]; then
2447
2801
  BLOCK_REASON=""
2448
2802
  [ "$VERDICT" = "CHANGES REQUESTED" ] && BLOCK_REASON="review verdict is CHANGES REQUESTED (blocking finding confirmed by Phase 7B)"
2449
2803
  [ "$HAS_PURPOSE_REGRESSION" = "true" ] && BLOCK_REASON="${BLOCK_REASON:+${BLOCK_REASON}; }purpose regression detected by Phase 7A (\`HAS_PURPOSE_REGRESSION=true\`)"
@@ -2461,10 +2815,23 @@ PRE_MERGE_RESULT=$(gh pr view {PR_NUMBER} {MERGE_GH_FLAG} --json mergeable,merge
2461
2815
  PRE_MERGE_HEALTH=${PRE_MERGE_RESULT%%|*}
2462
2816
  PRE_MERGE_HEALTH_STATE=${PRE_MERGE_RESULT##*|}
2463
2817
 
2464
- if [ "$PRE_MERGE_HEALTH" = "CONFLICTING" ] || [ "$PRE_MERGE_HEALTH_STATE" = "DIRTY" ] || [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then
2465
- gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "⛔ Auto-merge aborted for PR #{PR_NUMBER}: PR is not mergeable (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). Rebase the branch onto \`{MERGE_BASE}\` and resolve conflicts, then re-run /review-pr."
2818
+ PRE_MERGE_BASE_CONFLICT=false
2819
+ if [ "$PRE_MERGE_HEALTH" = "CONFLICTING" ] || [ "$PRE_MERGE_HEALTH_STATE" = "DIRTY" ]; then
2820
+ PRE_MERGE_BASE_CONFLICT=true
2821
+ elif [ "$PRE_MERGE_HEALTH_STATE" = "BEHIND" ]; then
2822
+ # BEHIND is a conflict only under required-up-to-date protection; unreadable protection counts as "not required". <!-- Added: forge#3496 -->
2823
+ [ "$(gh api "repos/${REPO}/branches/{MERGE_BASE}/protection" --jq '.required_status_checks.strict // false' 2>/dev/null)" = "true" ] && PRE_MERGE_BASE_CONFLICT=true
2824
+ fi
2825
+
2826
+ if [ "$PRE_MERGE_BASE_CONFLICT" = "true" ]; then
2827
+ # Base moved during review: route to the base-sync path, NOT needs-human. <!-- Added: forge#3496 -->
2828
+ gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "⛔ Auto-merge aborted for PR #{PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). Merge \`origin/{MERGE_BASE}\` into the PR branch (merge only, no rebase, no force-push), resolve conflicts by reading both sides, then re-run /review-pr." # <!-- allowlist:check-command-side-effects -->
2829
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "base-conflict". Do NOT add needs-human and do NOT attempt gh pr merge.
2830
+ elif [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then
2831
+ # Branch protection not satisfied: syncing the base cannot clear it, so this is NOT a base-conflict blocker.
2832
+ gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "⛔ Auto-merge aborted for PR #{PR_NUMBER}: branch protection is not satisfied (\`mergeStateStatus=BLOCKED\`); syncing the base cannot clear it. Satisfy the required review/checks, then re-run /review-pr."
2466
2833
  gh issue edit {MERGE_ISSUE} {MERGE_GH_FLAG} --add-label "needs-human" 2>/dev/null || true
2467
- # STOP — do not attempt gh pr merge on a CONFLICTING/DIRTY PR
2834
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "branch protection not satisfied" (never "base-conflict")
2468
2835
  else
2469
2836
 
2470
2837
  # Previously-escalated re-review guard <!-- Added: forge#1810; base-scoped: forge#2570 -->
@@ -2646,7 +3013,7 @@ fi
2646
3013
 
2647
3014
  ```bash
2648
3015
  if [ "${IS_MILESTONE_TO_STAGING:-false}" = "true" ]; then
2649
- PR_MERGE_STATE=$(gh pr view $ARGUMENTS --json state --jq '.state' 2>/dev/null || echo "")
3016
+ PR_MERGE_STATE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json state --jq '.state' 2>/dev/null || echo "")
2650
3017
  if [ "$PR_MERGE_STATE" = "MERGED" ]; then
2651
3018
  echo "Phase 9A: Checking for open review-finding issues to demilestone (fallback — manual merge path)..."
2652
3019
 
@@ -2677,7 +3044,7 @@ fi
2677
3044
  <!-- Added: forge#815 -->
2678
3045
 
2679
3046
  ```bash
2680
- CURRENT_SHA=$(gh pr view $ARGUMENTS --json headRefOid --jq '.headRefOid')
3047
+ CURRENT_SHA=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq '.headRefOid')
2681
3048
  REVIEW_IS_STALE="false"
2682
3049
  if [ "$CURRENT_SHA" != "$REVIEW_SHA" ]; then REVIEW_IS_STALE="true"; fi
2683
3050
  ```
@@ -2715,7 +3082,7 @@ if [ "$IS_FIRST_TIME_CONTRIBUTOR" = "true" ] && [ "$WELCOME_ENABLED" != "false"
2715
3082
  # ${VAR} interpolation, which a quoted heredoc would disable. Literal backticks in
2716
3083
  # the static text are still escaped (\`) since they remain live inside an unquoted
2717
3084
  # heredoc, same as inside a double-quoted string. See forge#2223.
2718
- gh pr comment "$ARGUMENTS" --body "$(cat <<EOF
3085
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body "$(cat <<EOF
2719
3086
  ## Welcome to ${PROJECT_NAME_WELCOME}! 🎉
2720
3087
 
2721
3088
  Hi @${PR_AUTHOR} — this looks like your first merged contribution here. Thanks for opening a PR!
@@ -2744,8 +3111,18 @@ fi
2744
3111
  A degraded run that skipped Task-based agent dispatch must be visible from this summary alone, without interrogating the agent afterward. Compute the actual launched-agent count from the `<!-- FORGE:REVIEW-AGENT:{domain} -->` comments each agent is required to post (Phase 3C), rather than trusting a free-text tally:
2745
3112
 
2746
3113
  ```bash
2747
- ACTUAL_AGENT_DOMAINS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
2748
- --jq '[.[].body | scan("<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | join(", ")' 2>/dev/null || echo "")
3114
+ # Same trust rule as Phase 3C/4: only trusted comments whose body starts with the marker count.
3115
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
3116
+ TRUSTED_SCRIPT=""
3117
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
3118
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
3119
+ done
3120
+ ACTUAL_AGENT_DOMAINS=""
3121
+ if [ -n "$TRUSTED_SCRIPT" ]; then
3122
+ ACTUAL_AGENT_DOMAINS=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
3123
+ | bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
3124
+ | jq -rs '[.[] | scan("^<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | join(", ")' 2>/dev/null || echo "") # unresolvable => empty => count 0 (degraded, fail closed)
3125
+ fi
2749
3126
  # NOTE: no `|| echo 0` fallback here — grep -c always prints a count (including 0 on
2750
3127
  # no match) even though it exits 1 in that case. Appending `|| echo 0` after a `grep -c`
2751
3128
  # pipeline double-counts: the pipeline already printed "0", then the fallback prints a
@@ -2757,7 +3134,7 @@ ACTUAL_AGENT_COUNT=$(echo "$ACTUAL_AGENT_DOMAINS" | tr ',' '\n' | grep -c '.' 2>
2757
3134
  Substitute `ACTUAL_AGENT_COUNT`/`ACTUAL_AGENT_DOMAINS` into the summary's `**Agents**: [N] ([names])` field below — do NOT substitute a manually-counted or remembered figure. Compare it to `SELECTED_AGENT_COUNT` from Phase 3C. If it is smaller, the panel is degraded: the Phase 3C hard-stop path must already have labelled the PR `review-degraded`, added `needs-human`, and exited without a verdict. Never summarize, approve, or merge a partial panel. `ACTUAL_AGENT_COUNT=0` is the same hard stop, not a solo/inline review mode.
2758
3135
 
2759
3136
  ```bash
2760
- gh pr comment $ARGUMENTS --body "$(cat <<'EOF'
3137
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body "$(cat <<'EOF'
2761
3138
  # PR Review Summary: #[NUMBER] - [TITLE]
2762
3139
 
2763
3140
  ## Review Integrity