forgedock 1.8.4 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bin/engine/admission.mjs +23 -4
  2. package/bin/engine/admission.test.mjs +37 -0
  3. package/commands/orchestrate/phase-1-resolve.md +40 -24
  4. package/commands/orchestrate/phase-3-dependency.md +56 -11
  5. package/commands/orchestrate/phase-4-execution.md +349 -111
  6. package/commands/orchestrate/phase-5-cleanup.md +17 -0
  7. package/commands/orchestrate/phase-6-report.md +1 -1
  8. package/commands/quality-gate.md +51 -0
  9. package/commands/recover-orphans.md +3 -3
  10. package/commands/review-pr.md +87 -16
  11. package/commands/work-on/build/context.md +25 -4
  12. package/commands/work-on/build/validate.md +1 -0
  13. package/commands/work-on/build.md +26 -9
  14. package/commands/work-on/close.md +104 -40
  15. package/commands/work-on/decompose.md +13 -7
  16. package/commands/work-on/investigate.md +13 -7
  17. package/commands/work-on/remediate.md +15 -4
  18. package/commands/work-on/review.md +48 -19
  19. package/commands/work-on.md +62 -9
  20. package/docs/CODEX.md +12 -0
  21. package/docs/CONFIG.md +49 -1
  22. package/docs/RELEASE-NOTES.md +52 -0
  23. package/package.json +1 -1
  24. package/scripts/amplification-breaker.sh +89 -0
  25. package/scripts/amplification-breaker.test.sh +39 -0
  26. package/scripts/check-admission-parity.mjs +74 -1
  27. package/scripts/check-spec-markers.sh +67 -3
  28. package/scripts/classify-finding.sh +120 -0
  29. package/scripts/classify-finding.test.sh +74 -0
  30. package/scripts/close-dossier-no-main-commit.test.sh +139 -0
  31. package/scripts/forge-root.test.sh +84 -27
  32. package/scripts/lint-build-validate-files.sh +29 -0
  33. package/scripts/lint-build-validate-files.test.sh +25 -0
  34. package/scripts/lint-dispatch-prompt.test.sh +2 -1
  35. package/scripts/recovery-claim-live.sh +73 -0
  36. package/scripts/recovery-claim-live.test.sh +66 -0
  37. package/scripts/verify-phase-trail.sh +183 -4
  38. package/scripts/verify-phase-trail.test.sh +211 -1
@@ -82,7 +82,7 @@ release_orchestrator_lease() {
82
82
 
83
83
  1. Enumerate every task id still tracked in `ENGINE_DISPATCH_MAP` whose issue has not yet reached a terminal `workflow:*` label (per Step 4B's `classify_predecessor_state()`).
84
84
  2. Call `TaskStop(task_id)` (the harness tool) for each one, in the same turn, before ending the session.
85
- 3. Call `release_orchestrator_lease()` (Step 4A-pre.-1) so a future resume or a different operator is not blocked by a stale lease from this now-stopped session.
85
+ 3. Call `cleanup_trail_cache` (Step 4B) and `release_orchestrator_lease()` (Step 4A-pre.-1) so a future resume or a different operator is not blocked by a stale lease from this now-stopped session.
86
86
  4. Report which issues had in-flight dispatch stopped mid-pipeline (their `workflow:*` label will reflect whatever phase they reached — a future `/work-on {NUMBER}` or orchestrator resume picks them back up from GitHub state, per the Universal Phase Dispatcher in `commands/work-on.md`).
87
87
 
88
88
  **This is a documentation/behavioral contract, not a bash block**: unlike the lease gate above, there is no shell construct that reliably intercepts "the orchestrator's own session is being stopped" — that is a harness-level event the orchestrating agent must handle in its own turn when it observes an interrupt, using its own tools (`TaskStop`), exactly the way `commands/orchestrate/phase-5-cleanup.md` already documents cleanup as an agent-driven procedure rather than a background script.
@@ -789,6 +789,48 @@ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ]; then
789
789
  fi
790
790
  ```
791
791
 
792
+ **Recovery-claim dispatch gate (MANDATORY, before every individual dispatch)** <!-- Added: forge#3172 -->: a `/recover-orphans` sweep that is resuming an issue inline holds an issue-scoped `<!-- FORGE:RECOVERY_CLAIM -->` comment. Dispatching a worker underneath a live claim double-runs the issue. This gate is independent of the claims board above (it applies even when `FORGE_COORD_ISSUE` is unset) and uses the shared predicate `scripts/recovery-claim-live.sh` (unreleased claim, within `RECOVERY_CLAIM_TTL_MIN`, no `FORGE:RECOVERY_CLAIM_RELEASED` for its sweep id). It fails closed: `CLAIM: LIVE` and `CLAIM: ERROR` (exit 1 or 2, or an unresolvable script) both defer. A deferral writes nothing to GitHub (no `needs-human`, no label), the claim being transient. The issue goes into `DEFERRED_CONCURRENCY_ISSUES[]`, so it re-enters the candidate set on the next dispatch computation and is never dropped; report each deferral as `RECOVERY CLAIM DEFER` in the status update. Shell state does not persist between Bash calls, so the canonical `FORGE_ROOT` bootstrap is repeated in the block; if it resolves empty, every issue defers.
793
+
794
+ ```bash
795
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
796
+ FORGE_ROOT=""
797
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
798
+ _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
799
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
800
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
801
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
802
+ # 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.
803
+ _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")")"
804
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
805
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
806
+ # 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)
807
+ _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)"
808
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
809
+ # '${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.
810
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
811
+ while IFS= read -r _c; do
812
+ 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
813
+ done <<< "$_k"
814
+ fi
815
+ recovery_claim_blocks() {
816
+ local NUM="$1" OUT RC
817
+ if [ -z "${FORGE_ROOT:-}" ] || [ ! -f "$FORGE_ROOT/scripts/recovery-claim-live.sh" ]; then
818
+ echo "RECOVERY CLAIM DEFER: #${NUM} (recovery-claim-live.sh unresolved — failing closed)" >&2
819
+ return 0
820
+ fi
821
+ OUT=$(bash "$FORGE_ROOT/scripts/recovery-claim-live.sh" "$NUM" -R {GH_REPO}); RC=$?
822
+ [ "$RC" -eq 0 ] && return 1
823
+ echo "RECOVERY CLAIM DEFER: #${NUM} (${OUT}) — retried on the next dispatch computation"
824
+ return 0
825
+ }
826
+
827
+ # Same sites as claim_conflicts_with_live_holder: engine-first loop, Agent/task launch, Step 4B newly-ready (via Step 4A).
828
+ if recovery_claim_blocks "$NUM"; then
829
+ DEFERRED_CONCURRENCY_ISSUES+=("$NUM")
830
+ continue
831
+ fi
832
+ ```
833
+
792
834
  **Engine-first dispatch (default)**: When `forgedock` is in PATH, dispatch each ready issue via the durable engine rather than spawning prose Agent sub-agents. The engine's phase table enforces gate semantics in code — its fail-closed review gate and deterministic phase ordering are not subject to LLM interpretation.
793
835
 
794
836
  **OpenCode dispatch (runtime-neutral adapter)**: When `FORGE_RUNTIME=opencode` or
@@ -925,6 +967,10 @@ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
925
967
  # instruction below re-checks immediately before each launch to close the remaining TOCTOU gap.
926
968
  CLAIM_SAFE_DISPATCH=()
927
969
  for NUM in "${DISPATCH_NOW[@]}"; do
970
+ if recovery_claim_blocks "$NUM"; then # forge#3172: live FORGE:RECOVERY_CLAIM (fails closed)
971
+ DEFERRED_CONCURRENCY_ISSUES+=("$NUM")
972
+ continue
973
+ fi
928
974
  if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && claim_conflicts_with_live_holder "$NUM"; then
929
975
  DEFERRED_CLAIM_ISSUES+=("$NUM")
930
976
  continue
@@ -969,7 +1015,7 @@ fi
969
1015
  fi
970
1016
  ```
971
1017
 
972
- **Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Immediately before each call, run `claim_conflicts_with_live_holder "{NUM}"`; if it returns success, defer that issue rather than dispatching it. Issue one `Bash(...)` call per remaining issue in `DISPATCH_NOW`, all in the same message, so they run concurrently within the headroom already computed above:
1018
+ **Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Immediately before each call, run `recovery_claim_blocks "{NUM}"` and `claim_conflicts_with_live_holder "{NUM}"`; if either returns success, defer that issue rather than dispatching it. Issue one `Bash(...)` call per remaining issue in `DISPATCH_NOW`, all in the same message, so they run concurrently within the headroom already computed above:
973
1019
 
974
1020
  ```
975
1021
  Bash(command="FORGE_GIST_CAPABLE=${FORGE_GIST_CAPABLE} forgedock run-issue {NUM} --lane {PR_BASE}", run_in_background=true, description="Engine-drive issue #{NUM}")
@@ -1054,6 +1100,7 @@ gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name | select
1054
1100
  **Terminal condition also**: `needs-human` label present, `workflow:awaiting-merge` label present, OR issue state is `closed`
1055
1101
  `needs-human` and `workflow:awaiting-merge` are terminal-FOR-THIS-AGENT (this individual `/work-on` run stops here — a human decision or merge is now the blocking step) but are NOT "done" from the DAG's point of view; see Predecessor Classification in Step 4B for how the orchestrator's dependency logic treats them (`GATED`, not `DONE`).
1056
1102
  If the label is NOT terminal (e.g., `workflow:investigating`, `workflow:ready-to-build`, `workflow:building`, `workflow:in-review`), invoke `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{NUMBER} --under-orchestration')` again immediately. The `/work-on` skill will re-read GitHub state and advance to the next phase. Do NOT output a summary, do NOT pause, do NOT ask for confirmation — just invoke it again.
1103
+ **NEVER end a turn while the label is non-terminal.** Every `Skill(...)` call runs synchronously to completion in your own turn. Never end or yield your turn to wait for a background phase or a completion notification — notifications go to the orchestrator's root session, never to you, so a turn that ends non-terminal is never resumed. A turn that ends with a non-terminal label and no `*_RESULT:` block seen is a contract violation. A "running", backgrounded or empty return is not a result: re-read the labels and `FORGE:*` markers on GitHub and invoke `work-on` again immediately — never wait.
1057
1104
 
1058
1105
  **CRITICAL — SOURCE BRANCH DETECTION**:
1059
1106
  - If the issue has the `review-finding` label, read the issue body for `**Code branch**: \`{branch}\``
@@ -1099,14 +1146,20 @@ RENDERED_PROMPT=$(cat "$RENDERED_PROMPT_SRC"); rm -f "$RENDERED_PROMPT_SRC"
1099
1146
  # immediately before that issue's Agent()/task() call. LINT_REFUSED_ISSUES is initialised once, empty.
1100
1147
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
1101
1148
  FORGE_ROOT=""
1102
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
1103
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
1104
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
1105
- # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
1106
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{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-)"
1107
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
1149
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
1150
+ _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
1151
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
1152
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
1153
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
1154
+ # 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.
1155
+ _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")")"
1156
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
1157
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
1158
+ # 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)
1159
+ _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)"
1160
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
1108
1161
  # '${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.
1109
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
1162
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
1110
1163
  while IFS= read -r _c; do
1111
1164
  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
1112
1165
  done <<< "$_k"
@@ -1311,7 +1364,7 @@ fi
1311
1364
  echo "Dispatching ${#DISPATCH_NOW[@]} issue(s) this message (headroom was ${HEADROOM})"
1312
1365
  ```
1313
1366
 
1314
- Immediately before every Claude `Agent()` or OpenCode `task()` call, run `claim_conflicts_with_live_holder "{NUM}"`; when it returns success, append the issue to `DEFERRED_CLAIM_ISSUES[]` and do not make that call. Spawn `Agent()` for every remaining issue in `DISPATCH_NOW` (not the raw ready set) using the template above. After the batch spawn, increment `ACTIVE_DISPATCH_COUNT` by the number actually dispatched — this happens in addition to, not instead of, capturing each returned agent ID into `AGENT_ISSUE_MAP` above.
1367
+ Immediately before every Claude `Agent()` or OpenCode `task()` call, run `recovery_claim_blocks "{NUM}"` (success: append to `DEFERRED_CONCURRENCY_ISSUES[]`, forge#3172) and `claim_conflicts_with_live_holder "{NUM}"` (success: append the issue to `DEFERRED_CLAIM_ISSUES[]`); on either, do not make that call. Spawn `Agent()` for every remaining issue in `DISPATCH_NOW` (not the raw ready set) using the template above. After the batch spawn, increment `ACTIVE_DISPATCH_COUNT` by the number actually dispatched — this happens in addition to, not instead of, capturing each returned agent ID into `AGENT_ISSUE_MAP` above.
1315
1368
 
1316
1369
  If `DISPATCH_NOW` is empty (headroom is 0), do not spawn any agents this cycle — wait for the next `agent_completed` notification, which frees a slot and re-triggers this dispatch computation (Step 4B).
1317
1370
 
@@ -1338,23 +1391,60 @@ For an OpenCode completion, retain `OPENCODE_DISPATCH_MAP[{NUMBER}]` until the t
1338
1391
 
1339
1392
  **Successor dispatch latency is measured from `agent_completed`, not from orchestrator polling.** The moment you receive an `agent_completed` notification for issue N, that is t=0 for dispatching N's successors. Any successor whose predecessors are all now terminal MUST be dispatched in the same response that processes the notification — not after a poll cycle, not after a sleep. This is the design property that makes streaming DAG execution faster than wave-based execution. <!-- Added: forge#1251 -->
1340
1393
 
1394
+ **Cycle-start cache reset (MANDATORY — before item 0 (phase-trail verification), before item 5, and before Step 4B.5 stall detection)** <!-- Added: forge#3182 -->: at the very start of every monitoring cycle, call `init_trail_cache` once so no `classify_predecessor_state` call in this cycle (item 0 phase-trail verification, item 5/6 readiness checks, stall detection, item 6.6) reads the previous cycle's cached trail state. Re-declare `trail_cache_dir` / `init_trail_cache` in the same Bash block (same-shell rule as `classify_predecessor_state`) and run it before the first classify call. Item 6.6 does not reset the cache itself. `cleanup_trail_cache` removes the directory on every exit path (Termination condition, Step 4F, "Stopping the orchestrator").
1395
+
1341
1396
  **Predecessor Classification (DONE / GATED / FAILED)** <!-- Added: forge#1812 --> — every check in this file that asks "is predecessor X resolved enough for its dependents to proceed" MUST classify X into exactly one of three states below — never a single binary terminal/non-terminal grep. Earlier versions of this file independently patched `grep -qE 'workflow:merged|workflow:invalid|needs-human|CLOSED'` in multiple places, and the copies drifted: the readiness check (this step) treated `needs-human` as done-enough-to-dispatch-through, while the failure handler (item 6 below) treated the identical label as a hard failure that skips dependents. Both cannot be right at once — `needs-human` means the predecessor's code is paused pending a human decision and is NOT yet in the base branch, so dispatching a dependent against it is unsafe, but permanently skipping the dependent is also wrong once the human resolves the block. The fix is a third state:
1342
1397
 
1343
1398
  ```bash
1399
+ # Trail cache lifecycle (forge#3182). Each Bash tool call is a fresh shell, so a persisted TRAIL_CACHE_DIR, a `$$`-keyed
1400
+ # path and a bare `rm -rf "$TRAIL_CACHE_DIR"` all differ or no-op per call (and a per-call `mktemp -d` leaks a directory every
1401
+ # cycle). The path is therefore derived from a STABLE key that every block can recompute without shell state: the numeric uid
1402
+ # plus the target repo (never `$$`; a pre-set TRAIL_CACHE_DIR / env value is deliberately ignored so `rm -rf` can never be
1403
+ # pointed at an arbitrary path). The dir is private (mode 700, owned by us, not a symlink) under a per-user base.
1404
+ # init_trail_cache resets it (call once at the TOP of each monitoring cycle, see "Cycle-start cache reset");
1405
+ # cleanup_trail_cache removes it on every orchestrator exit path. The cache holds only ABSENT/ACTIVE/RELEASED words.
1406
+ trail_cache_dir() {
1407
+ printf '%s' "${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/forge-trail-$(id -u)-$(printf '%s' "{GH_REPO}" | tr -c 'A-Za-z0-9._-' '_')"
1408
+ }
1409
+ init_trail_cache() {
1410
+ local D; D="$(trail_cache_dir)"
1411
+ case "$D" in */forge-trail-*) ;; *) echo "init_trail_cache: refusing unexpected path" >&2; return 1 ;; esac
1412
+ case "$D" in *..*) echo "init_trail_cache: refusing unexpected path" >&2; return 1 ;; esac
1413
+ rm -rf -- "$D"
1414
+ mkdir -m 700 -p -- "$D" && [ -O "$D" ] && [ ! -L "$D" ] || { echo "init_trail_cache: could not create a private cache dir" >&2; return 1; }
1415
+ TRAIL_CACHE_DIR="$D"
1416
+ }
1417
+ cleanup_trail_cache() {
1418
+ local D; D="$(trail_cache_dir)"
1419
+ case "$D" in */forge-trail-*) ;; *) return 0 ;; esac
1420
+ case "$D" in *..*) return 0 ;; esac
1421
+ rm -rf -- "$D"; unset TRAIL_CACHE_DIR; return 0
1422
+ }
1423
+
1344
1424
  # hold_merged_trail <issue> <trail-text> (forge#3156): the single escalation path for a merged issue whose phase
1345
1425
  # trail failed or could not be verified. Used by Step 4B item 0 and by the classifier's wake re-verification.
1346
- # Order matters (forge#3169): the escalation COMMENT is posted FIRST (re-read immediately before each post, skipped when
1347
- # an ACTIVE escalation already exists, verified ACTIVE by re-read after), then the `needs-human` label is written
1348
- # (read-back verified, 3 tries). The comment is the durable audit record, independent of the verifier, so a label-only
1349
- # hold is not the normal state and a human release always has an anchor to be recorded against. Returns 0 only when both
1350
- # halves are verified; otherwise returns 1 and stderr names which half failed (the label is still attempted after a
1351
- # comment failure, because holding dependents is the safe side).
1426
+ # Order matters (forge#3223): the `needs-human` label is written FIRST (read-back verified, 3 tries). Only when the
1427
+ # label is verified is the escalation comment posted (re-read immediately before each post, skipped when an ACTIVE
1428
+ # escalation already exists, verified ACTIVE by re-read after, 2 tries). A comment can therefore only exist after the label
1429
+ # did, so the merged + no-label + ACTIVE state (which classifies as a human release) is unreachable through this path.
1430
+ # If the label cannot be verified, NO comment is posted and the function returns 1. If the comment fails after a verified
1431
+ # label, the label hold stays (a labelled ABSENT hold classifies GATED, fail-closed). Returns 0 only when both halves are
1432
+ # verified; otherwise returns 1 and stderr names which half failed.
1352
1433
  hold_merged_trail() {
1353
1434
  local N="$1" TXT="$2" TRY COMMENTED=1 HELD=1
1435
+ for TRY in 1 2 3; do
1436
+ gh issue edit "$N" -R {GH_REPO} --add-label "needs-human" >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
1437
+ if gh issue view "$N" -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null \
1438
+ | grep -qE '(^|,)needs-human(,|$)'; then HELD=0; break; fi
1439
+ done
1440
+ if [ "$HELD" -ne 0 ]; then
1441
+ echo "PHASE TRAIL HOLD FAILED: #${N} — needs-human label could not be verified; no escalation comment posted (a comment without the label would read as a released hold); dependents are NOT held. Hold them manually." >&2
1442
+ return 1
1443
+ fi
1354
1444
  for TRY in 1 2; do
1355
1445
  # forge#3157: dedupe only against an ACTIVE escalation, so a re-escalation after a recorded release posts a new one.
1356
1446
  # forge#3169: re-read right before each post (cache entry removed) to narrow the concurrent-classifier duplicate window.
1357
- [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1447
+ rm -f "$(trail_cache_dir)/trail-$N"
1358
1448
  if [ "$(trail_escalation_state "$N")" = "ACTIVE" ]; then COMMENTED=0; break; fi
1359
1449
  gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_FAILED -->
1360
1450
  #${N} merged with an incomplete phase trail (or one that could not be verified), detected by /orchestrate.
@@ -1364,28 +1454,35 @@ ${TXT}
1364
1454
  \`\`\`
1365
1455
 
1366
1456
  The PR is already merged, so \`/work-on\` cannot re-run the missing phases. Review the merged change, then remove \`needs-human\` to release dependents held as \`blocked-on-human-merge\`." >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
1367
- [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1457
+ rm -f "$(trail_cache_dir)/trail-$N"
1368
1458
  if [ "$(trail_escalation_state "$N")" = "ACTIVE" ]; then COMMENTED=0; break; fi
1369
1459
  done
1370
- for TRY in 1 2 3; do
1371
- gh issue edit "$N" -R {GH_REPO} --add-label "needs-human" >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
1372
- if gh issue view "$N" -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null \
1373
- | grep -qE '(^|,)needs-human(,|$)'; then HELD=0; break; fi
1374
- done
1375
- [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1376
- [ "$COMMENTED" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — escalation comment could not be verified (audit record missing); hold dependents manually." >&2
1377
- [ "$HELD" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — needs-human label could not be verified; dependents are NOT held. Hold them manually." >&2
1378
- [ "$COMMENTED" -eq 0 ] && [ "$HELD" -eq 0 ]
1460
+ rm -f "$(trail_cache_dir)/trail-$N"
1461
+ [ "$COMMENTED" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — escalation comment could not be verified (audit record missing); the needs-human label hold stays (fail-closed)." >&2
1462
+ [ "$COMMENTED" -eq 0 ]
1379
1463
  }
1380
1464
 
1381
- # resolve_orch_login (forge#3168): prints the login of the identity that authors the orchestrator's own FAILED/RELEASED
1382
- # marker comments, so trail_escalation_state can trust them even when the bot reports author_association NONE or
1383
- # CONTRIBUTOR. Resolution order: FORGE_BOT_LOGIN env override, then the git identity (`git config user.name`, which for a
1384
- # GitHub App is its `<app>[bot]` login). Never calls `/user` (403s for App tokens). An unresolved value prints nothing and
1385
- # the caller fails closed: only the author_association set below is trusted, never "trust every author".
1465
+ # resolve_orch_login (forge#3168, forge#3205): prints the login of the identity that authors the orchestrator's own
1466
+ # FAILED/RELEASED marker comments, so trail_escalation_state can trust them even when the bot reports author_association
1467
+ # NONE or CONTRIBUTOR. Resolution order: FORGE_BOT_LOGIN env override, then the per-cycle cached value, then the identity
1468
+ # gh actually authenticates as (GraphQL `viewer { login }`, which works for App tokens), then `git config user.name` as a
1469
+ # last resort (a commit-author setting that can differ from the posting identity). Never calls `/user` (403s for App
1470
+ # tokens). A non-empty result is cached in `orch-login` inside the owner-checked trail cache dir (a `$(...)` subshell
1471
+ # cannot keep a shell variable; `init_trail_cache` / `cleanup_trail_cache` remove it with the dir); an empty result is
1472
+ # never cached so it is retried. An unresolved value warns on stderr and prints nothing; the caller fails closed: only
1473
+ # the author_association set below is trusted, never "trust every author".
1386
1474
  resolve_orch_login() {
1387
- local L="${FORGE_BOT_LOGIN:-}"
1475
+ local L="${FORGE_BOT_LOGIN:-}" D C; D="$(trail_cache_dir)"; C="$D/orch-login"
1476
+ if [ -z "$L" ] && [ -d "$D" ] && [ -O "$D" ] && [ ! -L "$D" ] && [ -f "$C" ] && [ ! -L "$C" ] && [ -O "$C" ]; then
1477
+ L=$(head -n 1 "$C" 2>/dev/null || true)
1478
+ fi
1479
+ [ -n "$L" ] || L=$(gh api graphql -f query='{viewer{login}}' --jq '.data.viewer.login' 2>/dev/null || true)
1388
1480
  [ -n "$L" ] || L=$(git config user.name 2>/dev/null || true)
1481
+ if [ -z "$L" ]; then
1482
+ echo "resolve_orch_login: could not resolve the orchestrator login (set FORGE_BOT_LOGIN); trusting only OWNER/MEMBER/COLLABORATOR trail markers" >&2
1483
+ elif [ -z "${FORGE_BOT_LOGIN:-}" ] && [ -d "$D" ] && [ -O "$D" ] && [ ! -L "$D" ] && [ ! -e "$C" ]; then
1484
+ printf '%s\n' "$L" > "$C" 2>/dev/null || true
1485
+ fi
1389
1486
  printf '%s' "$L"
1390
1487
  }
1391
1488
 
@@ -1393,33 +1490,57 @@ resolve_orch_login() {
1393
1490
  # FORGE:PHASE_TRAIL_FAILED comment id is greater than the newest FORGE:PHASE_TRAIL_RELEASED id), RELEASED (a human
1394
1491
  # release was recorded after the last escalation, so a later unrelated `needs-human` no longer gates), or UNREADABLE.
1395
1492
  # forge#3168: only TRUSTED comments count, for BOTH markers. Trusted = author_association OWNER/MEMBER/COLLABORATOR, or
1396
- # `.user.login` equal to the orchestrator identity (resolve_orch_login, used only when non-empty). A marker string posted
1493
+ # `.user.login` equal to the orchestrator identity (resolve_orch_login, used only when non-empty; compared case-insensitively). A marker string posted
1397
1494
  # by anyone else is ignored (treated as absent), so an outside commenter can neither forge a release nor a failure.
1495
+ # forge#3207: MEMBER/COLLABORATOR association is not write access, so those authors additionally need admin/maintain/write on
1496
+ # `collaborators/<login>/permission` (OWNER and the orchestrator login pass directly; a failed lookup fails closed = untrusted).
1497
+ # forge#3181: both markers must also START the comment body (startswith, not contains), so a trusted comment that merely
1498
+ # quotes the marker text is not counted. hold_merged_trail / release_merged_trail emit the marker as the first line.
1398
1499
  # The comment read is cached per issue for the current monitoring cycle: the cache is a directory of files
1399
- # (`$(...)` subshells lose shell arrays), `TRAIL_CACHE_DIR=$(mktemp -d)` and is recreated at the start of every
1400
- # monitoring cycle (Step 4B item 6.6), so a human edit made between cycles is always seen. Only successful reads are cached.
1500
+ # (`$(...)` subshells lose shell arrays) at the stable per-user/per-repo path from `trail_cache_dir` (created only by `init_trail_cache`; reads/writes skip it if it is absent or not owned by us), and is reset by
1501
+ # `init_trail_cache` at the TOP of every monitoring cycle (Step 4B "Cycle-start cache reset", before item 5 and stall
1502
+ # detection), so a human edit made between cycles is always seen. Only successful reads are cached.
1401
1503
  trail_escalation_state() {
1402
- local N="$1" F="${TRAIL_CACHE_DIR:+$TRAIL_CACHE_DIR/trail-$1}" RAW OUT FAILED_ID RELEASED_ID RES
1403
- if [ -n "$F" ] && [ -s "$F" ]; then cat "$F"; return; fi
1504
+ local N="$1" F="$(trail_cache_dir)/trail-$1" RAW OUT FAILED_ID RELEASED_ID RES
1505
+ if [ -s "$F" ] && [ -O "$F" ]; then cat "$F"; return; fi
1404
1506
  if ! RAW=$(gh api --paginate "repos/{GH_REPO}/issues/${N}/comments" --jq '.[] | {id: .id, body: (.body // ""), login: (.user.login // ""), assoc: (.author_association // "")}' 2>/dev/null); then
1405
1507
  echo "UNREADABLE"; return
1406
1508
  fi
1407
- if ! OUT=$(printf '%s\n' "$RAW" | jq -r --arg L "$(resolve_orch_login)" 'select((.assoc | IN("OWNER","MEMBER","COLLABORATOR")) or ($L != "" and .login == $L)) | if (.body | contains("FORGE:PHASE_TRAIL_FAILED") and contains("merged with an incomplete phase trail")) then "F \(.id)" elif (.body | contains("<!-- FORGE:PHASE_TRAIL_RELEASED -->")) then "R \(.id)" else empty end' 2>/dev/null); then
1509
+ local ORCH; ORCH=$(resolve_orch_login)
1510
+ if ! OUT=$(printf '%s\n' "$RAW" | jq -r --arg L "$ORCH" 'select((.assoc | IN("OWNER","MEMBER","COLLABORATOR")) or ($L != "" and (.login | ascii_downcase) == ($L | ascii_downcase))) | if (.body | startswith("<!-- FORGE:PHASE_TRAIL_FAILED -->") and contains("merged with an incomplete phase trail")) then "F \(.id) \(.assoc) \(.login)" elif (.body | startswith("<!-- FORGE:PHASE_TRAIL_RELEASED -->")) then "R \(.id) \(.assoc) \(.login)" else empty end' 2>/dev/null); then
1408
1511
  echo "UNREADABLE"; return
1409
1512
  fi
1513
+ # forge#3207: MEMBER / COLLABORATOR association is NOT write access (read-only org members, triage/read collaborators).
1514
+ # Keep only marker rows whose author is OWNER, the orchestrator login, or has admin/maintain/write permission per
1515
+ # collaborators/<login>/permission (one lookup per login, memoised in $PERMS for this call). Fail closed: a failed lookup drops the row.
1516
+ local PERMS="" KEPT="" KIND KID KASSOC KLOGIN KP
1517
+ while read -r KIND KID KASSOC KLOGIN; do
1518
+ [ -n "$KID" ] || continue
1519
+ if [ "$KASSOC" = "OWNER" ] || { [ -n "$ORCH" ] && [ "$(printf '%s' "$KLOGIN" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$ORCH" | tr '[:upper:]' '[:lower:]')" ]; }; then
1520
+ KEPT="${KEPT}${KIND} ${KID}"$'\n'; continue
1521
+ fi
1522
+ KP=$(printf '%s\n' "$PERMS" | awk -v l="$KLOGIN" '$1==l{print $2; exit}')
1523
+ if [ -z "$KP" ]; then
1524
+ KP=$(gh api "repos/{GH_REPO}/collaborators/${KLOGIN}/permission" --jq '.permission' 2>/dev/null || true)
1525
+ [ -n "$KP" ] || KP="none"
1526
+ PERMS="${PERMS}${KLOGIN} ${KP}"$'\n'
1527
+ fi
1528
+ case "$KP" in admin|maintain|write) KEPT="${KEPT}${KIND} ${KID}"$'\n' ;; esac
1529
+ done <<< "$OUT"
1530
+ OUT="$KEPT"
1410
1531
  FAILED_ID=$(printf '%s\n' "$OUT" | awk '$1=="F"&&$2+0>m{m=$2+0}END{print m+0}')
1411
1532
  RELEASED_ID=$(printf '%s\n' "$OUT" | awk '$1=="R"&&$2+0>m{m=$2+0}END{print m+0}')
1412
1533
  if [ "$FAILED_ID" -eq 0 ]; then RES="ABSENT"
1413
1534
  elif [ "$RELEASED_ID" -gt "$FAILED_ID" ]; then RES="RELEASED"
1414
1535
  else RES="ACTIVE"; fi
1415
- [ -n "$F" ] && printf '%s\n' "$RES" > "$F"
1536
+ [ -d "$(trail_cache_dir)" ] && [ -O "$(trail_cache_dir)" ] && printf '%s\n' "$RES" > "$F"
1416
1537
  echo "$RES"
1417
1538
  }
1418
1539
 
1419
1540
  # release_merged_trail <issue> (forge#3157): record that a human cleared a merged-trail hold, so the escalation
1420
1541
  # stops gating. Call only after classify_predecessor_state reported DONE for a merged issue whose escalation is ACTIVE.
1421
- # Only an ACTIVE escalation is released here: hold_merged_trail posts the comment before the label (forge#3169), so a
1422
- # human release of a label-only (ABSENT) hold is not expected and is not recorded (avoids a stray RELEASED marker).
1542
+ # Only an ACTIVE escalation is released here: hold_merged_trail writes the label before the comment (forge#3223), so an
1543
+ # ACTIVE escalation with no label only arises from a human removing needs-human, and a label-only (ABSENT) hold is not recorded (avoids a stray RELEASED marker).
1423
1544
  # Idempotent (no-op unless ACTIVE) and never run in dry-run. The comment must not contain the escalation marker text.
1424
1545
  release_merged_trail() {
1425
1546
  local N="$1"
@@ -1427,7 +1548,8 @@ release_merged_trail() {
1427
1548
  [ "$(trail_escalation_state "$N")" = "ACTIVE" ] || return 0
1428
1549
  gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_RELEASED -->
1429
1550
  The merged-trail hold on #${N} was released by a human (needs-human cleared or trail re-verified). Dependents held as \`blocked-on-human-merge\` are released. A later, unrelated \`needs-human\` on this merged issue no longer gates them." >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
1430
- [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1551
+ rm -f "$(trail_cache_dir)/trail-$N"
1552
+ return 0
1431
1553
  }
1432
1554
 
1433
1555
  # classify_predecessor_state <issue> (forge#3168): the helpers hold_merged_trail, resolve_orch_login,
@@ -1470,14 +1592,20 @@ classify_predecessor_state() {
1470
1592
  if [ -z "${FORGE_ROOT:-}" ]; then
1471
1593
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
1472
1594
  FORGE_ROOT=""
1473
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
1474
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
1475
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
1476
- # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
1477
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{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-)"
1478
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
1595
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
1596
+ _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
1597
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
1598
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
1599
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
1600
+ # 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.
1601
+ _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")")"
1602
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
1603
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
1604
+ # 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)
1605
+ _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)"
1606
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
1479
1607
  # '${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.
1480
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
1608
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
1481
1609
  while IFS= read -r _c; do
1482
1610
  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
1483
1611
  done <<< "$_k"
@@ -1496,8 +1624,8 @@ classify_predecessor_state() {
1496
1624
  # forge#3148: GATED when Step 4B item 0 escalated a merged issue with an incomplete phase trail
1497
1625
  # (the code is in the base branch, but /work-on cannot repair a merged issue, so a human must review it;
1498
1626
  # dependents wait until a human clears needs-human). forge#3169: the hold is NOT keyed on the verifier. A passing
1499
- # verifier does not mean "not held" (an agent-reported bypass verifies clean), and hold_merged_trail posts the
1500
- # comment before the label, so merged + needs-human with no record is a failed/partial hold: fail closed (GATED).
1627
+ # verifier does not mean "not held" (an agent-reported bypass verifies clean), and hold_merged_trail writes the
1628
+ # label before the comment (forge#3223), so merged + needs-human with no record is a partial hold: fail closed (GATED).
1501
1629
  # A human release is the removal of needs-human, recorded by the release sweep via release_merged_trail.
1502
1630
  local TRAIL_ESC
1503
1631
  TRAIL_ESC=$(trail_escalation_state "$PRED")
@@ -1522,7 +1650,7 @@ classify_predecessor_state() {
1522
1650
  else
1523
1651
  # Unverified trail with no hold on record: establish it now so a human can release it.
1524
1652
  # Output is discarded (stdout is the classification), but a failed hold is reported on stderr, not swallowed.
1525
- [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$PRED"
1653
+ rm -f "$(trail_cache_dir)/trail-$PRED"
1526
1654
  [ "${DRY_RUN:-false}" = "true" ] || [ "$(trail_escalation_state "$PRED")" != "ABSENT" ] || hold_merged_trail "$PRED" "PHASE_TRAIL: FAIL (re-verified at wake classification)" >/dev/null || echo "classify_predecessor_state: hold on #${PRED} could not be verified" >&2
1527
1655
  echo "GATED"
1528
1656
  fi ;;
@@ -1859,14 +1987,20 @@ done
1859
1987
  ```bash
1860
1988
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
1861
1989
  FORGE_ROOT=""
1862
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
1863
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
1864
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
1865
- # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
1866
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{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-)"
1867
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
1990
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
1991
+ _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
1992
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
1993
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
1994
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
1995
+ # 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.
1996
+ _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")")"
1997
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
1998
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
1999
+ # 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)
2000
+ _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)"
2001
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
1868
2002
  # '${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.
1869
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
2003
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
1870
2004
  while IFS= read -r _c; do
1871
2005
  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
1872
2006
  done <<< "$_k"
@@ -1914,7 +2048,7 @@ done
1914
2048
 
1915
2049
  On `PHASE_TRAIL: FAIL`, classify the issue as a **bypass**, never `DONE`. Classify ONLY on objective evidence — the trail verdict above and the issue's labels. Do NOT reclassify an issue from a worker's own narrative ("ran inline", "dispatched reviewers directly", "skipped an optional step"): every phase runs forked and the merge itself is gated (CI green on the exact merged commit, reviewed head or docs-only delta, phase trail), so a merged issue whose trail passes is DONE. Field evidence: narrative-based holds flagged spec-compliant behaviour and put 5 of 6 merged issues in a stress batch on needs-human. Surface it in the operator status update (`PHASE BYPASS: #{NUMBER} — missing: ...`) and do not cascade it as a satisfied predecessor.
1916
2050
 
1917
- **Do NOT re-dispatch a merged issue** (forge#3148): `/work-on` Phase 0B stops on a closed or `workflow:merged` issue before its resume preflight, so a fresh agent exits without repairing anything and the predecessor would stay unresolved forever. The PR is already merged, so the gap can only be resolved by a human. Escalate it instead, which classifies it **GATED** (`classify_predecessor_state` holds `workflow:merged` + `needs-human` with no recorded release, regardless of the verifier; the comment is posted first, then the label, both verified), so item 6.5 tracks its dependents as `blocked-on-human-merge` instead of dispatching them. A merged issue whose trail could not be read (`TRAIL_RC -ge 2`, handled above) is escalated by the same block, so it is gated the same way. A bypass reported by the agent on a `workflow:merged` issue whose trail verifies is handled the same way (set `TRAIL_RC=1` before this block):
2051
+ **Do NOT re-dispatch a merged issue** (forge#3148): `/work-on` Phase 0B stops on a closed or `workflow:merged` issue before its resume preflight, so a fresh agent exits without repairing anything and the predecessor would stay unresolved forever. The PR is already merged, so the gap can only be resolved by a human. Escalate it instead, which classifies it **GATED** (`classify_predecessor_state` holds `workflow:merged` + `needs-human` with no recorded release, regardless of the verifier; the label is written and verified first, then the comment, which is posted only after the label is verified), so item 6.5 tracks its dependents as `blocked-on-human-merge` instead of dispatching them. A merged issue whose trail could not be read (`TRAIL_RC -ge 2`, handled above) is escalated by the same block, so it is gated the same way. A bypass reported by the agent on a `workflow:merged` issue whose trail verifies is handled the same way (set `TRAIL_RC=1` before this block):
1918
2052
  ```bash
1919
2053
  # Only a merged issue is escalated here; an unmerged bypass is resumed by item 2 (see below).
1920
2054
  # Any non-zero rc on a merged issue escalates: rc 1 (missing phases) and rc >= 2 (unverifiable trail) alike,
@@ -1926,10 +2060,10 @@ done
1926
2060
  if [ "${DRY_RUN:-false}" = "true" ]; then
1927
2061
  echo "[DRY-RUN] Would add needs-human and post FORGE:PHASE_TRAIL_FAILED on merged #{NUMBER}"
1928
2062
  else
1929
- # hold_merged_trail (defined with classify_predecessor_state) posts the escalation comment FIRST (re-read before
1930
- # posting, verified), then writes `needs-human` (read-back verified, retried). A merged + needs-human issue with no
1931
- # comment classifies GATED regardless of the verifier. A failed half is reported on stderr and the operator must
1932
- # hold dependents manually (residual: a human clearing the label after both comment attempts failed).
2063
+ # hold_merged_trail (defined with classify_predecessor_state) writes `needs-human` FIRST (read-back verified,
2064
+ # retried), then posts the escalation comment (re-read before posting, verified). If the label is not verified no
2065
+ # comment is posted. A merged + needs-human issue with no comment classifies GATED regardless of the verifier. A
2066
+ # failed half is reported on stderr and the operator must hold dependents manually when the label failed.
1933
2067
  hold_merged_trail {NUMBER} "${TRAIL}" || echo "PHASE TRAIL ESCALATION FAILED: #{NUMBER} — comment or needs-human not verified." >&2
1934
2068
  fi
1935
2069
  fi
@@ -2154,7 +2288,7 @@ done
2154
2288
 
2155
2289
  **YOUR MISSION**: Invoke `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{GATING_PR} --remediate --issue {PRED} --repo {GH_REPO} --gh-flag {GH_FLAG}')` and let it run to completion. This is a self-contained flow: it classifies the gate first, replaces `needs-human` with `workflow:in-review` only for fixable remediation, checks out the PR branch, fixes any fixable review findings, re-reviews, and either auto-lands the PR, holds it at `workflow:awaiting-merge` for a human, re-escalates back to `needs-human`, or reports the block as policy-level and unfixable. Do NOT intervene manually — do not run raw git/gh commands yourself.
2156
2290
 
2157
- **DO NOT STOP EARLY**: if the Skill call returns without a terminal `REMEDIATE_RESULT.status`, invoke it again — it re-reads GitHub state and resumes. Terminal statuses are: `COMPLETE`, `ALREADY_DONE`, `UNFIXABLE`, `BLOCKED`.
2291
+ **DO NOT STOP EARLY**: if the Skill call returns without a terminal `REMEDIATE_RESULT.status`, invoke it again — it re-reads GitHub state and resumes. Terminal statuses are: `COMPLETE`, `ALREADY_DONE`, `UNFIXABLE`, `BLOCKED`, `REREVIEW_REQUIRED`.
2158
2292
 
2159
2293
  Do not ask the user questions — you are running autonomously in the background."
2160
2294
  )
@@ -2163,6 +2297,28 @@ Do not ask the user questions — you are running autonomously in the background
2163
2297
  fi
2164
2298
  ```
2165
2299
 
2300
+ **Re-review handoff (forge#3240)**: a remediation worker can finish with `REMEDIATE_RESULT: status: REREVIEW_REQUIRED` — the fix is pushed and CI is green, but the forked remediation had no sub-agent dispatch tool, so `review-pr` (which refuses to review inline) never ran. The issue is then at `workflow:in-review` WITHOUT `needs-human`, so the trigger above does not fire and the `ALREADY_REMEDIATED` guard (which counts any `FORGE:REMEDIATION`) is irrelevant. This is not a human gate. On the next completion-monitoring cycle, for the issue that just completed, detect it from the paper trail and run the re-review from THIS session (which has dispatch), exactly once:
2301
+
2302
+ ```bash
2303
+ PRED="$NUM"
2304
+ REREVIEW_PR=$(gh pr list -R {GH_REPO} --state open --search "\"Closes #${PRED}\" in:body" \
2305
+ --json number --jq '.[0].number // empty' 2>/dev/null || echo "")
2306
+ if [ -n "$REREVIEW_PR" ]; then
2307
+ NEEDS_REREVIEW=$(gh api repos/{GH_REPO}/issues/${REREVIEW_PR}/comments --paginate \
2308
+ --jq '[.[] | select((.body | contains("FORGE:REMEDIATION:COMPLETE")) and (.body | contains("REREVIEW-REQUIRED")))] | length' 2>/dev/null | awk '{s+=$1} END {print s+0}')
2309
+ REREVIEW_DONE=$(gh api repos/{GH_REPO}/issues/${REREVIEW_PR}/comments --paginate \
2310
+ --jq '[.[] | select(.body | contains("FORGE:REREVIEW_DISPATCHED"))] | length' 2>/dev/null | awk '{s+=$1} END {print s+0}')
2311
+ if [ "$NEEDS_REREVIEW" -ge 1 ] && [ "$REREVIEW_DONE" -eq 0 ] && [ "${DRY_RUN:-false}" != "true" ]; then
2312
+ gh pr comment "$REREVIEW_PR" -R {GH_REPO} --body "<!-- FORGE:REREVIEW_DISPATCHED -->
2313
+ Re-review dispatched by the orchestrator: remediation had no sub-agent dispatch tool."
2314
+ # then, from this session:
2315
+ # Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${REREVIEW_PR} --auto-merge --issue ${PRED} --base {BASE} --gh-flag -R {GH_REPO}")
2316
+ fi
2317
+ fi
2318
+ ```
2319
+
2320
+ The `FORGE:REREVIEW_DISPATCHED` marker bounds this to a single re-review per PR (no loop). On `REVIEW_RESULT: status: COMPLETE` (merged), drive `work-on:close` and let item 6.6 wake dependents as normal; on any other result, review-pr's own verdict/labels (CHANGES REQUESTED, `needs-human` for genuine blockers) apply and the issue is `GATED` or failed per the existing rules. Never review inline.
2321
+
2166
2322
  This satisfies #1809 Q2 (the orchestrator auto-dispatches remediation against the gated issue itself — the exact gap forge#1812's item 6.5/6.6 left open, since those items only ever track and wake *dependents*, never the gated PR's own remediation) — and closes forge#2243 (the gap that #1812's fix left open for *leaf* issues with no dependents: because this item's `$PRED` binding was never made explicit and self-contained, it was only ever reached while walking a dependent's predecessor list, so a `needs-human` issue that has no dependents never got remediation dispatched and its CHANGES-REQUESTED PR re-reviewed the same unchanged commit forever). The remediation agent's outcome is picked up on the **next** completion-monitoring cycle of this same Step 4B loop: if it lands (`workflow:merged`), item 6.6 below fires normally and wakes any `blocked-on-human-merge` dependents (if any exist — a leaf issue simply has none to wake); if it holds/re-escalates, the issue simply remains `GATED` and item 6.5 continues tracking its dependents (if any) unchanged.
2167
2323
 
2168
2324
  6.5. **Handle predecessor gating** (`GATED` — `needs-human` or `workflow:awaiting-merge`) <!-- Added: forge#1812 --> — if a completed agent's issue classifies as `GATED`, its direct dependents are neither dispatched nor marked failed/skipped. For each direct dependent `DEP` of the gated predecessor `PRED`:
@@ -2212,15 +2368,14 @@ Do not ask the user questions — you are running autonomously in the background
2212
2368
 
2213
2369
  6.6. **Release sweep for blocked-on-human-merge dependents** <!-- Added: forge#1812, reworked: forge#3157 --> — run this sweep on **every monitoring cycle** (after items 5-6 of each completion cycle and in each stall-detection pass), not only when an agent just merged: a human clearing `needs-human` on an already-merged, trail-held predecessor (Step 4B item 0) fires no completion event, so a sweep keyed on the just-completed `$NUM` would leave its dependents held until the next `/orchestrate` wake. Every hold the orchestrator creates needs a matching release check on each cycle. The sweep re-classifies the gating predecessors of every `blocked-on-human-merge` dependent and releases a dependent once all of them classify `DONE`:
2214
2370
  ```bash
2215
- # New per-cycle comment cache for trail_escalation_state (human edits between cycles are always re-read).
2216
- [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -rf "$TRAIL_CACHE_DIR"
2217
- TRAIL_CACHE_DIR=$(mktemp -d)
2371
+ # The per-cycle trail cache was already reset by init_trail_cache at the top of this cycle (see "Cycle-start cache
2372
+ # reset" in Step 4B); do NOT reset it here, that would be after items 5-6 and stall detection already read it.
2218
2373
  WOKEN=$(gh issue list -R {GH_REPO} --state open --label "blocked-on-human-merge" --json number \
2219
2374
  --jq '.[].number' 2>/dev/null || echo "")
2220
2375
  for DEP in $WOKEN; do
2221
2376
  GATING_PREDS=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
2222
- --jq '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | (.body | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)] | unique | .[]' 2>/dev/null || echo "")
2223
- [ -n "$GATING_PREDS" ] || continue
2377
+ --jq '[.[] | select((.body // "") | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | ((.body // "") | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)] | unique | .[]' 2>/dev/null || echo "")
2378
+ [ -n "$GATING_PREDS" ] || { echo "WARNING: #${DEP} has blocked-on-human-merge but no parseable Gating predecessor line - leaving blocked" >&2; continue; }
2224
2379
  STILL_GATED=false
2225
2380
  for GPRED in $GATING_PREDS; do
2226
2381
  [ "$(classify_predecessor_state "$GPRED")" = "DONE" ] || STILL_GATED=true
@@ -2230,17 +2385,36 @@ Do not ask the user questions — you are running autonomously in the background
2230
2385
  DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
2231
2386
  --jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
2232
2387
  if [ "$DEP_ALREADY_DISPATCHED" -eq 0 ]; then
2388
+ if [ "${DRY_RUN:-false}" = "true" ]; then echo "DRY_RUN: would release #${DEP} (remove blocked-on-human-merge, post FORGE:UNBLOCKED, dispatch)"; continue; fi
2389
+ # Durable marker FIRST (forge#3182 CONC-3): a crash between label removal and dispatch would otherwise strand DEP with no
2390
+ # hold label and no workflow:* label. Post FORGE:UNBLOCKED before removing the label (deduped against the last comment so a
2391
+ # retried cycle does not repeat it), so an interrupted release leaves durable evidence for a human or later recovery.
2392
+ LAST_DEP_COMMENT=$(gh issue view "$DEP" -R {GH_REPO} --json comments --jq '.comments[-1].body // ""' 2>/dev/null || echo "")
2393
+ case "$LAST_DEP_COMMENT" in "<!-- FORGE:UNBLOCKED -->"*) ;; *)
2394
+ gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
2395
+ All gating predecessor(s) (#$(echo $GATING_PREDS | sed 's/ /, #/g')) are now resolved — merged, or released by a human after a phase-trail hold — dispatching now. (Was tracked via a prior FORGE:BLOCKED_ON_HUMAN_MERGE comment.)" >/dev/null 2>&1 || { echo "WARNING: #${DEP} UNBLOCKED marker not posted — not releasing; retrying next cycle." >&2; continue; } ;;
2396
+ esac
2397
+ # Remove the hold label and verify it is gone (forge#3182). Fail CLOSED: a failed edit OR a failed/empty `view` is
2398
+ # "unverified" — skip dispatch and retry next cycle (the view result is captured separately, never piped into grep).
2399
+ gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" >/dev/null 2>&1 \
2400
+ || { echo "WARNING: #${DEP} label removal failed — not dispatching; retrying next cycle." >&2; continue; }
2401
+ if ! DEP_LABELS=$(gh issue view "$DEP" -R {GH_REPO} --json labels --jq '.labels[].name' 2>/dev/null) \
2402
+ || printf '%s\n' "$DEP_LABELS" | grep -qx "blocked-on-human-merge"; then
2403
+ echo "WARNING: #${DEP} label removal unverified — not dispatching; retrying next cycle." >&2
2404
+ continue
2405
+ fi
2406
+ # Re-check after removal: another path may have labelled DEP workflow:* in the meantime.
2407
+ DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
2408
+ --jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
2409
+ [ "$DEP_ALREADY_DISPATCHED" -eq 0 ] || continue
2233
2410
  # Record a human release of a trail-held merged predecessor so the escalation stops gating (decay).
2234
2411
  for GPRED in $GATING_PREDS; do release_merged_trail "$GPRED"; done
2235
- gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" 2>/dev/null || true
2236
- gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
2237
- All gating predecessor(s) (#$(echo $GATING_PREDS | sed 's/ /, #/g')) are now resolved — merged, or released by a human after a phase-trail hold — dispatching now. (Was tracked via a prior FORGE:BLOCKED_ON_HUMAN_MERGE comment.)"
2238
2412
  echo "#{DEP} unblocked (gating predecessors resolved) — dispatching immediately (Steps 4A.pre.0 → 4A.pre → 4A)."
2239
2413
  # Add DEP to the same-response dispatch batch
2240
2414
  fi
2241
2415
  done
2242
2416
  ```
2243
- `release_merged_trail` is a no-op unless the predecessor carries an ACTIVE merged-trail escalation, so ordinary merges are unaffected. The classifier's per-cycle comment cache bounds the escalation-comment reads to one per predecessor per cycle. For the case where the hold is released after the orchestrator session has already ended, the equivalent check runs in `phase-3-dependency.md`'s wake/compaction reconstruction on the next `/orchestrate` invocation — see that file's "Orchestrator state reconstruction on wake / after compaction" section.
2417
+ `release_merged_trail` is a no-op unless the predecessor carries an ACTIVE merged-trail escalation, so ordinary merges are unaffected. The classifier's per-cycle comment cache (reset at cycle start by `init_trail_cache`) bounds the escalation-comment reads to one per predecessor per cycle. For the case where the hold is released after the orchestrator session has already ended, the equivalent check runs in `phase-3-dependency.md`'s wake/compaction reconstruction on the next `/orchestrate` invocation — see that file's "Orchestrator state reconstruction on wake / after compaction" section.
2244
2418
 
2245
2419
  6.7. **Human-gated idle/backpressure check** (`BATCH_FULLY_GATED`) <!-- Added: forge#1814 --> — run this after every completion cycle, once the per-issue classification above (items 5-6.6) has been applied for this cycle. It answers a different question than the paused-drain/blocked-on-human-merge tracking above: those items handle *individual* gated predecessors and their *direct* dependents; this check asks whether the **entire original batch** has now exhausted into human-gated states, which is the condition under which continuing to dispatch cascade-spawned review findings (Step 4C) produces net-negative churn — closing 1 issue while opening 2-4 more, with the real blockers (the GATED issues) unresolved:
2246
2420
 
@@ -2338,7 +2512,7 @@ Do not ask the user questions — you are running autonomously in the background
2338
2512
 
2339
2513
  9. **Run staging integrity check** (from Step 4A-pre) if the completed agent merged a PR targeting staging.
2340
2514
 
2341
- **Termination condition**: All issues in the DAG have reached `DONE` or `FAILED` (merged, invalid, or skipped due to dependency failure) — OR are `blocked-on-human-merge` (item 6.5) with no further dispatchable work remaining in the batch. These two outcomes are reported differently: a batch where every issue is `DONE`/`FAILED` is a **clean drain**; a batch where one or more issues remain `blocked-on-human-merge` is a **paused drain** — the active dispatch loop stops (there is nothing left to do until a human merges a gating PR) but this MUST be reported as paused, not as fully complete (see `phase-6-report.md`'s `🔗 Blocked-on-Merge` section). `needs-human` predecessors with no open PR are neither — they remain GATED indefinitely until either a PR appears (dependent moves to blocked-on-human-merge) or the predecessor itself resolves; do not treat isolated `needs-human` issues with no dependents as blocking termination. When either drain condition is met, check whether deferred review-spawned findings exist (accumulated in `DEFERRED_FINDINGS` during Step 4C). If deferred findings exist → proceed to Step 4F (Completion Sweep). If no deferred findings → **call `release_orchestrator_lease()` (Step 4A-pre.-1)** — this is a clean/paused drain of this session's own dispatch loop, so the lease should not be left held for a now-idle session — then proceed to Phase 5. <!-- Added: forge#2627 -->
2515
+ **Termination condition**: All issues in the DAG have reached `DONE` or `FAILED` (merged, invalid, or skipped due to dependency failure) — OR are `blocked-on-human-merge` (item 6.5) with no further dispatchable work remaining in the batch. These two outcomes are reported differently: a batch where every issue is `DONE`/`FAILED` is a **clean drain**; a batch where one or more issues remain `blocked-on-human-merge` is a **paused drain** — the active dispatch loop stops (there is nothing left to do until a human merges a gating PR) but this MUST be reported as paused, not as fully complete (see `phase-6-report.md`'s `🔗 Blocked-on-Merge` section). `needs-human` predecessors with no open PR are neither — they remain GATED indefinitely until either a PR appears (dependent moves to blocked-on-human-merge) or the predecessor itself resolves; do not treat isolated `needs-human` issues with no dependents as blocking termination. When either drain condition is met, check whether deferred review-spawned findings exist (accumulated in `DEFERRED_FINDINGS` during Step 4C). If deferred findings exist → proceed to Step 4F (Completion Sweep). If no deferred findings → **call `cleanup_trail_cache` (Step 4B) and `release_orchestrator_lease()` (Step 4A-pre.-1)** — this is a clean/paused drain of this session's own dispatch loop, so the lease should not be left held for a now-idle session — then proceed to Phase 5. <!-- Added: forge#2627 -->
2342
2516
 
2343
2517
  **Reminder — this is the normal-exit lease release, distinct from the interrupted-stop procedure**: whether the drain is clean or paused, this session is no longer actively dispatching, so its lease must be released here (or, for a paused drain, at minimum have its heartbeat refresh stop — see Step 4A-pre.-1). This complements, but does not replace, the **Stopping the orchestrator** procedure (Step 4A-pre.-0.5) which handles the abnormal case of a mid-dispatch interrupt.
2344
2518
 
@@ -2371,14 +2545,20 @@ RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' f
2371
2545
 
2372
2546
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2373
2547
  FORGE_ROOT=""
2374
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
2375
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
2376
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
2377
- # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
2378
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{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-)"
2379
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
2548
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
2549
+ _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
2550
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
2551
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
2552
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
2553
+ # 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.
2554
+ _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")")"
2555
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
2556
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
2557
+ # 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)
2558
+ _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)"
2559
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
2380
2560
  # '${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.
2381
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
2561
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
2382
2562
  while IFS= read -r _c; do
2383
2563
  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
2384
2564
  done <<< "$_k"
@@ -2403,14 +2583,20 @@ If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR`
2403
2583
  ```bash
2404
2584
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2405
2585
  FORGE_ROOT=""
2406
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
2407
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
2408
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
2409
- # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
2410
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{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-)"
2411
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
2586
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
2587
+ _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
2588
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
2589
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
2590
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
2591
+ # 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.
2592
+ _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")")"
2593
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
2594
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
2595
+ # 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)
2596
+ _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)"
2597
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
2412
2598
  # '${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.
2413
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
2599
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
2414
2600
  while IFS= read -r _c; do
2415
2601
  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
2416
2602
  done <<< "$_k"
@@ -2484,21 +2670,36 @@ for NUM in {active_issue_numbers}; do
2484
2670
  CURRENT_STATE=$(gh issue view $NUM -R {GH_REPO} --json labels \
2485
2671
  --jq '[.labels[].name | select(startswith("workflow:"))] | .[0] // "unknown"')
2486
2672
 
2673
+ # Liveness check (worker-idle-no-live-child): a live child is a harness-tracked task for this
2674
+ # issue that has not reported completion (ENGINE_DISPATCH_MAP[NUM] / OPENCODE_DISPATCH_MAP[NUM]
2675
+ # task still running, or an Agent-spawned worker still executing a tool call), or a FORGE:HEARTBEAT
2676
+ # newer than STALL_TIMEOUT. Set CHILD_LIVE=true when any holds, else CHILD_LIVE=false.
2677
+ # No live child + non-terminal label = the worker yielded waiting for a notification that was
2678
+ # delivered to the root session; its background state is dead.
2679
+
2487
2680
  if [ "$STALL_COUNT" -lt 2 ]; then
2488
- # Auto-resume: post stall annotation and re-invoke /work-on
2681
+ # Auto-recover: post stall annotation, then resume (live child) or relaunch fresh (no live child)
2489
2682
  RESUME_ATTEMPT=$(( STALL_COUNT + 1 ))
2683
+ if [ "$CHILD_LIVE" = "true" ]; then STALL_CAUSE="worker-idle-child-live"; else STALL_CAUSE="worker-idle-no-live-child"; fi
2490
2684
  gh issue comment $NUM -R {GH_REPO} --body "<!-- FORGE:STALL_DETECTED -->
2491
2685
  ## Stall Detected
2492
2686
 
2493
2687
  **Issue**: #${NUM}
2494
2688
  **Elapsed since last activity**: ${ELAPSED_MIN} min (threshold: ${STALL_TIMEOUT} min)
2495
2689
  **Current workflow state**: ${CURRENT_STATE}
2690
+ **Cause**: ${STALL_CAUSE}
2496
2691
  **Auto-resume attempt**: ${RESUME_ATTEMPT} of 2
2497
2692
  **Timestamp**: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
2498
2693
 
2499
- # Resume the agent — collect all resumes and launch in a single message (see Step 4B rule)
2500
- # STALL_RESUME_LIST is accumulated and launched in parallel after the loop
2501
- STALL_RESUME_LIST="$STALL_RESUME_LIST $NUM"
2694
+ if [ "$CHILD_LIVE" = "true" ]; then
2695
+ # Live child: resuming the same agent is safe — collect all resumes and launch in a single message (see Step 4B rule)
2696
+ # STALL_RESUME_LIST is accumulated and launched in parallel after the loop
2697
+ STALL_RESUME_LIST="$STALL_RESUME_LIST $NUM"
2698
+ else
2699
+ # No live child: do NOT resume (a resumed worker keeps its false "background build is running" belief).
2700
+ # STALL_FRESH_LIST is relaunched as a fresh worker after the loop.
2701
+ STALL_FRESH_LIST="$STALL_FRESH_LIST $NUM"
2702
+ fi
2502
2703
  else
2503
2704
  # 2+ prior stalls — auto-resume exhausted, escalate to needs-human
2504
2705
  gh issue edit $NUM -R {GH_REPO} --add-label "needs-human"
@@ -2519,7 +2720,9 @@ done
2519
2720
  # For each NUM in $STALL_RESUME_LIST, call Agent(resume=AGENT_ISSUE_MAP[NUM], run_in_background=true, ...)
2520
2721
  ```
2521
2722
 
2522
- **Resume all stalled agents in a single message** (parallel). Use the same `Agent(resume=...)` pattern as Step 4B — do not wait between individual resumes.
2723
+ **Resume stalled agents that still have a live child** (`STALL_RESUME_LIST`) in a single message (parallel). Use the same `Agent(resume=...)` pattern as Step 4B — do not wait between individual resumes.
2724
+
2725
+ **Relaunch a fresh worker for every issue in `STALL_FRESH_LIST`** (cause `worker-idle-no-live-child`) instead of resuming. A fresh worker/agent is spawned through the exact Step 4A dispatch path for that issue (Agent / OpenCode `task()` / engine variant, same template) — all pipeline state lives in GitHub, so nothing is lost. Append this corrective note to the worker prompt: "The previous worker's background state is dead; no phase is running. Invoke `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{NUM} --under-orchestration')` now and run every phase synchronously to completion — never end your turn to wait for a notification." Before each relaunch run `recovery_claim_blocks "{NUM}"` and `claim_conflicts_with_live_holder "{NUM}"` exactly as in Step 4B (on either, defer instead of launching). Keep slot accounting symmetric: release the stale worker's slot (decrement `ACTIVE_DISPATCH_COUNT`, drop its `AGENT_ISSUE_MAP`/`ENGINE_DISPATCH_MAP`/`OPENCODE_DISPATCH_MAP` entry), then re-increment on the fresh launch and record the new id in the map. The `STALL_COUNT < 2` cap still applies to fresh relaunches.
2523
2726
 
2524
2727
  **Track stall resume cycles separately** from completion-event resumes (Step 4B). If the same issue accumulates ≥ 2 `FORGE:STALL_DETECTED` comments AND still hasn't reached terminal state, do not resume again — the `needs-human` label is already set.
2525
2728
 
@@ -2631,6 +2834,28 @@ When `CASCADE_MAX_AMPLIFICATION` is not `off`, and the current ratio is greater
2631
2834
 
2632
2835
  **Amplification breaker (on by default — forge#3060):** `max_amplification` above only ever sees same-lineage refinements, but most cascade growth is *new-surface* P3 hardening notes on code the batch just wrote. The breaker therefore counts **all** cascade findings: while `AMPLIFICATION_BREAKER_TRIPPED=true` (the latest `CONVERGENCE_WINDOW` ratios are all `>= 1.0`; see the block below), every P3-and-below finding is deferred under rule 6 (below) regardless of lineage. Deferred P3s are not discarded — they stay eligible for the P3 batch sweep (`planP3BatchGroups()`, with `batchExclusionReason()` unchanged: security/billing/domain findings stay unbatched and individually triaged) and for Step 4F's post-drain re-evaluation. P1/P2 findings are admitted exactly as before. The breaker releases when the newest ratio drops below 1.0. It is independent of `orchestration.cascade.policy`; only an explicit `amplification_breaker: off` disables it.
2633
2836
 
2837
+ **Deterministic breaker check (MANDATORY on every dispatch path — engine CLI, Agent-spawn fallback, or a hand-driven orchestrator):** the in-spec bookkeeping above only runs when the Step 4B/4C bash runs. A batch driven through the Agent-spawn fallback never evaluated it and reached about 2 findings per merge (2026-10-08 audit). Before dispatching ANY `review-finding` issue at `priority:P3` or below, and after every completed merge, run `scripts/amplification-breaker.sh` against GitHub state. Its exit code is authoritative, and it overrides an in-memory `AMPLIFICATION_BREAKER_TRIPPED=false`:
2838
+
2839
+ ```bash
2840
+ AMP_SCRIPT=""
2841
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}"; do
2842
+ case "$_c" in /*) [ -z "$AMP_SCRIPT" ] && [ -f "$_c/scripts/amplification-breaker.sh" ] && AMP_SCRIPT="$_c/scripts/amplification-breaker.sh" ;; esac
2843
+ done
2844
+ if [ "$AMPLIFICATION_BREAKER" != "off" ]; then
2845
+ if [ -n "$AMP_SCRIPT" ]; then
2846
+ AMP_LINE=$(bash "$AMP_SCRIPT" --since "$BATCH_T0" -R {GH_REPO}); AMP_RC=$?
2847
+ else
2848
+ AMP_LINE="AMPLIFICATION: script not resolvable — fail closed"; AMP_RC=4
2849
+ fi
2850
+ echo "$AMP_LINE" # include this line verbatim in the Step 4B/4C status update
2851
+ # rc 3 (tripped) or 4 (unreadable / unresolved): defer every P3-and-below finding under rule 6
2852
+ # into planP3BatchGroups() bounded batches. P1/P2 findings are admitted as before.
2853
+ [ "$AMP_RC" -eq 3 ] || [ "$AMP_RC" -eq 4 ] && AMPLIFICATION_BREAKER_TRIPPED=true
2854
+ fi
2855
+ ```
2856
+
2857
+ A run that did not record `BATCH_T0` uses the coordination issue's creation time. An operator instruction such as "everything" or "pick up new issues" does not skip this check (see the override guardrail below).
2858
+
2634
2859
  **Override guardrail (MANDATORY — forge#3060):** An operator directive that lifts the generation cap or token budget (`policy: all`, `--max-generation unlimited`, `--token-budget unlimited`, or a free-text request like "pick up any new issues") widens what is admitted at Phase 1/rule 1/rule 5; it MUST NOT leave P3 cascade unbounded. When such an override is active: (a) keep `AMPLIFICATION_BREAKER=on` unless the operator explicitly set `amplification_breaker: off`; (b) P1/P2 stay unlimited; (c) P3-and-below findings are never dispatched individually — they route through `planP3BatchGroups()` into bounded batches (`batch_max_generation` stays finite); (d) state the replacement bound in the Step 4B/4C status output. An orchestrator MUST NOT satisfy an override by simply deleting a bound without a replacement.
2635
2860
 
2636
2861
  **Status updates (MANDATORY):** every Step 4B/4C status update must include `amplification={FINDINGS_SPAWNED}/{MERGED_UNITS}={AMPLIFICATION_RATIO} breaker={AMPLIFICATION_BREAKER}/{tripped|clear} p3_paused={#AMPLIFICATION_BREAKER_DEFERRED[@]}` so the operator sees divergence before the pipeline does.
@@ -2952,20 +3177,26 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
2952
3177
  # unrounded ratio plus the last CONVERGENCE_WINDOW-1 recorded observations form the window.
2953
3178
  # Applied even when another rule already deferred the finding, and APPENDED to the reason, so
2954
3179
  # Step 4F's `*"amplification breaker"*` hold (checked FIRST) still catches it.
2955
- if [ "$AMPLIFICATION_BREAKER" = "on" ] && echo "$PRIORITY" | grep -qE '^P[3-9]$'; then
3180
+ if [ "$AMPLIFICATION_BREAKER" = "on" ] && [ "$(printf '%s' "$PRIORITY" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]')" = "P3" ]; then
2956
3181
  # Fail CLOSED: if the classifier cannot run (missing module, bad cwd), treat the finding as
2957
3182
  # non-routine so a security/billing finding is never paused. Import via {REPO_PATH} and pass
2958
3183
  # the text through the environment (argv text beginning with "-" is parsed as a node option).
2959
3184
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2960
3185
  FORGE_ROOT=""
2961
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
2962
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
2963
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
2964
- # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
2965
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{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-)"
2966
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
3186
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
3187
+ _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
3188
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
3189
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
3190
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
3191
+ # 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.
3192
+ _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")")"
3193
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
3194
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
3195
+ # 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)
3196
+ _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)"
3197
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
2967
3198
  # '${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.
2968
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
3199
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
2969
3200
  while IFS= read -r _c; do
2970
3201
  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
2971
3202
  done <<< "$_k"
@@ -3118,14 +3349,20 @@ for FINDING_NUM in "${BATCHING_CANDIDATES[@]}"; do
3118
3349
 
3119
3350
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
3120
3351
  FORGE_ROOT=""
3121
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
3122
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
3123
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
3124
- # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
3125
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{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-)"
3126
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
3352
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
3353
+ _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
3354
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
3355
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
3356
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
3357
+ # 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.
3358
+ _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")")"
3359
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
3360
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
3361
+ # 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)
3362
+ _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)"
3363
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
3127
3364
  # '${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.
3128
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
3365
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
3129
3366
  while IFS= read -r _c; do
3130
3367
  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
3131
3368
  done <<< "$_k"
@@ -3800,6 +4037,7 @@ gh issue view ${FINDING_NUM} -R {GH_REPO} --json labels --jq '[.labels[].name |
3800
4037
  **Terminal labels** (only these allow you to stop): \`workflow:merged\`, \`workflow:invalid\`
3801
4038
  **Terminal condition also**: \`needs-human\` label present, \`workflow:awaiting-merge\` label present, OR issue state is \`closed\`
3802
4039
  If the label is NOT terminal (e.g., \`workflow:investigating\`, \`workflow:ready-to-build\`, \`workflow:building\`, \`workflow:in-review\`), invoke \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='${FINDING_NUM} --under-orchestration')\` again immediately. The \`/work-on\` skill will re-read GitHub state and advance to the next phase. Do NOT output a summary, do NOT pause, do NOT ask for confirmation — just invoke it again.
4040
+ **NEVER end a turn while the label is non-terminal.** Every \`Skill(...)\` call runs synchronously to completion in your own turn. Never end or yield your turn to wait for a background phase or a completion notification — notifications go to the orchestrator's root session, never to you, so a turn that ends non-terminal is never resumed. A turn that ends with a non-terminal label and no \`*_RESULT:\` block seen is a contract violation. A \"running\", backgrounded or empty return is not a result: re-read the labels and \`FORGE:*\` markers on GitHub and invoke \`work-on\` again immediately — never wait.
3803
4041
 
3804
4042
  **CRITICAL — SOURCE BRANCH DETECTION**:
3805
4043
  - If the issue has the \`review-finding\` label, read the issue body for \`**Code branch**: \\\`{branch}\\\`\`
@@ -3834,7 +4072,7 @@ Completion Sweep Results:
3834
4072
  Token-gated — still deferred: #{H} (sweep allowance also exhausted — re-evaluable next run)
3835
4073
  ```
3836
4074
 
3837
- **After sweep agents complete** (or if no findings were dispatched): output the budget deferred-issues report (if applicable), **call `release_orchestrator_lease()` (Step 4A-pre.-1)** — the sweep is this session's last dispatch activity, so the lease must not be left held for a now-idle session (the Termination condition's own release call, above, only fires on the no-deferred-findings branch; this is the equivalent release for the deferred-findings/Completion Sweep branch) <!-- Added: forge#2627 -->, then proceed to Phase 5.
4075
+ **After sweep agents complete** (or if no findings were dispatched): output the budget deferred-issues report (if applicable), **call `cleanup_trail_cache` (Step 4B) and `release_orchestrator_lease()` (Step 4A-pre.-1)** — the sweep is this session's last dispatch activity, so the lease must not be left held for a now-idle session (the Termination condition's own release call, above, only fires on the no-deferred-findings branch; this is the equivalent release for the deferred-findings/Completion Sweep branch) <!-- Added: forge#2627 -->, then proceed to Phase 5.
3838
4076
 
3839
4077
  **Anti-patterns — DO NOT DO THIS:**
3840
4078
  - Re-sweeping findings spawned during the sweep itself — this creates unbounded recursion. Sweep is a single pass.