forgedock 1.16.2 → 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 (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,1427 @@
1
+ #!/usr/bin/env bash
2
+ # review-finalize.sh — deterministic review-pr Phases 6-9: finding triage and filing preparation (part A), verdict, calibration, merge gates, integrity (part B).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # One self-contained shell per subcommand: each re-derives every value it uses (no helper or variable is defined in one place and
7
+ # consumed in another), resolves ForgeDock's own helpers through forge_resolve (FORGEDOCK_HOME / plugin root / FORGE_ROOT / plugin
8
+ # cache; the working directory and the PR checkout are NEVER a script source), and treats everything reviewer-controlled (comment
9
+ # bodies, finding titles, file paths) as data: comment text is only ever read through scripts/trusted-comments.sh, JSON is built with
10
+ # `jq --arg`, nothing from a comment is eval'd or expanded in arithmetic.
11
+ #
12
+ # Usage: review-finalize.sh <subcommand> --pr N --repo owner/name --context FILE [options]
13
+ # extract 6A trusted-comment extraction + 6B dedup + 6B.4 provenance/concreteness gates. Writes the findings JSON.
14
+ # classify 6B.5 note disposition: classifier (classify-finding.sh, called as is), contract scope, cross-head re-flag, per-review
15
+ # cap, pre-existing route; posts FORGE:NOTE_DISPOSITION (and FORGE:INPR_FIX on the first auto-merge round).
16
+ # file-prepare 6C pre-filing work: labels, milestone, title and line-range dedup, regression to P1, pattern recurrence,
17
+ # severity-to-priority, BATCHABLE. Writes the JSON work list the caller files from.
18
+ # file-verify 6C post-filing parse: reads the issue-creation result text on stdin, prints ISSUE_NUM=N, exits 1 when no number.
19
+ # verdict 7A gate log + 7B base-conflict computation; posts the verdict comment when --result stale|clean|blocking is given.
20
+ # calibrate 7B.5 calibration-table consultation (can only tighten); records FORGE:CALIBRATION_CHECK on the linked issue.
21
+ # merge-gate Phase 8: note-disposition, in-PR fix, --issue, phase-trail, guard-state, base-conflict / calibration / trust routing,
22
+ # pre-merge mergeability, previously-escalated parking, CI gate, reviewed-head guard, merge. Never skips a gate.
23
+ # demilestone 8B / 9A: clears the milestone from open review-finding issues of a merged milestone PR.
24
+ # integrity Phase 9: stale-head check and the verifiable trusted REVIEW-AGENT count (carry-aware).
25
+ #
26
+ # Boundary: the filing call itself (the /issue skill with its programmatic contract) and the LLM-authored finding body stay in the
27
+ # spec: a shell script cannot invoke a skill. The script ends at the work list (file-prepare) and resumes at file-verify.
28
+ # Also left to the caller: INTEG-N findings from Phase 2.5, unstructured-finding extraction when `extract` reports 0 structured
29
+ # findings, pattern-class consolidation issue creation, project-board add, 6D, and the choice of which NOTEs are fixed or dropped
30
+ # (pass --notes-fixed / --notes-dropped; every other plain NOTE is counted as listed).
31
+ #
32
+ # Options:
33
+ # --pr N PR number (numeric, required) --repo owner/name (required)
34
+ # --context FILE the review-prepare.sh context JSON (schema_version 1 required): pr.review_sha, pr.base_ref, delta.mode/from,
35
+ # contract.issue (the PR's linked issue) are read from it, never re-derived
36
+ # --out-dir DIR artifacts (default: ${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-finalize-<pr>)
37
+ # --findings FILE input findings JSON (default: extract -> n/a, classify -> OUT_DIR/findings.json, file-prepare -> OUT_DIR/classified.json)
38
+ # --out FILE output JSON (default: extract -> OUT_DIR/findings.json, classify -> OUT_DIR/classified.json, file-prepare -> OUT_DIR/worklist.json)
39
+ # --auto-merge B classify: true enables the in-PR fix gate (auto-merge review with a linked issue, first round only)
40
+ # --notes-fixed N / --notes-dropped N classify: NOTE dispositions chosen by the caller (default 0); the rest are notes_listed
41
+ # --patterns FILE file-prepare: JSON object {finding id: FORGE:PATTERN slug}; absent = the recurrence check is skipped
42
+ # --dry-run classify: post no PR or issue comments; file-prepare: create no labels
43
+ # --scope-files FILE classify (staging bundle review): newline list of the files in review scope (cross-PR, unreviewed and deploy-wiring files). When
44
+ # given, a MEDIUM/LOW/INFO finding whose cited file is outside it becomes `NOTE out_of_scope` (never HIGH/CRITICAL, never a safety-exempt
45
+ # or re-flagged finding, never a finding with no file), counted as out_of_scope in the disposition record (`scope=scoped`).
46
+ # --prior-prs "N N" classify (staging bundle review; an empty list is valid): read the prior findings and NOTE lists of these PRs (the bundle
47
+ # members) for the cross-PR re-flag instead of this PR's own earlier heads. It also marks the run as a bundle review: the context's
48
+ # contract.issue is ignored (no single fixing issue, lineage none) and the disposition record carries out_of_scope= and scope=
49
+ # Phase 7-9 subcommands: --context FILE is optional; without it pass --review-sha SHA, --review-mode FULL|DELTA|BASE_SYNC_ONLY, --issue N, --base B.
50
+ # verdict: --merge-health S --merge-health-state S [--base B] [--head-branch H] [--is-milestone true|false] [--has-purpose-regression true|false]
51
+ # [--purpose-findings TEXT] [--result stale|clean|blocking] [--agents TEXT] [--findings-count N] [--blocking-count N]
52
+ # [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--dry-run]
53
+ # calibrate: [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--trust-needs-human B] [--dry-run]; the issue is context contract.issue or --issue N
54
+ # merge-gate: --verdict "APPROVED|CHANGES REQUESTED" --has-purpose-regression B --calibration-needs-human B --trust-needs-human B
55
+ # --base-conflict B --other-blocking B [--calibration-note T] [--table-cell-key K] [--base B] [--issue N]
56
+ # (every B is true|false; an empty or other value aborts the merge: the guard state must be bound)
57
+ # demilestone: --is-milestone true|false [--merge-state S] (8B passes MERGED; 9A omits it and the state is read from the PR)
58
+ # Env: FORGE_CONFIG (forge.yaml for review.max_filed_findings; default ./forge.yaml).
59
+ # Exit: 0 ok | 3 BLOCKED (REVIEW_RESULT: status: BLOCKED printed; the caller must stop the review, never skip) | 4 merge-gate only:
60
+ # REVIEW_RESULT: status: PHASE_TRAIL_FAILED printed, followed by the verifier's MISSING lines (re-dispatch is owned by work-on/review.md R4)
61
+ # | 1 file-verify found no issue number | 2 usage error
62
+ # merge-gate reports every non-merging routed outcome as MERGE_OUTCOME=<merged|merge-failed|parked-awaiting-merge|base-conflict|needs-human|
63
+ # protection-blocked|stale-review|ci-not-green>; everything except merged / merge-failed / parked-awaiting-merge also exits 3 with a blocker.
64
+ #
65
+ # Stdout of each subcommand is KEY=value lines (plus REVIEW_RESULT on BLOCKED). Bash 3.2 portable (no mapfile, associative arrays, grep -P).
66
+
67
+ set -u
68
+
69
+ SUB="${1:-}"
70
+ case "$SUB" in
71
+ extract|classify|file-prepare|file-verify|verdict|calibrate|merge-gate|demilestone|integrity) shift ;;
72
+ *) echo "usage: review-finalize.sh extract|classify|file-prepare|file-verify|verdict|calibrate|merge-gate|demilestone|integrity --pr N --repo owner/name --context FILE [options]" >&2; exit 2 ;;
73
+ esac
74
+
75
+ PR_NUMBER=""; REPO=""; CONTEXT=""; OUT_DIR=""; IN_FILE=""; OUT_FILE=""; AUTO_MERGE="false"; NOTES_FIXED=0; NOTES_DROPPED=0; PATTERNS=""; DRY_RUN="false"; SCOPE_FILES=""; PRIOR_PRS=""; BUNDLE="false"
76
+ REVIEW_SHA_OPT=""; REVIEW_MODE_OPT=""; BASE_OPT=""; HEAD_BRANCH=""; ISSUE_OPT=""; VERDICT=""; HAS_PURPOSE_REGRESSION=""; CALIBRATION_NEEDS_HUMAN=""; CALIBRATION_NOTE=""; TRUST_NEEDS_HUMAN=""
77
+ BASE_CONFLICT=""; OTHER_BLOCKING=""; INTENSITY_TIER=""; TABLE_CELL_KEY=""; TRUST_SHADOW_MODE=""; MERGE_HEALTH=""; MERGE_HEALTH_STATE=""; RESULT=""
78
+ AGENTS_TEXT=""; FINDINGS_COUNT=""; BLOCKING_COUNT=""; PURPOSE_FINDINGS=""; IS_MILESTONE="false"; MERGE_STATE_IN=""
79
+ while [ $# -gt 0 ]; do
80
+ case "$1" in
81
+ --pr|--repo|--context|--out-dir|--findings|--out|--auto-merge|--notes-fixed|--notes-dropped|--patterns|--base|--head-branch|--issue|--verdict|--has-purpose-regression|--calibration-needs-human|--calibration-note|--trust-needs-human|--base-conflict|--other-blocking|--intensity-tier|--table-cell-key|--trust-shadow-mode|--merge-health|--merge-health-state|--result|--agents|--findings-count|--blocking-count|--purpose-findings|--is-milestone|--merge-state|--review-sha|--review-mode|--scope-files|--prior-prs)
82
+ [ $# -ge 2 ] || { echo "review-finalize: $1 requires a value" >&2; exit 2; } ;;
83
+ esac
84
+ case "$1" in
85
+ --pr) PR_NUMBER="${2:-}"; shift 2 ;;
86
+ --repo) REPO="${2:-}"; shift 2 ;;
87
+ --context) CONTEXT="${2:-}"; shift 2 ;;
88
+ --out-dir) OUT_DIR="${2:-}"; shift 2 ;;
89
+ --findings) IN_FILE="${2:-}"; shift 2 ;;
90
+ --out) OUT_FILE="${2:-}"; shift 2 ;;
91
+ --auto-merge) AUTO_MERGE="${2:-false}"; shift 2 ;;
92
+ --notes-fixed) NOTES_FIXED="${2:-0}"; shift 2 ;;
93
+ --notes-dropped) NOTES_DROPPED="${2:-0}"; shift 2 ;;
94
+ --patterns) PATTERNS="${2:-}"; shift 2 ;;
95
+ --review-sha) REVIEW_SHA_OPT="${2:-}"; shift 2 ;;
96
+ --review-mode) REVIEW_MODE_OPT="${2:-}"; shift 2 ;;
97
+ --base) BASE_OPT="${2:-}"; shift 2 ;;
98
+ --head-branch) HEAD_BRANCH="${2:-}"; shift 2 ;;
99
+ --issue) ISSUE_OPT="${2:-}"; shift 2 ;;
100
+ --verdict) VERDICT="${2:-}"; shift 2 ;;
101
+ --has-purpose-regression) HAS_PURPOSE_REGRESSION="${2:-}"; shift 2 ;;
102
+ --calibration-needs-human) CALIBRATION_NEEDS_HUMAN="${2:-}"; shift 2 ;;
103
+ --calibration-note) CALIBRATION_NOTE="${2:-}"; shift 2 ;;
104
+ --trust-needs-human) TRUST_NEEDS_HUMAN="${2:-}"; shift 2 ;;
105
+ --base-conflict) BASE_CONFLICT="${2:-}"; shift 2 ;;
106
+ --other-blocking) OTHER_BLOCKING="${2:-}"; shift 2 ;;
107
+ --intensity-tier) INTENSITY_TIER="${2:-}"; shift 2 ;;
108
+ --table-cell-key) TABLE_CELL_KEY="${2:-}"; shift 2 ;;
109
+ --trust-shadow-mode) TRUST_SHADOW_MODE="${2:-}"; shift 2 ;;
110
+ --merge-health) MERGE_HEALTH="${2:-}"; shift 2 ;;
111
+ --merge-health-state) MERGE_HEALTH_STATE="${2:-}"; shift 2 ;;
112
+ --result) RESULT="${2:-}"; shift 2 ;;
113
+ --agents) AGENTS_TEXT="${2:-}"; shift 2 ;;
114
+ --findings-count) FINDINGS_COUNT="${2:-}"; shift 2 ;;
115
+ --blocking-count) BLOCKING_COUNT="${2:-}"; shift 2 ;;
116
+ --purpose-findings) PURPOSE_FINDINGS="${2:-}"; shift 2 ;;
117
+ --is-milestone) IS_MILESTONE="${2:-false}"; shift 2 ;;
118
+ --merge-state) MERGE_STATE_IN="${2:-}"; shift 2 ;;
119
+ --scope-files) SCOPE_FILES="${2:-}"; shift 2 ;;
120
+ --prior-prs) PRIOR_PRS="${2:-}"; BUNDLE="true"; shift 2 ;;
121
+ --dry-run) DRY_RUN="true"; shift ;;
122
+ *) echo "review-finalize: unknown argument: $1" >&2; exit 2 ;;
123
+ esac
124
+ done
125
+
126
+ # --- file-verify needs nothing else: it only parses the issue-creation result text on stdin ---
127
+ if [ "$SUB" = "file-verify" ]; then
128
+ ISSUE_SKILL_OUTPUT=$(cat)
129
+ # The creation step succeeds only after its API create-token read-back; its explicit result marker distinguishes a verified create
130
+ # from an intentional dedup STOP. Never use title search to mask a swallowed 403.
131
+ ISSUE_NUM=$(printf '%s\n' "$ISSUE_SKILL_OUTPUT" | sed -n 's/.*ISSUE_CREATE_RESULT:CREATED number=\([0-9][0-9]*\).*/\1/p' | head -1)
132
+ DEDUP_NUMBER=$(printf '%s\n' "$ISSUE_SKILL_OUTPUT" | sed -n 's/.*ISSUE_CREATE_RESULT:DEDUP number=\([0-9][0-9]*\).*/\1/p' | head -1)
133
+ if [ -n "$DEDUP_NUMBER" ]; then
134
+ echo "Review finding deduped against existing issue #${DEDUP_NUMBER}."
135
+ echo "ISSUE_NUM=${DEDUP_NUMBER}"; echo "DEDUPED=true"; exit 0
136
+ elif [ -z "$ISSUE_NUM" ]; then
137
+ echo "ERROR: /issue did not report a verified review-finding number; stopping review instead of silently dropping the finding." >&2
138
+ exit 1
139
+ fi
140
+ echo "ISSUE_NUM=${ISSUE_NUM}"; echo "DEDUPED=false"; exit 0
141
+ fi
142
+
143
+ case "$PR_NUMBER" in ''|*[!0-9]*) echo "review-finalize: --pr must be numeric" >&2; exit 2 ;; esac
144
+ case "$REPO" in */*) ;; *) echo "review-finalize: --repo must be owner/name" >&2; exit 2 ;; esac
145
+ case "$REPO" in *[!A-Za-z0-9._/-]*|*/*/*|/*|*/) echo "review-finalize: invalid --repo" >&2; exit 2 ;; esac
146
+ # Phase 6 subcommands need the review-prepare context; the Phase 7-9 subcommands take it when given and otherwise run from flags
147
+ # (--review-sha, --review-mode, --issue, --base), so their spec fences stand alone.
148
+ case "$SUB" in
149
+ extract|classify|file-prepare) [ -n "$CONTEXT" ] || { echo "review-finalize: --context FILE is required" >&2; exit 2; } ;;
150
+ esac
151
+ case "$NOTES_FIXED" in ''|*[!0-9]*) NOTES_FIXED=0 ;; esac
152
+ case "$NOTES_DROPPED" in ''|*[!0-9]*) NOTES_DROPPED=0 ;; esac
153
+ [ "$AUTO_MERGE" = "true" ] || AUTO_MERGE="false"
154
+ [ "$IS_MILESTONE" = "true" ] || IS_MILESTONE="false"
155
+ case "$RESULT" in ''|stale|clean|blocking) ;; *) echo "review-finalize: --result must be stale|clean|blocking" >&2; exit 2 ;; esac
156
+ case "$FINDINGS_COUNT" in *[!0-9]*) FINDINGS_COUNT="" ;; esac
157
+ case "$BLOCKING_COUNT" in *[!0-9]*) BLOCKING_COUNT="" ;; esac
158
+ command -v jq >/dev/null 2>&1 || { echo "review-finalize: jq is required" >&2; exit 2; }
159
+
160
+ # --- trusted helper resolution (install roots only; never cwd) ---
161
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; _SELF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P)"; 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/* "$_SELF_ROOT"; 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() { :; }; }
162
+
163
+ # Every fail-closed path prints the spec's marker text byte-identically, then exits 3 so the caller cannot skip it.
164
+ blocked() { echo "REVIEW_RESULT: status: BLOCKED, blocker: $1"; exit 3; }
165
+
166
+ # --- the review-prepare context (schema_version 1) ---
167
+ REVIEW_SHA="$REVIEW_SHA_OPT"; REVIEW_MODE="${REVIEW_MODE_OPT:-FULL}"; DELTA_FROM=""; MERGE_ISSUE=""; CTX_BASE_BRANCH=""
168
+ if [ -n "$CONTEXT" ]; then
169
+ [ -r "$CONTEXT" ] || blocked "review-prepare context unreadable: $CONTEXT"
170
+ jq -e . "$CONTEXT" >/dev/null 2>&1 || blocked "review-prepare context is not valid JSON"
171
+ CTX_SV=$(jq -r '.schema_version // empty' "$CONTEXT" 2>/dev/null)
172
+ [ "$CTX_SV" = "1" ] || blocked "review-prepare context schema_version ${CTX_SV:-missing} is not supported (expected 1)"
173
+ [ "$(jq -r '.status // empty' "$CONTEXT")" != "BLOCKED" ] || blocked "review-prepare context is BLOCKED: $(jq -r '(.blockers // []) | join("; ")' "$CONTEXT")"
174
+ REVIEW_SHA=$(jq -r '.pr.review_sha // empty' "$CONTEXT")
175
+ REVIEW_MODE=$(jq -r '.delta.mode // "FULL"' "$CONTEXT")
176
+ DELTA_FROM=$(jq -r '.delta.from // empty' "$CONTEXT")
177
+ # Never the context's contract.issue: review-prepare reads it from the PR body (author-controlled), and the fixing
178
+ # issue drives lineage damping, contract demotion and the cap. Only the invoker's --issue sets it (#3952 SEC-1/QA-2).
179
+ MERGE_ISSUE=""
180
+ CTX_BASE_BRANCH=$(jq -r '.pr.base_ref // empty' "$CONTEXT")
181
+ fi
182
+ # --issue is the invoker's own --issue (never PR-author text); a non-numeric value is ignored like the context's.
183
+ case "$ISSUE_OPT" in ''|*[!0-9]*) ;; *) MERGE_ISSUE="$ISSUE_OPT" ;; esac
184
+ # A staging bundle review (--prior-prs) has no single fixing issue, whatever was passed.
185
+ [ "$BUNDLE" != "true" ] || MERGE_ISSUE=""
186
+ case "$REVIEW_MODE" in FULL|DELTA|BASE_SYNC_ONLY) ;; *) REVIEW_MODE="FULL" ;; esac
187
+
188
+ [ -n "$OUT_DIR" ] || OUT_DIR="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-finalize-${PR_NUMBER}"
189
+ mkdir -p "$OUT_DIR" 2>/dev/null || { echo "review-finalize: cannot create $OUT_DIR" >&2; exit 2; }
190
+ WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/review-finalize.XXXXXX") || exit 2
191
+ trap 'rm -rf "$WORKDIR"' EXIT
192
+ _pscr="$WORKDIR"
193
+
194
+ is_sha() { case "${1}" in *[!0-9a-f]*|"") return 1 ;; esac; [ "${#1}" -eq 40 ] || [ "${#1}" -eq 64 ]; }
195
+ # Shared: validate the input findings JSON for classify and file-prepare.
196
+ need_findings() {
197
+ [ -r "$IN_FILE" ] && jq -e '.findings | type == "array"' "$IN_FILE" >/dev/null 2>&1 \
198
+ || { echo "review-finalize: findings JSON unreadable or malformed: $IN_FILE" >&2; exit 2; }
199
+ }
200
+ fj() { jq -r --argjson i "$I" ".findings[\$i]$1 // empty" "$IN_FILE" 2>/dev/null; } # one field of finding I
201
+ upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]'; }
202
+ sev_rank() { case "$(upper "$1")" in CRITICAL) echo 0 ;; HIGH) echo 1 ;; MEDIUM) echo 2 ;; LOW) echo 3 ;; *) echo 4 ;; esac; }
203
+
204
+ # ======================================================================================================================
205
+ # extract: 6A + 6B + 6B.4
206
+ # ======================================================================================================================
207
+ do_extract() {
208
+ OUT_FILE="${OUT_FILE:-$OUT_DIR/findings.json}"
209
+ case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
210
+ [ "$REVIEW_SHA_OK" = "true" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA is not a commit id (an empty SHA would count every head's findings)"; exit 3; }
211
+ # Only TRUSTED bodies for the CURRENT head count (BASE_SYNC_ONLY and DELTA add the carried / earlier reviewed head, see below) (scripts/trusted-comments.sh + anchored marker + Reviewed-SHA line):
212
+ # findings from earlier heads are already fixed or re-reviewed, and an untrusted commenter must never be able to inject
213
+ # findings or post a forged synthesis block that makes the review read clean (the head SHA is public).
214
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
215
+ REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)
216
+ BASE_BRANCH="$CTX_BASE_BRANCH"
217
+ [ -n "$BASE_BRANCH" ] || BASE_BRANCH=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
218
+ COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
219
+ if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then
220
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read clean (fail closed)"; exit 3
221
+ fi
222
+ _dm=$(forge_resolve lib/review-delta-mode.sh); [ -n "$_dm" ] && . "$_dm" || { review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; }
223
+ # Trusted synthesized block for one head when present, else that head's trusted agent bodies (the same predicate as before,
224
+ # now per head). Returns non-zero when the trust script cannot count or read.
225
+ bodies_at() {
226
+ local sha="${1}" synth agent has
227
+ synth="^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${sha}(\\r?\\n|$)"
228
+ agent="^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${sha}(\\r?\\n|$)"
229
+ has=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "$synth" 2>/dev/null || echo "")
230
+ case "$has" in ''|*[!0-9]*) return 1 ;; esac
231
+ if [ "$has" -gt 0 ]; then
232
+ printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$synth" 2>/dev/null || return 1
233
+ else
234
+ printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$agent" 2>/dev/null || return 1
235
+ fi
236
+ }
237
+ # Trusted raw agent bodies for one head (the carrier of each finding's agent name and prose, even when a synthesis block is the source).
238
+ agent_bodies_at() {
239
+ printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${1}(\\r?\\n|$)" 2>/dev/null || true
240
+ }
241
+ # FULL: the current head's bodies, exactly as before. BASE_SYNC_ONLY: the head has no reviewers, so the findings are the
242
+ # carried head's, and only a VERIFIED carry record (trusted, same head, resolver agrees) selects that SHA. DELTA: reviewers
243
+ # saw only the delta, so the earlier fully reviewed head's findings are read too (they must still reach the Phase 8 gates);
244
+ # the 6B.4 head-SHA and provenance gates then drop any whose cited line is gone from the current head.
245
+ PRIMARY_SHA="$REVIEW_SHA"; EARLIER_SHA=""
246
+ if [ "$REVIEW_MODE" = "BASE_SYNC_ONLY" ]; then
247
+ CARRIED_FROM=$(carry_verified_from); [ -n "$CARRIED_FROM" ] && PRIMARY_SHA="$CARRIED_FROM"
248
+ fi
249
+ if [ "$REVIEW_MODE" = "DELTA" ] && is_sha "$DELTA_FROM"; then EARLIER_SHA="$DELTA_FROM"; fi
250
+ SOURCE_BODIES=$(bodies_at "$PRIMARY_SHA") \
251
+ || { echo "REVIEW_RESULT: status: BLOCKED, blocker: trusted reviewer comments unreadable (fail closed)"; exit 3; }
252
+ # The panel guard guarantees at least one trusted reviewer body for this head; none means a misread, never "clean".
253
+ [ -n "$SOURCE_BODIES" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: no trusted reviewer comments for head ${PRIMARY_SHA} (fail closed)"; exit 3; }
254
+ CARRIER_BODIES=$(agent_bodies_at "$PRIMARY_SHA")
255
+ if [ -n "$EARLIER_SHA" ]; then
256
+ EARLIER_BODIES=$(bodies_at "$EARLIER_SHA") && [ -n "$EARLIER_BODIES" ] \
257
+ || { echo "REVIEW_RESULT: status: BLOCKED, blocker: earlier reviewed head ${EARLIER_SHA} findings unreadable (fail closed)"; exit 3; }
258
+ SOURCE_BODIES=$(printf '%s\n%s' "$SOURCE_BODIES" "$EARLIER_BODIES")
259
+ CARRIER_BODIES=$(printf '%s\n%s' "$CARRIER_BODIES" "$(agent_bodies_at "$EARLIER_SHA")")
260
+ fi
261
+ # Match to the closing ` -->`, never `[^>]+`: a `>` or `->` inside a title must not drop the finding (#3897).
262
+ FINDINGS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' 2>/dev/null)
263
+ # Fail closed when a raw marker did not parse: a lost finding must never read as a clean review.
264
+ N_RAW_MARKERS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r '[scan("<!-- FINDING:")] | length' 2>/dev/null | awk '{ s += $1 } END { print s + 0 }')
265
+ N_PARSED_MARKERS=$(printf '%s\n' "$FINDINGS" | grep -c . || true)
266
+ [ "$N_RAW_MARKERS" -eq "$N_PARSED_MARKERS" ] \
267
+ || { echo "REVIEW_RESULT: status: BLOCKED, blocker: ${N_RAW_MARKERS} FINDING markers but ${N_PARSED_MARKERS} parsed (malformed marker, fail closed)"; exit 3; }
268
+
269
+ # Carrier lookup order: raw agent bodies first (they carry the agent marker and the per-finding prose), then the source bodies.
270
+ printf '%s\n%s\n' "$CARRIER_BODIES" "$SOURCE_BODIES" | grep -v '^$' > "$WORKDIR/carriers.jsonl" || : > "$WORKDIR/carriers.jsonl"
271
+
272
+ RAW="$WORKDIR/raw.jsonl"; : > "$RAW"; N_EXTRACTED=0
273
+ while IFS='|' read -r F_ID F_CONF F_SEV F_LOC F_TITLE; do
274
+ [ -n "$F_ID" ] || continue
275
+ N_EXTRACTED=$((N_EXTRACTED + 1))
276
+ # file[:N[-M]] -> file, line start, line end (a non-numeric suffix stays part of the file name).
277
+ F_FILE="$F_LOC"; L1=""; L2=""
278
+ case "$F_LOC" in
279
+ *:[0-9]*) _r="${F_LOC##*:}"
280
+ case "$_r" in *[!0-9-]*|"") ;; *) F_FILE="${F_LOC%:*}"; L1="${_r%%-*}"; L2="${_r#*-}"; [ "$L2" != "$_r" ] || L2="$L1" ;; esac ;;
281
+ esac
282
+ case "$L1" in *[!0-9]*) L1=""; L2="" ;; esac; case "$L2" in *[!0-9]*) L2="$L1" ;; esac
283
+ # The carrying comment: the first trusted body that holds this finding's marker.
284
+ BODY_FILE="$WORKDIR/body.$N_EXTRACTED"
285
+ jq -rs --arg m "<!-- FINDING:${F_ID}|" 'map(select(contains($m))) | .[0] // ""' "$WORKDIR/carriers.jsonl" > "$BODY_FILE" 2>/dev/null || : > "$BODY_FILE"
286
+ AGENT=$(sed -n 's/^<!-- FORGE:REVIEW-AGENT:\([a-z-]*\) -->.*/\1/p' "$BODY_FILE" | head -n 1)
287
+ # The finding's own prose section: from the first non-marker line naming the id to the next heading / marker / other finding id.
288
+ SECTION_FILE="$WORKDIR/section.$N_EXTRACTED"
289
+ awk -v id="$F_ID" '
290
+ function hasid(l, p, pre, post, rest, off) {
291
+ rest = l; off = 0
292
+ while ((p = index(rest, id)) > 0) {
293
+ pre = (off + p > 1) ? substr(l, off + p - 1, 1) : ""
294
+ post = substr(rest, p + length(id), 1)
295
+ if (pre !~ /[A-Za-z0-9-]/ && post !~ /[0-9A-Za-z]/) return 1
296
+ off += p; rest = substr(rest, p + 1)
297
+ }
298
+ return 0
299
+ }
300
+ /<!-- (FINDING:|REVIEW-FINDINGS)/ { if (started) exit; next }
301
+ !started { if (hasid($0)) { started = 1; print; first = 0; n = 1 } ; next }
302
+ { n++
303
+ if ($0 ~ /^#/) exit
304
+ if (match($0, /^[-*#> \t]*\**[A-Z][A-Z0-9]*-[0-9]+/) && !hasid($0)) exit
305
+ print }' "$BODY_FILE" > "$SECTION_FILE" 2>/dev/null || : > "$SECTION_FILE"
306
+ # Failure scenario: present / absent when the section was found, unknown when the carrying comment or section is unreadable.
307
+ SCENARIO="unknown"
308
+ if [ -s "$SECTION_FILE" ]; then
309
+ SCENARIO=$(awk '
310
+ tolower($0) ~ /\*\*failure scenario\*\*/ { s = $0; sub(/^.*\*\*[Ff]ailure [Ss]cenario\*\*[*:[:space:]]*/, "", s); gsub(/[[:space:]]/, "", s)
311
+ if (s != "") { print "present"; found = 1; exit } else { want = 1; next } }
312
+ want { t = $0; gsub(/[[:space:]]/, "", t); if (t != "") { print "present"; found = 1; exit } }
313
+ END { if (!found) print "absent" }' "$SECTION_FILE")
314
+ fi
315
+ FIX_EFF=$(grep -iE '^[[:space:]]*(\*\*Fix efficacy\*\*:?|FIX_EFFICACY:)[[:space:]]*\**[[:space:]]*(fails|regresses)' "$SECTION_FILE" 2>/dev/null | head -n 1 \
316
+ | tr '[:upper:]' '[:lower:]' | sed -E 's/.*(fails|regresses).*/\1/')
317
+ TEXT_FILE="$WORKDIR/text.$N_EXTRACTED"
318
+ { printf '%s\n%s\n' "$F_TITLE" "$F_FILE"; cat "$SECTION_FILE" 2>/dev/null; } > "$TEXT_FILE"
319
+ jq -n -c --arg id "$F_ID" --arg c "$F_CONF" --arg s "$F_SEV" --arg loc "$F_LOC" --arg file "$F_FILE" --arg l1 "$L1" --arg l2 "$L2" \
320
+ --arg title "$F_TITLE" --arg agent "$AGENT" --arg scen "$SCENARIO" --arg fe "$FIX_EFF" --rawfile text "$TEXT_FILE" \
321
+ '{id: $id, confidence: $c, severity: $s, loc: $loc, file: $file, line_start: ($l1 | tonumber? // null), line_end: ($l2 | tonumber? // null),
322
+ title: $title, agent: $agent, scenario: $scen, fix_efficacy: $fe, text: $text}' >> "$RAW"
323
+ done <<EOF
324
+ $FINDINGS
325
+ EOF
326
+
327
+ # 6B: dedup by file + line range +-5 (keep the higher confidence) and by same file + 3 shared title keywords; CONFIRMED first, then
328
+ # LIKELY, then POSSIBLE, within a group by severity. Sorting first means a kept finding is always at least as confident as a dropped one.
329
+ DEDUPED="$WORKDIR/deduped.json"
330
+ jq -s '
331
+ def up: (. // "") | ascii_upcase | gsub("\\s"; "");
332
+ def cr: (.confidence | up) as $c | if $c == "CONFIRMED" then 0 elif $c == "LIKELY" then 1 elif $c == "POSSIBLE" then 2 else 3 end;
333
+ def sr: (.severity | up) as $s | if $s == "CRITICAL" then 0 elif $s == "HIGH" then 1 elif $s == "MEDIUM" then 2 elif $s == "LOW" then 3 else 4 end;
334
+ def kw: (.title | ascii_downcase | [scan("[a-z0-9_]+")] | map(select(length > 3)) | unique);
335
+ def shared($a; $b): [$a[] | select(. as $w | any($b[]; . == $w))] | length;
336
+ to_entries | map(.value + {_i: .key}) | sort_by([cr, sr, ._i])
337
+ | reduce .[] as $f ([];
338
+ if any(.[]; . as $k | $k.file == $f.file and (
339
+ (($f.line_start != null and $k.line_start != null) and ((($k.line_start - $f.line_start) | if . < 0 then -. else . end) <= 5))
340
+ or (shared($f | kw; $k | kw) >= 3)))
341
+ then . else . + [$f] end)
342
+ | map(del(._i))' "$RAW" > "$DEDUPED" 2>/dev/null || { echo "[]" > "$DEDUPED"; }
343
+ N_DEDUPED=$(jq 'length' "$DEDUPED")
344
+
345
+ # 6B.4: provenance and concreteness gates (head-SHA, introduced-line, concrete-failure), in this order. Every gate fails toward keeping.
346
+ # Paths come from the files API (raw names), never from `+++` header lines: those are quoted/tab-suffixed for special names.
347
+ FILES_JSON="$WORKDIR/pr-files.json"
348
+ gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate > "$FILES_JSON" 2>/dev/null || : > "$FILES_JSON"
349
+ # "path:line" for every added line; an empty file means the diff was unreadable (gates then keep every finding).
350
+ ADDED_LINES_FILE="$WORKDIR/added-lines"
351
+ jq -r '.[] | select(.patch != null) | "FILE\t\(.filename)", .patch' "$FILES_JSON" 2>/dev/null | awk '
352
+ /^FILE\t/{f=substr($(0),6);next}
353
+ /^@@/{s=$(0);sub(/^@@ -[0-9,]+ \+/,"",s);sub(/[ ,].*/,"",s);n=s+0;next}
354
+ /^\\/{next}
355
+ /^\+/{print f":"n;n++;next}
356
+ /^-/{next}
357
+ {n++}' > "$ADDED_LINES_FILE" || : > "$ADDED_LINES_FILE"
358
+ # Files the PR deleted or renamed (the old name too): findings on them always intersect.
359
+ GONE_FILES_FILE="$WORKDIR/gone-files"
360
+ jq -r '.[] | select(.status=="removed" or .status=="renamed") | .filename, (.previous_filename // empty)' "$FILES_JSON" > "$GONE_FILES_FILE" 2>/dev/null || : > "$GONE_FILES_FILE"
361
+ # Files present with no `patch` (large/truncated diff): their added lines are UNKNOWN, so findings on them are always kept.
362
+ NOPATCH_FILES_FILE="$WORKDIR/nopatch-files"
363
+ jq -r '.[] | select(.patch == null and .status != "removed") | .filename' "$FILES_JSON" > "$NOPATCH_FILES_FILE" 2>/dev/null || : > "$NOPATCH_FILES_FILE"
364
+ PR_NAMES_FILE="$WORKDIR/pr-names"
365
+ jq -r '.[] | .filename' "$FILES_JSON" > "$PR_NAMES_FILE" 2>/dev/null || : > "$PR_NAMES_FILE"
366
+
367
+ # Line count of FILE at REVIEW_SHA: a number, ABSENT (the commit is local and the path is not in it) or UNKNOWN (a failed probe is never 0).
368
+ file_lines_at_sha() {
369
+ local f="$1" t="$WORKDIR/at-sha"
370
+ if git rev-parse -q --verify "${REVIEW_SHA}^{commit}" >/dev/null 2>&1; then
371
+ if git show "${REVIEW_SHA}:${f}" > "$t" 2>/dev/null; then awk 'END{print NR}' "$t"; return 0; fi
372
+ if git cat-file -e "${REVIEW_SHA}:${f}" 2>/dev/null; then echo UNKNOWN; else echo ABSENT; fi
373
+ return 0
374
+ fi
375
+ case "$f" in *[!A-Za-z0-9._/@+-]*|"") echo UNKNOWN; return 0 ;; esac
376
+ if gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${REVIEW_SHA}" > "$t" 2>/dev/null; then awk 'END{print NR}' "$t"; else echo UNKNOWN; fi
377
+ }
378
+
379
+ FINAL="$WORKDIR/final.jsonl"; : > "$FINAL"
380
+ N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_KEPT=0
381
+ I=0; TOTAL=$(jq 'length' "$DEDUPED")
382
+ while [ "$I" -lt "$TOTAL" ]; do
383
+ F=$(jq -c ".[$I]" "$DEDUPED"); I=$((I + 1))
384
+ FILE=$(printf '%s' "$F" | jq -r '.file'); SEV=$(upper "$(printf '%s' "$F" | jq -r '.severity')")
385
+ LINE=$(printf '%s' "$F" | jq -r '.line_start // empty'); SCEN=$(printf '%s' "$F" | jq -r '.scenario')
386
+ ROUTE="file"
387
+ # Validate before any arithmetic: never expand reviewer text in $(( )).
388
+ case "$LINE" in ""|*[!0-9]*) LINE="" ;; esac
389
+ GONE=0; NOPATCH=0
390
+ grep -qxF -- "$FILE" "$GONE_FILES_FILE" && GONE=1
391
+ grep -qxF -- "$FILE" "$NOPATCH_FILES_FILE" && NOPATCH=1
392
+ # Gate 1 (head-SHA): the cited line must exist at REVIEW_SHA. A file the PR deleted or renamed is kept (checked first).
393
+ if [ "$GONE" -eq 0 ] && [ -n "$LINE" ]; then
394
+ AT=$(file_lines_at_sha "$FILE")
395
+ STALE=0
396
+ case "$AT" in
397
+ UNKNOWN|"") ;;
398
+ ABSENT) if [ -s "$PR_NAMES_FILE" ] && ! grep -qxF -- "$FILE" "$PR_NAMES_FILE"; then STALE=1; fi ;;
399
+ *[!0-9]*) ;;
400
+ *) [ "$LINE" -gt "$AT" ] && STALE=1 ;;
401
+ esac
402
+ if [ "$STALE" -eq 1 ]; then N_STALE=$((N_STALE + 1)); continue; fi
403
+ fi
404
+ # Gate 2 (introduced-line): MEDIUM or lower with no added line within +-5 is pre-existing. CRITICAL/HIGH and unknown severities never are.
405
+ if [ -n "$LINE" ] && [ "$GONE" -eq 0 ] && [ "$NOPATCH" -eq 0 ] && [ -s "$ADDED_LINES_FILE" ]; then
406
+ case "$SEV" in
407
+ MEDIUM|LOW|INFO)
408
+ LO=$((LINE > 5 ? LINE - 5 : 1)); INTRODUCED=0; L="$LO"
409
+ while [ "$L" -le $((LINE + 5)) ]; do
410
+ if grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE"; then INTRODUCED=1; break; fi
411
+ L=$((L + 1))
412
+ done
413
+ [ "$INTRODUCED" -eq 1 ] || ROUTE="pre-existing" ;;
414
+ esac
415
+ fi
416
+ # Gate 3 (concrete failure): MEDIUM or lower without a **Failure scenario** is speculative hardening, demoted to a NOTE.
417
+ # CRITICAL/HIGH are exempt; an unreadable carrying comment keeps the finding.
418
+ if [ "$ROUTE" = "file" ] && [ "$SCEN" = "absent" ]; then
419
+ case "$SEV" in MEDIUM|LOW|INFO) ROUTE="demoted-no-scenario" ;; esac
420
+ fi
421
+ case "$ROUTE" in pre-existing) N_PREEXISTING=$((N_PREEXISTING + 1)) ;; demoted-no-scenario) N_NO_SCENARIO=$((N_NO_SCENARIO + 1)) ;; esac
422
+ N_KEPT=$((N_KEPT + 1))
423
+ printf '%s' "$F" | jq -c --arg r "$ROUTE" '. + {route: $r}' >> "$FINAL"
424
+ done
425
+
426
+ jq -s --arg sha "$REVIEW_SHA" --arg mode "$REVIEW_MODE" --arg mi "$MERGE_ISSUE" \
427
+ --argjson ex "$N_EXTRACTED" --argjson dd "$N_DEDUPED" --argjson st "$N_STALE" --argjson pe "$N_PREEXISTING" --argjson ns "$N_NO_SCENARIO" \
428
+ '{schema_version: 1, review_sha: $sha, review_mode: $mode, merge_issue: ($mi | tonumber? // null),
429
+ counts: {findings_extracted: $ex, findings_deduped: $dd, findings_dropped_stale: $st, findings_preexisting: $pe, notes_demoted_no_scenario: $ns},
430
+ findings: .}' "$FINAL" > "$OUT_FILE" || { echo "review-finalize: cannot write $OUT_FILE" >&2; exit 2; }
431
+ echo "FINDINGS_OUT=$OUT_FILE"
432
+ echo "STRUCTURED_FINDINGS=$N_EXTRACTED"
433
+ echo "FINDINGS_KEPT=$N_KEPT"
434
+ echo "FINDINGS_DROPPED_STALE=$N_STALE"
435
+ echo "FINDINGS_PREEXISTING=$N_PREEXISTING"
436
+ echo "NOTES_DEMOTED_NO_SCENARIO=$N_NO_SCENARIO"
437
+ [ "$N_EXTRACTED" -gt 0 ] || echo "CLEAN_STRUCTURED=true" # 0 structured findings: the caller scans unstructured text, then skips to Phase 7 if still 0
438
+ exit 0
439
+ }
440
+
441
+ # ======================================================================================================================
442
+ # classify: 6B.5 + 6B.6
443
+ # ======================================================================================================================
444
+ do_classify() {
445
+ IN_FILE="${IN_FILE:-$OUT_DIR/findings.json}"; OUT_FILE="${OUT_FILE:-$OUT_DIR/classified.json}"
446
+ need_findings
447
+ case "$REVIEW_SHA" in *[!0-9a-f]*|"") echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA is not a commit id"; exit 3 ;; esac
448
+ CLASSIFY_SCRIPT=$(forge_resolve classify-finding.sh)
449
+ # Fail closed (#3639): no hand classification. An unresolvable classifier ends the review here.
450
+ if [ -z "$CLASSIFY_SCRIPT" ]; then
451
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: classifier unavailable (classify-finding.sh not resolvable through the trusted resolver)"
452
+ exit 3
453
+ fi
454
+ export FORGE_REPO="$REPO" FORGE_PR="$PR_NUMBER" FORGE_ISSUE="${MERGE_ISSUE:-}" # forge#3834: classifier telemetry context
455
+ # Per-review cap (#3642): same trusted resolver, same fail-closed rule. An unresolvable cap helper would file unbounded, so it ends the review.
456
+ CAP_SCRIPT=$(forge_resolve cap-findings.sh)
457
+ if [ -z "$CAP_SCRIPT" ]; then
458
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: cap helper unavailable (cap-findings.sh not resolvable through the trusted resolver)"
459
+ exit 3
460
+ fi
461
+ # forge.yaml review.max_filed_findings (optional; absent = the helper default: 2, or 1 on review-finding lineage). Never validate here: the helper falls back on an invalid value.
462
+ MAX_FILED_FINDINGS=$(yq '.review.max_filed_findings // ""' "${FORGE_CONFIG:-forge.yaml}" 2>/dev/null || echo '')
463
+ FINDING_LINEAGE="none"
464
+ if [ -n "${MERGE_ISSUE:-}" ] && gh issue view "$MERGE_ISSUE" -R "$REPO" --json labels --jq '.labels[].name' 2>/dev/null | grep -qx 'review-finding'; then
465
+ FINDING_LINEAGE="review-finding"
466
+ fi
467
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
468
+ # In-PR fix gate (6B.6): only for an auto-merge review with a linked issue, and only for the first
469
+ # fix round on this PR. Any trusted FORGE:INPR_FIX* marker already on the PR means the round is used.
470
+ INPR_DIFF_FILE=""
471
+ if [ "${AUTO_MERGE:-false}" = "true" ] && [ -n "${MERGE_ISSUE:-}" ]; then
472
+ if [ -n "$TRUSTED_SCRIPT" ]; then INPR_MARKERS=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
473
+ | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:INPR_FIX' 2>/dev/null || echo ""); else INPR_MARKERS=""; fi
474
+ if [ "$INPR_MARKERS" = "0" ]; then
475
+ INPR_DIFF_FILE="$WORKDIR/inpr-diff"
476
+ gh pr diff "$PR_NUMBER" -R "$REPO" --name-only > "$INPR_DIFF_FILE" 2>/dev/null || { rm -f "$INPR_DIFF_FILE"; INPR_DIFF_FILE=""; }
477
+ fi
478
+ # An unreadable marker list or diff leaves INPR_DIFF_FILE empty: findings are filed as before (never gated blind).
479
+ fi
480
+ # Contract-declared scope (#3447; rules in the 6B.5 bullet): `deferred -> #N` (follow-up open) and `accepted-risk:` paths become NOTEs, `not-affected`
481
+ # never demotes, and any missing input or unverifiable deferred row leaves nothing demoted (fail toward filing).
482
+ CONTRACT_SCOPE_FILE=""; CONTRACT_OPEN_FILE=""; PR_FILES_FILE=""; CONTRACT_DEMOTED=0
483
+ if [ -n "${MERGE_ISSUE:-}" ]; then
484
+ SCOPE_SCRIPT=""
485
+ [ -n "$TRUSTED_SCRIPT" ] && [ -f "$(dirname "$TRUSTED_SCRIPT")/check-contract-scope.sh" ] && SCOPE_SCRIPT="$(dirname "$TRUSTED_SCRIPT")/check-contract-scope.sh"
486
+ if [ -n "$SCOPE_SCRIPT" ]; then
487
+ _scratch="$WORKDIR"
488
+ CONTRACT_BODY_FILE="$_scratch/contract"; CONTRACT_SCOPE_FILE="$_scratch/contract-scope"; CONTRACT_OPEN_FILE="$_scratch/contract-open"; PR_FILES_FILE="$_scratch/pr-files"
489
+ : > "$CONTRACT_BODY_FILE"; : > "$CONTRACT_SCOPE_FILE"; : > "$CONTRACT_OPEN_FILE"; : > "$PR_FILES_FILE"
490
+ # Only a trusted author's contract counts (an untrusted comment must not suppress findings); the LAST one wins.
491
+ gh api --paginate "repos/${REPO}/issues/${MERGE_ISSUE}/comments" 2>/dev/null \
492
+ | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:CONTRACT -->' 2>/dev/null | tail -n 1 | jq -r . > "$CONTRACT_BODY_FILE" 2>/dev/null
493
+ if [ -s "$CONTRACT_BODY_FILE" ] \
494
+ && bash "$SCOPE_SCRIPT" list "$CONTRACT_BODY_FILE" > "$CONTRACT_SCOPE_FILE" 2>/dev/null \
495
+ && [ -s "$CONTRACT_SCOPE_FILE" ] \
496
+ && gh pr diff "$PR_NUMBER" -R "$REPO" --name-only > "$PR_FILES_FILE" 2>/dev/null; then
497
+ # A deferred row counts only if issue N is OPEN, is an issue (not a PR), is not MERGE_ISSUE, and its body
498
+ # carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus the row's path, and is authored by a trusted author
499
+ # (OWNER/MEMBER/COLLABORATOR or Bot; same predicate as trusted-comments.sh; missing fields fail closed).
500
+ # Unverified rows are dropped.
501
+ _verified="$_scratch/contract-verified"; : > "$_verified"
502
+ while IFS="$(printf '\t')" read -r _disp _ipath _n; do
503
+ if [ "$_disp" = "deferred" ]; then
504
+ case "$_n" in ''|*[!0-9]*) continue ;; esac
505
+ [ "$_n" = "${MERGE_ISSUE#\#}" ] && continue
506
+ gh api "repos/${REPO}/issues/$_n" 2>/dev/null \
507
+ | jq -e --arg m "FORGE:DEFERRED_FROM: #${MERGE_ISSUE#\#} -->" --arg p "$_ipath" \
508
+ '.state == "open" and .pull_request == null and ((.body // "") | contains($m) and contains($p)) and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))' >/dev/null 2>&1 \
509
+ || continue
510
+ grep -Fxq -- "$_n" "$CONTRACT_OPEN_FILE" || echo "$_n" >> "$CONTRACT_OPEN_FILE"
511
+ fi
512
+ printf '%s\t%s\t%s\n' "$_disp" "$_ipath" "$_n" >> "$_verified"
513
+ done < "$CONTRACT_SCOPE_FILE"
514
+ mv "$_verified" "$CONTRACT_SCOPE_FILE"
515
+ else
516
+ rm -f "$CONTRACT_SCOPE_FILE" "$CONTRACT_OPEN_FILE" "$PR_FILES_FILE"; CONTRACT_SCOPE_FILE=""; CONTRACT_OPEN_FILE=""; PR_FILES_FILE=""
517
+ fi
518
+ rm -f "$CONTRACT_BODY_FILE"
519
+ fi
520
+ fi
521
+ PRIOR_FILE=""; DIFF_MAP_FILE=""; NOTES_REFLAG=0; OUT_OF_SCOPE=0
522
+ # Cross-head re-flag input (#3641). Fail soft: an unreadable source only shrinks the TSV, never blocks the review.
523
+ # 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
524
+ # (disposition: filed = linked issue OPEN, filed-closed = CLOSED, note = recorded NOTE_DISPOSITION with no issue, unknown = reviewer-body finding whose linkage failed, never demotes a MEDIUM CONFIRMED/LIKELY re-flag).
525
+ # Every comment is read through scripts/trusted-comments.sh. With PF_SKIP set, that head's reviewer bodies are skipped and only NOTE_DISPOSITION records with a different `reviewed_sha=` count.
526
+ 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)
527
+ local _loc="$PF_LOC" _file _r _a="" _b=""; _file="$_loc"
528
+ case "$_loc" in *:[0-9]*) _r="${_loc##*:}"; case "$_r" in *[!0-9-]*|"") ;; *) _file="${_loc%:*}"; _a="${_r%%-*}"; _b="${_r#*-}"; [ "$_b" != "$_r" ] || _b="$_a" ;; esac ;; esac
529
+ printf '%s\t%s\t%s\n' "$_file" "${_a:--}" "${_b:--}"
530
+ }
531
+ prior_findings_tsv() {
532
+ local pr cj rows issues _id _c _s _loc _t _p _hit _f _a _b _src _dflt
533
+ [ -n "${TRUSTED_SCRIPT:-}" ] || return 0
534
+ rows=$(mktemp "${_pscr}/prior-rows.XXXXXX"); issues=$(mktemp "${_pscr}/prior-issues.XXXXXX")
535
+ for pr in $PF_PRS; do
536
+ case "$pr" in ''|*[!0-9]*) continue ;; esac
537
+ cj=$(gh api --paginate "repos/${REPO}/issues/${pr}/comments" 2>/dev/null) || continue
538
+ [ -n "$cj" ] || continue
539
+ 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 \
540
+ | jq -r --arg skip "${PF_SKIP:-}" 'select($skip == "" or (test("(^|\\n)Reviewed-SHA: " + $skip + "\\r?(\\n|$)") | not)) | scan("<!-- FINDING:(.+?) -->") | .[0]' 2>/dev/null \
541
+ | while IFS='|' read -r _id _c _s _loc _t; do
542
+ [ -n "$_t" ] || continue
543
+ _p=$(PF_LOC="$_loc" pf_split_loc); printf 'F\t%s\t%s\n' "$_p" "$(printf '%s' "$_t" | tr '\t' ' ')"
544
+ done >> "$rows"
545
+ printf '%s' "$cj" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:NOTE_DISPOSITION' 2>/dev/null \
546
+ | 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 \
547
+ | sed -n 's/^- [^ ]* \([^ ]*\) \[[A-Za-z?]*\] \(.*\): [^:]*$/\1\t\2/p' \
548
+ | while IFS="$(printf '\t')" read -r _loc _t; do
549
+ [ "$_loc" != "?" ] || continue
550
+ _p=$(PF_LOC="$_loc" pf_split_loc); printf 'N\t%s\t%s\n' "$_p" "$_t"
551
+ done >> "$rows"
552
+ gh issue list -R "$REPO" --label review-finding --state all --search "${pr} in:body" --limit 100 --json number,state,title,body 2>/dev/null \
553
+ | jq -c '.[]' >> "$issues" 2>/dev/null || true
554
+ done
555
+ # Linkage: a filed issue whose body names the file and whose title shares >= 3 tokens with the finding (open preferred).
556
+ # Rows carry a leading source column (F = reviewer FINDING marker, N = NOTE_DISPOSITION record), stripped on output. An unlinked F row may
557
+ # have been filed under a reworded title, so it is `unknown` (never demotes MEDIUM CONFIRMED/LIKELY); an unlinked N row is a true `note`.
558
+ sort -u "$rows" | while IFS="$(printf '\t')" read -r _src _f _a _b _t; do
559
+ [ -n "$_f" ] || continue
560
+ [ "$_a" != "-" ] || _a=""; [ "$_b" != "-" ] || _b=""
561
+ case "$_src" in N) _dflt=note ;; *) _dflt=unknown ;; esac
562
+ _hit=$(jq -rs --arg f "$_f" --arg t "$_t" 'def toks: ascii_downcase | [scan("[a-z0-9]{3,}")] | unique;
563
+ ($t | toks) as $a | [ .[] | select((.body // "") | contains($f))
564
+ | select((((.title // "") | toks) as $b | [$a[] | select(. as $x | $b | index($x))] | length) >= 3)
565
+ | {n: .number, s: (.state | ascii_downcase)} ]
566
+ | (map(select(.s == "open")) + map(select(.s != "open"))) | .[0] // empty | "\(.s)\t\(.n)"' "$issues" 2>/dev/null) || _hit=""
567
+ case "$_hit" in
568
+ open*) printf '%s\t%s\t%s\t%s\tfiled\t%s\n' "$_f" "$_a" "$_b" "$_t" "${_hit#* }" ;;
569
+ closed*) printf '%s\t%s\t%s\t%s\tfiled-closed\t%s\n' "$_f" "$_a" "$_b" "$_t" "${_hit#* }" ;;
570
+ *) printf '%s\t%s\t%s\t%s\t%s\t\n' "$_f" "$_a" "$_b" "$_t" "$_dflt" ;;
571
+ esac
572
+ done
573
+ rm -f "$rows" "$issues"
574
+ }
575
+ # A staging bundle review (--prior-prs) re-flags against the member PRs' own reviews, with nothing skipped; otherwise against this PR's earlier heads.
576
+ if [ "$BUNDLE" = "true" ]; then PRIOR_ROWS=$(PF_SKIP="" PF_PRS="$PRIOR_PRS" prior_findings_tsv); else PRIOR_ROWS=$(PF_SKIP="$REVIEW_SHA" PF_PRS="$PR_NUMBER" prior_findings_tsv); fi
577
+ if [ -n "$PRIOR_ROWS" ]; then
578
+ PRIOR_FILE="$WORKDIR/prior-findings"; printf '%s\n' "$PRIOR_ROWS" > "$PRIOR_FILE"
579
+ # Best-effort hunk map from the most recent prior reviewed head to this one, so a moved line still overlaps. Any failure leaves the unshifted ranges.
580
+ PRIOR_SHA=""
581
+ [ "$BUNDLE" = "true" ] || PRIOR_SHA=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
582
+ | 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 \
583
+ | jq -r 'scan("(^|\\n)Reviewed-SHA: ([0-9a-f]{40,64})") | .[1]' 2>/dev/null | grep -vx "$REVIEW_SHA" | tail -n 1)
584
+ _top=$(git rev-parse --show-toplevel 2>/dev/null) || _top=""
585
+ if [ -n "$PRIOR_SHA" ] && [ -n "$_top" ]; then
586
+ DIFF_MAP_FILE="$WORKDIR/prior-diffmap"
587
+ { git -C "$_top" diff -U0 --no-color --no-renames "$PRIOR_SHA" "$REVIEW_SHA" 2>/dev/null || true; } | while IFS= read -r _l; do
588
+ case "$_l" in
589
+ "+++ b/"*) _f="${_l#+++ b/}" ;;
590
+ "@@ "*) _h="${_l#@@ -}"; _o="${_h%% *}"; _n="${_h#* +}"; _n="${_n%% *}"
591
+ case "$_o" in *,*) _oc="${_o#*,}" ;; *) _oc=1 ;; esac; case "$_n" in *,*) _nc="${_n#*,}" ;; *) _nc=1 ;; esac
592
+ printf '%s\t%s\t%s\t%s\t%s\n' "$_f" "${_o%%,*}" "$_oc" "${_n%%,*}" "$_nc" ;;
593
+ esac
594
+ done > "$DIFF_MAP_FILE" || : > "$DIFF_MAP_FILE"
595
+ fi
596
+ fi
597
+
598
+ # Per deduped finding: the classifier is called as is (§6B.5). Pre-existing findings are classified without --inpr-diff (a defect the PR did
599
+ # not introduce is never an in-PR fix); no-scenario demotions are NOTEs and are not classified.
600
+ CAP_ROWS_FILE="$WORKDIR/cap-rows"; : > "$CAP_ROWS_FILE"
601
+ DISP_FILE="$WORKDIR/disp.tsv"; : > "$DISP_FILE"
602
+ INPR_FIX_COUNT=0; INPR_FIX_IDS=""; INPR_MAX_RANK=9; INPR_MAX_SEV=""; INPR_FIX_LIST_FILE="$WORKDIR/inpr-fix-list"; : > "$INPR_FIX_LIST_FILE"
603
+ TOTAL=$(jq '.findings | length' "$IN_FILE"); I=0
604
+ while [ "$I" -lt "$TOTAL" ]; do
605
+ F_ID=$(fj .id); F_SEV=$(fj .severity); F_CONF=$(fj .confidence); F_AGENT=$(fj .agent); F_FILE=$(fj .file); F_TITLE=$(fj .title)
606
+ F_ROUTE=$(fj .route); F_FIX=$(fj .fix_efficacy); F_L1=$(fj .line_start); F_L2=$(fj .line_end); F_LOC=$(fj .loc)
607
+ if [ "$F_ROUTE" = "demoted-no-scenario" ]; then printf '%s\t%s\n' "$I" "NOTE no-scenario" >> "$DISP_FILE"; I=$((I + 1)); continue; fi
608
+ FINDING_TEXT_FILE="$WORKDIR/finding-text.$I"; jq -r ".findings[$I].text // \"\"" "$IN_FILE" > "$FINDING_TEXT_FILE" 2>/dev/null
609
+ set -- --severity "$F_SEV" --confidence "$F_CONF" --agent "$F_AGENT" --lineage "$FINDING_LINEAGE" --text-file "$FINDING_TEXT_FILE"
610
+ [ -n "$F_FILE" ] && set -- "$@" --file "$F_FILE"
611
+ [ -n "$INPR_DIFF_FILE" ] && [ "$F_ROUTE" != "pre-existing" ] && set -- "$@" --inpr-diff "$INPR_DIFF_FILE"
612
+ [ -n "$CONTRACT_SCOPE_FILE" ] && set -- "$@" --contract-scope "$CONTRACT_SCOPE_FILE" --contract-open "$CONTRACT_OPEN_FILE" --pr-files "$PR_FILES_FILE"
613
+ [ -n "$MERGE_ISSUE" ] && set -- "$@" --fixes-issue "$MERGE_ISSUE"
614
+ case "$F_FIX" in fails|regresses) set -- "$@" --fix-efficacy "$F_FIX" ;; esac
615
+ [ -n "$PRIOR_FILE" ] && { set -- "$@" --prior-findings "$PRIOR_FILE" --title "$F_TITLE"; [ -n "$DIFF_MAP_FILE" ] && set -- "$@" --diff-map "$DIFF_MAP_FILE"; }
616
+ [ -n "$F_L1" ] && set -- "$@" --line-start "$F_L1" --line-end "${F_L2:-$F_L1}"
617
+ DISPOSITION=$(bash "$CLASSIFY_SCRIPT" "$@" 2>/dev/null) || DISPOSITION=""
618
+ DISPOSITION=$(printf '%s\n' "$DISPOSITION" | head -n 1)
619
+ # A classifier runtime failure fails toward filing, like the classifier itself (the resolution failure above is what blocks).
620
+ case "$DISPOSITION" in ISSUE*|NOTE*|INPR_FIX*) ;; *) DISPOSITION="ISSUE classifier-error" ;; esac
621
+ # Staging bundle scope rule (--scope-files; the two SCOPE_RULE lines are evaluated by scripts/review-pr-staging-scope.test.sh): only a finding whose
622
+ # normalised severity is MEDIUM, LOW or INFO may be demoted, and only when it cites a file and that file is outside the review scope.
623
+ # The classifier's safety exemption and an existing re-flag are never second-guessed, and the demotion is a listed NOTE, never a drop.
624
+ SEV=$(printf '%s' "$F_SEV" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]') # SCOPE_RULE
625
+ case "$SEV" in MEDIUM|LOW|INFO) OOS_DEMOTE=1 ;; *) OOS_DEMOTE=0 ;; esac # SCOPE_RULE
626
+ case "$DISPOSITION" in *safety-exemption*|"NOTE reflag"*|INPR_FIX*) OOS_DEMOTE=0 ;; esac
627
+ if [ -n "$SCOPE_FILES" ] && [ -r "$SCOPE_FILES" ] && [ "$OOS_DEMOTE" = "1" ] && [ -n "$F_FILE" ] && ! grep -qxF -- "$F_FILE" "$SCOPE_FILES"; then
628
+ DISPOSITION="NOTE out_of_scope"; OUT_OF_SCOPE=$((OUT_OF_SCOPE + 1))
629
+ fi
630
+ case "$DISPOSITION" in
631
+ "NOTE contract-deferred #"*)
632
+ _dn="${DISPOSITION##*#}"
633
+ case "$_dn" in ''|*[!0-9]*) ;; *)
634
+ [ "$DRY_RUN" = "true" ] || gh issue comment "$_dn" -R "$REPO" --body "Review of #${PR_NUMBER} also found this on a path declared deferred here: ${F_LOC} ${F_TITLE}" >/dev/null 2>&1 || true # allowlist:check-command-side-effects
635
+ ;; esac
636
+ CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
637
+ "NOTE contract-accepted-risk"*) CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
638
+ "NOTE reflag-"*) NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
639
+ INPR_FIX*)
640
+ INPR_FIX_COUNT=$((INPR_FIX_COUNT + 1)); INPR_FIX_IDS="${INPR_FIX_IDS:+$INPR_FIX_IDS,}${F_ID}"
641
+ _rk=$(sev_rank "$F_SEV"); if [ "$_rk" -lt "$INPR_MAX_RANK" ]; then INPR_MAX_RANK="$_rk"; INPR_MAX_SEV=$(upper "$F_SEV"); fi
642
+ _fix=$(grep -iE '(suggested fix|\*\*fix\*\*)[*]*:' "$FINDING_TEXT_FILE" 2>/dev/null | head -n 1 | sed -E 's/^.*[Ff]ix[*]*:[[:space:]]*//')
643
+ printf -- '- %s %s: %s%s\n' "$F_ID" "$F_LOC" "$(printf '%s' "$F_TITLE" | tr '\t\n' ' ')" "${_fix:+ (fix: $_fix)}" >> "$INPR_FIX_LIST_FILE" ;;
644
+ esac
645
+ printf '%s\t%s\t%s\t%s\t%s\n' "$I" "$F_SEV" "$F_CONF" "$DISPOSITION" "$F_AGENT" > "$WORKDIR/row.$I"
646
+ # Pre-existing findings are bounded separately (below) and never consume the per-review cap.
647
+ [ "$F_ROUTE" = "pre-existing" ] || cat "$WORKDIR/row.$I" >> "$CAP_ROWS_FILE"
648
+ printf '%s\t%s\n' "$I" "$DISPOSITION" >> "$DISP_FILE"
649
+ I=$((I + 1))
650
+ done
651
+
652
+ # Per-review cap pass (#3642, MANDATORY): classify EVERY finding first; file nothing until the cap has run.
653
+ CAP_OUT=$(bash "$CAP_SCRIPT" --lineage "$FINDING_LINEAGE" ${MAX_FILED_FINDINGS:+--cap "$MAX_FILED_FINDINGS"} --input "$CAP_ROWS_FILE" 2>/dev/null) || CAP_OUT=""
654
+ NOTES_CAPPED=$(printf '%s\n' "$CAP_OUT" | sed -n 's/^CAPPED=//p' | tail -n 1); NOTES_CAPPED="${NOTES_CAPPED:-0}"
655
+ CAPPED_IDS=$(printf '%s\n' "$CAP_OUT" | awk -F'\t' 'NF >= 2 && $2 == "NOTE capped" { print $1 }') # allowlist:positional-arg
656
+ # An id in CAPPED_IDS is a NOTE (`capped`, list it, never filed); every other row keeps the classifier disposition. A helper runtime failure
657
+ # (empty CAP_OUT) keeps the original dispositions and NOTES_CAPPED=0 (fail toward filing; the cap is a damper, not a gate).
658
+ [ -n "$CAP_OUT" ] || NOTES_CAPPED=0
659
+ case "$NOTES_CAPPED" in ''|*[!0-9]*) NOTES_CAPPED=0 ;; esac
660
+ for _cid in $CAPPED_IDS; do
661
+ case "$_cid" in ''|*[!0-9]*) continue ;; esac
662
+ awk -F'\t' -v k="$_cid" 'BEGIN{OFS="\t"} $1 == k { $2 = "NOTE capped" } { print }' "$DISP_FILE" > "$DISP_FILE.n" && mv "$DISP_FILE.n" "$DISP_FILE"
663
+ done
664
+
665
+ # Assemble the classified JSON: disposition and class per finding.
666
+ jq --rawfile d "$DISP_FILE" '
667
+ ($d | split("\n") | map(select(length > 0) | split("\t") | {(.[0]): .[1]}) | add // {}) as $m
668
+ | .findings |= (to_entries | map(.value + {disposition: ($m[.key | tostring] // "")} | . + {class: (.disposition | split(" ")[0])}) )' "$IN_FILE" > "$OUT_FILE.tmp" \
669
+ || { echo "review-finalize: cannot assemble classified JSON" >&2; exit 2; }
670
+ # Pre-existing route: at most 3 per review, highest severity first, among those the classifier called ISSUE; the rest are listed only.
671
+ PRE_FILED=0; PRE_IDX=$(jq -r '.findings | to_entries | map(select(.value.route == "pre-existing" and .value.class == "ISSUE")) | sort_by([(.value.severity | ascii_upcase | if . == "CRITICAL" then 0 elif . == "HIGH" then 1 elif . == "MEDIUM" then 2 else 3 end), .key]) | .[].key' "$OUT_FILE.tmp")
672
+ PRE_KEEP=$(printf '%s\n' "$PRE_IDX" | sed -n '1,3p' | tr '\n' ' ')
673
+ PRE_FILED=$(printf '%s\n' "$PRE_IDX" | sed -n '1,3p' | grep -c . || true)
674
+ jq --arg keep " ${PRE_KEEP}" '
675
+ .findings = [ .findings | to_entries[] | .key as $k | .value
676
+ | if .route == "pre-existing" and .class == "ISSUE" then
677
+ (if ($keep | contains(" " + ($k | tostring) + " ")) then . + {route: "file-pre-existing"}
678
+ else . + {disposition: "NOTE preexisting-over-cap", class: "NOTE"} end)
679
+ else . end ]' "$OUT_FILE.tmp" > "$OUT_FILE" || { echo "review-finalize: cannot assemble classified JSON" >&2; exit 2; }
680
+ rm -f "$OUT_FILE.tmp"
681
+
682
+ # Counters (after the cap pass). Contract-demoted, re-flagged and capped NOTEs are counted only in their own counters.
683
+ FINDINGS_FILED=$(jq '[.findings[] | select(.class == "ISSUE" and .route != "file-pre-existing" and .route != "pre-existing")] | length' "$OUT_FILE")
684
+ PLAIN_NOTES=$(jq '[.findings[] | select(.class == "NOTE" and .route != "pre-existing" and (.disposition | test("^NOTE (contract-|reflag-|capped)") | not))] | length' "$OUT_FILE")
685
+ NOTES_LISTED=$((PLAIN_NOTES - NOTES_FIXED - NOTES_DROPPED)); [ "$NOTES_LISTED" -ge 0 ] || NOTES_LISTED=0
686
+ N_STALE=$(jq -r '.counts.findings_dropped_stale // 0' "$OUT_FILE"); N_PRE=$(jq -r '.counts.findings_preexisting // 0' "$OUT_FILE"); N_NOSCEN=$(jq -r '.counts.notes_demoted_no_scenario // 0' "$OUT_FILE")
687
+ N_EXTRACTED=$(jq -r '.counts.findings_extracted // (.findings | length)' "$OUT_FILE")
688
+ CLASSIFIER_MODE=script # the guard above blocks the review when the classifier is unresolvable, so this is always script
689
+
690
+ # NOTE list: one line per NOTE `- <id> <file:line|?> [<CONFIDENCE>] <title>: <disposition>` (the shape prior_findings_tsv parses on a later head;
691
+ # the disposition text carries no colon so the last `: ` always splits title from disposition).
692
+ NOTE_LIST_FILE="$OUT_DIR/note-list.txt"; : > "$NOTE_LIST_FILE"
693
+ jq -r '.findings[] | select(.class == "NOTE" and .route != "pre-existing") |
694
+ [.id, (if (.file // "") == "" then "?" else .loc end), ((.confidence // "") | ascii_upcase | gsub("[^A-Z]"; "") | if . == "" then "?" else . end),
695
+ ((.title // "") | gsub("[\t\r\n]"; " ")),
696
+ (.disposition | sub("^NOTE "; "")
697
+ | if test("^reflag-filed #") then "tracked on #" + (capture("#(?<n>[0-9]+)").n // "?")
698
+ elif test("^reflag-note") then "already noted on an earlier head"
699
+ elif test("^contract-deferred #") then "tracked on #" + (capture("#(?<n>[0-9]+)").n // "?")
700
+ elif test("^contract-accepted-risk") then "accepted-risk"
701
+ elif . == "capped" then "capped - over review.max_filed_findings"
702
+ else gsub(":"; " ") end)]
703
+ | "- \(.[0]) \(.[1] | gsub(" "; "_")) [\(.[2])] \(.[3]): \(.[4])"' "$OUT_FILE" > "$NOTE_LIST_FILE" 2>/dev/null
704
+
705
+ # Disposition marker (MANDATORY whenever Phase 6A extracted at least one finding): Phase 8 refuses to auto-merge a PR that has FINDING:
706
+ # markers but no FORGE:NOTE_DISPOSITION comment. `reviewed_sha` is the head this record covers (a later review of ANOTHER head reads it as a prior list).
707
+ BUNDLE_MARKER=""; if [ "$BUNDLE" = "true" ]; then _sm=full; [ -z "$SCOPE_FILES" ] || _sm=scoped; BUNDLE_MARKER=" out_of_scope=${OUT_OF_SCOPE} scope=${_sm}"; fi
708
+ NOTE_DISPOSITION_BODY="<!-- FORGE:NOTE_DISPOSITION: notes_fixed=${NOTES_FIXED:-0} notes_listed=${NOTES_LISTED:-0} notes_dropped=${NOTES_DROPPED:-0} findings_filed=${FINDINGS_FILED:-0} inpr_fix=${INPR_FIX_COUNT:-0} contract_demoted=${CONTRACT_DEMOTED:-0} findings_dropped_stale=${N_STALE:-0} findings_preexisting=${N_PRE:-0} notes_demoted_no_scenario=${N_NOSCEN:-0} lineage=${FINDING_LINEAGE} classifier=${CLASSIFIER_MODE} notes_reflag=${NOTES_REFLAG:-0} notes_capped=${NOTES_CAPPED:-0} reviewed_sha=${REVIEW_SHA}${BUNDLE_MARKER} -->
709
+ $(cat "$NOTE_LIST_FILE" 2>/dev/null)"
710
+ printf '%s\n' "$NOTE_DISPOSITION_BODY" > "$OUT_DIR/note-disposition.md"
711
+ POSTED_DISPOSITION=false; POSTED_INPR=false
712
+ if [ "$N_EXTRACTED" -gt 0 ] && [ "$DRY_RUN" != "true" ]; then
713
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body "$NOTE_DISPOSITION_BODY" >/dev/null || blocked "could not post FORGE:NOTE_DISPOSITION (fail closed)" # allowlist:check-command-side-effects
714
+ POSTED_DISPOSITION=true
715
+ fi
716
+ # In-PR fix request (6B.6), after the disposition marker. Phase 8 then refuses to merge this head (`in-pr fix required`).
717
+ if [ "$INPR_FIX_COUNT" -gt 0 ] && [ "$DRY_RUN" != "true" ]; then
718
+ INPR_HEAD=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq .headRefOid 2>/dev/null); is_sha "$INPR_HEAD" || INPR_HEAD="$REVIEW_SHA"
719
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body "<!-- FORGE:INPR_FIX: round=1 head=${INPR_HEAD} max_severity=${INPR_MAX_SEV:-MEDIUM} findings=${INPR_FIX_IDS} -->
720
+ In-PR fix requested before merge (CONFIRMED findings in files this PR changed). One fix round; anything still present on the next review is filed as an issue.
721
+ $(cat "$INPR_FIX_LIST_FILE" 2>/dev/null)" >/dev/null || blocked "could not post FORGE:INPR_FIX (fail closed)" # allowlist:check-command-side-effects
722
+ POSTED_INPR=true
723
+ fi
724
+ echo "CLASSIFIED_OUT=$OUT_FILE"
725
+ echo "NOTE_LIST=$NOTE_LIST_FILE"
726
+ echo "FINDING_LINEAGE=$FINDING_LINEAGE"
727
+ echo "FINDINGS_FILED=$FINDINGS_FILED"
728
+ echo "INPR_FIX=$INPR_FIX_COUNT"
729
+ echo "CONTRACT_DEMOTED=$CONTRACT_DEMOTED"
730
+ echo "NOTES_REFLAG=$NOTES_REFLAG"
731
+ echo "NOTES_CAPPED=$NOTES_CAPPED"
732
+ echo "NOTES_LISTED=$NOTES_LISTED"
733
+ echo "PREEXISTING_FILED=$PRE_FILED"
734
+ echo "NOTE_DISPOSITION_POSTED=$POSTED_DISPOSITION"
735
+ echo "INPR_FIX_POSTED=$POSTED_INPR"
736
+ exit 0
737
+ }
738
+
739
+ # ======================================================================================================================
740
+ # file-prepare: 6C up to (not including) the issue-creation call
741
+ # ======================================================================================================================
742
+ do_file_prepare() {
743
+ IN_FILE="${IN_FILE:-$OUT_DIR/classified.json}"; OUT_FILE="${OUT_FILE:-$OUT_DIR/worklist.json}"
744
+ need_findings
745
+ if [ "$DRY_RUN" != "true" ]; then
746
+ # Colors match the canonical ForgeDock label manifest (bin/labels.json).
747
+ gh label create "review-finding" --color "D93F0B" --description "Defect or improvement found during automated PR review. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
748
+ gh label create "needs-validation" --color "FBCA04" --description "Review finding awaiting human validation. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
749
+ gh label create "validated" --color "0E8A16" --description "Review finding confirmed as a real issue. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
750
+ gh label create "false-positive" --color "CCCCCC" --description "Review finding dismissed as a false positive. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
751
+ gh label create "pre-existing" --color "BFD4F2" --description "Defect noticed during PR review that the PR did not introduce. Managed by ForgeDock." --force -R "$REPO" >/dev/null 2>&1 || true
752
+ fi
753
+ BASE_BRANCH="$CTX_BASE_BRANCH"
754
+ [ -n "$BASE_BRANCH" ] || BASE_BRANCH=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
755
+ # scripts/derive-finding-milestone.sh is the single source of truth for milestone derivation (also called by review-pr-staging.md, so
756
+ # the specs cannot drift). Unresolved helper: empty milestone (non-blocking), never a cwd-relative fallback.
757
+ DERIVE_MILESTONE_SCRIPT=$(forge_resolve derive-finding-milestone.sh)
758
+ MILESTONE_TITLE=""
759
+ [ -n "$DERIVE_MILESTONE_SCRIPT" ] && MILESTONE_TITLE=$(bash "$DERIVE_MILESTONE_SCRIPT" "${PR_NUMBER}" -R "${REPO}" 2>/dev/null)
760
+ DEDUP_SCRIPT=$(forge_resolve issue-dedup.sh)
761
+ PRIORITY_SCRIPT=$(forge_resolve severity-to-priority.sh)
762
+ # Existing review-finding issues, read once (never per finding). An unreadable list means nothing matches (file normally).
763
+ OPEN_JSON=$(gh issue list -R "$REPO" --state open --label "review-finding" --limit 100 --json number,title,body 2>/dev/null) || OPEN_JSON="[]"
764
+ CLOSED_JSON=$(gh issue list -R "$REPO" --state closed --label "review-finding" --limit 100 --json number,title,body 2>/dev/null) || CLOSED_JSON="[]"
765
+ printf '%s' "$OPEN_JSON" | jq -e 'type == "array"' >/dev/null 2>&1 || OPEN_JSON="[]"
766
+ printf '%s' "$CLOSED_JSON" | jq -e 'type == "array"' >/dev/null 2>&1 || CLOSED_JSON="[]"
767
+ SELF="${MERGE_ISSUE:-0}"; [[ "$SELF" =~ ^[0-9]+$ ]] || SELF=0
768
+
769
+ # first number of the open (or closed) issue whose body cites FILE within +-5 lines of LINE (a title rule applies to open issues only)
770
+ line_match() { # line_match <json> <file> <line> <title> <title-rule 0|1>
771
+ printf '%s' "$1" | jq -r --arg file "$2" --argjson ln "$3" --arg title "$4" --argjson tr "$5" --argjson self "$SELF" '
772
+ def words: ascii_downcase | split(" ") | map(select(length > 3));
773
+ .[] | select(.number != $self) | select((.body // "") | contains($file))
774
+ | ([(.body | split($file + ":") | .[1:][] | capture("^(?<l>[0-9]+)")? | .l | tonumber)] | .[0] // 0) as $el
775
+ | if ($ln > 0 and $el >= ($ln - 5) and $el <= ($ln + 5)) then .number
776
+ elif ($tr == 1 and (($title | words) as $a | (.title | words) as $b | ([$a[] | select(. as $w | any($b[]; . == $w))] | unique | length) >= 3)) then .number
777
+ else empty end' 2>/dev/null | head -1
778
+ }
779
+
780
+ OUT_ITEMS="$WORKDIR/items.jsonl"; : > "$OUT_ITEMS"
781
+ TOTAL=$(jq '.findings | length' "$IN_FILE"); I=0; N_FILE=0
782
+ while [ "$I" -lt "$TOTAL" ]; do
783
+ F_ROUTE=$(fj .route); F_CLASS=$(fj .class)
784
+ case "$F_CLASS:$F_ROUTE" in ISSUE:file|ISSUE:file-pre-existing) ;; *) I=$((I + 1)); continue ;; esac
785
+ F_ID=$(fj .id); F_FILE=$(fj .file); F_TITLE=$(fj .title); F_SEV=$(fj .severity); F_AGENT=$(fj .agent); F_LINE=$(fj .line_start)
786
+ case "$F_LINE" in ''|*[!0-9]*) F_LINE=0 ;; esac
787
+ ACTION="file"; DEDUP_OF=""; REGRESSION=""; NOTE=""; CONSOLIDATE="false"; PRIOR_NUMS=""; SUBSYSTEM=""; RECURRENCE=""
788
+ # Title (defense in depth: strip command substitution so the raw title stays readable if it round-trips through an eval-based consumer).
789
+ ISSUE_TITLE="fix: ${F_TITLE} (review finding — PR #${PR_NUMBER})"
790
+ ISSUE_TITLE=$(printf '%s' "$ISSUE_TITLE" | tr '\t\n`' " '" | sed 's/\$(/$ (/g')
791
+ # Step 0: deterministic title dedup (scripts/issue-dedup.sh; also excludes the issue this PR fixes: it is about to be closed, so a
792
+ # finding matched to it is tracked nowhere). Unresolved helper: "not deduped"; NEVER exit 1 (which means "duplicate, skip").
793
+ if [ -n "$DEDUP_SCRIPT" ]; then
794
+ DEDUP_RESULT=$(bash "$DEDUP_SCRIPT" "$ISSUE_TITLE" "-R $REPO" ${MERGE_ISSUE:+--exclude "$MERGE_ISSUE"} 2>&1); DEDUP_EXIT=$?
795
+ else
796
+ DEDUP_RESULT=""; DEDUP_EXIT=0
797
+ fi
798
+ if [ "$DEDUP_EXIT" -eq 1 ]; then
799
+ ACTION="skip-dedup"; NOTE="$DEDUP_RESULT"; DEDUP_OF=$(printf '%s' "$DEDUP_RESULT" | sed -n 's/[^0-9]*\([0-9][0-9]*\).*/\1/p' | head -1)
800
+ echo "DEDUP: Skipping — $DEDUP_RESULT"
801
+ elif [ "$DEDUP_EXIT" -eq 2 ]; then
802
+ ACTION="skip-usage-error"; NOTE="$DEDUP_RESULT"
803
+ echo "DEDUP: Usage error — $DEDUP_RESULT"
804
+ else
805
+ # Line-range / title dedup: +-5 tolerance covers off-by-one from upstream insertions; titles are compared without the
806
+ # constant "(review finding — PR #N)" suffix, which every review-finding title shares.
807
+ EXISTING=""; [ -n "$F_FILE" ] && EXISTING=$(line_match "$OPEN_JSON" "$F_FILE" "$F_LINE" "$F_TITLE" 1)
808
+ if [ -n "$EXISTING" ]; then
809
+ ACTION="skip-dedup"; DEDUP_OF="$EXISTING"
810
+ echo "DEDUP: Skipping — open issue #${EXISTING} already covers ${F_FILE}:${F_LINE} (range/title match)"
811
+ else
812
+ # A CLOSED review-finding at the same location +-5 lines is a regression: create with a warning and elevate to priority:P1.
813
+ [ -n "$F_FILE" ] && REGRESSION=$(line_match "$CLOSED_JSON" "$F_FILE" "$F_LINE" "$F_TITLE" 0)
814
+ [ -z "$REGRESSION" ] || echo "REGRESSION: Previously fixed in #${REGRESSION} — elevating priority"
815
+ fi
816
+ fi
817
+ # priority:* is a function of Severity only (scripts/severity-to-priority.sh), never of Confidence. Unresolved or failing helper: skip
818
+ # the finding (never defaulted to a label).
819
+ FINDING_PRIORITY=""
820
+ if [ -n "$PRIORITY_SCRIPT" ]; then FINDING_PRIORITY=$(bash "$PRIORITY_SCRIPT" "$F_SEV" 2>/dev/null); PRIORITY_EXIT=$?; else PRIORITY_EXIT=127; fi
821
+ if [ "$PRIORITY_EXIT" -ne 0 ] || [ -z "$FINDING_PRIORITY" ]; then
822
+ [ "$ACTION" = "file" ] && { ACTION="skip-priority"; NOTE="severity-to-priority.sh failed (exit $PRIORITY_EXIT) for severity '$F_SEV'"; }
823
+ echo "PRIORITY: severity-to-priority.sh failed (exit $PRIORITY_EXIT) for severity '$F_SEV' — skipping finding issue creation"
824
+ FINDING_PRIORITY=""
825
+ fi
826
+ [ -z "$REGRESSION" ] || [ -z "$FINDING_PRIORITY" ] || FINDING_PRIORITY="priority:P1"
827
+ # Pattern recurrence (needs the FORGE:PATTERN slug the caller wrote for the finding, passed in --patterns). Exemptions are decided FIRST.
828
+ PATTERN_SLUG=""
829
+ [ -n "$PATTERNS" ] && [ -r "$PATTERNS" ] && PATTERN_SLUG=$(jq -r --arg id "$F_ID" '.[$id] // empty' "$PATTERNS" 2>/dev/null)
830
+ if [ "$ACTION" != "file" ]; then RECURRENCE="not-applicable"
831
+ elif [ -z "$PATTERN_SLUG" ]; then RECURRENCE="skipped - no pattern slug supplied"
832
+ else
833
+ FINDING_EXEMPT="false"
834
+ case "$(upper "$F_SEV")" in HIGH|CRITICAL) FINDING_EXEMPT="true" ;; esac
835
+ case "$(printf '%s' "$F_AGENT" | tr '[:upper:]' '[:lower:]')" in *security*) FINDING_EXEMPT="true" ;; esac
836
+ [ -z "$REGRESSION" ] || FINDING_EXEMPT="true"
837
+ case "$FINDING_PRIORITY" in priority:P0|priority:P1) FINDING_EXEMPT="true" ;; esac
838
+ if [ "$FINDING_EXEMPT" = "true" ]; then
839
+ RECURRENCE="exempt (severity/category/regression) - filing normally"
840
+ elif ! [[ "$PATTERN_SLUG" =~ ^[a-z0-9-]+$ ]]; then
841
+ RECURRENCE="skipped - invalid slug, filing normally"
842
+ else
843
+ # Subsystem = the directory of the finding's primary file, truncated to its first two segments.
844
+ SUBSYSTEM=$(dirname -- "$F_FILE" | cut -d/ -f1-2)
845
+ if [ "$SUBSYSTEM" = "." ] || ! [[ "$SUBSYSTEM" =~ ^[A-Za-z0-9._/-]+$ ]]; then
846
+ RECURRENCE="skipped - no valid subsystem, filing normally"; SUBSYSTEM=""
847
+ else
848
+ # Narrow server-side by the exact slug tag (the slug is validated, so safe in --search), then filter in jq. Excludes class
849
+ # issues, false-positive findings and issues closed as not planned. Fail closed on lookup errors: file normally.
850
+ PRIOR=$(gh issue list -R "$REPO" --state all --label review-finding --limit 200 --search "FORGE:PATTERN: ${PATTERN_SLUG} in:body" \
851
+ --json number,body,labels,stateReason 2>/dev/null) || PRIOR="__LOOKUP_FAILED__"
852
+ if [ "$PRIOR" = "__LOOKUP_FAILED__" ] || ! printf '%s' "$PRIOR" | jq -e 'type == "array"' >/dev/null 2>&1; then
853
+ RECURRENCE="lookup failed - filing as a normal instance issue"
854
+ else
855
+ RECURRENCE="checked"
856
+ PRIOR_NUMS=$(printf '%s' "$PRIOR" | jq -r --arg slug "$PATTERN_SLUG" --arg sub "$SUBSYSTEM" --argjson self "$SELF" '
857
+ ($sub | gsub("\\."; "\\.")) as $esc
858
+ | [.[] | select(.number != $self)
859
+ | select(.stateReason != "NOT_PLANNED")
860
+ | select((.labels | map(.name)) as $l | (($l | index("pattern-class")) or ($l | index("false-positive"))) | not)
861
+ | select(.body | contains("<!-- FORGE:PATTERN: " + $slug + " -->"))
862
+ | select(.body | test("(^|[^A-Za-z0-9._-])" + $esc + "/"))
863
+ | .number] | join(" ")')
864
+ [ -z "$PRIOR_NUMS" ] || { CONSOLIDATE="true"; echo "RECURRENCE: $PATTERN_SLUG seen before in $SUBSYSTEM (#${PRIOR_NUMS// /, #}) - consolidating"; }
865
+ fi
866
+ fi
867
+ fi
868
+ fi
869
+ # P3 batchable annotation: a P3 finding off security/billing paths may be grouped with other P3s by the orchestrator.
870
+ BATCHABLE="false"
871
+ if [ "$FINDING_PRIORITY" = "priority:P3" ]; then
872
+ IS_SECURITY_PATH=0; IS_BILLING_PATH=0
873
+ echo "$F_FILE" | grep -qiE 'security|billing|payment|stripe|charge|invoice' && IS_SECURITY_PATH=1
874
+ echo "$F_TITLE" | grep -qiE 'security|billing|payment|stripe|charge|invoice' && IS_BILLING_PATH=1
875
+ if [ "$IS_SECURITY_PATH" -eq 0 ] && [ "$IS_BILLING_PATH" -eq 0 ]; then BATCHABLE="true"; fi
876
+ fi
877
+ LABELS='["review-finding","needs-validation"]'
878
+ [ "$F_ROUTE" != "file-pre-existing" ] || LABELS='["pre-existing"]'
879
+ [ -z "$FINDING_PRIORITY" ] || LABELS=$(printf '%s' "$LABELS" | jq -c --arg p "$FINDING_PRIORITY" '. + [$p]')
880
+ [ "$ACTION" != "file" ] || N_FILE=$((N_FILE + 1))
881
+ jq -n -c --arg id "$F_ID" --arg action "$ACTION" --arg title "$ISSUE_TITLE" --arg file "$F_FILE" --arg line "$F_LINE" --arg sev "$(upper "$F_SEV")" \
882
+ --arg prio "$FINDING_PRIORITY" --argjson labels "$LABELS" --arg ms "$MILESTONE_TITLE" --arg batch "$BATCHABLE" --arg dedup "$DEDUP_OF" \
883
+ --arg reg "$REGRESSION" --arg note "$NOTE" --arg cons "$CONSOLIDATE" --arg prior "$PRIOR_NUMS" --arg sub "$SUBSYSTEM" --arg slug "$PATTERN_SLUG" \
884
+ --arg rec "$RECURRENCE" --arg route "$F_ROUTE" --arg agent "$F_AGENT" \
885
+ '{id: $id, action: $action, route: $route, title: $title, file: $file, line: ($line | tonumber), severity: $sev, agent: $agent, priority: $prio,
886
+ labels: $labels, milestone: $ms, batchable: ($batch == "true"), dedup_of: ($dedup | tonumber? // null), regression_of: ($reg | tonumber? // null),
887
+ note: $note, pattern: {slug: $slug, subsystem: $sub, consolidate: ($cons == "true"), prior: ($prior | split(" ") | map(select(length > 0) | tonumber)), recurrence: $rec}}' >> "$OUT_ITEMS"
888
+ I=$((I + 1))
889
+ done
890
+ jq -s --arg ms "$MILESTONE_TITLE" --arg base "$BASE_BRANCH" --arg mi "$MERGE_ISSUE" --arg sha "$REVIEW_SHA" --argjson pr "$PR_NUMBER" --arg repo "$REPO" \
891
+ '{schema_version: 1, pr: $pr, repo: $repo, review_sha: $sha, base_branch: $base, merge_issue: ($mi | tonumber? // null), milestone: $ms, items: .}' "$OUT_ITEMS" > "$OUT_FILE" \
892
+ || { echo "review-finalize: cannot write $OUT_FILE" >&2; exit 2; }
893
+ echo "WORKLIST_OUT=$OUT_FILE"
894
+ echo "FINDINGS_TO_FILE=$N_FILE"
895
+ echo "MILESTONE=$MILESTONE_TITLE"
896
+ exit 0
897
+ }
898
+
899
+ # ======================================================================================================================
900
+ # verdict: 7A log + 7B base-conflict computation and verdict post
901
+ # ======================================================================================================================
902
+ # Computes BASE_CONFLICT (the single source of truth for "a base sync would fix this"), then posts the verdict comment when --result is
903
+ # given. gh pr review --approve fails for self-reviews, so the verdict is always a --comment review. The LLM-authored pieces (panel
904
+ # summary, finding counts, purpose-regression finding list) arrive as flags and are only ever interpolated as data.
905
+ do_verdict() {
906
+ BASE="${BASE_OPT:-$CTX_BASE_BRANCH}"
907
+ [ -n "$BASE" ] || BASE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
908
+ HEAD="$HEAD_BRANCH"
909
+ [ -n "$HEAD" ] || HEAD=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefName --jq '.headRefName' 2>/dev/null || echo "")
910
+ if [ -n "$RESULT" ]; then is_sha "$REVIEW_SHA" || blocked "REVIEW_SHA is not a commit id (pass --review-sha or --context)"; fi
911
+ REVIEW_SHA_SHORT=$(printf '%s' "$REVIEW_SHA" | cut -c1-7)
912
+ # 7A Step 4: the purpose-regression gate log (milestone PRs only; the judgement itself stays with the reviewer, passed as a flag).
913
+ if [ "$IS_MILESTONE" = "true" ]; then
914
+ if [ "$HAS_PURPOSE_REGRESSION" = "true" ]; then
915
+ echo "PURPOSE REGRESSION GATE: BLOCKED"
916
+ echo "Reason: ${PURPOSE_FINDINGS:-finding} contradicts milestone goal"
917
+ echo "Verdict escalated to CHANGES REQUESTED."
918
+ else
919
+ echo "PURPOSE REGRESSION GATE: PASSED"
920
+ echo "No findings contradict the milestone's stated purpose."
921
+ echo "Verdict determined by standard blocking criteria."
922
+ fi
923
+ fi
924
+ HAS_MERGE_CONFLICT=false
925
+ BASE_CONFLICT=false
926
+ MERGE_CONFLICT_MSG=""
927
+ MERGE_PROTECTION_WARNING=""
928
+ BASE_REQUIRES_UPTODATE=false
929
+ if [ "$MERGE_HEALTH_STATE" = "BEHIND" ]; then
930
+ # Unreadable protection (404 / 403 / no admin access) is treated as "not required": no sync, and a later failed merge lands in the existing needs-human path.
931
+ [ "$(gh api "repos/${REPO}/branches/${BASE}/protection" --jq '.required_status_checks.strict // false' 2>/dev/null)" = "true" ] && BASE_REQUIRES_UPTODATE=true
932
+ fi
933
+ if [ "$MERGE_HEALTH" = "CONFLICTING" ] || [ "$MERGE_HEALTH_STATE" = "DIRTY" ] || { [ "$MERGE_HEALTH_STATE" = "BEHIND" ] && [ "$BASE_REQUIRES_UPTODATE" = "true" ]; }; then
934
+ HAS_MERGE_CONFLICT=true
935
+ BASE_CONFLICT=true
936
+ MERGE_CONFLICT_MSG="Base conflict with \`${BASE}\`. Merge \`origin/${BASE}\` into \`${HEAD}\` (merge only, no rebase, no force-push), resolve the conflicting files by reading both sides, then re-run /review-pr."
937
+ elif [ "$MERGE_HEALTH_STATE" = "BLOCKED" ]; then
938
+ MERGE_PROTECTION_WARNING="Merge state is BLOCKED: branch protection is not satisfied (required review or check). Syncing the base cannot clear this."
939
+ fi
940
+ # ONLY_BASE_CONFLICT is NOT computed here: CALIBRATION_NEEDS_HUMAN and TRUST_NEEDS_HUMAN are only final at the merge gate.
941
+ ATTRIBUTION_PR_FOOTER=$(grep -A5 "^attribution:" "${FORGE_CONFIG:-forge.yaml}" 2>/dev/null | grep "pr_footer:" | awk '{print $(2)}' | tr -d '"')
942
+ [ -n "$ATTRIBUTION_PR_FOOTER" ] || ATTRIBUTION_PR_FOOTER="false"
943
+ ATTRIBUTION_FOOTER_LINE=""
944
+ if [ "$ATTRIBUTION_PR_FOOTER" = "true" ]; then
945
+ ATTRIBUTION_FOOTER_LINE="
946
+ ---
947
+ > ⚒️ Orchestrated with [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock) — state, scheduling, review, and memory on GitHub."
948
+ fi
949
+ # Trust annotation: always included so the decision is auditable (defaults mirror Phase 3B.5).
950
+ TRUST_ANNOTATION="**Intensity tier**: ${INTENSITY_TIER:-SHADOW} | **Cell**: \`${TABLE_CELL_KEY:-(unavailable)}\` | **Shadow mode**: ${TRUST_SHADOW_MODE:-true}"
951
+ # A clean verdict can never be posted over a blocker this subcommand computed (criteria 3 and 4): fail closed to CHANGES REQUESTED.
952
+ VERDICT_COERCED=false
953
+ if [ "$RESULT" = "clean" ] && { [ "$BASE_CONFLICT" = "true" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ]; }; then
954
+ RESULT="blocking"; VERDICT_COERCED=true
955
+ fi
956
+ VERDICT_OUT=""
957
+ case "$RESULT" in
958
+ stale) VERDICT_OUT="STALE" ;;
959
+ clean) VERDICT_OUT="APPROVED" ;;
960
+ blocking) VERDICT_OUT="CHANGES REQUESTED" ;;
961
+ esac
962
+ if [ -n "$RESULT" ] && [ "$DRY_RUN" != "true" ]; then
963
+ PURPOSE_LINE=""; [ "$HAS_PURPOSE_REGRESSION" = "true" ] && PURPOSE_LINE="
964
+ ⚠ Purpose Regression: ${BLOCKING_COUNT:-[N]} finding(s) contradict the milestone's stated goal and are automatically blocking regardless of runtime impact. See: ${PURPOSE_FINDINGS}"
965
+ CONFLICT_LINE=""; [ "$HAS_MERGE_CONFLICT" = "true" ] && CONFLICT_LINE="
966
+ 🔴 Base Conflict: ${MERGE_CONFLICT_MSG}"
967
+ PROTECTION_LINE=""; [ -n "$MERGE_PROTECTION_WARNING" ] && PROTECTION_LINE="
968
+ ⚠ ${MERGE_PROTECTION_WARNING}"
969
+ UNKNOWN_LINE=""; [ "$MERGE_HEALTH" = "UNKNOWN" ] && UNKNOWN_LINE="
970
+ ⚠ Mergeability: GitHub is still computing merge state (UNKNOWN after retries). Verify manually before merging."
971
+ case "$RESULT" in
972
+ stale)
973
+ gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "Review of commit $REVIEW_SHA_SHORT is stale — PR HEAD changed. Re-run /review-pr." \
974
+ || blocked "verdict comment could not be posted" ;;
975
+ clean)
976
+ gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "APPROVED: commit $REVIEW_SHA_SHORT after context-aware review (${AGENTS_TEXT:-[N] agents: [names]}). ${FINDINGS_COUNT:-[M]} findings created as issues. Safe to merge.
977
+ ${TRUST_ANNOTATION}${UNKNOWN_LINE}${ATTRIBUTION_FOOTER_LINE}" \
978
+ || blocked "verdict comment could not be posted" ;;
979
+ blocking)
980
+ gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "CHANGES REQUESTED: commit $REVIEW_SHA_SHORT — ${BLOCKING_COUNT:-[N]} blocking issues found. See GitHub issues.
981
+ ${TRUST_ANNOTATION}${CONFLICT_LINE}${PROTECTION_LINE}${PURPOSE_LINE}${ATTRIBUTION_FOOTER_LINE}" \
982
+ || blocked "verdict comment could not be posted" ;;
983
+ esac
984
+ fi
985
+ echo "VERDICT=${VERDICT_OUT}"
986
+ echo "VERDICT_COERCED=${VERDICT_COERCED}"
987
+ echo "HAS_PURPOSE_REGRESSION=${HAS_PURPOSE_REGRESSION}"
988
+ echo "HAS_MERGE_CONFLICT=${HAS_MERGE_CONFLICT}"
989
+ echo "BASE_CONFLICT=${BASE_CONFLICT}"
990
+ echo "MERGE_CONFLICT_MSG=${MERGE_CONFLICT_MSG}"
991
+ echo "MERGE_PROTECTION_WARNING=${MERGE_PROTECTION_WARNING}"
992
+ exit 0
993
+ }
994
+
995
+ # ======================================================================================================================
996
+ # calibrate: 7B.5 calibration threshold consultation
997
+ # ======================================================================================================================
998
+ # Fail-safe: ANY error reading the table yields CALIBRATION_NEEDS_HUMAN=false (static criteria stay authoritative); the table can only tighten.
999
+ do_calibrate() {
1000
+ CALIBRATION_NEEDS_HUMAN=false
1001
+ CALIBRATION_CELL=""
1002
+ CALIBRATION_NOTE=""
1003
+ ISSUE_NUMBER="${MERGE_ISSUE:-}"
1004
+ TASK_TYPE=""; CONFIDENCE=""
1005
+ if [ -n "$ISSUE_NUMBER" ]; then
1006
+ # Trusted, anchored INVESTIGATOR only (body starts with the marker; Bot or OWNER/MEMBER/COLLABORATOR): an
1007
+ # outsider's comment that merely mentions the marker must never feed the calibration lookup or the note.
1008
+ INVESTIGATOR_BODY=$(gh api --paginate "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" 2>/dev/null \
1009
+ | jq -rs '[.[][] | select((.body // "") | startswith("<!-- FORGE:INVESTIGATOR")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")))] | last | .body // ""' 2>/dev/null || echo '')
1010
+ # Values reach CALIBRATION_NOTE, which the spec substitutes into a shell fence: restrict to a safe charset.
1011
+ TASK_TYPE=$(printf '%s\n' "$INVESTIGATOR_BODY" | sed -n 's/.*\*\*Task Type\*\*: \(.*\)/\1/p' | head -1 | tr -cd 'A-Za-z0-9_-' | cut -c1-40)
1012
+ CONFIDENCE=$(printf '%s\n' "$INVESTIGATOR_BODY" | sed -n 's/.*\*\*Confidence\*\*: \(.*\)/\1/p' | head -1 | tr -cd 'A-Za-z' | tr '[:lower:]' '[:upper:]')
1013
+ case "$CONFIDENCE" in HIGH|MEDIUM|LOW) ;; *) CONFIDENCE="" ;; esac
1014
+ echo "Phase 7B.5: task_type=${TASK_TYPE:-unknown} confidence=${CONFIDENCE:-unknown} (from issue #${ISSUE_NUMBER})"
1015
+ fi
1016
+ if [ -n "${TASK_TYPE:-}" ] && [ -n "${CONFIDENCE:-}" ]; then
1017
+ CALIB_RAW=$(git show "origin/forge-knowledge:calibration/table.json" 2>/dev/null || echo '')
1018
+ if [ -n "$CALIB_RAW" ]; then
1019
+ CALIB_CELL=$(echo "$CALIB_RAW" | jq -r --arg tt "$TASK_TYPE" --arg c "$CONFIDENCE" \
1020
+ '.rows[] | select(.taskType == $tt and .confidence == $c and .trusted == true)' 2>/dev/null || echo '')
1021
+ if [ -n "$CALIB_CELL" ]; then
1022
+ SURVIVAL_RATE=$(echo "$CALIB_CELL" | jq -r '.survivalRate // "null"' 2>/dev/null || echo 'null')
1023
+ SAMPLE_COUNT=$(echo "$CALIB_CELL" | jq -r '.sampleCount // 0' 2>/dev/null || echo '0')
1024
+ CELL_FLAG=$(echo "$CALIB_CELL" | jq -r '.flag // "null"' 2>/dev/null || echo 'null')
1025
+ CALIBRATION_CELL="${TASK_TYPE} × ${CONFIDENCE}: survival=${SURVIVAL_RATE} (n=${SAMPLE_COUNT})"
1026
+ echo "Phase 7B.5: calibration cell found — ${CALIBRATION_CELL} flag=${CELL_FLAG}"
1027
+ if [ "$CONFIDENCE" = "HIGH" ] && [ "$CELL_FLAG" = "overconfidence" ]; then
1028
+ CALIBRATION_NEEDS_HUMAN=true
1029
+ CALIBRATION_NOTE="Calibration table: ${TASK_TYPE} × HIGH confidence has ${SURVIVAL_RATE} survival rate (< 0.80 threshold, n=${SAMPLE_COUNT}). Routing to needs-human per forge#1741 policy."
1030
+ echo "Phase 7B.5: CALIBRATION_NEEDS_HUMAN=true — ${CALIBRATION_NOTE}"
1031
+ else
1032
+ CALIBRATION_NOTE="Calibration cell: ${CALIBRATION_CELL} — within acceptable threshold"
1033
+ echo "Phase 7B.5: CALIBRATION_NEEDS_HUMAN=false — ${CALIBRATION_NOTE}"
1034
+ fi
1035
+ else
1036
+ CALIBRATION_NOTE="Calibration cell for ${TASK_TYPE} × ${CONFIDENCE} is absent or untrusted — using static behavior"
1037
+ echo "Phase 7B.5: no trusted cell — ${CALIBRATION_NOTE}"
1038
+ fi
1039
+ else
1040
+ CALIBRATION_NOTE="forge-knowledge:calibration/table.json not found — using static behavior"
1041
+ echo "Phase 7B.5: calibration table unavailable — ${CALIBRATION_NOTE}"
1042
+ fi
1043
+ else
1044
+ CALIBRATION_NOTE="task type or confidence not resolved from FORGE:INVESTIGATOR — using static behavior"
1045
+ echo "Phase 7B.5: could not resolve task type/confidence — ${CALIBRATION_NOTE}"
1046
+ fi
1047
+ # TRAJECTORY record: the threshold decision and the cell that justified it.
1048
+ if [ -n "$ISSUE_NUMBER" ] && [ "$DRY_RUN" != "true" ]; then
1049
+ gh issue comment "${ISSUE_NUMBER}" -R "$REPO" --body "<!-- FORGE:CALIBRATION_CHECK -->
1050
+ **Phase 7B.5 — Calibration Threshold Check**
1051
+ **Cell**: ${CALIBRATION_CELL:-not found}
1052
+ **CALIBRATION_NEEDS_HUMAN**: ${CALIBRATION_NEEDS_HUMAN}
1053
+ **Note**: ${CALIBRATION_NOTE}
1054
+
1055
+ **Phase 3B.5 — Provenance Trust Decision**
1056
+ **Intensity tier**: ${INTENSITY_TIER:-SHADOW}
1057
+ **Provenance cell**: \`${TABLE_CELL_KEY:-(unavailable)}\`
1058
+ **Shadow mode**: ${TRUST_SHADOW_MODE:-true}
1059
+ **TRUST_NEEDS_HUMAN**: ${TRUST_NEEDS_HUMAN:-false}
1060
+ **Timestamp**: $(date -u +%Y-%m-%dT%H:%M:%SZ)" 2>/dev/null || true
1061
+ fi
1062
+ echo "CALIBRATION_NEEDS_HUMAN=${CALIBRATION_NEEDS_HUMAN}"
1063
+ echo "CALIBRATION_CELL=${CALIBRATION_CELL}"
1064
+ # The orchestrator substitutes this line into a single-quoted shell assignment (review-pr.md Phase 8): never emit
1065
+ # a quote, backtick, dollar sign, backslash or newline (#3918 class).
1066
+ CALIBRATION_NOTE=$(printf '%s' "$CALIBRATION_NOTE" | tr -d "'\`\$\\\\" | tr '\r\n' ' ')
1067
+ echo "CALIBRATION_NOTE=${CALIBRATION_NOTE}"
1068
+ exit 0
1069
+ }
1070
+
1071
+ # ======================================================================================================================
1072
+ # merge-gate: Phase 8 (note disposition, in-PR fix, phase trail, guard state, routing, pre-merge, CI gate, merge)
1073
+ # ======================================================================================================================
1074
+ # Every refusal prints the REVIEW_RESULT line and exits 3 (4 for PHASE_TRAIL_FAILED, which carries the MISSING lines). A parked or
1075
+ # routed outcome (base-conflict, awaiting-merge) is reported through MERGE_OUTCOME. Nothing below a failed gate ever runs.
1076
+ do_merge_gate() {
1077
+ BASE="${BASE_OPT:-$CTX_BASE_BRANCH}"
1078
+ # --- note-disposition preflight: fail closed on an unreadable comment list ---
1079
+ DISPO_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null) || DISPO_JSON=""
1080
+ if [ -z "$DISPO_JSON" ]; then
1081
+ echo "NOTE DISPOSITION: could not read PR comments — refusing to merge (fail closed)" >&2
1082
+ blocked "note disposition unreadable"
1083
+ fi
1084
+ FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test("<!-- FINDING:"))] | length' 2>/dev/null)
1085
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
1086
+ if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$FINDING_COUNT" ]; then
1087
+ echo "NOTE DISPOSITION: scripts/trusted-comments.sh unresolvable or findings unreadable — refusing to merge (fail closed)" >&2
1088
+ blocked "note disposition unreadable"
1089
+ fi
1090
+ DISPOSITION_COUNT=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:NOTE_DISPOSITION') || DISPOSITION_COUNT=""
1091
+ if [ -z "$DISPOSITION_COUNT" ]; then
1092
+ echo "NOTE DISPOSITION: trust filter failed — refusing to merge (fail closed)" >&2
1093
+ blocked "note disposition unreadable"
1094
+ fi
1095
+ if [ "${FINDING_COUNT:-0}" -gt 0 ] && [ "${DISPOSITION_COUNT:-0}" -eq 0 ]; then
1096
+ echo "NOTE DISPOSITION: ${FINDING_COUNT} finding comment(s) but no FORGE:NOTE_DISPOSITION record — run §6B.5 before merging" >&2
1097
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body "Auto-merge skipped: review findings exist but §6B.5 note disposition was not recorded. Re-run \`/review-pr ${PR_NUMBER} --auto-merge --issue <N>\`." 2>/dev/null || true # allowlist:check-command-side-effects
1098
+ blocked "note disposition missing"
1099
+ fi
1100
+ # --- in-PR fix gate (§6B.6): an unanswered INPR_FIX request at the current head blocks the merge ---
1101
+ PR_HEAD_NOW=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq .headRefOid 2>/dev/null || echo "")
1102
+ INPR_BODIES=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INPR_FIX: ') || INPR_BODIES="__ERR__"
1103
+ INPR_WAIVED=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:INPR_FIX_WAIVED: head=${PR_HEAD_NOW}") || INPR_WAIVED=""
1104
+ if [ "$INPR_BODIES" = "__ERR__" ] || [ -z "$INPR_WAIVED" ]; then
1105
+ echo "IN-PR FIX: trust filter failed — refusing to merge (fail closed)" >&2
1106
+ blocked "note disposition unreadable"
1107
+ fi
1108
+ # Latest trusted request wins (bodies are JSON strings, one per line).
1109
+ INPR_REQ=$(printf '%s\n' "$INPR_BODIES" | tail -n 1 | jq -r 'capture("head=(?<h>[0-9a-f]{7,40})").h // ""' 2>/dev/null || echo "")
1110
+ if [ -n "$INPR_REQ" ] && [ "$INPR_REQ" = "$PR_HEAD_NOW" ] && [ "${INPR_WAIVED:-0}" -eq 0 ]; then
1111
+ echo "IN-PR FIX: CONFIRMED findings in this PR's files were requested at head ${PR_HEAD_NOW} — fix them before merging (§6B.6)" >&2
1112
+ # Not needs-human: /work-on R4 runs one fix round.
1113
+ blocked "in-pr fix required"
1114
+ fi
1115
+
1116
+ # --- phase-trail preflight: a reviewer verdict alone must not merge work whose earlier phases were skipped ---
1117
+ if [ -z "${MERGE_ISSUE:-}" ]; then
1118
+ echo "PHASE TRAIL: auto-merge requested without --issue — the issue is never inferred from PR-author-controlled text, so the phase trail cannot be verified; refusing to merge (fail closed)" >&2
1119
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body "Auto-merge skipped: no --issue was given, so the phase trail cannot be verified. Re-run \`/review-pr ${PR_NUMBER} --auto-merge --issue <N>\` or merge manually." 2>/dev/null || true # allowlist:check-command-side-effects
1120
+ # Not a PHASE_TRAIL_FAILED (nothing to re-run).
1121
+ blocked "auto-merge requires --issue"
1122
+ fi
1123
+ TRAIL_SCRIPT=$(forge_resolve verify-phase-trail.sh); DOCS_ONLY_SCRIPT=$(forge_resolve is-docs-only.sh)
1124
+ # Docs-only predicate: ONE shared copy (scripts/is-docs-only.sh). Fail closed: unresolved script or empty diff -> no flag.
1125
+ DOCS_ONLY_FLAG=""
1126
+ # Both sides of a rename (filename + previous_filename): `gh pr diff --name-only` collapses a rename to its destination.
1127
+ PR_FILES_ALL=$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[] | .filename, (.previous_filename // empty)' 2>/dev/null) || PR_FILES_ALL=""
1128
+ # Fail closed on truncation: the files API caps at 3000 files, so a PR at/over the cap (or an unreadable count) never yields a docs-only flag.
1129
+ PR_CHANGED_N=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .changed_files 2>/dev/null) || PR_CHANGED_N=""
1130
+ case "$PR_CHANGED_N" in ''|*[!0-9]*) PR_FILES_ALL="" ;; *) [ "$PR_CHANGED_N" -ge 3000 ] && PR_FILES_ALL="" ;; esac
1131
+ if [ -n "$PR_FILES_ALL" ] && [ -n "$DOCS_ONLY_SCRIPT" ] && echo "$PR_FILES_ALL" | bash "$DOCS_ONLY_SCRIPT"; then DOCS_ONLY_FLAG="--docs-only"; fi
1132
+ # Band cross-check: a non-docs PR diff means an agent-chosen INVESTIGATION band must not waive requirements. Anything not positively docs-only is a code diff.
1133
+ CODE_DIFF_FLAG="--code-diff"
1134
+ if [ -n "$DOCS_ONLY_FLAG" ]; then CODE_DIFF_FLAG=""; fi
1135
+ OVERRIDE_BOUND_HEAD=""
1136
+ if [ -z "$TRAIL_SCRIPT" ]; then
1137
+ # Fail closed: an unresolvable verifier is NOT a pass (never fall back to the consumer repo).
1138
+ echo "PHASE TRAIL: verify-phase-trail.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install) — refusing to merge" >&2
1139
+ TRAIL="PHASE_TRAIL: ERROR (verifier not resolvable)"; TRAIL_RC=127
1140
+ else
1141
+ # Bind the human break-glass override to the PR head. An unreadable head omits the flag, so no override is offered (fails closed).
1142
+ PR_HEAD_SHA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .head.sha 2>/dev/null) || PR_HEAD_SHA=""
1143
+ HEAD_SHA_ARGS=()
1144
+ if [[ "$PR_HEAD_SHA" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then HEAD_SHA_ARGS=(--head-sha "$PR_HEAD_SHA"); fi
1145
+ TRAIL=$(bash "$TRAIL_SCRIPT" "$MERGE_ISSUE" -R "$REPO" $DOCS_ONLY_FLAG $CODE_DIFF_FLAG ${HEAD_SHA_ARGS[@]+"${HEAD_SHA_ARGS[@]}"}); TRAIL_RC=$?
1146
+ if [ "$TRAIL_RC" -eq 0 ]; then
1147
+ # An `OVERRIDE:` line means the verifier accepted a human override. Record it from the verifier's output (never agent prose).
1148
+ OVR=$(printf '%s\n' "$TRAIL" | sed -n 's/^OVERRIDE: //p' | sed -n '1p')
1149
+ if [ -n "$OVR" ]; then
1150
+ OVERRIDE_BOUND_HEAD="$PR_HEAD_SHA" # the merge below must land exactly this commit
1151
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body "$(printf '<!-- FORGE:PHASE_TRAIL_OVERRIDE_APPLIED -->\nThe phase-trail gate was waived by a human break-glass override.\n\n%s\n%s\n%s\n' '```' "$OVR" '```')" # <!-- allowlist:check-command-side-effects -->
1152
+ fi
1153
+ fi
1154
+ fi
1155
+ if [ "$TRAIL_RC" -ge 2 ]; then
1156
+ # rc 2 (trail unreadable) / 127 (verifier not resolvable) is an infrastructure failure, NOT missing phases: never report PHASE_TRAIL_FAILED here.
1157
+ TRAIL_ERR_BODY="<!-- FORGE:PHASE_TRAIL_ERROR -->
1158
+ Auto-merge refused for PR #${PR_NUMBER}: the phase-trail verifier could not run (rc=${TRAIL_RC}). This is an infrastructure problem, not a missing phase. Do not re-run phases.
1159
+
1160
+ \`\`\`
1161
+ ${TRAIL}
1162
+ \`\`\`
1163
+
1164
+ Fix the cause (gh auth/API outage, \`FORGE_TRAIL_QG_SINCE\`/runner clock, or set \`FORGEDOCK_HOME\` when the verifier is unresolvable), then re-run /review-pr."
1165
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "$TRAIL_ERR_BODY" # <!-- allowlist:check-command-side-effects -->
1166
+ # Nothing can resume this automatically, so park it for a human instead of leaving workflow:in-review.
1167
+ gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
1168
+ # The PR stays open and unmerged; callers must NOT fall back to a manual merge.
1169
+ blocked "phase trail unreadable (rc=${TRAIL_RC})"
1170
+ elif [ "$TRAIL_RC" -ne 0 ]; then
1171
+ TRAIL_FAIL_BODY="<!-- FORGE:PHASE_TRAIL_FAILED -->
1172
+ Auto-merge refused for PR #${PR_NUMBER}: the issue's phase trail is incomplete.
1173
+
1174
+ \`\`\`
1175
+ ${TRAIL}
1176
+ \`\`\`
1177
+
1178
+ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING line), then re-run /review-pr."
1179
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "$TRAIL_FAIL_BODY" # <!-- allowlist:check-command-side-effects -->
1180
+ # rc 1 only; the work-on review phase (work-on/review.md R4) is the single owner of re-dispatching the named phases. Not an immediate needs-human escalation.
1181
+ echo "REVIEW_RESULT: status: PHASE_TRAIL_FAILED"
1182
+ printf '%s\n' "$TRAIL"
1183
+ exit 4
1184
+ fi
1185
+ [ "${TRAIL_RC:-1}" -eq 0 ] || blocked "phase trail unreadable (rc=${TRAIL_RC:-1})" # hard guard: nothing below runs unless the trail verified
1186
+
1187
+ # --- merge guard state: §7B verdict + purpose-regression + calibration + trust-escalation ---
1188
+ # These are decided in earlier steps (earlier shells), so they arrive as flags. An empty or unbound value would read as
1189
+ # "not blocking" and fall through to the merge path, so any value outside the expected set stops here instead of merging.
1190
+ [ "$VERDICT" = "APPROVE" ] && VERDICT="APPROVED"
1191
+ GUARD_STATE_OK=true
1192
+ case "$VERDICT" in "APPROVED"|"CHANGES REQUESTED") ;; *) GUARD_STATE_OK=false ;; esac
1193
+ for _guard_val in "$HAS_PURPOSE_REGRESSION" "$CALIBRATION_NEEDS_HUMAN" "$TRUST_NEEDS_HUMAN" "$BASE_CONFLICT" "$OTHER_BLOCKING"; do
1194
+ case "$_guard_val" in true|false) ;; *) GUARD_STATE_OK=false ;; esac
1195
+ done
1196
+ if [ "$GUARD_STATE_OK" != "true" ]; then
1197
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: the Phase 8 merge guard state is unbound (VERDICT='${VERDICT}', HAS_PURPOSE_REGRESSION='${HAS_PURPOSE_REGRESSION}', CALIBRATION_NEEDS_HUMAN='${CALIBRATION_NEEDS_HUMAN}', TRUST_NEEDS_HUMAN='${TRUST_NEEDS_HUMAN}', BASE_CONFLICT='${BASE_CONFLICT}', OTHER_BLOCKING='${OTHER_BLOCKING}'). Not merging; re-run /review-pr." # <!-- allowlist:check-command-side-effects -->
1198
+ gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
1199
+ blocked "merge guard state unbound"
1200
+ fi
1201
+ # ONLY_BASE_CONFLICT: the base conflict is the ONLY reason for CHANGES REQUESTED, evaluated after every human gate is set.
1202
+ ONLY_BASE_CONFLICT=false
1203
+ if [ "$BASE_CONFLICT" = "true" ] && [ "$OTHER_BLOCKING" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ] && [ "$CALIBRATION_NEEDS_HUMAN" != "true" ] && [ "$TRUST_NEEDS_HUMAN" != "true" ]; then
1204
+ ONLY_BASE_CONFLICT=true
1205
+ fi
1206
+ if [ "$ONLY_BASE_CONFLICT" = "true" ] && [ "$VERDICT" = "CHANGES REQUESTED" ]; then
1207
+ # Pure base conflict: route to the base-sync path, NOT needs-human. A standalone /review-pr caller just sees the blocker.
1208
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push), re-running the quality gate, then re-reviewing the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1209
+ echo "MERGE_OUTCOME=base-conflict"
1210
+ blocked "base-conflict"
1211
+ elif [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "$TRUST_NEEDS_HUMAN" = "true" ]; then
1212
+ BLOCK_REASON=""
1213
+ [ "$VERDICT" = "CHANGES REQUESTED" ] && BLOCK_REASON="review verdict is CHANGES REQUESTED (blocking finding confirmed by Phase 7B)"
1214
+ [ "$HAS_PURPOSE_REGRESSION" = "true" ] && BLOCK_REASON="${BLOCK_REASON:+${BLOCK_REASON}; }purpose regression detected by Phase 7A (\`HAS_PURPOSE_REGRESSION=true\`)"
1215
+ [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] && BLOCK_REASON="${BLOCK_REASON:+${BLOCK_REASON}; }calibration threshold: ${CALIBRATION_NOTE}"
1216
+ [ "$TRUST_NEEDS_HUMAN" = "true" ] && BLOCK_REASON="${BLOCK_REASON:+${BLOCK_REASON}; }provenance trust: NOVEL_NEEDS_HUMAN tier for cell \`${TABLE_CELL_KEY}\` — no sufficient prior data (forge#1745)"
1217
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: ${BLOCK_REASON}. Manual review required before merging."
1218
+ gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true
1219
+ echo "MERGE_OUTCOME=needs-human"
1220
+ blocked "auto-merge blocked: ${BLOCK_REASON}"
1221
+ fi
1222
+
1223
+ # --- pre-merge mergeability guard: re-fetch fresh state (the base may have moved while the review ran) ---
1224
+ PRE_MERGE_RESULT=$(gh pr view "$PR_NUMBER" -R "$REPO" --json mergeable,mergeStateStatus --jq '"\(.mergeable)|\(.mergeStateStatus)"')
1225
+ PRE_MERGE_HEALTH=${PRE_MERGE_RESULT%%|*}
1226
+ PRE_MERGE_HEALTH_STATE=${PRE_MERGE_RESULT##*|}
1227
+ PRE_MERGE_BASE_CONFLICT=false
1228
+ if [ "$PRE_MERGE_HEALTH" = "CONFLICTING" ] || [ "$PRE_MERGE_HEALTH_STATE" = "DIRTY" ]; then
1229
+ PRE_MERGE_BASE_CONFLICT=true
1230
+ elif [ "$PRE_MERGE_HEALTH_STATE" = "BEHIND" ]; then
1231
+ # BEHIND is a conflict only under required-up-to-date protection; unreadable protection counts as "not required".
1232
+ [ "$(gh api "repos/${REPO}/branches/${BASE}/protection" --jq '.required_status_checks.strict // false' 2>/dev/null)" = "true" ] && PRE_MERGE_BASE_CONFLICT=true
1233
+ fi
1234
+ if [ "$PRE_MERGE_BASE_CONFLICT" = "true" ]; then
1235
+ # Base moved during review: route to the base-sync path, NOT needs-human.
1236
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push) and then re-reviewing. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1237
+ echo "MERGE_OUTCOME=base-conflict"
1238
+ blocked "base-conflict"
1239
+ elif [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then
1240
+ # Branch protection not satisfied: syncing the base cannot clear it, so this is NOT a base-conflict blocker.
1241
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: branch protection is not satisfied (\`mergeStateStatus=BLOCKED\`); syncing the base cannot clear it. Satisfy the required review/checks, then re-run /review-pr."
1242
+ gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true
1243
+ echo "MERGE_OUTCOME=protection-blocked"
1244
+ blocked "branch protection not satisfied"
1245
+ fi
1246
+
1247
+ # --- previously-escalated re-review guard (base-scoped): park at awaiting-merge, never merge here ---
1248
+ PREVIOUSLY_ESCALATED=$(gh issue view "$MERGE_ISSUE" -R "$REPO" --json labels,comments \
1249
+ --jq '([.labels[].name | . == "needs-human" or . == "workflow:remediating"] + [.comments[].body | contains("FORGE:REMEDIATION")]) | any' 2>/dev/null || echo "false")
1250
+ GUARD_BASE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
1251
+ if [ "$PREVIOUSLY_ESCALATED" = "true" ]; then
1252
+ # `jq -r '.baseRefName'` stringifies a JSON null to the text "null": the resolver treats that like an unresolved base (strict).
1253
+ GATE_SCRIPT=$(forge_resolve resolve-deploy-gate.sh)
1254
+ if [ -n "$GATE_SCRIPT" ]; then GUARD_GATE=$(bash "$GATE_SCRIPT" "$GUARD_BASE" "$REPO" 2>/dev/null || echo true); else GUARD_GATE=true; fi; [ "$GUARD_GATE" = "false" ] || GUARD_GATE=true
1255
+ if [ "$GUARD_GATE" = "false" ]; then
1256
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "🟠 PR #${PR_NUMBER} was previously escalated (\`needs-human\`) and has now been re-reviewed to \`${VERDICT:-APPROVED}\` with a clean mergeability check on non-default-branch base \`${GUARD_BASE}\`. It is parked at \`workflow:awaiting-merge\` for the remediation auto-land bar (\`remediate.md\` Phase M7, forge#2570) — which auto-merges it on the same fast-lane condition the normal \`/work-on\` path uses for this target branch — NOT for a human merge decision. If no remediation \`M7\` follows (e.g. this review was invoked standalone), merge manually once reviewed: \`gh pr merge ${PR_NUMBER} -R ${REPO} --merge\`."
1257
+ else
1258
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "🟠 PR #${PR_NUMBER} was previously escalated (\`needs-human\`) and has now been re-reviewed to \`${VERDICT:-APPROVED}\` with a clean mergeability check. This PR targets the deploy gate (\`${GUARD_BASE:-unresolved base}\`), so it is held at \`workflow:awaiting-merge\` for a human merge decision — \`staging → main\` is the genuine human gate. Merge manually once reviewed: \`gh pr merge ${PR_NUMBER} -R ${REPO} --merge\`."
1259
+ fi
1260
+ # transition-label.sh: install roots only. Unresolvable falls through to the prose label edit so the transition is never silently skipped.
1261
+ TRANSITION_SCRIPT=$(forge_resolve transition-label.sh)
1262
+ if [ -n "$TRANSITION_SCRIPT" ]; then
1263
+ bash "$TRANSITION_SCRIPT" "$MERGE_ISSUE" -R "$REPO" awaiting-merge
1264
+ else
1265
+ gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "workflow:awaiting-merge" \
1266
+ --remove-label "needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
1267
+ fi
1268
+ # Do not merge here: a human decides for a deploy-gate base, remediate.md Phase M7's auto-land bar for any other base.
1269
+ echo "MERGE_OUTCOME=parked-awaiting-merge"
1270
+ echo "GUARD_GATE=${GUARD_GATE}"
1271
+ exit 0
1272
+ fi
1273
+
1274
+ # --- CI gate (MANDATORY before any autonomous merge): merge only when every check on the PR is green ---
1275
+ CI_GATE_SCRIPT=$(forge_resolve wait-ci-green.sh)
1276
+ if [ -n "$CI_GATE_SCRIPT" ]; then CI_GATE_OUT=$(bash "$CI_GATE_SCRIPT" "$PR_NUMBER" -R "$REPO"); CI_GATE_RC=$?
1277
+ else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
1278
+ echo "$CI_GATE_OUT"
1279
+ GATED_HEAD=$(printf '%s\n' "$CI_GATE_OUT" | sed -n 's/^CI_GATE_HEAD: //p' | head -1)
1280
+ # rc 2 "head moved" is a stale review, not a human problem; rc 3 TIMEOUT means CI is still running.
1281
+ printf '%s\n' "$CI_GATE_OUT" | grep -q 'PR head moved during the wait' && CI_GATE_RC=4
1282
+ if [ "$CI_GATE_RC" -eq 3 ]; then
1283
+ echo "CI_GATE_TIMEOUT: CI still running — re-run this subcommand (up to 3 more times) before treating it as a failure."
1284
+ fi
1285
+ # Reviewed-head guard: merge only the exact commit this review approved. REVIEWED_SHORT is re-derived from this review's
1286
+ # FORGE:REVIEW_ROUTE marker, never assumed. A docs-only delta (this review's own §6B.5 note fixes) is allowed; any other change is stale.
1287
+ REVIEWED_SHORT=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate --jq '.[] | select(.body | contains("FORGE:REVIEW_ROUTE")) | .body' 2>/dev/null \
1288
+ | sed -n 's/.*FORGE:REVIEW_ROUTE[^>]* sha=\([0-9a-f]\{7,\}\).*/\1/p' | tail -1)
1289
+ if [ "$CI_GATE_RC" -eq 0 ]; then
1290
+ if [ -z "$REVIEWED_SHORT" ] || [ -z "$GATED_HEAD" ] || [ "$GATED_HEAD" = "unknown" ]; then
1291
+ CI_GATE_RC=4; CI_GATE_OUT="STALE_REVIEW: cannot bind the merge to a reviewed commit (route sha='${REVIEWED_SHORT}', gated head='${GATED_HEAD}')"
1292
+ elif [ "${GATED_HEAD#"$REVIEWED_SHORT"}" = "$GATED_HEAD" ]; then
1293
+ REVIEW_BASE_SHA=$(gh api "repos/${REPO}/commits/${REVIEWED_SHORT}" --jq '.sha' 2>/dev/null)
1294
+ POST_REVIEW_FILES=$(gh api "repos/${REPO}/compare/${REVIEW_BASE_SHA:-$REVIEWED_SHORT}...${GATED_HEAD}" --jq '.files[] | .filename, (.previous_filename // empty)' 2>/dev/null)
1295
+ if [ -n "$POST_REVIEW_FILES" ] && [ -n "$DOCS_ONLY_SCRIPT" ] && printf '%s\n' "$POST_REVIEW_FILES" | bash "$DOCS_ONLY_SCRIPT"; then
1296
+ echo "Post-review commits are docs-only (note fixes) — reviewed code unchanged."
1297
+ else
1298
+ CI_GATE_RC=4; CI_GATE_OUT="STALE_REVIEW: PR head ${GATED_HEAD:0:7} is not the reviewed commit ${REVIEWED_SHORT} and the delta is not docs-only"
1299
+ fi
1300
+ fi
1301
+ fi
1302
+ # An accepted break-glass override is bound to one exact head: a push after the verifier ran (even a docs-only one) is not covered.
1303
+ if [ "$CI_GATE_RC" -eq 0 ] && [ -n "${OVERRIDE_BOUND_HEAD:-}" ] && [ "$GATED_HEAD" != "$OVERRIDE_BOUND_HEAD" ]; then
1304
+ CI_GATE_RC=4; CI_GATE_OUT="STALE_REVIEW: break-glass override was bound to ${OVERRIDE_BOUND_HEAD:0:7} but the gated head is ${GATED_HEAD:0:7}"
1305
+ fi
1306
+ if [ "$CI_GATE_RC" -eq 4 ]; then
1307
+ # Not a human problem: the caller re-runs /review-pr on the new head (work-on/review.md R4).
1308
+ echo "MERGE_OUTCOME=stale-review"
1309
+ blocked "stale review — ${CI_GATE_OUT}; re-run /review-pr on the new head"
1310
+ elif [ "$CI_GATE_RC" -eq 3 ]; then
1311
+ # TIMEOUT = checks still pending, not failing (wait-ci-green.sh rc=3). Not a human problem: no needs-human label,
1312
+ # so the documented re-run (or engine retry) merges once CI is green instead of parking behind the
1313
+ # previously-escalated guard (#3919).
1314
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⏳ Auto-merge deferred for PR #${PR_NUMBER}: CI checks still pending after the wait window (ci gate rc=3). Re-run the merge gate once they finish; nothing to fix." 2>/dev/null || true # allowlist:check-command-side-effects
1315
+ echo "MERGE_OUTCOME=ci-pending"
1316
+ blocked "checks still pending after the wait window (timeout) — re-run the merge once CI finishes"
1317
+ elif [ "$CI_GATE_RC" -ne 0 ]; then
1318
+ CI_MSG="⛔ Auto-merge refused for PR #${PR_NUMBER}: CI is not green (ci gate rc=${CI_GATE_RC}). The PR stays open; fix the failing checks on its branch (remediation treats this as FIXABLE) or re-run flaky ones.
1319
+ \`\`\`
1320
+ ${CI_GATE_OUT}
1321
+ \`\`\`"
1322
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "$CI_MSG" 2>/dev/null || true # allowlist:check-command-side-effects
1323
+ gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
1324
+ echo "MERGE_OUTCOME=ci-not-green"
1325
+ blocked "ci gate not green (rc=${CI_GATE_RC})"
1326
+ fi
1327
+
1328
+ # --- merge exactly the commit the CI gate (and the reviewed-head guard) checked ---
1329
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "Review complete for PR #${PR_NUMBER}. Verdict: ${VERDICT:-APPROVED}. CI green. Proceeding to merge."
1330
+ gh pr merge "$PR_NUMBER" -R "$REPO" --merge --match-head-commit "$GATED_HEAD"
1331
+ MERGE_STATE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json state --jq '.state')
1332
+ if [ "$MERGE_STATE" != "MERGED" ]; then
1333
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "PR #${PR_NUMBER} merge failed. State: $MERGE_STATE."
1334
+ echo "MERGE_OUTCOME=merge-failed"
1335
+ else
1336
+ echo "MERGE_OUTCOME=merged"
1337
+ fi
1338
+ echo "MERGE_STATE=${MERGE_STATE}"
1339
+ echo "MERGED_HEAD=${GATED_HEAD}"
1340
+ exit 0
1341
+ }
1342
+
1343
+ # ======================================================================================================================
1344
+ # demilestone: 8B (merge just happened) and 9A (manual-merge fallback) review-finding demilestoning
1345
+ # ======================================================================================================================
1346
+ # With --merge-state the caller reports the state it just observed (8B: only MERGED runs); without it the state is read from the PR (9A).
1347
+ do_demilestone() {
1348
+ [ "$IS_MILESTONE" = "true" ] || { echo "DEMILESTONED=0"; exit 0; }
1349
+ if [ -z "$MERGE_STATE_IN" ]; then MERGE_STATE_IN=$(gh pr view "$PR_NUMBER" -R "$REPO" --json state --jq '.state' 2>/dev/null || echo ""); fi
1350
+ [ "$MERGE_STATE_IN" = "MERGED" ] || { echo "DEMILESTONED=0"; exit 0; }
1351
+ echo "Phase 8B/9A: Clearing milestone from open review-finding issues referencing PR #${PR_NUMBER}..."
1352
+ # The title template in Phase 6C always includes: "review finding — PR #${PR_NUMBER}"
1353
+ FINDINGS_TO_DEMILESTONE=$(gh issue list -R "${REPO}" \
1354
+ --state open \
1355
+ --label "review-finding" \
1356
+ --limit 200 \
1357
+ --json number,title,milestone \
1358
+ --jq ".[] | select(.milestone != null) | select(.title | test(\"PR #${PR_NUMBER}\")) | .number" \
1359
+ 2>/dev/null || echo "")
1360
+ MOVED_COUNT=0
1361
+ if [ -z "$FINDINGS_TO_DEMILESTONE" ]; then
1362
+ echo "Phase 8B/9A: No open review-finding issues with milestones found referencing PR #${PR_NUMBER} (already cleared or none created)."
1363
+ else
1364
+ for FINDING_NUM in $FINDINGS_TO_DEMILESTONE; do
1365
+ case "$FINDING_NUM" in ''|*[!0-9]*) continue ;; esac
1366
+ FINDING_TITLE=$(gh issue view "$FINDING_NUM" -R "${REPO}" --json title --jq '.title' 2>/dev/null || echo "#${FINDING_NUM}")
1367
+ if gh issue edit "$FINDING_NUM" -R "${REPO}" --milestone "" 2>/dev/null; then
1368
+ echo " Moved to fast lane: #${FINDING_NUM} — ${FINDING_TITLE}"
1369
+ MOVED_COUNT=$((MOVED_COUNT + 1))
1370
+ else
1371
+ echo " WARNING: Failed to clear milestone for #${FINDING_NUM}"
1372
+ fi
1373
+ done
1374
+ echo "Phase 8B/9A: Review finding demilestoning complete."
1375
+ fi
1376
+ echo "DEMILESTONED=${MOVED_COUNT}"
1377
+ exit 0
1378
+ }
1379
+
1380
+ # ======================================================================================================================
1381
+ # integrity: Phase 9 stale check and verifiable agent count
1382
+ # ======================================================================================================================
1383
+ do_integrity() {
1384
+ is_sha "$REVIEW_SHA" || blocked "REVIEW_SHA is not a commit id (pass --review-sha or --context)"
1385
+ CURRENT_SHA=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
1386
+ REVIEW_IS_STALE="false"
1387
+ # An unreadable head is stale (fail closed): the summary must never claim CURRENT without having read the head.
1388
+ if [ -z "$CURRENT_SHA" ] || [ "$CURRENT_SHA" != "$REVIEW_SHA" ]; then REVIEW_IS_STALE="true"; fi
1389
+ # Same trust rule as Phase 3C/4: only trusted comments whose body starts with the marker count.
1390
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
1391
+ REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)
1392
+ BASE_BRANCH="${BASE_OPT:-$CTX_BASE_BRANCH}"
1393
+ [ -n "$BASE_BRANCH" ] || BASE_BRANCH=$(gh pr view "$PR_NUMBER" -R "$REPO" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
1394
+ COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
1395
+ _dm=$(forge_resolve lib/review-delta-mode.sh); [ -n "$_dm" ] && . "$_dm" || { review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; }
1396
+ # BASE_SYNC_ONLY: zero reviewers is the expected panel only when the carry record is trusted and the resolver re-verifies it.
1397
+ CARRIED_FROM=""
1398
+ if [ "$REVIEW_MODE" = "BASE_SYNC_ONLY" ]; then CARRIED_FROM=$(carry_verified_from); fi
1399
+ ACTUAL_AGENT_DOMAINS=""
1400
+ if [ -n "$TRUSTED_SCRIPT" ]; then
1401
+ ACTUAL_AGENT_DOMAINS=$(printf '%s' "$COMMENTS_JSON" \
1402
+ | bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
1403
+ | jq -rs '[.[] | scan("^<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | join(", ")' 2>/dev/null || echo "") # unresolvable => empty => count 0 (degraded, fail closed)
1404
+ fi
1405
+ # No `|| echo 0` fallback after grep -c: it always prints a count (0 on no match) even though it exits 1, so a fallback would double-count to "0\n0".
1406
+ ACTUAL_AGENT_COUNT=$(echo "$ACTUAL_AGENT_DOMAINS" | tr ',' '\n' | grep -c '.' 2>/dev/null)
1407
+ # A verified carried head dispatched no reviewers by design: report it as carried instead of a degraded zero-agent panel.
1408
+ if [ -n "$CARRIED_FROM" ]; then ACTUAL_AGENT_DOMAINS="carried from ${CARRIED_FROM}"; ACTUAL_AGENT_COUNT=0; fi
1409
+ echo "REVIEW_SHA=${REVIEW_SHA}"
1410
+ echo "CURRENT_SHA=${CURRENT_SHA}"
1411
+ echo "REVIEW_IS_STALE=${REVIEW_IS_STALE}"
1412
+ echo "ACTUAL_AGENT_COUNT=${ACTUAL_AGENT_COUNT}"
1413
+ echo "ACTUAL_AGENT_DOMAINS=${ACTUAL_AGENT_DOMAINS}"
1414
+ echo "CARRIED_FROM: ${CARRIED_FROM}"
1415
+ exit 0
1416
+ }
1417
+
1418
+ case "$SUB" in
1419
+ extract) do_extract ;;
1420
+ classify) do_classify ;;
1421
+ file-prepare) do_file_prepare ;;
1422
+ verdict) do_verdict ;;
1423
+ calibrate) do_calibrate ;;
1424
+ merge-gate) do_merge_gate ;;
1425
+ demilestone) do_demilestone ;;
1426
+ integrity) do_integrity ;;
1427
+ esac