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