forgedock 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +2 -0
  2. package/bin/engine.mjs +48 -3
  3. package/bin/runner.mjs +20 -4
  4. package/commands/orchestrate/phase-4-execution.md +1 -1
  5. package/commands/review-pr-agents/protocols.md +19 -0
  6. package/commands/review-pr.md +7 -6
  7. package/commands/work-on/build/architect.md +1 -1
  8. package/commands/work-on/build/implement.md +9 -1
  9. package/commands/work-on/build/validate.md +1 -1
  10. package/commands/work-on/build.md +118 -8
  11. package/commands/work-on/close.md +9 -4
  12. package/commands/work-on/remediate.md +11 -4
  13. package/commands/work-on/review.md +11 -3
  14. package/commands/work-on.md +4 -4
  15. package/docs/CONFIG.md +5 -1
  16. package/docs/RELEASE-NOTES.md +15 -0
  17. package/docs/TELEMETRY.md +2 -2
  18. package/docs/WORK-ON-RUNTIME.md +11 -3
  19. package/package.json +1 -1
  20. package/scripts/architect-resume-match.test.sh +12 -3
  21. package/scripts/build-knowledge-index.mjs +8 -5
  22. package/scripts/builder-resume-match.test.sh +12 -3
  23. package/scripts/calibration.mjs +5 -2
  24. package/scripts/check-spec-markers.sh +2 -0
  25. package/scripts/ci-local/lib.mjs +11 -3
  26. package/scripts/ci-local/run.mjs +155 -49
  27. package/scripts/ci-local/scoping.mjs +27 -4
  28. package/scripts/ci-local.sh +3 -2
  29. package/scripts/ci-local.test.sh +189 -0
  30. package/scripts/classify-finding.sh +1 -1
  31. package/scripts/file-deferred-followups.sh +91 -30
  32. package/scripts/file-deferred-followups.test.sh +101 -8
  33. package/scripts/lib/telemetry.sh +2 -0
  34. package/scripts/review-finalize.sh +101 -12
  35. package/scripts/review-finalize.test.sh +136 -5
  36. package/scripts/review-roster.sh +18 -5
  37. package/scripts/review-roster.test.sh +57 -1
  38. package/scripts/selfreview-plan.sh +86 -0
  39. package/scripts/selfreview-plan.test.sh +90 -0
  40. package/scripts/spawn-depth-check.sh +5 -4
  41. package/scripts/spawn-depth-check.test.sh +3 -0
  42. package/scripts/telemetry.test.sh +8 -0
  43. package/scripts/transition-label.sh +26 -0
  44. package/scripts/verify-phase-trail.test.sh +6 -0
@@ -4,8 +4,9 @@
4
4
  # SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  #
6
6
  # The builder decides fold-vs-defer (judgment, build.md B2.0); this script does the mechanics for the items
7
- # that stay deferred: trusted lookup of an open follow-up carrying <!-- FORGE:DEFERRED_FROM: #N -->, append the
8
- # missing items to it as checklist entries (exact path match), or create ONE issue with a checklist.
7
+ # that stay deferred: trusted lookup (claim marker <!-- FORGE:DEFERRED_ISSUE: #M --> on the parent, then a state-all search) of a follow-up carrying
8
+ # <!-- FORGE:DEFERRED_FROM: #N -->, append the missing items (exact path match; a closed one is reopened), or create ONE issue
9
+ # with a checklist, claim it on the parent and re-check once for a concurrent lower-numbered duplicate.
9
10
  #
10
11
  # Usage: file-deferred-followups.sh --parent N --repo OWNER/REPO [--items FILE|-] [--folded N]
11
12
  # items TSV, one per line: <path> TAB <why> TAB <known-flaw flag: known-flaw|1|yes|true, else empty>
@@ -71,27 +72,60 @@ finish() { # $1 = batched_into number or empty, $2 = filed count
71
72
  }
72
73
 
73
74
  # --- Trusted lookup (author predicate applied before path match), one retry ---------------------------------
74
- do_lookup() {
75
+ # Sources, in order: (1) claim markers `<!-- FORGE:DEFERRED_ISSUE: #M -->` on the parent's REST comments (read-after-write
76
+ # consistent), (2) a search over ALL issue states (eventually consistent, so only a fallback). Every candidate is then
77
+ # verified by number over REST (trusted author, DEFERRED_FROM marker, not a PR); the lowest number is canonical.
78
+ JQ_PRE='def lst: split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0));
79
+ def trusted: ((.author_association // "") as $x | ($assoc | lst) | index($x) != null)
80
+ or ((.user.type // "") == "Bot")
81
+ or ((.user.login // "") as $x | ($logins | lst) | index($x) != null);'
82
+ CLAIM_RE='^\s*<!-- FORGE:DEFERRED_ISSUE: #(?<n>[0-9]+) -->'
83
+ claim_nums() {
84
+ gh api --paginate "repos/${REPO}/issues/${PARENT}/comments" 2>/dev/null \
85
+ | jq -s -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg re "$CLAIM_RE" "$JQ_PRE"'
86
+ [.[][] | select(trusted) | (.body // "") | capture($re) | .n | tonumber] | unique | .[]' 2>/dev/null
87
+ }
88
+ search_nums() {
75
89
  gh api -X GET search/issues -f per_page=50 \
76
- -f q="\"FORGE:DEFERRED_FROM: #${PARENT}\" in:body repo:${REPO} is:issue is:open" 2>/dev/null \
77
- | jq -c --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" '
78
- ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
79
- | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
80
- | [.items[]
81
- | select(((.author_association // "") as $x | $A | index($x) != null)
82
- or ((.user.type // "") == "Bot")
83
- or ((.user.login // "") as $x | $L | index($x) != null))
84
- | select((.body // "") | contains($m))
85
- | {number, body: (.body // "")}]
86
- | sort_by(.number)' 2>/dev/null
90
+ -f q="\"FORGE:DEFERRED_FROM: #${PARENT}\" in:body repo:${REPO} is:issue" 2>/dev/null \
91
+ | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" "$JQ_PRE"'
92
+ [.items[] | select(trusted) | select((.body // "") | contains($m)) | .number] | sort | .[]' 2>/dev/null
93
+ }
94
+ verify_num() { # number -> `number<TAB>state` when a trusted, non-PR issue carries the marker; empty when not (a 404 is "not it")
95
+ local out rc
96
+ out=$(gh api "repos/${REPO}/issues/$1" 2>"$TMP/verr"); rc=$?
97
+ if [ "$rc" -ne 0 ]; then grep -qE '404|Not Found|410' "$TMP/verr" 2>/dev/null && return 0; return 1; fi
98
+ printf '%s' "$out" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" "$JQ_PRE"'
99
+ select(.pull_request == null) | select(trusted) | select((.body // "") | contains($m))
100
+ | "\(.number)\t\(.state)"' 2>/dev/null
101
+ }
102
+ CANON=""; CSTATE=""
103
+ pick_verified() { # numbers... ; sets CANON/CSTATE from the first verified one; non-zero only when a fetch failed
104
+ local n r
105
+ for n in "$@"; do
106
+ r=$(verify_num "$n") || return 1
107
+ if [ -n "$r" ]; then CANON="${r%%$'\t'*}"; CSTATE="${r#*$'\t'}"; return 0; fi
108
+ done
109
+ return 0
87
110
  }
88
- LOOKUP=""
111
+ find_canonical() {
112
+ local cands
113
+ CANON=""; CSTATE=""
114
+ cands=$(claim_nums) || return 1
115
+ # shellcheck disable=SC2086
116
+ pick_verified $cands || return 1
117
+ [ -n "$CANON" ] && return 0
118
+ cands=$(search_nums) || return 1
119
+ # shellcheck disable=SC2086
120
+ pick_verified $cands || return 1
121
+ return 0
122
+ }
123
+ LOOKUP_OK=""
89
124
  for attempt in 1 2; do
90
- LOOKUP=$(do_lookup) && [ -n "$LOOKUP" ] && break
91
- LOOKUP=""
125
+ if find_canonical; then LOOKUP_OK=1; break; fi
92
126
  [ "$attempt" = 1 ] && sleep "${FORGE_DEFERRED_RETRY_SLEEP:-2}"
93
127
  done
94
- if [ -z "$LOOKUP" ]; then
128
+ if [ -z "$LOOKUP_OK" ]; then
95
129
  echo "DEFERRED_LOOKUP=FAILED"
96
130
  exit 3
97
131
  fi
@@ -108,27 +142,35 @@ has_entry() { # body-file path : exact checklist-line match, not substring
108
142
  grep -F -- "\`$2\`" "$1" | grep -qE "^- \[[ xX]\] \`$(printf '%s' "$2" | sed 's/[][\.*^$+?(){}|]/\\&/g')\`( |\$)"
109
143
  }
110
144
 
111
- NUM=$(printf '%s' "$LOOKUP" | jq -r 'first | .number // empty' 2>/dev/null)
112
-
113
- if [ -n "$NUM" ]; then
145
+ # Append the missing items to follow-up $1 (state $2), then reopen it when closed (review-finalize needs it open).
146
+ append_items() { # number state ; non-zero = failure
147
+ local num="$1" state="$2" note=""
114
148
  # Re-read the body immediately before editing (parallel builds of the same parent), then append what is missing.
115
- if ! gh api "repos/${REPO}/issues/${NUM}" --jq '.body // ""' > "$TMP/body" 2>/dev/null; then
116
- echo "DEFERRED_FILE=FAILED"; exit 4
117
- fi
149
+ gh api "repos/${REPO}/issues/${num}" --jq '.body // ""' > "$TMP/body" 2>/dev/null || return 1
118
150
  : > "$TMP/add"
119
151
  while IFS="$SEP" read -r p w k; do
120
152
  has_entry "$TMP/body" "$p" || entry "$p" "$w" "$k" >> "$TMP/add"
121
153
  done < "$TMP/items"
154
+ [ "$state" = "closed" ] && note=" (follow-up was closed; reopened)"
122
155
  if [ -s "$TMP/add" ]; then
123
- { cat "$TMP/body"; printf '\n### Added by a later build of #%s\n\n' "$PARENT"; cat "$TMP/add"; } > "$TMP/newbody"
156
+ { cat "$TMP/body"; printf '\n### Added by a later build of #%s%s\n\n' "$PARENT" "$note"; cat "$TMP/add"; } > "$TMP/newbody"
124
157
  if [ -n "${DRY_RUN:-}" ]; then
125
- echo "DRY_RUN: gh issue edit $NUM (append $(wc -l < "$TMP/add" | tr -d ' ') item(s))" >&2
126
- elif ! gh issue edit "$NUM" -R "$REPO" --body-file "$TMP/newbody" >/dev/null 2>&1; then
127
- echo "DEFERRED_FILE=FAILED"; exit 4
158
+ echo "DRY_RUN: gh issue edit $num (append $(wc -l < "$TMP/add" | tr -d ' ') item(s))" >&2
159
+ else
160
+ gh issue edit "$num" -R "$REPO" --body-file "$TMP/newbody" >/dev/null 2>&1 || return 1
128
161
  fi
129
162
  fi
130
- while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
131
- finish "$NUM" 0
163
+ if [ "$state" = "closed" ]; then
164
+ if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: gh issue reopen $num" >&2
165
+ else gh issue reopen "$num" -R "$REPO" >/dev/null 2>&1 || return 1; fi
166
+ fi
167
+ return 0
168
+ }
169
+
170
+ if [ -n "$CANON" ]; then
171
+ append_items "$CANON" "$CSTATE" || { echo "DEFERRED_FILE=FAILED"; exit 4; }
172
+ while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$CANON"; done < "$TMP/items"
173
+ finish "$CANON" 0
132
174
  exit 0
133
175
  fi
134
176
 
@@ -154,6 +196,25 @@ else
154
196
  NUM=$(printf '%s' "$URL" | grep -oE '[0-9]+$' | tail -n 1)
155
197
  [ -n "$NUM" ] || { echo "DEFERRED_FILE=FAILED"; exit 4; }
156
198
  fi
199
+ if [ -z "${DRY_RUN:-}" ]; then
200
+ # Claim the follow-up on the parent (REST comments are read-after-write consistent; best effort, the search still finds it).
201
+ gh issue comment "$PARENT" -R "$REPO" --body "<!-- FORGE:DEFERRED_ISSUE: #${NUM} -->" >/dev/null 2>&1 \
202
+ || echo "file-deferred-followups: warning: could not post claim marker for #${NUM} on #${PARENT}" >&2
203
+ # Post-create re-check: a concurrent build may have filed first. The lowest trusted number is canonical; the newer one is closed.
204
+ if find_canonical; then
205
+ if [ -n "$CANON" ] && [ "$CANON" -lt "$NUM" ]; then
206
+ if append_items "$CANON" "$CSTATE"; then
207
+ gh issue close "$NUM" -R "$REPO" --reason "not planned" --comment "Duplicate of #${CANON}: deferred items of #${PARENT} are tracked there." >/dev/null 2>&1 \
208
+ || echo "file-deferred-followups: warning: could not close duplicate #${NUM}" >&2
209
+ NUM="$CANON"
210
+ else
211
+ echo "file-deferred-followups: warning: could not move items to #${CANON}; keeping #${NUM}" >&2
212
+ fi
213
+ fi
214
+ else
215
+ echo "file-deferred-followups: warning: post-create re-check failed; keeping #${NUM}" >&2
216
+ fi
217
+ fi
157
218
  while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
158
219
  finish "$NUM" 1
159
220
  exit 0
@@ -8,19 +8,27 @@ S="$HERE/file-deferred-followups.sh"
8
8
  T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
9
9
  mkdir "$T/bin"
10
10
  export FORGE_DEFERRED_RETRY_SLEEP=0
11
- export GH_LOG="$T/gh.log" SEARCH_JSON="$T/search.json" ISSUE_BODY="$T/issue.body"
11
+ export GH_LOG="$T/gh.log" SEARCH_JSON="$T/search.json" ISSUE_BODY="$T/issue.body" COMMENTS_JSON="$T/comments.json" T_DIR="$T"
12
12
 
13
13
  cat > "$T/bin/gh" <<'MOCK'
14
14
  #!/usr/bin/env bash
15
15
  echo "$*" >> "$GH_LOG"
16
16
  case "$1 $2" in
17
17
  "api -X") [ -n "${LOOKUP_FAIL:-}" ] && exit 1; cat "$SEARCH_JSON"; exit 0 ;;
18
+ "api --paginate") [ -n "${CLAIM_FAIL:-}" ] && exit 1; cat "$COMMENTS_JSON"; exit 0 ;;
18
19
  "issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; echo "https://github.com/o/r/issues/901"; exit 0 ;;
20
+ "issue comment") [ -n "${COMMENT_FAIL:-}" ] && exit 1; exit 0 ;;
21
+ "issue reopen") [ -n "${REOPEN_FAIL:-}" ] && exit 1; exit 0 ;;
22
+ "issue close") [ -n "${CLOSE_FAIL:-}" ] && exit 1; exit 0 ;;
19
23
  "issue edit")
20
24
  [ -n "${EDIT_FAIL:-}" ] && exit 1
21
25
  while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$ISSUE_BODY.edited"; shift; done; exit 0 ;;
22
26
  esac
23
- case "$1" in api) cat "$ISSUE_BODY"; exit 0 ;; esac
27
+ if [ "$1" = api ]; then
28
+ case "$*" in *--jq*) cat "$ISSUE_BODY"; exit 0 ;; esac
29
+ n="${2##*/}"; [ -f "$T_DIR/issue.$n.json" ] && { cat "$T_DIR/issue.$n.json"; exit 0; }
30
+ echo "HTTP 404: Not Found" >&2; exit 1
31
+ fi
24
32
  exit 1
25
33
  MOCK
26
34
  chmod +x "$T/bin/gh"; export PATH="$T/bin:$PATH"
@@ -29,7 +37,12 @@ PASS=0; FAIL=0
29
37
  ok() { PASS=$((PASS+1)); echo "PASS: $1"; }
30
38
  bad() { FAIL=$((FAIL+1)); echo "FAIL: $1"; }
31
39
  check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
32
- reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited"; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL DRY_RUN; echo '{"items":[]}' > "$SEARCH_JSON"; }
40
+ reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited" "$T"/issue.*.json; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL CLAIM_FAIL COMMENT_FAIL REOPEN_FAIL CLOSE_FAIL DRY_RUN
41
+ echo '{"items":[]}' > "$SEARCH_JSON"; echo '[]' > "$COMMENTS_JSON"; }
42
+ # issue_json NUMBER STATE ASSOCIATION [PARENT]: REST view of a follow-up carrying the DEFERRED_FROM marker
43
+ issue_json() { printf '{"number":%s,"state":"%s","author_association":"%s","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #%s -->"}\n' "$1" "$2" "$3" "${4:-10}" > "$T/issue.$1.json"; }
44
+ # claim_json ASSOCIATION NUMBER...: parent comments carrying claim markers
45
+ claim_json() { local a="$1" out="" n; shift; for n in "$@"; do out="${out:+$out,}{\"author_association\":\"$a\",\"user\":{\"type\":\"User\"},\"body\":\"<!-- FORGE:DEFERRED_ISSUE: #$n -->\"}"; done; echo "[$out]" > "$COMMENTS_JSON"; }
33
46
  printf 'a/x.sh\twhy a\t\nb/y.sh\twhy b\tknown-flaw\nc/z.sh\twhy c\t\n' > "$T/items.tsv"
34
47
 
35
48
  # 1. N items -> exactly one create
@@ -43,7 +56,7 @@ check "create: telemetry" "printf '%s' \"\$OUT\" | grep -q 'DEFERRED_TELEMETRY d
43
56
  # 2. append to existing trusted open issue; exact path match
44
57
  reset
45
58
  printf '## Affected Files\n- `a/x.sh`\n\n- [ ] `a/x.sh.bak` — other\n<!-- FORGE:DEFERRED_FROM: #10 -->\n' > "$ISSUE_BODY"
46
- echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
59
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 77 open OWNER
47
60
  OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
48
61
  check "append: rc 0, no create" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG'"
49
62
  check "append: telemetry batched into 77 filed=0" "printf '%s' \"\$OUT\" | grep -q 'batched_into=#77 filed=0'"
@@ -55,7 +68,7 @@ reset
55
68
  printf 'd/w.sh\t\tknown-flaw\n' > "$T/emptywhy.tsv"
56
69
  bash "$S" --parent 10 --repo o/r --items "$T/emptywhy.tsv" >/dev/null; RC=$?
57
70
  rm -f "$ISSUE_BODY.edited"; printf 'x\n' > "$ISSUE_BODY"
58
- echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
71
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 77 open OWNER
59
72
  OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/emptywhy.tsv"); RC=$?
60
73
  check "empty-why: append keeps known-flaw flag" "[ $RC -eq 0 ] && grep -q 'd/w.sh. \*\*known-flaw\*\*' '$ISSUE_BODY.edited'"
61
74
 
@@ -66,13 +79,13 @@ check "missing option value: rc 2" "[ $RC -eq 2 ]"
66
79
  # 3. already-listed path is not duplicated; nothing to add -> no edit
67
80
  reset
68
81
  printf -- '- [ ] `a/x.sh` — w\n- [x] `b/y.sh`\n- [ ] `c/z.sh`\n' > "$ISSUE_BODY"
69
- echo '{"items":[{"number":77,"author_association":"MEMBER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
82
+ echo '{"items":[{"number":77,"author_association":"MEMBER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 77 open OWNER
70
83
  bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null; RC=$?
71
84
  check "idempotent: no edit, no create on resume" "[ $RC -eq 0 ] && ! grep -qE '^issue (edit|create)' '$GH_LOG'"
72
85
 
73
86
  # 4. untrusted match ignored -> new issue
74
87
  reset
75
- echo '{"items":[{"number":66,"author_association":"NONE","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
88
+ echo '{"items":[{"number":66,"author_association":"NONE","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 66 open NONE; issue_json 77 open OWNER
76
89
  OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv")
77
90
  check "untrusted: ignored, create filed" "grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'filed=1'"
78
91
 
@@ -87,7 +100,7 @@ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
87
100
  check "create failure: rc 4, no telemetry" "[ $RC -eq 4 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TELEMETRY"
88
101
  reset; export EDIT_FAIL=1
89
102
  printf 'x\n' > "$ISSUE_BODY"
90
- echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
103
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 77 open OWNER
91
104
  OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
92
105
  check "edit failure: rc 4, no telemetry" "[ $RC -eq 4 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TELEMETRY"
93
106
 
@@ -102,5 +115,85 @@ OUT=$(printf '' | bash "$S" --parent 10 --repo o/r)
102
115
  check "empty: no create, deferred_items=0" "! grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'deferred_items=0'"
103
116
  bash "$S" --repo o/r >/dev/null 2>&1; check "usage: missing parent rc 2" "[ $? -eq 2 ]"
104
117
 
118
+ # 9. e1: search index lags (returns nothing) but the claim marker on the parent finds the follow-up
119
+ reset
120
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 open OWNER; claim_json OWNER 77
121
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
122
+ check "index-lag: claim found, no duplicate create" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'batched_into=#77 filed=0'"
123
+ check "index-lag: search never needed" "! grep -q search/issues '$GH_LOG'"
124
+
125
+ # 10. e2: closed follow-up (found only by a state-all search) is reused and reopened
126
+ reset
127
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 closed OWNER
128
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
129
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
130
+ check "closed: reused, reopened, no create" "[ $RC -eq 0 ] && grep -q '^issue reopen 77' '$GH_LOG' && ! grep -q '^issue create' '$GH_LOG'"
131
+ check "closed: search query covers all states" "grep -q 'is:issue' '$GH_LOG' && ! grep 'api -X' '$GH_LOG' | grep -q 'is:open'"
132
+ reset; export REOPEN_FAIL=1
133
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 closed OWNER; claim_json OWNER 77
134
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null; RC=$?
135
+ check "closed: reopen failure is rc 4" "[ $RC -eq 4 ]"
136
+
137
+ # 11. create posts the claim marker on the parent
138
+ reset
139
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
140
+ check "claim: posted on parent after create" "[ $RC -eq 0 ] && grep -q '^issue comment 10 .*FORGE:DEFERRED_ISSUE: #901' '$GH_LOG'"
141
+ reset; export COMMENT_FAIL=1
142
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" 2>/dev/null); RC=$?
143
+ check "claim: post failure only warns" "[ $RC -eq 0 ] && printf '%s' \"\$OUT\" | grep -q 'batched_into=#901 filed=1'"
144
+
145
+ # 12. e3: a concurrent build filed #800 first; the first claim read (before our create) misses it, the post-create re-check sees it
146
+ race_setup() { # first comments read returns [] (the other build has not claimed yet), later reads return the real listing
147
+ CALLS="$T/claim.calls"; : > "$CALLS"; export CALLS
148
+ mv "$T/bin/gh" "$T/bin/gh.real"
149
+ cat > "$T/bin/gh" <<'WRAP'
150
+ #!/usr/bin/env bash
151
+ if [ "$1 $2" = "api --paginate" ]; then
152
+ echo x >> "$CALLS"
153
+ if [ "$(wc -l < "$CALLS" | tr -d ' ')" -le 1 ]; then echo "$*" >> "$GH_LOG"; echo '[]'; exit 0; fi
154
+ fi
155
+ exec "$(dirname "$0")/gh.real" "$@"
156
+ WRAP
157
+ chmod +x "$T/bin/gh"
158
+ }
159
+ race_teardown() { mv "$T/bin/gh.real" "$T/bin/gh"; }
160
+ reset
161
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 800 open OWNER; claim_json OWNER 800
162
+ race_setup
163
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
164
+ race_teardown
165
+ check "race: newer duplicate closed, items moved to lowest" "[ $RC -eq 0 ] && grep -q '^issue close 901' '$GH_LOG' && grep -q '^issue edit 800' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'batched_into=#800 filed=1'"
166
+ check "race: rows point at canonical issue" "[ \$(printf '%s\n' \"\$OUT\" | grep -c '800\$') -ge 3 ]"
167
+ # e5: duplicate-close failure only warns
168
+ reset; export CLOSE_FAIL=1
169
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 800 open OWNER; claim_json OWNER 800
170
+ race_setup
171
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" 2>/dev/null); RC=$?
172
+ race_teardown
173
+ check "race: duplicate-close failure still rc 0" "[ $RC -eq 0 ] && printf '%s' \"\$OUT\" | grep -q 'batched_into=#800'"
174
+
175
+ # 13. e4: claim listing failure is a failed lookup, never "none found"
176
+ reset; export CLAIM_FAIL=1
177
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
178
+ check "claim-failure: rc 3, no create" "[ $RC -eq 3 ] && printf '%s' \"\$OUT\" | grep -q DEFERRED_LOOKUP=FAILED && ! grep -q '^issue create' '$GH_LOG'"
179
+
180
+ # 15. e6: untrusted claim comment is ignored (falls through to create)
181
+ reset
182
+ issue_json 77 open OWNER; claim_json NONE 77
183
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" 2>/dev/null); RC=$?
184
+ check "untrusted-claim: ignored, create filed" "[ $RC -eq 0 ] && grep -q '^issue create' '$GH_LOG'"
185
+
186
+ # 16. e7: several claims, lowest valid wins; a claim to a missing issue falls through
187
+ reset
188
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 80 open OWNER; issue_json 90 open OWNER; claim_json OWNER 70 90 80
189
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
190
+ check "multi-claim: lowest valid (80) wins, missing 70 skipped" "[ $RC -eq 0 ] && printf '%s' \"\$OUT\" | grep -q 'batched_into=#80 filed=0'"
191
+
192
+ # 17. DRY_RUN: no claim, reopen or close
193
+ reset; export DRY_RUN=1
194
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 closed OWNER; claim_json OWNER 77
195
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
196
+ check "dry-run: no comment/reopen/close/edit" "! grep -qE '^issue (comment|reopen|close|edit|create)' '$GH_LOG'"
197
+
105
198
  echo "passed=$PASS failed=$FAIL"
106
199
  [ "$FAIL" -eq 0 ]
@@ -51,6 +51,8 @@ _forge_tel_record() { # event k=v... -> one JSON line on stdout
51
51
  run: ($run | str), phase: ($phase | str), event: $ev, eid: $eid} + $p' --args "${args[@]}"
52
52
  }
53
53
 
54
+ # build_selfreview (forge#3971: tier, agents, findings, fixed, declined, skipped=<reason>; ids and counts only) has no review-side
55
+ # routing: it defaults to the issue target below, like diff_size.
54
56
  # finding_disposition payload is generic key=value (incl. scope=introduced|pre-existing|unknown); no per-field allowlist applies.
55
57
  # Prints "<number> <repo>" of the comment target (issue or PR) for this event, or nothing when none is usable.
56
58
  _forge_tel_target() {
@@ -50,7 +50,8 @@
50
50
  # Phase 7-9 subcommands: --context FILE is optional; without it pass --review-sha SHA, --review-mode FULL|DELTA|BASE_SYNC_ONLY, --issue N, --base B.
51
51
  # verdict: --merge-health S --merge-health-state S [--base B] [--head-branch H] [--is-milestone true|false] [--has-purpose-regression true|false]
52
52
  # [--purpose-findings-file F | --purpose-findings TEXT] [--result stale|clean|blocking] [--agents-file F | --agents TEXT] [--findings-count N] [--blocking-count N]
53
- # [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--dry-run]
53
+ # [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--other-blocking true|false] [--out-dir D] [--dry-run]
54
+ # (--other-blocking: the reviewer's criterion 1/2/3/5 result; resolved and persisted to OUT_DIR/other-blocking.txt, which merge-gate ORs in)
54
55
  # calibrate: [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--trust-needs-human B] [--dry-run]; the issue is context contract.issue or --issue N
55
56
  # merge-gate: --verdict "APPROVED|CHANGES REQUESTED" --has-purpose-regression B --calibration-needs-human B --trust-needs-human B
56
57
  # --base-conflict B --other-blocking B [--calibration-note-file F | --calibration-note T] [--table-cell-key K] [--base B] [--issue N]
@@ -407,13 +408,15 @@ EOF
407
408
  gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate > "$FILES_JSON" 2>/dev/null || : > "$FILES_JSON"
408
409
  # "path:line" for every added line; an empty file means the diff was unreadable (gates then keep every finding).
409
410
  ADDED_LINES_FILE="$WORKDIR/added-lines"
410
- jq -r '.[] | select(.patch != null) | "FILE\t\(.filename)", .patch' "$FILES_JSON" 2>/dev/null | awk '
411
+ # Second stream from the same pass: "path<TAB>text" per added line, for the Gate 2 symbol fallback.
412
+ ADDED_TEXT_FILE="$WORKDIR/added-text"; : > "$ADDED_TEXT_FILE"
413
+ jq -r '.[] | select(.patch != null) | "FILE\t\(.filename)", .patch' "$FILES_JSON" 2>/dev/null | awk -v tf="$ADDED_TEXT_FILE" '
411
414
  /^FILE\t/{f=substr($(0),6);next}
412
415
  /^@@/{s=$(0);sub(/^@@ -[0-9,]+ \+/,"",s);sub(/[ ,].*/,"",s);n=s+0;next}
413
416
  /^\\/{next}
414
- /^\+/{print f":"n;n++;next}
417
+ /^\+/{print f":"n; print f "\t" substr($(0),2) > tf; n++;next}
415
418
  /^-/{next}
416
- {n++}' > "$ADDED_LINES_FILE" || : > "$ADDED_LINES_FILE"
419
+ {n++}' > "$ADDED_LINES_FILE" || { : > "$ADDED_LINES_FILE"; : > "$ADDED_TEXT_FILE"; }
417
420
  # Files the PR deleted or renamed (the old name too): findings on them always intersect.
418
421
  GONE_FILES_FILE="$WORKDIR/gone-files"
419
422
  jq -r '.[] | select(.status=="removed" or .status=="renamed") | .filename, (.previous_filename // empty)' "$FILES_JSON" > "$GONE_FILES_FILE" 2>/dev/null || : > "$GONE_FILES_FILE"
@@ -435,6 +438,52 @@ EOF
435
438
  if gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${REVIEW_SHA}" > "$t" 2>/dev/null; then awk 'END{print NR}' "$t"; else echo UNKNOWN; fi
436
439
  }
437
440
 
441
+ # Gate 2 symbol fallback: 0 when an identifier the finding names occurs (whole word, fixed string) in the added lines of FILE
442
+ # and nowhere in the base version of FILE (a symbol the PR introduced).
443
+ # Candidates: backticked tokens, plus identifier-shaped tokens on "Root cause" lines; each must match an anchored identifier
444
+ # regex of 4+ chars and not be a common word. Reviewer text is never used in arithmetic, a glob or a regex. Any failure returns 1.
445
+ # A symbol counts only when it is NEW: present in the added lines AND absent from the base version of the file (#4068 review
446
+ # DSEC-1/DBH-1: `self`, `return`, `result` in an added line must not re-scope a pre-existing finding). The base text comes from
447
+ # `git show <base>:<file>` (local commit) or the raw contents API; a file the PR adds has an empty base; an unreadable base
448
+ # disables the fallback (the line-window result stands).
449
+ SYM_BASE_SHA=""
450
+ base_text_for() { # base_text_for <file> <out>; 0 = out holds the base text (possibly empty), 1 = unknown
451
+ local f="$1" o="$2"
452
+ if jq -e --arg f "$f" 'any(.[]; .filename == $f and .status == "added")' "$FILES_JSON" >/dev/null 2>&1; then : > "$o"; return 0; fi
453
+ [ -n "$SYM_BASE_SHA" ] || SYM_BASE_SHA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .base.sha 2>/dev/null || true)
454
+ case "$SYM_BASE_SHA" in *[!0-9a-f]*|"") SYM_BASE_SHA="-"; return 1 ;; -) return 1 ;; esac
455
+ if git rev-parse -q --verify "${SYM_BASE_SHA}^{commit}" >/dev/null 2>&1; then
456
+ git show "${SYM_BASE_SHA}:${f}" > "$o" 2>/dev/null && return 0
457
+ git cat-file -e "${SYM_BASE_SHA}:${f}" 2>/dev/null && return 1
458
+ : > "$o"; return 0
459
+ fi
460
+ case "$f" in *[!A-Za-z0-9._/@+-]*|"") return 1 ;; esac
461
+ gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${SYM_BASE_SHA}" > "$o" 2>/dev/null && return 0
462
+ return 1
463
+ }
464
+ symbol_in_added() {
465
+ local f="$1" text="$2" t="$WORKDIR/sym-added" b="$WORKDIR/sym-base" c n=0
466
+ [ -s "$ADDED_TEXT_FILE" ] || return 1
467
+ awk -v f="$f" 'index($0, f "\t") == 1 { print substr($0, length(f) + 2) }' "$ADDED_TEXT_FILE" > "$t" 2>/dev/null || return 1
468
+ [ -s "$t" ] || return 1
469
+ base_text_for "$f" "$b" || return 1
470
+ while IFS= read -r c; do
471
+ case "$c" in ""|[!A-Za-z_]*) continue ;; esac
472
+ [ "${#c}" -ge 4 ] || continue
473
+ case "$(printf '%s' "$c" | tr '[:upper:]' '[:lower:]')" in
474
+ this|that|with|from|true|false|null|none|file|line|code|test|case|value|root|cause|when|then|which|where|there|their|error|data|name|path|type|call|only|also|into|have|does|used|uses) continue ;;
475
+ esac
476
+ n=$((n + 1)); [ "$n" -le 8 ] || break
477
+ if grep -qwF -- "$c" "$t" 2>/dev/null && ! grep -qwF -- "$c" "$b" 2>/dev/null; then return 0; fi
478
+ done <<SYMEOF
479
+ $({
480
+ printf '%s\n' "$text" | grep -oE '`[^`]+`' 2>/dev/null | tr -c 'A-Za-z0-9_\n' '\n'
481
+ printf '%s\n' "$text" | grep -iE 'root cause' 2>/dev/null | grep -oE '[A-Za-z_][A-Za-z0-9_]*' | grep -E '_|[a-z][A-Z]|[0-9]'
482
+ } 2>/dev/null | grep -E '^[A-Za-z_][A-Za-z0-9_]{3,}$' | awk '!seen[$0]++')
483
+ SYMEOF
484
+ return 1
485
+ }
486
+
438
487
  FINAL="$WORKDIR/final.jsonl"; : > "$FINAL"
439
488
  N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_INTRODUCED=0; N_KEPT=0
440
489
  I=0; TOTAL=$(jq 'length' "$DEDUPED")
@@ -472,6 +521,8 @@ EOF
472
521
  if grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE"; then INTRODUCED=1; break; fi
473
522
  L=$((L + 1))
474
523
  done
524
+ # The cited line is the reviewer's estimate (stale or at the call site): symbol fallback before routing pre-existing.
525
+ if [ "$INTRODUCED" -eq 0 ] && symbol_in_added "$FILE" "$(printf '%s' "$F" | jq -r '.text // ""')"; then INTRODUCED=1; fi
475
526
  if [ "$INTRODUCED" -eq 1 ]; then SCOPE="introduced"; else
476
527
  SCOPE="pre-existing"
477
528
  case "$SEV" in MEDIUM|LOW|INFO) ROUTE="pre-existing" ;; esac
@@ -561,7 +612,7 @@ do_classify() {
561
612
  && [ -s "$CONTRACT_SCOPE_FILE" ] \
562
613
  && gh pr diff "$PR_NUMBER" -R "$REPO" --name-only > "$PR_FILES_FILE" 2>/dev/null; then
563
614
  # A deferred row counts only if issue N is OPEN, is an issue (not a PR), is not MERGE_ISSUE, and its body
564
- # carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus the row's path, and is authored by a trusted author
615
+ # carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus a checklist line for the row's exact path (the list script strips a trailing slash, so `p` and `p/` both match; `- [ ] \`path\``, [xX] accepted, the producer format of file-deferred-followups.sh; a body that only mentions the path does not count), and is authored by a trusted author
565
616
  # (OWNER/MEMBER/COLLABORATOR or Bot; same predicate as trusted-comments.sh; missing fields fail closed).
566
617
  # Unverified rows are dropped.
567
618
  _verified="$_scratch/contract-verified"; : > "$_verified"
@@ -571,7 +622,7 @@ do_classify() {
571
622
  [ "$_n" = "${MERGE_ISSUE#\#}" ] && continue
572
623
  gh api "repos/${REPO}/issues/$_n" 2>/dev/null \
573
624
  | jq -e --arg m "FORGE:DEFERRED_FROM: #${MERGE_ISSUE#\#} -->" --arg p "$_ipath" \
574
- '.state == "open" and .pull_request == null and ((.body // "") | contains($m) and contains($p)) and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))' >/dev/null 2>&1 \
625
+ '.state == "open" and .pull_request == null and ((.body // "") | (contains($m) and (split("\n") | any(rtrimstr("\r") as $l | ("- [ ] `","- [x] `","- [X] `") as $pre | ($p, $p + "/") as $q | ($pre + $q + "`") as $w | $l == $w or ($l | startswith($w + " ")))))) and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))' >/dev/null 2>&1 \
575
626
  || continue
576
627
  grep -Fxq -- "$_n" "$CONTRACT_OPEN_FILE" || echo "$_n" >> "$CONTRACT_OPEN_FILE"
577
628
  fi
@@ -696,11 +747,32 @@ do_classify() {
696
747
  fi
697
748
  case "$DISPOSITION" in
698
749
  "NOTE contract-deferred #"*)
699
- _dn="${DISPOSITION##*#}"
700
- case "$_dn" in ''|*[!0-9]*) ;; *)
701
- [ "$DRY_RUN" = "true" ] || gh issue comment "$_dn" -R "$REPO" --body "Review of #${PR_NUMBER} also found this on a path declared deferred here: ${F_LOC} ${F_TITLE}" >/dev/null 2>&1 || true # allowlist:check-command-side-effects
750
+ # The demotion counts only after the comment on #N succeeded and #N is still OPEN; any failure files the finding (fails toward filing).
751
+ _dn="${DISPOSITION##*#}"; _dok=1
752
+ case "$_dn" in ''|*[!0-9]*) _dok=0 ;; *)
753
+ if [ "$DRY_RUN" != "true" ]; then
754
+ _dst=$(gh issue view "$_dn" -R "$REPO" --json state --jq .state 2>/dev/null) || _dst=""
755
+ if [ "$_dst" != "OPEN" ]; then _dok=0
756
+ elif ! gh issue comment "$_dn" -R "$REPO" --body "Review of #${PR_NUMBER} also found this on a path declared deferred here: ${F_LOC} ${F_TITLE}" >/dev/null 2>&1; then _dok=0 # allowlist:check-command-side-effects
757
+ fi
758
+ fi
702
759
  ;; esac
703
- CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
760
+ if [ "$_dok" = "1" ]; then CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)); else
761
+ # Fall back to what the finding would have been absent the contract (a LOW/POSSIBLE or lineage-damped finding stays a NOTE),
762
+ # not a blanket ISSUE: re-run the classifier without the contract flags. An INPR_FIX or unusable result files (fails toward filing).
763
+ DISPOSITION="ISSUE contract-comment-failed"
764
+ _n=$#; _skip=0
765
+ while [ "$_n" -gt 0 ]; do
766
+ _a="$1"; shift; _n=$((_n - 1))
767
+ if [ "$_skip" = "1" ]; then _skip=0; continue; fi
768
+ case "$_a" in --contract-scope|--contract-open|--pr-files) _skip=1 ;; *) set -- "$@" "$_a" ;; esac
769
+ done
770
+ _fb=$(bash "$CLASSIFY_SCRIPT" "$@" 2>/dev/null | head -n 1) || _fb=""
771
+ case "$_fb" in
772
+ "NOTE reflag"*) DISPOSITION="$_fb"; NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
773
+ NOTE*|ISSUE*) DISPOSITION="$_fb" ;;
774
+ esac
775
+ fi ;;
704
776
  "NOTE contract-accepted-risk"*) CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
705
777
  "NOTE reflag-"*) NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
706
778
  INPR_FIX*)
@@ -1021,6 +1093,20 @@ do_verdict() {
1021
1093
  if [ "$RESULT" = "clean" ] && { [ "$BASE_CONFLICT" = "true" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ]; }; then
1022
1094
  RESULT="blocking"; VERDICT_COERCED=true
1023
1095
  fi
1096
+ # OTHER_BLOCKING (criteria 1, 2, 3 and 5) is reviewer-judged, so it is resolved here and persisted for merge-gate to OR in: a blocking
1097
+ # result that BASE_CONFLICT and HAS_PURPOSE_REGRESSION do not explain was caused by another criterion, whatever the reviewer passed.
1098
+ OTHER_BLOCKING_RESOLVED=""
1099
+ case "$OTHER_BLOCKING" in true|false) OTHER_BLOCKING_RESOLVED="$OTHER_BLOCKING" ;; esac
1100
+ if [ "$RESULT" = "blocking" ] && [ "$BASE_CONFLICT" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ]; then
1101
+ OTHER_BLOCKING_RESOLVED=true
1102
+ elif [ "$RESULT" = "clean" ]; then
1103
+ OTHER_BLOCKING_RESOLVED=false
1104
+ fi
1105
+ # Rewritten (or removed) on every run so a stale value from a prior head can never survive a re-review.
1106
+ rm -f "$OUT_DIR/other-blocking.txt" 2>/dev/null || true
1107
+ if { [ "$RESULT" = "clean" ] || [ "$RESULT" = "blocking" ]; } && [ -n "$OTHER_BLOCKING_RESOLVED" ] && [ "$DRY_RUN" != "true" ]; then
1108
+ printf '%s\n' "$OTHER_BLOCKING_RESOLVED" > "$OUT_DIR/other-blocking.txt" 2>/dev/null || true
1109
+ fi
1024
1110
  VERDICT_OUT=""
1025
1111
  case "$RESULT" in
1026
1112
  stale) VERDICT_OUT="STALE" ;;
@@ -1055,6 +1141,7 @@ ${TRUST_ANNOTATION}${CONFLICT_LINE}${PROTECTION_LINE}${PURPOSE_LINE}${ATTRIBUTIO
1055
1141
  echo "HAS_PURPOSE_REGRESSION=${HAS_PURPOSE_REGRESSION}"
1056
1142
  echo "HAS_MERGE_CONFLICT=${HAS_MERGE_CONFLICT}"
1057
1143
  echo "BASE_CONFLICT=${BASE_CONFLICT}"
1144
+ echo "OTHER_BLOCKING=${OTHER_BLOCKING_RESOLVED}"
1058
1145
  echo "MERGE_CONFLICT_MSG=${MERGE_CONFLICT_MSG}"
1059
1146
  echo "MERGE_PROTECTION_WARNING=${MERGE_PROTECTION_WARNING}"
1060
1147
  exit 0
@@ -1347,6 +1434,8 @@ The review must add a \`### Degraded Paths Reviewed\` section that names each de
1347
1434
  gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
1348
1435
  blocked "merge guard state unbound"
1349
1436
  fi
1437
+ # OR in the value `verdict` persisted: a passed `false` never overrides a persisted `true`. Anything else in the file is ignored.
1438
+ if [ "$(head -n 1 "$OUT_DIR/other-blocking.txt" 2>/dev/null | tr -d '[:space:]')" = "true" ]; then OTHER_BLOCKING=true; fi
1350
1439
  # ONLY_BASE_CONFLICT: the base conflict is the ONLY reason for CHANGES REQUESTED, evaluated after every human gate is set.
1351
1440
  ONLY_BASE_CONFLICT=false
1352
1441
  if [ "$BASE_CONFLICT" = "true" ] && [ "$OTHER_BLOCKING" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ] && [ "$CALIBRATION_NEEDS_HUMAN" != "true" ] && [ "$TRUST_NEEDS_HUMAN" != "true" ]; then
@@ -1354,7 +1443,7 @@ The review must add a \`### Degraded Paths Reviewed\` section that names each de
1354
1443
  fi
1355
1444
  if [ "$ONLY_BASE_CONFLICT" = "true" ] && [ "$VERDICT" = "CHANGES REQUESTED" ]; then
1356
1445
  # Pure base conflict: route to the base-sync path, NOT needs-human. A standalone /review-pr caller just sees the blocker.
1357
- gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push), re-running the quality gate, then re-reviewing the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1446
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason). Base sync needed: the work-on pipeline performs the base sync (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push); a standalone \`/review-pr --auto-merge\` caller with no work-on router must run \`/work-on ${MERGE_ISSUE} --remediate\` to do it. The sync re-runs the quality gate and then re-reviews the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1358
1447
  echo "MERGE_OUTCOME=base-conflict"
1359
1448
  blocked "base-conflict"
1360
1449
  elif [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "$TRUST_NEEDS_HUMAN" = "true" ]; then
@@ -1382,7 +1471,7 @@ The review must add a \`### Degraded Paths Reviewed\` section that names each de
1382
1471
  fi
1383
1472
  if [ "$PRE_MERGE_BASE_CONFLICT" = "true" ]; then
1384
1473
  # Base moved during review: route to the base-sync path, NOT needs-human.
1385
- gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push) and then re-reviewing. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1474
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). Base sync needed: the work-on pipeline performs the base sync (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push); a standalone \`/review-pr --auto-merge\` caller with no work-on router must run \`/work-on ${MERGE_ISSUE} --remediate\` to do it. The sync is followed by a re-review. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1386
1475
  echo "MERGE_OUTCOME=base-conflict"
1387
1476
  blocked "base-conflict"
1388
1477
  elif [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then