forgedock 1.18.0 → 1.20.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 (135) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/outcome-codes.mjs +13 -0
  3. package/bin/engine/phases.mjs +54 -4
  4. package/bin/engine/runlog.mjs +10 -2
  5. package/bin/engine/telemetry-seam.mjs +3 -0
  6. package/bin/engine/telemetry.test.mjs +3 -2
  7. package/bin/engine-cli.mjs +32 -2
  8. package/bin/engine.mjs +187 -10
  9. package/bin/runner.mjs +89 -13
  10. package/bin/stats/aggregate.mjs +7 -3
  11. package/bin/stats/cache.mjs +7 -3
  12. package/bin/stats/github-source.mjs +136 -28
  13. package/bin/stats/index.mjs +46 -9
  14. package/bin/stats/render.mjs +1 -1
  15. package/bin/stats/stats.test.mjs +201 -40
  16. package/commands/orchestrate/phase-4-execution.md +1 -1
  17. package/commands/review-pr-agents/protocols.md +19 -0
  18. package/commands/review-pr-staging.md +4 -4
  19. package/commands/review-pr.md +31 -20
  20. package/commands/work-on/build/architect.md +54 -1
  21. package/commands/work-on/build/implement.md +28 -1
  22. package/commands/work-on/build/validate.md +321 -29
  23. package/commands/work-on/build.md +184 -17
  24. package/commands/work-on/close.md +9 -4
  25. package/commands/work-on/investigate.md +7 -0
  26. package/commands/work-on/remediate.md +43 -17
  27. package/commands/work-on/review.md +43 -22
  28. package/commands/work-on.md +4 -4
  29. package/docs/CONFIG.md +93 -2
  30. package/docs/OUTCOME-CODES.md +5 -1
  31. package/docs/RELEASE-NOTES.md +15 -0
  32. package/docs/STATS.md +11 -4
  33. package/docs/TELEMETRY.md +5 -2
  34. package/docs/WORK-ON-RUNTIME.md +29 -3
  35. package/package.json +2 -2
  36. package/packages/protocol/src/types.js +1 -1
  37. package/scripts/acceptance-red-green.sh +90 -0
  38. package/scripts/acceptance-red-green.test.sh +74 -0
  39. package/scripts/architect-resume-match.test.sh +12 -3
  40. package/scripts/build-knowledge-index.mjs +8 -5
  41. package/scripts/builder-resume-match.test.sh +12 -3
  42. package/scripts/calibration.mjs +5 -2
  43. package/scripts/cap-findings.test.sh +2 -0
  44. package/scripts/check-contract-scope.test.sh +2 -0
  45. package/scripts/check-edge-cases.sh +143 -0
  46. package/scripts/check-edge-cases.test.sh +175 -0
  47. package/scripts/check-spec-markers.sh +4 -0
  48. package/scripts/check-test-tmp-cleanup.sh +56 -0
  49. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  50. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  51. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  52. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  53. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  54. package/scripts/ci-local/adapters/common.mjs +115 -0
  55. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  56. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  57. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  58. package/scripts/ci-local/adapters/portability.mjs +104 -0
  59. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  60. package/scripts/ci-local/cli.mjs +50 -0
  61. package/scripts/ci-local/lib.mjs +379 -0
  62. package/scripts/ci-local/plan.mjs +186 -0
  63. package/scripts/ci-local/run.mjs +451 -0
  64. package/scripts/ci-local/scoping.mjs +531 -0
  65. package/scripts/ci-local.sh +98 -0
  66. package/scripts/ci-local.test.sh +1108 -0
  67. package/scripts/classify-finding.sh +33 -7
  68. package/scripts/classify-finding.test.sh +23 -0
  69. package/scripts/classify-test-run.sh +69 -0
  70. package/scripts/classify-test-run.test.sh +90 -0
  71. package/scripts/close-parent-c3.test.sh +2 -0
  72. package/scripts/consumer-sweep.sh +569 -0
  73. package/scripts/consumer-sweep.test.sh +357 -0
  74. package/scripts/detect-append-registries.sh +106 -0
  75. package/scripts/detect-append-registries.test.sh +64 -0
  76. package/scripts/file-deferred-followups.sh +91 -30
  77. package/scripts/file-deferred-followups.test.sh +101 -8
  78. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  79. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  80. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  81. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  82. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  83. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  84. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  85. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  86. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  87. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  88. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  89. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  90. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  91. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  92. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  93. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  94. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  95. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  96. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  97. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  98. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  99. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  100. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  101. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  102. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  103. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  104. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  105. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  106. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  107. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  108. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  109. package/scripts/forge-root.test.sh +1 -1
  110. package/scripts/lib/telemetry.sh +3 -0
  111. package/scripts/remediate-approved-head.test.sh +2 -0
  112. package/scripts/remediate-basesync.test.sh +1 -1
  113. package/scripts/review-acceptance-gate.sh +2 -2
  114. package/scripts/review-acceptance-gate.test.sh +20 -0
  115. package/scripts/review-delta.sh +8 -1
  116. package/scripts/review-delta.test.sh +19 -0
  117. package/scripts/review-finalize.sh +294 -53
  118. package/scripts/review-finalize.test.sh +347 -9
  119. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  120. package/scripts/review-prepare.sh +5 -3
  121. package/scripts/review-prepare.test.sh +18 -2
  122. package/scripts/review-roster.sh +18 -5
  123. package/scripts/review-roster.test.sh +57 -1
  124. package/scripts/run-shell-tests.sh +5 -0
  125. package/scripts/selfreview-plan.sh +86 -0
  126. package/scripts/selfreview-plan.test.sh +90 -0
  127. package/scripts/spawn-depth-check.sh +5 -4
  128. package/scripts/spawn-depth-check.test.sh +3 -0
  129. package/scripts/telemetry.test.sh +10 -0
  130. package/scripts/transition-label.sh +26 -0
  131. package/scripts/trusted-comments.test.sh +2 -1
  132. package/scripts/verify-phase-trail.test.sh +14 -0
  133. package/scripts/wait-ci-green.sh +61 -9
  134. package/scripts/wait-ci-green.test.sh +41 -3
  135. package/scripts/with-run-tmpdir.sh +20 -0
@@ -18,15 +18,16 @@
18
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
19
  # verdict 7A gate log + 7B base-conflict computation; posts the verdict comment when --result stale|clean|blocking is given.
20
20
  # calibrate 7B.5 calibration-table consultation (can only tighten); records FORGE:CALIBRATION_CHECK on the linked issue.
21
- # merge-gate Phase 8: note-disposition, in-PR fix, --issue, phase-trail, guard-state, base-conflict / calibration / trust routing,
21
+ # merge-gate Phase 8: note-disposition, in-PR fix, --issue, phase-trail, degraded-gate review, guard-state, base-conflict / calibration / trust routing,
22
22
  # pre-merge mergeability, previously-escalated parking, CI gate, reviewed-head guard, merge. Never skips a gate.
23
23
  # demilestone 8B / 9A: clears the milestone from open review-finding issues of a merged milestone PR.
24
24
  # integrity Phase 9: stale-head check and the verifiable trusted REVIEW-AGENT count (carry-aware).
25
25
  #
26
26
  # Boundary: the filing call itself (the /issue skill with its programmatic contract) and the LLM-authored finding body stay in the
27
27
  # spec: a shell script cannot invoke a skill. The script ends at the work list (file-prepare) and resumes at file-verify.
28
- # Also left to the caller: INTEG-N findings from Phase 2.5, unstructured-finding extraction when `extract` reports 0 structured
29
- # findings, pattern-class consolidation issue creation, project-board add, 6D, and the choice of which NOTEs are fixed or dropped
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
30
31
  # (pass --notes-fixed / --notes-dropped; every other plain NOTE is counted as listed).
31
32
  #
32
33
  # Options:
@@ -48,17 +49,23 @@
48
49
  # contract.issue is ignored (no single fixing issue, lineage none) and the disposition record carries out_of_scope= and scope=
49
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.
50
51
  # verdict: --merge-health S --merge-health-state S [--base B] [--head-branch H] [--is-milestone true|false] [--has-purpose-regression true|false]
51
- # [--purpose-findings TEXT] [--result stale|clean|blocking] [--agents TEXT] [--findings-count N] [--blocking-count N]
52
- # [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--dry-run]
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] [--other-blocking true|false] [--out-dir D] [--dry-run]
54
+ # (--other-blocking: the reviewer's criterion 1/2/3/5 result; resolved and persisted to OUT_DIR/other-blocking.txt, which merge-gate ORs in)
53
55
  # calibrate: [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--trust-needs-human B] [--dry-run]; the issue is context contract.issue or --issue N
54
56
  # merge-gate: --verdict "APPROVED|CHANGES REQUESTED" --has-purpose-regression B --calibration-needs-human B --trust-needs-human B
55
- # --base-conflict B --other-blocking B [--calibration-note T] [--table-cell-key K] [--base B] [--issue N]
57
+ # --base-conflict B --other-blocking B [--calibration-note-file F | --calibration-note T] [--table-cell-key K] [--base B] [--issue N]
56
58
  # (every B is true|false; an empty or other value aborts the merge: the guard state must be bound)
59
+ # Free text (purpose findings, agents, calibration note) derives from PR content: pass it through the *-file flags (a file written with the
60
+ # file-write tool, read here as data: control characters stripped, length capped, unreadable file = empty). The inline TEXT flags are for
61
+ # trusted callers only; the file flag wins when both are given. Never place free text in a shell word.
57
62
  # demilestone: --is-milestone true|false [--merge-state S] (8B passes MERGED; 9A omits it and the state is read from the PR)
58
63
  # Env: FORGE_CONFIG (forge.yaml for review.max_filed_findings; default ./forge.yaml).
59
64
  # Exit: 0 ok | 3 BLOCKED (REVIEW_RESULT: status: BLOCKED printed; the caller must stop the review, never skip) | 4 merge-gate only:
60
65
  # REVIEW_RESULT: status: PHASE_TRAIL_FAILED printed, followed by the verifier's MISSING lines (re-dispatch is owned by work-on/review.md R4)
61
66
  # | 1 file-verify found no issue number | 2 usage error
67
+ # merge-gate: `blocked degraded-gate-unreviewed` (exit 3, needs-human added) when the linked issue's newest trusted FORGE:QUALITY_GATE `**Degraded**:` line or
68
+ # FORGE:ACCEPTANCE_GATE:DEGRADED marker lists checks that no trusted `### Degraded Paths Reviewed` PR section names (forge#3628).
62
69
  # merge-gate reports every non-merging routed outcome as MERGE_OUTCOME=<merged|merge-failed|parked-awaiting-merge|base-conflict|needs-human|
63
70
  # protection-blocked|stale-review|ci-not-green>; everything except merged / merge-failed / parked-awaiting-merge also exits 3 with a blocker.
64
71
  #
@@ -76,9 +83,10 @@ PR_NUMBER=""; REPO=""; CONTEXT=""; OUT_DIR=""; IN_FILE=""; OUT_FILE=""; AUTO_MER
76
83
  REVIEW_SHA_OPT=""; REVIEW_MODE_OPT=""; BASE_OPT=""; HEAD_BRANCH=""; ISSUE_OPT=""; VERDICT=""; HAS_PURPOSE_REGRESSION=""; CALIBRATION_NEEDS_HUMAN=""; CALIBRATION_NOTE=""; TRUST_NEEDS_HUMAN=""
77
84
  BASE_CONFLICT=""; OTHER_BLOCKING=""; INTENSITY_TIER=""; TABLE_CELL_KEY=""; TRUST_SHADOW_MODE=""; MERGE_HEALTH=""; MERGE_HEALTH_STATE=""; RESULT=""
78
85
  AGENTS_TEXT=""; FINDINGS_COUNT=""; BLOCKING_COUNT=""; PURPOSE_FINDINGS=""; IS_MILESTONE="false"; MERGE_STATE_IN=""
86
+ CALIBRATION_NOTE_FILE=""; AGENTS_FILE=""; PURPOSE_FINDINGS_FILE=""
79
87
  while [ $# -gt 0 ]; do
80
88
  case "$1" in
81
- --pr|--repo|--context|--out-dir|--findings|--out|--auto-merge|--notes-fixed|--notes-dropped|--patterns|--base|--head-branch|--issue|--verdict|--has-purpose-regression|--calibration-needs-human|--calibration-note|--trust-needs-human|--base-conflict|--other-blocking|--intensity-tier|--table-cell-key|--trust-shadow-mode|--merge-health|--merge-health-state|--result|--agents|--findings-count|--blocking-count|--purpose-findings|--is-milestone|--merge-state|--review-sha|--review-mode|--scope-files|--prior-prs)
89
+ --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)
82
90
  [ $# -ge 2 ] || { echo "review-finalize: $1 requires a value" >&2; exit 2; } ;;
83
91
  esac
84
92
  case "$1" in
@@ -101,6 +109,9 @@ while [ $# -gt 0 ]; do
101
109
  --has-purpose-regression) HAS_PURPOSE_REGRESSION="${2:-}"; shift 2 ;;
102
110
  --calibration-needs-human) CALIBRATION_NEEDS_HUMAN="${2:-}"; shift 2 ;;
103
111
  --calibration-note) CALIBRATION_NOTE="${2:-}"; shift 2 ;;
112
+ --calibration-note-file) CALIBRATION_NOTE_FILE="${2:-}"; shift 2 ;;
113
+ --agents-file) AGENTS_FILE="${2:-}"; shift 2 ;;
114
+ --purpose-findings-file) PURPOSE_FINDINGS_FILE="${2:-}"; shift 2 ;;
104
115
  --trust-needs-human) TRUST_NEEDS_HUMAN="${2:-}"; shift 2 ;;
105
116
  --base-conflict) BASE_CONFLICT="${2:-}"; shift 2 ;;
106
117
  --other-blocking) OTHER_BLOCKING="${2:-}"; shift 2 ;;
@@ -123,6 +134,16 @@ while [ $# -gt 0 ]; do
123
134
  esac
124
135
  done
125
136
 
137
+ # 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.
138
+ # Control characters are dropped, newlines fold to spaces, output is capped at 2000 bytes; an unreadable or empty path prints nothing.
139
+ read_text_file() {
140
+ [ -n "${1:-}" ] && [ -f "$1" ] && [ -r "$1" ] || return 0
141
+ head -c 2000 "$1" 2>/dev/null | tr '\r\n\t' ' ' | tr -d '\000-\037\177'
142
+ }
143
+ [ -z "$PURPOSE_FINDINGS_FILE" ] || PURPOSE_FINDINGS=$(read_text_file "$PURPOSE_FINDINGS_FILE")
144
+ [ -z "$AGENTS_FILE" ] || AGENTS_TEXT=$(read_text_file "$AGENTS_FILE")
145
+ [ -z "$CALIBRATION_NOTE_FILE" ] || CALIBRATION_NOTE=$(read_text_file "$CALIBRATION_NOTE_FILE")
146
+
126
147
  # --- file-verify needs nothing else: it only parses the issue-creation result text on stdin ---
127
148
  if [ "$SUB" = "file-verify" ]; then
128
149
  ISSUE_SKILL_OUTPUT=$(cat)
@@ -252,12 +273,20 @@ do_extract() {
252
273
  # The panel guard guarantees at least one trusted reviewer body for this head; none means a misread, never "clean".
253
274
  [ -n "$SOURCE_BODIES" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: no trusted reviewer comments for head ${PRIMARY_SHA} (fail closed)"; exit 3; }
254
275
  CARRIER_BODIES=$(agent_bodies_at "$PRIMARY_SHA")
276
+ CARRIER_BODIES_PRIMARY="$CARRIER_BODIES"
255
277
  if [ -n "$EARLIER_SHA" ]; then
256
278
  EARLIER_BODIES=$(bodies_at "$EARLIER_SHA") && [ -n "$EARLIER_BODIES" ] \
257
279
  || { echo "REVIEW_RESULT: status: BLOCKED, blocker: earlier reviewed head ${EARLIER_SHA} findings unreadable (fail closed)"; exit 3; }
258
280
  SOURCE_BODIES=$(printf '%s\n%s' "$SOURCE_BODIES" "$EARLIER_BODIES")
259
281
  CARRIER_BODIES=$(printf '%s\n%s' "$CARRIER_BODIES" "$(agent_bodies_at "$EARLIER_SHA")")
260
282
  fi
283
+ # Panel integrity: a trusted agent comment for the primary head counts as delivered only with a findings block (it may be empty).
284
+ # Computed from the RAW agent bodies, never from bodies_at, so a synthesized block cannot mask a missing agent block.
285
+ # Per domain: a later (re-dispatched) comment with a complete block clears an earlier block-less one.
286
+ AGENTS_WITHOUT_BLOCK=$(printf '%s\n' "$CARRIER_BODIES_PRIMARY" | jq -rs '[.[] | select(type == "string")
287
+ | {d: (capture("^<!-- FORGE:REVIEW-AGENT:(?<d>[a-z-]+) -->").d // empty),
288
+ ok: (contains("<!-- REVIEW-FINDINGS-START -->") and contains("<!-- REVIEW-FINDINGS-END -->"))}]
289
+ | group_by(.d) | map(select(any(.[]; .ok) | not) | .[0].d) | .[]' 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//')
261
290
  # Match to the closing ` -->`, never `[^>]+`: a `>` or `->` inside a title must not drop the finding (#3897).
262
291
  FINDINGS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' 2>/dev/null)
263
292
  # Fail closed when a raw marker did not parse: a lost finding must never read as a clean review.
@@ -267,7 +296,8 @@ do_extract() {
267
296
  || { echo "REVIEW_RESULT: status: BLOCKED, blocker: ${N_RAW_MARKERS} FINDING markers but ${N_PARSED_MARKERS} parsed (malformed marker, fail closed)"; exit 3; }
268
297
 
269
298
  # Carrier lookup order: raw agent bodies first (they carry the agent marker and the per-finding prose), then the source bodies.
270
- printf '%s\n%s\n' "$CARRIER_BODIES" "$SOURCE_BODIES" | grep -v '^$' > "$WORKDIR/carriers.jsonl" || : > "$WORKDIR/carriers.jsonl"
299
+ printf '%s\n%s\n' "$CARRIER_BODIES" "$SOURCE_BODIES" | grep -v '^$' | awk '!seen[$0]++' > "$WORKDIR/carriers.jsonl" || : > "$WORKDIR/carriers.jsonl"
300
+ : > "$WORKDIR/seen-markers"
271
301
 
272
302
  RAW="$WORKDIR/raw.jsonl"; : > "$RAW"; N_EXTRACTED=0
273
303
  while IFS='|' read -r F_ID F_CONF F_SEV F_LOC F_TITLE; do
@@ -280,11 +310,24 @@ do_extract() {
280
310
  case "$_r" in *[!0-9-]*|"") ;; *) F_FILE="${F_LOC%:*}"; L1="${_r%%-*}"; L2="${_r#*-}"; [ "$L2" != "$_r" ] || L2="$L1" ;; esac ;;
281
311
  esac
282
312
  case "$L1" in *[!0-9]*) L1=""; L2="" ;; esac; case "$L2" in *[!0-9]*) L2="$L1" ;; esac
283
- # The carrying comment: the first trusted body that holds this finding's marker.
313
+ # The carrying comment: bound by the finding's FULL marker line, not its id. A finding repeated across heads, slices or agents
314
+ # 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
315
+ # prose. Falls back to the first body holding the id-prefix only when no body holds the exact line (reworded synthesis marker).
284
316
  BODY_FILE="$WORKDIR/body.$N_EXTRACTED"
285
- jq -rs --arg m "<!-- FINDING:${F_ID}|" 'map(select(contains($m))) | .[0] // ""' "$WORKDIR/carriers.jsonl" > "$BODY_FILE" 2>/dev/null || : > "$BODY_FILE"
317
+ F_TITLE="${F_TITLE%$'\r'}" # a CRLF-ending parse (Windows jq) must not leak a CR into the exact marker
318
+ F_MARKER="<!-- FINDING:${F_ID}|${F_CONF}|${F_SEV}|${F_LOC}|${F_TITLE} -->"
319
+ SEEN_K=$(grep -cxF -- "$F_MARKER" "$WORKDIR/seen-markers" 2>/dev/null || true); case "$SEEN_K" in ''|*[!0-9]*) SEEN_K=0 ;; esac
320
+ printf '%s\n' "$F_MARKER" >> "$WORKDIR/seen-markers"
321
+ 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"
322
+ # 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.
323
+ # 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
324
+ if ! tr -d '\r\n' < "$BODY_FILE" | grep -q .; then
325
+ jq -rs --arg p "<!-- FINDING:${F_ID}|" '[.[] | select(contains($p))] | .[0] // ""' "$WORKDIR/carriers.jsonl" > "$BODY_FILE" 2>/dev/null || : > "$BODY_FILE"
326
+ fi
286
327
  AGENT=$(sed -n 's/^<!-- FORGE:REVIEW-AGENT:\([a-z-]*\) -->.*/\1/p' "$BODY_FILE" | head -n 1)
287
- # The finding's own prose section: from the first non-marker line naming the id to the next heading / marker / other finding id.
328
+ # The finding's own prose section. Start anchor, in order: a heading naming the id; a non-list line naming the id (a summary
329
+ # bullet `- ID: ...` is never the section); the first mention; the prose after the finding's own marker. It runs to the next
330
+ # heading / marker / other finding id.
288
331
  SECTION_FILE="$WORKDIR/section.$N_EXTRACTED"
289
332
  awk -v id="$F_ID" '
290
333
  function hasid(l, p, pre, post, rest, off) {
@@ -297,12 +340,29 @@ do_extract() {
297
340
  }
298
341
  return 0
299
342
  }
300
- /<!-- (FINDING:|REVIEW-FINDINGS)/ { if (started) exit; next }
301
- !started { if (hasid($0)) { started = 1; print; first = 0; n = 1 } ; next }
302
- { n++
303
- if ($0 ~ /^#/) exit
304
- if (match($0, /^[-*#> \t]*\**[A-Z][A-Z0-9]*-[0-9]+/) && !hasid($0)) exit
305
- print }' "$BODY_FILE" > "$SECTION_FILE" 2>/dev/null || : > "$SECTION_FILE"
343
+ { line[NR] = $0 }
344
+ END {
345
+ start = 0; nonlist = 0; mention = 0; mk = 0
346
+ for (i = 1; i <= NR; i++) {
347
+ l = line[i]
348
+ if (l ~ /<!-- (FINDING:|REVIEW-FINDINGS)/) { if (!mk && index(l, "<!-- FINDING:" id "|")) mk = i; continue }
349
+ if (!hasid(l)) continue
350
+ if (!mention) mention = i
351
+ if (!nonlist && l !~ /^[ \t]*([-*+]|[0-9]+[.)])[ \t]/) nonlist = i
352
+ if (l ~ /^#/) { start = i; break }
353
+ }
354
+ if (!start) start = nonlist
355
+ if (!start) start = mention
356
+ if (!start && mk) start = mk + 1
357
+ if (!start) exit
358
+ for (i = start; i <= NR; i++) {
359
+ l = line[i]
360
+ if (l ~ /<!-- (FINDING:|REVIEW-FINDINGS)/) break
361
+ if (i > start && l ~ /^#/) break
362
+ if (i > start && match(l, /^[-*#> \t]*\**[A-Z][A-Z0-9]*-[0-9]+/) && !hasid(l)) break
363
+ print l
364
+ }
365
+ }' "$BODY_FILE" > "$SECTION_FILE" 2>/dev/null || : > "$SECTION_FILE"
306
366
  # Failure scenario: present / absent when the section was found, unknown when the carrying comment or section is unreadable.
307
367
  SCENARIO="unknown"
308
368
  if [ -s "$SECTION_FILE" ]; then
@@ -348,13 +408,15 @@ EOF
348
408
  gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate > "$FILES_JSON" 2>/dev/null || : > "$FILES_JSON"
349
409
  # "path:line" for every added line; an empty file means the diff was unreadable (gates then keep every finding).
350
410
  ADDED_LINES_FILE="$WORKDIR/added-lines"
351
- jq -r '.[] | select(.patch != null) | "FILE\t\(.filename)", .patch' "$FILES_JSON" 2>/dev/null | awk '
411
+ # Second stream from the same pass: "path<TAB>text" per added line, for the Gate 2 symbol fallback.
412
+ ADDED_TEXT_FILE="$WORKDIR/added-text"; : > "$ADDED_TEXT_FILE"
413
+ jq -r '.[] | select(.patch != null) | "FILE\t\(.filename)", .patch' "$FILES_JSON" 2>/dev/null | awk -v tf="$ADDED_TEXT_FILE" '
352
414
  /^FILE\t/{f=substr($(0),6);next}
353
415
  /^@@/{s=$(0);sub(/^@@ -[0-9,]+ \+/,"",s);sub(/[ ,].*/,"",s);n=s+0;next}
354
416
  /^\\/{next}
355
- /^\+/{print f":"n;n++;next}
417
+ /^\+/{print f":"n; print f "\t" substr($(0),2) > tf; n++;next}
356
418
  /^-/{next}
357
- {n++}' > "$ADDED_LINES_FILE" || : > "$ADDED_LINES_FILE"
419
+ {n++}' > "$ADDED_LINES_FILE" || { : > "$ADDED_LINES_FILE"; : > "$ADDED_TEXT_FILE"; }
358
420
  # Files the PR deleted or renamed (the old name too): findings on them always intersect.
359
421
  GONE_FILES_FILE="$WORKDIR/gone-files"
360
422
  jq -r '.[] | select(.status=="removed" or .status=="renamed") | .filename, (.previous_filename // empty)' "$FILES_JSON" > "$GONE_FILES_FILE" 2>/dev/null || : > "$GONE_FILES_FILE"
@@ -376,8 +438,54 @@ EOF
376
438
  if gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${REVIEW_SHA}" > "$t" 2>/dev/null; then awk 'END{print NR}' "$t"; else echo UNKNOWN; fi
377
439
  }
378
440
 
441
+ # Gate 2 symbol fallback: 0 when an identifier the finding names occurs (whole word, fixed string) in the added lines of FILE
442
+ # and nowhere in the base version of FILE (a symbol the PR introduced).
443
+ # Candidates: backticked tokens, plus identifier-shaped tokens on "Root cause" lines; each must match an anchored identifier
444
+ # regex of 4+ chars and not be a common word. Reviewer text is never used in arithmetic, a glob or a regex. Any failure returns 1.
445
+ # A symbol counts only when it is NEW: present in the added lines AND absent from the base version of the file (#4068 review
446
+ # DSEC-1/DBH-1: `self`, `return`, `result` in an added line must not re-scope a pre-existing finding). The base text comes from
447
+ # `git show <base>:<file>` (local commit) or the raw contents API; a file the PR adds has an empty base; an unreadable base
448
+ # disables the fallback (the line-window result stands).
449
+ SYM_BASE_SHA=""
450
+ base_text_for() { # base_text_for <file> <out>; 0 = out holds the base text (possibly empty), 1 = unknown
451
+ local f="$1" o="$2"
452
+ if jq -e --arg f "$f" 'any(.[]; .filename == $f and .status == "added")' "$FILES_JSON" >/dev/null 2>&1; then : > "$o"; return 0; fi
453
+ [ -n "$SYM_BASE_SHA" ] || SYM_BASE_SHA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .base.sha 2>/dev/null || true)
454
+ case "$SYM_BASE_SHA" in *[!0-9a-f]*|"") SYM_BASE_SHA="-"; return 1 ;; -) return 1 ;; esac
455
+ if git rev-parse -q --verify "${SYM_BASE_SHA}^{commit}" >/dev/null 2>&1; then
456
+ git show "${SYM_BASE_SHA}:${f}" > "$o" 2>/dev/null && return 0
457
+ git cat-file -e "${SYM_BASE_SHA}:${f}" 2>/dev/null && return 1
458
+ : > "$o"; return 0
459
+ fi
460
+ case "$f" in *[!A-Za-z0-9._/@+-]*|"") return 1 ;; esac
461
+ gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${SYM_BASE_SHA}" > "$o" 2>/dev/null && return 0
462
+ return 1
463
+ }
464
+ symbol_in_added() {
465
+ local f="$1" text="$2" t="$WORKDIR/sym-added" b="$WORKDIR/sym-base" c n=0
466
+ [ -s "$ADDED_TEXT_FILE" ] || return 1
467
+ awk -v f="$f" 'index($0, f "\t") == 1 { print substr($0, length(f) + 2) }' "$ADDED_TEXT_FILE" > "$t" 2>/dev/null || return 1
468
+ [ -s "$t" ] || return 1
469
+ base_text_for "$f" "$b" || return 1
470
+ while IFS= read -r c; do
471
+ case "$c" in ""|[!A-Za-z_]*) continue ;; esac
472
+ [ "${#c}" -ge 4 ] || continue
473
+ case "$(printf '%s' "$c" | tr '[:upper:]' '[:lower:]')" in
474
+ this|that|with|from|true|false|null|none|file|line|code|test|case|value|root|cause|when|then|which|where|there|their|error|data|name|path|type|call|only|also|into|have|does|used|uses) continue ;;
475
+ esac
476
+ n=$((n + 1)); [ "$n" -le 8 ] || break
477
+ if grep -qwF -- "$c" "$t" 2>/dev/null && ! grep -qwF -- "$c" "$b" 2>/dev/null; then return 0; fi
478
+ done <<SYMEOF
479
+ $({
480
+ printf '%s\n' "$text" | grep -oE '`[^`]+`' 2>/dev/null | tr -c 'A-Za-z0-9_\n' '\n'
481
+ printf '%s\n' "$text" | grep -iE 'root cause' 2>/dev/null | grep -oE '[A-Za-z_][A-Za-z0-9_]*' | grep -E '_|[a-z][A-Z]|[0-9]'
482
+ } 2>/dev/null | grep -E '^[A-Za-z_][A-Za-z0-9_]{3,}$' | awk '!seen[$0]++')
483
+ SYMEOF
484
+ return 1
485
+ }
486
+
379
487
  FINAL="$WORKDIR/final.jsonl"; : > "$FINAL"
380
- N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_KEPT=0
488
+ N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_INTRODUCED=0; N_KEPT=0
381
489
  I=0; TOTAL=$(jq 'length' "$DEDUPED")
382
490
  while [ "$I" -lt "$TOTAL" ]; do
383
491
  F=$(jq -c ".[$I]" "$DEDUPED"); I=$((I + 1))
@@ -401,17 +509,24 @@ EOF
401
509
  esac
402
510
  if [ "$STALE" -eq 1 ]; then N_STALE=$((N_STALE + 1)); continue; fi
403
511
  fi
404
- # Gate 2 (introduced-line): MEDIUM or lower with no added line within +-5 is pre-existing. CRITICAL/HIGH and unknown severities never are.
405
- if [ -n "$LINE" ] && [ "$GONE" -eq 0 ] && [ "$NOPATCH" -eq 0 ] && [ -s "$ADDED_LINES_FILE" ]; then
406
- case "$SEV" in
407
- MEDIUM|LOW|INFO)
408
- LO=$((LINE > 5 ? LINE - 5 : 1)); INTRODUCED=0; L="$LO"
409
- while [ "$L" -le $((LINE + 5)) ]; do
410
- if grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE"; then INTRODUCED=1; break; fi
411
- L=$((L + 1))
412
- done
413
- [ "$INTRODUCED" -eq 1 ] || ROUTE="pre-existing" ;;
414
- esac
512
+ # 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;
513
+ # 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.
514
+ # CRITICAL/HIGH and unknown severities never are (their scope is still recorded).
515
+ SCOPE="unknown"
516
+ if [ "$GONE" -eq 1 ]; then
517
+ SCOPE="introduced"
518
+ elif [ -n "$LINE" ] && [ "$NOPATCH" -eq 0 ] && [ -s "$ADDED_LINES_FILE" ]; then
519
+ LO=$((LINE > 5 ? LINE - 5 : 1)); INTRODUCED=0; L="$LO"
520
+ while [ "$L" -le $((LINE + 5)) ]; do
521
+ if grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE"; then INTRODUCED=1; break; fi
522
+ L=$((L + 1))
523
+ done
524
+ # The cited line is the reviewer's estimate (stale or at the call site): symbol fallback before routing pre-existing.
525
+ if [ "$INTRODUCED" -eq 0 ] && symbol_in_added "$FILE" "$(printf '%s' "$F" | jq -r '.text // ""')"; then INTRODUCED=1; fi
526
+ if [ "$INTRODUCED" -eq 1 ]; then SCOPE="introduced"; else
527
+ SCOPE="pre-existing"
528
+ case "$SEV" in MEDIUM|LOW|INFO) ROUTE="pre-existing" ;; esac
529
+ fi
415
530
  fi
416
531
  # Gate 3 (concrete failure): MEDIUM or lower without a **Failure scenario** is speculative hardening, demoted to a NOTE.
417
532
  # CRITICAL/HIGH are exempt; an unreadable carrying comment keeps the finding.
@@ -420,13 +535,14 @@ EOF
420
535
  fi
421
536
  case "$ROUTE" in pre-existing) N_PREEXISTING=$((N_PREEXISTING + 1)) ;; demoted-no-scenario) N_NO_SCENARIO=$((N_NO_SCENARIO + 1)) ;; esac
422
537
  N_KEPT=$((N_KEPT + 1))
423
- printf '%s' "$F" | jq -c --arg r "$ROUTE" '. + {route: $r}' >> "$FINAL"
538
+ [ "$SCOPE" != "introduced" ] || N_INTRODUCED=$((N_INTRODUCED + 1))
539
+ printf '%s' "$F" | jq -c --arg r "$ROUTE" --arg sc "$SCOPE" '. + {route: $r, scope: $sc}' >> "$FINAL"
424
540
  done
425
541
 
426
542
  jq -s --arg sha "$REVIEW_SHA" --arg mode "$REVIEW_MODE" --arg mi "$MERGE_ISSUE" \
427
- --argjson ex "$N_EXTRACTED" --argjson dd "$N_DEDUPED" --argjson st "$N_STALE" --argjson pe "$N_PREEXISTING" --argjson ns "$N_NO_SCENARIO" \
543
+ --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" \
428
544
  '{schema_version: 1, review_sha: $sha, review_mode: $mode, merge_issue: ($mi | tonumber? // null),
429
- counts: {findings_extracted: $ex, findings_deduped: $dd, findings_dropped_stale: $st, findings_preexisting: $pe, notes_demoted_no_scenario: $ns},
545
+ counts: {findings_extracted: $ex, findings_deduped: $dd, findings_dropped_stale: $st, findings_preexisting: $pe, findings_introduced: $ni, notes_demoted_no_scenario: $ns},
430
546
  findings: .}' "$FINAL" > "$OUT_FILE" || { echo "review-finalize: cannot write $OUT_FILE" >&2; exit 2; }
431
547
  echo "FINDINGS_OUT=$OUT_FILE"
432
548
  echo "STRUCTURED_FINDINGS=$N_EXTRACTED"
@@ -434,7 +550,8 @@ EOF
434
550
  echo "FINDINGS_DROPPED_STALE=$N_STALE"
435
551
  echo "FINDINGS_PREEXISTING=$N_PREEXISTING"
436
552
  echo "NOTES_DEMOTED_NO_SCENARIO=$N_NO_SCENARIO"
437
- [ "$N_EXTRACTED" -gt 0 ] || echo "CLEAN_STRUCTURED=true" # 0 structured findings: the caller scans unstructured text, then skips to Phase 7 if still 0
553
+ echo "AGENTS_WITHOUT_BLOCK=$AGENTS_WITHOUT_BLOCK" # undelivered agents (no findings block): re-dispatch once, else degraded panel; never read free text
554
+ { [ "$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
438
555
  exit 0
439
556
  }
440
557
 
@@ -495,7 +612,7 @@ do_classify() {
495
612
  && [ -s "$CONTRACT_SCOPE_FILE" ] \
496
613
  && gh pr diff "$PR_NUMBER" -R "$REPO" --name-only > "$PR_FILES_FILE" 2>/dev/null; then
497
614
  # A deferred row counts only if issue N is OPEN, is an issue (not a PR), is not MERGE_ISSUE, and its body
498
- # carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus the row's path, and is authored by a trusted author
615
+ # carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus a checklist line for the row's exact path (the list script strips a trailing slash, so `p` and `p/` both match; `- [ ] \`path\``, [xX] accepted, the producer format of file-deferred-followups.sh; a body that only mentions the path does not count), and is authored by a trusted author
499
616
  # (OWNER/MEMBER/COLLABORATOR or Bot; same predicate as trusted-comments.sh; missing fields fail closed).
500
617
  # Unverified rows are dropped.
501
618
  _verified="$_scratch/contract-verified"; : > "$_verified"
@@ -505,7 +622,7 @@ do_classify() {
505
622
  [ "$_n" = "${MERGE_ISSUE#\#}" ] && continue
506
623
  gh api "repos/${REPO}/issues/$_n" 2>/dev/null \
507
624
  | jq -e --arg m "FORGE:DEFERRED_FROM: #${MERGE_ISSUE#\#} -->" --arg p "$_ipath" \
508
- '.state == "open" and .pull_request == null and ((.body // "") | contains($m) and contains($p)) and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))' >/dev/null 2>&1 \
625
+ '.state == "open" and .pull_request == null and ((.body // "") | (contains($m) and (split("\n") | any(rtrimstr("\r") as $l | ("- [ ] `","- [x] `","- [X] `") as $pre | ($p, $p + "/") as $q | ($pre + $q + "`") as $w | $l == $w or ($l | startswith($w + " ")))))) and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))' >/dev/null 2>&1 \
509
626
  || continue
510
627
  grep -Fxq -- "$_n" "$CONTRACT_OPEN_FILE" || echo "$_n" >> "$CONTRACT_OPEN_FILE"
511
628
  fi
@@ -603,7 +720,7 @@ do_classify() {
603
720
  TOTAL=$(jq '.findings | length' "$IN_FILE"); I=0
604
721
  while [ "$I" -lt "$TOTAL" ]; do
605
722
  F_ID=$(fj .id); F_SEV=$(fj .severity); F_CONF=$(fj .confidence); F_AGENT=$(fj .agent); F_FILE=$(fj .file); F_TITLE=$(fj .title)
606
- F_ROUTE=$(fj .route); F_FIX=$(fj .fix_efficacy); F_L1=$(fj .line_start); F_L2=$(fj .line_end); F_LOC=$(fj .loc)
723
+ 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)
607
724
  if [ "$F_ROUTE" = "demoted-no-scenario" ]; then printf '%s\t%s\n' "$I" "NOTE no-scenario" >> "$DISP_FILE"; I=$((I + 1)); continue; fi
608
725
  FINDING_TEXT_FILE="$WORKDIR/finding-text.$I"; jq -r ".findings[$I].text // \"\"" "$IN_FILE" > "$FINDING_TEXT_FILE" 2>/dev/null
609
726
  set -- --severity "$F_SEV" --confidence "$F_CONF" --agent "$F_AGENT" --lineage "$FINDING_LINEAGE" --text-file "$FINDING_TEXT_FILE"
@@ -611,6 +728,7 @@ do_classify() {
611
728
  [ -n "$INPR_DIFF_FILE" ] && [ "$F_ROUTE" != "pre-existing" ] && set -- "$@" --inpr-diff "$INPR_DIFF_FILE"
612
729
  [ -n "$CONTRACT_SCOPE_FILE" ] && set -- "$@" --contract-scope "$CONTRACT_SCOPE_FILE" --contract-open "$CONTRACT_OPEN_FILE" --pr-files "$PR_FILES_FILE"
613
730
  [ -n "$MERGE_ISSUE" ] && set -- "$@" --fixes-issue "$MERGE_ISSUE"
731
+ case "$F_SCOPE" in introduced|pre-existing|unknown) set -- "$@" --scope "$F_SCOPE" ;; esac
614
732
  case "$F_FIX" in fails|regresses) set -- "$@" --fix-efficacy "$F_FIX" ;; esac
615
733
  [ -n "$PRIOR_FILE" ] && { set -- "$@" --prior-findings "$PRIOR_FILE" --title "$F_TITLE"; [ -n "$DIFF_MAP_FILE" ] && set -- "$@" --diff-map "$DIFF_MAP_FILE"; }
616
734
  [ -n "$F_L1" ] && set -- "$@" --line-start "$F_L1" --line-end "${F_L2:-$F_L1}"
@@ -629,11 +747,32 @@ do_classify() {
629
747
  fi
630
748
  case "$DISPOSITION" in
631
749
  "NOTE contract-deferred #"*)
632
- _dn="${DISPOSITION##*#}"
633
- case "$_dn" in ''|*[!0-9]*) ;; *)
634
- [ "$DRY_RUN" = "true" ] || gh issue comment "$_dn" -R "$REPO" --body "Review of #${PR_NUMBER} also found this on a path declared deferred here: ${F_LOC} ${F_TITLE}" >/dev/null 2>&1 || true # allowlist:check-command-side-effects
750
+ # The demotion counts only after the comment on #N succeeded and #N is still OPEN; any failure files the finding (fails toward filing).
751
+ _dn="${DISPOSITION##*#}"; _dok=1
752
+ case "$_dn" in ''|*[!0-9]*) _dok=0 ;; *)
753
+ if [ "$DRY_RUN" != "true" ]; then
754
+ _dst=$(gh issue view "$_dn" -R "$REPO" --json state --jq .state 2>/dev/null) || _dst=""
755
+ if [ "$_dst" != "OPEN" ]; then _dok=0
756
+ elif ! 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; then _dok=0 # allowlist:check-command-side-effects
757
+ fi
758
+ fi
635
759
  ;; esac
636
- CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
760
+ if [ "$_dok" = "1" ]; then CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)); else
761
+ # Fall back to what the finding would have been absent the contract (a LOW/POSSIBLE or lineage-damped finding stays a NOTE),
762
+ # not a blanket ISSUE: re-run the classifier without the contract flags. An INPR_FIX or unusable result files (fails toward filing).
763
+ DISPOSITION="ISSUE contract-comment-failed"
764
+ _n=$#; _skip=0
765
+ while [ "$_n" -gt 0 ]; do
766
+ _a="$1"; shift; _n=$((_n - 1))
767
+ if [ "$_skip" = "1" ]; then _skip=0; continue; fi
768
+ case "$_a" in --contract-scope|--contract-open|--pr-files) _skip=1 ;; *) set -- "$@" "$_a" ;; esac
769
+ done
770
+ _fb=$(bash "$CLASSIFY_SCRIPT" "$@" 2>/dev/null | head -n 1) || _fb=""
771
+ case "$_fb" in
772
+ "NOTE reflag"*) DISPOSITION="$_fb"; NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
773
+ NOTE*|ISSUE*) DISPOSITION="$_fb" ;;
774
+ esac
775
+ fi ;;
637
776
  "NOTE contract-accepted-risk"*) CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
638
777
  "NOTE reflag-"*) NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
639
778
  INPR_FIX*)
@@ -683,7 +822,7 @@ do_classify() {
683
822
  FINDINGS_FILED=$(jq '[.findings[] | select(.class == "ISSUE" and .route != "file-pre-existing" and .route != "pre-existing")] | length' "$OUT_FILE")
684
823
  PLAIN_NOTES=$(jq '[.findings[] | select(.class == "NOTE" and .route != "pre-existing" and (.disposition | test("^NOTE (contract-|reflag-|capped)") | not))] | length' "$OUT_FILE")
685
824
  NOTES_LISTED=$((PLAIN_NOTES - NOTES_FIXED - NOTES_DROPPED)); [ "$NOTES_LISTED" -ge 0 ] || NOTES_LISTED=0
686
- N_STALE=$(jq -r '.counts.findings_dropped_stale // 0' "$OUT_FILE"); N_PRE=$(jq -r '.counts.findings_preexisting // 0' "$OUT_FILE"); N_NOSCEN=$(jq -r '.counts.notes_demoted_no_scenario // 0' "$OUT_FILE")
825
+ 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")
687
826
  N_EXTRACTED=$(jq -r '.counts.findings_extracted // (.findings | length)' "$OUT_FILE")
688
827
  CLASSIFIER_MODE=script # the guard above blocks the review when the classifier is unresolvable, so this is always script
689
828
 
@@ -705,7 +844,7 @@ do_classify() {
705
844
  # Disposition marker (MANDATORY whenever Phase 6A extracted at least one finding): Phase 8 refuses to auto-merge a PR that has FINDING:
706
845
  # markers but no FORGE:NOTE_DISPOSITION comment. `reviewed_sha` is the head this record covers (a later review of ANOTHER head reads it as a prior list).
707
846
  BUNDLE_MARKER=""; if [ "$BUNDLE" = "true" ]; then _sm=full; [ -z "$SCOPE_FILES" ] || _sm=scoped; BUNDLE_MARKER=" out_of_scope=${OUT_OF_SCOPE} scope=${_sm}"; fi
708
- NOTE_DISPOSITION_BODY="<!-- FORGE:NOTE_DISPOSITION: notes_fixed=${NOTES_FIXED:-0} notes_listed=${NOTES_LISTED:-0} notes_dropped=${NOTES_DROPPED:-0} findings_filed=${FINDINGS_FILED:-0} inpr_fix=${INPR_FIX_COUNT:-0} contract_demoted=${CONTRACT_DEMOTED:-0} findings_dropped_stale=${N_STALE:-0} findings_preexisting=${N_PRE:-0} notes_demoted_no_scenario=${N_NOSCEN:-0} lineage=${FINDING_LINEAGE} classifier=${CLASSIFIER_MODE} notes_reflag=${NOTES_REFLAG:-0} notes_capped=${NOTES_CAPPED:-0} reviewed_sha=${REVIEW_SHA}${BUNDLE_MARKER} -->
847
+ 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} -->
709
848
  $(cat "$NOTE_LIST_FILE" 2>/dev/null)"
710
849
  printf '%s\n' "$NOTE_DISPOSITION_BODY" > "$OUT_DIR/note-disposition.md"
711
850
  POSTED_DISPOSITION=false; POSTED_INPR=false
@@ -782,7 +921,8 @@ do_file_prepare() {
782
921
  while [ "$I" -lt "$TOTAL" ]; do
783
922
  F_ROUTE=$(fj .route); F_CLASS=$(fj .class)
784
923
  case "$F_CLASS:$F_ROUTE" in ISSUE:file|ISSUE:file-pre-existing) ;; *) I=$((I + 1)); continue ;; esac
785
- F_ID=$(fj .id); F_FILE=$(fj .file); F_TITLE=$(fj .title); F_SEV=$(fj .severity); F_AGENT=$(fj .agent); F_LINE=$(fj .line_start)
924
+ 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)
925
+ case "$F_SCOPE" in introduced|pre-existing|unknown) ;; *) F_SCOPE="unknown" ;; esac
786
926
  case "$F_LINE" in ''|*[!0-9]*) F_LINE=0 ;; esac
787
927
  ACTION="file"; DEDUP_OF=""; REGRESSION=""; NOTE=""; CONSOLIDATE="false"; PRIOR_NUMS=""; SUBSYSTEM=""; RECURRENCE=""
788
928
  # Title (defense in depth: strip command substitution so the raw title stays readable if it round-trips through an eval-based consumer).
@@ -881,8 +1021,8 @@ do_file_prepare() {
881
1021
  jq -n -c --arg id "$F_ID" --arg action "$ACTION" --arg title "$ISSUE_TITLE" --arg file "$F_FILE" --arg line "$F_LINE" --arg sev "$(upper "$F_SEV")" \
882
1022
  --arg prio "$FINDING_PRIORITY" --argjson labels "$LABELS" --arg ms "$MILESTONE_TITLE" --arg batch "$BATCHABLE" --arg dedup "$DEDUP_OF" \
883
1023
  --arg reg "$REGRESSION" --arg note "$NOTE" --arg cons "$CONSOLIDATE" --arg prior "$PRIOR_NUMS" --arg sub "$SUBSYSTEM" --arg slug "$PATTERN_SLUG" \
884
- --arg rec "$RECURRENCE" --arg route "$F_ROUTE" --arg agent "$F_AGENT" \
885
- '{id: $id, action: $action, route: $route, title: $title, file: $file, line: ($line | tonumber), severity: $sev, agent: $agent, priority: $prio,
1024
+ --arg rec "$RECURRENCE" --arg route "$F_ROUTE" --arg agent "$F_AGENT" --arg scope "$F_SCOPE" \
1025
+ '{id: $id, action: $action, route: $route, title: $title, file: $file, line: ($line | tonumber), severity: $sev, scope: $scope, agent: $agent, priority: $prio,
886
1026
  labels: $labels, milestone: $ms, batchable: ($batch == "true"), dedup_of: ($dedup | tonumber? // null), regression_of: ($reg | tonumber? // null),
887
1027
  note: $note, pattern: {slug: $slug, subsystem: $sub, consolidate: ($cons == "true"), prior: ($prior | split(" ") | map(select(length > 0) | tonumber)), recurrence: $rec}}' >> "$OUT_ITEMS"
888
1028
  I=$((I + 1))
@@ -953,6 +1093,20 @@ do_verdict() {
953
1093
  if [ "$RESULT" = "clean" ] && { [ "$BASE_CONFLICT" = "true" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ]; }; then
954
1094
  RESULT="blocking"; VERDICT_COERCED=true
955
1095
  fi
1096
+ # OTHER_BLOCKING (criteria 1, 2, 3 and 5) is reviewer-judged, so it is resolved here and persisted for merge-gate to OR in: a blocking
1097
+ # result that BASE_CONFLICT and HAS_PURPOSE_REGRESSION do not explain was caused by another criterion, whatever the reviewer passed.
1098
+ OTHER_BLOCKING_RESOLVED=""
1099
+ case "$OTHER_BLOCKING" in true|false) OTHER_BLOCKING_RESOLVED="$OTHER_BLOCKING" ;; esac
1100
+ if [ "$RESULT" = "blocking" ] && [ "$BASE_CONFLICT" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ]; then
1101
+ OTHER_BLOCKING_RESOLVED=true
1102
+ elif [ "$RESULT" = "clean" ]; then
1103
+ OTHER_BLOCKING_RESOLVED=false
1104
+ fi
1105
+ # Rewritten (or removed) on every run so a stale value from a prior head can never survive a re-review.
1106
+ rm -f "$OUT_DIR/other-blocking.txt" 2>/dev/null || true
1107
+ if { [ "$RESULT" = "clean" ] || [ "$RESULT" = "blocking" ]; } && [ -n "$OTHER_BLOCKING_RESOLVED" ] && [ "$DRY_RUN" != "true" ]; then
1108
+ printf '%s\n' "$OTHER_BLOCKING_RESOLVED" > "$OUT_DIR/other-blocking.txt" 2>/dev/null || true
1109
+ fi
956
1110
  VERDICT_OUT=""
957
1111
  case "$RESULT" in
958
1112
  stale) VERDICT_OUT="STALE" ;;
@@ -987,6 +1141,7 @@ ${TRUST_ANNOTATION}${CONFLICT_LINE}${PROTECTION_LINE}${PURPOSE_LINE}${ATTRIBUTIO
987
1141
  echo "HAS_PURPOSE_REGRESSION=${HAS_PURPOSE_REGRESSION}"
988
1142
  echo "HAS_MERGE_CONFLICT=${HAS_MERGE_CONFLICT}"
989
1143
  echo "BASE_CONFLICT=${BASE_CONFLICT}"
1144
+ echo "OTHER_BLOCKING=${OTHER_BLOCKING_RESOLVED}"
990
1145
  echo "MERGE_CONFLICT_MSG=${MERGE_CONFLICT_MSG}"
991
1146
  echo "MERGE_PROTECTION_WARNING=${MERGE_PROTECTION_WARNING}"
992
1147
  exit 0
@@ -1061,9 +1216,10 @@ do_calibrate() {
1061
1216
  fi
1062
1217
  echo "CALIBRATION_NEEDS_HUMAN=${CALIBRATION_NEEDS_HUMAN}"
1063
1218
  echo "CALIBRATION_CELL=${CALIBRATION_CELL}"
1064
- # The orchestrator substitutes this line into a single-quoted shell assignment (review-pr.md Phase 8): never emit
1065
- # a quote, backtick, dollar sign, backslash or newline (#3918 class).
1219
+ # Defense in depth: strip quote, backtick, dollar sign, backslash and newline from the echoed line (#3918 class). Phase 8 reads the
1220
+ # note from the file below, not from this line, so it never passes through a shell word.
1066
1221
  CALIBRATION_NOTE=$(printf '%s' "$CALIBRATION_NOTE" | tr -d "'\`\$\\\\" | tr '\r\n' ' ')
1222
+ printf '%s\n' "$CALIBRATION_NOTE" > "$OUT_DIR/calibration-note.txt" 2>/dev/null || true
1067
1223
  echo "CALIBRATION_NOTE=${CALIBRATION_NOTE}"
1068
1224
  exit 0
1069
1225
  }
@@ -1184,6 +1340,86 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
1184
1340
  fi
1185
1341
  [ "${TRAIL_RC:-1}" -eq 0 ] || blocked "phase trail unreadable (rc=${TRAIL_RC:-1})" # hard guard: nothing below runs unless the trail verified
1186
1342
 
1343
+ # --- degraded-gate gate (forge#3628): a build whose tests ran nothing is not a verified build ---
1344
+ # The newest trusted FORGE:QUALITY_GATE (`**Degraded**: name — reason; ...`) and FORGE:ACCEPTANCE_GATE (`:DEGRADED` marker) on the linked
1345
+ # issue name the degraded checks. Each one must be named in a trusted `### Degraded Paths Reviewed` section on the PR; a generic section
1346
+ # does not satisfy it. Unreadable comments fail closed. Only trusted-comments.sh ever reads these comments, so an untrusted comment can
1347
+ # neither forge a DEGRADED marker nor clear one.
1348
+ DEG_ISSUE_JSON=$(gh api --paginate "repos/${REPO}/issues/${MERGE_ISSUE}/comments" 2>/dev/null) || DEG_ISSUE_JSON=""
1349
+ if [ -z "$DEG_ISSUE_JSON" ] || [ -z "$TRUSTED_SCRIPT" ]; then
1350
+ echo "DEGRADED GATE: issue comments or trusted-comments.sh unreadable — refusing to merge (fail closed)" >&2
1351
+ blocked "note disposition unreadable"
1352
+ fi
1353
+ DEG_NAMES=""; DEG_ANY=false
1354
+ DEG_QG=$(printf '%s' "$DEG_ISSUE_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:QUALITY_GATE -->') || DEG_QG="__ERR__"
1355
+ DEG_AG=$(printf '%s' "$DEG_ISSUE_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:ACCEPTANCE_GATE -->') || DEG_AG="__ERR__"
1356
+ if [ "$DEG_QG" = "__ERR__" ] || [ "$DEG_AG" = "__ERR__" ]; then
1357
+ echo "DEGRADED GATE: trust filter failed — refusing to merge (fail closed)" >&2
1358
+ blocked "note disposition unreadable"
1359
+ fi
1360
+ # newest body of each gate (bodies are JSON strings, one per line, oldest first)
1361
+ 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')
1362
+ DEG_AG_BODY=$(printf '%s\n' "$DEG_AG" | tail -n 1 | jq -r 'select(type == "string")' 2>/dev/null)
1363
+ case "$DEG_QG_LINE" in ''|none|None|NONE) ;; *) DEG_ANY=true; DEG_NAMES="$DEG_QG_LINE" ;; esac
1364
+ if printf '%s\n' "$DEG_AG_BODY" | grep -qE '^[[:space:]]*<!-- FORGE:ACCEPTANCE_GATE:DEGRADED -->[[:space:]]*$'; then
1365
+ DEG_ANY=true
1366
+ DEG_AG_LINE=$(printf '%s\n' "$DEG_AG_BODY" | sed -n 's/^\*\*Degraded checks[^*]*\*\*: *//p' | sed -n '1p')
1367
+ [ -n "$DEG_AG_LINE" ] && DEG_NAMES="${DEG_NAMES:+$DEG_NAMES; }${DEG_AG_LINE}"
1368
+ fi
1369
+ if [ "$DEG_ANY" = "true" ]; then
1370
+ # Reviewed section (forge#3995): only a trusted review comment (FORGE:REVIEW-AGENT / FORGE:REVIEW / synthesis marker as the
1371
+ # LEADING text) carrying `Reviewed-SHA:` for the CURRENT head counts, so a stale section from an older head or a plain comment
1372
+ # cannot clear the gate. An unreadable head fails closed.
1373
+ DEG_SECTIONS=""
1374
+ DEG_HEAD=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .head.sha 2>/dev/null) || DEG_HEAD=""
1375
+ if ! [[ "$DEG_HEAD" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then
1376
+ echo "DEGRADED GATE: PR head unreadable — refusing to merge (fail closed)" >&2
1377
+ blocked "note disposition unreadable"
1378
+ fi
1379
+ 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__"
1380
+ if [ "$DEG_PR_BODIES" = "__ERR__" ]; then
1381
+ echo "DEGRADED GATE: trust filter failed on PR comments — refusing to merge (fail closed)" >&2
1382
+ blocked "note disposition unreadable"
1383
+ fi
1384
+ if [ -n "$DEG_PR_BODIES" ]; then
1385
+ DEG_SECTIONS=$(printf '%s\n' "$DEG_PR_BODIES" | while IFS= read -r _db; do
1386
+ 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 \
1387
+ | awk '/^### Degraded Paths Reviewed[[:space:]]*$/ {f=1; next} f && /^(#|##|###) / {f=0} f {print}'
1388
+ done)
1389
+ fi
1390
+ DEG_MISSING=""
1391
+ if [ -z "$(printf '%s' "$DEG_SECTIONS" | tr -d '[:space:]')" ]; then
1392
+ DEG_MISSING="no \`### Degraded Paths Reviewed\` section on a review comment for the current head"
1393
+ else
1394
+ # per-check match (forge#3995): the name is everything before " — " in each "; "-separated entry. It must be a whole list
1395
+ # item `- name: <how it was reviewed>` whose explanation does not negate it; a substring or a "NOT reviewed" item is not coverage.
1396
+ DEG_NAME_LIST=$(printf '%s\n' "$DEG_NAMES" | tr ';' '\n' | sed 's/ — .*$//; s/^[[:space:]]*//; s/[[:space:]]*$//' | sed '/^$/d' | sort -u)
1397
+ while IFS= read -r _dn; do
1398
+ [ -n "$_dn" ] || continue
1399
+ _hit=$(printf '%s\n' "$DEG_SECTIONS" | awk -v n="$_dn" '
1400
+ { l=$0; sub(/^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+/, "", l); gsub(/`/, "", l)
1401
+ if (index(l, n) != 1) next
1402
+ r=substr(l, length(n)+1)
1403
+ if (r !~ /^[[:space:]]*[:—-][[:space:]]*[^[:space:]]/) next
1404
+ sub(/^[[:space:]]*[:—-][[:space:]]*/, "", r)
1405
+ if (tolower(r) ~ /^(not|never|no|n\/a|none|unreviewed|skipped)([^a-z]|$)/) next
1406
+ found=1 }
1407
+ END { print found+0 }')
1408
+ [ "$_hit" = "1" ] || DEG_MISSING="${DEG_MISSING:+$DEG_MISSING, }${_dn}"
1409
+ done <<< "$DEG_NAME_LIST"
1410
+ [ -z "$DEG_MISSING" ] || DEG_MISSING="not covered: ${DEG_MISSING}"
1411
+ fi
1412
+ if [ -n "$DEG_MISSING" ]; then
1413
+ echo "DEGRADED GATE: the build's gate is DEGRADED (${DEG_NAMES}) and the review does not cover it (${DEG_MISSING})" >&2
1414
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "<!-- FORGE:DEGRADED_GATE_UNREVIEWED -->
1415
+ Auto-merge refused for PR #${PR_NUMBER}: the build's verification was DEGRADED (a check ran nothing or did not complete): ${DEG_NAMES}.
1416
+
1417
+ 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 -->
1418
+ gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
1419
+ blocked "degraded-gate-unreviewed"
1420
+ fi
1421
+ fi
1422
+
1187
1423
  # --- merge guard state: §7B verdict + purpose-regression + calibration + trust-escalation ---
1188
1424
  # These are decided in earlier steps (earlier shells), so they arrive as flags. An empty or unbound value would read as
1189
1425
  # "not blocking" and fall through to the merge path, so any value outside the expected set stops here instead of merging.
@@ -1198,6 +1434,8 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
1198
1434
  gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
1199
1435
  blocked "merge guard state unbound"
1200
1436
  fi
1437
+ # OR in the value `verdict` persisted: a passed `false` never overrides a persisted `true`. Anything else in the file is ignored.
1438
+ if [ "$(head -n 1 "$OUT_DIR/other-blocking.txt" 2>/dev/null | tr -d '[:space:]')" = "true" ]; then OTHER_BLOCKING=true; fi
1201
1439
  # ONLY_BASE_CONFLICT: the base conflict is the ONLY reason for CHANGES REQUESTED, evaluated after every human gate is set.
1202
1440
  ONLY_BASE_CONFLICT=false
1203
1441
  if [ "$BASE_CONFLICT" = "true" ] && [ "$OTHER_BLOCKING" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ] && [ "$CALIBRATION_NEEDS_HUMAN" != "true" ] && [ "$TRUST_NEEDS_HUMAN" != "true" ]; then
@@ -1205,7 +1443,7 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
1205
1443
  fi
1206
1444
  if [ "$ONLY_BASE_CONFLICT" = "true" ] && [ "$VERDICT" = "CHANGES REQUESTED" ]; then
1207
1445
  # Pure base conflict: route to the base-sync path, NOT needs-human. A standalone /review-pr caller just sees the blocker.
1208
- gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push), re-running the quality gate, then re-reviewing the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1446
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason). Base sync needed: the work-on pipeline performs the base sync (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push); a standalone \`/review-pr --auto-merge\` caller with no work-on router must run \`/work-on ${MERGE_ISSUE} --remediate\` to do it. The sync re-runs the quality gate and then re-reviews the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1209
1447
  echo "MERGE_OUTCOME=base-conflict"
1210
1448
  blocked "base-conflict"
1211
1449
  elif [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "$TRUST_NEEDS_HUMAN" = "true" ]; then
@@ -1233,7 +1471,7 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
1233
1471
  fi
1234
1472
  if [ "$PRE_MERGE_BASE_CONFLICT" = "true" ]; then
1235
1473
  # Base moved during review: route to the base-sync path, NOT needs-human.
1236
- gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push) and then re-reviewing. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1474
+ 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}\`). Base sync needed: the work-on pipeline performs the base sync (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push); a standalone \`/review-pr --auto-merge\` caller with no work-on router must run \`/work-on ${MERGE_ISSUE} --remediate\` to do it. The sync is followed by a re-review. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1237
1475
  echo "MERGE_OUTCOME=base-conflict"
1238
1476
  blocked "base-conflict"
1239
1477
  elif [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then
@@ -1277,6 +1515,9 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
1277
1515
  else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
1278
1516
  echo "$CI_GATE_OUT"
1279
1517
  GATED_HEAD=$(printf '%s\n' "$CI_GATE_OUT" | sed -n 's/^CI_GATE_HEAD: //p' | head -1)
1518
+ # rc 4 RATE_LIMITED (GitHub throttled or briefly unavailable for the whole window) is a wait like TIMEOUT: map it to the
1519
+ # pending path BEFORE rc 4 is reused below as the stale-review sentinel, so it is never a merge and never a re-review.
1520
+ [ "$CI_GATE_RC" -eq 4 ] && CI_GATE_RC=3
1280
1521
  # rc 2 "head moved" is a stale review, not a human problem; rc 3 TIMEOUT means CI is still running.
1281
1522
  printf '%s\n' "$CI_GATE_OUT" | grep -q 'PR head moved during the wait' && CI_GATE_RC=4
1282
1523
  if [ "$CI_GATE_RC" -eq 3 ]; then
@@ -1399,7 +1640,7 @@ do_integrity() {
1399
1640
  ACTUAL_AGENT_DOMAINS=""
1400
1641
  if [ -n "$TRUSTED_SCRIPT" ]; then
1401
1642
  ACTUAL_AGENT_DOMAINS=$(printf '%s' "$COMMENTS_JSON" \
1402
- | bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
1643
+ | bash "$TRUSTED_SCRIPT" bodies "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
1403
1644
  | jq -rs '[.[] | scan("^<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | join(", ")' 2>/dev/null || echo "") # unresolvable => empty => count 0 (degraded, fail closed)
1404
1645
  fi
1405
1646
  # 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".