forgedock 1.17.0 → 1.18.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 (60) hide show
  1. package/bin/engine/outcome-codes.mjs +24 -2
  2. package/bin/engine/phases.mjs +153 -102
  3. package/bin/engine/runlog.mjs +7 -0
  4. package/bin/engine-cli.mjs +5 -0
  5. package/bin/engine.mjs +84 -10
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/hooks/pre-tool-use.mjs +65 -32
  8. package/bin/runner.mjs +63 -4
  9. package/bin/stats/github-source.mjs +89 -18
  10. package/bin/stats/stats.test.mjs +89 -1
  11. package/commands/cleanup.md +10 -3
  12. package/commands/deploy-pr.md +64 -0
  13. package/commands/fix-ci.md +20 -6
  14. package/commands/milestone.md +34 -17
  15. package/commands/orchestrate/phase-4-execution.md +11 -4
  16. package/commands/review-pr-staging.md +142 -519
  17. package/commands/review-pr.md +490 -3128
  18. package/commands/test-gate.md +25 -10
  19. package/commands/work-on/build/architect.md +13 -1
  20. package/commands/work-on/build/implement.md +2 -1
  21. package/commands/work-on/build/validate.md +2 -0
  22. package/commands/work-on/build.md +107 -86
  23. package/commands/work-on/close.md +50 -15
  24. package/commands/work-on/decompose.md +72 -34
  25. package/commands/work-on/investigate.md +9 -12
  26. package/commands/work-on/remediate.md +27 -13
  27. package/commands/work-on/review.md +5 -3
  28. package/commands/work-on.md +1 -2
  29. package/docs/CONFIG.md +44 -25
  30. package/docs/OUTCOME-CODES.md +7 -2
  31. package/docs/RELEASE-NOTES.md +12 -0
  32. package/docs/STATS.md +2 -1
  33. package/docs/TELEMETRY.md +2 -2
  34. package/docs/WORK-ON-RUNTIME.md +6 -0
  35. package/docs/spec/forge-protocol-v1.md +2 -2
  36. package/package.json +1 -1
  37. package/packages/protocol/src/phases.js +5 -5
  38. package/scripts/cap-findings.sh +1 -1
  39. package/scripts/cap-findings.test.sh +1 -1
  40. package/scripts/check-spec-bash.sh +5 -1
  41. package/scripts/check-spec-bash.test.sh +20 -0
  42. package/scripts/close-parent-c3.test.sh +69 -0
  43. package/scripts/diff-size.sh +93 -4
  44. package/scripts/diff-size.test.sh +59 -2
  45. package/scripts/forge-root.test.sh +13 -11
  46. package/scripts/publish-staging-sync.test.sh +48 -0
  47. package/scripts/resolve-deploy-gate.sh +32 -6
  48. package/scripts/resolve-deploy-gate.test.sh +42 -1
  49. package/scripts/review-finalize.sh +1427 -0
  50. package/scripts/review-finalize.test.sh +833 -0
  51. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  52. package/scripts/review-pr-domain-map.test.sh +18 -13
  53. package/scripts/review-pr-gate2.test.sh +9 -65
  54. package/scripts/review-pr-staging-scope.test.sh +14 -98
  55. package/scripts/review-prepare.sh +48 -12
  56. package/scripts/review-prepare.test.sh +19 -1
  57. package/scripts/review-roster.sh +16 -2
  58. package/scripts/review-roster.test.sh +22 -0
  59. package/scripts/trusted-comments.test.sh +9 -2
  60. package/scripts/wait-ci-green.test.sh +3 -3
@@ -47,7 +47,7 @@ When `IS_OPENCODE_RUNTIME=true`, lowercase native `task` is the preferred isolat
47
47
  1. **If `Task` is available in the current environment**: set `{DISPATCH_TOOL} = Task`. This is the preferred tool — tightest `allowed-tools` scoping. (Identical resolution logic to `/review-pr` Phase 3C — do not diverge.)
48
48
  2. **Else if `Agent` is available**: set `{DISPATCH_TOOL} = Agent`. This is the documented fallback, not a degraded path — use it exactly as you would `Task`: one call per selected agent (Bug Hunters in Phase 3, Code Quality in Phase 4, domain agents in Phase 5), same prompt template, `subagent_type: "general-purpose"` (or the closest equivalent the environment offers), same requirement that each agent posts its own findings directly to the PR via `gh pr comment`. Isolation and fresh-context review are preserved either way.
49
49
  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.
50
- 4. **Dispatch pool exhausted or dispatch call fails**: this is not tool absence. If any Bug Hunter, quality, domain, material-change, or regression reviewer cannot be launched because the sub-agent pool/session limit is exhausted (or any dispatch call fails), HARD STOP. Do not substitute inline review or silently continue with a partial panel. Mark the PR `review-degraded`, post `<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->` with the selected and completed counts, and exit without a deploy verdict. A fresh session must re-run the full panel.
50
+ 4. **Dispatch pool exhausted or dispatch call fails**: this is not tool absence. If any Bug Hunter, quality, domain, material-change, or regression reviewer cannot be launched because the sub-agent pool/session limit is exhausted (or any dispatch call fails), HARD STOP. Do not substitute inline review or silently continue with a partial panel. Mark the PR `review-degraded`, post `<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->` with the selected and completed counts, and exit without a deploy verdict. A fresh session must re-run the full panel. **Disk-full carve-out**: if the failure text matches `EDQUOT`, `ENOSPC`, `No space left`, or `Disk quota exceeded` (errno -28/-122), it is a transient infrastructure failure, not a panel integrity failure: do NOT add `needs-human`, do NOT post `review-panel-integrity`, and do NOT mark `review-degraded`; exit with `REVIEW_RESULT: status: BLOCKED, blocker: infra disk full (EDQUOT/ENOSPC)` so the engine ends a retryable `engine-error` that `--retry` resumes once space is freed.
51
51
 
52
52
  **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.
53
53
 
@@ -110,50 +110,12 @@ The **Evidence-Based Review Protocol** and **Structured Findings Protocol** are
110
110
 
111
111
  **Idempotent re-review (merge-train aware)**: <!-- Added: forge#1328, extended: forge#1332 --> This spec is safe to invoke multiple times on the same PR. Each invocation posts a new `FORGE:REVIEW_ROUTE` marker with the current SHA — re-review passes are distinguished by SHA, not by PR number. When a staging→main deploy PR receives blocking findings and the fixes are pushed to the head branch, re-invoke this spec on the same PR number. Do NOT close the PR and create a new one.
112
112
 
113
- **Prior-finding awareness on re-entry**: On each re-invocation, load findings from prior review passes on the same PR before running any new checks. Previously-resolved findings (issues that are now closed or marked `false-positive`) are excluded from the current verdict to avoid re-surfacing already-fixed issues. Previously-unresolved findings (open issues from a prior pass) are carried forward as context so agents know what was previously flagged:
114
-
115
- ```bash
116
- # Load prior FORGE:REVIEW_ROUTE comments to detect re-entry
117
- PRIOR_ROUTES=$(gh api repos/${GH_REPO}/issues/${PR_NUMBER}/comments \
118
- --jq '[.[] | select(.body | test("FORGE:REVIEW_ROUTE")) | .body] | length' 2>/dev/null || echo 0)
119
-
120
- IS_REENTRY=0
121
- if [ "$PRIOR_ROUTES" -gt 0 ]; then
122
- IS_REENTRY=1
123
- echo "Re-entry detected: $PRIOR_ROUTES prior review pass(es) on PR #${PR_NUMBER}"
124
-
125
- # Anchored post-filter: keep a search hit only if its title ends `PR #N)` or its body has a
126
- # `**Source**: PR #N` line. Hidden markers (issue-create-token, etc.) never count as references.
127
- FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
128
-
129
- # Load findings from prior passes that are still open
130
- PRIOR_OPEN_FINDINGS=$(gh issue list ${GH_FLAG} \
131
- --label "review-finding" \
132
- --state open \
133
- --search "PR #${PR_NUMBER}" \
134
- --limit 50 \
135
- --json number,title,labels,body 2>/dev/null \
136
- | jq -r --arg n "${PR_NUMBER}" "$FINDING_PR_FILTER" 2>/dev/null || true) # uses FINDING_PR_FILTER
137
-
138
- # Load findings that were resolved (closed) since the last pass
139
- PRIOR_RESOLVED_FINDINGS=$(gh issue list ${GH_FLAG} \
140
- --label "review-finding" \
141
- --state closed \
142
- --search "PR #${PR_NUMBER}" \
143
- --limit 50 \
144
- --json number,title,body 2>/dev/null \
145
- | jq -r --arg n "${PR_NUMBER}" "$FINDING_PR_FILTER" 2>/dev/null || true) # uses FINDING_PR_FILTER
146
-
147
- echo "Prior open findings (carry forward): $(echo "$PRIOR_OPEN_FINDINGS" | grep -c '.' || echo 0)"
148
- echo "Prior resolved findings (exclude from verdict): $(echo "$PRIOR_RESOLVED_FINDINGS" | grep -c '.' || echo 0)"
149
- fi
150
- ```
151
-
152
- Agents receive `$IS_REENTRY`, `$PRIOR_OPEN_FINDINGS`, and `$PRIOR_RESOLVED_FINDINGS` as context. They MUST NOT re-file issues that already exist in `$PRIOR_OPEN_FINDINGS` (dedup gate in Phase 7 covers this). Resolved findings do NOT contribute to the BLOCK verdict for the current pass.
153
-
154
113
  Resolve the staging→main PR number and post a routing marker immediately. This creates an audit trail — if a staging→main PR has no `FORGE:REVIEW_ROUTE` comment after this command was invoked, the review was bypassed or never started.
155
114
 
156
115
  ```bash
116
+ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}" # this fence resolves PR_NUMBER itself, so it binds config directly instead of reloading the persisted pointer
117
+ GH_REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG_FILE" 2>/dev/null); GH_FLAG="-R $GH_REPO"
118
+ DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE" 2>/dev/null); STAGING_BRANCH=$(yq '.branches.staging' "$CONFIG_FILE" 2>/dev/null)
157
119
  # Resolve PR_NUMBER from the argument string (never name the loader placeholder in this block: every
158
120
  # occurrence is substituted textually, comments included) <!-- Added: forge#3466 -->
159
121
  # The argument string may be: a PR number, "staging", "feature", or "staging:feature"
@@ -212,6 +174,50 @@ if [ -n "$PR_NUMBER" ]; then printf '%s\n' "$PR_NUMBER" > "$_SD/staging_pr_numbe
212
174
 
213
175
  `$PR_NUMBER` is now set for all downstream phases that conditionally post gate comments to the PR. It is also persisted to the run-keyed pointer `$_SD/staging_pr_number.${FORGE_RUN_KEY}`, because every Bash call is a fresh shell and concurrent runs share `$_SD`. `FORGE_RUN_KEY` is a pre-exported `FORGE_RUN_KEY`, else `FORGE_RUN_ID` or the first available session id (Claude Code, Codex, OpenCode), else `$PPID` (best-effort, may not be stable across calls); `fence-bootstrap.sh` derives it identically and never falls back to an unkeyed pointer. Every fence that consumes cross-fence state (`PR_NUMBER`, `ALL_PR_NUMBERS`, `GH_REPO`, branch names) begins with the `# FENCE_BOOTSTRAP` stub, which sources `scripts/lib/fence-bootstrap.sh` (via `scripts/lib/forge-resolve.sh`) to re-derive it and exits 1 when the helper is unresolvable; `scripts/review-pr-staging-scope.test.sh` fails if a consuming fence omits it. The Phase 6.5 gate result and the Phase 6.75 launched-agent list are persisted to `$_SD` by their producers and reloaded (validated) by their consumers.
214
176
 
177
+ **Prior-finding awareness on re-entry**: On each re-invocation, load findings from prior review passes on the same PR before running any new checks. Previously-resolved findings (issues that are now closed or marked `false-positive`) are excluded from the current verdict to avoid re-surfacing already-fixed issues. Previously-unresolved findings (open issues from a prior pass) are carried forward as context so agents know what was previously flagged:
178
+
179
+ ```bash
180
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
181
+ . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
182
+ GH_FLAG="-R $GH_REPO"
183
+ # Load prior FORGE:REVIEW_ROUTE comments to detect re-entry
184
+ PRIOR_ROUTES=$(gh api repos/${GH_REPO}/issues/${PR_NUMBER}/comments \
185
+ --jq '[.[] | select(.body | test("FORGE:REVIEW_ROUTE")) | .body] | length' 2>/dev/null || echo 0)
186
+
187
+ IS_REENTRY=0
188
+ if [ "$PRIOR_ROUTES" -gt 0 ]; then
189
+ IS_REENTRY=1
190
+ echo "Re-entry detected: $PRIOR_ROUTES prior review pass(es) on PR #${PR_NUMBER}"
191
+
192
+ # Anchored post-filter: keep a search hit only if its title ends `PR #N)` or its body has a
193
+ # `**Source**: PR #N` line. Hidden markers (issue-create-token, etc.) never count as references.
194
+ FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
195
+
196
+ # Load findings from prior passes that are still open
197
+ PRIOR_OPEN_FINDINGS=$(gh issue list ${GH_FLAG} \
198
+ --label "review-finding" \
199
+ --state open \
200
+ --search "PR #${PR_NUMBER}" \
201
+ --limit 50 \
202
+ --json number,title,labels,body 2>/dev/null \
203
+ | jq -r --arg n "${PR_NUMBER}" "$FINDING_PR_FILTER" 2>/dev/null || true) # uses FINDING_PR_FILTER
204
+
205
+ # Load findings that were resolved (closed) since the last pass
206
+ PRIOR_RESOLVED_FINDINGS=$(gh issue list ${GH_FLAG} \
207
+ --label "review-finding" \
208
+ --state closed \
209
+ --search "PR #${PR_NUMBER}" \
210
+ --limit 50 \
211
+ --json number,title,body 2>/dev/null \
212
+ | jq -r --arg n "${PR_NUMBER}" "$FINDING_PR_FILTER" 2>/dev/null || true) # uses FINDING_PR_FILTER
213
+
214
+ echo "Prior open findings (carry forward): $(echo "$PRIOR_OPEN_FINDINGS" | grep -c '.' || echo 0)"
215
+ echo "Prior resolved findings (exclude from verdict): $(echo "$PRIOR_RESOLVED_FINDINGS" | grep -c '.' || echo 0)"
216
+ fi
217
+ ```
218
+
219
+ Agents receive `$IS_REENTRY`, `$PRIOR_OPEN_FINDINGS`, and `$PRIOR_RESOLVED_FINDINGS` as context. They MUST NOT re-file issues that already exist in `$PRIOR_OPEN_FINDINGS` (dedup gate in Phase 7 covers this). Resolved findings do NOT contribute to the BLOCK verdict for the current pass.
220
+
215
221
  ---
216
222
 
217
223
  ## Phase 0A: Open Review-Finding Gate (BLOCKING — runs before scope analysis)
@@ -221,6 +227,8 @@ if [ -n "$PR_NUMBER" ]; then printf '%s\n' "$PR_NUMBER" > "$_SD/staging_pr_numbe
221
227
  **Why this matters**: Review findings are filed before the originating PR merges to staging. Without this gate, a staging→main bundle can include commits with known unfixed bugs — the review system caught the issue, but the deploy path ignored it. This gate closes the gap between issue discovery and deploy execution. <!-- Added: forge#303 -->
222
228
 
223
229
  ```bash
230
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
231
+ . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
224
232
  git fetch origin $DEFAULT_BRANCH $STAGING_BRANCH
225
233
 
226
234
  # Step 1: Find all PR numbers in the staging→main bundle
@@ -381,6 +389,8 @@ If the gate exits with `RESULT: PASS` → a `<!-- FORGE:GATE_PASS -->` structure
381
389
  ## Phase 0B: Scope Analysis
382
390
 
383
391
  ```bash
392
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
393
+ . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
384
394
  git fetch origin $DEFAULT_BRANCH $STAGING_BRANCH
385
395
  git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH --stat | tail -20
386
396
  git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH --numstat | awk '{add+=$(1); del+=$(2)} END {print "Added:", add, "Deleted:", del, "Total:", add+del}'
@@ -512,7 +522,7 @@ fi
512
522
  # Deploy/runtime readiness files are always in scope: migrations, env, workflows, compose/infra, dependency manifests.
513
523
  _DW=$(git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH --name-only) || { SCOPE_MODE=full; _DW=""; }
514
524
  DEPLOY_WIRING_FILES=$(printf '%s\n' "$_DW" | grep -E '(^|/)(migrations?/|\.github/workflows/|infra/|deploy/|docker-compose[^/]*\.ya?ml$|\.env[^/]*$|Dockerfile[^/]*$|package(-lock)?\.json$|pnpm-lock\.yaml$|yarn\.lock$|requirements[^/]*\.txt$|pyproject\.toml$|poetry\.lock$|go\.(mod|sum)$|Cargo\.(toml|lock)$)' || true)
515
- # Persist the results for 7B.5 (a later Bash call is a fresh shell). %q keeps multi-line file lists intact.
525
+ # Persist the results for 7A (a later Bash call is a fresh shell). %q keeps multi-line file lists intact.
516
526
  printf 'SCOPE_MODE=%q\nCROSS_PR_FILES=%q\nUNREVIEWED_FILES=%q\nDEPLOY_WIRING_FILES=%q\n' "$SCOPE_MODE" "${CROSS_PR_FILES:-}" "${UNREVIEWED_FILES:-}" "${DEPLOY_WIRING_FILES:-}" > "$_SD/${PR_NUMBER}_scope.env"
517
527
  echo "SCOPE_MODE=$SCOPE_MODE reviewed_prs=$REVIEWED_PR_COUNT unreviewed_commits=$(echo $UNREVIEWED_COMMITS | wc -w) unreviewed_files=$(echo "$UNREVIEWED_FILES" | grep -c .) cross_pr_files=$(echo "$CROSS_PR_FILES" | grep -c .)"
518
528
  ```
@@ -525,6 +535,30 @@ echo "SCOPE_MODE=$SCOPE_MODE reviewed_prs=$REVIEWED_PR_COUNT unreviewed_commits=
525
535
 
526
536
  ---
527
537
 
538
+ ### 0C: Prepare (one script call)
539
+
540
+ `scripts/review-prepare.sh` is the same preparation `/review-pr` Phase 1 runs, called with `--no-delta` (a bundle review always reviews the whole bundle: no FULL/DELTA resolver). It resolves the persona template source, the per-domain diff slices (`slices.<domain>.diff_file`, read from `origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH` when the API diff is too large), project config and the head SHA into one context file that Phase 5 and the Phase 7 `review-finalize.sh` calls read. A non-zero exit is `BLOCKED` (for example no template source or no readable trusted forge.yaml): STOP, never skip. An unconfigured `typescript.build` / `python.format` is reported as a SKIPPED Phase 2 finding (`--build-gate advisory`), as the deploy review always did.
541
+
542
+ ```bash
543
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
544
+ . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
545
+ [ -n "${PR_NUMBER:-}" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: PR_NUMBER unavailable after reload; nothing reviewed"; exit 1; }
546
+ RP=$(forge_resolve review-prepare.sh) # install roots only, never the PR checkout; unresolvable is a stop (fail closed)
547
+ [ -n "$RP" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/review-prepare.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install); refusing to continue (fail closed)"; exit 1; }
548
+ CTX="$_SD/review-ctx-${PR_NUMBER}.json"
549
+ bash "$RP" --pr "$PR_NUMBER" --repo "$GH_REPO" --no-delta --build-gate advisory --out "$CTX"; RP_RC=$? # an unconfigured build is reported SKIPPED, never a deploy block
550
+ if [ "$RP_RC" -ne 0 ] || [ ! -s "$CTX" ]; then
551
+ [ -s "$CTX" ] && jq -r '.blockers[]? | "BLOCKER: " + .' "$CTX"
552
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: review-prepare failed (exit ${RP_RC}); nothing reviewed (fail closed)"; exit 1
553
+ fi
554
+ echo "CONTEXT_FILE=$CTX"
555
+ jq -r '"TEMPLATE_SOURCE=\(.templates.source)", "TEMPLATE_BASE=\(.templates.base)", "MONOLITHIC_CATALOG=\(.templates.monolithic_catalog // "")"' "$CTX"
556
+ ```
557
+
558
+ The context file lives at `$_SD/review-ctx-${PR_NUMBER}.json`; later fences recompute that path after their bootstrap line.
559
+
560
+ ---
561
+
528
562
  ## Phase 1: Automated Checks
529
563
 
530
564
  ### 1A: Python Linting
@@ -708,141 +742,15 @@ Reviewed-SHA: [REVIEW_SHA]
708
742
 
709
743
  ## Phase 5: Security & Billing Deep Dive
710
744
 
711
- **MANDATORY TEMPLATE-RESOLUTION GUARD — run BEFORE reading the agent catalog:**
712
-
713
- Missing persona templates are a fatal setup error, not permission to skip multi-agent review. Resolve the template source through this ordered chain (identical to the one used by `/review-pr` Phase 3C — do not diverge) and STOP if none resolve — never fall through to reviewing inline in the main context:
714
-
715
- ```bash
716
- TEMPLATE_BASE=""
717
- # Tier 0: the running plugin's own root (Claude Code substitutes this exact spelling; elsewhere it stays a
718
- # literal that the /* check rejects). Wins over an exported FORGE_HOME, which may name an older checkout.
719
- _PLUGIN_ROOT='${CLAUDE_PLUGIN_ROOT}'; case "$_PLUGIN_ROOT" in /*) ;; *) _PLUGIN_ROOT="" ;; esac
720
- if [[ -n "$_PLUGIN_ROOT" && -f "$_PLUGIN_ROOT/commands/review-pr-agents/protocols.md" ]]; then
721
- TEMPLATE_BASE="$_PLUGIN_ROOT/commands/review-pr-agents"
722
- TEMPLATE_SOURCE="plugin_root"
723
- elif [[ -f "$FORGE_HOME/commands/review-pr-agents/protocols.md" ]]; then
724
- TEMPLATE_BASE="$FORGE_HOME/commands/review-pr-agents"
725
- TEMPLATE_SOURCE="forge_home"
726
- else
727
- FORGE_YAML="${FORGE_CONFIG:-$(git rev-parse --show-toplevel 2>/dev/null)/forge.yaml}"
728
- REPO_PATH=$(yq '.paths.root' "$FORGE_YAML" 2>/dev/null || git rev-parse --show-toplevel 2>/dev/null || pwd)
729
- if [[ -f "$REPO_PATH/commands/review-pr-agents/protocols.md" ]]; then
730
- TEMPLATE_BASE="$REPO_PATH/commands/review-pr-agents"
731
- TEMPLATE_SOURCE="repo_path"
732
- elif [[ -f "$REPO_PATH/commands/review-pr-agents.md" ]] && grep -q "^### Agent:" "$REPO_PATH/commands/review-pr-agents.md" 2>/dev/null; then
733
- # Content check (not just existence) required: a post-split repo still ships a small
734
- # router stub at this same path that only points back to the (missing) persona
735
- # directory — reading it would provide no actual protocol/persona content.
736
- MONOLITHIC_CATALOG="$REPO_PATH/commands/review-pr-agents.md"
737
- TEMPLATE_SOURCE="monolithic_catalog"
738
- else
739
- TEMPLATE_SOURCE="none"
740
- fi
741
- fi
742
-
743
- if [[ "$TEMPLATE_SOURCE" == "none" ]]; then
744
- echo "FATAL: no review-pr-agents template source resolved (checked \$FORGE_HOME, repo-path fallback, monolithic catalog)."
745
- # HARD STOP — post error, add needs-human, do NOT review
746
- fi
747
- ```
748
-
749
- **If `TEMPLATE_SOURCE` is `none`**: HARD STOP. Post a PR comment explaining the setup is broken, instructing the user to run `npx forgedock update` to repair the install, add `needs-human`, and exit without posting any findings or a verdict. **NEVER perform the review inline in the main agent context as a substitute.**
750
-
751
- **If `TEMPLATE_SOURCE` is `plugin_root`, `forge_home` or `repo_path`** (normal cases — behavior unchanged): `Read: $TEMPLATE_BASE/protocols.md` and `Read: $TEMPLATE_BASE/<persona>.md` per selected agent.
752
-
753
- **If `TEMPLATE_SOURCE` is `monolithic_catalog`** (last resort): `Read: $MONOLITHIC_CATALOG` and extract the shared protocols section plus each selected persona's section from within that single file.
745
+ **Templates (resolved by `review-prepare.sh` in 0C, the same ordered chain `/review-pr` Phase 3C uses)**: `TEMPLATE_SOURCE` is `plugin_root`, `forge_home`, `repo_path` or `monolithic_catalog`; a missing source BLOCKED the review in 0C. **NEVER perform the review inline as a substitute.** Read `$TEMPLATE_BASE/protocols.md` and `$TEMPLATE_BASE/<persona>.md` per selected agent; for `monolithic_catalog` read `$MONOLITHIC_CATALOG` and extract the shared protocols plus each persona's section from that one file.
754
746
 
755
747
  Launch domain-specific agents based on which domains have changes. Substitute PR diff commands with staging diff commands.
756
748
 
757
749
  **Risk-tiered domain panel** <!-- Added: forge#3599 -->: choose the domain agents with `scripts/review-roster.sh` (resolve it with `forge_resolve review-roster.sh`, never from `$PWD`) instead of launching every domain with a change. Every PR in the bundle already had its own review, so this panel only needs to catch cross-PR interactions. Pass the bundle's changed lines (`git diff --numstat "origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH"`, added plus deleted), changed-file count and file list (`--files-file -`), optional `--scores` diff-text signals (omit for none), and `--fix-pr false`; a bundle of 60 lines or fewer runs Security only unless a path trigger fires, a larger one follows the medium and standard tiers, and Auth and Infra run only when auth/session/trust-filter or CI/docker/deploy files are in the bundle. Run the full union when `--thorough` was passed. When the script cannot be resolved or fails, launch the full union (fail toward more agents). Record the tier and reason in the final summary.
758
750
 
759
- **Per-domain diff slices (MANDATORY)**: `protocols.md` tells agents to use a pre-supplied `[DOMAIN_DIFF_SLICE]` and not to re-fetch the diff, so staging MUST supply one — the staging→main diff is the largest diff in the pipeline. Slice the uncapped diff first, then cap each slice at ~100K chars with `truncate_slice` (same order as `commands/review-pr.md` "Domain Diff Slicing"). Compute the slices once, then substitute `[DOMAIN_DIFF_SLICE]` in each persona's prompt before dispatch:
760
- ```bash
761
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
762
- . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
763
- # Fetched once, UNCAPPED; agents do NOT re-fetch it. Never cap here: slicing must see the whole bundle.
764
- FULL_DIFF=$(git diff "origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH")
765
-
766
- # --- Security agent: always receives the full diff (cross-cutting domain) ---
767
- DIFF_SLICE_SECURITY="$FULL_DIFF"
768
-
769
- # --- Auth agent: auth, session, jwt, oauth, middleware, permission files ---
770
- DIFF_SLICE_AUTH=$(echo "$FULL_DIFF" | awk '
771
- /^diff --git/ { in_block=0 }
772
- /^diff --git.*\/(auth|session|jwt|oauth|permission|middleware|login|token)/ { in_block=1 }
773
- in_block { print }
774
- ')
775
- [ -z "$DIFF_SLICE_AUTH" ] && DIFF_SLICE_AUTH="$FULL_DIFF"
776
-
777
- # --- Billing agent: billing, payment, credit, pricing, stripe, subscription files ---
778
- DIFF_SLICE_BILLING=$(echo "$FULL_DIFF" | awk '
779
- /^diff --git/ { in_block=0 }
780
- /^diff --git.*\/(billing|payment|credit|pricing|stripe|subscription|invoice|charge|refund)/ { in_block=1 }
781
- in_block { print }
782
- ')
783
- [ -z "$DIFF_SLICE_BILLING" ] && DIFF_SLICE_BILLING="$FULL_DIFF"
784
-
785
- # --- Concurrency agent: transaction, lock, queue, async, worker, race-condition files ---
786
- DIFF_SLICE_CONCURRENCY=$(echo "$FULL_DIFF" | awk '
787
- /^diff --git/ { in_block=0 }
788
- /^diff --git.*\/(worker|queue|task|async|lock|transaction|pipeline|job)/ { in_block=1 }
789
- in_block { print }
790
- ')
791
- [ -z "$DIFF_SLICE_CONCURRENCY" ] && DIFF_SLICE_CONCURRENCY="$FULL_DIFF"
792
-
793
- # --- Database agent: migration, model, schema, ORM, SQL files ---
794
- DIFF_SLICE_DATABASE=$(echo "$FULL_DIFF" | awk '
795
- /^diff --git/ { in_block=0 }
796
- /^diff --git.*\/(migration|model|schema|\.sql$|orm|database|db)/ { in_block=1 }
797
- in_block { print }
798
- ')
799
- [ -z "$DIFF_SLICE_DATABASE" ] && DIFF_SLICE_DATABASE="$FULL_DIFF"
800
-
801
- # --- API Design agent: router, endpoint, openapi, schema, serializer files ---
802
- DIFF_SLICE_API=$(echo "$FULL_DIFF" | awk '
803
- /^diff --git/ { in_block=0 }
804
- /^diff --git.*\/(router|route|endpoint|openapi|serializer|schema|api)/ { in_block=1 }
805
- in_block { print }
806
- ')
807
- [ -z "$DIFF_SLICE_API" ] && DIFF_SLICE_API="$FULL_DIFF"
808
-
809
- # --- Infrastructure agent: docker, nginx, ci, deploy, infra, workflow files ---
810
- DIFF_SLICE_INFRA=$(echo "$FULL_DIFF" | awk '
811
- /^diff --git/ { in_block=0 }
812
- /^diff --git.*\/(docker|nginx|infra|\.github|deploy|ci|traefik|k8s|helm)/ { in_block=1 }
813
- in_block { print }
814
- ')
815
- [ -z "$DIFF_SLICE_INFRA" ] && DIFF_SLICE_INFRA="$FULL_DIFF"
816
-
817
- # --- Domain Logic agent: scraping/browser-automation files (does not match bare playwright/E2E test files) ---
818
- DIFF_SLICE_SCRAPER=$(echo "$FULL_DIFF" | awk '
819
- /^diff --git/ { in_block=0 }
820
- /^diff --git.*\/(scrape|scraper|stealth|browser_pool|headless|anti_bot|captcha|crawl)/ { in_block=1 }
821
- in_block { print }
822
- ')
823
- [ -z "$DIFF_SLICE_SCRAPER" ] && DIFF_SLICE_SCRAPER="$FULL_DIFF"
824
-
825
- # Cap each slice AFTER slicing and after the empty-slice fallback (a fallback slice is the truncated full diff)
826
- truncate_slice() {
827
- local slice="${1}"
828
- local limit=102400
829
- if [ "${#slice}" -gt "$limit" ]; then
830
- echo "${slice:0:$limit}"
831
- echo "... [TRUNCATED — diff exceeded 100K chars; focus on the files listed above]"
832
- else
833
- echo "$slice"
834
- fi
835
- }
836
- DIFF_SLICE_SECURITY=$(truncate_slice "$DIFF_SLICE_SECURITY")
837
- DIFF_SLICE_AUTH=$(truncate_slice "$DIFF_SLICE_AUTH")
838
- DIFF_SLICE_BILLING=$(truncate_slice "$DIFF_SLICE_BILLING")
839
- DIFF_SLICE_CONCURRENCY=$(truncate_slice "$DIFF_SLICE_CONCURRENCY")
840
- DIFF_SLICE_DATABASE=$(truncate_slice "$DIFF_SLICE_DATABASE")
841
- DIFF_SLICE_API=$(truncate_slice "$DIFF_SLICE_API")
842
- DIFF_SLICE_INFRA=$(truncate_slice "$DIFF_SLICE_INFRA")
843
- DIFF_SLICE_SCRAPER=$(truncate_slice "$DIFF_SLICE_SCRAPER")
844
- ```
845
- For each dispatched persona, substitute `[DOMAIN_DIFF_SLICE]` → its matching `$DIFF_SLICE_*` variable (an empty slice falls back to the full diff, which is then truncated like every other slice). Also substitute `[AGENT_DOMAIN]` → the persona's marker domain (the `{domain}` used in its `FORGE:REVIEW-AGENT` marker), and bind `[PR_NUMBER]` to the staging review's PR number (`staging` when there is none) and `[REVIEW_SHA_SHORT]` to the literal Phase -1 `REVIEW_SHA_SHORT` value (hex, or `unresolved`; never `REVIEW_SHA_STAGING`, whose `n/a` sentinel would put a `/` in the path): personas derive their slice path `forge-slice-[PR_NUMBER]-[REVIEW_SHA_SHORT]-[AGENT_DOMAIN].diff` from them. Also substitute `[REVIEW_SHA]` → the literal full head SHA (40 or 64 hex, the orchestrator's Phase -1 `REVIEW_SHA_FULL` value) into the dispatch prompt: a reviewer runs its post fence in a fresh shell, so it MUST NOT reference `${REVIEW_SHA_FULL}` and MUST NOT invent a value; when no full SHA resolved (no PR), omit the `Reviewed-SHA:` line value rather than fabricate one (Phase 7 consumers then fail closed). Agents never run a full `gh pr diff`; only `--name-only` is allowed. Agents: General Security (always), Auth, Billing, Concurrency, Scraper, API Design, Database, Infrastructure.
751
+ **Per-domain diff slices (MANDATORY)**: `protocols.md` tells agents to use a pre-supplied `[DOMAIN_DIFF_SLICE]` and not to re-fetch the diff, so staging MUST supply one (the staging→main diff is the largest in the pipeline). `review-prepare.sh` (0C) already sliced the uncapped bundle diff by domain and capped each slice at 100K chars: read `slices.<domain>.diff_file` from the context file (`jq -r '.slices["<domain>"].diff_file' "$_SD/review-ctx-${PR_NUMBER}.json"`; `security` and `spec-cli` get the full diff, a domain with no pattern hit the full diff) with the canonical key (`api-design`→`api`, `infrastructure`→`infra`, the others unchanged).
752
+
753
+ For each dispatched persona, substitute `[DOMAIN_DIFF_SLICE]` → the content of its `slices.<domain>.diff_file`. Also substitute `[AGENT_DOMAIN]` → the persona's marker domain (the `{domain}` used in its `FORGE:REVIEW-AGENT` marker), and bind `[PR_NUMBER]` to the staging review's PR number (`staging` when there is none) and `[REVIEW_SHA_SHORT]` to the literal Phase -1 `REVIEW_SHA_SHORT` value (hex, or `unresolved`; never `REVIEW_SHA_STAGING`, whose `n/a` sentinel would put a `/` in the path): personas derive their slice path `forge-slice-[PR_NUMBER]-[REVIEW_SHA_SHORT]-[AGENT_DOMAIN].diff` from them. Also substitute `[REVIEW_SHA]` → the literal full head SHA (40 or 64 hex, the orchestrator's Phase -1 `REVIEW_SHA_FULL` value) into the dispatch prompt: a reviewer runs its post fence in a fresh shell, so it MUST NOT reference `${REVIEW_SHA_FULL}` and MUST NOT invent a value; when no full SHA resolved (no PR), omit the `Reviewed-SHA:` line value rather than fabricate one (Phase 7 consumers then fail closed). Agents never run a full `gh pr diff`; only `--name-only` is allowed. Agents: General Security (always), Auth, Billing, Concurrency, Scraper, API Design, Database, Infrastructure.
846
754
 
847
755
  **MANDATORY — each domain agent MUST persist its finalized body before posting its findings directly to the PR immediately upon completion** (not batched by the orchestrator). It MUST return verdict, finding count, and one line per finding to the orchestrator independently of delivery; if posting fails, return the durable file path and stop without retrying:
848
756
  ```bash
@@ -1153,321 +1061,74 @@ fi
1153
1061
 
1154
1062
  An empty or unreadable launched-agent list is also a delivery failure (the fence exits 1), never evidence that nothing needed verifying; recover the list from the PR comments and rerun. Missing comments are an explicit failure, never evidence of no findings. Do not triage or synthesize a deploy verdict while any launched agent is unaccounted for.
1155
1063
 
1156
- ### 7A: Extract Findings
1157
- Only findings inside trusted, current-head reviewer comments count: the head SHA is public, so an untrusted commenter must never be able to inject `FINDING` markers that get filed as issues. Read every page of comments, fail closed when the trust script or comments are unreadable (never fall through to "no findings").
1064
+ ### 7A: Extract, dedupe and gate (with the note-disposition dry run)
1158
1065
 
1159
- ```bash
1160
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1161
- . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
1162
- # Each Bash call is a fresh shell: re-resolve the full head SHA if it is not set, and refuse when it is not a commit id
1163
- # (an empty SHA would make the Reviewed-SHA match accept any head).
1164
- [ -n "${REVIEW_SHA_FULL:-}" ] || REVIEW_SHA_FULL=$(gh pr view "$PR_NUMBER" -R "$GH_REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
1165
- case "$REVIEW_SHA_FULL" in *[!0-9a-f]*|"") REVIEW_SHA_FULL="" ;; *) [ "${#REVIEW_SHA_FULL}" -eq 40 ] || [ "${#REVIEW_SHA_FULL}" -eq 64 ] || REVIEW_SHA_FULL="" ;; esac
1166
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1167
- TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
1168
- [ -n "$REVIEW_SHA_FULL" ] || { echo "STAGING_REVIEW: BLOCKED - head SHA is not a commit id; refusing to extract findings (fail closed)"; exit 1; }
1169
- COMMENTS_JSON=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
1170
- if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then
1171
- echo "STAGING_REVIEW: BLOCKED - scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to extract findings (fail closed)"; exit 1
1172
- fi
1173
- AGENT_RE="^<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA:[ ]${REVIEW_SHA_FULL}(\\r?\\n|$)"
1174
- AGENT_BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$AGENT_RE" 2>/dev/null) \
1175
- || { echo "STAGING_REVIEW: BLOCKED - trusted reviewer comments unreadable (fail closed)"; exit 1; }
1176
- ALL_FINDINGS=$(printf '%s\n' "$AGENT_BODIES" | jq -r 'scan("<!-- FINDING:([^>]+) -->") | .[0]' 2>/dev/null)
1177
- ```
1178
-
1179
- Extract structured findings (`<!-- FINDING:... -->`) from `ALL_FINDINGS`. If none found, scan the trusted bodies for unstructured findings. If still 0 → skip to Phase 8.
1180
-
1181
- ### 7B: Filter & Deduplicate
1182
- Keep ALL findings (CONFIRMED/LIKELY/POSSIBLE) for the 7B.6 provenance gates and then classification (7B.5). Deduplicate within this run by file + pattern + location: same file, location within +-5 lines, and 3+ shared title keywords (a shared explicit finding id is an additional match, never a substitute for the keyword test) are one finding (keep higher confidence). When `SCOPE_MODE=scoped`, a finding is `out_of_scope` only when it cites at least one file and EVERY cited file is outside `CROSS_PR_FILES`, `UNREVIEWED_FILES` and `DEPLOY_WIRING_FILES` (0B.1; a finding with any in-scope file, such as a cross-file interaction, stays in scope), and the filter may only DEMOTE it, never drop it: only a finding whose normalised severity is `MEDIUM`, `LOW` or `INFO` becomes a listed note. `CRITICAL` and `HIGH` (any confidence), and any missing or unrecognised severity, are always kept and go to 7B.5 as usual, so scope can never remove a blocking finding. Every demoted finding is listed (id, file:line, reason `out_of_scope`) in the 7B.5 disposition; none is silently dropped. Findings with no file (readiness-checklist items such as undocumented env vars or a migration-number collision across PRs) are never demoted. The executable rule is the two `SCOPE_RULE` lines inside `classify_one` in 7B.5 (`scripts/review-pr-staging-scope.test.sh` evaluates them).
1183
-
1184
- Sort: CONFIRMED first, then by severity.
1185
-
1186
- ### 7B.6: Provenance and concreteness gates (forge#3452)
1187
-
1188
- Before filing, apply the three gates defined in `commands/review-pr.md` §6B.4 (head-SHA, introduced-line, failure scenario), in that order, to every finding — do not duplicate the algorithm here. Use the staging diff (`git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH`, or the reviewed PR's diff) as the introduced-line source and the full 40-hex head SHA (`git rev-parse origin/$STAGING_BRANCH`, never an abbreviated one) as the reviewed SHA. Stale findings are dropped (`findings_dropped_stale`), findings outside the diff are `pre-existing` (`findings_preexisting`: listed in the summary, MEDIUM or lower only, since CRITICAL/HIGH, any confidence, are never routed as pre-existing and a file with no `patch` in the diff API is UNKNOWN and kept; filed only when safety-domain with the `pre-existing` label and never `review-finding`, so they are excluded from findings-per-PR and the amplification ratio), and findings without a `**Failure scenario**` are demoted to notes (`notes_demoted_no_scenario`) unless CRITICAL/HIGH, any confidence. Unreadable inputs keep the finding. Report the three counts in the staging review summary using the same names, and record every demoted or listed note in a `FORGE:NOTE_DISPOSITION` record in the same format as `commands/review-pr.md` §6B.5.
1189
-
1190
- ### 7B.5: Note Disposition (same damper and classifier as `/review-pr` §6B.5)
1191
-
1192
- Classify every deduped finding with `scripts/classify-finding.sh` before filing, exactly as `commands/review-pr.md` §6B.5 does (the rules live in the script, not here: HIGH/CRITICAL always ISSUE, content-based safety exemption where LOW needs a keyword AND CONFIRMED and a domain agent rescues MEDIUM only, otherwise LOW/POSSIBLE become NOTEs). Resolve it with the same `forge_resolve` stub. There is no `$PWD` tier: the repo under review is author-controlled. The staging PR is not a finding fix, so `FINDING_LINEAGE=none`.
1193
-
1194
- **Cross-PR re-flag** <!-- Added: forge#3641 -->: the 7B.6 gates run first, so re-flag counts cover only findings that survived them. A finding that a bundle member PR's review already posted, or that a member PR's `FORGE:NOTE_DISPOSITION` list recorded, is not filed or noted again. The fence builds a prior-findings TSV (file, line-start, line-end, title, prior-disposition, issue#) from each member PR in `ALL_PR_NUMBERS`, reading every comment through `scripts/trusted-comments.sh` (never an untrusted author), and passes it to the classifier as `--prior-findings`. A finding re-flags a row when it is the same file AND (line IoU >= 0.6 AND >= 1 shared non-stopword title token, OR >= 3 shared title tokens of which >= 2 are non-stopword; generic stopwords never count as topical similarity); the classifier answers `NOTE reflag-filed #N` (the prior issue is still open) or `NOTE reflag-note`, never a new ISSUE. HIGH/CRITICAL are never demoted (they keep blocking via §7B), and a row whose linked issue is closed is ignored so a regressed defect is classified normally. Reviewer-body rows whose issue linkage fails are passed as `unknown` (recorded NOTE_DISPOSITION rows as `note`); an `unknown` row never demotes a MEDIUM CONFIRMED/LIKELY re-flag. Re-flag NOTEs are listed with the other NOTEs and counted in `notes_reflag` only. An unreadable member PR only shrinks the TSV; it never blocks.
1066
+ `scripts/review-finalize.sh` is the script `/review-pr` Phase 6 runs; staging adds `--scope-files` (the 0B.1 scoped review set) and `--prior-prs` (the bundle member PRs). It reads reviewer comments only through `scripts/trusted-comments.sh` (trusted author, body starting with the marker, the current-head `Reviewed-SHA` line), fails closed on an unreadable source or a marker extraction mismatch (a marker the parser cannot read never makes the review look clean), dedupes by file + line range ±5 and by title, and applies the `commands/review-pr.md` §6B.4 provenance and concreteness gates (head SHA, introduced-line against the PR diff, failure scenario): a stale finding is dropped, a MEDIUM-or-lower finding outside the diff is `pre-existing`, and **CRITICAL/HIGH are never routed as pre-existing**, whatever their confidence. **Exit 3 means `REVIEW_RESULT: status: BLOCKED` was printed** (unresolvable classifier or cap helper, unreadable comments, extraction mismatch, no trusted reviewer comment for the head): STOP, file nothing, emit no deploy approval and report `RESULT: BLOCK DEPLOY` with the blocker (`exit` ends only that Bash block).
1195
1067
 
1196
1068
  ```bash
1197
1069
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1198
- CLASSIFY_SCRIPT=$(forge_resolve classify-finding.sh)
1199
- # Fail closed (#3639): no hand classification and no ISSUE fallback. Kept OUTSIDE classify_one (the scope test sources that function with a stubbed CLASSIFY_SCRIPT).
1200
- if [ -z "$CLASSIFY_SCRIPT" ]; then
1201
- echo "REVIEW_RESULT: status: BLOCKED, blocker: classifier unavailable (classify-finding.sh not resolvable through the trusted resolver)"
1202
- exit 0
1203
- fi
1204
- # Per-review cap (#3642): same resolver, same fail-closed rule.
1205
- CAP_SCRIPT=$(forge_resolve cap-findings.sh)
1206
- if [ -z "$CAP_SCRIPT" ]; then
1207
- echo "REVIEW_RESULT: status: BLOCKED, blocker: cap helper unavailable (cap-findings.sh not resolvable through the trusted resolver)"
1208
- exit 0
1209
- fi
1210
- # review.max_filed_findings (optional; helper default 2, falls back on invalid)
1211
- MAX_FILED_FINDINGS=$(yq '.review.max_filed_findings // ""' forge.yaml 2>/dev/null || echo '')
1212
- # Per-finding inputs come from each structured block: FINDING_ID, FINDING_SEVERITY, FINDING_CONFIDENCE, FINDING_AGENT, FINDING_TITLE, FINDING_BODY,
1213
- # FINDING_FILE (primary) and FINDING_PATHS (newline-separated, every cited file). Run THIS WHOLE BLOCK in ONE Bash call, looping over every deduped finding
1214
- # (init once, then classify_one per finding, then the disposition record), because counters and the note list live in shell state and a fresh shell loses them.
1215
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1216
1070
  . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
1217
- # An empty PR_NUMBER would key every scratch file as "_notes.md" / "_scope.env" and the disposition record could not be posted: stop and report it.
1218
- [ -n "${PR_NUMBER:-}" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: PR_NUMBER unavailable (Phase -1 did not persist the run-keyed staging_pr_number pointer); FORGE:NOTE_DISPOSITION cannot be recorded"; exit 0; }
1219
- NOTE_LIST_FILE="$_SD/${PR_NUMBER}_notes.md"; : > "$NOTE_LIST_FILE"
1220
- NOTES_LISTED=0; NOTES_DROPPED=0; FINDINGS_FILED=0; OUT_OF_SCOPE=0; NOTES_CAPPED=0
1221
- CAP_ROWS_FILE="$_SD/${PR_NUMBER}_cap-rows.tsv"; : > "$CAP_ROWS_FILE" # ISSUE rows awaiting the cap
1222
- CAPPED_IDS_FILE="$_SD/${PR_NUMBER}_capped-ids.txt"; : > "$CAPPED_IDS_FILE" # capped ids: 7E/7F skip them
1071
+ CTX="$_SD/review-ctx-${PR_NUMBER}.json"; [ -s "$CTX" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: review context $CTX missing (0C did not run); nothing triaged (fail closed)"; exit 1; } # STOP
1072
+ RF=$(forge_resolve review-finalize.sh) # install roots only, never the PR checkout; unresolvable is a stop (fail closed), never a skipped gate
1073
+ [ -n "$RF" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/review-finalize.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install); refusing to continue (fail closed)"; exit 1; } # STOP
1074
+ OUT_DIR="$_SD/review-finalize-${PR_NUMBER}"
1223
1075
  # 0B.1 results were written to a scope file by the Phase 0B.1 block; a missing or unreadable file means SCOPE_MODE=full (no demotion, fail open), announced rather than silent.
1224
1076
  SCOPE_MODE=full; CROSS_PR_FILES=""; UNREVIEWED_FILES=""; DEPLOY_WIRING_FILES=""
1225
1077
  if [ -r "$_SD/${PR_NUMBER}_scope.env" ]; then . "$_SD/${PR_NUMBER}_scope.env"; else echo "WARNING: $_SD/${PR_NUMBER}_scope.env unreadable: SCOPE_MODE=full (no out-of-scope demotion)"; fi
1226
- # Cross-PR re-flag input (forge#3641): prior findings and NOTE lists of every bundle member PR, trusted authors only. Unresolvable script or unreadable PRs leave PRIOR_FILE empty (no demotion).
1227
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1228
- TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
1229
- REPO="$GH_REPO"; _pscr="$_SD"; NOTES_REFLAG=0; PRIOR_FILE=""
1230
- # Cross-head re-flag input (#3641). Fail soft: every unreadable source just leaves the TSV smaller, never blocks the review.
1231
- # PF_SKIP="<sha or empty>" PF_PRS="<pr numbers>" prior_findings_tsv prints file<TAB>line-start<TAB>line-end<TAB>title<TAB>disposition<TAB>issue# rows
1232
- # (disposition: filed = linked issue OPEN, filed-closed = linked issue CLOSED, note = a recorded NOTE_DISPOSITION with no linked issue, unknown = a reviewer-body finding whose issue linkage failed, so the classifier must not let it demote a MEDIUM CONFIRMED/LIKELY re-flag). Every comment is read through
1233
- # scripts/trusted-comments.sh, so an untrusted commenter cannot pre-post a finding to suppress a real one. With PF_SKIP set, reviewer
1234
- # bodies for that head are skipped and only NOTE_DISPOSITION records carrying a different `reviewed_sha=` count (this head's own record,
1235
- # from an earlier run of this same review, must never swallow its findings).
1236
- pf_split_loc() { # PF_LOC="file[:N[-M]]" -> file<TAB>N<TAB>M (`-` sentinel when a line column is absent: a tab IFS read collapses empty fields)
1237
- local _loc="$PF_LOC" _file _r _a="" _b=""; _file="$_loc"
1238
- case "$_loc" in *:[0-9]*) _r="${_loc##*:}"; case "$_r" in *[!0-9-]*|"") ;; *) _file="${_loc%:*}"; _a="${_r%%-*}"; _b="${_r#*-}"; [ "$_b" != "$_r" ] || _b="$_a" ;; esac ;; esac
1239
- printf '%s\t%s\t%s\n' "$_file" "${_a:--}" "${_b:--}"
1240
- }
1241
- prior_findings_tsv() {
1242
- local pr cj rows issues _id _c _s _loc _t _p _hit _f _a _b _src _dflt
1243
- [ -n "${TRUSTED_SCRIPT:-}" ] || return 0
1244
- rows=$(mktemp "${_pscr}/prior-rows.XXXXXX"); issues=$(mktemp "${_pscr}/prior-issues.XXXXXX")
1245
- for pr in $PF_PRS; do
1246
- case "$pr" in ''|*[!0-9]*) continue ;; esac
1247
- cj=$(gh api --paginate "repos/${REPO}/issues/${pr}/comments" 2>/dev/null) || continue
1248
- [ -n "$cj" ] || continue
1249
- printf '%s' "$cj" | bash "$TRUSTED_SCRIPT" bodies '^<!-- (FORGE:REVIEW-AGENT:[a-z-]+|REVIEW-FINDINGS-SYNTHESIZED-START) -->[\s\S]*(^|\n)Reviewed-SHA: [0-9a-f]{40,64}' 2>/dev/null \
1250
- | jq -r --arg skip "${PF_SKIP:-}" 'select($skip == "" or (test("(^|\\n)Reviewed-SHA: " + $skip + "\\r?(\\n|$)") | not)) | scan("<!-- FINDING:([^>]+) -->") | .[0]' 2>/dev/null \
1251
- | while IFS='|' read -r _id _c _s _loc _t; do
1252
- [ -n "$_t" ] || continue
1253
- _p=$(PF_LOC="$_loc" pf_split_loc); printf 'F\t%s\t%s\n' "$_p" "$(printf '%s' "$_t" | tr '\t' ' ')"
1254
- done >> "$rows"
1255
- printf '%s' "$cj" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:NOTE_DISPOSITION' 2>/dev/null \
1256
- | jq -r --arg skip "${PF_SKIP:-}" 'select($skip == "" or ((capture("^<!-- FORGE:NOTE_DISPOSITION:[^>]*reviewed_sha=(?<s>[0-9a-f]{40,64})") | .s) as $s | $s != $skip)) | split("\n")[] | select(startswith("- "))' 2>/dev/null \
1257
- | sed -n 's/^- [^ ]* \([^ ]*\) \[[A-Za-z?]*\] \(.*\): [^:]*$/\1\t\2/p' \
1258
- | while IFS="$(printf '\t')" read -r _loc _t; do
1259
- [ "$_loc" != "?" ] || continue
1260
- _p=$(PF_LOC="$_loc" pf_split_loc); printf 'N\t%s\t%s\n' "$_p" "$_t"
1261
- done >> "$rows"
1262
- gh issue list -R "$REPO" --label review-finding --state all --search "${pr} in:body" --limit 100 --json number,state,title,body 2>/dev/null \
1263
- | jq -c '.[]' >> "$issues" 2>/dev/null || true
1264
- done
1265
- # Linkage: a filed issue whose body names the file and whose title shares >= 3 tokens with the finding (open preferred).
1266
- # Rows carry a leading source column (F = reviewer FINDING marker, N = NOTE_DISPOSITION record), stripped on output. An unlinked F row may
1267
- # have been filed under a reworded title, so it is `unknown` (never demotes MEDIUM CONFIRMED/LIKELY); an unlinked N row is a true `note`.
1268
- sort -u "$rows" | while IFS="$(printf '\t')" read -r _src _f _a _b _t; do
1269
- [ -n "$_f" ] || continue
1270
- [ "$_a" != "-" ] || _a=""; [ "$_b" != "-" ] || _b=""
1271
- case "$_src" in N) _dflt=note ;; *) _dflt=unknown ;; esac
1272
- _hit=$(jq -rs --arg f "$_f" --arg t "$_t" 'def toks: ascii_downcase | [scan("[a-z0-9]{3,}")] | unique;
1273
- ($t | toks) as $a | [ .[] | select((.body // "") | contains($f))
1274
- | select((((.title // "") | toks) as $b | [$a[] | select(. as $x | $b | index($x))] | length) >= 3)
1275
- | {n: .number, s: (.state | ascii_downcase)} ]
1276
- | (map(select(.s == "open")) + map(select(.s != "open"))) | .[0] // empty | "\(.s)\t\(.n)"' "$issues" 2>/dev/null) || _hit=""
1277
- case "$_hit" in
1278
- open*) printf '%s\t%s\t%s\t%s\tfiled\t%s\n' "$_f" "$_a" "$_b" "$_t" "${_hit#* }" ;;
1279
- closed*) printf '%s\t%s\t%s\t%s\tfiled-closed\t%s\n' "$_f" "$_a" "$_b" "$_t" "${_hit#* }" ;;
1280
- *) printf '%s\t%s\t%s\t%s\t%s\t\n' "$_f" "$_a" "$_b" "$_t" "$_dflt" ;;
1281
- esac
1282
- done
1283
- rm -f "$rows" "$issues"
1284
- }
1285
- PRIOR_ROWS=$(PF_SKIP="" PF_PRS="$ALL_PR_NUMBERS" prior_findings_tsv)
1286
- if [ -n "$PRIOR_ROWS" ]; then PRIOR_FILE="$_SD/${PR_NUMBER}_prior-findings.tsv"; printf '%s\n' "$PRIOR_ROWS" > "$PRIOR_FILE"; fi
1287
- classify_one() {
1288
- # FINDING_TEXT_FILE holds the finding text: the classifier's content-based safety exemption reads it, so an empty file silently disables the exemption.
1289
- local FINDING_TEXT_FILE; FINDING_TEXT_FILE=$(mktemp "$_SD/staging-finding.XXXXXX")
1290
- printf '%s\n%s\n%s\n' "${FINDING_TITLE:-}" "${FINDING_BODY:-}" "${FINDING_PATHS:-${FINDING_FILE:-}}" > "$FINDING_TEXT_FILE"
1291
- DISPOSITION=""
1292
- if [ -n "$CLASSIFY_SCRIPT" ]; then
1293
- DISPOSITION=$(bash "$CLASSIFY_SCRIPT" --severity "$FINDING_SEVERITY" --confidence "$FINDING_CONFIDENCE" \
1294
- --agent "$FINDING_AGENT" --lineage none --text-file "$FINDING_TEXT_FILE" \
1295
- ${PRIOR_FILE:+--prior-findings "$PRIOR_FILE" --title "${FINDING_TITLE:-}" --file "${FINDING_FILE:-}"}) || DISPOSITION=""
1296
- fi
1297
- rm -f "$FINDING_TEXT_FILE"
1298
- # Fail toward filing, never drop: a classifier runtime failure or garbled result on one finding is ISSUE (the classifier's own convention); an unresolvable classifier never reaches here (guard above). INPR_FIX is never emitted (no --inpr-diff on a bundle).
1299
- case "$DISPOSITION" in ISSUE*|NOTE*) ;; *) DISPOSITION="ISSUE classifier-error" ;; esac
1300
- # 7B scope rule (the two SCOPE_RULE lines are executed here and evaluated by scripts/review-pr-staging-scope.test.sh):
1301
- SEV=$(printf '%s' "$FINDING_SEVERITY" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]') # SCOPE_RULE
1302
- case "$SEV" in MEDIUM|LOW|INFO) OOS_DEMOTE=1 ;; *) OOS_DEMOTE=0 ;; esac # SCOPE_RULE
1303
- # Demote ONLY when scoped, the rule allows it, the classifier did not apply its safety exemption, and EVERY cited file is out of scope.
1304
- # A finding with no file, or with any file in scope, is never demoted. The demotion is recorded as a listed note, never dropped.
1305
- case "$DISPOSITION" in *safety-exemption*|"NOTE reflag"*) OOS_DEMOTE=0 ;; esac # a re-flag is already a recorded NOTE: never double-count it as out_of_scope
1306
- if [ "$SCOPE_MODE" = "scoped" ] && [ "$OOS_DEMOTE" = "1" ] && [ -n "${FINDING_PATHS:-${FINDING_FILE:-}}" ]; then
1307
- local _ANY_IN=false _f
1308
- while IFS= read -r _f; do
1309
- [ -n "$_f" ] || continue
1310
- printf '%s\n' "$CROSS_PR_FILES" "$UNREVIEWED_FILES" "$DEPLOY_WIRING_FILES" | grep -qxF -- "$_f" && _ANY_IN=true
1311
- done <<< "${FINDING_PATHS:-$FINDING_FILE}"
1312
- if [ "$_ANY_IN" = "false" ]; then DISPOSITION="NOTE out_of_scope"; OUT_OF_SCOPE=$((OUT_OF_SCOPE+1)); fi
1313
- fi
1314
- case "$DISPOSITION" in
1315
- "NOTE reflag"*) NOTES_REFLAG=$((NOTES_REFLAG+1)); printf '%s\n' "- ${FINDING_ID:-?} ${FINDING_FILE:-?} [${SEV:-?}] ${FINDING_TITLE:-}: ${DISPOSITION}" >> "$NOTE_LIST_FILE" ;; # counted in notes_reflag only
1316
- NOTE*) NOTES_LISTED=$((NOTES_LISTED+1)); printf '%s\n' "- ${FINDING_ID:-?} ${FINDING_FILE:-?} [${SEV:-?}] ${FINDING_TITLE:-}: ${DISPOSITION}" >> "$NOTE_LIST_FILE" ;;
1317
- *) [ -n "${CAP_ROWS_FILE:-}" ] && printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "${FINDING_ID:--}" "${SEV:--}" "${FINDING_CONFIDENCE:--}" "$DISPOSITION" "${FINDING_AGENT:--}" "${FINDING_FILE:--}" "$(printf '%s' "${FINDING_TITLE:--}" | tr '\t\n' ' ')" >> "$CAP_ROWS_FILE" ;; # ISSUE: counted by cap_pass after the cap
1318
- esac
1319
- }
1320
- cap_pass() {
1321
- # #3642: run ONCE after the last classify_one, before 7E; sets FINDINGS_FILED (post-cap) and NOTES_CAPPED.
1322
- local _out="$_SD/${PR_NUMBER}_cap-out.tsv" _total
1323
- FINDINGS_FILED=0; NOTES_CAPPED=0; : > "$CAPPED_IDS_FILE"
1324
- [ -s "$CAP_ROWS_FILE" ] || return 0
1325
- _total=$(grep -c . "$CAP_ROWS_FILE")
1326
- # Helper failure keeps every ISSUE (fail toward filing).
1327
- if ! bash "$CAP_SCRIPT" --lineage none ${MAX_FILED_FINDINGS:+--cap "$MAX_FILED_FINDINGS"} --input "$CAP_ROWS_FILE" > "$_out" 2>/dev/null || ! grep -q '^CAPPED=' "$_out"; then
1328
- FINDINGS_FILED="$_total"; echo "WARNING: cap-findings.sh failed: all $_total ISSUE findings kept"; return 0
1329
- fi
1330
- NOTES_CAPPED=$(sed -n 's/^CAPPED=//p' "$_out" | tail -n 1); NOTES_CAPPED="${NOTES_CAPPED:-0}"
1331
- awk -F'\t' -v ids="$CAPPED_IDS_FILE" 'NR == FNR { if ($0 !~ /^CAPPED=/) o[++n] = $2; next } # allowlist:positional-arg (awk fields)
1332
- { k++; if (o[k] == "NOTE capped") { printf "%s\n", $1 > ids; printf "- %s %s [%s] %s: NOTE capped (over review.max_filed_findings)\n", $1, $6, $2, $7 } }' "$_out" "$CAP_ROWS_FILE" >> "$NOTE_LIST_FILE" # allowlist:positional-arg (awk fields)
1333
- FINDINGS_FILED=$((_total - NOTES_CAPPED))
1334
- echo "cap_pass: findings_filed=$FINDINGS_FILED notes_capped=$NOTES_CAPPED"
1335
- }
1336
- # per deduped finding: set FINDING_*, call classify_one; then cap_pass ONCE. Ids in $CAPPED_IDS_FILE are capped NOTEs: skip them in 7E/7F.
1078
+ SCOPE_FILE="$_SD/${PR_NUMBER}_scope-files.txt"; rm -f "$SCOPE_FILE"
1079
+ if [ "$SCOPE_MODE" = "scoped" ]; then printf '%s\n%s\n%s\n' "$CROSS_PR_FILES" "$UNREVIEWED_FILES" "$DEPLOY_WIRING_FILES" | sed '/^$/d' | sort -u > "$SCOPE_FILE"; else SCOPE_FILE=""; fi
1080
+ PRIOR_PRS=$(printf '%s ' $ALL_PR_NUMBERS)
1081
+ bash "$RF" extract --pr "$PR_NUMBER" --repo "$GH_REPO" --context "$CTX" --out-dir "$OUT_DIR"; RC=$?
1082
+ [ "$RC" -eq 0 ] || exit 1 # STOP: REVIEW_RESULT: status: BLOCKED was printed
1083
+ # note-disposition dry run (posts nothing): classifies every kept finding and prints the NOTE list
1084
+ bash "$RF" classify --pr "$PR_NUMBER" --repo "$GH_REPO" --context "$CTX" --out-dir "$OUT_DIR" --auto-merge false ${SCOPE_FILE:+--scope-files "$SCOPE_FILE"} --prior-prs "$PRIOR_PRS" --dry-run; RC=$?
1085
+ [ "$RC" -eq 0 ] || exit 1 # STOP
1086
+ cat "$OUT_DIR/note-list.txt" 2>/dev/null
1337
1087
  ```
1338
1088
 
1339
- If the guard printed `REVIEW_RESULT: status: BLOCKED` (`classifier unavailable` or `cap helper unavailable`), STOP: `exit 0` only ends that Bash block, so do not run 7E/7F or any later phase, file no issues, emit no deploy approval, and report `RESULT: BLOCK DEPLOY` with the blocker.
1089
+ Read `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the summary. With no kept finding and no unstructured finding in the trusted reviewer bodies, skip to Phase 8.
1340
1090
 
1341
- **Per-review cap** <!-- Added: forge#3642 -->: `cap_pass` (once, before 7E) runs `scripts/cap-findings.sh`: at most `review.max_filed_findings` ISSUEs (default 2) are kept, ranked by severity, confidence, safety domain. The overflow is a listed `NOTE capped` (counted in `notes_capped`, never filed or dropped); HIGH/CRITICAL are never capped.
1091
+ ### 7B: Note disposition (same damper and classifier as `/review-pr` §6B.5)
1342
1092
 
1343
- Only `ISSUE` findings that are not in `CAPPED_IDS_FILE` continue to 7E/7F. Each `NOTE` is listed in the PR body (`## Non-blocking notes`, edit never replace) or dropped as a duplicate or nit; NOTEs are NEVER passed to `Skill(issue)`. Post one disposition record (counts only plus the NOTE list; every NOTE is recorded, never silently dropped), whenever 7A extracted at least one finding:
1093
+ `classify` runs `scripts/classify-finding.sh` (never hand-classify) on every finding that survived 7A, then `scripts/cap-findings.sh`; the rules live there (HIGH/CRITICAL always ISSUE; a content-based safety exemption; otherwise LOW/POSSIBLE become NOTEs). The staging PR is not a finding fix, so `lineage=none`. **Scope**: with `SCOPE_MODE=scoped` a MEDIUM, LOW or INFO finding whose cited files are all outside the 0B.1 set becomes `NOTE out_of_scope` (listed, never dropped; never HIGH/CRITICAL, never a safety-exempt or re-flagged finding, never a finding that cites no file). **Cross-PR re-flag** (`--prior-prs`): a finding a bundle member PR's review or `FORGE:NOTE_DISPOSITION` list already covered becomes `NOTE reflag-*` and files nothing (never for HIGH/CRITICAL). **Per-review cap**: at most `review.max_filed_findings` ISSUEs (default 2), the overflow is `NOTE capped`. Each NOTE gets one disposition: list it in the PR body under `## Non-blocking notes` (edit, never replace) or drop a duplicate or nit; NOTEs never reach the filing call. The 7C fence runs `classify` for real, which posts the `FORGE:NOTE_DISPOSITION` record (counts, `out_of_scope=`, `scope=`, then the NOTE list, dropped NOTEs included) whenever findings were extracted. Read `FINDINGS_FILED`, `NOTES_LISTED`, `NOTES_CAPPED`, `NOTES_REFLAG` and `NOTE_DISPOSITION_POSTED`.
1344
1094
 
1345
- ```bash
1346
- CLASSIFIER_MODE=script # the 7B.5 guard blocks the review when the classifier is unresolvable, so this is always script
1347
- # This is a separate Bash call: shell state is lost. Re-derive PR_NUMBER and GH_REPO; counters the caller did not carry over are recomputed from the note list file (NOTES_LISTED) or flagged.
1348
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1349
- . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
1350
- if [ -z "${PR_NUMBER:-}" ]; then
1351
- echo "REVIEW_RESULT: status: BLOCKED, blocker: PR_NUMBER unavailable; FORGE:NOTE_DISPOSITION not posted (record lost)"
1352
- else
1353
- NOTE_LIST_FILE="${NOTE_LIST_FILE:-$_SD/${PR_NUMBER}_notes.md}"
1354
- [ -n "${NOTES_LISTED:-}" ] || { NOTES_LISTED=$(grep -v ': NOTE capped (' "$NOTE_LIST_FILE" 2>/dev/null | grep -c '^- ' || true); echo "WARNING: classification counters lost (fresh shell): notes_listed recomputed from $NOTE_LIST_FILE; set SCOPE_MODE, FINDINGS_FILED, OUT_OF_SCOPE, NOTES_REFLAG and NOTES_CAPPED from the 7B.5 block output before posting"; }
1355
- [ -n "${NOTES_CAPPED:-}" ] || NOTES_CAPPED=$(grep -c ': NOTE capped (' "$NOTE_LIST_FILE" 2>/dev/null || true)
1356
- [ -n "${SCOPE_MODE:-}" ] || { SCOPE_MODE=full; echo "WARNING: SCOPE_MODE lost (fresh shell): recording scope=full"; }
1357
- _DISP_BODY="$_SD/${PR_NUMBER}_note_disposition.md"
1358
- { printf '%s\n' "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=${NOTES_LISTED:-0} notes_dropped=${NOTES_DROPPED:-0} findings_filed=${FINDINGS_FILED:-0} out_of_scope=${OUT_OF_SCOPE:-0} notes_reflag=${NOTES_REFLAG:-0} notes_capped=${NOTES_CAPPED:-0} lineage=none classifier=${CLASSIFIER_MODE} scope=${SCOPE_MODE} -->"; cat "$NOTE_LIST_FILE" 2>/dev/null; } > "$_DISP_BODY"
1359
- gh pr comment "$PR_NUMBER" -R "$GH_REPO" --body-file "$_DISP_BODY" # allowlist:check-command-side-effects
1360
- fi
1361
- ```
1362
-
1363
- ### 7C: Ensure Labels
1364
- ```bash
1365
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1366
- . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
1367
- # Colors match the canonical ForgeDock label manifest (bin/labels.json).
1368
- # Run `npx forgedock labels setup` to bootstrap all managed labels at once.
1369
- gh label create "review-finding" --color "D93F0B" --description "Defect or improvement found during automated PR review. Managed by ForgeDock." --force -R "$GH_REPO" 2>/dev/null
1370
- gh label create "needs-validation" --color "FBCA04" --description "Review finding awaiting human validation. Managed by ForgeDock." --force -R "$GH_REPO" 2>/dev/null
1371
- gh label create "pre-existing" --color "BFD4F2" --description "Defect noticed during PR review that the PR did not introduce. Managed by ForgeDock." --force -R "$GH_REPO" 2>/dev/null
1372
- gh label create "staging-review" --color "1D76DB" --description "Finding from a staging branch review before deploy to main. Managed by ForgeDock." --force -R "$GH_REPO" 2>/dev/null
1373
- ```
1095
+ ### 7C: Prepare and create issues
1374
1096
 
1375
- ### 7D: Milestone & Code Branch Detection
1376
-
1377
- Derive both `CODE_BRANCH` and `MILESTONE_FLAG` from the reviewed PR before creating any findings. Unlike `commands/review-pr.md` (feature-lane PRs with short-lived HEAD branches that get deleted post-merge — see the rationale comment on that spec's finding template), `review-pr-staging.md` reviews long-lived branches (`staging`, `milestone/*`) that persist after merge, so `CODE_BRANCH` uses `headRefName` (the PR's HEAD — the branch actually being reviewed), consistent with forge#1391's original fix. This replaces the previous hardcoded `staging` literal, which was silently wrong whenever this spec reviewed a `milestone/X → staging` PR instead of a `staging → main` PR — `CODE_BRANCH` used to always say `staging` even when the reviewed branch was `milestone/X`.
1097
+ `file-prepare` does every pre-filing step for the ISSUE findings: managed labels, the milestone (`scripts/derive-finding-milestone.sh`), the title dedup (`scripts/issue-dedup.sh`) and the line-range check against open `review-finding` issues, a regression flag when a closed `review-finding` sits at the same location (elevated one priority level), `priority:*` from `**Severity**` only via `scripts/severity-to-priority.sh` (never from Confidence), and the pattern recurrence check. A dedup helper or lookup failure never suppresses a finding. Staging reviews long-lived branches (`staging`, `milestone/*`), so the code branch is the PR's `headRefName`, not a short-lived head.
1378
1098
 
1379
1099
  ```bash
1380
1100
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1381
1101
  . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
1382
- CODE_BRANCH=$(gh pr view "$PR_NUMBER" -R "$GH_REPO" --json headRefName --jq '.headRefName')
1383
- MILESTONE_FLAG=""
1384
-
1385
- # scripts/derive-finding-milestone.sh is the single source of truth for
1386
- # milestone derivation — commands/review-pr.md and commands/review-pr-staging.md
1387
- # both call it identically so the two specs cannot independently drift.
1388
- # <!-- forge#2443 -->
1389
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1390
- DERIVE_MILESTONE_SCRIPT=$(forge_resolve derive-finding-milestone.sh)
1391
- # Unresolved helper: empty milestone (non-blocking), never a cwd-relative fallback.
1392
- MILESTONE_TITLE=""
1393
- [ -n "$DERIVE_MILESTONE_SCRIPT" ] && MILESTONE_TITLE=$(bash "$DERIVE_MILESTONE_SCRIPT" "${PR_NUMBER}" -R "$GH_REPO")
1394
- # Quoted so multi-word milestone titles survive as a single value wherever
1395
- # MILESTONE_FLAG is later interpolated — matches the quoting convention already
1396
- # used for --title/--body-file/--label below.
1397
- [ -n "$MILESTONE_TITLE" ] && MILESTONE_FLAG="--milestone \"$MILESTONE_TITLE\""
1102
+ CTX="$_SD/review-ctx-${PR_NUMBER}.json"; [ -s "$CTX" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: review context $CTX missing (0C did not run); nothing triaged (fail closed)"; exit 1; } # STOP
1103
+ RF=$(forge_resolve review-finalize.sh) # install roots only, never the PR checkout; unresolvable is a stop (fail closed), never a skipped gate
1104
+ [ -n "$RF" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/review-finalize.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install); refusing to continue (fail closed)"; exit 1; } # STOP
1105
+ OUT_DIR="$_SD/review-finalize-${PR_NUMBER}"
1106
+ SCOPE_FILE="$_SD/${PR_NUMBER}_scope-files.txt"; [ -r "$SCOPE_FILE" ] || SCOPE_FILE="" # written by 7A only for a scoped review
1107
+ PRIOR_PRS=$(printf '%s ' $ALL_PR_NUMBERS)
1108
+ # 7B for real: posts FORGE:NOTE_DISPOSITION with the caller's NOTE counts.
1109
+ bash "$RF" classify --pr "$PR_NUMBER" --repo "$GH_REPO" --context "$CTX" --out-dir "$OUT_DIR" ${SCOPE_FILE:+--scope-files "$SCOPE_FILE"} --prior-prs "$PRIOR_PRS" \
1110
+ --auto-merge false --notes-fixed 0 --notes-dropped "${NOTES_DROPPED:-0}"; RC=$? # never merges here (staging deploy is gated separately) allowlist:check-command-side-effects
1111
+ [ "$RC" -eq 0 ] || exit 1 # STOP: REVIEW_RESULT: status: BLOCKED was printed
1112
+ PATTERNS_FILE="$OUT_DIR/patterns.json"; [ -s "$PATTERNS_FILE" ] || PATTERNS_FILE=""
1113
+ bash "$RF" file-prepare --pr "$PR_NUMBER" --repo "$GH_REPO" --context "$CTX" --out-dir "$OUT_DIR" ${PATTERNS_FILE:+--patterns "$PATTERNS_FILE"}; RC=$?
1114
+ [ "$RC" -eq 0 ] || exit 1 # STOP
1115
+ gh label create "staging-review" --color "1D76DB" --description "Finding from a staging branch review before deploy to main. Managed by ForgeDock." --force -R "$GH_REPO" 2>/dev/null || true
1116
+ echo "CODE_BRANCH=$(gh pr view "$PR_NUMBER" -R "$GH_REPO" --json headRefName --jq '.headRefName')"
1117
+ jq -c '.items[] | select(.action == "file")' "$OUT_DIR/worklist.json"
1398
1118
  ```
1399
1119
 
1400
- Plain staging→main reviews resolve `CODE_BRANCH` to `staging` dynamically (same value as before, now derived rather than hardcoded) and typically resolve no milestone. A milestone→staging review resolves `CODE_BRANCH` to `milestone/X` and (via the shared script) the matching milestone.
1401
-
1402
- ### 7E: Deduplicate Against Existing Issues (BEFORE every filing)
1403
-
1404
- Re-reviewing the same bundle must not re-file existing findings. For each `ISSUE` finding, check open AND closed `review-finding` issues by file + pattern + location before creating anything:
1120
+ Each printed line is one item to file (`skip-*` items are covered or unfilable: record them in the Phase 8 summary as `deduped: #N`, `regression: #N` or `dedup-unavailable`). An item carries `title`, `labels` (with `priority:*`), `milestone`, `batchable`, `regression_of`, `file`, `line`, `severity`, `agent` and `pattern`; pattern consolidation follows `commands/review-pr.md` §6C. **For each item to file**, write the issue body, then create it through the `/issue` programmatic invocation contract (`commands/issue.md` § "Programmatic Invocation Contract"), never the raw issue-creation command:
1405
1121
 
1406
1122
  ```bash
1407
1123
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1408
1124
  . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
1409
- GH_FLAG="-R $GH_REPO"
1410
- # FINDING_FILE/FINDING_LINE come from the finding's structured block. Define the title and scratch dir BEFORE use (7F reuses them).
1411
- SCRATCHPAD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"; mkdir -p "$SCRATCHPAD"
1412
- STAGING_FINDING_TITLE="chore: ${FINDING_TITLE:-[summary]} (staging review — PR #${PR_NUMBER})"
1413
- EXISTING_ISSUE_EXCLUDE="${EXISTING_ISSUE_EXCLUDE:-}" # optional comma-separated issue numbers already linked to this bundle
1414
- _SAFE_FILE=$(printf '%s' "$FINDING_FILE" | tr -d '"\\')
1415
- # issue-dedup.sh ships with ForgeDock next to classify-finding.sh (resolved in 7B.5, install-root only, never the repo under review).
1416
- DEDUP_SCRIPT=""; [ -n "${CLASSIFY_SCRIPT:-}" ] && [ -f "$(dirname "$CLASSIFY_SCRIPT")/issue-dedup.sh" ] && DEDUP_SCRIPT="$(dirname "$CLASSIFY_SCRIPT")/issue-dedup.sh"
1417
- if [ -n "$DEDUP_SCRIPT" ]; then
1418
- DEDUP_OUT=$(bash "$DEDUP_SCRIPT" "${FINDING_TITLE:-$STAGING_FINDING_TITLE}" "$GH_FLAG" ${EXISTING_ISSUE_EXCLUDE:+--exclude "$EXISTING_ISSUE_EXCLUDE"} 2>&1) # open issues, token match
1419
- DEDUP_EXIT=$?
1420
- else
1421
- DEDUP_EXIT=127 # dedup unavailable
1422
- fi
1423
- # The title token match alone is weak (any two findings share words like "staging" and "review" in the boilerplate, which is why only the finding's own
1424
- # title is matched above): an exit 1 is a duplicate ONLY when the matched open issue also cites this finding's file. Otherwise it is not a duplicate.
1425
- if [ "$DEDUP_EXIT" -eq 1 ]; then
1426
- _DUP_N=$(printf '%s' "$DEDUP_OUT" | sed -n 's/^DUPLICATE: #\([0-9][0-9]*\).*/\1/p' | head -1)
1427
- _DUP_BODY=$(gh issue view "${_DUP_N:-0}" -R "$GH_REPO" --json body --jq '.body' 2>/dev/null) || _DUP_BODY=""
1428
- printf '%s' "$_DUP_BODY" | grep -qF -- "$_SAFE_FILE" || DEDUP_EXIT=0
1429
- fi
1430
- # Closed issues that cite this file at a line within +-5 of the finding. FINDING_FILE is finding content: strip quotes/backslashes so it cannot add search qualifiers.
1431
- _FILE_RE=$(printf '%s' "$_SAFE_FILE" | sed 's/[][\.^$*+?(){}|\/]/\\&/g')
1432
- REGRESSION_CHECK=ok; CLOSED_HIT=""
1433
- # Capture the gh read first and test its own status: through a pipe a failed read looks like "no closed hit".
1434
- _CL=$(gh issue list -R "$GH_REPO" --label review-finding --state closed --search "\"${_SAFE_FILE}\" in:body" --json number,title,body,stateReason --limit 100 2>/dev/null) || REGRESSION_CHECK=unavailable
1435
- # FINDING_LINE may be a range such as 12-15: use its first integer, and 0 when there is none.
1436
- _L=$(printf '%s' "${FINDING_LINE:-0}" | grep -oE '[0-9]+' | head -1); _L="${_L:-0}"
1437
- _HITS=""; [ "$REGRESSION_CHECK" = "ok" ] && { _HITS=$(printf '%s' "$_CL" | jq -r --arg re "$_FILE_RE" --argjson l "$_L" '.[] | select((.body // "") != "") | select(.stateReason != "NOT_PLANNED")
1438
- | select([.body | scan($re + "[:#L]+([0-9]+)") | .[0] | tonumber | select((. - $l) <= 5 and ($l - .) <= 5)] | length > 0) | "#\(.number) \(.title)"') || REGRESSION_CHECK=unavailable; }
1439
- CLOSED_HIT=$(printf '%s\n' "$_HITS" | sed '/^$/d' | head -3)
1440
- # Confirm the 3+ shared title keywords against each hit before treating it as a regression.
1441
- ```
1442
-
1443
- - `DEDUP_EXIT=1` (open duplicate): skip creation, record `deduped: #N`. Exit `0` means no duplicate. Any other exit (usage error 2, script unavailable 126/127) means dedup is unavailable: record `dedup-unavailable` in the Phase 8 summary and file the finding normally (the `/issue` Phase 2D dedup remains as the second pass); a broken dedup helper must never suppress a finding, least of all a CRITICAL or HIGH. Pass `--exclude` for any issue already linked to this bundle.
1444
- - `REGRESSION_CHECK=unavailable` (the closed-issue read or its filter failed): record `regression-check-unavailable` in the Phase 8 summary; it never suppresses filing.
1445
- - `CLOSED_HIT` non-empty (closed issue, not closed as not-planned, at the same file with a location within +-5 lines and 3+ shared title keywords): potential regression: file it (it is not a duplicate of a fixed defect), elevate its priority one level, link the closed issue, and record it as `regression: #N`. A closed hit never suppresses a finding.
1446
- - Record every deduped item (finding id, matched issue) for the Phase 8 summary and audit.
1447
-
1448
- ### 7F: Create Issues
1449
- Sequential creation. Title: `Staging Review: {summary} (staging → main)`. Labels: review-finding, needs-validation, staging-review, priority:P0-P3 (derived from Severity — see below). Body includes: source branch context (`${CODE_BRANCH}`, derived in Phase 7D — not a hardcoded `staging` literal), code context, evidence, validation checklist.
1450
-
1451
- **For each finding** (classified `ISSUE` in 7B.5 and not deduped in 7E), create issue through the `/issue` create-hook's programmatic invocation contract (see `commands/issue.md` § "Programmatic Invocation Contract") instead of calling the raw issue-creation command directly:
1452
- ```bash
1453
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1454
- . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
1455
- # STAGING_FINDING_TITLE was built in 7E; re-assert it here if 7E was skipped.
1456
- STAGING_FINDING_TITLE="${STAGING_FINDING_TITLE:-chore: [summary] (staging review — PR #${PR_NUMBER})}"
1457
- # Defense-in-depth: /issue's arg tokenizer (commands/issue.md, forge#2094) uses
1458
- # an xargs-based tokenizer that never expands backtick/$(...) substitution, so
1459
- # this is no longer required for safety — but strip it anyway so the raw title
1460
- # stays readable if it round-trips through any other eval-based consumer.
1461
- STAGING_FINDING_TITLE=$(printf '%s' "$STAGING_FINDING_TITLE" | tr '`' "'" | sed 's/\$(/$ (/g')
1462
- SCRATCHPAD="${SCRATCHPAD:-${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}}"
1463
- REVIEW_AGENT_TOKEN="${AGENT_ID:-${HOSTNAME:-reviewer}-$$}"
1464
- mkdir -p "$SCRATCHPAD"
1125
+ SCRATCHPAD="$_SD"; REVIEW_AGENT_TOKEN="${AGENT_ID:-${HOSTNAME:-reviewer}-$$}"
1465
1126
  STAGING_FINDING_BODY_MARKER="FORGE:BODY-INTEGRITY:${PR_NUMBER}_staging-review_${REVIEW_AGENT_TOKEN}"
1466
1127
  STAGING_FINDING_BODY_FILE=$(mktemp "$SCRATCHPAD/${PR_NUMBER}_staging-review_${REVIEW_AGENT_TOKEN}.XXXXXX.md")
1467
1128
  cat <<'ISSUE_EOF' > "$STAGING_FINDING_BODY_FILE"
1468
1129
  ## Problem
1469
1130
 
1470
- [One sentence: what bug or issue was found. Where it occurs (`file:line`) and what it causes.]
1131
+ [One sentence: what bug was found, where (`file:line`) and what it causes.]
1471
1132
 
1472
1133
  **Source**: PR #[PR_NUMBER] — [TITLE]
1473
1134
  **Confidence**: [CONFIRMED/LIKELY/POSSIBLE]
@@ -1481,81 +1142,42 @@ Files that need changes:
1481
1142
 
1482
1143
  ## Source Branch Context
1483
1144
 
1484
- **Code branch**: `[CODE_BRANCH]`
1485
- **Worktree base**: `origin/[CODE_BRANCH]`
1486
-
1487
- > When fixing: `git worktree add ../fix-{slug} -b fix/{slug} origin/[CODE_BRANCH]`
1145
+ **Code branch**: `[CODE_BRANCH]` (worktree base `origin/[CODE_BRANCH]`)
1488
1146
 
1489
1147
  ## Code Context
1490
- [10 lines around finding]
1148
+ [10 lines around the finding]
1491
1149
 
1492
1150
  ## Evidence
1493
- [From agent comment]
1151
+ [From the agent comment]
1494
1152
 
1495
1153
  ## Acceptance Criteria
1496
1154
 
1497
1155
  - [ ] Finding validated: VALIDATED / FALSE_POSITIVE / INCONCLUSIVE
1498
- - [ ] If VALIDATED: fix implemented and tested on correct branch
1156
+ - [ ] If VALIDATED: fix implemented and tested on the correct branch
1157
+ [BATCHABLE_ANNOTATION]
1499
1158
  ISSUE_EOF
1500
1159
  printf '\n<!-- %s -->\n' "$STAGING_FINDING_BODY_MARKER" >> "$STAGING_FINDING_BODY_FILE"
1160
+ echo "STAGING_FINDING_BODY_FILE=$STAGING_FINDING_BODY_FILE"
1161
+ ```
1501
1162
 
1502
- # STAGING_FINDING_SEVERITY is extracted from the finding's own **Severity**
1503
- # body field (set above in the heredoc) — example assignment shown here for
1504
- # clarity, same convention as STAGING_FINDING_TITLE etc.
1505
- STAGING_FINDING_SEVERITY="LOW"
1163
+ Replace `[BATCHABLE_ANNOTATION]` with `<!-- FORGE:BATCHABLE -->` when the item's `batchable` is true, else remove it; add "Previously fixed in #N: regression" when `regression_of` is set. The filing call is a skill invocation, not a shell fence: `--label` repeats for every item label plus `staging-review` (never comma-joined), `--milestone` only when the item's is non-empty:
1506
1164
 
1507
- # priority:* label is a deterministic function of the finding's **Severity**
1508
- # (CRITICAL/HIGH/MEDIUM/LOW) — NEVER of its Confidence
1509
- # (CONFIRMED/LIKELY/POSSIBLE). scripts/severity-to-priority.sh is the single
1510
- # source of truth for this mapping; commands/review-pr.md calls the identical
1511
- # script so the two specs cannot independently drift. <!-- forge#2447 -->
1512
- type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1513
- PRIORITY_SCRIPT=$(forge_resolve severity-to-priority.sh)
1514
- # Unresolved helper: non-zero status so finding creation is skipped (never defaulted to a priority).
1515
- if [ -n "$PRIORITY_SCRIPT" ]; then
1516
- STAGING_FINDING_PRIORITY=$(bash "$PRIORITY_SCRIPT" "$STAGING_FINDING_SEVERITY")
1517
- STAGING_FINDING_PRIORITY_EXIT=$?
1518
- else
1519
- STAGING_FINDING_PRIORITY=""; STAGING_FINDING_PRIORITY_EXIT=127
1520
- fi
1165
+ ```
1166
+ >>> INVOKE: Skill(skill="{FORGE_SKILL_PREFIX}issue", args="--title \"<item.title>\" --body-file \"{STAGING_FINDING_BODY_FILE}\" --label review-finding --label needs-validation --label staging-review --label \"<item.priority>\" [--milestone \"<item.milestone>\"]")
1167
+ ```
1521
1168
 
1522
- # Exit code MUST be checked before use — severity-to-priority.sh exits 1 (empty stdout)
1523
- # on a missing/unrecognized severity. Proceeding with an empty $STAGING_FINDING_PRIORITY
1524
- # would call Skill(issue, --label "") instead of aborting issue creation for this finding.
1525
- # Mirrors the identical check in commands/review-pr.md. <!-- forge#2479 -->
1526
- if [ "$STAGING_FINDING_PRIORITY_EXIT" -ne 0 ]; then
1527
- echo "PRIORITY: severity-to-priority.sh failed (exit $STAGING_FINDING_PRIORITY_EXIT) for severity '$STAGING_FINDING_SEVERITY' — skipping finding issue creation"
1528
- # Skip this finding — do NOT fall through to issue creation with an empty label
1529
- else
1169
+ For a pre-existing item use the item's `labels` (`pre-existing` plus priority) plus `staging-review`. `/issue` succeeds only after its API create-token read-back and prints `ISSUE_CREATE_RESULT:CREATED number=N` or `ISSUE_CREATE_RESULT:DEDUP number=N`; pass that line to `file-verify`, which prints `ISSUE_NUM=N` and exits 1 when no number was reported (the review then stops instead of silently dropping the finding):
1530
1170
 
1531
- # --label is repeatable (not comma-joined) per the /issue programmatic contract.
1532
- # ${MILESTONE_FLAG} carries the Phase 7D derivation through — empty string is
1533
- # a no-op arg when the reviewed branch has no milestone (plain staging→main).
1534
- ISSUE_SKILL_OUTPUT=$(Skill(skill="{FORGE_SKILL_PREFIX}issue", args="--title \"$STAGING_FINDING_TITLE\" --body-file \"$STAGING_FINDING_BODY_FILE\" --label review-finding --label needs-validation --label staging-review --label \"$STAGING_FINDING_PRIORITY\" ${MILESTONE_FLAG}"))
1535
- # /issue re-reads the created issue and hard-fails unless this exact marker is present.
1536
- rm -f "$STAGING_FINDING_BODY_FILE"
1537
-
1538
- # /issue succeeds only after its API create-token read-back (Phase 4B). Its
1539
- # explicit result marker distinguishes a verified create from an intentional
1540
- # dedup STOP; do not use title search to mask a swallowed 403.
1541
- ISSUE_NUM=$(printf '%s\n' "$ISSUE_SKILL_OUTPUT" | sed -n 's/.*ISSUE_CREATE_RESULT:CREATED number=\([0-9][0-9]*\).*/\1/p' | head -1)
1542
- DEDUP_NUMBER=$(printf '%s\n' "$ISSUE_SKILL_OUTPUT" | sed -n 's/.*ISSUE_CREATE_RESULT:DEDUP number=\([0-9][0-9]*\).*/\1/p' | head -1)
1543
- if [ -n "$DEDUP_NUMBER" ]; then
1544
- ISSUE_NUM="$DEDUP_NUMBER"
1545
- echo "Staging review finding deduped against existing issue #${ISSUE_NUM}."
1546
- elif [ -z "$ISSUE_NUM" ]; then
1547
- echo "ERROR: /issue did not report a verified staging review-finding number; stopping review instead of silently dropping the finding." >&2
1548
- exit 1
1549
- fi
1550
- fi
1171
+ ```bash
1172
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1173
+ RF=$(forge_resolve review-finalize.sh) # install roots only, never the PR checkout; unresolvable is a stop (fail closed)
1174
+ [ -n "$RF" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/review-finalize.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install); refusing to continue (fail closed)"; exit 1; } # STOP
1175
+ printf '%s\n' "{ISSUE_RESULT_LINE}" | bash "$RF" file-verify || exit 1 # STOP: the finding was not verified as filed
1551
1176
  ```
1552
1177
 
1553
- Labels: `review-finding` + `needs-validation` + `staging-review` + priority. `priority:*` is derived from the finding's `**Severity**` field via `scripts/severity-to-priority.sh` (identical script used by `commands/review-pr.md` — single documented mapping, see that script's header comment): `CRITICAL` → `priority:P0`, `HIGH` → `priority:P1`, `MEDIUM` → `priority:P2`, `LOW` → `priority:P3`. **Never derive `priority:*` from Confidence** (CONFIRMED/LIKELY/POSSIBLE) — conflating the two axes previously mislabeled LOW-severity CONFIRMED findings as `priority:P1`, defeating orchestrate's P3 batching rule. <!-- forge#2447 -->
1554
-
1555
- **Filing disposition**: Filing follows §7B.5 and the `commands/review-pr.md` §6B.4/§6B.5 rules — LOW severity and POSSIBLE confidence findings are non-blocking NOTEs (recorded in `FORGE:NOTE_DISPOSITION`), not standalone issues. Only findings that are `ISSUE` after 7B.5 and not deduped in 7E become issues; NOTEs and duplicates are recorded, never silently dropped. Validation agents sort out false positives downstream for the findings that are filed.
1178
+ ### 7D: Project board and PR description
1556
1179
 
1557
- ### 7G: Add to Project Board
1558
- ### 7H: Update PR Description with Findings Table
1180
+ Add each created issue to the project board (`forge.yaml → project_board`; a missing config skips this silently) and append a `## Review Findings` table (summaries and issue links) to the PR body: edit, never replace.
1559
1181
 
1560
1182
  ---
1561
1183
 
@@ -1569,7 +1191,7 @@ Post summary with verdict:
1569
1191
  4. CONFIRMED HIGH blocking (crashes, data loss) → NEEDS FIXES FIRST
1570
1192
  5. All else → APPROVE FOR DEPLOY
1571
1193
 
1572
- Include in Finding Triage Results: `SCOPE_MODE`, `reviewed_prs`, `unreviewed_commits`, `cross_pr_files` counts (Phase 0B.1), `notes_fixed`/`notes_listed`/`notes_dropped`, `findings_filed`, `out_of_scope`, and the deduped list (7E). **Idempotency**: re-running the staging review on an unchanged bundle files 0 new findings (reviewed-set exclusion plus 7E dedup); a re-run that files anything must cite what changed in the bundle.
1194
+ Include in Finding Triage Results: `SCOPE_MODE`, `reviewed_prs`, `unreviewed_commits`, `cross_pr_files` counts (Phase 0B.1), `notes_fixed`/`notes_listed`/`notes_dropped`, `findings_filed`, `out_of_scope`, and the deduped list (the `skip-*` worklist items). **Idempotency**: re-running the staging review on an unchanged bundle files 0 new findings (reviewed-set exclusion plus 7E dedup); a re-run that files anything must cite what changed in the bundle.
1573
1195
 
1574
1196
  Include: Material Changes Summary, Risk Matrix (CI, Build, Bugs, Security, Billing, Quality, Regression, **Test Gate**), Finding Triage Results, Blocking Issues, Deployment Checklist (pre-deploy, deploy, post-deploy verification, rollback triggers), Stats.
1575
1197
 
@@ -1611,7 +1233,8 @@ COST_SCRIPT=$(forge_resolve cost-report.sh)
1611
1233
  ```bash
1612
1234
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
1613
1235
  . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
1614
- STATS_OUT=$(forgedock stats --compact --repo "${GH_REPO}" 2>/dev/null || npx --no-install forgedock stats --compact --repo "${GH_REPO}" 2>/dev/null) || STATS_OUT=""
1236
+ STATS_TMO=""; command -v timeout >/dev/null 2>&1 && STATS_TMO="timeout 120" # bound the crawl; stock macOS lacks timeout. A killed run keeps its cache checkpoints
1237
+ STATS_OUT=$($STATS_TMO forgedock stats --compact --repo "${GH_REPO}" 2>/dev/null || $STATS_TMO npx --no-install forgedock stats --compact --repo "${GH_REPO}" 2>/dev/null) || STATS_OUT=""
1615
1238
  [ -n "$STATS_OUT" ] && printf '%s\n' "$STATS_OUT" || echo "Pipeline stats: unavailable"
1616
1239
  ```
1617
1240