forgedock 1.15.0 → 1.16.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.
@@ -218,6 +218,15 @@ function hasPhaseComplete(comments) {
218
218
  return hasMarker(comments, "FORGE:PHASE:COMPLETE", "FORGE:PHASE:COMPLETE");
219
219
  }
220
220
 
221
+ /**
222
+ * close.md's own idempotency key: a trusted FORGE:TRAJECTORY comment. Shared by close.reconcile
223
+ * and close.detectOutcome so the two cannot drift. Throws on fetch error; callers fail closed.
224
+ */
225
+ async function hasTrajectory(issue, io) {
226
+ const { comments } = await issueMarkers(issue, io);
227
+ return comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY"));
228
+ }
229
+
221
230
  /**
222
231
  * Trusted comment bodies, chronological — the same single fetch + trust predicate
223
232
  * as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
@@ -960,8 +969,7 @@ export const PHASES = [
960
969
  // proof close.md ran (board, summary, trajectory). Its own idempotency key is the
961
970
  // FORGE:TRAJECTORY comment; only that makes this satisfied.
962
971
  try {
963
- const { comments } = await issueMarkers(state.issue, io);
964
- return { satisfied: comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY")), terminalReason: "investigation" };
972
+ return { satisfied: await hasTrajectory(state.issue, io), terminalReason: "investigation" };
965
973
  } catch { return { satisfied: false }; }
966
974
  }
967
975
  if (snap.state === "CLOSED") return { satisfied: true };
@@ -992,8 +1000,17 @@ export const PHASES = [
992
1000
  if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
993
1001
  return { status: "committed", terminalReason: "merged", outputs: {} };
994
1002
  // forge#3534: an investigation exit closes the issue itself; keep that reason honest.
995
- if (snap.state === "CLOSED")
996
- return { status: "committed", terminalReason: state.terminalReason === "investigation" ? "investigation" : "invalid", outputs: {} };
1003
+ if (snap.state === "CLOSED") {
1004
+ if (state.terminalReason !== "investigation")
1005
+ return { status: "committed", terminalReason: "invalid", outputs: {} };
1006
+ // The issue is always already CLOSED for an investigation run, so require the same
1007
+ // positive evidence as reconcile (a trusted FORGE:TRAJECTORY); fail closed on error.
1008
+ let ran = false;
1009
+ try { ran = await hasTrajectory(state.issue, io); } catch { ran = false; }
1010
+ return ran
1011
+ ? { status: "committed", terminalReason: "investigation", outputs: {} }
1012
+ : { status: "failed", detail: "investigation close: no FORGE:TRAJECTORY" };
1013
+ }
997
1014
  // forge#3545: PHASE_COMPLETE is a valid close exit (multi-phase issue: PR merged,
998
1015
  // issue deliberately left OPEN). Only the current cycle's marker counts.
999
1016
  if (snap.state === "OPEN") {
@@ -61,6 +61,8 @@ export function deriveState(events) {
61
61
  remediationKind: null };
62
62
  // forge#3528: handoff reason of the LAST PHASE_COMMIT only (replaced, not sticky).
63
63
  let lastCommitReason = null;
64
+ // forge#3754: phase of the LAST PHASE_COMMIT, so a reopen can tell a remediate-terminal run.
65
+ let lastCommitPhase = null;
64
66
  for (const e of events) {
65
67
  switch (e.event) {
66
68
  case "RUN_START":
@@ -75,6 +77,7 @@ export function deriveState(events) {
75
77
  // remediate instead of defaulting to a "merged" close.
76
78
  if (e.terminalReason) s.terminalReason = e.terminalReason;
77
79
  lastCommitReason = e.terminalReason ?? null;
80
+ lastCommitPhase = e.phase ?? null;
78
81
  // forge#3530: the review's remediation kind must survive resume. A later review
79
82
  // commit replaces it; a committed remediate consumes it, so it is never reused.
80
83
  if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
@@ -91,6 +94,12 @@ export function deriveState(events) {
91
94
  // retry re-enters remediate/decompose instead of close/build.
92
95
  case "RUN_REOPEN":
93
96
  s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
97
+ // forge#3754: a remediate that ended needs-human is re-run after a human clears the
98
+ // label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
99
+ // does not re-terminate and remediate's entryCondition re-selects it.
100
+ if (lastCommitPhase === "remediate" && lastCommitReason === "needs-human") {
101
+ s.committed = s.committed.filter((p) => p !== "remediate");
102
+ }
94
103
  break;
95
104
  // forge#3562: an explicit `run-issue --retry` on a `phase-complete` run
96
105
  // (multi-phase issue: this phase merged, more remain) starts a NEW cycle.
@@ -102,7 +111,7 @@ export function deriveState(events) {
102
111
  case "RUN_NEXT_CYCLE":
103
112
  s.terminal = false; s.terminalReason = null; s.committed = [];
104
113
  s.branch = null; s.pr = null; s.remediationKind = null;
105
- lastCommitReason = null; s.v = e.seq;
114
+ lastCommitReason = null; lastCommitPhase = null; s.v = e.seq;
106
115
  break;
107
116
  // forge#2524: a session-limit pause is purely informational — it does
108
117
  // NOT touch committed/terminal/terminalReason. The phase that hit the
package/bin/engine.mjs CHANGED
@@ -514,6 +514,8 @@ export async function runIssue(opts) {
514
514
  // must honour that reason instead of picking the next phase (close would throw in buildArgs
515
515
  // and end as engine-error; build would run on a decomposed parent). Review's `needs-human`
516
516
  // is a handoff to remediate, so only these committed phases trigger the guard.
517
+ // forge#3754: a reopened (--retry) remediate-terminal run has `remediate` un-committed by the
518
+ // run-log reducer, so this guard does not fire and remediate re-runs.
517
519
  if (state.terminalReason && TERMINAL_REASONS.includes(state.terminalReason) &&
518
520
  ((state.committed.includes("remediate") && !["merged", "decomposed", "invalid"].includes(state.terminalReason)) ||
519
521
  (state.committed.includes("decompose") && state.terminalReason === "decomposed") ||
@@ -52,6 +52,16 @@ Commands are classified by role. Each role has a max line count — beyond this,
52
52
 
53
53
  ---
54
54
 
55
+ ## CI Spec-Size Budget
56
+
57
+ `scripts/check-spec-size.sh` (job `spec-size-budget` in `check-commands.yml`, PRs only) enforces a budget that is independent of the line-count `.baselines`; the baseline is refreshed on release via `/forge-stats baseline`. It compares bytes against the PR base:
58
+
59
+ - `review-pr.md`, `review-pr-staging.md`, `work-on/remediate.md`, `work-on/review.md`, `work-on.md` may not grow by more than 2% or 4 KB. Shrinking always passes.
60
+ - No `commands/**` file may gain a copy of a canonical block (FORGE_ROOT bootstrap, TRUSTED_SCRIPT/HELPER_SCRIPT resolver, FENCE_BOOTSTRAP anchor); the per-file marker count may not rise. Tighten to an outright ban once the shared helper (#3493) lands.
61
+ - Exemption: the `spec-size-exempt` PR label, or a `<!-- spec-size-exempt: <reason> -->` line in the PR body (non-empty reason). The job summary lists the per-file delta and any waiver.
62
+
63
+ ---
64
+
55
65
  ## Mode: `diff` (default)
56
66
 
57
67
  Compare current line counts against the last git commit. Shows what grew and what shrank.
@@ -1190,7 +1190,11 @@ if [ -n "$REVIEW_SHA_FULL" ] && [ -n "$TRUSTED_SCRIPT" ]; then
1190
1190
  | jq -r 'scan("^<!-- FORGE:REVIEW-AGENT:([a-z0-9-]+) -->") | .[0]' 2>/dev/null | sort -u | grep -c . || echo 0)
1191
1191
  fi
1192
1192
  case "$ACTUAL_AGENT_COUNT" in ''|*[!0-9]*) ACTUAL_AGENT_COUNT=0 ;; esac # fail closed: unreadable or untrusted counts as not posted
1193
- if [ "$DISPATCH_FAILED" = "true" ] || [ "$ACTUAL_AGENT_COUNT" -lt "$SELECTED_AGENT_COUNT" ]; then
1193
+ # 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).
1194
+ [ -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 .); }
1195
+ SELECTED_COUNT_INVALID=false
1196
+ case "${SELECTED_AGENT_COUNT:-}" in ''|*[!0-9]*|0) SELECTED_COUNT_INVALID=true; SELECTED_AGENT_COUNT=0 ;; esac
1197
+ if [ "${DISPATCH_FAILED:-}" = "true" ] || [ "$SELECTED_COUNT_INVALID" = "true" ] || [ "$ACTUAL_AGENT_COUNT" -lt "$SELECTED_AGENT_COUNT" ]; then
1194
1198
  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
1195
1199
  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
1196
1200
  gh pr comment "$PR_NUMBER" -R "$GH_REPO" --body "<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->
@@ -1222,6 +1226,12 @@ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}" # FENCE_BOOTSTRAP
1222
1226
  [ -n "${PR_NUMBER:-}" ] || echo "WARNING: PR_NUMBER is empty after reload (no staging_pr_number in $_SD): this fence cannot key its scratch files or post to a PR." # FENCE_BOOTSTRAP
1223
1227
  # Reload the launched-agent list persisted at dispatch (validated: lowercase marker domains only).
1224
1228
  [ -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' ' '); }
1229
+ # 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".
1230
+ if [ -z "${PR_NUMBER:-}" ] || [ -z "${LAUNCHED_REVIEW_AGENTS:-}" ]; then
1231
+ echo "REVIEW DELIVERY FAILURE: launched-agent list is empty or unreadable (PR_NUMBER='${PR_NUMBER:-}')"
1232
+ echo "Recover the launched reviewer domains from the PR's FORGE:REVIEW-AGENT comments, re-append them to the launched-agent file, and rerun."
1233
+ exit 1
1234
+ fi
1225
1235
  MISSING_AGENT_COMMENTS=""
1226
1236
  # 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
1227
1237
  # (an empty SHA would make the Reviewed-SHA match accept any head).
@@ -1256,7 +1266,7 @@ if [ -n "$MISSING_AGENT_COMMENTS" ]; then
1256
1266
  fi
1257
1267
  ```
1258
1268
 
1259
- Missing comments are an explicit failure, never evidence of no findings. Do not triage or synthesize a deploy verdict while any launched agent is unaccounted for.
1269
+ An empty or unreadable launched-agent list is also a delivery failure (the fence exits 1), never evidence that nothing needed verifying; recover the list from the PR comments and rerun. Missing comments are an explicit failure, never evidence of no findings. Do not triage or synthesize a deploy verdict while any launched agent is unaccounted for.
1260
1270
 
1261
1271
  ### 7A: Extract Findings
1262
1272
  Only findings inside trusted, current-head reviewer comments count: the head SHA is public, so an untrusted commenter must never be able to inject `FINDING` markers that get filed as issues. Read every page of comments, fail closed when the trust script or comments are unreadable (never fall through to "no findings").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # check-spec-size.sh — PR spec-size budget (forge#3762).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Usage: check-spec-size.sh [base_ref] (default: $BASE_REF, else origin/staging)
7
+ # Env: PR_LABELS comma-separated PR label names (untrusted; grep-matched only)
8
+ # PR_BODY PR body text (untrusted; grep-matched only)
9
+ # GITHUB_STEP_SUMMARY summary file (stdout when unset)
10
+ # Exit: 0 pass/exempt, 1 budget violation, 2 usage/base-ref error (fail closed)
11
+ #
12
+ # Rules (against the PR base, compared in bytes):
13
+ # 1. Budgeted specs may not grow by more than 2% or more than 4096 bytes.
14
+ # 2. No commands/**/*.md file may gain a canonical block copy (per-file marker
15
+ # count may not rise). Count-based until the shared helper lands (#3493).
16
+ # Shrinking or equal size always passes.
17
+ # Exemption: `spec-size-exempt` label, or a `<!-- spec-size-exempt: <reason> -->`
18
+ # line with a non-empty reason in the PR body. Failures are reported, exit 0.
19
+ # Bash 3.2 portable: no assoc arrays, mapfile, grep -P.
20
+ set -uo pipefail
21
+
22
+ BASE="${1:-${BASE_REF:-origin/staging}}"
23
+ PCT_NUM=2 # 2%
24
+ ABS_MAX=4096 # bytes
25
+ BUDGETED="commands/review-pr.md commands/review-pr-staging.md commands/work-on/remediate.md commands/work-on/review.md commands/work-on.md"
26
+
27
+ SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}"
28
+ out() { printf '%s\n' "$*" >> "$SUMMARY"; }
29
+
30
+ if ! git rev-parse --verify -q "$BASE^{commit}" >/dev/null; then
31
+ echo "ERROR: base ref '$BASE' cannot be resolved (shallow checkout? need fetch-depth: 0)" >&2
32
+ exit 2
33
+ fi
34
+
35
+ # --- exemption -------------------------------------------------------------
36
+ EXEMPT_REASON=""
37
+ if printf '%s' "${PR_LABELS:-}" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -qxF 'spec-size-exempt'; then
38
+ EXEMPT_REASON="label spec-size-exempt"
39
+ else
40
+ R=$(printf '%s\n' "${PR_BODY:-}" | tr -d '\r' \
41
+ | sed -n 's/^[[:space:]]*<!--[[:space:]]*spec-size-exempt:[[:space:]]*\(.*\)-->[[:space:]]*$/\1/p' \
42
+ | head -n 1 | sed 's/[[:space:]]*$//')
43
+ [ -n "$R" ] && EXEMPT_REASON="PR body marker: $R"
44
+ fi
45
+
46
+ FAILURES=0
47
+ base_size() { git cat-file -s "$BASE:$1" 2>/dev/null || echo 0; }
48
+ MARKERS='FORGE_ROOT bootstrap \(canonical|TRUSTED_SCRIPT=""|HELPER_SCRIPT=""|mkdir -p "\$_SD".*# FENCE_BOOTSTRAP'
49
+
50
+ out "## Spec-size budget (base: \`$BASE\`)"
51
+ out ""
52
+ out "| File | Base | Head | Delta | Delta % | Status |"
53
+ out "|------|-----:|-----:|------:|--------:|--------|"
54
+ for f in $BUDGETED; do
55
+ [ -f "$f" ] || [ "$(base_size "$f")" != 0 ] || continue
56
+ b=$(base_size "$f")
57
+ h=0; [ -f "$f" ] && h=$(wc -c < "$f" | tr -d ' ')
58
+ g=$((h - b))
59
+ status="ok"
60
+ if [ "$g" -gt 0 ] && { [ $((g * 100)) -gt $((b * PCT_NUM)) ] || [ "$g" -gt "$ABS_MAX" ]; }; then
61
+ status="OVER BUDGET"; FAILURES=$((FAILURES + 1))
62
+ fi
63
+ if [ "$b" -gt 0 ]; then
64
+ p=$(awk -v g="$g" -v b="$b" 'BEGIN{printf "%+.2f%%", g*100/b}')
65
+ else p="n/a"; fi
66
+ out "| \`$f\` | $b | $h | $g | $p | $status |"
67
+ done
68
+
69
+ DUP_ROWS=""
70
+ # Every commands/**/*.md present at head (new files have base count 0).
71
+ for f in $(git ls-files 'commands/*.md' 'commands/**/*.md' | sort -u); do
72
+ [ -f "$f" ] || continue
73
+ hc=$(grep -cE "$MARKERS" "$f" 2>/dev/null || true); hc=${hc:-0}
74
+ bc=$(git show "$BASE:$f" 2>/dev/null | grep -cE "$MARKERS" 2>/dev/null || true); bc=${bc:-0}
75
+ if [ "$hc" -gt "$bc" ]; then
76
+ DUP_ROWS="$DUP_ROWS| \`$f\` | $bc | $hc | NEW CANONICAL BLOCK COPY |
77
+ "
78
+ FAILURES=$((FAILURES + 1))
79
+ fi
80
+ done
81
+ if [ -n "$DUP_ROWS" ]; then
82
+ out ""
83
+ out "### Canonical block copies added (source the shared helper instead)"
84
+ out ""
85
+ out "| File | Base markers | Head markers | Status |"
86
+ out "|------|-----:|-----:|--------|"
87
+ printf '%s' "$DUP_ROWS" >> "$SUMMARY"
88
+ fi
89
+
90
+ out ""
91
+ if [ "$FAILURES" -eq 0 ]; then
92
+ out "Result: PASS"
93
+ exit 0
94
+ fi
95
+ if [ -n "$EXEMPT_REASON" ]; then
96
+ out "Result: EXEMPT ($FAILURES violation(s) waived: $EXEMPT_REASON)"
97
+ echo "spec-size: $FAILURES violation(s) waived: $EXEMPT_REASON"
98
+ exit 0
99
+ fi
100
+ out "Result: FAIL ($FAILURES violation(s)). Shrink the spec, or add the spec-size-exempt label / a \`<!-- spec-size-exempt: <reason> -->\` line to the PR body."
101
+ echo "spec-size: FAIL ($FAILURES violation(s)); see job summary" >&2
102
+ exit 1
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # check-spec-size.test.sh — cases for scripts/check-spec-size.sh (forge#3762).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-spec-size.sh"
7
+ PASS=0; FAILN=0
8
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
9
+ ok() { PASS=$((PASS+1)); }
10
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
11
+
12
+ git init -q --bare "$TMP/origin.git"
13
+ git clone -q "$TMP/origin.git" "$TMP/w" 2>/dev/null
14
+ cd "$TMP/w" || exit 1
15
+ git config user.email t@example.com; git config user.name t
16
+ mkdir -p commands/work-on
17
+ gen() { awk -v n="$1" 'BEGIN{for(i=0;i<n;i++) print "line of spec text number " i}'; }
18
+ gen 4000 > commands/review-pr.md # ~110 KB: 2% ~ 2.2 KB < 4 KB cap
19
+ gen 20000 > commands/work-on.md # ~560 KB: 2% ~ 11 KB, so the 4 KB cap binds
20
+ gen 100 > commands/other.md
21
+ gen 100 > commands/work-on/remediate.md
22
+ git add -A; git commit -qm base; git branch -M staging; git push -q origin staging 2>/dev/null
23
+ git checkout -q -b feat
24
+
25
+ reset() { git reset -q --hard origin/staging; git clean -qfd; }
26
+ run() { # name want_rc [env...]
27
+ local name="$1" want="$2"; shift 2
28
+ local sum="$TMP/summary"; : > "$sum"
29
+ env GITHUB_STEP_SUMMARY="$sum" PR_LABELS="" PR_BODY="" "$@" bash "$S" origin/staging >"$TMP/out" 2>"$TMP/err"
30
+ local rc=$?
31
+ if [ "$rc" = "$want" ]; then ok; else bad "$name (rc=$rc want $want)"; cat "$TMP/err"; fi
32
+ }
33
+ has() { grep -qF "$2" "$TMP/summary" && ok || bad "$1 (summary lacks: $2)"; }
34
+
35
+ reset; run "no change passes" 0
36
+ has "summary shows delta table" "| File | Base | Head | Delta |"
37
+
38
+ reset; gen 100 >> commands/review-pr.md # +~2.8 KB on ~110 KB = ~2.5% (>2%, <4 KB)
39
+ run "over 2% fails" 1; has "over-budget flagged" "OVER BUDGET"
40
+
41
+ reset; gen 200 >> commands/work-on.md # +~5.6 KB (<2% of 560 KB, >4 KB)
42
+ run "over 4 KB fails" 1
43
+
44
+ reset; gen 5 >> commands/review-pr.md # small growth under both limits
45
+ run "small growth passes" 0
46
+
47
+ reset; gen 5000 >> commands/other.md # not a budgeted spec
48
+ run "non-budgeted growth passes" 0
49
+
50
+ reset; gen 100 > commands/review-pr.md # shrink
51
+ run "shrink passes" 0
52
+
53
+ reset; printf 'TRUSTED_SCRIPT=""\n' >> commands/other.md
54
+ run "new TRUSTED_SCRIPT copy fails" 1; has "dup flagged" "NEW CANONICAL BLOCK COPY"
55
+
56
+ reset; printf '_SD=x; mkdir -p "$_SD" # FENCE_BOOTSTRAP\n' >> commands/other.md
57
+ run "new FENCE_BOOTSTRAP anchor fails" 1
58
+
59
+ reset; printf 'echo hi # FENCE_BOOTSTRAP\n' >> commands/other.md
60
+ run "non-anchor FENCE_BOOTSTRAP tag passes" 0
61
+
62
+ reset; printf '# FORGE_ROOT bootstrap (canonical)\n' > commands/new-spec.md; git add -A
63
+ run "new spec file with block fails" 1
64
+
65
+ # Existing block, count unchanged or reduced, passes
66
+ reset; printf 'HELPER_SCRIPT=""\n' >> commands/other.md; git commit -qam x; git push -q origin HEAD:staging 2>/dev/null
67
+ git fetch -q origin staging
68
+ run "unchanged count passes" 0
69
+ : > commands/other.md; git add -A
70
+ run "reduced count passes" 0
71
+
72
+ # Exemption
73
+ reset; gen 100 >> commands/review-pr.md
74
+ run "exempt by label" 0 PR_LABELS="bug,spec-size-exempt"; has "label reported" "EXEMPT"
75
+ run "exempt by body marker" 0 PR_BODY=$'text\n<!-- spec-size-exempt: security fix -->\nmore'; has "reason reported" "security fix"
76
+ run "empty-reason marker not exempt" 1 PR_BODY='<!-- spec-size-exempt: -->'
77
+ run "label substring not exempt" 1 PR_LABELS="spec-size-exempt-not"
78
+
79
+ # Fail closed on unresolvable base
80
+ : > "$TMP/summary"
81
+ GITHUB_STEP_SUMMARY="$TMP/summary" bash "$S" origin/does-not-exist >/dev/null 2>&1; rc=$?
82
+ [ "$rc" = 2 ] && ok || bad "unresolved base fails closed (rc=$rc)"
83
+
84
+ echo "check-spec-size.test: pass=$PASS fail=$FAILN"
85
+ [ "$FAILN" -eq 0 ]
@@ -113,6 +113,34 @@ grep -q 'test_gate_verdict' "$SPEC" && grep -q 'launched_review_agents' "$SPEC"
113
113
  if grep -qE '\$_SD/(test_gate_verdict|test_gate_reason|launched_review_agents)|\.forge-scratch\}/launched_review_agents' "$SPEC"; then bad "gate/launched-agent scratch files must be keyed by \${PR_NUMBER}_"; else ok; fi
114
114
  grep -qF ': > "$_SD/${PR_NUMBER}_launched_review_agents"' "$SPEC" && grep -qF 'rm -f "$_SD/${PR_NUMBER}_test_gate_verdict" "$_SD/${PR_NUMBER}_test_gate_reason"' "$SPEC" && ok || bad "Phase 2 must truncate and Phase 6.5 must rm -f the PR-keyed scratch files in executable commands"
115
115
  if awk '/^### 7E:/,/^### 7F:/' "$SPEC" | grep -qF '{GH_REPO}'; then bad "7E must not use the {GH_REPO} template token"; else ok; fi
116
+ # (8b) forge#3733: Phase 6.75 fails closed on an empty/absent launched-agent list or empty PR_NUMBER; Phase 6 fails closed on a bad SELECTED_AGENT_COUNT
117
+ F675=$(awk '/^### Phase 6.75/,/^### 7A/' "$SPEC" | awk '/^```bash/{f=1;next} /^```/{if(f)exit} f')
118
+ GUARD_LN=$(printf '%s\n' "$F675" | grep -n 'launched-agent list is empty or unreadable' | head -1 | cut -d: -f1)
119
+ LOOP_LN=$(printf '%s\n' "$F675" | grep -n '^for AGENT_DOMAIN in' | head -1 | cut -d: -f1)
120
+ [ -n "$GUARD_LN" ] && [ -n "$LOOP_LN" ] && [ "$GUARD_LN" -lt "$LOOP_LN" ] && ok || bad "6.75 empty-list guard must precede the for AGENT_DOMAIN loop"
121
+ printf '%s\n' "$F675" | sed -n "1,$((LOOP_LN-1))p" | grep -v '^[[:space:]]*gh ' > "$TMP/f675.sh"
122
+ mkdir -p "$TMP/sd9/empty" "$TMP/sd9/full" "$TMP/bin9"
123
+ 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
+ : > "$TMP/sd9/empty/42_launched_review_agents"
125
+ 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=$?" ); }
127
+ for _case in "$TMP/sd9/empty|42" "$TMP/sd9/none|42" "$TMP/sd9/full|"; do
128
+ OUT9=$(run675 "${_case%%|*}" "${_case##*|}")
129
+ 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
130
+ done
131
+ OUT9=$(run675 "$TMP/sd9/full" 42)
132
+ case "$OUT9" in *"launched-agent list is empty"*) bad "6.75 guard must not fire for a non-empty list (got '$OUT9')" ;; *) ok ;; esac
133
+ # Phase 6 SELECTED_AGENT_COUNT validation: static order + behaviour of the reload/validate lines
134
+ F6=$(awk '/Selected isolated reviewers/{exit} {print}' "$SPEC" | awk '/^```bash/{b=""; f=1; next} /^```/{f=0; next} f{b=b $0 "\n"; last=b} END{printf "%s", last}')
135
+ printf '%s' "$F6" | grep -qF 'SELECTED_COUNT_INVALID' && ok || bad "Phase 6 fence must validate SELECTED_AGENT_COUNT (SELECTED_COUNT_INVALID)"
136
+ printf '%s' "$F6" | grep -F 'SELECTED_AGENT_COUNT=' | grep -F 'launched_review_agents' > "$TMP/sel.sh"
137
+ printf '%s\n' 'SELECTED_COUNT_INVALID=false' "$(printf '%s' "$F6" | grep -F 'case "${SELECTED_AGENT_COUNT:-}"')" > "$TMP/selcase.sh"
138
+ for _c in "$TMP/sd9/empty|42" "$TMP/sd9/none|42" "$TMP/sd9/full|42"; do
139
+ OUT9=$( ( unset SELECTED_AGENT_COUNT; _SD="${_c%%|*}"; PR_NUMBER="${_c##*|}"; . "$TMP/sel.sh"; . "$TMP/selcase.sh"; echo "$SELECTED_COUNT_INVALID|$SELECTED_AGENT_COUNT" ) 2>&1 )
140
+ case "${_c%%|*}" in */full) [ "$OUT9" = "false|1" ] && ok || bad "Phase 6: one launched agent must give SELECTED_AGENT_COUNT=1 (got '$OUT9')" ;; *) [ "$OUT9" = "true|0" ] && ok || bad "Phase 6: empty/absent list must be invalid (got '$OUT9')" ;; esac
141
+ done
142
+ OUT9=$( ( SELECTED_AGENT_COUNT="abc"; . "$TMP/selcase.sh"; echo "$SELECTED_COUNT_INVALID" ) 2>&1 )
143
+ [ "$OUT9" = "true" ] && ok || bad "Phase 6: non-integer SELECTED_AGENT_COUNT must be invalid (got '$OUT9')"
116
144
  mkdir -p "$TMP/sd8/full" "$TMP/sd8/empty" "$TMP/sd8/bad" "$TMP/bin"
117
145
  printf '#!/bin/sh\necho stub-value\n' > "$TMP/bin/yq"; chmod +x "$TMP/bin/yq"
118
146
  printf '42\n' > "$TMP/sd8/full/staging_pr_number"; printf '7\n8\n' > "$TMP/sd8/full/42_bundle-prs.txt"