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
@@ -519,26 +519,9 @@ The architect records the caller and sibling sweep as a machine-readable manifes
519
519
 
520
520
  ```bash
521
521
  cd {WORKTREE_PATH}
522
- # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
523
- FORGE_ROOT=""
524
- # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
525
- _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
526
- # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
527
- _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
528
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
529
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
530
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
531
- # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
532
- _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
533
- # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
534
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
535
- _m="$HOME/.claude/plugins/marketplaces/$_mk"
536
- # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
537
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
538
- while IFS= read -r _c; do
539
- case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
540
- done <<< "$_k"
541
- fi
522
+ # FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
523
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
524
+ FORGE_ROOT="$(forge_root 2>/dev/null || true)"
542
525
  BR_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-blast-radius.sh}"; [ -f "$BR_SCRIPT" ] || BR_SCRIPT=""
543
526
  TRUST_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/trusted-comments.sh}"; [ -f "$TRUST_SCRIPT" ] || TRUST_SCRIPT=""
544
527
  BR_STATE=""; BR_NOTE=""; BR_FILE=$(mktemp "${TMPDIR:-/tmp}/forge-blast-radius.XXXXXX")
@@ -649,26 +632,9 @@ cd {WORKTREE_PATH}
649
632
  if git ls-remote --exit-code origin {PR_BASE} >/dev/null 2>&1; then
650
633
  git fetch origin {PR_BASE} >/dev/null 2>&1 || true
651
634
  # Same install-root-only resolution as work-on/review.md R1 (resolve_script 'check-branch-ancestry'): the script gates this branch's own push, so it is NEVER taken from the worktree (the PR branch under audit) or a cwd-relative path. Unresolvable -> prose fallback below, which fails closed.
652
- # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
653
- FORGE_ROOT=""
654
- # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
655
- _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
656
- # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
657
- _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
658
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
659
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
660
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
661
- # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
662
- _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
663
- # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
664
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
665
- _m="$HOME/.claude/plugins/marketplaces/$_mk"
666
- # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
667
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
668
- while IFS= read -r _c; do
669
- case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
670
- done <<< "$_k"
671
- fi
635
+ # FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
636
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
637
+ FORGE_ROOT="$(forge_root 2>/dev/null || true)"
672
638
  ANCESTRY_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-branch-ancestry.sh}"; [ -f "$ANCESTRY_SCRIPT" ] || ANCESTRY_SCRIPT=""
673
639
  if [ -n "$ANCESTRY_SCRIPT" ]; then
674
640
  MERGE_COMMITS=$(bash "$ANCESTRY_SCRIPT" HEAD origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
@@ -65,31 +65,14 @@ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
65
65
  echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
66
66
  ADAPTIVE_ENABLED=false
67
67
  fi
68
- # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the canonical bootstrap
68
+ # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the shared forge_resolve stub
69
69
  # below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
70
70
  # repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
71
- # Resolution: $FORGEDOCK_HOME (authoritative when set) > $FORGE_HOME > $CLAUDE_PLUGIN_ROOT > the
71
+ # Resolution: $FORGEDOCK_HOME (authoritative when set) > the running plugin root (FORGE_SOURCED_ROOT) > $FORGE_HOME > the
72
72
  # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
73
- # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
74
- FORGE_ROOT=""
75
- # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
76
- _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
77
- # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
78
- _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
79
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
80
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
81
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
82
- # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
83
- _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
84
- # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
85
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
86
- _m="$HOME/.claude/plugins/marketplaces/$_mk"
87
- # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
88
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
89
- while IFS= read -r _c; do
90
- case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
91
- done <<< "$_k"
92
- fi
73
+ # FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
74
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
75
+ FORGE_ROOT="$(forge_root 2>/dev/null || true)"
93
76
  UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
94
77
  # NOTE: never resolve this via `which` or `find` — universal scripts are
95
78
  # install-relative, not installed on $PATH, so a PATH lookup always misses.
@@ -159,8 +142,8 @@ Re-read current state before doing anything:
159
142
  gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state,milestone
160
143
 
161
144
  # Check investigation report
162
- gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate \
163
- --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body'
145
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh) # script resolution block verbatim; unresolved/failed read = fail closed
146
+ gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INVESTIGATOR -->'
164
147
 
165
148
  # Check if build already completed (require FORGE:BUILDER:COMPLETE — not just FORGE:BUILDER)
166
149
  gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate \
@@ -176,7 +159,7 @@ EXISTING_FAST_PATH=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate
176
159
  - Lookup failed (`trusted-comments.sh` unresolved or `gh` error) → Blocked exit with blocker `gate-lookup-unavailable: could not read FORGE:ACCEPTANCE_GATE` (a transient GitHub failure uses the `github-unavailable:` prefix). Never read a failed lookup as "no gate marker".
177
160
  - Newest is `FORGE:ACCEPTANCE_GATE:FAILED`, `FORGE:ACCEPTANCE_GATE:BLOCKED`, or a `FORGE:BUILD_BLOCKED` comment → the gate is blocked and the engine fails closed on it, so returning ALREADY_DONE would deadlock every re-run. Derive BRANCH/WORKTREE_PATH/PR_BASE as in B1A/B1C (without creating anything), verify the branch and worktree exist and that `git -C "$WORKTREE_PATH" branch --show-current` equals BRANCH (missing or mismatched → Blocked exit `worktree-missing: cannot re-enter B6.5`; never recreate silently), recover `COMPLEXITY_BAND` from `EXISTING_FAST_PATH` with the B0.5 resume extraction, then skip B0.5-B6 and re-enter at **Phase B6.5**, which posts a fresh PASSED/FAILED/BLOCKED verdict (the one-repair `FORGE:ACCEPTANCE_REPAIR` bound still applies).
178
161
  - Newest is `FORGE:ACCEPTANCE_GATE:PASSED`, or no gate marker exists → build already complete. Derive BRANCH/WORKTREE_PATH as in B1A/B1C (without creating anything), then print `BUILD_RESULT: status: ALREADY_DONE`.
179
- - **Size-gate resume** (checked BEFORE the partial-BUILDER rule below; first match wins) <!-- Added: forge#3463 -->: if a `<!-- FORGE:BUILDER -->` comment exists without `<!-- FORGE:BUILDER:COMPLETE -->` AND the latest trusted `FORGE:DIFF_SIZE` comment has `result: OVER`, the build stopped at a deliberate B5.5 exit (`NEEDS_DECOMPOSE` or the decompose-loop-guard Blocked exit), not in a crash. Keep the BUILDER comment, do not re-run B2-B5, and re-enter at **Phase B5.5**. Read the marker through the same anchored, trust-filtered lookup B5.5 uses (script resolution block verbatim):
162
+ - **Size-gate resume** (checked BEFORE the partial-BUILDER rule below; first match wins) <!-- Added: forge#3463 -->: if a `<!-- FORGE:BUILDER -->` comment exists without `<!-- FORGE:BUILDER:COMPLETE -->` AND the latest trusted `FORGE:DIFF_SIZE` comment has `result: OVER` or `result: COHESIVE`, the build stopped at a deliberate B5.5 exit (`NEEDS_DECOMPOSE`, the decompose-loop-guard Blocked exit, or a crash right after the `COHESIVE` comment was posted), not in an implement crash. Keep the BUILDER comment, do not re-run B2-B5, and re-enter at **Phase B5.5**. Read the marker through the same anchored, trust-filtered lookup B5.5 uses (script resolution block verbatim):
180
163
  ```bash
181
164
  # <Script resolution block from above goes here, verbatim>
182
165
  set -o pipefail
@@ -188,7 +171,7 @@ EXISTING_FAST_PATH=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate
188
171
  echo "DIFF_SIZE_RESULT=${LAST_RESULT:-<none>}"
189
172
  ```
190
173
  - `DIFF_SIZE_LOOKUP=FAILED` → Blocked exit with blocker `size-gate-unavailable: could not read FORGE:DIFF_SIZE` (a transient GitHub failure uses the `github-unavailable:` prefix). Never read a failed lookup as "no DIFF_SIZE".
191
- - `DIFF_SIZE_RESULT=OVER` → verify, without creating anything, that the branch and worktree exist (derive BRANCH/WORKTREE_PATH as in B1A/B1C), that `git -C "$WORKTREE_PATH" branch --show-current` equals BRANCH, and that a staged diff is present (`! git -C "$WORKTREE_PATH" diff --cached --quiet`). Recover `COMPLEXITY_BAND` from `EXISTING_FAST_PATH` with the B0.5 resume extraction. When all hold, skip B0.5-B5 and continue at B5.5 (it re-measures, then applies the override / `NEEDS_DECOMPOSE` / `OK` logic).
174
+ - `DIFF_SIZE_RESULT=OVER` or `DIFF_SIZE_RESULT=COHESIVE` → verify, without creating anything, that the branch and worktree exist (derive BRANCH/WORKTREE_PATH as in B1A/B1C), that `git -C "$WORKTREE_PATH" branch --show-current` equals BRANCH, and that a staged diff is present (`! git -C "$WORKTREE_PATH" diff --cached --quiet`). Recover `COMPLEXITY_BAND` from `EXISTING_FAST_PATH` with the B0.5 resume extraction. When all hold, skip B0.5-B5 and continue at B5.5 (it re-measures, then applies the override / `COHESIVE` / `NEEDS_DECOMPOSE` / `OK` logic; never re-run implement).
192
175
  - Anything else (no `FORGE:DIFF_SIZE`, `result` is not `OVER`, worktree or branch missing or mismatched, nothing staged, band unrecoverable) → never skip ahead on missing state; fall through to the next rule.
193
176
  - If `<!-- FORGE:BUILDER -->` exists BUT `<!-- FORGE:BUILDER:COMPLETE -->` is ABSENT → build was interrupted after the comment was posted but before the commit (validate V5). Delete the partial comment and restart from Phase B2 (contract): <!-- Added: forge#1305 -->
194
177
  ```bash
@@ -650,7 +633,7 @@ esac
650
633
  - Prose tier (script unresolvable) → skip the gate, continue to B6 (the script is part of the install; a missing install must not strand every build).
651
634
  - Exit 2, empty output, or output without `diff_lines=`, `threshold=` and `over=`, AND `FETCH_OK=0` (the base fetch failed after the 10s/30s/60s retries, worst case about 100s) → Blocked exit with blocker `github-unavailable: could not fetch origin/{PR_BASE} for diff-size measurement` (transient; the router retry rule applies).
652
635
  - The same unmeasurable result with `FETCH_OK=1` (the fetch succeeded, so the script itself failed) → Blocked exit with blocker `size-gate-unavailable: diff-size.sh failed (rc=<N>)`. Either way, do NOT treat an unmeasurable diff as under threshold.
653
- - Otherwise parse `diff_lines`, `excluded_lines`, `threshold`, `over` and the `top=` lines.
636
+ - Otherwise parse `diff_lines`, `excluded_lines`, `threshold`, `over`, the cohesion keys `new_lines`, `test_lines`, `cohesive_lines`, `cohesive` and the `top=` lines. Fail closed on cohesion: a missing or unparseable `cohesive=` (an older script) reads as `cohesive=false`, never as true.
654
637
 
655
638
  **Step 2 — look up an override** (only when `over=true`). Both lookups are anchored, trust-filtered through `scripts/trusted-comments.sh` (trusted association, Bot, or `FORGE_TRAIL_TRUSTED_LOGINS`), and fail closed:
656
639
 
@@ -669,7 +652,7 @@ if [ -n "$JUSTIFICATION" ]; then echo "OVERRIDE=VALID"; else echo "OVERRIDE=NONE
669
652
  - `OVERRIDE_LOOKUP=FAILED` → Blocked exit with blocker `size-gate-unavailable: could not read FORGE:SIZE_OVERRIDE` (a transient GitHub failure uses the `github-unavailable:` prefix per the retry rule above). Never read a failed lookup as "no override".
670
653
  - `OVERRIDE=VALID` → result `OVERRIDDEN`. A marker with an empty justification is not valid.
671
654
 
672
- **Step 3 — record.** Set `DIFF_LINES`, `EXCLUDED_LINES`, `THRESHOLD`, `RESULT` (`OK`, `OVERRIDDEN` or `OVER`) and `SPLIT_PROPOSAL` from the steps above, then post one `<!-- FORGE:DIFF_SIZE -->` comment, idempotently: find the latest trusted comment starting with the marker and edit it, otherwise create it (on resume never stack duplicates; readers use the latest trusted one):
655
+ **Step 3 — record.** Set `DIFF_LINES`, `EXCLUDED_LINES`, `THRESHOLD`, `RESULT` (`OK`, `OVERRIDDEN`, `COHESIVE` or `OVER`), `SPLIT_PROPOSAL` and `COHESION_NOTE` (set only for `COHESIVE`) from the steps above, then post one `<!-- FORGE:DIFF_SIZE -->` comment, idempotently: find the latest trusted comment starting with the marker and edit it, otherwise create it (on resume never stack duplicates; readers use the latest trusted one):
673
656
 
674
657
  ```bash
675
658
  BODY="<!-- FORGE:DIFF_SIZE -->
@@ -679,23 +662,26 @@ diff_lines: ${DIFF_LINES}
679
662
  excluded_lines: ${EXCLUDED_LINES}
680
663
  threshold: ${THRESHOLD}
681
664
  result: ${RESULT}
682
- ${SPLIT_PROPOSAL:-}"
665
+ ${ESTIMATE_LINE:-}${COHESION_NOTE:-}${SPLIT_PROPOSAL:-}"
683
666
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
684
667
  CID=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" --jq '[.[] | select(.body | startswith("<!-- FORGE:DIFF_SIZE")) | select((.author_association | IN("OWNER","MEMBER","COLLABORATOR")) or (.user.type == "Bot"))] | last | .id // ""' 2>/dev/null || true)
685
668
  if [ -n "$CID" ]; then run gh api "repos/{GH_REPO}/issues/comments/${CID}" -X PATCH -f body="$BODY" >/dev/null 2>&1 || run gh issue comment {NUMBER} {GH_FLAG} --body "$BODY"
686
669
  else run gh issue comment {NUMBER} {GH_FLAG} --body "$BODY"; fi
687
670
  ```
688
671
 
672
+ For a sub-issue (see Step 4) record the estimate next to the actual: read `ESTIMATED_DIFF_LINES: N` from the issue body (written by `work-on:decompose` D3) and set `ESTIMATE_LINE` to `estimated_diff_lines: N` plus a newline; leave it empty when the body has no such line (a top-level issue, or one decomposed before this field existed). For `COHESIVE`, set `COHESION_NOTE` to the lines `cohesive_lines: {cohesive_lines} ({pct}% of {diff_lines}; new {new_lines}, test {test_lines})`, `review-note: over the gate but cohesive (new files plus tests dominate and diff is at most 1.5x threshold); reviewers: check the new module's boundary and that the tests exercise it` and `reason: sub-issue over threshold, no seam left to split`, each followed by a newline. A `COHESIVE` comment never contains a `### Split Proposal` heading (the engine routes only `OVER` plus that heading to decompose).
673
+
689
674
  **Step 4 — decide.**
690
675
 
691
676
  | Condition | Result | Action |
692
677
  |-----------|--------|--------|
693
678
  | `over=false` | `OK` | Post/refresh `FORGE:DIFF_SIZE`, continue to B6 |
694
679
  | `over=true`, valid override | `OVERRIDDEN` | Post/refresh `FORGE:DIFF_SIZE` (quote the justification), continue to B6 |
695
- | `over=true`, no override, issue body has a `**Parent**: #N` line OR a trusted `FORGE:DECOMPOSED` comment exists | `OVER` | Decompose-loop guard: do NOT route to decompose. Post/refresh `FORGE:DIFF_SIZE`, then Blocked exit with blocker `diff-size-over-threshold: {diff_lines} > {threshold}; post a FORGE:SIZE_OVERRIDE with a justification to proceed` |
680
+ | `over=true`, no override, issue body has a `**Parent**: #N` line OR a trusted `FORGE:DECOMPOSED` comment exists, AND `cohesive=true` | `COHESIVE` | Cohesive-overage path <!-- Added: forge#3790 -->: a sub-issue that is over the gate because one new module plus its tests dominate the diff (at least 70% of counted lines are new or test files, and `diff_lines` is at most `threshold` x 1.5, both computed by `diff-size.sh`) has no seam left to split and the loop guard would only strand it. Post/refresh `FORGE:DIFF_SIZE` with the cohesion note, do NOT add `needs-human`, continue to B6. Test lines are classified, never discounted. A `FORGE:SIZE_OVERRIDE` still takes precedence (`OVERRIDDEN`) |
681
+ | `over=true`, no override, issue body has a `**Parent**: #N` line OR a trusted `FORGE:DECOMPOSED` comment exists (and not `cohesive=true`) | `OVER` | Decompose-loop guard: do NOT route to decompose. Post/refresh `FORGE:DIFF_SIZE`, then Blocked exit with blocker `diff-size-over-threshold: {diff_lines} > {threshold}; post a FORGE:SIZE_OVERRIDE with a justification to proceed` |
696
682
  | `over=true`, no override, otherwise | `OVER` | Put a `### Split Proposal` in the `FORGE:DIFF_SIZE` comment, then print `BUILD_RESULT: status: NEEDS_DECOMPOSE` (see below) |
697
683
 
698
- **`### Split Proposal`** (written into the `FORGE:DIFF_SIZE` comment): list the top files by changed lines from the `top=` output, then propose 2-4 sub-issues split along directory or phase seams, each independently buildable and no more than about half the threshold, in dependency order, one `- **{title}** — {files} — wip: {WIP_REF}@{WIP_SHA} (git checkout {WIP_SHA} -- {files})` line each (this is the plan `work-on:decompose` consumes in D0/D2). The `wip:` part comes from the persist step below; when persistence fell back to a patch file write `patch: {PATCH_PATH}` instead, and when both failed write `wip: unavailable`. Lines without the suffix stay valid.
684
+ **`### Split Proposal`** (written into the `FORGE:DIFF_SIZE` comment): list the top files by changed lines from the `top=` output, then propose 2-4 sub-issues split along directory or phase seams, each independently buildable and at or under `0.8 x threshold` (code plus tests; a seam that can't fit says so explicitly), in dependency order, one `- **{title}** — {files} — wip: {WIP_REF}@{WIP_SHA} (git checkout {WIP_SHA} -- {files})` line each (this is the plan `work-on:decompose` consumes in D0/D2). The `wip:` part comes from the persist step below; when persistence fell back to a patch file write `patch: {PATCH_PATH}` instead, and when both failed write `wip: unavailable`. Lines without the suffix stay valid.
699
685
 
700
686
  **Persist the staged hunks** (before the `NEEDS_DECOMPOSE` exit; runs once the `FORGE:DIFF_SIZE` result is `OVER` and the decompose-loop guard did not fire) <!-- Added: forge#3573 -->: commit the staged index to a standalone commit object WITHOUT moving the build branch or the index, and push it to a dedicated ref named only from the issue number (never interpolate a title or file name into a ref). Re-running overwrites the same ref, so resume is idempotent. Never block `NEEDS_DECOMPOSE` on a persistence failure.
701
687
 
@@ -724,8 +710,8 @@ BUILD_RESULT:
724
710
  blocker: diff-size-over-threshold: {diff_lines} > {threshold}; split proposal posted in FORGE:DIFF_SIZE
725
711
  ```
726
712
 
727
- **Resume**: a re-invoked build re-enters here via the B0 size-gate resume branch (partial `FORGE:BUILDER` plus latest trusted `FORGE:DIFF_SIZE` `result: OVER` plus a staged diff in the kept worktree), so it does not re-run implement; this covers both exits that leave BUILDER incomplete, `NEEDS_DECOMPOSE` and the decompose-loop-guard Blocked exit. B5.5 re-measures: if still over with no override it re-pushes the same `forge/wip/{NUMBER}` ref (idempotent) and re-emits `NEEDS_DECOMPOSE` without a duplicate comment (the loop-guard case re-emits the same Blocked exit); when a valid `FORGE:SIZE_OVERRIDE` has appeared since, it continues via `OVERRIDDEN`.
728
- # MUST CONTINUE to Phase B6 — the size gate result is intermediate, NOT terminal (validation still required), except the NEEDS_DECOMPOSE and Blocked exits above.
713
+ **Resume**: a re-invoked build re-enters here via the B0 size-gate resume branch (partial `FORGE:BUILDER` plus latest trusted `FORGE:DIFF_SIZE` `result: OVER` or `COHESIVE` plus a staged diff in the kept worktree), so it does not re-run implement; this covers the exits that leave BUILDER incomplete, `NEEDS_DECOMPOSE` and the decompose-loop-guard Blocked exit (a `COHESIVE` marker re-enters the same way after a crash and re-decides without a duplicate comment). B5.5 re-measures: if still over with no override it re-pushes the same `forge/wip/{NUMBER}` ref (idempotent) and re-emits `NEEDS_DECOMPOSE` without a duplicate comment (the loop-guard case re-emits the same Blocked exit); when a valid `FORGE:SIZE_OVERRIDE` has appeared since, it continues via `OVERRIDDEN`.
714
+ # MUST CONTINUE to Phase B6 (also after `COHESIVE`) — the size gate result is intermediate, NOT terminal (validation still required), except the NEEDS_DECOMPOSE and Blocked exits above.
729
715
 
730
716
  ---
731
717
 
@@ -757,10 +743,19 @@ CHANGED_FILES=$(git -C "{WORKTREE_PATH}" diff --name-only "origin/{PR_BASE}...HE
757
743
 
758
744
  **Read acceptance spec from FORGE:INVESTIGATOR comment**:
759
745
 
746
+ The spec lines are executed as check targets, so read them only from trusted authors (`scripts/trusted-comments.sh`, resolved by `forge_resolve` from the script resolution block, never the working directory). Unresolvable helper or unreadable comments: fail closed (Blocked exit, run nothing). A spec present only in untrusted comments is no spec (the empty case below). <!-- Added: forge#3813 -->
747
+
760
748
  ```bash
761
- ACCEPTANCE_CHECKS=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate \
762
- --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body' \
763
- | grep "^ACCEPTANCE_CHECK:" )
749
+ # <Script resolution block, verbatim>
750
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
751
+ COMMENTS_JSON=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate 2>/dev/null)
752
+ if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ] \
753
+ || ! TRUSTED_BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INVESTIGATOR -->'); then
754
+ echo "acceptance spec unreadable: trust helper or comments unavailable (fail closed)"
755
+ # post nothing executable: label needs-human, print BUILD_RESULT: status: BLOCKED, blocker "acceptance spec trust check unavailable"
756
+ else
757
+ ACCEPTANCE_CHECKS=$(printf '%s\n' "$TRUSTED_BODIES" | jq -r '.' | grep "^ACCEPTANCE_CHECK:")
758
+ fi
764
759
  ```
765
760
 
766
761
  **If `ACCEPTANCE_CHECKS` is empty** (investigation predates this feature or comment was deleted): post a warning comment and **block** — do not silently pass:
@@ -799,6 +794,10 @@ The acceptance spec contains only a skip sentinel (\`type=skipped\`). No automat
799
794
  GATE_PASS=true
800
795
  CORRECTED=""
801
796
  FAILED_CHECKS=""
797
+ VERIFIED_COUNT=0 # checks actually executed and judged (PASS or FAIL); malformed ones do not count
798
+ MALFORMED_CHECKS="" # malformed-check: generated check that can never pass; reported, never repaired, never needs-human
799
+ DEFERRED_COUNT=0 # phase=review checks (and legacy "no pull requests found" results): evaluated by review R2.5, never counted here
800
+ DEFERRED_CHECKS="" # <!-- Added: forge#3793 -->
802
801
 
803
802
  while IFS= read -r check_line; do
804
803
  # Fields are extracted by anchoring each sed pattern to `^ACCEPTANCE_CHECK:` and walking
@@ -833,11 +832,25 @@ while IFS= read -r check_line; do
833
832
  MATCHER=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("([^"\\]|\\.)*"|[^ ]*) matcher="(([^"\\]|\\.)*)".*/\3/p' | sed 's/\\"/"/g')
834
833
  [ -z "$MATCHER" ] && MATCHER=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=[^ ]* matcher=\([^ ]*\).*/\1/p')
835
834
  DESC=$(echo "$check_line" | sed -n 's/.*description=\(.*\)/\1/p')
835
+ # phase= (optional, after matcher=, before description=): `review` defers the check to review R2.5 (the PR does not exist yet at build time).
836
+ # Anchored after the matcher field so `phase=` text inside description/target/matcher cannot hijack it; any other value or an absent token is `build` (fail closed).
837
+ PHASE=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("(([^"\\]|\\.)*)"|[^ ]*) matcher=("(([^"\\]|\\.)*)"|[^ ]*) phase=(build|review)( .*)?$/\7/p')
838
+ [ "$PHASE" = "review" ] || PHASE=build
836
839
 
837
840
  [ "$TYPE" = "skipped" ] && continue
838
841
 
842
+ # Deferred (review) <!-- Added: forge#3793 -->: never executed, never VERIFIED, never GATE_PASS=false, never in FAILED_CHECKS / --fix-acceptance.
843
+ if [ "$PHASE" = "review" ]; then DEFERRED_COUNT=$((DEFERRED_COUNT+1)); DEFERRED_CHECKS="${DEFERRED_CHECKS}\n- **$ID** ($DESC): DEFERRED (review)"; continue; fi
844
+
845
+ # malformed-check <!-- Added: forge#3694 -->: a generated command/behavior target that can never pass is a
846
+ # check defect, not an implementation failure. Static detection only parses (bash -n), never executes.
847
+ MALFORMED=""
848
+ { [ "$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)"; }
849
+ if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
850
+
839
851
  RESULT="PASS"
840
852
  DETAIL=""
853
+ ERR=""
841
854
 
842
855
  case "$TYPE" in
843
856
  exists)
@@ -853,10 +866,16 @@ while IFS= read -r check_line; do
853
866
  ;;
854
867
  command|behavior)
855
868
  if [ "$MATCHER" = "exit_0" ]; then
856
- eval "$TARGET" >/dev/null 2>&1 || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
869
+ ERR=$(eval "$TARGET" 2>&1 >/dev/null); RC=$?
870
+ # grep exit >= 2 is a usage/syntax error (e.g. a pattern starting with `-`), never a verdict on the code; exit 1 stays FAIL.
871
+ # A missing/unreadable operand file also exits 2 but is a REAL failure (the check asserts a file exists), so it is never malformed.
872
+ [ "$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
873
+ [ "$RC" -eq 0 ] || [ -n "$MALFORMED" ] || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
857
874
  else
858
- OUTPUT=$(eval "$TARGET" 2>&1)
859
- if ! printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER"; then
875
+ OUTPUT=$(eval "$TARGET" 2>&1); RC=$?; ERR="$OUTPUT"
876
+ [ "$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
877
+ if [ -n "$MALFORMED" ]; then :
878
+ elif ! printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER"; then
860
879
  if printf '%s\n' "$OUTPUT" | grep -qF -- "$MATCHER"; then CORRECTED="${CORRECTED}\n- **$ID**: output matched as a literal string"
861
880
  else RESULT="FAIL"; DETAIL="output did not match '$MATCHER'. Got: $(printf '%s\n' "$OUTPUT" | head -3)"; fi
862
881
  fi
@@ -867,11 +886,26 @@ while IFS= read -r check_line; do
867
886
  ;;
868
887
  esac
869
888
 
889
+ if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
890
+
891
+ # Legacy fallback (untagged spec): a gh pr check that finds no PR is waiting on the review phase, not failing.
892
+ if [ "$RESULT" = "FAIL" ] && printf '%s' "$ERR" | grep -qF 'no pull requests found'; then
893
+ case "$TARGET" in *'gh pr'*) DEFERRED_COUNT=$((DEFERRED_COUNT+1)); DEFERRED_CHECKS="${DEFERRED_CHECKS}\n- **$ID** ($DESC): DEFERRED (review) (no pull request exists yet)"; continue;; esac
894
+ fi
895
+
896
+ VERIFIED_COUNT=$((VERIFIED_COUNT+1))
870
897
  if [ "$RESULT" = "FAIL" ]; then
871
898
  GATE_PASS=false
872
899
  FAILED_CHECKS="${FAILED_CHECKS}\n- **$ID** ($DESC): $DETAIL"
873
900
  fi
874
901
  done <<< "$ACCEPTANCE_CHECKS"
902
+
903
+ # A gate that verified nothing must not pass: with every check skipped as malformed, PASSED would be a green light on zero evidence.
904
+ # A spec whose checks are all deferred to review (DEFERRED_COUNT > 0) is not vacuous: review R2.5 evaluates them before merge.
905
+ if [ "$VERIFIED_COUNT" -eq 0 ] && [ "$DEFERRED_COUNT" -eq 0 ]; then
906
+ GATE_PASS=false
907
+ FAILED_CHECKS="${FAILED_CHECKS}\n- **(gate)**: no acceptance check could be verified (all were malformed or none ran); regenerate the acceptance spec"
908
+ fi
875
909
  ```
876
910
 
877
911
  **Post gate result comment**:
@@ -882,8 +916,10 @@ if [ "$GATE_PASS" = "true" ]; then
882
916
  run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ACCEPTANCE_GATE -->
883
917
  ## Acceptance Gate — PASSED
884
918
 
885
- All machine-checkable acceptance criteria verified against real behavior.
919
+ All machine-checkable build-phase acceptance criteria verified against real behavior.
920
+ $( [ -n "$DEFERRED_CHECKS" ] && printf '\n**Deferred to review (phase=review; evaluated against the PR before merge)**:%b\n' "$DEFERRED_CHECKS" )
886
921
  $( [ -n "$CORRECTED" ] && printf '\n**Checks corrected (malformed matcher, literal text present)**:%b\n' "$CORRECTED" )
922
+ $( [ -n "$MALFORMED_CHECKS" ] && printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS" )
887
923
 
888
924
  <!-- FORGE:ACCEPTANCE_GATE:PASSED -->"
889
925
  else
@@ -893,6 +929,7 @@ else
893
929
  The following acceptance checks did not pass:
894
930
 
895
931
  $(echo -e "$FAILED_CHECKS")
932
+ $( [ -n "$MALFORMED_CHECKS" ] && printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS" )
896
933
 
897
934
  Merge is blocked. Fix the failing criteria and re-run the validate phase.
898
935
 
@@ -901,6 +938,10 @@ Merge is blocked. Fix the failing criteria and re-run the validate phase.
901
938
  fi
902
939
  ```
903
940
 
941
+ A `malformed-check` (a command/behavior target with a run of 4+ backslashes or a `bash -n` syntax error, or a grep target exiting >= 2 for a reason other than a missing/unreadable operand file, which stays a real FAIL) is reported in the comment and never sets `GATE_PASS=false` by itself, never enters `FAILED_CHECKS`, is never passed to `--fix-acceptance`, and never adds `needs-human` by itself; the gate still posts exactly one of PASSED/FAILED. The gate never posts PASSED with zero verified checks: if every check was malformed (`VERIFIED_COUNT=0` and `DEFERRED_COUNT=0`) it posts FAILED so the repair step regenerates the spec. <!-- Added: forge#3694 -->
942
+
943
+ A `phase=review` check (or, for an untagged legacy spec, a `command`/`behavior` check on a `gh pr` target whose output contains `no pull requests found`) is recorded as `DEFERRED (review)` in the PASSED comment: it is never executed here, never counted in `VERIFIED_COUNT`, never sets `GATE_PASS=false`, never enters `FAILED_CHECKS` or `--fix-acceptance`, and never spends the repair. A spec whose checks are all deferred posts PASSED (it is not "No Spec Found"). Review R2.5 evaluates the deferred checks against the real PR and blocks merge on failure. <!-- Added: forge#3793 -->
944
+
904
945
  If `GATE_PASS = false`, the build repairs itself **once** before anything escalates (a failing acceptance check is pipeline work, not a human decision):
905
946
 
906
947
  1. Count `<!-- FORGE:ACCEPTANCE_REPAIR: issue={NUMBER} -->` comments on the issue. If one already exists, skip to step 4.
@@ -946,6 +987,7 @@ BUILD_RESULT:
946
987
  - `ALREADY_DONE` — B0 found `FORGE:BUILDER:COMPLETE`.
947
988
  - `INVESTIGATION_COMPLETE` — the implement child created the deliverable issues (B5); no review follows.
948
989
  - `NEEDS_DECOMPOSE` — B5.5 measured the staged diff over `build.diff_size.threshold` with no valid `FORGE:SIZE_OVERRIDE`; a split proposal is in `FORGE:DIFF_SIZE`. The router runs `work-on:decompose`. Not a failure: no `needs-human`.
990
+ - `COHESIVE` is not a `BUILD_RESULT` status: B5.5 records `result: COHESIVE` in `FORGE:DIFF_SIZE` for an over-gate sub-issue dominated by one new module plus tests, then the build continues to B6 and ends `COMPLETE`.
949
991
  - `BLOCKED` — any guard or failure; `needs-human` is set and the blocker is posted.
950
992
 
951
993
  ---
@@ -95,31 +95,14 @@ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
95
95
  echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
96
96
  ADAPTIVE_ENABLED=false
97
97
  fi
98
- # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the canonical bootstrap
98
+ # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the shared forge_resolve stub
99
99
  # below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
100
100
  # repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
101
- # Resolution: $FORGEDOCK_HOME (authoritative when set) > $FORGE_HOME > $CLAUDE_PLUGIN_ROOT > the
101
+ # Resolution: $FORGEDOCK_HOME (authoritative when set) > the running plugin root (FORGE_SOURCED_ROOT) > $FORGE_HOME > the
102
102
  # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
103
- # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
104
- FORGE_ROOT=""
105
- # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
106
- _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
107
- # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
108
- _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
109
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
110
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
111
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
112
- # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
113
- _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
114
- # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
115
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
116
- _m="$HOME/.claude/plugins/marketplaces/$_mk"
117
- # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
118
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
119
- while IFS= read -r _c; do
120
- case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
121
- done <<< "$_k"
122
- fi
103
+ # FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
104
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
105
+ FORGE_ROOT="$(forge_root 2>/dev/null || true)"
123
106
  UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
124
107
  # NOTE: never resolve this via `which` or `find` — universal scripts are
125
108
  # install-relative, not installed on $PATH, so a PATH lookup always misses.
@@ -346,6 +329,8 @@ The `INVARIANT_ANOMALIES` variable is read in Phase C5 (Step 1) and rendered in
346
329
 
347
330
  **Skip if**: `TERMINAL_STATE` is `decomposed` or `invalid` (set `REMAINING_AFTER=0`; nothing to check off).
348
331
 
332
+ **Investigation terminal** <!-- Added: forge#3772 -->: if `TERMINAL_STATE` is `investigation`, skip the multi-phase guard below and set `REMAINING_AFTER=0`. An investigation exit closes its own issue and is never one phase of a multi-phase body; classifying it as multi-phase would post `FORGE:PHASE:COMPLETE` in C2 and skip the trajectory in C5.
333
+
349
334
  **Fresh read — intentionally NOT covered by the session-state cache.** The body is about to be rewritten below, and the review phase's `/review-pr` invocation is an external process that can post comments/edits between the last read and here. Writing back a stale cached body would silently revert any concurrent change, so this file always fetches the body fresh immediately before a body mutation (the parent body in Phase C3 likewise).
350
335
 
351
336
  **Multi-phase guard**: Before checking off items, detect whether the issue has multiple phases. Only check off items belonging to the current completed phase — not all remaining items across future phases.
@@ -59,31 +59,14 @@ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
59
59
  echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
60
60
  ADAPTIVE_ENABLED=false
61
61
  fi
62
- # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the canonical bootstrap
62
+ # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the shared forge_resolve stub
63
63
  # below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
64
64
  # repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
65
- # Resolution: $FORGEDOCK_HOME (authoritative when set) > $FORGE_HOME > $CLAUDE_PLUGIN_ROOT > the
65
+ # Resolution: $FORGEDOCK_HOME (authoritative when set) > the running plugin root (FORGE_SOURCED_ROOT) > $FORGE_HOME > the
66
66
  # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
67
- # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
68
- FORGE_ROOT=""
69
- # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
70
- _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
71
- # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
72
- _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
73
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
74
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
75
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
76
- # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
77
- _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
78
- # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
79
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
80
- _m="$HOME/.claude/plugins/marketplaces/$_mk"
81
- # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
82
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
83
- while IFS= read -r _c; do
84
- case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
85
- done <<< "$_k"
86
- fi
67
+ # FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
68
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
69
+ FORGE_ROOT="$(forge_root 2>/dev/null || true)"
87
70
  UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
88
71
  # NOTE: never resolve this via `which` or `find` — universal scripts are
89
72
  # install-relative, not installed on $PATH, so a PATH lookup always misses.
@@ -261,13 +244,13 @@ If `GIST_URLS` is non-empty, a `## Prior Investigation` section will be appended
261
244
  ## Phase D2: Design Sub-Issues
262
245
 
263
246
  From the Decomposition Assessment in the investigation report (adjusted for any owner override detected in Phase D0), or, when the assessment lists none, from the size-gate `### Split Proposal` in the trusted `FORGE:DIFF_SIZE` comment (Phase D0), extract:
264
- 1. Sub-issue titles (in dependency order — independent issues first)
247
+ 1. Sub-issue titles (in dependency order — independent issues first). **Packing** <!-- Added: forge#3786 -->: do not create one sub-issue per seam. Bin-pack the seams into the fewest sub-issues whose estimate (code plus tests) is at or under 0.8 x threshold, i.e. `max(2, ceil(ESTIMATED_DIFF_LINES / (0.8 x threshold)))` sub-issues, keeping related seams together; each sub-issue body lists the seams it covers. Migration-ordering seams may stay separate. **Budget check** <!-- Added: forge#3790 -->: reject or repack any plan item whose estimate (code plus tests) is over 0.8 x threshold: split it along a real seam or merge-and-resplit until each item fits. A seam that can't fit (one cohesive module plus its tests) stays as one item, and its body states that explicitly with the estimate; build B5.5 then treats a cohesive overage as `COHESIVE` instead of stranding it. Never inflate the sub-issue count to force a fit. Record each item's estimate as `ESTIMATED_DIFF_LINES` for Phase D3. The parent is a terminal tracker and builds nothing, so every packed item, including the first, becomes a sub-issue (N packed items give N sub-issues)
265
248
  2. Dependencies between sub-issues (if issue B depends on issue A, A is created first)
266
249
  3. Brief description for each sub-issue body
267
250
 
268
251
  For each sub-issue, prepare:
269
252
  - **Title**: from investigation report's proposed sub-issue title (re-derived from the owner override when one was detected), including the final `{fix|feat|refactor}: ` prefix used in Phase D3
270
- - **Body**: brief description of scope + `**Parent**: #{NUMBER}` + dependency note if applicable + a `## Staged Hunks` section when the Split Proposal line carries a wip ref or patch (see D3)
253
+ - **Body**: brief description of scope + `**Parent**: #{NUMBER}` + an own-line `ESTIMATED_DIFF_LINES: {N}` (the packed item's estimate, code plus tests; build B5.5 compares it with the actual diff in `FORGE:DIFF_SIZE`) + dependency note if applicable + a `## Staged Hunks` section when the Split Proposal line carries a wip ref or patch (see D3)
271
254
  - **Labels**: inherit priority label (P0/P1/P2) from parent; do NOT copy workflow labels
272
255
  - **Milestone**: same milestone title as parent (if parent has one)
273
256
 
@@ -355,6 +338,7 @@ Files that need changes:
355
338
 
356
339
  **Parent**: #{NUMBER}
357
340
  {If depends on another sub-issue: "**Depends on**: #{SUB_ISSUE_N} — {reason}"}
341
+ ESTIMATED_DIFF_LINES: {N}
358
342
 
359
343
  {Only when this seam's Split Proposal line carries a wip ref or patch (D0); otherwise omit this section entirely:}
360
344
  ## Staged Hunks