forgedock 1.30.0 → 1.31.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 (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
@@ -9,7 +9,7 @@
9
9
  # the PR checkout, a SPEC_CLI persona file, forge.yaml paths.root) as data.
10
10
  #
11
11
  # Usage: review-prepare.sh --pr N --repo owner/name [--merge-issue N] [--thorough true|false] [--out FILE] [--out-dir DIR]
12
- # [--checkout-pr] [--no-delta] [--build-gate block|advisory]
12
+ # [--checkout-pr] [--no-delta] [--delta-from SHA] [--build-gate block|advisory]
13
13
  # --pr PR number (numeric, required) --repo owner/name (required)
14
14
  # --merge-issue issue the PR closes, from --issue (numeric; non-numeric is ignored)
15
15
  # --thorough true restores the full-union roster --out write the context JSON here (default: stdout)
@@ -23,6 +23,11 @@
23
23
  # --no-delta skip the FULL/DELTA/BASE_SYNC_ONLY resolver: the review mode is always FULL (a staging bundle review always reviews the
24
24
  # whole bundle), without widening the roster to the full union the way --thorough does; it also enables the pinned
25
25
  # local-git diff fallback when `gh pr diff` fails (e.g. HTTP 406 over 20,000 lines), which a milestone->staging PR gets without it
26
+ # --delta-from explicit DELTA from a caller-supplied earlier reviewed head (forge#4360, `forgedock review-staging --delta`): skips the
27
+ # resolver (even with --no-delta), narrows diff, slices and roster to SHA..review sha (the roster is the domains the delta
28
+ # files touch). BLOCKED when SHA is not a full commit id in the checkout, equals or is not an ancestor of the review sha,
29
+ # or has no trusted FORGE:REVIEW-AGENT body with `Reviewed-SHA: SHA` (no earlier pass to credit). An empty file delta
30
+ # is FULL. Not combinable with --thorough true (exit 2).
26
31
  # Env: FORGE_CONFIG (forge.yaml path; default: the main checkout's forge.yaml, then the git top-level one),
27
32
  # REVIEW_PREPARE_SLEEP (seconds between mergeability retries, default 5),
28
33
  # REVIEW_PREPARE_INDEX_TIMEOUT (seconds the code-index build may take, default 120; on timeout or failure the slices say
@@ -61,10 +66,10 @@
61
66
 
62
67
  set -u
63
68
 
64
- PR_NUMBER=""; REPO=""; MERGE_ISSUE=""; THOROUGH="false"; OUT=""; OUT_DIR=""; CHECKOUT_PR="false"; NO_DELTA="false"; BUILD_GATE="block"
69
+ PR_NUMBER=""; REPO=""; MERGE_ISSUE=""; THOROUGH="false"; OUT=""; OUT_DIR=""; CHECKOUT_PR="false"; NO_DELTA="false"; BUILD_GATE="block"; DELTA_FROM_OPT=""
65
70
  while [ $# -gt 0 ]; do
66
71
  case "$1" in
67
- --pr|--repo|--merge-issue|--thorough|--out|--out-dir)
72
+ --pr|--repo|--merge-issue|--thorough|--out|--out-dir|--delta-from)
68
73
  [ $# -ge 2 ] || { echo "review-prepare: $1 requires a value" >&2; exit 2; } ;;
69
74
  esac
70
75
  case "$1" in
@@ -76,6 +81,7 @@ while [ $# -gt 0 ]; do
76
81
  --out-dir) OUT_DIR="${2:-}"; shift 2 ;;
77
82
  --checkout-pr) CHECKOUT_PR="true"; shift ;;
78
83
  --no-delta) NO_DELTA="true"; shift ;;
84
+ --delta-from) DELTA_FROM_OPT="${2:-}"; [ -n "$DELTA_FROM_OPT" ] || DELTA_FROM_OPT="-"; shift 2 ;;
79
85
  --build-gate) case "${2:-}" in block|advisory) BUILD_GATE="$2" ;; *) echo "review-prepare: --build-gate must be block or advisory" >&2; exit 2 ;; esac; shift 2 ;;
80
86
  *) echo "review-prepare: unknown argument: $1" >&2; exit 2 ;;
81
87
  esac
@@ -85,6 +91,7 @@ case "$REPO" in */*) ;; *) echo "review-prepare: --repo must be owner/name" >&2;
85
91
  case "$REPO" in *[!A-Za-z0-9._/-]*|*/*/*|/*|*/) echo "review-prepare: invalid --repo" >&2; exit 2 ;; esac
86
92
  case "$MERGE_ISSUE" in *[!0-9]*) MERGE_ISSUE="" ;; esac
87
93
  [ "$THOROUGH" = "true" ] || THOROUGH="false"
94
+ if [ -n "$DELTA_FROM_OPT" ] && [ "$THOROUGH" = "true" ]; then echo "review-prepare: --delta-from cannot be combined with --thorough true" >&2; exit 2; fi
88
95
 
89
96
  # --- trusted helper resolution (install roots only; never cwd) ---
90
97
  # shellcheck source=/dev/null # the resolver lib is found at runtime in the trusted install root
@@ -125,7 +132,7 @@ cleanup() { restore_ref; rm -rf "$TMP_DIR"; }
125
132
  trap cleanup EXIT
126
133
 
127
134
  # --- Phase 1A: fetch ---
128
- PR_INFO=$(gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,author,baseRefName,headRefName,headRefOid,isCrossRepository,additions,deletions,changedFiles 2>/dev/null) || PR_INFO=""
135
+ PR_INFO=$(gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,author,baseRefName,headRefName,headRefOid,isCrossRepository,additions,deletions,changedFiles,labels 2>/dev/null) || PR_INFO=""
129
136
  printf '%s' "$PR_INFO" | jq -e . >/dev/null 2>&1 || PR_INFO=""
130
137
  if [ -z "$PR_INFO" ]; then block "gh pr view failed for #${PR_NUMBER}"; fi
131
138
  pj() { printf '%s' "$PR_INFO" | jq -r "$1 // empty" 2>/dev/null; }
@@ -452,7 +459,53 @@ delta_roster() {
452
459
  printf '%s' "$out"
453
460
  }
454
461
  REVIEW_MODE="FULL"; DELTA_FROM=""; CARRIED_FROM=""; FULL_ROUNDS=0; DELTA_FILES=""
455
- if [ "$THOROUGH" != "true" ] && [ "$IS_MILESTONE_TO_STAGING" != "true" ] && [ "$NO_DELTA" != "true" ]; then
462
+ if [ -n "$DELTA_FROM_OPT" ]; then
463
+ # forge#4360: explicit delta. Every check fails closed (BLOCKED); only an empty file delta widens to FULL (below).
464
+ _dtop="${TOP:-.}"
465
+ case "$DELTA_FROM_OPT" in *[!0-9a-f]*|"") _dok=false ;; *) { [ "${#DELTA_FROM_OPT}" -eq 40 ] || [ "${#DELTA_FROM_OPT}" -eq 64 ]; } && _dok=true || _dok=false ;; esac
466
+ if [ "$_dok" != "true" ]; then block "--delta-from is not a full commit id: $(printf '%s' "$DELTA_FROM_OPT" | tr -cd '0-9A-Za-z' | cut -c1-64)"
467
+ elif [ "$DELTA_FROM_OPT" = "$REVIEW_SHA" ]; then block "--delta-from ${DELTA_FROM_OPT} is the review head: nothing to review"
468
+ elif ! git -C "$_dtop" cat-file -e "${DELTA_FROM_OPT}^{commit}" 2>/dev/null; then block "--delta-from ${DELTA_FROM_OPT} is not a commit in this checkout (fetch it first)"
469
+ elif ! git -C "$_dtop" merge-base --is-ancestor "$DELTA_FROM_OPT" "$REVIEW_SHA" 2>/dev/null; then block "--delta-from ${DELTA_FROM_OPT} is not an ancestor of ${REVIEW_SHA} (force-push or rebase): run a full review"
470
+ elif printf '%s' "$PR_INFO" | jq -e '[.labels[]?.name] | index("review-degraded") != null' >/dev/null 2>&1; then block "--delta-from ${DELTA_FROM_OPT}: the PR is labelled review-degraded (an earlier panel was incomplete): run a full review"
471
+ else
472
+ # forge#4471: the earlier pass must be complete and non-degraded, as scripts/review-delta.sh requires of a checkpoint:
473
+ # an agent body with a findings block for <from>, a post-panel-guard proof naming <from> (staging summary, synthesized
474
+ # block, or a # PR Review Summary whose Agents count matches) and no degraded marker after its first agent body.
475
+ _dv="error"
476
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$COMMENTS_JSON" ]; then
477
+ _db=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->|^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->|# PR Review Summary|^<!-- FORGE:GATE_(PASS|FAILURE) -->|<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity|<!-- FORGE:REVIEW_DEGRADED|<!-- FORGE:REVIEW_BLOCKED' 2>/dev/null) \
478
+ && _dv=$(printf '%s\n' "$_db" | jq -s -r --arg f "$DELTA_FROM_OPT" '
479
+ def shas($t; $re): [ $t | scan($re) | .[0] ];
480
+ def one($a): if ($a | length) == 1 then $a[0] else null end;
481
+ def rsha: "(?:^|\\n)Reviewed-SHA: ([0-9a-f]{40}|[0-9a-f]{64})\\r?(?:\\n|$)";
482
+ to_entries as $E
483
+ | [ $E[] | select((.value | test("\\A<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->")) and (.value | test("<!-- REVIEW-FINDINGS-START -->")))
484
+ | select(one(shas(.value; rsha)) == $f)
485
+ | {i: .key, d: (.value | capture("\\A<!-- FORGE:REVIEW-AGENT:(?<d>[a-z0-9-]+) -->").d)} ] as $AG
486
+ | if ($AG | length) == 0 then "no-agent" else
487
+ ($AG | map(.i) | min) as $first | ($AG | map(.d) | unique | length) as $nd
488
+ | ([ $E[] | .value as $t | select(
489
+ (($t | test("\\A<!-- FORGE:GATE_(?:PASS|FAILURE) -->\\r?\\n## Staging Review:"))
490
+ and one(shas($t; "\\*\\*Head\\*\\*: `([0-9a-f]{40}|[0-9a-f]{64})`")) == $f)
491
+ or (($t | startswith("<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->")) and one(shas($t; rsha)) == $f)
492
+ or (($t | test("# PR Review Summary"))
493
+ and one(shas($t; "\\*\\*Reviewed commit\\*\\*: `?([0-9a-f]{40}|[0-9a-f]{64})`?(?:[ |\\r\\n]|$)")) == $f
494
+ and one(shas($t; "\\*\\*Agents\\*\\*: ([0-9]+)")) == ($nd | tostring))) ] | length) as $proof
495
+ | ([ $E[] | select(.key > $first)
496
+ | select(.value | test("<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity|<!-- FORGE:REVIEW_DEGRADED|<!-- FORGE:REVIEW_BLOCKED")) ] | length) as $deg
497
+ | if $deg > 0 then "degraded" elif $proof == 0 then "no-proof" else "ok" end
498
+ end' 2>/dev/null) || _dv="error"
499
+ fi
500
+ case "$_dv" in
501
+ ok) REVIEW_MODE="DELTA"; DELTA_FROM="$DELTA_FROM_OPT" ;;
502
+ no-agent) block "--delta-from ${DELTA_FROM_OPT}: no trusted FORGE:REVIEW-AGENT comment with a findings block for that head, no earlier pass to credit: run a full review" ;;
503
+ no-proof) block "--delta-from ${DELTA_FROM_OPT}: no trusted proof that the earlier panel completed (staging summary, synthesized block or PR Review Summary for that head): run a full review" ;;
504
+ degraded) block "--delta-from ${DELTA_FROM_OPT}: the earlier pass is degraded (review-panel-integrity / REVIEW_DEGRADED / REVIEW_BLOCKED after its agents): run a full review" ;;
505
+ *) block "--delta-from ${DELTA_FROM_OPT}: could not read the trusted review comments, no earlier pass to credit: run a full review" ;;
506
+ esac
507
+ fi
508
+ elif [ "$THOROUGH" != "true" ] && [ "$IS_MILESTONE_TO_STAGING" != "true" ] && [ "$NO_DELTA" != "true" ]; then
456
509
  read -r RD_KIND RD_ARG RD_ROUNDS <<< "$(review_delta_mode)"
457
510
  FULL_ROUNDS="${RD_ROUNDS:-0}"
458
511
  case "$RD_KIND" in
@@ -463,7 +516,21 @@ fi
463
516
  if [ "$REVIEW_MODE" = "DELTA" ]; then
464
517
  DELTA_FILES=$(git -C "${TOP:-.}" -c core.quotepath=off diff --name-only --no-renames "${DELTA_FROM}" "${REVIEW_SHA}" 2>/dev/null) || DELTA_FILES=""
465
518
  if [ -z "$DELTA_FILES" ]; then REVIEW_MODE="FULL"; DELTA_FROM=""
466
- else SELECTED_AGENTS=$(canonical_roster "$(delta_roster "$FULL_ROUNDS" "$SELECTED_AGENTS" "$DELTA_FILES")"); fi
519
+ else
520
+ # an explicit --delta-from always narrows the roster to the domains the delta touches (no full-rounds gate)
521
+ _drounds="$FULL_ROUNDS"
522
+ # forge#4471: an explicit delta that changes the review specs/config cannot credit the earlier pass (same list as
523
+ # scripts/review-delta.sh, which answers FULL there; review-prepare.test.sh checks the two lists match)
524
+ if [ -n "$DELTA_FROM_OPT" ]; then
525
+ while IFS= read -r _df; do
526
+ case "$_df" in
527
+ forge.yaml|commands/review-pr*.md|commands/review-pr-agents/*|scripts/review-delta.sh|scripts/trusted-comments.sh|scripts/verify-phase-trail.sh|docs/spec/review-protocol.md|scripts/classify-finding.sh) block "--delta-from ${DELTA_FROM}: the delta changes ${_df} (review spec/config), the earlier pass cannot be credited: run a full review"; break ;;
528
+ esac
529
+ done <<< "$DELTA_FILES"
530
+ fi
531
+ [ -n "$DELTA_FROM_OPT" ] && { _drounds=2; ROSTER_REASON="delta ${DELTA_FROM:0:7}..${REVIEW_SHA:0:7}: domains the delta touches"; }
532
+ SELECTED_AGENTS=$(canonical_roster "$(delta_roster "$_drounds" "$SELECTED_AGENTS" "$DELTA_FILES")")
533
+ fi
467
534
  fi
468
535
  [ "$REVIEW_MODE" = "BASE_SYNC_ONLY" ] && SELECTED_AGENTS=""
469
536
 
@@ -199,6 +199,91 @@ DELTA_KIND=DELTA prep "$WORK/root-delta" --thorough true; eq "thorough never goe
199
199
  setup feat/x staging "$FIXFILES" "$DIFF_AUTH_BILL" "notasha"
200
200
  DELTA_KIND=DELTA prep "$WORK/root-delta"; eq "unvalidated sha is FULL" .delta.mode FULL
201
201
 
202
+ # --- 5b. explicit --delta-from (forge#4360: forgedock review-staging --delta): no resolver involved ---
203
+ cmt() { # cmt <body> [association]: one comment object
204
+ jq -n --arg b "$1" --arg a "${2:-OWNER}" '{body: $b, author_association: $a, user: {login: "r", type: "User"}}'
205
+ }
206
+ agent_body() { printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n<!-- REVIEW-FINDINGS-START -->\n<!-- REVIEW-FINDINGS-END -->' "$1"; }
207
+ # shellcheck disable=SC2016 # the backticks are literal markdown
208
+ staging_summary() { printf '<!-- FORGE:GATE_FAILURE -->\n## Staging Review: NEEDS FIXES FIRST\n\n**Head**: `%s`\n\n<!-- FORGE:GATE_FAILURE:TYPE=staging-review|BUNDLE= -->' "$1"; }
209
+ comments_of() { jq -s . > "$FIX/comments.json"; } # comments_of < <cmt ...>...
210
+ earlier_pass() { # earlier_pass <sha> [association]: a complete earlier pass (agent body + post-guard staging summary)
211
+ { cmt "$(agent_body "$1")" "${2:-OWNER}"; cmt "$(staging_summary "$1")" "${2:-OWNER}"; } | comments_of
212
+ }
213
+ git -C "$REPO_DIR" checkout -q -b pr-empty pr-branch; git -C "$REPO_DIR" commit -q --allow-empty -m C; # own branch: pr-branch stays at B (pinned-fallback tests below)
214
+ SHA_C=$(git -C "$REPO_DIR" rev-parse HEAD)
215
+ git -C "$REPO_DIR" checkout -q -b side main; echo d > "$REPO_DIR/d.txt"; git -C "$REPO_DIR" add -A; git -C "$REPO_DIR" commit -qm D
216
+ SHA_D=$(git -C "$REPO_DIR" rev-parse HEAD); git -C "$REPO_DIR" checkout -q main
217
+ setup staging main "$FIXFILES" "$DIFF_AUTH_BILL"; earlier_pass "$SHA_A"
218
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"
219
+ rc_is "--delta-from ok" 0; eq "--delta-from mode" .delta.mode DELTA; eq "--delta-from from" .delta.from "$SHA_A"
220
+ eq "--delta-from files" '.delta.files | join(" ")' "services/api/auth.py"
221
+ eq "--delta-from roster is the touched domains, even before 2 full rounds" '.roster.agents | join(" ")' "security auth api"
222
+ eq "--delta-from reason names the delta" '.roster.reason | startswith("delta ")' true
223
+ eq "--delta-from slices narrow to the delta" '.slices.security.paths | join(" ")' "services/api/auth.py"
224
+ eq "--delta-from full_files stays whole" '.full_files | length' 2
225
+ prep "$WORK/root-nodelta" --no-delta; eq "--no-delta without --delta-from stays FULL" .delta.mode FULL
226
+ earlier_pass "$SHA_A" NONE
227
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "untrusted earlier pass" 3
228
+ eq "untrusted earlier pass blocker" '.blockers | join(" ") | test("no earlier pass to credit")' true
229
+ earlier_pass "$SHA_B"
230
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "no earlier pass at the from sha" 3
231
+ earlier_pass "$SHA_D"
232
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_D"; rc_is "non-ancestor from sha" 3
233
+ eq "non-ancestor blocker" '.blockers | join(" ") | test("not an ancestor")' true
234
+ earlier_pass "$SHA_B"
235
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_B"; rc_is "from sha == head" 3
236
+ eq "head blocker" '.blockers | join(" ") | test("is the review head")' true
237
+ prep "$WORK/root-nodelta" --no-delta --delta-from "0123456789012345678901234567890123456789"; rc_is "unknown commit" 3
238
+ prep "$WORK/root-nodelta" --no-delta --delta-from "abc123"; rc_is "short sha" 3
239
+ setup staging main "$FIXFILES" "$DIFF_AUTH_BILL" "$SHA_C"; earlier_pass "$SHA_B"
240
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_B"; rc_is "empty delta" 0; eq "empty delta is FULL" .delta.mode FULL
241
+ prep "$WORK/root-nodelta" --delta-from "$SHA_B" --thorough true; rc_is "--delta-from with --thorough" 2
242
+ # forge#4471: the earlier pass must be complete and non-degraded, and the delta must not change review specs/config
243
+ setup staging main "$FIXFILES" "$DIFF_AUTH_BILL"
244
+ { cmt "$(agent_body "$SHA_A")"; cmt "<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->
245
+ panel degraded: 1 of 4 reviewers completed"; } | comments_of
246
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "degraded earlier pass blocks --delta-from" 3
247
+ eq "degraded earlier pass blocker" '.blockers | join(" ") | test("earlier pass is degraded")' true
248
+ { cmt "$(agent_body "$SHA_A")"; cmt "$(staging_summary "$SHA_A")"; cmt "<!-- FORGE:REVIEW_DEGRADED -->"; } | comments_of
249
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "REVIEW_DEGRADED after a summarised pass blocks" 3
250
+ { cmt "<!-- FORGE:REVIEW_DEGRADED -->"; cmt "$(agent_body "$SHA_A")"; cmt "$(staging_summary "$SHA_A")"; } | comments_of
251
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "degraded marker before the earlier pass does not disqualify it" 0
252
+ { cmt "$(agent_body "$SHA_A")"; } | comments_of
253
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "partial earlier pass (no post-guard proof) blocks" 3
254
+ eq "partial earlier pass blocker" '.blockers | join(" ") | test("no trusted proof that the earlier panel completed")' true
255
+ { cmt "$(agent_body "$SHA_A")"; cmt "$(staging_summary "$SHA_A")" NONE; } | comments_of
256
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "untrusted staging summary is no proof" 3
257
+ { cmt "$(staging_summary "$SHA_B")"; cmt "$(agent_body "$SHA_A")"; } | comments_of
258
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "summary for another head is no proof" 3
259
+ { cmt "<!-- FORGE:REVIEW-AGENT:security -->
260
+ Reviewed-SHA: $SHA_A
261
+
262
+ no findings block"; cmt "$(staging_summary "$SHA_A")"; } | comments_of
263
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "agent body without a findings block blocks" 3
264
+ { cmt "$(agent_body "$SHA_A")"; cmt "<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->
265
+ Reviewed-SHA: $SHA_A
266
+ <!-- REVIEW-FINDINGS-SYNTHESIZED-END -->"; } | comments_of
267
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "synthesized block is a post-guard proof" 0
268
+ { cmt "$(agent_body "$SHA_A")"; cmt "# PR Review Summary
269
+ **Reviewed commit**: \`$SHA_A\`
270
+ **Agents**: 2"; } | comments_of
271
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "PR Review Summary with an Agents count above the domains blocks" 3
272
+ earlier_pass "$SHA_A"
273
+ sed 's/"changedFiles":1}/"changedFiles":1,"labels":[{"name":"review-degraded"}]}/' "$FIX/prinfo.json" > "$FIX/prinfo.tmp" && mv "$FIX/prinfo.tmp" "$FIX/prinfo.json"
274
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "review-degraded label blocks --delta-from" 3
275
+ eq "review-degraded label blocker" '.blockers | join(" ") | test("labelled review-degraded")' true
276
+ for _sf in forge.yaml scripts/trusted-comments.sh; do
277
+ git -C "$REPO_DIR" checkout -q -b "pr-spec-$(basename "$_sf")" pr-branch; mkdir -p "$REPO_DIR/$(dirname "$_sf")"; echo x > "$REPO_DIR/$_sf"
278
+ git -C "$REPO_DIR" add -A; git -C "$REPO_DIR" commit -qm "touch $_sf"; _sh=$(git -C "$REPO_DIR" rev-parse HEAD); git -C "$REPO_DIR" checkout -q main
279
+ setup staging main "$FIXFILES" "$DIFF_AUTH_BILL" "$_sh"; earlier_pass "$SHA_B"
280
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_B"; rc_is "delta touching $_sf blocks --delta-from" 3
281
+ eq "delta touching $_sf blocker" '.blockers | join(" ") | test("review spec/config")' true
282
+ done
283
+ # the spec/config list must stay identical to the resolver's FULL list
284
+ _pl() { grep -oE 'forge\.yaml\|commands/review-pr[^)]*\)' "$1" | head -n 1; }
285
+ if [ -n "$(_pl "$ROOT/scripts/review-delta.sh")" ] && [ "$(_pl "$ROOT/scripts/review-prepare.sh")" = "$(_pl "$ROOT/scripts/review-delta.sh")" ]; then ok; else bad "review-prepare.sh spec/config list drifted from scripts/review-delta.sh"; fi
286
+
202
287
  # --- 6. full-build lane: empty trusted config is BLOCKED, never a silent skip ---
203
288
  TS_DIFF='diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
204
289
  +x'
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env bash
2
+ # review-staging-post.sh — deterministic post-dispatch checks of the staging->main review (forge#4359): the incomplete-panel guard,
3
+ # Phase 6.75 agent delivery verification and the Phase 6.5 test-gate verdict handling, moved out of commands/review-pr-staging.md.
4
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #
7
+ # Usage: review-staging-post.sh delivery --repo R --pr N --sd DIR [--head SHA]
8
+ # review-staging-post.sh test-gate --repo R --pr N --sd DIR --output FILE --prs-file F
9
+ # [--default BRANCH] [--config forge.yaml] [--posture blocking|advisory] [--override-phrase TEXT]
10
+ # delivery counts trusted, current-head FORGE:REVIEW-AGENT comments (findings block required) for every domain listed in
11
+ # DIR/<N>_launched_review_agents. A shortfall labels the PR review-degraded + needs-human and posts
12
+ # FORGE:GATE_FAILURE:TYPE=review-panel-integrity; an empty/unreadable list or a missing domain is a delivery failure.
13
+ # Exit 0 = every launched reviewer delivered, 1 = not.
14
+ # test-gate reads the saved /test-gate output FILE (the last FORGE:TEST_GATE:RESULT= marker; an absent marker stays empty and
15
+ # fails closed as BLOCK, never SKIP), applies the trusted override, the posture and the FORGE:GATE_PASS /
16
+ # FORGE:GATE_FAILURE test-gate comments, and persists DIR/<N>_test_gate_verdict and _reason.
17
+ # Exit 0 = continue, 1 = RESULT: BLOCK DEPLOY.
18
+ # Exit: 2 on a usage error.
19
+ #
20
+ # The `# BEGIN <name>` ... `# END <name>` blocks below are the spec's former fences, copied without their forge_resolve locator
21
+ # stubs (this script resolves helpers once, below). Helpers (trusted-comments.sh) resolve through forge_resolve from trusted
22
+ # install roots only, never the working directory or the repo under review.
23
+ set -u
24
+
25
+ CMD="${1:-}"; [ $# -gt 0 ] && shift
26
+ GH_REPO=""; PR_NUMBER=""; SD=""; HEAD_ARG=""; OUTPUT_FILE=""; PRS_FILE=""; CONFIG_ARG=""; POSTURE_ARG=""; PHRASE_ARG=""; DEFAULT_BRANCH="main"
27
+ while [ $# -gt 0 ]; do
28
+ [ $# -ge 2 ] || { echo "review-staging-post: $1 requires a value" >&2; exit 2; }
29
+ case "$1" in
30
+ --repo) GH_REPO="$2" ;;
31
+ --pr) PR_NUMBER="$2" ;;
32
+ --sd) SD="$2" ;;
33
+ --head) HEAD_ARG="$2" ;;
34
+ --output) OUTPUT_FILE="$2" ;;
35
+ --prs-file) PRS_FILE="$2" ;;
36
+ --config) CONFIG_ARG="$2" ;;
37
+ --posture) POSTURE_ARG="$2" ;;
38
+ --override-phrase) PHRASE_ARG="$2" ;;
39
+ --default) DEFAULT_BRANCH="$2" ;;
40
+ *) echo "review-staging-post: unknown argument: $1" >&2; exit 2 ;;
41
+ esac
42
+ shift 2
43
+ done
44
+ case "$CMD" in delivery|test-gate) ;; *) echo "review-staging-post: unknown subcommand '${CMD}' (delivery|test-gate)" >&2; exit 2 ;; esac
45
+ case "$PR_NUMBER" in ''|*[!0-9]*) echo "review-staging-post: --pr must be numeric" >&2; exit 2 ;; esac
46
+ case "$GH_REPO" in *[!A-Za-z0-9._/-]*|*/*/*|/*|*/|'') echo "review-staging-post: invalid --repo" >&2; exit 2 ;; */*) ;; *) echo "review-staging-post: --repo must be owner/name" >&2; exit 2 ;; esac
47
+ [ -n "$SD" ] || { echo "review-staging-post: --sd is required" >&2; exit 2; }
48
+ mkdir -p "$SD" || exit 2
49
+ export FORGE_SCRATCHPAD="$SD"
50
+ _SD="$SD"
51
+ # A malformed --head is dropped: the blocks re-resolve it and refuse on empty (fail closed).
52
+ REVIEW_SHA_FULL="$HEAD_ARG"
53
+ case "$REVIEW_SHA_FULL" in *[!0-9a-f]*|"") REVIEW_SHA_FULL="" ;; *) [ "${#REVIEW_SHA_FULL}" -eq 40 ] || [ "${#REVIEW_SHA_FULL}" -eq 64 ] || REVIEW_SHA_FULL="" ;; esac
54
+ unset LAUNCHED_REVIEW_AGENTS SELECTED_AGENT_COUNT TEST_GATE_VERDICT TEST_GATE_REASON
55
+
56
+ # --- trusted helper resolution (install roots only; never cwd) ---
57
+ # shellcheck source=/dev/null # the resolver lib is found at runtime in the trusted install root
58
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; _SELF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P)"; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/* "$_SELF_ROOT"; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
59
+
60
+ if [ "$CMD" = delivery ]; then
61
+ echo "=== Incomplete-panel guard and Phase 6.75: verify agent delivery ==="
62
+ # BEGIN panel-guard
63
+ # Each Bash call is a fresh shell: re-resolve the full head SHA if it is not set, and refuse when it is not a commit id
64
+ # (an empty SHA would make the Reviewed-SHA match accept any head).
65
+ [ -n "${REVIEW_SHA_FULL:-}" ] || REVIEW_SHA_FULL=$(gh pr view "$PR_NUMBER" -R "$GH_REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
66
+ case "$REVIEW_SHA_FULL" in *[!0-9a-f]*|"") REVIEW_SHA_FULL="" ;; *) [ "${#REVIEW_SHA_FULL}" -eq 40 ] || [ "${#REVIEW_SHA_FULL}" -eq 64 ] || REVIEW_SHA_FULL="" ;; esac
67
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
68
+ ACTUAL_AGENT_COUNT=0
69
+ if [ -n "$REVIEW_SHA_FULL" ] && [ -n "$TRUSTED_SCRIPT" ]; then
70
+ # Only TRUSTED, current-head reviewer comments count, over every page of comments. Unique domains, so a duplicate does not inflate the count.
71
+ ACTUAL_AGENT_COUNT=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
72
+ | bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA:[ ]${REVIEW_SHA_FULL}(\\r?\\n|$)" 2>/dev/null \
73
+ | jq -r 'scan("^<!-- FORGE:REVIEW-AGENT:([a-z0-9-]+) -->") | .[0]' 2>/dev/null | sort -u | grep -c . || echo 0)
74
+ fi
75
+ case "$ACTUAL_AGENT_COUNT" in ''|*[!0-9]*) ACTUAL_AGENT_COUNT=0 ;; esac # fail closed: unreadable or untrusted counts as not posted
76
+ # Fresh shell: SELECTED_AGENT_COUNT is not carried over. Reload it from the PR-keyed launched-agent file, then fail closed when it is empty, non-integer or zero (a staging review always selects at least one reviewer).
77
+ [ -n "${SELECTED_AGENT_COUNT:-}" ] || { [ -n "${PR_NUMBER:-}" ] && [ -r "$_SD/${PR_NUMBER}_launched_review_agents" ] && SELECTED_AGENT_COUNT=$(grep -E '^[a-z0-9-]+$' "$_SD/${PR_NUMBER}_launched_review_agents" | sort -u | grep -c .); }
78
+ SELECTED_COUNT_INVALID=false
79
+ case "${SELECTED_AGENT_COUNT:-}" in ''|*[!0-9]*|0) SELECTED_COUNT_INVALID=true; SELECTED_AGENT_COUNT=0 ;; esac
80
+ if [ "${DISPATCH_FAILED:-}" = "true" ] || [ "$SELECTED_COUNT_INVALID" = "true" ] || [ "$ACTUAL_AGENT_COUNT" -lt "$SELECTED_AGENT_COUNT" ]; then
81
+ gh label create "review-degraded" --color "E4E669" --description "PR review panel was incomplete; re-review required before deployment. Managed by ForgeDock." --force -R "$GH_REPO" 2>/dev/null || true
82
+ gh pr edit "$PR_NUMBER" -R "$GH_REPO" --add-label "review-degraded" --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
83
+ gh pr comment "$PR_NUMBER" -R "$GH_REPO" --body "<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->
84
+ ## Deploy Review Blocked: Incomplete Isolated Review Panel
85
+
86
+ **Selected isolated reviewers**: ${SELECTED_AGENT_COUNT}
87
+ **Completed isolated reviewers**: ${ACTUAL_AGENT_COUNT}
88
+
89
+ Re-run the full staging review in a fresh session before deployment."
90
+ exit 1
91
+ fi
92
+ # END panel-guard
93
+ # BEGIN delivery-check
94
+ # Reload the launched-agent list persisted at dispatch (validated: lowercase marker domains only).
95
+ [ -n "${LAUNCHED_REVIEW_AGENTS:-}" ] || { [ -r "$_SD/${PR_NUMBER}_launched_review_agents" ] && LAUNCHED_REVIEW_AGENTS=$(grep -E '^[a-z0-9-]+$' "$_SD/${PR_NUMBER}_launched_review_agents" | sort -u | tr '\n' ' '); }
96
+ # Fail closed: a staging review always launches at least the Phase 2 material-change agent, so an empty list means lost state or skipped bookkeeping, never "nothing to verify".
97
+ if [ -z "${PR_NUMBER:-}" ] || [ -z "${LAUNCHED_REVIEW_AGENTS:-}" ]; then
98
+ echo "REVIEW DELIVERY FAILURE: launched-agent list is empty or unreadable (PR_NUMBER='${PR_NUMBER:-}')"
99
+ echo "Recover the launched reviewer domains from the PR's FORGE:REVIEW-AGENT comments, re-append them to the launched-agent file, and rerun."
100
+ exit 1
101
+ fi
102
+ MISSING_AGENT_COMMENTS=""
103
+ # Each Bash call is a fresh shell: re-resolve the full head SHA if it is not set, and refuse when it is not a commit id
104
+ # (an empty SHA would make the Reviewed-SHA match accept any head).
105
+ [ -n "${REVIEW_SHA_FULL:-}" ] || REVIEW_SHA_FULL=$(gh pr view "$PR_NUMBER" -R "$GH_REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
106
+ case "$REVIEW_SHA_FULL" in *[!0-9a-f]*|"") REVIEW_SHA_FULL="" ;; *) [ "${#REVIEW_SHA_FULL}" -eq 40 ] || [ "${#REVIEW_SHA_FULL}" -eq 64 ] || REVIEW_SHA_FULL="" ;; esac
107
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
108
+ for AGENT_DOMAIN in $LAUNCHED_REVIEW_AGENTS; do
109
+ AGENT_COMMENT_COUNT=0
110
+ if [ -n "$REVIEW_SHA_FULL" ] && [ -n "$TRUSTED_SCRIPT" ]; then
111
+ AGENT_COMMENT_COUNT=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
112
+ | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA:[ ]${REVIEW_SHA_FULL}(\\r?\\n|$)" 2>/dev/null || echo 0)
113
+ fi
114
+ case "$AGENT_COMMENT_COUNT" in ''|*[!0-9]*) AGENT_COMMENT_COUNT=0 ;; esac # fail closed: counts as missing
115
+ if [ "${AGENT_COMMENT_COUNT:-0}" -lt 1 ]; then
116
+ MISSING_AGENT_COMMENTS="${MISSING_AGENT_COMMENTS} ${AGENT_DOMAIN}"
117
+ fi
118
+ done
119
+
120
+ if [ -n "$MISSING_AGENT_COMMENTS" ]; then
121
+ echo "REVIEW DELIVERY FAILURE: missing findings comment(s) for:${MISSING_AGENT_COMMENTS}"
122
+ echo "A comment without a REVIEW-FINDINGS-START block counts as missing. Re-dispatch the missing agent once with the note: structured findings block required. A second miss is a degraded panel. Recover each agent's returned verdict, findings, and durable body path before rerunning review."
123
+ exit 1
124
+ fi
125
+ # END delivery-check
126
+ echo "DELIVERY: OK (${ACTUAL_AGENT_COUNT}/${SELECTED_AGENT_COUNT} launched reviewers posted a findings block for head ${REVIEW_SHA_FULL})"
127
+ exit 0
128
+ fi
129
+
130
+ # test-gate
131
+ case "$POSTURE_ARG" in ''|blocking|advisory) ;; *) echo "review-staging-post: --posture must be blocking or advisory" >&2; exit 2 ;; esac
132
+ [ -n "$PRS_FILE" ] || { echo "review-staging-post: --prs-file is required" >&2; exit 2; }
133
+ [ -r "$PRS_FILE" ] || { echo "review-staging-post: --prs-file $PRS_FILE is not readable (run the pre-dispatch first)" >&2; exit 2; }
134
+ [ -n "$OUTPUT_FILE" ] || { echo "review-staging-post: --output is required (the saved /test-gate output)" >&2; exit 2; }
135
+ ALL_PR_NUMBERS=$(grep -E '^[0-9]+$' "$PRS_FILE")
136
+ CONFIG_FILE="${CONFIG_ARG:-${FORGE_CONFIG:-forge.yaml}}"
137
+ GH_FLAG="-R $GH_REPO"
138
+ GATE_POSTURE="$POSTURE_ARG"
139
+ [ -n "$GATE_POSTURE" ] || GATE_POSTURE=$(yq '.verification.test_gate.posture // "blocking"' "$CONFIG_FILE" 2>/dev/null || echo "blocking")
140
+ case "$GATE_POSTURE" in advisory) ;; *) GATE_POSTURE=blocking ;; esac # unreadable or unknown is the blocking default
141
+ OVERRIDE_PHRASE="$PHRASE_ARG"
142
+ [ -n "$OVERRIDE_PHRASE" ] || OVERRIDE_PHRASE=$(yq '.verification.test_gate.override_phrase // "OVERRIDE: shipping with test failures —"' "$CONFIG_FILE" 2>/dev/null || echo "OVERRIDE: shipping with test failures —")
143
+ [ -n "$OVERRIDE_PHRASE" ] || OVERRIDE_PHRASE="OVERRIDE: shipping with test failures —"
144
+ echo "=== Phase 6.5: Runtime Test Gate ==="
145
+ echo "Bundle PRs: $(echo $ALL_PR_NUMBERS | tr '\n' ' ')"
146
+ echo "Posture: ${GATE_POSTURE}"
147
+ # Reset any stale gate result from a prior run (files are PR-keyed)
148
+ rm -f "${_SD:?}/${PR_NUMBER:?}_test_gate_verdict" "${_SD:?}/${PR_NUMBER:?}_test_gate_reason"
149
+ TEST_GATE_VERDICT="SKIP"
150
+ TEST_GATE_REASON="Phase 6.5 not yet run"
151
+ GATE_OUTPUT=""
152
+ if [ -r "$OUTPUT_FILE" ]; then GATE_OUTPUT=$(cat "$OUTPUT_FILE"); else echo "WARNING: /test-gate output $OUTPUT_FILE unreadable: treated as a missing result"; fi
153
+ # BEGIN test-gate-extract
154
+ # Extract machine-readable verdict from Skill output. An absent/invalid marker stays EMPTY (never coerced to SKIP):
155
+ # the verdict-handling fence treats an empty verdict as missing/invalid and fails closed under blocking posture.
156
+ TEST_GATE_VERDICT=$(echo "$GATE_OUTPUT" | grep -oE 'FORGE:TEST_GATE:RESULT=(BLOCK|PASS|SKIP)' | sed 's/^FORGE:TEST_GATE:RESULT=//' | tail -1)
157
+ [ -n "$TEST_GATE_VERDICT" ] || TEST_GATE_REASON="gate result missing/invalid (no FORGE:TEST_GATE:RESULT marker in /test-gate output)"
158
+ # END test-gate-extract
159
+ echo "Test-gate verdict: ${TEST_GATE_VERDICT}"
160
+ printf '%s\n' "$TEST_GATE_VERDICT" > "$_SD/${PR_NUMBER}_test_gate_verdict"; printf '%s\n' "$TEST_GATE_REASON" > "$_SD/${PR_NUMBER}_test_gate_reason"
161
+ # BEGIN test-gate-verdict
162
+ # Reload the Phase 6.5 gate result persisted by its producer (validated: only a known verdict reaches a gate decision).
163
+ [ -n "${TEST_GATE_VERDICT:-}" ] || { [ -r "$_SD/${PR_NUMBER}_test_gate_verdict" ] && TEST_GATE_VERDICT=$(sed -n -E '1{/^(BLOCK|PASS|SKIP)$/p;q}' "$_SD/${PR_NUMBER}_test_gate_verdict"); }
164
+ [ -n "${TEST_GATE_REASON:-}" ] || { [ -r "$_SD/${PR_NUMBER}_test_gate_reason" ] && TEST_GATE_REASON=$(sed -n 1p "$_SD/${PR_NUMBER}_test_gate_reason" | tr -d '\r\000-\037' | cut -c1-400); }
165
+ # Fail closed: a missing, empty, or unrecognised verdict is never a SKIP. Normalize it to BLOCK so the BLOCK arm applies
166
+ # the override check, advisory/blocking posture, and exit 1; GATE_VERDICT_INVALID makes the reasons say missing/invalid.
167
+ GATE_VERDICT_INVALID=0
168
+ case "${TEST_GATE_VERDICT:-}" in
169
+ BLOCK|PASS|SKIP) ;;
170
+ *)
171
+ GATE_VERDICT_INVALID=1
172
+ if [ -r "$_SD/${PR_NUMBER}_test_gate_verdict" ]; then _GV_WHY="persisted verdict file present but empty/invalid"; else _GV_WHY="persisted verdict file missing"; fi
173
+ echo "⚠️ Test gate: gate result missing/invalid (${_GV_WHY}) — treating as BLOCK, not SKIP."
174
+ TEST_GATE_VERDICT="BLOCK"
175
+ ;;
176
+ esac
177
+ case "$TEST_GATE_VERDICT" in
178
+
179
+ SKIP)
180
+ echo "ℹ️ Test gate: SKIP — no executable changes or tests not configured."
181
+ echo " This gap will appear in the Phase 8 summary. No deploy impact."
182
+ TEST_GATE_REASON="SKIP — no runtime tests ran (docs-only bundle, no integration tests configured, or manual-only criteria)"
183
+ ;;
184
+
185
+ PASS)
186
+ echo "✅ Test gate: PASS — all test clusters passed. Deploy may proceed."
187
+ TEST_GATE_REASON="PASS — all automated test clusters passed"
188
+ # Post FORGE:GATE_PASS for symmetric observability
189
+ if [ -n "$PR_NUMBER" ]; then
190
+ gh pr comment "$PR_NUMBER" ${GH_FLAG} --body "<!-- FORGE:GATE_PASS -->
191
+ ## Deploy Gate: PASSED
192
+
193
+ **Gate**: test-gate (runtime acceptance criteria)
194
+ **Result**: PASS — all test clusters passed. Deploy may proceed.
195
+ **Bundle PRs**: ${ALL_PR_NUMBERS}
196
+ **Timestamp**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
197
+
198
+ <!-- FORGE:GATE_PASS:TYPE=test-gate|BUNDLE=$(echo $ALL_PR_NUMBERS | tr '\n' ' ' | xargs) -->" 2>/dev/null || true
199
+ fi
200
+ ;;
201
+
202
+ BLOCK)
203
+ # Check for override comment on the staging→main PR (mirrors Phase 0A pattern)
204
+ if [ -n "$PR_NUMBER" ]; then
205
+ # Trusted authors only, all comment pages, anchored; fail closed (no override) if the script or comments are
206
+ # unreadable (#3492). OVERRIDE_PHRASE comes from forge.yaml, so regex-escape it before using it as a pattern.
207
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
208
+ TG_SCRIPT="$TRUSTED_SCRIPT"
209
+ TG_COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null) || TG_COMMENTS=""
210
+ TG_OVERRIDE=0; OVERRIDE_REASON="(reason not captured)"
211
+ if [ -n "$TG_SCRIPT" ] && [ -n "$TG_COMMENTS" ]; then
212
+ TG_RE="^$(printf '%s' "$OVERRIDE_PHRASE" | sed 's/[][\\.^$*+?(){}|\/]/\\&/g')"
213
+ TG_OVERRIDE=$(printf '%s' "$TG_COMMENTS" | bash "$TG_SCRIPT" count "$TG_RE" 2>/dev/null || echo 0)
214
+ [ "${TG_OVERRIDE:-0}" -gt 0 ] && OVERRIDE_REASON=$(printf '%s' "$TG_COMMENTS" | bash "$TG_SCRIPT" bodies "$TG_RE" 2>/dev/null | tail -1 | jq -r '.' 2>/dev/null || echo "(reason not captured)")
215
+ fi
216
+ else
217
+ TG_OVERRIDE=0
218
+ fi
219
+
220
+ if [ "${TG_OVERRIDE:-0}" -gt 0 ]; then
221
+ echo "⚠️ Test gate: BLOCK — but override comment detected on PR #${PR_NUMBER}."
222
+ echo " Override: ${OVERRIDE_REASON}"
223
+ echo " Proceeding with deploy. Override is logged in Phase 8 summary."
224
+ TEST_GATE_VERDICT="PASS"
225
+ TEST_GATE_REASON="BLOCK downgraded to PASS by override: ${OVERRIDE_REASON}"
226
+ [ "$GATE_VERDICT_INVALID" = 1 ] && TEST_GATE_REASON="BLOCK (gate result missing/invalid) downgraded to PASS by override: ${OVERRIDE_REASON}"
227
+
228
+ elif [ "$GATE_POSTURE" = "advisory" ]; then
229
+ echo "⚠️ Test gate: BLOCK (advisory posture) — runtime failures detected but deploy is NOT prevented."
230
+ echo " Switch verification.test_gate.posture to 'blocking' in forge.yaml to enforce this gate."
231
+ TEST_GATE_REASON="BLOCK (advisory) — runtime failures detected; deploy allowed by advisory posture"
232
+ [ "$GATE_VERDICT_INVALID" = 1 ] && TEST_GATE_REASON="BLOCK (advisory) — gate result missing/invalid; deploy allowed by advisory posture"
233
+
234
+ else
235
+ # blocking posture (default) — STOP
236
+ echo "⛔ DEPLOY BLOCKED — /test-gate returned BLOCK verdict."
237
+ [ "$GATE_VERDICT_INVALID" = 1 ] && echo " (gate result missing/invalid — failing closed; re-run /test-gate or post the override comment)"
238
+ echo ""
239
+ echo "Runtime failures were detected in the staging→${DEFAULT_BRANCH} bundle."
240
+ echo "The failures were batch-introduced (not pre-existing on ${DEFAULT_BRANCH} baseline)."
241
+ echo ""
242
+ echo "Options:"
243
+ echo " 1. Fix the failing tests and merge fixes to staging before retrying the deploy."
244
+ echo " 2. Post a comment on this PR starting with \"${OVERRIDE_PHRASE} <reason>\" to bypass this gate."
245
+ echo " 3. Set verification.test_gate.posture: advisory in forge.yaml to downgrade to a warning."
246
+ echo ""
247
+ echo "RESULT: BLOCK DEPLOY"
248
+
249
+ # Post structured FORGE:GATE_FAILURE comment for pipeline-health tracking
250
+ GATE_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
251
+ if [ -n "$PR_NUMBER" ]; then
252
+ gh pr comment "$PR_NUMBER" ${GH_FLAG} --body "<!-- FORGE:GATE_FAILURE -->
253
+ ## Deploy Gate: BLOCKED
254
+
255
+ **Gate**: test-gate
256
+ **Timestamp**: ${GATE_TIMESTAMP}
257
+ **Bundle PRs**: $(echo $ALL_PR_NUMBERS | tr '\n' ' ')
258
+ **Posture**: ${GATE_POSTURE}
259
+
260
+ ### What Happened
261
+
262
+ \`/test-gate\` detected runtime failures in the staging→\`${DEFAULT_BRANCH}\` bundle that were NOT present on the \`${DEFAULT_BRANCH}\` baseline. These are batch-introduced regressions. (If the gate result was missing/invalid, no verdict could be read and the gate failed closed; re-run \`/test-gate\`.)
263
+
264
+ \`/test-gate\` has filed \`test-failure\` issues for each failing cluster — check the issue tracker for details.
265
+
266
+ ### Resolution
267
+
268
+ 1. Fix the failing tests and merge fixes to staging.
269
+ 2. Retry the staging→\`${DEFAULT_BRANCH}\` deploy.
270
+
271
+ To override (ship known failures with documented reason), post a comment containing:
272
+ \`${OVERRIDE_PHRASE} <reason>\`
273
+
274
+ <!-- FORGE:GATE_FAILURE:TYPE=test-gate|BUNDLE=$(echo $ALL_PR_NUMBERS | tr '\n' ' ' | xargs) -->" 2>/dev/null || true
275
+ fi
276
+ _GV_REASON="BLOCK (blocking posture, no override)"; [ "$GATE_VERDICT_INVALID" = 1 ] && _GV_REASON="BLOCK (gate result missing/invalid; blocking posture, no override)"
277
+ printf '%s\n' "$TEST_GATE_VERDICT" > "$_SD/${PR_NUMBER}_test_gate_verdict"; printf '%s\n' "$_GV_REASON" > "$_SD/${PR_NUMBER}_test_gate_reason"
278
+ exit 1
279
+ fi
280
+ ;;
281
+
282
+ *)
283
+ # Unreachable (normalized above); fail closed regardless — never SKIP.
284
+ echo "⛔ Test gate: gate result missing/invalid ('${TEST_GATE_VERDICT}') — failing closed."
285
+ printf '%s\n' "BLOCK" > "$_SD/${PR_NUMBER}_test_gate_verdict"; printf '%s\n' "BLOCK (gate result missing/invalid)" > "$_SD/${PR_NUMBER}_test_gate_reason"
286
+ exit 1
287
+ ;;
288
+
289
+ esac
290
+ # Persist the gate result: every Bash call is a fresh shell, and the verdict-handling fence and Phase 8 read it back (validated on reload).
291
+ printf '%s\n' "$TEST_GATE_VERDICT" > "$_SD/${PR_NUMBER}_test_gate_verdict"; printf '%s\n' "$TEST_GATE_REASON" > "$_SD/${PR_NUMBER}_test_gate_reason"
292
+ # END test-gate-verdict
293
+ exit 0