forgedock 1.18.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 (116) hide show
  1. package/bin/engine/outcome-codes.mjs +13 -0
  2. package/bin/engine/phases.mjs +54 -4
  3. package/bin/engine/runlog.mjs +10 -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 +32 -2
  7. package/bin/engine.mjs +139 -7
  8. package/bin/runner.mjs +69 -9
  9. package/bin/stats/aggregate.mjs +7 -3
  10. package/bin/stats/cache.mjs +7 -3
  11. package/bin/stats/github-source.mjs +136 -28
  12. package/bin/stats/index.mjs +46 -9
  13. package/bin/stats/render.mjs +1 -1
  14. package/bin/stats/stats.test.mjs +201 -40
  15. package/commands/review-pr-staging.md +4 -4
  16. package/commands/review-pr.md +25 -15
  17. package/commands/work-on/build/architect.md +53 -0
  18. package/commands/work-on/build/implement.md +19 -0
  19. package/commands/work-on/build/validate.md +320 -28
  20. package/commands/work-on/build.md +66 -9
  21. package/commands/work-on/investigate.md +7 -0
  22. package/commands/work-on/remediate.md +32 -13
  23. package/commands/work-on/review.md +32 -19
  24. package/docs/CONFIG.md +89 -2
  25. package/docs/OUTCOME-CODES.md +5 -1
  26. package/docs/STATS.md +11 -4
  27. package/docs/TELEMETRY.md +5 -2
  28. package/docs/WORK-ON-RUNTIME.md +18 -0
  29. package/package.json +2 -2
  30. package/packages/protocol/src/types.js +1 -1
  31. package/scripts/acceptance-red-green.sh +90 -0
  32. package/scripts/acceptance-red-green.test.sh +74 -0
  33. package/scripts/cap-findings.test.sh +2 -0
  34. package/scripts/check-contract-scope.test.sh +2 -0
  35. package/scripts/check-edge-cases.sh +143 -0
  36. package/scripts/check-edge-cases.test.sh +175 -0
  37. package/scripts/check-spec-markers.sh +2 -0
  38. package/scripts/check-test-tmp-cleanup.sh +56 -0
  39. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  40. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  41. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  42. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  43. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  44. package/scripts/ci-local/adapters/common.mjs +115 -0
  45. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  46. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  47. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  48. package/scripts/ci-local/adapters/portability.mjs +104 -0
  49. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  50. package/scripts/ci-local/cli.mjs +50 -0
  51. package/scripts/ci-local/lib.mjs +371 -0
  52. package/scripts/ci-local/plan.mjs +186 -0
  53. package/scripts/ci-local/run.mjs +345 -0
  54. package/scripts/ci-local/scoping.mjs +508 -0
  55. package/scripts/ci-local.sh +97 -0
  56. package/scripts/ci-local.test.sh +919 -0
  57. package/scripts/classify-finding.sh +32 -6
  58. package/scripts/classify-finding.test.sh +23 -0
  59. package/scripts/classify-test-run.sh +69 -0
  60. package/scripts/classify-test-run.test.sh +90 -0
  61. package/scripts/close-parent-c3.test.sh +2 -0
  62. package/scripts/consumer-sweep.sh +569 -0
  63. package/scripts/consumer-sweep.test.sh +357 -0
  64. package/scripts/detect-append-registries.sh +106 -0
  65. package/scripts/detect-append-registries.test.sh +64 -0
  66. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  67. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  68. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  69. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  70. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  71. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  72. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  73. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  74. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  75. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  76. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  77. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  78. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  79. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  80. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  81. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  82. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  83. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  84. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  85. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  86. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  87. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  88. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  89. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  90. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  91. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  92. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  93. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  94. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  95. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  96. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  97. package/scripts/forge-root.test.sh +1 -1
  98. package/scripts/lib/telemetry.sh +1 -0
  99. package/scripts/remediate-approved-head.test.sh +2 -0
  100. package/scripts/remediate-basesync.test.sh +1 -1
  101. package/scripts/review-acceptance-gate.sh +2 -2
  102. package/scripts/review-acceptance-gate.test.sh +20 -0
  103. package/scripts/review-delta.sh +8 -1
  104. package/scripts/review-delta.test.sh +19 -0
  105. package/scripts/review-finalize.sh +193 -41
  106. package/scripts/review-finalize.test.sh +211 -4
  107. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  108. package/scripts/review-prepare.sh +5 -3
  109. package/scripts/review-prepare.test.sh +18 -2
  110. package/scripts/run-shell-tests.sh +5 -0
  111. package/scripts/telemetry.test.sh +2 -0
  112. package/scripts/trusted-comments.test.sh +2 -1
  113. package/scripts/verify-phase-trail.test.sh +8 -0
  114. package/scripts/wait-ci-green.sh +61 -9
  115. package/scripts/wait-ci-green.test.sh +41 -3
  116. 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,22 @@
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
+ # [--purpose-findings-file F | --purpose-findings TEXT] [--result stale|clean|blocking] [--agents-file F | --agents TEXT] [--findings-count N] [--blocking-count N]
52
53
  # [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--dry-run]
53
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
54
55
  # merge-gate: --verdict "APPROVED|CHANGES REQUESTED" --has-purpose-regression B --calibration-needs-human B --trust-needs-human B
55
- # --base-conflict B --other-blocking B [--calibration-note T] [--table-cell-key K] [--base B] [--issue N]
56
+ # --base-conflict B --other-blocking B [--calibration-note-file F | --calibration-note T] [--table-cell-key K] [--base B] [--issue N]
56
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.
57
61
  # demilestone: --is-milestone true|false [--merge-state S] (8B passes MERGED; 9A omits it and the state is read from the PR)
58
62
  # Env: FORGE_CONFIG (forge.yaml for review.max_filed_findings; default ./forge.yaml).
59
63
  # Exit: 0 ok | 3 BLOCKED (REVIEW_RESULT: status: BLOCKED printed; the caller must stop the review, never skip) | 4 merge-gate only:
60
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)
61
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).
62
68
  # merge-gate reports every non-merging routed outcome as MERGE_OUTCOME=<merged|merge-failed|parked-awaiting-merge|base-conflict|needs-human|
63
69
  # protection-blocked|stale-review|ci-not-green>; everything except merged / merge-failed / parked-awaiting-merge also exits 3 with a blocker.
64
70
  #
@@ -76,9 +82,10 @@ PR_NUMBER=""; REPO=""; CONTEXT=""; OUT_DIR=""; IN_FILE=""; OUT_FILE=""; AUTO_MER
76
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=""
77
83
  BASE_CONFLICT=""; OTHER_BLOCKING=""; INTENSITY_TIER=""; TABLE_CELL_KEY=""; TRUST_SHADOW_MODE=""; MERGE_HEALTH=""; MERGE_HEALTH_STATE=""; RESULT=""
78
84
  AGENTS_TEXT=""; FINDINGS_COUNT=""; BLOCKING_COUNT=""; PURPOSE_FINDINGS=""; IS_MILESTONE="false"; MERGE_STATE_IN=""
85
+ CALIBRATION_NOTE_FILE=""; AGENTS_FILE=""; PURPOSE_FINDINGS_FILE=""
79
86
  while [ $# -gt 0 ]; do
80
87
  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)
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)
82
89
  [ $# -ge 2 ] || { echo "review-finalize: $1 requires a value" >&2; exit 2; } ;;
83
90
  esac
84
91
  case "$1" in
@@ -101,6 +108,9 @@ while [ $# -gt 0 ]; do
101
108
  --has-purpose-regression) HAS_PURPOSE_REGRESSION="${2:-}"; shift 2 ;;
102
109
  --calibration-needs-human) CALIBRATION_NEEDS_HUMAN="${2:-}"; shift 2 ;;
103
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 ;;
104
114
  --trust-needs-human) TRUST_NEEDS_HUMAN="${2:-}"; shift 2 ;;
105
115
  --base-conflict) BASE_CONFLICT="${2:-}"; shift 2 ;;
106
116
  --other-blocking) OTHER_BLOCKING="${2:-}"; shift 2 ;;
@@ -123,6 +133,16 @@ while [ $# -gt 0 ]; do
123
133
  esac
124
134
  done
125
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
+
126
146
  # --- file-verify needs nothing else: it only parses the issue-creation result text on stdin ---
127
147
  if [ "$SUB" = "file-verify" ]; then
128
148
  ISSUE_SKILL_OUTPUT=$(cat)
@@ -252,12 +272,20 @@ do_extract() {
252
272
  # The panel guard guarantees at least one trusted reviewer body for this head; none means a misread, never "clean".
253
273
  [ -n "$SOURCE_BODIES" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: no trusted reviewer comments for head ${PRIMARY_SHA} (fail closed)"; exit 3; }
254
274
  CARRIER_BODIES=$(agent_bodies_at "$PRIMARY_SHA")
275
+ CARRIER_BODIES_PRIMARY="$CARRIER_BODIES"
255
276
  if [ -n "$EARLIER_SHA" ]; then
256
277
  EARLIER_BODIES=$(bodies_at "$EARLIER_SHA") && [ -n "$EARLIER_BODIES" ] \
257
278
  || { echo "REVIEW_RESULT: status: BLOCKED, blocker: earlier reviewed head ${EARLIER_SHA} findings unreadable (fail closed)"; exit 3; }
258
279
  SOURCE_BODIES=$(printf '%s\n%s' "$SOURCE_BODIES" "$EARLIER_BODIES")
259
280
  CARRIER_BODIES=$(printf '%s\n%s' "$CARRIER_BODIES" "$(agent_bodies_at "$EARLIER_SHA")")
260
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/ $//')
261
289
  # Match to the closing ` -->`, never `[^>]+`: a `>` or `->` inside a title must not drop the finding (#3897).
262
290
  FINDINGS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' 2>/dev/null)
263
291
  # Fail closed when a raw marker did not parse: a lost finding must never read as a clean review.
@@ -267,7 +295,8 @@ do_extract() {
267
295
  || { echo "REVIEW_RESULT: status: BLOCKED, blocker: ${N_RAW_MARKERS} FINDING markers but ${N_PARSED_MARKERS} parsed (malformed marker, fail closed)"; exit 3; }
268
296
 
269
297
  # 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"
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"
271
300
 
272
301
  RAW="$WORKDIR/raw.jsonl"; : > "$RAW"; N_EXTRACTED=0
273
302
  while IFS='|' read -r F_ID F_CONF F_SEV F_LOC F_TITLE; do
@@ -280,11 +309,24 @@ do_extract() {
280
309
  case "$_r" in *[!0-9-]*|"") ;; *) F_FILE="${F_LOC%:*}"; L1="${_r%%-*}"; L2="${_r#*-}"; [ "$L2" != "$_r" ] || L2="$L1" ;; esac ;;
281
310
  esac
282
311
  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.
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).
284
315
  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"
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
286
326
  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.
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.
288
330
  SECTION_FILE="$WORKDIR/section.$N_EXTRACTED"
289
331
  awk -v id="$F_ID" '
290
332
  function hasid(l, p, pre, post, rest, off) {
@@ -297,12 +339,29 @@ do_extract() {
297
339
  }
298
340
  return 0
299
341
  }
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"
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"
306
365
  # Failure scenario: present / absent when the section was found, unknown when the carrying comment or section is unreadable.
307
366
  SCENARIO="unknown"
308
367
  if [ -s "$SECTION_FILE" ]; then
@@ -377,7 +436,7 @@ EOF
377
436
  }
378
437
 
379
438
  FINAL="$WORKDIR/final.jsonl"; : > "$FINAL"
380
- N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_KEPT=0
439
+ N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_INTRODUCED=0; N_KEPT=0
381
440
  I=0; TOTAL=$(jq 'length' "$DEDUPED")
382
441
  while [ "$I" -lt "$TOTAL" ]; do
383
442
  F=$(jq -c ".[$I]" "$DEDUPED"); I=$((I + 1))
@@ -401,17 +460,22 @@ EOF
401
460
  esac
402
461
  if [ "$STALE" -eq 1 ]; then N_STALE=$((N_STALE + 1)); continue; fi
403
462
  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
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
415
479
  fi
416
480
  # Gate 3 (concrete failure): MEDIUM or lower without a **Failure scenario** is speculative hardening, demoted to a NOTE.
417
481
  # CRITICAL/HIGH are exempt; an unreadable carrying comment keeps the finding.
@@ -420,13 +484,14 @@ EOF
420
484
  fi
421
485
  case "$ROUTE" in pre-existing) N_PREEXISTING=$((N_PREEXISTING + 1)) ;; demoted-no-scenario) N_NO_SCENARIO=$((N_NO_SCENARIO + 1)) ;; esac
422
486
  N_KEPT=$((N_KEPT + 1))
423
- printf '%s' "$F" | jq -c --arg r "$ROUTE" '. + {route: $r}' >> "$FINAL"
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"
424
489
  done
425
490
 
426
491
  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" \
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" \
428
493
  '{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},
494
+ counts: {findings_extracted: $ex, findings_deduped: $dd, findings_dropped_stale: $st, findings_preexisting: $pe, findings_introduced: $ni, notes_demoted_no_scenario: $ns},
430
495
  findings: .}' "$FINAL" > "$OUT_FILE" || { echo "review-finalize: cannot write $OUT_FILE" >&2; exit 2; }
431
496
  echo "FINDINGS_OUT=$OUT_FILE"
432
497
  echo "STRUCTURED_FINDINGS=$N_EXTRACTED"
@@ -434,7 +499,8 @@ EOF
434
499
  echo "FINDINGS_DROPPED_STALE=$N_STALE"
435
500
  echo "FINDINGS_PREEXISTING=$N_PREEXISTING"
436
501
  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
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
438
504
  exit 0
439
505
  }
440
506
 
@@ -603,7 +669,7 @@ do_classify() {
603
669
  TOTAL=$(jq '.findings | length' "$IN_FILE"); I=0
604
670
  while [ "$I" -lt "$TOTAL" ]; do
605
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)
606
- F_ROUTE=$(fj .route); F_FIX=$(fj .fix_efficacy); F_L1=$(fj .line_start); F_L2=$(fj .line_end); F_LOC=$(fj .loc)
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)
607
673
  if [ "$F_ROUTE" = "demoted-no-scenario" ]; then printf '%s\t%s\n' "$I" "NOTE no-scenario" >> "$DISP_FILE"; I=$((I + 1)); continue; fi
608
674
  FINDING_TEXT_FILE="$WORKDIR/finding-text.$I"; jq -r ".findings[$I].text // \"\"" "$IN_FILE" > "$FINDING_TEXT_FILE" 2>/dev/null
609
675
  set -- --severity "$F_SEV" --confidence "$F_CONF" --agent "$F_AGENT" --lineage "$FINDING_LINEAGE" --text-file "$FINDING_TEXT_FILE"
@@ -611,6 +677,7 @@ do_classify() {
611
677
  [ -n "$INPR_DIFF_FILE" ] && [ "$F_ROUTE" != "pre-existing" ] && set -- "$@" --inpr-diff "$INPR_DIFF_FILE"
612
678
  [ -n "$CONTRACT_SCOPE_FILE" ] && set -- "$@" --contract-scope "$CONTRACT_SCOPE_FILE" --contract-open "$CONTRACT_OPEN_FILE" --pr-files "$PR_FILES_FILE"
613
679
  [ -n "$MERGE_ISSUE" ] && set -- "$@" --fixes-issue "$MERGE_ISSUE"
680
+ case "$F_SCOPE" in introduced|pre-existing|unknown) set -- "$@" --scope "$F_SCOPE" ;; esac
614
681
  case "$F_FIX" in fails|regresses) set -- "$@" --fix-efficacy "$F_FIX" ;; esac
615
682
  [ -n "$PRIOR_FILE" ] && { set -- "$@" --prior-findings "$PRIOR_FILE" --title "$F_TITLE"; [ -n "$DIFF_MAP_FILE" ] && set -- "$@" --diff-map "$DIFF_MAP_FILE"; }
616
683
  [ -n "$F_L1" ] && set -- "$@" --line-start "$F_L1" --line-end "${F_L2:-$F_L1}"
@@ -683,7 +750,7 @@ do_classify() {
683
750
  FINDINGS_FILED=$(jq '[.findings[] | select(.class == "ISSUE" and .route != "file-pre-existing" and .route != "pre-existing")] | length' "$OUT_FILE")
684
751
  PLAIN_NOTES=$(jq '[.findings[] | select(.class == "NOTE" and .route != "pre-existing" and (.disposition | test("^NOTE (contract-|reflag-|capped)") | not))] | length' "$OUT_FILE")
685
752
  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")
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")
687
754
  N_EXTRACTED=$(jq -r '.counts.findings_extracted // (.findings | length)' "$OUT_FILE")
688
755
  CLASSIFIER_MODE=script # the guard above blocks the review when the classifier is unresolvable, so this is always script
689
756
 
@@ -705,7 +772,7 @@ do_classify() {
705
772
  # Disposition marker (MANDATORY whenever Phase 6A extracted at least one finding): Phase 8 refuses to auto-merge a PR that has FINDING:
706
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).
707
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
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} -->
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} -->
709
776
  $(cat "$NOTE_LIST_FILE" 2>/dev/null)"
710
777
  printf '%s\n' "$NOTE_DISPOSITION_BODY" > "$OUT_DIR/note-disposition.md"
711
778
  POSTED_DISPOSITION=false; POSTED_INPR=false
@@ -782,7 +849,8 @@ do_file_prepare() {
782
849
  while [ "$I" -lt "$TOTAL" ]; do
783
850
  F_ROUTE=$(fj .route); F_CLASS=$(fj .class)
784
851
  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)
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
786
854
  case "$F_LINE" in ''|*[!0-9]*) F_LINE=0 ;; esac
787
855
  ACTION="file"; DEDUP_OF=""; REGRESSION=""; NOTE=""; CONSOLIDATE="false"; PRIOR_NUMS=""; SUBSYSTEM=""; RECURRENCE=""
788
856
  # Title (defense in depth: strip command substitution so the raw title stays readable if it round-trips through an eval-based consumer).
@@ -881,8 +949,8 @@ do_file_prepare() {
881
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")" \
882
950
  --arg prio "$FINDING_PRIORITY" --argjson labels "$LABELS" --arg ms "$MILESTONE_TITLE" --arg batch "$BATCHABLE" --arg dedup "$DEDUP_OF" \
883
951
  --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,
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,
886
954
  labels: $labels, milestone: $ms, batchable: ($batch == "true"), dedup_of: ($dedup | tonumber? // null), regression_of: ($reg | tonumber? // null),
887
955
  note: $note, pattern: {slug: $slug, subsystem: $sub, consolidate: ($cons == "true"), prior: ($prior | split(" ") | map(select(length > 0) | tonumber)), recurrence: $rec}}' >> "$OUT_ITEMS"
888
956
  I=$((I + 1))
@@ -1061,9 +1129,10 @@ do_calibrate() {
1061
1129
  fi
1062
1130
  echo "CALIBRATION_NEEDS_HUMAN=${CALIBRATION_NEEDS_HUMAN}"
1063
1131
  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).
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.
1066
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
1067
1136
  echo "CALIBRATION_NOTE=${CALIBRATION_NOTE}"
1068
1137
  exit 0
1069
1138
  }
@@ -1184,6 +1253,86 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
1184
1253
  fi
1185
1254
  [ "${TRAIL_RC:-1}" -eq 0 ] || blocked "phase trail unreadable (rc=${TRAIL_RC:-1})" # hard guard: nothing below runs unless the trail verified
1186
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
+
1187
1336
  # --- merge guard state: §7B verdict + purpose-regression + calibration + trust-escalation ---
1188
1337
  # These are decided in earlier steps (earlier shells), so they arrive as flags. An empty or unbound value would read as
1189
1338
  # "not blocking" and fall through to the merge path, so any value outside the expected set stops here instead of merging.
@@ -1277,6 +1426,9 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
1277
1426
  else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
1278
1427
  echo "$CI_GATE_OUT"
1279
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
1280
1432
  # rc 2 "head moved" is a stale review, not a human problem; rc 3 TIMEOUT means CI is still running.
1281
1433
  printf '%s\n' "$CI_GATE_OUT" | grep -q 'PR head moved during the wait' && CI_GATE_RC=4
1282
1434
  if [ "$CI_GATE_RC" -eq 3 ]; then
@@ -1399,7 +1551,7 @@ do_integrity() {
1399
1551
  ACTUAL_AGENT_DOMAINS=""
1400
1552
  if [ -n "$TRUSTED_SCRIPT" ]; then
1401
1553
  ACTUAL_AGENT_DOMAINS=$(printf '%s' "$COMMENTS_JSON" \
1402
- | bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
1554
+ | bash "$TRUSTED_SCRIPT" bodies "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
1403
1555
  | jq -rs '[.[] | scan("^<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | join(", ")' 2>/dev/null || echo "") # unresolvable => empty => count 0 (degraded, fail closed)
1404
1556
  fi
1405
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".