forgedock 1.16.0 → 1.16.2

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 (39) hide show
  1. package/bin/engine/phases.mjs +69 -15
  2. package/bin/engine/runlog.mjs +21 -3
  3. package/bin/engine.mjs +43 -7
  4. package/bin/runner.mjs +21 -2
  5. package/commands/forge-stats.md +2 -2
  6. package/commands/orchestrate/phase-1-resolve.md +6 -40
  7. package/commands/orchestrate/phase-4-execution.md +26 -162
  8. package/commands/quality-gate.md +12 -80
  9. package/commands/review-pr-staging.md +67 -301
  10. package/commands/review-pr.md +150 -752
  11. package/commands/work-on/build/validate.md +6 -40
  12. package/commands/work-on/build.md +82 -40
  13. package/commands/work-on/close.md +7 -22
  14. package/commands/work-on/decompose.md +8 -24
  15. package/commands/work-on/investigate.md +18 -29
  16. package/commands/work-on/remediate.md +38 -147
  17. package/commands/work-on/review.md +25 -42
  18. package/commands/work-on.md +13 -63
  19. package/docs/CONFIG.md +10 -7
  20. package/docs/RELEASE-NOTES.md +6 -0
  21. package/docs/WORK-ON-RUNTIME.md +1 -1
  22. package/docs/spec/forge-protocol-v1.md +1 -1
  23. package/package.json +1 -1
  24. package/scripts/check-spec-markers.sh +2 -0
  25. package/scripts/check-spec-size.sh +47 -27
  26. package/scripts/check-spec-size.test.sh +46 -3
  27. package/scripts/diff-size.sh +36 -3
  28. package/scripts/diff-size.test.sh +47 -0
  29. package/scripts/forge-root.test.sh +218 -91
  30. package/scripts/lib/fence-bootstrap.sh +16 -0
  31. package/scripts/lib/forge-resolve.sh +96 -0
  32. package/scripts/lib/review-delta-mode.sh +41 -0
  33. package/scripts/lint-dispatch-prompt.test.sh +3 -3
  34. package/scripts/remediate-liveness.sh +96 -0
  35. package/scripts/review-acceptance-gate.sh +132 -0
  36. package/scripts/review-acceptance-gate.test.sh +90 -0
  37. package/scripts/review-pr-delta-wiring.test.sh +24 -7
  38. package/scripts/review-pr-staging-scope.test.sh +14 -7
  39. package/scripts/spec-no-worktree-script.test.sh +1 -1
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # forge-resolve.sh — sourced helper: locate ForgeDock's OWN install root and resolve its scripts.
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Source it, never exec it: . "$_r/scripts/lib/forge-resolve.sh"
7
+ # forge_root prints the first trusted install root that carries scripts/lib/forge-resolve.sh
8
+ # forge_resolve <name> prints the absolute path of scripts/<name> from the first trusted root that has it,
9
+ # or prints nothing (status 0). Callers fail closed on empty output.
10
+ #
11
+ # Trust boundary: roots come only from the engine (FORGEDOCK_HOME, authoritative when set), the running plugin root the
12
+ # stub locator handed over (FORGE_SOURCED_ROOT, the loader-substituted plugin root), the plugin runtime
13
+ # (CLAUDE_PLUGIN_ROOT), the legacy FORGE_ROOT/FORGE_HOME variables, the install.sh symlink, the Codex
14
+ # $CODEX_HOME/forge-home file, the newest pinned plugin cache and the marketplace checkout under CLAUDE_CONFIG_DIR
15
+ # then ~/.claude, and last the directory this file was sourced from. The consumer's working directory and the repo
16
+ # under review are author-controlled and are never consulted, and every root must be an absolute path.
17
+ #
18
+ # Order (matches docs/CONFIG.md "Install root resolution"): FORGEDOCK_HOME (when set, nothing else is consulted) >
19
+ # running plugin root (FORGE_SOURCED_ROOT) > CLAUDE_PLUGIN_ROOT > FORGE_ROOT > FORGE_HOME > install symlink >
20
+ # Codex forge-home > newest cache > marketplace > self.
21
+ # The running plugin root wins so a session keeps the scripts that match its own spec text even when a newer version is
22
+ # cached or a stale FORGE_HOME is exported. The stub assigns it from the loader-substituted plugin root (a shell-local
23
+ # assignment, never exported); non-Claude runtimes leave the literal placeholder, which the absolute-path check rejects.
24
+ # The self root is last on purpose: the stub locator that sourced this file picks a cache directory by glob order
25
+ # (lexical), so absent a running root the helper re-resolves by the numeric-newest tier instead of trusting the copy
26
+ # that happened to load.
27
+
28
+ # Root this file was sourced from (scripts/lib/.. /..), captured now because the cwd changes later. Under zsh
29
+ # BASH_SOURCE is empty, so the sourced path comes from ${(%):-%x} (eval'd: bash cannot parse that expansion). An empty
30
+ # path would make dirname "." (a cwd-derived root), so there is no self root when neither shell reports one.
31
+ _FORGE_SELF_ROOT=""; _forge_src=""
32
+ if [ -n "${BASH_SOURCE[0]:-}" ]; then _forge_src="${BASH_SOURCE[0]}"
33
+ elif [ -n "${ZSH_VERSION:-}" ]; then eval '_forge_src="${(%):-%x}"' 2>/dev/null || _forge_src=""; fi
34
+ [ -n "$_forge_src" ] && _FORGE_SELF_ROOT="$(cd "$(dirname "$_forge_src")/../.." 2>/dev/null && pwd -P)"
35
+ unset _forge_src
36
+
37
+ # Windows drive-letter path (C:/x or C:\x) -> /c/x (cygpath when present); anything else is printed unchanged.
38
+ _forge_posix() {
39
+ local _w="${1:-}" _u
40
+ case "$_w" in
41
+ [A-Za-z]:[/\\]*)
42
+ _u="$(cygpath -u "$_w" 2>/dev/null || true)"
43
+ [ -n "$_u" ] || _u="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')"
44
+ printf '%s\n' "$_u" ;;
45
+ *) printf '%s\n' "$_w" ;;
46
+ esac
47
+ }
48
+
49
+ # Running plugin root handed over by the stub locator (shell-local, consumed once, never exported).
50
+ _FORGE_RUNNING_ROOT="$(_forge_posix "${FORGE_SOURCED_ROOT:-}")"; unset FORGE_SOURCED_ROOT
51
+
52
+ # One absolute candidate root per line, highest priority first.
53
+ _forge_candidates() {
54
+ local _l _cfg _mk _x _cx
55
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then _forge_posix "$FORGEDOCK_HOME"; return 0; fi
56
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
57
+ printf '%s\n' "$_FORGE_RUNNING_ROOT" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}"
58
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
59
+ [ -n "$_cfg" ] || continue
60
+ _l="$(readlink -f "$_cfg/commands/work-on.md" 2>/dev/null || true)"
61
+ [ -n "$_l" ] && printf '%s\n' "${_l%/commands/work-on.md}"
62
+ done
63
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path).
64
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) head -n 1 "$_cx/forge-home" 2>/dev/null || true; echo ;; esac
65
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
66
+ [ -n "$_cfg" ] || continue
67
+ find -L "$_cfg/plugins/cache/$_mk/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null \
68
+ | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' \
69
+ | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true
70
+ done
71
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
72
+ [ -n "$_cfg" ] && printf '%s\n' "$_cfg/plugins/marketplaces/$_mk"
73
+ done
74
+ printf '%s\n' "$_FORGE_SELF_ROOT"
75
+ }
76
+
77
+ # forge_resolve <path under scripts/>: print the first match; print nothing when unresolved. Always returns 0, so a
78
+ # command substitution survives `set -e` and the caller's own empty-output check is the fail-closed branch.
79
+ forge_resolve() {
80
+ local _n="${1:-}" _c _all
81
+ case "$_n" in ''|/*|*..*) return 0 ;; esac
82
+ # Materialize the list first: returning early from a `< <(...)` loop would close the pipe under the generator (EPIPE noise).
83
+ _all="$(_forge_candidates)"
84
+ while IFS= read -r _c; do
85
+ case "$_c" in /*) [ -f "$_c/scripts/$_n" ] && { printf '%s\n' "$_c/scripts/$_n"; return 0; } ;; esac
86
+ done <<< "$_all"
87
+ return 0
88
+ }
89
+
90
+ # forge_root: print the first trusted install root (the one that carries this helper); prints nothing when unresolved.
91
+ forge_root() {
92
+ local _f
93
+ _f="$(forge_resolve lib/forge-resolve.sh)"
94
+ [ -n "$_f" ] && printf '%s\n' "${_f%/scripts/lib/forge-resolve.sh}"
95
+ return 0
96
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # review-delta-mode.sh: sourced helper for commands/review-pr.md (the review-delta mode functions).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Source it, never exec it; the spec resolves it with `forge_resolve lib/review-delta-mode.sh` (install roots only, never the
7
+ # working directory or the PR checkout). When it is unresolved the spec defines FULL-only fallbacks, so a missing helper
8
+ # can only cause a full review, never a skipped one.
9
+ is_sha() { case "${1}" in *[!0-9a-f]*|"") return 1 ;; esac; [ "${#1}" -eq 40 ] || [ "${#1}" -eq 64 ]; }
10
+ # Prints "<MODE> <ARG> <FULL_ROUNDS>" from scripts/review-delta.sh: "FULL - n", "DELTA <reviewed-sha> n" or "BASE_SYNC_ONLY <reviewed-sha> n".
11
+ # Needs REPO PR_NUMBER REVIEW_SHA BASE_BRANCH TRUSTED_SCRIPT REVIEW_DELTA_SCRIPT in scope. Every failure (unresolved script, bad output, bad SHA, empty delta) prints FULL, never a skipped review.
12
+ review_delta_mode() {
13
+ local out l1 l2 kind arg from to rounds=0 top
14
+ is_sha "$REVIEW_SHA" || { echo "FULL - 0"; return 0; }
15
+ if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$REVIEW_DELTA_SCRIPT" ] || [ -z "$BASE_BRANCH" ]; then echo "FULL - 0"; return 0; fi
16
+ top=$(git rev-parse --show-toplevel 2>/dev/null) || top=""
17
+ [ -n "$top" ] || { echo "FULL - 0"; return 0; }
18
+ out=$(bash "$REVIEW_DELTA_SCRIPT" --pr "$PR_NUMBER" --head "$REVIEW_SHA" --base "$BASE_BRANCH" --trusted-script "$TRUSTED_SCRIPT" --repo "$REPO" --repo-path "$top" 2>/dev/null) || out=""
19
+ l1=$(printf '%s\n' "$out" | sed -n 1p); l2=$(printf '%s\n' "$out" | sed -n 2p)
20
+ case "$l2" in FULL_ROUNDS=*) rounds="${l2#FULL_ROUNDS=}" ;; esac
21
+ case "$rounds" in ''|*[!0-9]*) echo "FULL - 0"; return 0 ;; esac
22
+ kind="${l1%% *}"; arg="${l1#* }"
23
+ if [ "$kind" = "BASE_SYNC_ONLY" ] && is_sha "$arg" && [ "$arg" != "$REVIEW_SHA" ]; then echo "BASE_SYNC_ONLY $arg $rounds"; return 0; fi
24
+ if [ "$kind" = "DELTA" ]; then
25
+ from="${arg%%..*}"; to="${arg##*..}"
26
+ if is_sha "$from" && [ "$to" = "$REVIEW_SHA" ] && [ "$from" != "$to" ]; then echo "DELTA $from $rounds"; return 0; fi
27
+ fi
28
+ echo "FULL - $rounds"
29
+ }
30
+ # Prints the Carried-From-SHA only when a TRUSTED FORGE:REVIEW-CARRIED record for this head names it AND the resolver
31
+ # still answers BASE_SYNC_ONLY from that same reviewed SHA right now. Needs COMMENTS_JSON as well. Marker text alone never counts.
32
+ carry_verified_from() {
33
+ local kind from rounds n
34
+ if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then return 0; fi
35
+ read -r kind from rounds <<< "$(review_delta_mode)"
36
+ [ "$kind" = "BASE_SYNC_ONLY" ] || return 0
37
+ n=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "\\A(?=[\\s\\S]*\\nReviewed-SHA: ${REVIEW_SHA}\\r?(\\n|$))(?=[\\s\\S]*\\nCarried-From-SHA: ${from}\\r?(\\n|$))<!-- FORGE:REVIEW-CARRIED -->" 2>/dev/null || echo "")
38
+ case "$n" in ''|*[!0-9]*) return 0 ;; esac
39
+ [ "$n" -ge 1 ] && printf '%s' "$from"
40
+ return 0
41
+ }
@@ -227,10 +227,10 @@ bash "$LINT" --expect nokey "$T/p27" >/dev/null 2>&1; [ $? -eq 2 ] && PASS=$((PA
227
227
  check "mid-line CR not stripped" 1 "$T/p25"
228
228
 
229
229
  # 10. Spec snippet enforcement (forge#3070): extract the lint gate from the spec and run it in a loop.
230
- # Gate = from the bootstrap marker to the end of its fenced code block (not a hard-coded count of
230
+ # Gate = from the forge_resolve stub locator line to the end of its fenced code block (not a hard-coded count of
231
231
  # top-level fi lines, which breaks whenever the bootstrap gains or loses a conditional).
232
- # Several spec blocks carry the bootstrap marker (e.g. the recovery-claim gate, forge#3172); pick the one holding the lint gate.
233
- awk '/^# FORGE_ROOT bootstrap/{f=1;buf=""} f&&/^```/{if(buf ~ /LINT_REFUSED_ISSUES/){printf "%s",buf;exit} f=0} f{buf=buf $0 "\n"}' "$SPEC" > "$T/gate"
232
+ # Several spec blocks carry the stub locator (e.g. the recovery-claim gate, forge#3172); pick the one holding the lint gate.
233
+ awk '/^type forge_resolve >\/dev\/null/{f=1;buf=""} f&&/^```/{if(buf ~ /LINT_REFUSED_ISSUES/){printf "%s",buf;exit} f=0} f{buf=buf $0 "\n"}' "$SPEC" > "$T/gate"
234
234
  [ -s "$T/gate" ] || { FAILN=$((FAILN+1)); echo "FAIL: could not extract spec lint gate"; }
235
235
  gate_refused() { # prompt-file home -> prints refusal entries or LAUNCHED
236
236
  ( LINT_REFUSED_ISSUES=(); RENDERED_PROMPT="$(cat "$1")"; FORGEDOCK_HOME="$2"; unset FORGE_HOME; reset_vals
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # remediate-liveness.sh — is an earlier remediation of this PR still alive? (remediate.md Phase M0, forge#3792)
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Usage: remediate-liveness.sh <pr> <issue> <owner/repo> [lease-holder]
7
+ # Env: DRY_RUN non-empty: print the deferral record instead of posting it
8
+ # FORGE_LIVENESS_NOW_MS override the clock (tests)
9
+ #
10
+ # The decision is deterministic and comes from GitHub state only, never from how partial trail comments look:
11
+ # (a) lease: the FORGE:STATE block in the issue body carries lease.until in (now, now + MAX_LEASE_TTL] for a
12
+ # lease.by that is not the dispatching holder and not a provably dead same-host engine pid. An until past
13
+ # the TTL cap is not a lease an engine could have written (the issue body is author-editable), so it is dead.
14
+ # (b) review: the newest trusted FORGE:REVIEW_ROUTE comment is younger than REVIEW_PENDING_MAX_S and no trusted
15
+ # FORGE:REVIEW-AGENT:* comment carries a Reviewed-SHA that prefix-matches the routed sha (either direction).
16
+ # A deferral is bounded: after MAX_DEFERRALS trusted FORGE:REMEDIATION_DEFERRED records for this PR the answer is
17
+ # EXHAUSTED (a human is needed), so a flapping lease or a dead review can never park a PR forever.
18
+ #
19
+ # The last stdout line is the verdict; the exit code mirrors it:
20
+ # LIVENESS: DEAD exit 0 proceed (adopt the interrupted attempt)
21
+ # LIVENESS: DEFER <kind> <detail> exit 10 a live run owns the PR; a FORGE:REMEDIATION_DEFERRED record was posted
22
+ # LIVENESS: EXHAUSTED <n> exit 11 deferral budget used up; escalate
23
+ # LIVENESS: ERROR <why> exit 2 unreadable state; fail closed (never "no lease", never delete)
24
+ # Trust predicate for comments: Bot, or OWNER/MEMBER/COLLABORATOR (same default as scripts/trusted-comments.sh).
25
+ set -uo pipefail
26
+
27
+ PR="${1:-}"; ISSUE="${2:-}"; REPO="${3:-}"; HOLDER="${4:-}"
28
+ MAX_LEASE_TTL_MS=1800000 # 3x the engine's 10 min lease TTL
29
+ REVIEW_PENDING_MAX_S=1800 # a review with no verdict after 30 min is dead, not in flight
30
+ MAX_DEFERRALS=3
31
+
32
+ err() { printf 'LIVENESS: ERROR %s\n' "$1"; exit 2; }
33
+ case "$PR" in ''|*[!0-9]*) err "bad pr" ;; esac
34
+ case "$ISSUE" in ''|*[!0-9]*) err "bad issue" ;; esac
35
+ [ -n "$REPO" ] || err "no repo"
36
+ printf '%s' "$HOLDER" | grep -qE '^[A-Za-z0-9._-]{1,128}$' || HOLDER=""
37
+ NOW_MS="${FORGE_LIVENESS_NOW_MS:-$(( $(date +%s) * 1000 ))}"
38
+ NOW_S=$(( NOW_MS / 1000 ))
39
+
40
+ TRUST='((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")))'
41
+
42
+ ISSUE_JSON=$(gh issue view "$ISSUE" -R "$REPO" --json body 2>/dev/null) || err "issue unreadable"
43
+ ISSUE_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$ISSUE/comments" 2>/dev/null) || err "issue comments unreadable"
44
+ PR_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$PR/comments" 2>/dev/null) || err "PR comments unreadable"
45
+ [ -n "$PR_COMMENTS" ] || err "PR comments unreadable"
46
+
47
+ # (a) lease. No FORGE:STATE block = no lease; a block that is present but unparseable fails closed.
48
+ STATE_JSON=$(printf '%s' "$ISSUE_JSON" | jq -r '.body // ""' | awk '/^<!-- FORGE:STATE[[:space:]]*$/{f=1;next} f&&/^-->/{exit} f')
49
+ LEASE_BY=""; LEASE_UNTIL=0; LEASE_LIVE=false
50
+ if [ -n "$STATE_JSON" ]; then
51
+ ROW=$(printf '%s' "$STATE_JSON" | jq -r '(.lease // {}) as $l | [($l.by // "" | tostring), (if ($l.until|type)=="number" then ($l.until|floor) else 0 end)] | @tsv' 2>/dev/null) \
52
+ || err "FORGE:STATE block unparseable"
53
+ LEASE_BY=$(printf '%s' "$ROW" | cut -f1); LEASE_UNTIL=$(printf '%s' "$ROW" | cut -f2)
54
+ case "$LEASE_UNTIL" in ''|*[!0-9]*) LEASE_UNTIL=0 ;; esac
55
+ if [ -n "$LEASE_BY" ] && [ "$LEASE_BY" != "$HOLDER" ] && [ "$LEASE_UNTIL" -gt "$NOW_MS" ] && [ "$LEASE_UNTIL" -le $(( NOW_MS + MAX_LEASE_TTL_MS )) ]; then
56
+ LEASE_LIVE=true
57
+ # Provably dead same-host engine (id cli_<host>_<pid>, pid gone): not live, as the engine's own I3 guard decides.
58
+ if [[ "$LEASE_BY" =~ ^cli_(.+)_([0-9]+)$ ]]; then
59
+ _h="${BASH_REMATCH[1]}"; _p="${BASH_REMATCH[2]}"
60
+ _me=$(uname -n 2>/dev/null | sed 's/[^A-Za-z0-9.-]/-/g')
61
+ if [ "$_h" = "${_me:-unknown}" ] && ! kill -0 "$_p" 2>/dev/null && ! ps -p "$_p" >/dev/null 2>&1; then LEASE_LIVE=false; fi
62
+ fi
63
+ fi
64
+ fi
65
+
66
+ # (b) a routed review that is still inside its window and has produced no verdict for the routed sha.
67
+ ROUTE=$(printf '%s' "$PR_COMMENTS" | jq -rs "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW_ROUTE mode=[^ ]+ spec=review-pr.md sha=[0-9a-f]{7,40} -->\")) | select((.body | test(\"mode=spec-evolution-blocked\")) | not)] | last | if . == null then \"\" else ((.body | capture(\"sha=(?<s>[0-9a-f]{7,40})\").s) + \" \" + ((.created_at // \"\") | fromdateiso8601? // 0 | tostring)) end" 2>/dev/null) \
68
+ || err "review comments unparseable"
69
+ ROUTE_SHA="${ROUTE%% *}"; ROUTE_AT="${ROUTE##* }"; REVIEW_PENDING=false
70
+ if [ -n "$ROUTE_SHA" ]; then
71
+ case "$ROUTE_AT" in ''|*[!0-9]*) ROUTE_AT=0 ;; esac
72
+ VERDICTS=$(printf '%s' "$PR_COMMENTS" | jq -rs --arg r "$ROUTE_SHA" "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->\")) | ((.body | capture(\"(^|\\\\n)Reviewed-SHA: (?<s>[0-9a-f]{7,40})\")? // {s: \"\"}).s) as \$s | select(\$s != \"\" and ((\$r | startswith(\$s)) or (\$s | startswith(\$r))))] | length" 2>/dev/null) || err "reviewer comments unreadable"
73
+ [ "${VERDICTS:-0}" -eq 0 ] && [ "$ROUTE_AT" -gt 0 ] && [ $(( NOW_S - ROUTE_AT )) -lt "$REVIEW_PENDING_MAX_S" ] && REVIEW_PENDING=true
74
+ fi
75
+
76
+ if [ "$LEASE_LIVE" != "true" ] && [ "$REVIEW_PENDING" != "true" ]; then
77
+ echo "LIVENESS: DEAD"; exit 0
78
+ fi
79
+
80
+ PRIOR=$(printf '%s' "$ISSUE_COMMENTS" | jq -rs --arg pr "pr=$PR" "[.[][] | select($TRUST) | select((.body // \"\") | startswith(\"<!-- FORGE:REMEDIATION_DEFERRED -->\")) | select((.body | split(\"\\n\") | index(\$pr)) != null)] | length" 2>/dev/null) || err "deferral records unreadable"
81
+ if [ "${PRIOR:-0}" -ge "$MAX_DEFERRALS" ]; then echo "LIVENESS: EXHAUSTED ${PRIOR}"; exit 11; fi
82
+
83
+ if [ "$LEASE_LIVE" = "true" ]; then KIND=lease; DETAIL="lease held by ${LEASE_BY} until ${LEASE_UNTIL}"
84
+ else KIND=review; DETAIL="re-review of ${ROUTE_SHA} has no reviewer verdicts yet"; fi
85
+ BODY="<!-- FORGE:REMEDIATION_DEFERRED -->
86
+ ## Remediation Deferred
87
+
88
+ PR #${PR}: an earlier remediation run is still live. This run changed nothing (deferral $(( ${PRIOR:-0} + 1 )) of ${MAX_DEFERRALS}).
89
+
90
+ pr=${PR}
91
+ kind=${KIND}
92
+ lease_by=${LEASE_BY:-none}
93
+ lease_until=${LEASE_UNTIL}"
94
+ if [ -n "${DRY_RUN:-}" ]; then printf 'DRY_RUN: would post:\n%s\n' "$BODY"
95
+ else gh issue comment "$ISSUE" -R "$REPO" --body "$BODY" >/dev/null 2>&1 || err "could not post deferral record"; fi
96
+ echo "LIVENESS: DEFER ${KIND} ${DETAIL}"; exit 10
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ # review-acceptance-gate.sh — review-phase acceptance gate (work-on/review.md Phase R2.5, forge#3793).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Usage: review-acceptance-gate.sh <issue> <gh_repo> "<gh_flag>" <worktree_path> (DRY_RUN=1 skips gh writes)
7
+ # Evaluates the ACCEPTANCE_CHECK lines the build gate (B6.5) deferred: `phase=review` checks, plus untagged legacy
8
+ # command/behavior checks on a `gh pr` target. Uses its own FORGE:ACCEPTANCE_GATE_REVIEW marker, never the build
9
+ # gate's FORGE:ACCEPTANCE_GATE header or :PASSED/:FAILED/:BLOCKED sentinels (the engine reads those).
10
+ # Malformed checks (forge#3694 parity with B6.5: 4+ backslash run, `bash -n` parse failure, grep usage error) are skipped and listed,
11
+ # never a FAIL and never needs-human. With zero verified checks and some malformed, a non-blocking notice is posted (no PASSED marker).
12
+ # Exit: 0 passed / no-op / notice / already passed for this head, 1 a deferred check failed (issue gets needs-human).
13
+ set -uo pipefail
14
+
15
+ NUMBER="${1:?issue number}"; GH_REPO="${2:?repo}"; GH_FLAG="${3:-}"; WT="${4:-.}"
16
+ cd "$WT" || exit 2
17
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
18
+ # shellcheck disable=SC2086 # GH_FLAG is a pre-split "-R owner/repo" flag string
19
+ gh_i() { gh "$@" $GH_FLAG; }
20
+
21
+ HEAD_SHA=$(git rev-parse HEAD 2>/dev/null)
22
+ # Trust (forge#3813): the spec lines are executed as check targets and the PASSED marker skips the gate, so both are read only
23
+ # from comments scripts/trusted-comments.sh accepts. The helper is resolved from ForgeDock's own install (forge_resolve), never
24
+ # the working directory; an unresolvable helper or unreadable comments fail closed (nothing executed, gate does not pass).
25
+ block() {
26
+ echo "Review acceptance gate: $1 (fail closed)" >&2
27
+ printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: review acceptance gate cannot trust issue comments (%s)\n' "$1"
28
+ exit 1
29
+ }
30
+ _RESOLVE_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/forge-resolve.sh"
31
+ type forge_resolve >/dev/null 2>&1 || { [ -f "$_RESOLVE_LIB" ] && . "$_RESOLVE_LIB"; }
32
+ type forge_resolve >/dev/null 2>&1 || block "forge_resolve unavailable"
33
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
34
+ [ -n "$TRUSTED_SCRIPT" ] || block "trusted-comments.sh unresolvable"
35
+ COMMENTS=$(gh api "repos/$GH_REPO/issues/$NUMBER/comments" --paginate 2>/dev/null) || block "comments unreadable"
36
+ [ -n "$COMMENTS" ] || block "comments unreadable"
37
+ TRUSTED_GATE=$(printf '%s' "$COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->') || block "trust filter failed"
38
+ TRUSTED_INV=$(printf '%s' "$COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INVESTIGATOR -->') || block "trust filter failed"
39
+ LAST=$(printf '%s\n' "$TRUSTED_GATE" | tail -n 1 | jq -r '. // ""' 2>/dev/null) || LAST=""
40
+ if printf '%s' "$LAST" | grep -qF "<!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HEAD_SHA} -->"; then
41
+ echo "Review acceptance gate already PASSED for ${HEAD_SHA}; skipping."; exit 0
42
+ fi
43
+
44
+ CHECKS=$(printf '%s\n' "$TRUSTED_INV" | jq -r '.' 2>/dev/null | grep "^ACCEPTANCE_CHECK:")
45
+ GATE_PASS=true; FAILED=""; VERIFIED_COUNT=0; MALFORMED_CHECKS=""; RESULTS=""
46
+ while IFS= read -r check_line; do
47
+ [ -z "$check_line" ] && continue
48
+ ID=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=\([^ ]*\) type=.*/\1/p')
49
+ TYPE=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=\([^ ]*\) target=.*/\1/p')
50
+ TARGET=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target="(([^"\\]|\\.)*)".*/\1/p' | sed 's/\\"/"/g')
51
+ [ -z "$TARGET" ] && TARGET=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=\([^ ]*\).*/\1/p')
52
+ MATCHER=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("([^"\\]|\\.)*"|[^ ]*) matcher="(([^"\\]|\\.)*)".*/\3/p' | sed 's/\\"/"/g')
53
+ [ -z "$MATCHER" ] && MATCHER=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=[^ ]* matcher=\([^ ]*\).*/\1/p')
54
+ DESC=$(echo "$check_line" | sed -n 's/.*description=\(.*\)/\1/p')
55
+ # phase= is anchored after matcher=, so text inside target/matcher/description cannot set it; anything else is build.
56
+ PHASE=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("(([^"\\]|\\.)*)"|[^ ]*) matcher=("(([^"\\]|\\.)*)"|[^ ]*) phase=(build|review)( .*)?$/\7/p')
57
+ [ "$PHASE" = "review" ] || PHASE=build
58
+ case "$TYPE" in command|behavior) ;; *) [ "$PHASE" = "review" ] || continue ;; esac
59
+ if [ "$PHASE" != "review" ]; then
60
+ printf '%s\n' "$check_line" | grep -qE ' phase=(build|review)( |$)' && continue
61
+ case "$TARGET" in *'gh pr'*) ;; *) continue ;; esac
62
+ fi
63
+ # malformed-check (forge#3694): static screen only parses (bash -n), never executes.
64
+ MALFORMED=""
65
+ { [ "$TYPE" = "command" ] || [ "$TYPE" = "behavior" ]; } && { case "$TARGET" in *'\\\\'*) MALFORMED="run of 4+ backslashes (over-escaped target)";; esac; [ -n "$MALFORMED" ] || bash -n -c "$TARGET" 2>/dev/null || MALFORMED="shell syntax error (unbalanced quotes)"; }
66
+ if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
67
+ RESULT="PASS"; DETAIL=""
68
+ case "$TYPE" in
69
+ exists) [ -e "$TARGET" ] || { RESULT="FAIL"; DETAIL="path not found: $TARGET"; } ;;
70
+ contains)
71
+ grep -qE "$MATCHER" "$TARGET" 2>/dev/null || grep -qF -- "$MATCHER" "$TARGET" 2>/dev/null || { RESULT="FAIL"; DETAIL="'$MATCHER' not found in $TARGET"; } ;;
72
+ command|behavior)
73
+ if [ "$MATCHER" = "exit_0" ]; then
74
+ ERR=$(eval "$TARGET" 2>&1 >/dev/null); RC=$?
75
+ # grep exit >= 2 is a usage error, not a verdict; a missing/unreadable operand also exits 2 but stays a real FAIL.
76
+ [ "$RC" -ge 2 ] && case "$TARGET" in grep\ *|egrep\ *|fgrep\ *) printf '%s' "$ERR" | grep -qE 'No such file or directory|Permission denied|Is a directory' || MALFORMED="grep usage error (exit $RC)";; esac
77
+ [ "$RC" -eq 0 ] || [ -n "$MALFORMED" ] || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
78
+ else
79
+ OUTPUT=$(eval "$TARGET" 2>&1); RC=$?; ERR="$OUTPUT"
80
+ [ "$RC" -ge 2 ] && case "$TARGET" in grep\ *|egrep\ *|fgrep\ *) printf '%s' "$ERR" | grep -qE 'No such file or directory|Permission denied|Is a directory' || MALFORMED="grep usage error (exit $RC)";; esac
81
+ if [ -n "$MALFORMED" ]; then :
82
+ else
83
+ printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER" || printf '%s\n' "$OUTPUT" | grep -qF -- "$MATCHER" \
84
+ || { RESULT="FAIL"; DETAIL="output did not match '$MATCHER'. Got: $(printf '%s\n' "$OUTPUT" | head -3)"; }
85
+ fi
86
+ fi ;;
87
+ *) RESULT="FAIL"; DETAIL="unknown check type: $TYPE" ;;
88
+ esac
89
+ if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
90
+ VERIFIED_COUNT=$((VERIFIED_COUNT+1))
91
+ RESULTS="${RESULTS}\n- **$ID** ($DESC): $RESULT"
92
+ if [ "$RESULT" = "FAIL" ]; then GATE_PASS=false; FAILED="${FAILED}\n- **$ID** ($DESC): $DETAIL"; fi
93
+ done <<< "$CHECKS"
94
+
95
+ MALFORMED_SECTION=""
96
+ [ -n "$MALFORMED_CHECKS" ] && MALFORMED_SECTION=$(printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS")
97
+
98
+ if [ "$VERIFIED_COUNT" -eq 0 ] && [ -z "$MALFORMED_CHECKS" ]; then
99
+ echo "No deferred acceptance checks; R2.5 is a no-op."
100
+ elif [ "$VERIFIED_COUNT" -eq 0 ]; then
101
+ # Review has no repair path, so unlike B6.5 this does not fail: notice only, no PASSED marker (a repaired spec is re-evaluated).
102
+ run gh_i issue comment "$NUMBER" --body "<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
103
+ ## Review Acceptance Gate: SKIPPED (malformed checks)
104
+
105
+ Every deferred (phase=review) acceptance check was malformed, so none could be verified. This is not a failure and does not block merge.
106
+ $MALFORMED_SECTION"
107
+ echo "Review acceptance gate: all deferred checks malformed; non-blocking notice posted."
108
+ elif [ "$GATE_PASS" = "true" ]; then
109
+ run gh_i issue comment "$NUMBER" --body "<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
110
+ ## Review Acceptance Gate: PASSED
111
+
112
+ Deferred (phase=review) acceptance checks verified against the PR:
113
+ $(echo -e "$RESULTS")
114
+ $MALFORMED_SECTION
115
+
116
+ <!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HEAD_SHA} -->"
117
+ else
118
+ run gh_i issue comment "$NUMBER" --body "<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
119
+ ## Review Acceptance Gate: FAILED
120
+
121
+ The following deferred (phase=review) acceptance checks did not pass against the PR:
122
+
123
+ $(echo -e "$FAILED")
124
+ $MALFORMED_SECTION
125
+
126
+ Merge is blocked. Fix the PR (for example its body) and re-run the review phase.
127
+
128
+ <!-- FORGE:ACCEPTANCE_GATE_REVIEW:FAILED -->"
129
+ run gh_i issue edit "$NUMBER" --add-label "needs-human"
130
+ printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: review acceptance gate failed (see FORGE:ACCEPTANCE_GATE_REVIEW comment)\n'
131
+ exit 1
132
+ fi
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # review-acceptance-gate.test.sh — trust filtering (forge#3813) and behavior (pass / fail / malformed-skip, forge#3814) of scripts/review-acceptance-gate.sh. No network (gh is stubbed, DRY_RUN=1).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ GATE="$SCRIPT_DIR/review-acceptance-gate.sh"
8
+ PASS=0; FAILN=0
9
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
10
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
11
+ eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
12
+
13
+ TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
14
+ mkdir -p "$TMP/bin" "$TMP/wt"
15
+ git -C "$TMP/wt" init -q . && git -C "$TMP/wt" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
16
+ HEAD_SHA=$(git -C "$TMP/wt" rev-parse HEAD)
17
+ cat > "$TMP/bin/gh" <<'STUB'
18
+ #!/usr/bin/env bash
19
+ [ "${GH_FAIL:-}" = 1 ] && exit 1
20
+ [ "$1" = api ] && { cat "$COMMENTS_FILE"; exit 0; }
21
+ echo "gh $*" >> "$GH_LOG"
22
+ STUB
23
+ chmod +x "$TMP/bin/gh"
24
+ export PATH="$TMP/bin:$PATH" GH_LOG="$TMP/gh.log" COMMENTS_FILE="$TMP/comments.json" FORGEDOCK_HOME="$(cd "$SCRIPT_DIR/.." && pwd)"
25
+ unset FORGE_TRAIL_TRUSTED_ASSOCIATIONS FORGE_TRAIL_TRUSTED_LOGINS
26
+
27
+ c() { jq -nc --arg a "$1" --arg t "$2" --arg b "$3" '{author_association:$a,user:{type:$t,login:"u"},body:$b}'; }
28
+ SENT="$TMP/sentinel"
29
+ INV_BAD="<!-- FORGE:INVESTIGATOR -->
30
+ ACCEPTANCE_CHECK: id=ac-1 type=command target=\"touch $SENT\" matcher=\"exit_0\" phase=review description=pwn"
31
+ INV_OK="<!-- FORGE:INVESTIGATOR -->
32
+ ACCEPTANCE_CHECK: id=ac-1 type=command target=\"touch $SENT\" matcher=\"exit_0\" phase=review description=ok"
33
+ PASSED="<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
34
+ <!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HEAD_SHA} -->"
35
+
36
+ run() { : > "$GH_LOG"; rm -f "$SENT"; DRY_RUN=1 bash "$GATE" 1 o/r "" "$TMP/wt" >"$TMP/out" 2>&1; echo $?; }
37
+
38
+ echo "[$(c NONE User "$INV_BAD")]" > "$COMMENTS_FILE"
39
+ rc=$(run); eq "untrusted spec ignored (rc 0, no-op)" "$rc" 0
40
+ [ ! -e "$SENT" ] && ok "untrusted spec line not executed" || bad "untrusted spec line executed"
41
+
42
+ echo "[$(c OWNER User "$INV_OK")]" > "$COMMENTS_FILE"
43
+ rc=$(run); eq "trusted spec runs" "$rc" 0
44
+ [ -e "$SENT" ] && ok "trusted check executed" || bad "trusted check not executed"
45
+
46
+ echo "[$(c NONE Bot "$INV_OK")]" > "$COMMENTS_FILE"
47
+ run >/dev/null; [ -e "$SENT" ] && ok "Bot author accepted" || bad "Bot author rejected"
48
+
49
+ echo "[$(c OWNER User "$INV_OK"),$(c NONE User "$PASSED")]" > "$COMMENTS_FILE"
50
+ run >/dev/null; [ -e "$SENT" ] && ok "untrusted PASSED marker does not skip" || bad "untrusted PASSED marker skipped the gate"
51
+
52
+ echo "[$(c OWNER User "$INV_OK"),$(c MEMBER User "$PASSED")]" > "$COMMENTS_FILE"
53
+ run >/dev/null; [ ! -e "$SENT" ] && grep -q "already PASSED" "$TMP/out" && ok "trusted PASSED marker skips" || bad "trusted PASSED marker ignored"
54
+
55
+ echo "[$(c OWNER User "$INV_OK")]" > "$COMMENTS_FILE"
56
+ GH_FAIL=1 run >/dev/null; rc=$(GH_FAIL=1 run)
57
+ eq "unreadable comments fail closed" "$rc" 1
58
+ [ ! -e "$SENT" ] && ok "nothing executed when unreadable" || bad "executed with unreadable comments"
59
+
60
+ rc=$(FORGEDOCK_HOME="$TMP/nowhere" run)
61
+ eq "unresolvable trust helper fails closed" "$rc" 1
62
+ [ ! -e "$SENT" ] && ok "nothing executed without helper" || bad "executed without helper"
63
+
64
+ # Behavioral cases (forge#3814). DRY_RUN=1 echoes gh writes as `DRY_RUN:` lines into $TMP/out.
65
+ printf 'x\n' > "$TMP/wt/f.txt"
66
+ inv() { local b="<!-- FORGE:INVESTIGATOR -->"; local l; for l in "$@"; do b="$b
67
+ $l"; done; echo "[$(c OWNER User "$b")]" > "$COMMENTS_FILE"; }
68
+ chk() { printf 'ACCEPTANCE_CHECK: id=%s type=command target="%s" matcher="exit_0" phase=review description=d' "$1" "$2"; }
69
+ nolabel() { ! grep -q 'needs-human' "$TMP/out"; }
70
+
71
+ inv "$(chk ac-1 true)"; rc=$(run); eq "pass: rc 0" "$rc" 0
72
+ grep -q ':PASSED:' "$TMP/out" && ok "pass: PASSED sentinel posted" || bad "pass: no PASSED sentinel"
73
+
74
+ inv "$(chk ac-1 false)"; rc=$(run); eq "real fail: rc 1" "$rc" 1
75
+ grep -q 'needs-human' "$TMP/out" && ok "real fail: needs-human added" || bad "real fail: no needs-human"
76
+ inv "$(chk ac-1 'grep -q nomatch f.txt')"; rc=$(run); eq "grep exit 1 is a real fail" "$rc" 1
77
+ inv "$(chk ac-1 'grep -q x nofile.txt')"; rc=$(run); eq "grep missing operand file stays a real fail" "$rc" 1
78
+
79
+ for t in "echo 'unbalanced" 'echo a\\\\\\\\b' "grep -E '[' f.txt"; do
80
+ inv "$(chk ac-7 "$t")"; rc=$(run); eq "malformed skipped, rc 0: $t" "$rc" 0
81
+ nolabel && ok "malformed: no needs-human" || bad "malformed: needs-human added: $t"
82
+ grep -qi 'malformed' "$TMP/out" && ok "malformed reported" || bad "malformed not reported: $t"
83
+ grep -q ':PASSED:' "$TMP/out" && bad "all-malformed wrote PASSED marker: $t" || ok "all-malformed: no PASSED marker"
84
+ done
85
+
86
+ inv "$(chk ac-1 true)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed pass+malformed: rc 0" "$rc" 0
87
+ grep -q ':PASSED:' "$TMP/out" && grep -qi 'malformed' "$TMP/out" && ok "mixed: PASSED with malformed listed" || bad "mixed: wrong output"
88
+ inv "$(chk ac-1 false)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed fail+malformed: fails closed" "$rc" 1
89
+
90
+ echo "$PASS passed, $FAILN failed"; [ "$FAILN" -eq 0 ]
@@ -92,15 +92,32 @@ fence_file() { # name pattern -> $TMP/<name>.sh (placeholders filled)
92
92
  }
93
93
 
94
94
  # ---- 0. structure ---------------------------------------------------------------------------------------------------
95
- n=$(count_blocks review-pr-delta-mode)
96
- [ "$n" -ge 6 ] && ok || bad "expected the review-delta mode snippet in every consuming fence (3C mode, carry post, Phase 4, 5, 6A, 9), found $n"
97
- distinct=$(awk '/^# BEGIN review-pr-delta-mode$/ {on=1; buf=""} on {buf = buf $0 "\n"} /^# END review-pr-delta-mode$/ && on {on=0; print buf "\0"}' "$SPEC" | tr -d '\n' | sed 's/\x0/\n/g' | sort -u | grep -c . || true)
98
- [ "$distinct" = 1 ] && ok || bad "review-delta mode snippet copies differ ($distinct distinct)"
95
+ # The mode functions live in ONE sourced helper (scripts/lib/review-delta-mode.sh); every consuming fence sources it through forge_resolve
96
+ # with a FULL-only fallback, so an unresolved helper can only mean a full review.
97
+ HELPER="$ROOT/scripts/lib/review-delta-mode.sh"
98
+ [ -f "$HELPER" ] && ok || bad "scripts/lib/review-delta-mode.sh must exist"
99
+ n=$(grep -c '^_dm=$(forge_resolve lib/review-delta-mode.sh)' "$SPEC")
100
+ [ "$n" -ge 6 ] && ok || bad "expected the review-delta helper source line in every consuming fence (3C mode, carry post, Phase 4, 5, 6A, 9), found $n"
101
+ distinct=$(grep '^_dm=$(forge_resolve lib/review-delta-mode.sh)' "$SPEC" | sort -u | grep -c . || true)
102
+ [ "$distinct" = 1 ] && ok || bad "review-delta helper source lines differ ($distinct distinct)"
103
+ [ "$(count_blocks review-pr-delta-mode)" = 0 ] && ok || bad "inline review-delta mode copies must not remain in the spec"
104
+ grep -q '^_dm=.*review_delta_mode() { echo "FULL - 0"; }' "$SPEC" && ok || bad "the helper source line must carry a FULL-only fallback"
105
+ # every source line is preceded by a locator in the same fence (a fresh shell has no forge_resolve)
106
+ miss=$(awk '/^```/{loc=0} /^type forge_resolve >\/dev\/null/{loc=1} /^_dm=\$\(forge_resolve lib\/review-delta-mode.sh\)/&&!loc{b++} END{print b+0}' "$SPEC")
107
+ [ "$miss" = 0 ] && ok || bad "$miss helper source line(s) lack a forge_resolve locator in the same fence"
108
+ # unresolved helper: the fallback answers FULL and carries nothing
109
+ fb=$(grep -m1 '^_dm=.*review_delta_mode() { echo "FULL - 0"; }' "$SPEC")
110
+ got=$(bash -c "forge_resolve() { :; }; $fb; review_delta_mode; printf 'carry=[%s]' \"\$(carry_verified_from)\"")
111
+ [ "$got" = "FULL - 0
112
+ carry=[]" ] && ok || bad "an unresolved helper must fall back to FULL (got '$got')"
113
+ # resolved helper: the real functions load
114
+ got=$(bash -c "forge_resolve() { echo '$HELPER'; }; $fb; type -t is_sha")
115
+ [ "$got" = "function" ] && ok || bad "a resolved helper must define the mode functions (got '$got')"
99
116
  grep -qF 'scripts/review-delta.sh' "$SPEC" && ok || bad "spec does not name scripts/review-delta.sh"
100
117
 
101
118
  # ---- 1. resolver block: trusted install only -------------------------------------------------------------------------
102
119
  mkrepo; HEAD=$(sha HEAD)
103
- awk '/^[ ]*# HELPER_SCRIPT resolver \(/ && index($0, "(REVIEW_DELTA_SCRIPT;") {inb=1} inb {print} inb && /^done <<< "\$_tc"$/ {exit}' "$SPEC" > "$TMP/rd_resolver.sh"
120
+ fence_with 'REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)' | grep -E '^type forge_resolve >/dev/null|^REVIEW_DELTA_SCRIPT=\$\(forge_resolve ' > "$TMP/rd_resolver.sh"
104
121
  [ -s "$TMP/rd_resolver.sh" ] && ok || bad "no canonical REVIEW_DELTA_SCRIPT resolver block in the spec"
105
122
  mkdir -p "$W/scripts"; printf '#!/usr/bin/env bash\necho "BASE_SYNC_ONLY %s"\necho FULL_ROUNDS=9\n' "$HEAD" > "$W/scripts/review-delta.sh"
106
123
  got=$(cd "$W" && HOME="$TMP/home" FORGE_ROOT="$ROOT" CLAUDE_CONFIG_DIR="" bash -c "$(cat "$TMP/rd_resolver.sh"); printf %s \"\$REVIEW_DELTA_SCRIPT\"")
@@ -191,8 +208,8 @@ W=$BS_W; R=$BS_R; HEAD=$BS_HEAD
191
208
  fence_file carry "CARRY_RECORD: POSTED" || exit 1
192
209
  run_carry() { subst "$TMP/carry.raw" > "$TMP/carry.sh"; run_fence "$TMP/carry.sh" "$@"; }
193
210
  verified() { # prints the verified Carried-From-SHA for the current $FAKE_COMMENTS using the real snippet
194
- { marked_block review-pr-delta-mode; echo 'COMMENTS_JSON=$(cat "$FAKE_COMMENTS"); carry_verified_from'; } > "$TMP/cv_body.sh"
195
- { cat "$TMP/carry.raw" | awk '/^# BEGIN review-pr-delta-mode$/ {exit} {print}'; cat "$TMP/cv_body.sh"; } | subst /dev/stdin > "$TMP/cv.sh"
211
+ { cat "$HELPER"; echo 'COMMENTS_JSON=$(cat "$FAKE_COMMENTS"); carry_verified_from'; } > "$TMP/cv_body.sh"
212
+ { cat "$TMP/carry.raw" | awk '/^_dm=\$\(forge_resolve lib\/review-delta-mode\.sh\)/ {exit} {print}'; cat "$TMP/cv_body.sh"; } | subst /dev/stdin > "$TMP/cv.sh"
196
213
  run_fence "$TMP/cv.sh" FAKE_COMMENTS="$FAKE_COMMENTS"
197
214
  }
198
215
  panel_items "$R" | jq -s '.' > "$FAKE_COMMENTS"
@@ -96,9 +96,13 @@ for _s in "$SPEC" "$SPEC_MAIN"; do
96
96
  if grep -qE 'BLOCKED.*classifier unavailable' "$_s" && ! grep -qF 'echo script || echo manual' "$_s" && ! grep -qF 'classifier=manual' "$_s" && ! grep -qF 'classifier-unavailable' "$_s"; then ok; else bad "$(basename "$_s") must fail closed with BLOCKED classifier unavailable and have no manual fallback"; fi
97
97
  done
98
98
  # (8) fresh-shell reload (forge#3654): the FENCE_BOOTSTRAP lines must reload PR_NUMBER / ALL_PR_NUMBERS from the scratchpad, stay empty (with a warning) when nothing was persisted, and reject a tampered number
99
- awk '/^[^ `#].* # FENCE_BOOTSTRAP/ && !seen[$0]++' "$SPEC" > "$TMP/boot.sh"
100
- [ "$(wc -l < "$TMP/boot.sh")" -eq 8 ] && ok || bad "FENCE_BOOTSTRAP block expected 8 distinct lines (got $(wc -l < "$TMP/boot.sh"))"
101
- [ "$(grep -c '^# Fence bootstrap' "$SPEC")" -ge 3 ] && ok || bad "bootstrap must be carried by 0B.1 and both 7B.5 fences"
99
+ # (forge#3778) the spec carries a byte-identical stub per fence; the body lives in scripts/lib/fence-bootstrap.sh, which is SOURCED
100
+ # in a fresh shell (the stub's locator + source line is executed for real, so the stub itself is covered too).
101
+ [ "$(grep '# FENCE_BOOTSTRAP$' "$SPEC" | sort -u | wc -l | tr -d ' ')" -eq 1 ] && ok || bad "FENCE_BOOTSTRAP stubs must be byte-identical"
102
+ [ "$(grep -c '# FENCE_BOOTSTRAP$' "$SPEC")" -ge 3 ] && ok || bad "bootstrap must be carried by 0B.1 and both 7B.5 fences"
103
+ grep '^type forge_resolve >/dev/null' "$SPEC" | sed 1q > "$TMP/boot.sh"
104
+ grep '# FENCE_BOOTSTRAP$' "$SPEC" | sed 1q >> "$TMP/boot.sh"
105
+ [ "$(wc -l < "$TMP/boot.sh")" -eq 2 ] && ok || bad "bootstrap stub (locator + source line) not found"
102
106
  # forge#3681: EVERY bash fence after Phase 0B that reads cross-fence state must carry the bootstrap (a fresh Bash call loses it)
103
107
  UNCOVERED=$(awk '
104
108
  /^```bash/ { infence=1; body=""; start=NR; next }
@@ -123,7 +127,7 @@ mkdir -p "$TMP/sd9/empty" "$TMP/sd9/full" "$TMP/bin9"
123
127
  printf '#!/bin/sh\necho stub-value\n' > "$TMP/bin9/yq"; printf '#!/bin/sh\nexit 0\n' > "$TMP/bin9/gh"; chmod +x "$TMP/bin9/yq" "$TMP/bin9/gh"
124
128
  : > "$TMP/sd9/empty/42_launched_review_agents"
125
129
  printf 'security\n' > "$TMP/sd9/full/42_launched_review_agents"
126
- run675() { ( unset LAUNCHED_REVIEW_AGENTS REVIEW_SHA_FULL; export PATH="$TMP/bin9:$PATH" FORGE_SCRATCHPAD="$1" HOME="$TMP/nohome"; [ -n "$2" ] && export PR_NUMBER="$2"; bash "$TMP/f675.sh" 2>&1; echo "rc=$?" ); }
130
+ run675() { ( unset LAUNCHED_REVIEW_AGENTS REVIEW_SHA_FULL; export PATH="$TMP/bin9:$PATH" FORGE_SCRATCHPAD="$1" HOME="$TMP/nohome" FORGEDOCK_HOME="$ROOT"; [ -n "$2" ] && export PR_NUMBER="$2"; bash "$TMP/f675.sh" 2>&1; echo "rc=$?" ); }
127
131
  for _case in "$TMP/sd9/empty|42" "$TMP/sd9/none|42" "$TMP/sd9/full|"; do
128
132
  OUT9=$(run675 "${_case%%|*}" "${_case##*|}")
129
133
  case "$OUT9" in *"REVIEW DELIVERY FAILURE: launched-agent list is empty or unreadable"*"rc=1"*) ok ;; *) bad "6.75 must exit 1 for case '$_case' (got '$OUT9')" ;; esac
@@ -147,7 +151,7 @@ printf '42\n' > "$TMP/sd8/full/staging_pr_number"; printf '7\n8\n' > "$TMP/sd8/f
147
151
  printf '42; rm -rf /\n' > "$TMP/sd8/bad/staging_pr_number"
148
152
  boot() { # scratch-dir -> prints "PR_NUMBER|ALL_PR_NUMBERS(joined)|GH_REPO" then any warning
149
153
  ( unset PR_NUMBER ALL_PR_NUMBERS GH_REPO DEFAULT_BRANCH STAGING_BRANCH; export PATH="$TMP/bin:$PATH" FORGE_SCRATCHPAD="$1"
150
- . "$TMP/boot.sh" 2>&1; echo "PR=${PR_NUMBER:-}|ALL=$(echo ${ALL_PR_NUMBERS:-})|REPO=${GH_REPO:-}|BR=${STAGING_BRANCH:-}" ) 2>&1; }
154
+ export FORGEDOCK_HOME="$ROOT"; . "$TMP/boot.sh" 2>&1; echo "PR=${PR_NUMBER:-}|ALL=$(echo ${ALL_PR_NUMBERS:-})|REPO=${GH_REPO:-}|BR=${STAGING_BRANCH:-}" ) 2>&1; }
151
155
  OUT8=$(boot "$TMP/sd8/full")
152
156
  [ "$OUT8" = "PR=42|ALL=7 8|REPO=stub-value|BR=stub-value" ] && ok || bad "fresh shell must reload staging_pr_number and bundle-prs from the scratchpad (got '$OUT8')"
153
157
  OUT8=$(boot "$TMP/sd8/empty")
@@ -155,7 +159,7 @@ case "$OUT8" in *"WARNING: PR_NUMBER is empty"*"PR=|ALL=|"*) ok ;; *) bad "empty
155
159
  OUT8=$(boot "$TMP/sd8/bad")
156
160
  case "$OUT8" in *"PR=|"*) ok ;; *) bad "non-numeric staging_pr_number must be rejected (got '$OUT8')" ;; esac
157
161
  # an already-set PR_NUMBER wins over a stale scratch file
158
- OUT8=$( ( unset ALL_PR_NUMBERS; PR_NUMBER=99; export PATH="$TMP/bin:$PATH" FORGE_SCRATCHPAD="$TMP/sd8/full"; . "$TMP/boot.sh" >/dev/null 2>&1; echo "PR=$PR_NUMBER|ALL=${ALL_PR_NUMBERS:-}" ) )
162
+ OUT8=$( ( unset ALL_PR_NUMBERS; PR_NUMBER=99; export PATH="$TMP/bin:$PATH" FORGE_SCRATCHPAD="$TMP/sd8/full" FORGEDOCK_HOME="$ROOT"; . "$TMP/boot.sh" >/dev/null 2>&1; echo "PR=$PR_NUMBER|ALL=${ALL_PR_NUMBERS:-}" ) )
159
163
  [ "$OUT8" = "PR=99|ALL=" ] && ok || bad "in-shell PR_NUMBER must not be overridden by the scratch file (got '$OUT8')"
160
164
  # producers persist, and 0B.1 / 7B.5 no longer use the unsubstituted-template repo token or a bare unquoted PR number in gh pr comment
161
165
  grep -q 'staging_pr_number' "$SPEC" && grep -q '_bundle-prs.txt' "$SPEC" && ok || bad "Phase -1 / 0A must persist staging_pr_number and _bundle-prs.txt"
@@ -278,7 +282,10 @@ OUT9=$(credit9 "$H"); N9=$(printf '%s' "${OUT9#*|}" | wc -w)
278
282
  grep -q 'review-delta.sh' "$SPEC" && grep -q 'REVIEW-CARRIED' "$SPEC" && ok || bad "spec must call review-delta.sh and accept the REVIEW-CARRIED record"
279
283
  if grep -qE 'merge-base --is-ancestor.*REVIEWED_SHA_STALE|diff_shape\(\)' "$SPEC"; then bad "spec must not carry a second copy of the resolver logic"; else ok; fi
280
284
  grep -qF 'printf '"'"'%s\n'"'"' "$_FILES" | sort -u | sed "s|^|$pr |" >> "$_PF"' "$SPEC" && grep -qF 'git diff-tree --no-commit-id --name-only -r --cc "$c"' "$SPEC" && ok || bad "CROSS_PR_FILES accumulation and --cc merge scoping must stay intact"
281
- [ "$(grep -c '# FENCE_BOOTSTRAP' "$SPEC" | head -1)" -ge 8 ] && ok || bad "FENCE_BOOTSTRAP lines must remain"
285
+ [ "$(grep -c '# FENCE_BOOTSTRAP$' "$SPEC" | head -1)" -ge 3 ] && ok || bad "FENCE_BOOTSTRAP stubs must remain"
286
+ # an unresolvable helper fails the fence closed (exit 1), never an unsourced bootstrap
287
+ OUT8=$( ( unset FORGEDOCK_HOME; export HOME="$TMP/nohome"; cd "$TMP" && bash -c "$(cat "$TMP/boot.sh"); echo reached" ) 2>&1 )
288
+ case "$OUT8" in *BLOCKED*) case "$OUT8" in *reached*) bad "unresolved bootstrap helper must stop the fence" ;; *) ok ;; esac ;; *) bad "unresolved bootstrap helper must print BLOCKED (got '$OUT8')" ;; esac
282
289
  # a stale-head PR must not credit its own merge commit (it contains the post-review commits): the DELTA branch only appends DELTA_COMMITS
283
290
  if awk '/^bundle_delta_credit\(\)/,/^\}/' "$SPEC" | grep -q 'mergeCommit'; then bad "the DELTA path must not credit the PR merge commit"; else ok; fi
284
291
  else echo "review-pr-staging-scope.test.sh: git missing, delta section skipped"; fi
@@ -32,7 +32,7 @@ chk commands/quality-gate.md 'VALIDATOR="${FORGE_ROOT:+$FORGE_ROOT/scripts/valid
32
32
  chk commands/quality-gate.md 'CONFLICT_CHECKER="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-native-conflicts.sh}"'
33
33
  chk commands/quality-gate.md 'SPEC_BASH_CHECKER="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-spec-bash.sh}"'
34
34
  chk commands/quality-gate.md 'CLASSIFIER="${FORGE_ROOT:+$FORGE_ROOT/scripts/flaky-quarantine.sh}"'
35
- chk commands/review-pr.md 'CLASSIFIER="${FORGE_ROOT:+$FORGE_ROOT/scripts/flaky-quarantine.sh}"'
35
+ chk commands/review-pr.md 'CLASSIFIER=$(forge_resolve flaky-quarantine.sh)'
36
36
  # validate.md must not name the worktree scripts dir at all (ac-1)
37
37
  grep -qF '{WORKTREE_PATH}/scripts' "$ROOT/commands/work-on/build/validate.md" && bad "validate.md references {WORKTREE_PATH}/scripts" || ok
38
38