forgedock 1.8.1 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bin/engine/admission.mjs +57 -1
  2. package/bin/engine/admission.test.mjs +60 -0
  3. package/commands/analytics.md +3 -1
  4. package/commands/audit-agents.md +3 -1
  5. package/commands/audit.md +10 -8
  6. package/commands/autopilot.md +16 -14
  7. package/commands/ci-audit.md +4 -2
  8. package/commands/cleanup.md +1 -1
  9. package/commands/deploy-pr.md +31 -11
  10. package/commands/geo-audit.md +3 -1
  11. package/commands/incident-response.md +3 -1
  12. package/commands/issue.md +4 -2
  13. package/commands/milestone.md +8 -6
  14. package/commands/orchestrate/config.md +2 -2
  15. package/commands/orchestrate/phase-1-resolve.md +56 -12
  16. package/commands/orchestrate/phase-3-dependency.md +15 -5
  17. package/commands/orchestrate/phase-4-execution.md +593 -78
  18. package/commands/orchestrate/phase-5-cleanup.md +4 -2
  19. package/commands/orchestrate/phase-6-report.md +7 -3
  20. package/commands/orchestrate.md +13 -9
  21. package/commands/pipeline-health/phase-4-proposals.md +6 -4
  22. package/commands/pipeline-health/phase-5-report.md +4 -2
  23. package/commands/pipeline-health.md +9 -7
  24. package/commands/pipeline-resume.md +4 -2
  25. package/commands/qa-sweep.md +3 -1
  26. package/commands/quality-gate.md +4 -3
  27. package/commands/recover-orphans.md +172 -13
  28. package/commands/review-pr-agents.md +6 -4
  29. package/commands/review-pr-staging.md +13 -5
  30. package/commands/review-pr.md +187 -19
  31. package/commands/rollback.md +3 -1
  32. package/commands/security-audit.md +3 -1
  33. package/commands/signal-planner.md +6 -4
  34. package/commands/sync-ecosystem.md +4 -2
  35. package/commands/test-gate.md +4 -2
  36. package/commands/upgrade-deps.md +3 -1
  37. package/commands/validate.md +3 -1
  38. package/commands/work-on/build/architect.md +81 -19
  39. package/commands/work-on/build/context.md +61 -18
  40. package/commands/work-on/build/implement.md +62 -18
  41. package/commands/work-on/build/validate.md +240 -60
  42. package/commands/work-on/build.md +344 -146
  43. package/commands/work-on/close.md +617 -246
  44. package/commands/work-on/decompose.md +239 -41
  45. package/commands/work-on/investigate.md +377 -61
  46. package/commands/work-on/remediate.md +42 -9
  47. package/commands/work-on/review.md +240 -29
  48. package/commands/work-on-monolithic.md +48 -7
  49. package/commands/work-on.md +180 -2014
  50. package/docs/CODEX.md +2 -0
  51. package/docs/CONFIG.md +11 -0
  52. package/package.json +1 -1
  53. package/scripts/build-spec-graph.mjs +11 -4
  54. package/scripts/check-spec-markers.sh +11 -1
  55. package/scripts/checkbox-sections.test.sh +8 -23
  56. package/scripts/extract-affected-files.sh +5 -2
  57. package/scripts/extract-affected-files.test.sh +27 -0
  58. package/scripts/forge-root.test.sh +176 -0
  59. package/scripts/is-docs-only.sh +31 -0
  60. package/scripts/is-docs-only.test.sh +64 -0
  61. package/scripts/lint-dispatch-prompt.sh +217 -0
  62. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  63. package/scripts/validate-spec-graph.sh +3 -2
  64. package/scripts/verify-phase-trail.sh +282 -0
  65. package/scripts/verify-phase-trail.test.sh +298 -0
  66. package/scripts/wait-ci-green.sh +98 -0
  67. package/scripts/wait-ci-green.test.sh +99 -0
@@ -29,7 +29,7 @@ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "
29
29
  exit 1
30
30
  ;;
31
31
  free|self)
32
- HOSTNAME_ID=$(hostname 2>/dev/null || echo "unknown-host")
32
+ HOSTNAME_ID=$(hostname 2>/dev/null || uname -n 2>/dev/null || true); HOSTNAME_ID="${HOSTNAME_ID:-unknown-host}"
33
33
  # GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
34
34
  gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
35
35
  **Holder Batch ID**: ${BATCH_ID}
@@ -392,10 +392,12 @@ resolve_classify_lane() {
392
392
  [ -n "${OPENCODE_SESSION_ID:-}" ] ||
393
393
  [ -n "${OPENCODE_PID:-}" ] ||
394
394
  [ -n "${OPENCODE:-}" ]; then
395
+ case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/classify-lane.sh') ;; esac # running plugin first
395
396
  [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
396
397
  [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
397
398
  candidates+=("$HOME/.opencode/scripts/classify-lane.sh")
398
399
  else
400
+ case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/classify-lane.sh') ;; esac # running plugin first
399
401
  [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
400
402
  candidates+=("$HOME/.claude/scripts/classify-lane.sh")
401
403
  [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
@@ -444,8 +446,11 @@ declare -A DEFERRED_REASONS
444
446
  # makes the reported amplification ratio look better than it was.
445
447
  FINDINGS_SPAWNED=0
446
448
  MERGED_UNITS=0
449
+ UNIT_MERGED_THIS_CYCLE=false # set per completed unit by the Step 4C accounting block (forge#3060)
447
450
  AMPLIFICATION_RATIO_HISTORY=()
448
451
  AMPLIFICATION_DEFERRED=()
452
+ AMPLIFICATION_BREAKER_DEFERRED=() # findings paused by the on-by-default breaker (forge#3060)
453
+ BREAKER_DEFER_TAG="amplification breaker" # single source for the defer-reason text matched in Step 4F
449
454
  declare -A FINDINGS_BY_SOURCE_PR
450
455
  declare -A REFINEMENT_FINDINGS
451
456
  declare -A NEW_SURFACE_FINDINGS
@@ -504,6 +509,7 @@ declare -A EDGE_REDERIVE_ATTEMPTS
504
509
  # copied ForgeDock's helper scripts into its own repository (#2794/#2791).
505
510
  resolve_extract_affected_files() {
506
511
  local candidates=()
512
+ case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/extract-affected-files.sh') ;; esac # running plugin first
507
513
  [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/extract-affected-files.sh")
508
514
  [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/extract-affected-files.sh")
509
515
  candidates+=("$PWD/scripts/extract-affected-files.sh")
@@ -596,7 +602,7 @@ fi
596
602
 
597
603
  TOKEN_ESTIMATE_PER_FINDING=$(yq '.pipeline.token_estimate_per_finding // 150000' forge.yaml 2>/dev/null || echo 150000)
598
604
 
599
- # Amplification is observational by default. The optional ceiling is evaluated
605
+ # Amplification is observational by default for the same-lineage ceiling (the breaker below is not). The optional ceiling is evaluated
600
606
  # only against same-lineage refinements in Step 4C, never against new surface.
601
607
  CASCADE_MAX_AMPLIFICATION=$(yq '.orchestration.cascade.max_amplification // "off"' forge.yaml 2>/dev/null || echo "off")
602
608
  if [ "$CASCADE_MAX_AMPLIFICATION" != "off" ]; then
@@ -611,7 +617,23 @@ if ! echo "$CONVERGENCE_WINDOW" | grep -qE '^[1-9][0-9]*$'; then
611
617
  echo "WARNING: forge.yaml → orchestration.cascade.convergence_window must be a positive integer — falling back to 3"
612
618
  CONVERGENCE_WINDOW=3
613
619
  fi
614
- echo "Cascade amplification: max_amplification=${CASCADE_MAX_AMPLIFICATION} convergence_window=${CONVERGENCE_WINDOW} (off preserves current admission behavior)"
620
+ # On-by-default amplification circuit breaker (forge#3060). Unlike max_amplification (opt-in,
621
+ # same-lineage refinements only), this counts ALL cascade findings and pauses P3-and-below
622
+ # admission when the ratio stays >= 1.0 for CONVERGENCE_WINDOW merged units. P1/P2 are never
623
+ # affected. It is independent of orchestration.cascade.policy, so `policy: all` and the
624
+ # --max-generation/--token-budget unlimited CLI overrides do NOT disable it. Opt out only
625
+ # with an explicit `orchestration.cascade.amplification_breaker: off`.
626
+ # NOTE: do NOT use yq's `// "on"` here — the alternative operator treats boolean `false` as falsy and
627
+ # would turn `amplification_breaker: false` into "on". Read the raw value and default null/empty to "on".
628
+ AMPLIFICATION_BREAKER=$(yq '.orchestration.cascade.amplification_breaker' forge.yaml 2>/dev/null || echo "on")
629
+ case "$AMPLIFICATION_BREAKER" in ""|null) AMPLIFICATION_BREAKER="on" ;; esac
630
+ case "$(echo "$AMPLIFICATION_BREAKER" | tr '[:upper:]' '[:lower:]')" in
631
+ on|true) AMPLIFICATION_BREAKER="on" ;;
632
+ off|false) AMPLIFICATION_BREAKER="off" ;;
633
+ *) echo "WARNING: forge.yaml → orchestration.cascade.amplification_breaker must be on or off (\"${AMPLIFICATION_BREAKER}\") — keeping the breaker on"; AMPLIFICATION_BREAKER="on" ;;
634
+ esac
635
+ AMPLIFICATION_BREAKER_TRIPPED=false
636
+ echo "Cascade amplification: max_amplification=${CASCADE_MAX_AMPLIFICATION} convergence_window=${CONVERGENCE_WINDOW} amplification_breaker=${AMPLIFICATION_BREAKER} (breaker is on by default; max_amplification off preserves same-lineage behavior)"
615
637
 
616
638
  # Independent boolean levers — each accepts an explicit granular override on
617
639
  # top of the resolved preset (preset supplies the default, not a hard value).
@@ -674,17 +696,21 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
674
696
  ### Step 4A.0: Probe Knowledge Gist capability once
675
697
 
676
698
  Probe the authenticated identity once for this orchestration run before any engine, Agent, or
677
- OpenCode worker is dispatched. A GitHub App installation token identifies as `Bot` and cannot use
678
- the Gists API; cache that fact rather than letting every worker rediscover it by attempting a
679
- write. An unavailable identity probe preserves existing behavior for PAT-authenticated runs.
699
+ OpenCode worker is dispatched. A GitHub App installation token cannot use the Gists API (and cannot even read `/user`); cache
700
+ that fact rather than letting every worker rediscover it by attempting a write. The probe fails
701
+ closed: Gists stay enabled only when `/user` positively reports a `User` account.
680
702
 
681
703
  ```bash
682
704
  if [ -z "${FORGE_GIST_CAPABLE+x}" ]; then
705
+ # Fail closed: only a positively identified user account can use the Gists API. A GitHub App
706
+ # installation token cannot read /user at all (HTTP 403, empty type), so "not Bot" is NOT
707
+ # evidence of capability; an unavailable probe disables Gists rather than letting every
708
+ # worker fail on its first Gist write.
683
709
  GIST_AUTH_TYPE=$(gh api user --jq '.type' 2>/dev/null || true)
684
- if [ "$GIST_AUTH_TYPE" = "Bot" ]; then
685
- FORGE_GIST_CAPABLE=false
686
- else
710
+ if [ "$GIST_AUTH_TYPE" = "User" ]; then
687
711
  FORGE_GIST_CAPABLE=true
712
+ else
713
+ FORGE_GIST_CAPABLE=false
688
714
  fi
689
715
  export FORGE_GIST_CAPABLE
690
716
  fi
@@ -782,7 +808,7 @@ task(
782
808
  description="Work on {PROJECT_PREFIX}#{NUMBER}",
783
809
  subagent_type="general",
784
810
  background=true,
785
- prompt="Use the same Phase 4A work-on template below. Before invoking it, run `export FORGE_GIST_CAPABLE={FORGE_GIST_CAPABLE}` so the cached orchestration capability is preserved. Invoke Skill(skill='work-on', args='{PROJECT_PREFIX}{NUMBER} --under-orchestration') and continue until a terminal workflow state."
811
+ prompt="Use the same Phase 4A work-on template below. Before invoking it, run `export FORGE_GIST_CAPABLE={FORGE_GIST_CAPABLE}` so the cached orchestration capability is preserved. Invoke Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{PROJECT_PREFIX}{NUMBER} --under-orchestration') and continue until a terminal workflow state."
786
812
  )
787
813
  ```
788
814
 
@@ -922,7 +948,7 @@ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
922
948
  # Lease heartbeat refresh (forge#2627) — once per dispatch chunk, so a long-running batch's
923
949
  # lease never goes stale purely from elapsed wall-clock time while dispatch is still active.
924
950
  if [ "${#DISPATCH_NOW[@]}" -gt 0 ] && [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "${BATCH_ID:-}" ]; then
925
- HOSTNAME_ID=$(hostname 2>/dev/null || echo "unknown-host")
951
+ HOSTNAME_ID=$(hostname 2>/dev/null || uname -n 2>/dev/null || true); HOSTNAME_ID="${HOSTNAME_ID:-unknown-host}"
926
952
  # GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
927
953
  gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
928
954
  **Holder Batch ID**: ${BATCH_ID}
@@ -976,6 +1002,8 @@ For each **ready** issue (all predecessors resolved or no predecessors), spawn a
976
1002
 
977
1003
  **One agent per issue.** Do NOT group multiple issues into a single agent. `/work-on` handles branching, labels, and PRs per-issue.
978
1004
 
1005
+ **Resolve `{FORGE_SKILL_PREFIX}` once before dispatching** (`forgedock:` for the plugin install, empty for `install.sh`; rule in `commands/work-on.md` § Skill Name Resolution) and substitute it literally into every copy of the template below (4A, 4B, 4F, remediation and finding dispatches). Subagents cannot see this resolution, so never leave the placeholder unfilled.
1006
+
979
1007
  **Copy this template. Fill in variables. Do not modify the structure:**
980
1008
 
981
1009
  ```
@@ -999,12 +1027,13 @@ Agent(
999
1027
  **HOW REVIEW FINDINGS WORK**: /review-pr may create GitHub issues (with `review-finding` label) for findings it discovers. These are NOT blockers — they are separate work items that will go through their own /work-on pipeline later. The original PR should ALWAYS merge after review. The only exception is build errors (code doesn't compile) — those must be fixed before merging.
1000
1028
 
1001
1029
  **IMPORTANT RULES**:
1002
- - **MANDATORY**: You MUST use the Skill tool to invoke 'work-on' with args '{PROJECT_PREFIX}{NUMBER}'. Do NOT implement manually — /work-on handles the full pipeline including label state machine (workflow:investigating → workflow:building → workflow:in-review → workflow:merged), investigation reports, PR creation, and cleanup.
1003
- - For default repo issues: `Skill(skill='work-on', args='{NUMBER} --under-orchestration')`
1004
- - For satellite repo issues: `Skill(skill='work-on', args='{SATELLITE_PREFIX}:{NUMBER} --under-orchestration')` (prefix from forge.yaml → repos.satellites)
1030
+ - **MANDATORY**: You MUST use the Skill tool to invoke '{FORGE_SKILL_PREFIX}work-on' with args '{PROJECT_PREFIX}{NUMBER}'. Do NOT implement manually — /work-on handles the full pipeline including label state machine (workflow:investigating → workflow:building → workflow:in-review → workflow:merged), investigation reports, PR creation, and cleanup.
1031
+ - For default repo issues: `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{NUMBER} --under-orchestration')`
1032
+ - For satellite repo issues: `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='<satellite-prefix>:{NUMBER} --under-orchestration')` (`<satellite-prefix>` from forge.yaml → repos.satellites)
1033
+ - **If `{FORGE_SKILL_PREFIX}work-on` is reported unknown or not found, this is a HARD ERROR: STOP and report 'skill not found: {FORGE_SKILL_PREFIX}work-on' as your final result. Do NOT run the pipeline phases inline or with the Agent tool — an inline run bypasses the phase trail.**
1005
1034
  - The `--under-orchestration` flag tells `/work-on` to post its phase-entry `FORGE:HEARTBEAT` comments (Phases 0/1/3/5) — this orchestrator's Step 4B.5 stall detector depends on those timestamps. A solo `/work-on` run omits the flag and skips those writes entirely (see `commands/work-on.md` → Orchestration Flag).
1006
1035
  - NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
1007
- - **File-backed GitHub bodies — entity scope plus read-back is mandatory**: Never stage a `gh --body-file` body at a generic shared `/tmp` path, including one made by bare `mktemp`, and never hand-roll a root-level path such as `/tmp_invbody_31076.txt`, which can hang unattended cleanup. Prefer the session scratchpad or a repo-relative scratch directory on Windows: a native Windows `gh` may not resolve Git Bash `/tmp` reliably. Create a filename that contains the target issue/PR number and an agent-unique token, then use `mktemp` for its random suffix. Put one caller-chosen marker such as `<!-- FORGE:BODY-INTEGRITY:${NUMBER}_investigator_${AGENT_TOKEN} -->` in the body. After every `gh issue create|edit` or `gh pr create|comment` using `--body-file`, re-read the target object and assert the exact marker is present; a mismatch is a hard error. Unique names reduce collisions, but only read-back detects a collision that substitutes plausible-looking content from another agent. Do not rely on eyeballing. (forge#2843, forge#2855) <!-- allowlist:check-command-side-effects -->
1036
+ - **File-backed GitHub bodies — entity scope plus read-back is mandatory**: Never stage a `gh --body-file` body at a generic shared `/tmp` path, including one made by bare `mktemp`, and never hand-roll a root-level path such as `/tmp_invbody_31076.txt`, which can hang unattended cleanup. Prefer the session scratchpad or a repo-relative scratch directory on Windows: a native Windows `gh` may not resolve Git Bash `/tmp` reliably. Create a filename that contains the target issue/PR number and an agent-unique token, then use `mktemp` for its random suffix. Put one caller-chosen marker such as `<!-- FORGE:BODY-INTEGRITY:<issue-number>_investigator_<agent-token> -->` in the body. After every `gh issue create|edit` or `gh pr create|comment` using `--body-file`, re-read the target object and assert the exact marker is present; a mismatch is a hard error. Unique names reduce collisions, but only read-back detects a collision that substitutes plausible-looking content from another agent. Do not rely on eyeballing. (forge#2843, forge#2855) <!-- allowlist:check-command-side-effects -->
1008
1037
  - **GitHub secondary rate limit**: If a GitHub API call returns HTTP 403 with `secondary rate limit` in its response, do NOT retry it or start a polling loop. Stop GitHub content creation for this phase, report the status and response body to the orchestrator in your final result, and wait for a later batch resume. Retrying extends the throttle for every sibling.
1009
1038
  - **Temp files — ALWAYS use `mktemp`, NEVER hand-roll a path**: You are one of several agents running concurrently on this host and you share its `/tmp` with all of them. Any time you stage content in a temp file before passing it to `gh` (e.g. `--body-file`), create that path with `mktemp` (e.g. `BODY_FILE="$(mktemp)"`). Never write a temp file to a single-segment root path such as `/tmp_invbody_31076.txt`: Claude Code treats removing it as dangerous and requires an explicit approval that bypass mode cannot clear, hanging unattended runs. Also never use a fixed literal such as `/tmp/body.md` or `/tmp/issue.json`: a fixed path collides with another concurrently-running agent and can silently overwrite the content you staged (or you can silently overwrite theirs) before either of you reads it back. Safe: `BODY_FILE="$(mktemp)"`. Unsafe: `/tmp_invbody_31076.txt` (a missing slash that creates a root-level path) and `/tmp/body.md` (a fixed path). `mktemp` costs nothing and prevents both failures. (forge#2198, forge#2855)
1010
1039
  - **`docker cp` — NEVER write into a bind-mounted shared container**: If the project you're working on runs its dev/test containers with a bind mount to the main (non-worktree) checkout, `docker cp` into that container writes through the mount into the main checkout — not your isolated per-issue worktree. Before running `docker cp` into any container, confirm its mount source is your own worktree, not a shared/main one; if you can't confirm that, don't assume an in-container test run reflects your feature branch. (forge#2198)
@@ -1016,30 +1045,115 @@ Agent(
1016
1045
  - **NEVER ask the user questions** — you are a background agent. If review finds issues, auto-fix simple ones and proceed, then let `/review-pr`'s own verdict decide: APPROVED (no unresolved CONFIRMED HIGH/CRITICAL finding) → merge to `staging` and create follow-up issues for the rest, **regardless of domain**. Domain alone (AUTH, BILLING, DATABASE, or any domain tagged as security-critical in Step 3B) is NOT a reason to add `needs-human` and stop — `staging` is reversible; the real human deploy gate is `staging → main`, not this merge. <!-- Added: forge#1815 --> `needs-human` is reserved for what the pipeline genuinely cannot do itself: spend/procurement decisions, real-environment validation it has no access to, product/architecture judgment calls a human must make, or `/review-pr`'s existing evidence-based escalations (spec-evolution guard, novel task-type/module-combo trust escalation, calibration-based overconfidence routing). An unresolved CONFIRMED HIGH/CRITICAL finding is `/review-pr`'s own withheld-APPROVED case, not a domain-driven `needs-human` halt — `/review-pr` already refuses to return APPROVED when that's true, so there is no separate domain check to perform here.
1017
1046
 
1018
1047
  **LABEL-STATE LOOP CONTRACT — enforce after EVERY Skill return**:
1019
- After EVERY `Skill(skill='work-on', ...)` call returns, immediately check the issue's current workflow label:
1048
+ After EVERY `Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)` call returns, immediately check the issue's current workflow label:
1020
1049
  ```bash
1021
1050
  gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name | select(startswith("workflow:"))]'
1022
1051
  ```
1023
1052
  **Terminal labels** (only these allow you to stop): `workflow:merged`, `workflow:invalid`
1024
1053
  **Terminal condition also**: `needs-human` label present, `workflow:awaiting-merge` label present, OR issue state is `closed`
1025
1054
  `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`).
1026
- If the label is NOT terminal (e.g., `workflow:investigating`, `workflow:ready-to-build`, `workflow:building`, `workflow:in-review`), invoke `Skill(skill='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.
1055
+ 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.
1027
1056
 
1028
1057
  **CRITICAL — SOURCE BRANCH DETECTION**:
1029
1058
  - If the issue has the `review-finding` label, read the issue body for `**Code branch**: \`{branch}\``
1030
- - If found, that is the SOURCE_BRANCH — the code ONLY exists on that branch (e.g., `staging`), NOT on `origin/main`
1031
- - Investigation MUST use `git show origin/{SOURCE_BRANCH}:{filepath}` to verify the code exists
1032
- - Worktree MUST branch from `origin/{SOURCE_BRANCH}`, NOT `origin/main`
1033
- - PR target is `{SOURCE_BRANCH}` (the fix goes back to where the code lives)
1059
+ - If found, that branch is the source branch (`<source-branch>` below) — the code ONLY exists on that branch (e.g., `staging`), NOT on `origin/main`
1060
+ - Investigation MUST use `git show origin/<source-branch>:<filepath>` to verify the code exists
1061
+ - Worktree MUST branch from `origin/<source-branch>`, NOT `origin/main`
1062
+ - PR target is `<source-branch>` (the fix goes back to where the code lives)
1034
1063
 
1035
1064
  **LANE**: {LANE} (PR target: {PR_BASE})
1036
1065
  **Issue title**: {ISSUE_TITLE}
1066
+ <!-- DISPATCH_CONTEXT:BEGIN -->
1037
1067
  {GIST_CONTEXT}
1038
1068
  {SOURCE_PR_HINT_CONTEXT}
1069
+ <!-- DISPATCH_CONTEXT:END -->
1039
1070
  "
1040
1071
  )
1041
1072
  ```
1042
1073
 
1074
+ **Prompt lint (MANDATORY before every dispatch — a best-effort, self-run guard for Hard Rule 1; no hook gates `Agent(...)`, so it is orchestrator-policed, not tamper-proof)** <!-- Added: forge#3062 -->: the only variable text allowed in the rendered prompt is the `{GIST_CONTEXT}`/`{SOURCE_PR_HINT_CONTEXT}` blocks between the `DISPATCH_CONTEXT` markers (which already carry the claims-board URL and the same-file "what changed" brief). Before every `Agent(...)`/`task(...)` launch built from the Step 4A template — initial dispatch, Step 4B resume/relaunch, Step 4F sweep — write the fully rendered prompt to a `mktemp` file and run the lint. A non-zero exit means the dispatch is REFUSED (the snippet below sets the refusal by appending to `LINT_REFUSED_ISSUES[]` and skipping that issue with `continue`): do not launch, do not "fix" the prompt by adding text — re-render from the unmodified template, drop the offending context block, and report every `LINT_REFUSED_ISSUES[]` entry in the operator status update (`DISPATCH REFUSED: #{NUMBER} — {reason}`). A missing/unreadable `scripts/lint-dispatch-prompt.sh` is a distinct `lint-script-not-found` refusal (fail closed, never a silent pass); do not conflate it with a lint verdict. **Exempt (not Step 4A-template prompts, no free-text variable slot, so the template-anchor lint does not apply)**: the Codex/OpenCode `task()` wrapper prompt in the engine-fallback section and the remediation `Agent()` prompt in Step 4B item 6.4 — both are fixed one-purpose wrappers that only invoke `Skill(...work-on...)` with substituted identifiers; never add free text to them.
1075
+
1076
+ ```bash
1077
+ # Bind the lint inputs FIRST (forge#3149). Every value is the SAME one substituted into the Step 4A template for this
1078
+ # issue (config.md / Step 4A resolve them) - substitute the literal values, never re-derive them. The rendered
1079
+ # prompt goes in through a quoted heredoc so no shell expansion touches it. A value containing a single quote is
1080
+ # written as '\'' . PROJECT_PREFIX, SATELLITE_PREFIX, FORGE_SKILL_PREFIX and SOURCE_BRANCH may be empty.
1081
+ PROJECT_NAME='{PROJECT_NAME}'; GH_REPO='{GH_REPO}'; REPO_PATH='{REPO_PATH}'
1082
+ LANE='{LANE}'; PR_BASE='{PR_BASE}'; STAGING_BRANCH='{STAGING_BRANCH}'
1083
+ SOURCE_BRANCH='{SOURCE_BRANCH}'; FORGE_GIST_CAPABLE='{FORGE_GIST_CAPABLE}'; SUBAGENT_MODEL='{SUBAGENT_MODEL}'
1084
+ PROJECT_PREFIX='{PROJECT_PREFIX}'; SATELLITE_PREFIX='{SATELLITE_PREFIX}'; FORGE_SKILL_PREFIX='{FORGE_SKILL_PREFIX}'
1085
+ # Heredoc into a file, then read it back: a heredoc body inside $( ) is parsed for quote balance by bash 3.2 (macOS),
1086
+ # and the rendered prompt contains apostrophes and parentheses.
1087
+ # The delimiter MUST be a string that occurs nowhere in the rendered prompt (the GIST/SOURCE_PR_HINT context blocks carry
1088
+ # untrusted text): replace RENDERED_PROMPT_EOF below with RENDERED_PROMPT_EOF_ plus 16 random hex chars, in BOTH places.
1089
+ RENDERED_PROMPT_SRC="$(mktemp)"
1090
+ cat > "$RENDERED_PROMPT_SRC" <<'RENDERED_PROMPT_EOF'
1091
+ {the fully rendered Step 4A prompt text for this issue, exactly as passed to Agent(prompt=...)}
1092
+ RENDERED_PROMPT_EOF
1093
+ RENDERED_PROMPT=$(cat "$RENDERED_PROMPT_SRC"); rm -f "$RENDERED_PROMPT_SRC"
1094
+ ```
1095
+
1096
+ ```bash
1097
+ # Runs inside the per-issue dispatch loop (same loop as claim_conflicts_with_live_holder above),
1098
+ # immediately before that issue's Agent()/task() call. LINT_REFUSED_ISSUES is initialised once, empty.
1099
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
1100
+ FORGE_ROOT=""
1101
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
1102
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
1103
+ _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")")"
1104
+ # 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)
1105
+ _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-)"
1106
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
1107
+ # '${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.
1108
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
1109
+ while IFS= read -r _c; do
1110
+ 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
1111
+ done <<< "$_k"
1112
+ fi
1113
+ # Fail closed on unbound inputs (forge#3149): an empty rendered prompt or a missing resolved value would lint an empty file
1114
+ # (rc=2) or compare against nothing; refuse with a distinct reason instead of a misleading lint verdict.
1115
+ if [ -z "$RENDERED_PROMPT" ] || [ -z "$PROJECT_NAME" ] || [ -z "$GH_REPO" ] || [ -z "$REPO_PATH" ] || [ -z "$LANE" ] \
1116
+ || [ -z "$PR_BASE" ] || [ -z "$STAGING_BRANCH" ] || [ -z "$FORGE_GIST_CAPABLE" ] || [ -z "$SUBAGENT_MODEL" ]; then
1117
+ echo "DISPATCH REFUSED for #{NUMBER}: lint inputs unbound (RENDERED_PROMPT/PROJECT_NAME/GH_REPO/REPO_PATH/LANE/PR_BASE/STAGING_BRANCH/FORGE_GIST_CAPABLE/SUBAGENT_MODEL must be set by the binding block above)" >&2
1118
+ LINT_REFUSED_ISSUES+=("{NUMBER}:lint-inputs-unbound")
1119
+ continue
1120
+ fi
1121
+ LINT_SCRIPT="$FORGE_ROOT/scripts/lint-dispatch-prompt.sh"
1122
+ if [ -z "$FORGE_ROOT" ] || [ ! -r "$LINT_SCRIPT" ]; then
1123
+ # Fail closed: a missing lint script is NOT a pass and NOT a lint verdict.
1124
+ echo "DISPATCH REFUSED for #{NUMBER}: lint script not found at $LINT_SCRIPT (set FORGEDOCK_HOME/FORGE_HOME) — cannot verify Hard Rule 1 (ForgeDock install root unresolved: set FORGEDOCK_HOME)" >&2
1125
+ LINT_REFUSED_ISSUES+=("{NUMBER}:lint-script-not-found")
1126
+ continue
1127
+ fi
1128
+ PROMPT_FILE="$(mktemp)"
1129
+ printf '%s' "$RENDERED_PROMPT" > "$PROMPT_FILE"
1130
+ # Exact-match expectations: every slot the orchestrator resolved itself must equal, byte for byte, the
1131
+ # value it substituted into the template (the SAME variables, never re-derived). One quoted argument per
1132
+ # slot, so spaces, parentheses, backslashes and non-ASCII are safe. A slot present in the prompt with no
1133
+ # --expect is refused (fail closed). PROJECT_PREFIX / FORGE_SKILL_PREFIX may legitimately be empty.
1134
+ LINT_EXPECT=(
1135
+ --expect "PROJECT_NAME=$PROJECT_NAME" --expect "GH_REPO=$GH_REPO"
1136
+ --expect "REPO_PATH=$REPO_PATH" --expect "LANE=$LANE"
1137
+ --expect "PR_BASE=$PR_BASE" --expect "STAGING_BRANCH=$STAGING_BRANCH"
1138
+ --expect "SOURCE_BRANCH=$SOURCE_BRANCH" --expect "NUMBER={NUMBER}"
1139
+ --expect "FORGE_GIST_CAPABLE=$FORGE_GIST_CAPABLE" --expect "SUBAGENT_MODEL=$SUBAGENT_MODEL"
1140
+ --expect "PROJECT_PREFIX=$PROJECT_PREFIX" --expect "SATELLITE_PREFIX=$SATELLITE_PREFIX"
1141
+ --expect "FORGE_SKILL_PREFIX=$FORGE_SKILL_PREFIX"
1142
+ )
1143
+ bash "$LINT_SCRIPT" "${LINT_EXPECT[@]}" "$PROMPT_FILE"; LINT_RC=$?
1144
+ rm -f "$PROMPT_FILE"
1145
+ if [ "$LINT_RC" -ne 0 ]; then
1146
+ # rc 1 = template deviation (violations printed above); rc 2 = unreadable/empty prompt; any other rc = lint crashed.
1147
+ echo "DISPATCH REFUSED for #{NUMBER}: prompt lint rc=$LINT_RC (Hard Rule 1)" >&2
1148
+ LINT_REFUSED_ISSUES+=("{NUMBER}:lint-rc-$LINT_RC")
1149
+ continue # skip this issue's Agent()/task() call; never launch the failing prompt
1150
+ fi
1151
+ ```
1152
+
1153
+ **Exact-match placeholders**: the lint does not guess whether a slot "looks like" a project name, path or branch (every charset/stopword heuristic was bypassed within a round). Each orchestrator-resolved slot (`{PROJECT_NAME}`, `{GH_REPO}`, `{REPO_PATH}`, `{LANE}`, `{PR_BASE}`, `{STAGING_BRANCH}`, `{SOURCE_BRANCH}`, `{NUMBER}`, `{FORGE_GIST_CAPABLE}`, `{SUBAGENT_MODEL}`, `{PROJECT_PREFIX}`, `{SATELLITE_PREFIX}`, `{FORGE_SKILL_PREFIX}`) must equal the `--expect KEY=VALUE` passed for it, and a slot with no `--expect` is refused. Only the free-text slots (issue title line, `DISPATCH_CONTEXT` block) are scanned for directive phrases. When you add a new resolved placeholder to the 4A template, add its `--expect` here and its key to `scripts/lint-dispatch-prompt.test.sh`.
1154
+
1155
+ The lint rejects: template deviations, any resolved placeholder that differs from its expected value, any text outside the context block after the issue title, and prompts that pre-decide a verdict or fix ("likely already resolved — verify and close", "the fix is ...", "make X terminal", "finish what the previous agent left"). The orchestrator never diagnoses, designs fixes, or summarizes what an issue "probably" needs — investigation is the agent's job via `work-on/investigate`.
1156
+
1043
1157
  **`{GIST_CONTEXT}` generation**: For each issue being dispatched, build the context block. **Prefer the deconflicted `FORGE:SYNTHESIS_BRIEF` (from Phase 2.5) when one exists** — it is a per-issue, already-reconciled brief that carries only the arbitration decisions and sibling investigation Gists relevant to *this* issue. Injecting it instead of the full aggregated milestone-index gist means the agent does not re-arbitrate the same contradictions (less token spend, less nondeterminism). Only when Phase 2.5 did not run (0/1 investigations — no brief exists) does this fall back to the raw parent-investigation + milestone-index gist behavior. <!-- Added: forge#1192 -->
1044
1158
 
1045
1159
  ```bash
@@ -1111,7 +1225,7 @@ Claims board issue URL: ${FORGE_COORD_ISSUE}
1111
1225
 
1112
1226
  On build start (Phase B2 / Phase 3C of /work-on), post a FORGE:CLAIM annotation on the
1113
1227
  coordination issue above. Required fields:
1114
- Holder: ##{NUMBER} / batch-$(date -u +%Y%m%dT%H%M%S)
1228
+ **Holder**: #{NUMBER} / batch-$(date -u +%Y%m%dT%H%M%S) (exact form — read_active_claims parses `**Holder**: #N`)
1115
1229
  Files: (list of files from your FORGE:CONTRACT deliverables table, one per line)
1116
1230
  Interfaces: (public function/type signatures you will modify or that callers must preserve)
1117
1231
  TTL: terminal state of Holder issue ##{NUMBER}
@@ -1226,9 +1340,97 @@ For an OpenCode completion, retain `OPENCODE_DISPATCH_MAP[{NUMBER}]` until the t
1226
1340
  **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:
1227
1341
 
1228
1342
  ```bash
1343
+ # hold_merged_trail <issue> <trail-text> (forge#3156): the single escalation path for a merged issue whose phase
1344
+ # trail failed or could not be verified. Used by Step 4B item 0 and by the classifier's wake re-verification.
1345
+ # Order matters: the `needs-human` label is written FIRST (read-back verified, 3 tries) because the classifier
1346
+ # holds on merged + needs-human even before the comment exists; the comment follows (verified, 2 tries) and is
1347
+ # the audit record that lets a human release the hold. Returns 0 when the label hold is verified, 1 otherwise.
1348
+ hold_merged_trail() {
1349
+ local N="$1" TXT="$2" TRY HELD=1
1350
+ for TRY in 1 2 3; do
1351
+ gh issue edit "$N" -R {GH_REPO} --add-label "needs-human" >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
1352
+ if gh issue view "$N" -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null \
1353
+ | grep -qE '(^|,)needs-human(,|$)'; then HELD=0; break; fi
1354
+ done
1355
+ [ "$HELD" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — needs-human label could not be verified; dependents are NOT held. Hold them manually." >&2
1356
+ for TRY in 1 2; do
1357
+ # forge#3157: dedupe only against an ACTIVE escalation, so a re-escalation after a recorded release posts a new one.
1358
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1359
+ [ "$(trail_escalation_state "$N")" = "ACTIVE" ] && break
1360
+ gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_FAILED -->
1361
+ #${N} merged with an incomplete phase trail (or one that could not be verified), detected by /orchestrate.
1362
+
1363
+ \`\`\`
1364
+ ${TXT}
1365
+ \`\`\`
1366
+
1367
+ 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
1368
+ done
1369
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1370
+ return "$HELD"
1371
+ }
1372
+
1373
+ # resolve_orch_login (forge#3168): prints the login of the identity that authors the orchestrator's own FAILED/RELEASED
1374
+ # marker comments, so trail_escalation_state can trust them even when the bot reports author_association NONE or
1375
+ # CONTRIBUTOR. Resolution order: FORGE_BOT_LOGIN env override, then the git identity (`git config user.name`, which for a
1376
+ # GitHub App is its `<app>[bot]` login). Never calls `/user` (403s for App tokens). An unresolved value prints nothing and
1377
+ # the caller fails closed: only the author_association set below is trusted, never "trust every author".
1378
+ resolve_orch_login() {
1379
+ local L="${FORGE_BOT_LOGIN:-}"
1380
+ [ -n "$L" ] || L=$(git config user.name 2>/dev/null || true)
1381
+ printf '%s' "$L"
1382
+ }
1383
+
1384
+ # trail_escalation_state <issue> (forge#3157): prints ABSENT (no merged-trail escalation comment), ACTIVE (newest
1385
+ # FORGE:PHASE_TRAIL_FAILED comment id is greater than the newest FORGE:PHASE_TRAIL_RELEASED id), RELEASED (a human
1386
+ # release was recorded after the last escalation, so a later unrelated `needs-human` no longer gates), or UNREADABLE.
1387
+ # forge#3168: only TRUSTED comments count, for BOTH markers. Trusted = author_association OWNER/MEMBER/COLLABORATOR, or
1388
+ # `.user.login` equal to the orchestrator identity (resolve_orch_login, used only when non-empty). A marker string posted
1389
+ # by anyone else is ignored (treated as absent), so an outside commenter can neither forge a release nor a failure.
1390
+ # The comment read is cached per issue for the current monitoring cycle: the cache is a directory of files
1391
+ # (`$(...)` subshells lose shell arrays), `TRAIL_CACHE_DIR=$(mktemp -d)` and is recreated at the start of every
1392
+ # monitoring cycle (Step 4B item 6.6), so a human edit made between cycles is always seen. Only successful reads are cached.
1393
+ trail_escalation_state() {
1394
+ local N="$1" F="${TRAIL_CACHE_DIR:+$TRAIL_CACHE_DIR/trail-$1}" RAW OUT FAILED_ID RELEASED_ID RES
1395
+ if [ -n "$F" ] && [ -s "$F" ]; then cat "$F"; return; fi
1396
+ 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
1397
+ echo "UNREADABLE"; return
1398
+ fi
1399
+ 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
1400
+ echo "UNREADABLE"; return
1401
+ fi
1402
+ FAILED_ID=$(printf '%s\n' "$OUT" | awk '$1=="F"&&$2+0>m{m=$2+0}END{print m+0}')
1403
+ RELEASED_ID=$(printf '%s\n' "$OUT" | awk '$1=="R"&&$2+0>m{m=$2+0}END{print m+0}')
1404
+ if [ "$FAILED_ID" -eq 0 ]; then RES="ABSENT"
1405
+ elif [ "$RELEASED_ID" -gt "$FAILED_ID" ]; then RES="RELEASED"
1406
+ else RES="ACTIVE"; fi
1407
+ [ -n "$F" ] && printf '%s\n' "$RES" > "$F"
1408
+ echo "$RES"
1409
+ }
1410
+
1411
+ # release_merged_trail <issue> (forge#3157): record that a human cleared a merged-trail hold, so the escalation
1412
+ # stops gating. Call only after classify_predecessor_state reported DONE for a merged issue whose escalation is ACTIVE.
1413
+ # Idempotent (no-op unless ACTIVE) and never run in dry-run. The comment must not contain the escalation marker text.
1414
+ release_merged_trail() {
1415
+ local N="$1"
1416
+ [ "${DRY_RUN:-false}" = "true" ] && return 0
1417
+ [ "$(trail_escalation_state "$N")" = "ACTIVE" ] || return 0
1418
+ gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_RELEASED -->
1419
+ 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 -->
1420
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1421
+ }
1422
+
1423
+ # classify_predecessor_state <issue> (forge#3168): the helpers hold_merged_trail, resolve_orch_login,
1424
+ # trail_escalation_state and release_merged_trail above MUST be declared in the SAME shell before this function runs
1425
+ # (reverify_merged_trail is nested below). It has exactly ONE guarded write (the DRY_RUN-guarded hold_merged_trail
1426
+ # call in the unverified-merged branch); every other path is read-only.
1229
1427
  classify_predecessor_state() {
1230
1428
  local PRED="$1"
1231
- local PRED_INFO
1429
+ local PRED_INFO H
1430
+ for H in hold_merged_trail resolve_orch_login trail_escalation_state release_merged_trail; do
1431
+ # Fail loudly (stderr) but closed: a missing helper must never read as DONE.
1432
+ type "$H" >/dev/null 2>&1 || { echo "classify_predecessor_state: helper $H is not declared in this shell; classifying #${PRED} GATED" >&2; echo "GATED"; return; }
1433
+ done
1232
1434
  # NOTE: the `workflow` array below deliberately also keeps `needs-human` — that label has NO
1233
1435
  # `workflow:` prefix (see bin/labels.json), so a bare `select(startswith("workflow:"))` would
1234
1436
  # drop it and the GATED branch's `needs-human` case would be dead code (forge#1812 primary case).
@@ -1238,10 +1440,62 @@ classify_predecessor_state() {
1238
1440
  PRED_STATE=$(echo "$PRED_INFO" | jq -r '.state // "OPEN"')
1239
1441
  PRED_LABELS=$(echo "$PRED_INFO" | jq -r '.workflow[]?' 2>/dev/null)
1240
1442
 
1443
+ # forge#3156: an empty/failed label read must never fall through to IN_PROGRESS/DONE for a merged issue. PRED_INFO
1444
+ # is '{}' on a failed read; re-read once, then fail closed (GATED) rather than guessing from absent labels.
1445
+ if [ "$PRED_INFO" = '{}' ]; then
1446
+ PRED_INFO=$(gh issue view "$PRED" -R {GH_REPO} --json labels,state \
1447
+ --jq '{state: .state, workflow: [.labels[].name | select(startswith("workflow:") or . == "needs-human")]}' 2>/dev/null || echo '{}')
1448
+ if [ "$PRED_INFO" = '{}' ]; then echo "GATED"; return; fi
1449
+ PRED_STATE=$(echo "$PRED_INFO" | jq -r '.state // "OPEN"')
1450
+ PRED_LABELS=$(echo "$PRED_INFO" | jq -r '.workflow[]?' 2>/dev/null)
1451
+ fi
1452
+
1453
+ # reverify_merged_trail: re-run the phase-trail verifier for a merged predecessor that has NO escalation
1454
+ # record (forge#3156), so wake reconstruction does not trust the `workflow:merged` label alone. Prints OK
1455
+ # when the trail verifies, UNVERIFIED otherwise. FORGE_ROOT comes from the Step 4A-pre/item 0 bootstrap; if
1456
+ # it is unresolved the verifier cannot run, so a predecessor already holding `needs-human` fails closed
1457
+ # (UNVERIFIED), while a plain merged one is trusted (OK) because item 0 verified it on its completion.
1458
+ reverify_merged_trail() {
1459
+ local P="$1" HAS_HUMAN="$2" RC
1460
+ if [ -z "${FORGE_ROOT:-}" ] || [ ! -f "$FORGE_ROOT/scripts/verify-phase-trail.sh" ]; then
1461
+ [ "$HAS_HUMAN" = "yes" ] && echo "UNVERIFIED" || echo "OK"; return
1462
+ fi
1463
+ bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" "$P" -R {GH_REPO} --code-diff >/dev/null 2>&1; RC=$?
1464
+ [ "$RC" -eq 0 ] && echo "OK" || echo "UNVERIFIED"
1465
+ }
1466
+
1241
1467
  if echo "$PRED_LABELS" | grep -qx "workflow:invalid"; then
1242
1468
  echo "FAILED"
1469
+ elif echo "$PRED_LABELS" | grep -qx "workflow:merged" && echo "$PRED_LABELS" | grep -qx "needs-human"; then
1470
+ # forge#3148: GATED when Step 4B item 0 escalated a merged issue with an incomplete phase trail
1471
+ # (the code is in the base branch, but /work-on cannot repair a merged issue, so a human must review it;
1472
+ # dependents wait until a human clears needs-human). Item 0 adds `needs-human` BEFORE posting the comment
1473
+ # (forge#3156), so merged + needs-human with no comment yet is either the in-flight window or a stale label:
1474
+ # re-verify the trail to tell them apart (clean trail => stale label => DONE). Unreadable comments fail closed.
1475
+ local TRAIL_ESC
1476
+ TRAIL_ESC=$(trail_escalation_state "$PRED")
1477
+ case "$TRAIL_ESC" in
1478
+ ABSENT) [ "$(reverify_merged_trail "$PRED" yes)" = "OK" ] && echo "DONE" || echo "GATED" ;;
1479
+ RELEASED) echo "DONE" ;; # forge#3157: a human release was recorded after the last escalation; this needs-human is unrelated
1480
+ *) echo "GATED" ;; # ACTIVE or UNREADABLE: fail closed
1481
+ esac
1243
1482
  elif echo "$PRED_LABELS" | grep -qx "workflow:merged"; then
1244
- echo "DONE"
1483
+ # forge#3156: no needs-human. A recorded escalation (ACTIVE or RELEASED) means a human already released the hold
1484
+ # (DONE, forge#3157: the sweep records it via release_merged_trail); otherwise re-verify the trail so a merged
1485
+ # predecessor that item 0 never got to escalate (orchestrator died mid-hold, wake reconstruction) is not trusted
1486
+ # on the label alone.
1487
+ case "$(trail_escalation_state "$PRED")" in
1488
+ ACTIVE|RELEASED) echo "DONE" ;;
1489
+ *)
1490
+ if [ "$(reverify_merged_trail "$PRED" no)" = "OK" ]; then
1491
+ echo "DONE"
1492
+ else
1493
+ # Unverified trail with no hold on record: establish it now so a human can release it.
1494
+ # Output is discarded (stdout is the classification), but a failed hold is reported on stderr, not swallowed.
1495
+ [ "${DRY_RUN:-false}" = "true" ] || 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
1496
+ echo "GATED"
1497
+ fi ;;
1498
+ esac
1245
1499
  elif echo "$PRED_LABELS" | grep -qxE "needs-human|workflow:awaiting-merge"; then
1246
1500
  echo "GATED"
1247
1501
  elif echo "$PRED_LABELS" | grep -qx "workflow:engine-error"; then
@@ -1271,12 +1525,12 @@ classify_predecessor_state() {
1271
1525
  }
1272
1526
  ```
1273
1527
 
1274
- - **DONE** — predecessor's code is in the base branch (`workflow:merged`), or the predecessor is closed with no pending code. Safe for dependents to dispatch.
1275
- - **GATED** — predecessor is paused pending a human decision (`needs-human`) or pending only a human merge click (`workflow:awaiting-merge`). Its code is NOT yet in the base branch. Dependents are neither dispatched nor skipped — they move to the `blocked-on-human-merge` tracked state (item 6.5 below).
1528
+ - **DONE** — predecessor's code is in the base branch (`workflow:merged`, unless gated by a Step 4B item 0 trail escalation), or the predecessor is closed with no pending code. Safe for dependents to dispatch.
1529
+ - **GATED** — predecessor is paused pending a human decision (`needs-human`) or pending only a human merge click (`workflow:awaiting-merge`). Its code is NOT yet in the base branch — except for a `workflow:merged` issue that also carries `needs-human` and the Step 4B item 0 trail-escalation comment (merged with an incomplete phase trail), whose code is merged but whose trail needs human review. Dependents are neither dispatched nor skipped — they move to the `blocked-on-human-merge` tracked state (item 6.5 below).
1276
1530
  - **FAILED** — predecessor was closed as `workflow:invalid`, or the agent explicitly reported a build/test error. Dependents are marked "skipped — dependency failed" (item 6 below) — unchanged from prior behavior.
1277
1531
  - **IN_PROGRESS** — predecessor is still mid-pipeline (`investigating`/`ready-to-build`/`building`/`in-review`), or terminated `workflow:engine-error` (forge#2261 — an engine/tool failure, not a human-judgment block or a genuine content failure; treated as still-in-flight since stall-detection auto-resumes it). Dependent simply continues waiting; no special tracking needed.
1278
1532
 
1279
- A GATED predecessor whose PR later merges reclassifies to DONE the next time `classify_predecessor_state` runs (its label flips to `workflow:merged`) — this is exactly what the merge-triggered wake check (item 6.6 below) relies on.
1533
+ A GATED predecessor whose PR later merges reclassifies to DONE the next time `classify_predecessor_state` runs (its label flips to `workflow:merged`; a merged predecessor gated on its phase trail reclassifies to DONE once a human removes `needs-human`) — this is exactly what the merge-triggered wake check (item 6.6 below) relies on.
1280
1534
 
1281
1535
  **File-overlap edge re-verification** <!-- Added: forge#1904 --> — `classify_predecessor_state()` answers "is the predecessor resolved enough to proceed," but it says nothing about whether a specific Layer 1/2/3 file-overlap edge (`EDGE_KIND`/`EDGE_FILES`, tagged in `phase-3-dependency.md` Step 3C/3D) was ever real. Both were built from a **pre-build guess** — the predecessor's `FORGE:INVESTIGATOR` "Affected Files" list, or a raw issue-body parse if no investigation existed yet. Once a predecessor reaches FAILED, GATED, **or DONE**, that guess must be checked against ground truth (the predecessor's actual PR diff, or the absence of any PR) before the edge is allowed to keep blocking a dependent or to inject a same-file brief. Without this, a predecessor that reaches `needs-human` or `workflow:invalid` having never touched the guessed shared file (or never opened a PR at all) leaves its dependents gated/skipped on a conflict that never existed — and on the happy path, an over-serialized chain is never reconciled at all and stays serialized for its entire life (forge#2848).
1282
1536
 
@@ -1570,6 +1824,84 @@ done
1570
1824
 
1571
1825
  **CRITICAL — Stall detection and recovery**: Background agents sometimes stop mid-pipeline (`stop_reason=end_turn`) after completing a sub-phase (e.g., investigation completes but build never starts). This causes the agent to "complete" from the Agent tool's perspective even though the `/work-on` pipeline is only partially done. When you receive a completion notification:
1572
1826
 
1827
+ 0. **Phase-trail verification (MANDATORY on EVERY merge completion, before classifying DONE)** <!-- Added: forge#3062 --> — a `workflow:merged` label alone is not proof the pipeline ran. **Scope (forge#3102)**: run it only when the issue carries `workflow:merged` (a failed label read fails closed). Issues in other terminal-for-this-agent states (`workflow:invalid`, `workflow:decomposed`, `needs-human`, `workflow:awaiting-merge`, paused) legitimately lack a full trail; classify them by label per Predecessor Classification, skip this verifier, and do NOT flag them as bypass or re-dispatch them. Run the deterministic verifier (add `--docs-only` for docs-only diffs):
1828
+ ```bash
1829
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
1830
+ FORGE_ROOT=""
1831
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
1832
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
1833
+ _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")")"
1834
+ # 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)
1835
+ _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-)"
1836
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
1837
+ # '${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.
1838
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
1839
+ while IFS= read -r _c; do
1840
+ 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
1841
+ done <<< "$_k"
1842
+ fi
1843
+ # forge#3102: only merged completions carry a full trail; invalid/decomposed/needs-human/awaiting-merge/paused do not.
1844
+ # Fail closed: a failed label read is an ERROR, never a skip.
1845
+ TRAIL_LABELS_READ_OK=0 # forge#3168: 1 only after a successful label read, so a failed read is distinct from an empty label set
1846
+ if ! TRAIL_LABELS=$(gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null); then
1847
+ TRAIL="PHASE_TRAIL: ERROR (could not read issue labels)"; TRAIL_RC=2 # infrastructure error, not a bypass (forge#3147)
1848
+ elif TRAIL_LABELS_READ_OK=1; ! echo ",$TRAIL_LABELS," | grep -q ',workflow:merged,'; then
1849
+ TRAIL="PHASE_TRAIL: SKIPPED (issue not workflow:merged — classified by label)"; TRAIL_RC=0
1850
+ elif [ -z "$FORGE_ROOT" ] || [ ! -f "$FORGE_ROOT/scripts/verify-phase-trail.sh" ]; then
1851
+ TRAIL="PHASE_TRAIL: ERROR (verify-phase-trail.sh not resolvable; set FORGEDOCK_HOME)"; TRAIL_RC=127 # fail closed
1852
+ else
1853
+ # forge#3123: a docs-only merged diff legitimately has no FORGE:QUALITY_GATE marker. Derive --docs-only from the
1854
+ # merged PR's diff with the SAME shared predicate as review-pr.md Phase 8 / work-on/review.md R1.5 (scripts/is-docs-only.sh, forge#3134). Fail closed: no PR / unreadable diff -> no flag.
1855
+ TRAIL_DOCS_FLAG="--code-diff" # forge#3149: fail closed - no matched PR / unreadable or empty diff means NO docs-only waiver and no usable INVESTIGATION band
1856
+ # forge#3133: the text search is fuzzy and unordered, so `.[0]` can be an unrelated docs-only PR. Evaluate EVERY
1857
+ # matching merged PR and set the flag only if at least one matched AND all matched diffs are non-empty and docs-only.
1858
+ # forge#3149: the search is a fuzzy prefilter; the jq test below keeps only PRs whose body has the exact token
1859
+ # "Closes #{NUMBER}" NOT followed by another digit (so #31490 never matches #3149). --limit 100 is a generous cap on
1860
+ # merged PRs mentioning one issue (a pipeline issue normally has one closing PR).
1861
+ TRAIL_PRS=$(gh pr list -R {GH_REPO} --state merged --limit 100 --search "\"Closes #{NUMBER}\" in:body" --json number,body --jq '.[] | select(.body | test("Closes #{NUMBER}([^0-9]|$)"; "i")) | .number' 2>/dev/null)
1862
+ if [ -n "$TRAIL_PRS" ]; then
1863
+ TRAIL_ALL_DOCS=1
1864
+ for TRAIL_PR in $TRAIL_PRS; do
1865
+ # forge#3145: both sides of a rename (filename + previous_filename); `gh pr diff --name-only` collapses a rename to its destination.
1866
+ TRAIL_FILES=$(gh api --paginate "repos/{GH_REPO}/pulls/${TRAIL_PR}/files" --jq '.[] | .filename, (.previous_filename // empty)' 2>/dev/null) || TRAIL_FILES=""
1867
+ # Fail closed on truncation: files API caps at 3000 files; at/over the cap or an unreadable count -> not docs-only.
1868
+ TRAIL_CHANGED_N=$(gh api "repos/{GH_REPO}/pulls/${TRAIL_PR}" --jq .changed_files 2>/dev/null) || TRAIL_CHANGED_N=""
1869
+ case "$TRAIL_CHANGED_N" in ''|*[!0-9]*) TRAIL_FILES="" ;; *) [ "$TRAIL_CHANGED_N" -ge 3000 ] && TRAIL_FILES="" ;; esac
1870
+ if [ -z "$TRAIL_FILES" ] || [ ! -f "$FORGE_ROOT/scripts/is-docs-only.sh" ] || ! echo "$TRAIL_FILES" | bash "$FORGE_ROOT/scripts/is-docs-only.sh"; then TRAIL_ALL_DOCS=0; break; fi
1871
+ done
1872
+ [ "$TRAIL_ALL_DOCS" = "1" ] && TRAIL_DOCS_FLAG="--docs-only"
1873
+ fi
1874
+ TRAIL=$(bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" {NUMBER} -R {GH_REPO} $TRAIL_DOCS_FLAG 2>&1); TRAIL_RC=$?
1875
+ fi
1876
+ echo "$TRAIL"
1877
+ ```
1878
+ Route on `TRAIL_RC` (forge#3147). Both non-zero cases fail closed: never `DONE`, never cascaded as a satisfied predecessor.
1879
+ - `TRAIL_RC -ge 2` (`PHASE_TRAIL: ERROR`: unreadable trail, unreadable labels, or 127 when the verifier is unresolvable) → classify as **BLOCKED (infrastructure)**, NOT a bypass. A re-dispatched `/work-on` would hit the same unreadable or unresolvable verifier in its resume preflight, so do NOT re-dispatch. Surface `PHASE TRAIL ERROR: #{NUMBER} — <TRAIL line>` in the operator status update, add `needs-human`, and continue with the rest of the batch.
1880
+ - `TRAIL_RC=1` (`PHASE_TRAIL: FAIL`) → classify as a **bypass** (handling below).
1881
+
1882
+ 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.
1883
+
1884
+ **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` unless the trail re-verifies clean; the label is written first, then the comment), 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):
1885
+ ```bash
1886
+ # Only a merged issue is escalated here; an unmerged bypass is resumed by item 2 (see below).
1887
+ # Any non-zero rc on a merged issue escalates: rc 1 (missing phases) and rc >= 2 (unverifiable trail) alike,
1888
+ # because classify_predecessor_state() gates a merged predecessor only when this escalation comment exists.
1889
+ # forge#3156: also escalate when the label read itself failed: an unreadable issue cannot be proven unmerged, so
1890
+ # fail closed instead of posting nothing. forge#3168: key on TRAIL_LABELS_READ_OK, not an empty TRAIL_LABELS, so a
1891
+ # successfully read, label-less, non-merged issue is never escalated (read OK + empty labels => no escalation).
1892
+ if [ "$TRAIL_RC" -ne 0 ] && { [ "${TRAIL_LABELS_READ_OK:-0}" != "1" ] || echo ",${TRAIL_LABELS:-}," | grep -q ',workflow:merged,'; }; then
1893
+ if [ "${DRY_RUN:-false}" = "true" ]; then
1894
+ echo "[DRY-RUN] Would add needs-human and post FORGE:PHASE_TRAIL_FAILED on merged #{NUMBER}"
1895
+ else
1896
+ # hold_merged_trail (defined with classify_predecessor_state) writes `needs-human` FIRST, read-back verified
1897
+ # with retries, then the escalation comment (verified, retried). The label alone holds dependents, so there
1898
+ # is no window where a comment without the label reads as DONE. A failed verification is reported on stderr.
1899
+ hold_merged_trail {NUMBER} "${TRAIL}" || echo "PHASE TRAIL ESCALATION FAILED: #{NUMBER} — needs-human not verified." >&2
1900
+ fi
1901
+ fi
1902
+ ```
1903
+ Never hand-post missing markers. A bypass report on an issue that is NOT merged (the agent stopped before merge) is not covered here: classify it by label and let item 2 resume it, because `/work-on`'s resume preflight re-runs each missing phase via `Skill(...)` before PR creation and merge.
1904
+
1573
1905
  1. **Check if the agent completed the FULL pipeline** — not just one phase:
1574
1906
  ```bash
1575
1907
  # Check final workflow state — workflow:merged, workflow:invalid, needs-human, and
@@ -1591,10 +1923,12 @@ done
1591
1923
  resume=AGENT_ISSUE_MAP[{NUMBER}],
1592
1924
  description="Resume #{NUMBER} pipeline",
1593
1925
  run_in_background=true,
1594
- prompt="The previous /work-on invocation stopped before completing the full pipeline. The issue is currently at {CURRENT_WORKFLOW_STATE}. Continue — invoke Skill(skill='work-on', args='{NUMBER} --under-orchestration') to resume the routing loop from the current state. /work-on will re-read GitHub state and pick up where it left off."
1926
+ prompt="{UNMODIFIED_STEP_4A_TEMPLATE_RENDERED_FOR_{NUMBER}}"
1595
1927
  )
1596
1928
  ```
1597
1929
 
1930
+ **Resume/relaunch prompts are the verbatim Step 4A template** <!-- Added: forge#3062 -->: never write free-text resume instructions such as "continue from where you left off" or "finish the uncommitted work". Render the unmodified 4A template (and pass it through the prompt lint above) so the agent re-enters via `Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)` and `/work-on`'s own checkpoint routing plus the phase-trail resume preflight pick the earliest missing phase. A recovered worktree with uncommitted work is never a substitute for the skipped phases.
1931
+
1598
1932
  **2b. Engine-first-dispatched issue (`ENGINE_DISPATCH_MAP[{NUMBER}]` set, no `AGENT_ISSUE_MAP` entry)** — `Agent(resume=...)` has nothing to resume here; the completed task was a backgrounded `Bash(command="forgedock run-issue ...")` call, not an `Agent()`. Re-issuing the identical `forgedock run-issue` command would just reproduce the same environmental failure. Instead, fall back to the Agent-spawn path for this ONE issue — but only when it is safe to do so:
1599
1933
  ```bash
1600
1934
  # Safety gate: only fall back when the engine run committed NOTHING — no branch, no PR, no
@@ -1784,7 +2118,7 @@ done
1784
2118
  run_in_background=true,
1785
2119
  prompt="You are remediating GitHub PR #{GATING_PR} for the {PROJECT_NAME} project (repo: {GH_REPO}), which is currently held at `needs-human` on its linked issue #{PRED}.
1786
2120
 
1787
- **YOUR MISSION**: Invoke `Skill(skill='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.
2121
+ **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.
1788
2122
 
1789
2123
  **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`.
1790
2124
 
@@ -1836,33 +2170,43 @@ Do not ask the user questions — you are running autonomously in the background
1836
2170
  if [ "$ALREADY_TRACKED" -eq 0 ]; then
1837
2171
  gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:BLOCKED_ON_HUMAN_MERGE -->
1838
2172
  **Gating predecessor**: #${PRED} (state: \`${PRED_LABEL}\`${GATING_PR:+, open PR #${GATING_PR}})
1839
- **Status**: This issue is ready to dispatch as soon as #${PRED}'s gating PR merges. No action needed — the orchestrator (live session via item 6.6, or the next \`/orchestrate\` invocation via phase-3-dependency.md's wake reconstruction) will auto-dispatch it the moment #${PRED} reaches \`workflow:merged\`."
2173
+ **Status**: This issue is ready to dispatch once #${PRED} is resolved: when its gating PR merges, or — if #${PRED} is already merged and held on its phase trail — when a human clears \`needs-human\` on it. No action needed — the orchestrator (live session via the item 6.6 sweep on every monitoring cycle, or the next \`/orchestrate\` invocation via phase-3-dependency.md's wake reconstruction) will auto-dispatch it as soon as #${PRED} classifies DONE."
1840
2174
  gh issue edit "$DEP" -R {GH_REPO} --add-label "blocked-on-human-merge" 2>/dev/null || true
1841
2175
  fi
1842
2176
  ```
1843
2177
  Do NOT dispatch `DEP`. Do NOT mark it skipped — it remains visibly tracked as `blocked-on-human-merge` in the DAG, re-evaluated on the next completion event, stall-detection pass, or session wake.
1844
2178
 
1845
- 6.6. **Merge-triggered wake for blocked-on-human-merge dependents** <!-- Added: forge#1812 --> — whenever a completed agent's issue classifies as `DONE` via `workflow:merged` (i.e. it just merged), check whether any other issue is tracked as blocked on it — this makes gated dependents dispatch the instant the gating PR merges, with no manual `/orchestrate` re-run required:
2179
+ 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`:
1846
2180
  ```bash
2181
+ # New per-cycle comment cache for trail_escalation_state (human edits between cycles are always re-read).
2182
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -rf "$TRAIL_CACHE_DIR"
2183
+ TRAIL_CACHE_DIR=$(mktemp -d)
1847
2184
  WOKEN=$(gh issue list -R {GH_REPO} --state open --label "blocked-on-human-merge" --json number \
1848
2185
  --jq '.[].number' 2>/dev/null || echo "")
1849
2186
  for DEP in $WOKEN; do
1850
- IS_GATED_BY_THIS=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
1851
- --jq --arg prednum "${NUM}" '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE") and test("Gating predecessor\\*\\*: #" + $prednum + "\\b"))] | length' 2>/dev/null || echo "0")
1852
- [ "$IS_GATED_BY_THIS" -gt 0 ] || continue
2187
+ GATING_PREDS=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
2188
+ --jq '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | (.body | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)] | unique | .[]' 2>/dev/null || echo "")
2189
+ [ -n "$GATING_PREDS" ] || continue
2190
+ STILL_GATED=false
2191
+ for GPRED in $GATING_PREDS; do
2192
+ [ "$(classify_predecessor_state "$GPRED")" = "DONE" ] || STILL_GATED=true
2193
+ done
2194
+ [ "$STILL_GATED" = "false" ] || continue
1853
2195
  # Idempotency: only dispatch if DEP hasn't already been dispatched by another path.
1854
2196
  DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
1855
2197
  --jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
1856
2198
  if [ "$DEP_ALREADY_DISPATCHED" -eq 0 ]; then
2199
+ # Record a human release of a trail-held merged predecessor so the escalation stops gating (decay).
2200
+ for GPRED in $GATING_PREDS; do release_merged_trail "$GPRED"; done
1857
2201
  gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" 2>/dev/null || true
1858
2202
  gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:UNBLOCKED -->
1859
- Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was tracked via a prior FORGE:BLOCKED_ON_HUMAN_MERGE comment.)"
1860
- echo "#{DEP} unblocked by #{NUM} merge — dispatching immediately (Steps 4A.pre.0 → 4A.pre → 4A)."
2203
+ 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.)"
2204
+ echo "#{DEP} unblocked (gating predecessors resolved) — dispatching immediately (Steps 4A.pre.0 → 4A.pre → 4A)."
1861
2205
  # Add DEP to the same-response dispatch batch
1862
2206
  fi
1863
2207
  done
1864
2208
  ```
1865
- This satisfies the live-session case. For the case where the gating PR merges 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.
2209
+ `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.
1866
2210
 
1867
2211
  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:
1868
2212
 
@@ -1991,9 +2335,29 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1991
2335
  RERESOLVE_ENABLED=$(yq '.orchestration.reresolve.enabled // true' forge.yaml 2>/dev/null || echo "true")
1992
2336
  RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' forge.yaml 2>/dev/null || echo "unbounded")
1993
2337
 
1994
- node "{REPO_PATH}/bin/engine/orchestrate-canary.mjs" \
1995
- "$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" \
1996
- "$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
2338
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2339
+ FORGE_ROOT=""
2340
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
2341
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
2342
+ _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")")"
2343
+ # 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)
2344
+ _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-)"
2345
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
2346
+ # '${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.
2347
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
2348
+ while IFS= read -r _c; do
2349
+ 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
2350
+ done <<< "$_k"
2351
+ fi
2352
+ # Run ForgeDock's OWN orchestrate-canary.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}.
2353
+ # Unresolved/missing => no result, so reResolve is treated as false for this cycle (fail closed, loudly).
2354
+ if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/orchestrate-canary.mjs" ]; then
2355
+ node "$FORGE_ROOT/bin/engine/orchestrate-canary.mjs" \
2356
+ "$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" \
2357
+ "$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
2358
+ else
2359
+ echo "WARNING: ForgeDock install root unresolved or orchestrate-canary.mjs missing (set FORGEDOCK_HOME) — skipping re-resolution this cycle" >&2
2360
+ fi
1997
2361
  ```
1998
2362
 
1999
2363
  If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR` has reached `max_rounds` — bounded termination per the issue's acceptance criteria), skip this step for the current cycle and log the reason. `RERESOLVE_ROUNDS_SO_FAR` starts at 0 for the batch and increments once per cycle this step actually runs (declare it alongside the other Step 4A.pre batch-scope accumulators — do not re-initialize per completion).
@@ -2003,13 +2367,33 @@ If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR`
2003
2367
  **Fold new matches through the existing admission path — never a bypass**:
2004
2368
 
2005
2369
  ```bash
2006
- node -e '
2007
- import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ foldNewMatches }) => {
2008
- const reResolved = JSON.parse(process.argv[1]);
2009
- const processed = JSON.parse(process.argv[2]);
2010
- console.log(JSON.stringify(foldNewMatches(reResolved, processed)));
2011
- }, () => process.exit(0));
2012
- ' "$RERESOLVED_NUMBERS_JSON" "$ALL_BATCH_ISSUE_NUMBERS_JSON"
2370
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2371
+ FORGE_ROOT=""
2372
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
2373
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
2374
+ _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")")"
2375
+ # 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)
2376
+ _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-)"
2377
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
2378
+ # '${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.
2379
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
2380
+ while IFS= read -r _c; do
2381
+ 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
2382
+ done <<< "$_k"
2383
+ fi
2384
+ # Import ForgeDock's OWN resolve.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}.
2385
+ # Unresolved/missing => no output, so no new matches are folded this cycle (fail closed, loudly).
2386
+ if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/resolve.mjs" ]; then
2387
+ node -e '
2388
+ import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ foldNewMatches }) => {
2389
+ const reResolved = JSON.parse(process.argv[2]);
2390
+ const processed = JSON.parse(process.argv[3]);
2391
+ console.log(JSON.stringify(foldNewMatches(reResolved, processed)));
2392
+ }, () => process.exit(0));
2393
+ ' "$FORGE_ROOT/bin/engine/resolve.mjs" "$RERESOLVED_NUMBERS_JSON" "$ALL_BATCH_ISSUE_NUMBERS_JSON"
2394
+ else
2395
+ echo "WARNING: ForgeDock install root unresolved or resolve.mjs missing (set FORGEDOCK_HOME) — re-resolved matches not folded this cycle" >&2
2396
+ fi
2013
2397
  ```
2014
2398
 
2015
2399
  `newMatches` from `foldNewMatches` are candidate issues, not admitted ones. Each one MUST be dispatched through the exact same path a T0-resolved issue takes — DAG dependency analysis (`phase-3-dependency.md`), then Step 4A/4B's standard `dispatch_headroom`-gated dispatch — **not** through Step 4C's review-finding-specific `evaluateCascadeFinding` chain (that gate's rules, e.g. the comment/typo keyword heuristic, are shaped for cascade-spawned findings, not arbitrary re-resolved issues). This is the same non-bypass requirement Step 4C already satisfies for its own admission stream; this step must not become a second, ungated entry point into the DAG. Add every `newMatch` to `ALL_BATCH_ISSUE_NUMBERS` (so a later re-resolution round or Step 4C sees it as already processed) and to `SORTED_READY_SET`/the DAG exactly as Step 4A.pre.0 processes a T0-resolved issue.
@@ -2186,32 +2570,38 @@ gh issue list -R {GH_REPO} --state open --search "label:review-finding created:>
2186
2570
  --json number,title,body,createdAt
2187
2571
  ```
2188
2572
 
2189
- **If review-finding issues were spawned:**
2190
-
2191
- **Amplification accounting (MANDATORY, before cascade admission):** Count every finding created by this batch, including findings later deferred by any cascade rule. After each merged unit, compute `FINDINGS_SPAWNED / MERGED_UNITS`, append the value to `AMPLIFICATION_RATIO_HISTORY`, and print a convergence warning when the latest `CONVERGENCE_WINDOW` observations are all `>= 1.0`. A high ratio is a signal, not a failure: valuable deep review findings can legitimately raise it.
2192
-
2193
- For each finding, extract its source PR from `**Source**: PR #N` and increment `FINDINGS_BY_SOURCE_PR[N]`. Classify it as a **same-lineage refinement** only when that source PR closes a `review-finding` issue and both that parent finding and this finding name the same affected file; otherwise classify it as **new surface**. Record the finding number in `REFINEMENT_FINDINGS` or `NEW_SURFACE_FINDINGS` respectively. If provenance or a file path cannot be established, classify as new surface conservatively; never suppress it as a refinement.
2194
-
2195
- When `CASCADE_MAX_AMPLIFICATION` is not `off`, and the current ratio is greater than that ceiling, defer only newly discovered entries in `REFINEMENT_FINDINGS` with reason `amplification bound exceeded (same-lineage refinement)`. Add them to `DEFERRED_FINDINGS` and `DEFERRED_REASONS` so Step 4F re-evaluates them after the batch drains. Do not apply this bound to new-surface findings, P1/P2 findings, or any finding when the option is `off`.
2196
-
2197
2573
  ```bash
2198
2574
  # Run once for each completed issue before collecting its findings. Only merged
2199
2575
  # units advance the denominator; invalid/skipped units do not imply delivered work.
2576
+ # The ratio observation for this unit is recorded AFTER its findings are counted, in the
2577
+ # "Amplification settle" block that follows the Step 4C rule loop (forge#3060) — recording it
2578
+ # here would lag by one unit and could never reach 1.0 at exactly one finding per merged unit.
2579
+ UNIT_MERGED_THIS_CYCLE=false
2200
2580
  if gh issue view "$NUM" -R {GH_REPO} --json labels \
2201
2581
  --jq '[.labels[].name | select(. == "workflow:merged")] | length' | grep -qx '1'; then
2202
2582
  MERGED_UNITS=$((MERGED_UNITS + 1))
2203
- AMPLIFICATION_RATIO=$(awk "BEGIN { printf \"%.2f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")
2204
- AMPLIFICATION_RATIO_HISTORY+=("$AMPLIFICATION_RATIO")
2205
- if [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge "$CONVERGENCE_WINDOW" ]; then
2206
- RECENT_RATIOS=("${AMPLIFICATION_RATIO_HISTORY[@]: -$CONVERGENCE_WINDOW}")
2207
- if printf '%s\n' "${RECENT_RATIOS[@]}" | awk '$1 < 1 { exit 1 }'; then
2208
- echo "CONVERGENCE WARNING: amplification has remained >= 1.0 for ${CONVERGENCE_WINDOW} merged units (${AMPLIFICATION_RATIO} current). This can be productive review refinement, but the batch is not shrinking."
2209
- fi
2210
- fi
2583
+ UNIT_MERGED_THIS_CYCLE=true
2211
2584
  fi
2212
2585
 
2213
2586
  ```
2214
2587
 
2588
+ **Unconditional unit accounting (forge#3060):** the merged-unit accounting block and the "Amplification settle" block below run for EVERY completed unit, including a merged unit that spawned ZERO review-findings (an empty `{spawned_finding_numbers}` list simply makes the rule loop a no-op). Skipping them for finding-free units would leave the ratio history containing only finding-producing units (every observation >= 1.0) and the breaker could never release.
2589
+
2590
+ **If review-finding issues were spawned:**
2591
+
2592
+ **Amplification accounting (MANDATORY, before cascade admission):** Count every finding created by this batch, including findings later deferred by any cascade rule. After each merged unit, compute `FINDINGS_SPAWNED / MERGED_UNITS`, append the value to `AMPLIFICATION_RATIO_HISTORY`, and print a convergence warning when the latest `CONVERGENCE_WINDOW` observations are all `>= 1.0`. A high ratio is a signal, not a failure: valuable deep review findings can legitimately raise it.
2593
+
2594
+ For each finding, extract its source PR from `**Source**: PR #N` and increment `FINDINGS_BY_SOURCE_PR[N]`. Classify it as a **same-lineage refinement** only when that source PR closes a `review-finding` issue and both that parent finding and this finding name the same affected file; otherwise classify it as **new surface**. Record the finding number in `REFINEMENT_FINDINGS` or `NEW_SURFACE_FINDINGS` respectively. If provenance or a file path cannot be established, classify as new surface conservatively; never suppress it as a refinement.
2595
+
2596
+ When `CASCADE_MAX_AMPLIFICATION` is not `off`, and the current ratio is greater than that ceiling, defer only newly discovered entries in `REFINEMENT_FINDINGS` with reason `amplification bound exceeded (same-lineage refinement)`. Add them to `DEFERRED_FINDINGS` and `DEFERRED_REASONS` so Step 4F re-evaluates them after the batch drains. Do not apply this bound to new-surface findings, P1/P2 findings, or any finding when the option is `off`.
2597
+
2598
+ **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.
2599
+
2600
+ **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.
2601
+
2602
+ **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.
2603
+
2604
+
2215
2605
  **Cascade control (MANDATORY — run before folding findings into the DAG):**
2216
2606
 
2217
2607
  For each spawned finding, determine whether it should be **executed** or **deferred**:
@@ -2224,7 +2614,9 @@ For each spawned finding, determine whether it should be **executed** or **defer
2224
2614
  4. **P3 + same-file overlap**: Finding is labeled `P3` AND the file it targets overlaps with ANY file already in the current batch (active or queued in the DAG). Rationale: same-file P3 findings add predecessor edges that serialize agents — one finding per original issue increases wall-clock time with no proportional value. **Configurable** via `orchestration.cascade.p3_same_file_defer` (default `true`; `false` under `policy: all` — forge#2234).
2225
2615
  5. **Per-batch token budget** (P3 and below only, applied AFTER surface-area batching below — see "Per-batch token budget gate") <!-- Added: forge#1858 -->: Once `BATCH_TOKEN_SPEND` would exceed `TOKEN_BUDGET`, additional P3-and-below units (an unclubbed finding, or an already-clubbed batch issue) defer rather than dispatch. This is NOT part of the per-finding rule 0-4 chain immediately below — it is a quantity gate applied to the POST-clubbing `QUEUED_FINDINGS` list, so a same-run surface-area batch issue (surface-area batching below) is charged once for the whole cluster, not once per member. P1/P2 are NEVER gated by it (they are excluded by rule 2 before reaching this gate, same as they are excluded from rules 3-4). **Configurable** via `orchestration.cascade.token_budget` (default `900000`, deprecated alias `pipeline.token_budget_per_batch`; `unlimited` under `policy: all` — forge#2234). This is a distinct, independent lever from rule 1's generation cap — "admit gen-2, stop at gen-3" (`max_generation: 3`) and "admit cascade until N tokens" (`token_budget: N`) can each be set without the other.
2226
2616
 
2227
- **Defer** (do NOT add to the DAG) if rules 0, 1, 3, 4, or 5 match.
2617
+ 6. **Amplification breaker** (P3 and below only — P0/P1/P2 and untriaged findings are never gated; forge#3060): `AMPLIFICATION_BREAKER=on` AND the breaker is tripped (re-evaluated live per finding). Defer with reason `amplification breaker tripped` (appended to any earlier defer reason); the finding is routed to the bounded P3 batch planner and Step 4F never dispatches it individually. Checked last, after the other rules. **Configurable** via `orchestration.cascade.amplification_breaker` (default `on`, not changed by any `cascade.policy` preset).
2618
+
2619
+ **Defer** (do NOT add to the DAG) if rules 0, 1, 3, 4, 5, or 6 match.
2228
2620
 
2229
2621
  **Execute** (add to the DAG) if:
2230
2622
  - Rule 2 matches (P1 or P2) — AND rule 0 did not already match (rule 0 is checked first and overrides rule 2)
@@ -2516,6 +2908,65 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
2516
2908
  AMPLIFICATION_DEFERRED+=("$FINDING_NUM")
2517
2909
  fi
2518
2910
 
2911
+ # Rule 6 (forge#3060): on-by-default breaker over ALL cascade findings. Applies ONLY to
2912
+ # P3-and-below (P0/P1/P2 and untriaged/unparseable priorities are never gated by it) and NEVER
2913
+ # to security/billing-class findings (classifyBatchSafety != routine): those can neither batch
2914
+ # with routine work nor be individually dispatched by the sweep, so pausing them would starve
2915
+ # them; they keep their pre-existing individual triage path unchanged.
2916
+ # Evaluated live per finding. When a unit merged this cycle (UNIT_MERGED_THIS_CYCLE), its ratio
2917
+ # observation is not yet in the history (recorded by "Amplification settle"), so the live
2918
+ # unrounded ratio plus the last CONVERGENCE_WINDOW-1 recorded observations form the window.
2919
+ # Applied even when another rule already deferred the finding, and APPENDED to the reason, so
2920
+ # Step 4F's `*"amplification breaker"*` hold (checked FIRST) still catches it.
2921
+ if [ "$AMPLIFICATION_BREAKER" = "on" ] && echo "$PRIORITY" | grep -qE '^P[3-9]$'; then
2922
+ # Fail CLOSED: if the classifier cannot run (missing module, bad cwd), treat the finding as
2923
+ # non-routine so a security/billing finding is never paused. Import via {REPO_PATH} and pass
2924
+ # the text through the environment (argv text beginning with "-" is parsed as a node option).
2925
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2926
+ FORGE_ROOT=""
2927
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
2928
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
2929
+ _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")")"
2930
+ # 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)
2931
+ _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-)"
2932
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
2933
+ # '${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.
2934
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
2935
+ while IFS= read -r _c; do
2936
+ 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
2937
+ done <<< "$_k"
2938
+ fi
2939
+ # Import ForgeDock's OWN admission.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}
2940
+ # (inert on non-ForgeDock repos, and would execute repo-controlled JS). Unresolved/missing => fail closed, loudly.
2941
+ BREAKER_SAFETY="unclassified"
2942
+ if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/admission.mjs" ]; then
2943
+ BREAKER_SAFETY=$(FINDING_TEXT="$(echo "$FINDING_DATA" | jq -r '(.title // "") + "\n" + (.body // "")')" \
2944
+ node -e 'import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ classifyBatchSafety }) => process.stdout.write(classifyBatchSafety(process.env.FINDING_TEXT) || "routine"))' "$FORGE_ROOT/bin/engine/admission.mjs" 2>/dev/null || echo "unclassified")
2945
+ else
2946
+ echo "WARNING: ForgeDock install root unresolved or admission.mjs missing (set FORGEDOCK_HOME) — breaker classifying #${FINDING_NUM} as unclassified (fail closed: not deferred)" >&2
2947
+ fi
2948
+ BREAKER_LIVE_TRIPPED="$AMPLIFICATION_BREAKER_TRIPPED"
2949
+ if [ "$BREAKER_LIVE_TRIPPED" = "false" ] && [ "$UNIT_MERGED_THIS_CYCLE" = "true" ] && [ "$MERGED_UNITS" -gt 0 ] && \
2950
+ awk "BEGIN { exit !($FINDINGS_SPAWNED / $MERGED_UNITS >= 1) }"; then
2951
+ if [ "$CONVERGENCE_WINDOW" -le 1 ] || \
2952
+ { [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge $((CONVERGENCE_WINDOW - 1)) ] && \
2953
+ printf '%s\n' "${AMPLIFICATION_RATIO_HISTORY[@]: -$((CONVERGENCE_WINDOW - 1))}" | awk '$1 < 1 { exit 1 }'; }; then
2954
+ BREAKER_LIVE_TRIPPED=true
2955
+ AMPLIFICATION_BREAKER_TRIPPED=true
2956
+ fi
2957
+ fi
2958
+ if [ "$BREAKER_LIVE_TRIPPED" = "true" ] && [ "$BREAKER_SAFETY" = "routine" ]; then
2959
+ DEFER_REASON="${DEFER_REASON:+${DEFER_REASON}; }${BREAKER_DEFER_TAG} tripped (ratio >= 1.0 for ${CONVERGENCE_WINDOW} merged units) — held for bounded P3 batches"
2960
+ DEFER=true
2961
+ case " ${AMPLIFICATION_BREAKER_DEFERRED[*]} " in *" $FINDING_NUM "*) ;; *) AMPLIFICATION_BREAKER_DEFERRED+=("$FINDING_NUM") ;; esac
2962
+ # Route to the bounded P3 batch planner (never individual dispatch); dedupe so a finding
2963
+ # already added by the generation rule is not listed twice.
2964
+ if [ "$PRIORITY" = "P3" ]; then
2965
+ case " ${BATCHABLE_DEFERRED_P3[*]} " in *" $FINDING_NUM "*) ;; *) BATCHABLE_DEFERRED_P3+=("$FINDING_NUM") ;; esac
2966
+ fi
2967
+ fi
2968
+ fi
2969
+
2519
2970
  if [ "$DEFER" = "true" ]; then
2520
2971
  DEFERRED_FINDINGS+=($FINDING_NUM)
2521
2972
  DEFERRED_REASONS[$FINDING_NUM]="$DEFER_REASON"
@@ -2526,6 +2977,30 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
2526
2977
  done
2527
2978
  ```
2528
2979
 
2980
+ **Amplification settle (MANDATORY — after the rule loop above, before batching/dispatch; forge#3060):** now that this unit's findings are counted, record its ratio observation (unrounded; the printed value is rounded for display only) and evaluate the breaker over the latest `CONVERGENCE_WINDOW` observations. Release as soon as the newest observation is below 1.0.
2981
+
2982
+ ```bash
2983
+ if [ "$UNIT_MERGED_THIS_CYCLE" = "true" ]; then
2984
+ AMPLIFICATION_RATIO=$(awk "BEGIN { printf \"%.2f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")
2985
+ AMPLIFICATION_RATIO_HISTORY+=("$(awk "BEGIN { printf \"%.6f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")")
2986
+ if [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge "$CONVERGENCE_WINDOW" ]; then
2987
+ RECENT_RATIOS=("${AMPLIFICATION_RATIO_HISTORY[@]: -$CONVERGENCE_WINDOW}")
2988
+ if printf '%s\n' "${RECENT_RATIOS[@]}" | awk '$1 < 1 { exit 1 }'; then
2989
+ echo "CONVERGENCE WARNING: amplification has remained >= 1.0 for ${CONVERGENCE_WINDOW} merged units (${AMPLIFICATION_RATIO} current). This can be productive review refinement, but the batch is not shrinking."
2990
+ if [ "$AMPLIFICATION_BREAKER" = "on" ] && [ "$AMPLIFICATION_BREAKER_TRIPPED" = "false" ]; then
2991
+ AMPLIFICATION_BREAKER_TRIPPED=true
2992
+ # One operator report per trip: ratio and queue size, no per-finding spam.
2993
+ echo "AMPLIFICATION BREAKER TRIPPED: ratio ${AMPLIFICATION_RATIO} (${FINDINGS_SPAWNED} findings / ${MERGED_UNITS} merged units) stayed >= 1.0 for ${CONVERGENCE_WINDOW} units. P3 cascade admission is paused; ${#AMPLIFICATION_BREAKER_DEFERRED[@]} finding(s) paused so far. P1/P2 are still admitted. Paused P3s are routed to the bounded P3 batch planner (planP3BatchGroups) and are never dispatched individually. Operator decision: batch them or defer. Opt out: orchestration.cascade.amplification_breaker: off"
2994
+ fi
2995
+ else
2996
+ # Newest observation fell below 1.0 — the cascade is converging again; release the breaker.
2997
+ AMPLIFICATION_BREAKER_TRIPPED=false
2998
+ fi
2999
+ fi
3000
+ fi
3001
+ UNIT_MERGED_THIS_CYCLE=false
3002
+ ```
3003
+
2529
3004
  **Concern-level batching for queued P3 findings (MANDATORY check before dispatch):** <!-- Added: forge#1818 -->
2530
3005
 
2531
3006
  Cascade-spawned findings collected within a single `/orchestrate` run must be reconsidered against the complete open, unbatched, undispatched candidate registry, not only `QUEUED_FINDINGS` from this completion cycle. At initial resolution and after every five completions (or whenever `DEFERRED_FINDINGS` reaches `MAX_CONCURRENT`), collect retained ungrouped candidates, new findings, and open batch membership; call `planP3BatchGroups()` with that registry and `batchExclusionReason()` danger-zone inputs. Execute returned `extensions` before new groups, retain `ungrouped` members for the next sweep, and record group kinds or exclusion predicates in the run summary. <!-- Added: forge#2858; extended: forge#2851, forge#2852 -->
@@ -2607,7 +3082,27 @@ for FINDING_NUM in "${BATCHING_CANDIDATES[@]}"; do
2607
3082
  FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
2608
3083
  [ -z "$FINDING_FILE" ] && continue
2609
3084
 
2610
- SAFETY_CLASS=$(node -e 'import("./bin/engine/admission.mjs").then(({ classifyBatchSafety }) => process.stdout.write(classifyBatchSafety(process.argv[1]) || "routine"))' "$(echo "$FINDING_DATA" | jq -r '.title + "\n" + .body + "\n" + (.labels | join(" "))')")
3085
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
3086
+ FORGE_ROOT=""
3087
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
3088
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
3089
+ _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")")"
3090
+ # 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)
3091
+ _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-)"
3092
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
3093
+ # '${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.
3094
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
3095
+ while IFS= read -r _c; do
3096
+ 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
3097
+ done <<< "$_k"
3098
+ fi
3099
+ # Import from ForgeDock's own root, never the consumer cwd; fail closed (non-routine) when unresolved.
3100
+ SAFETY_CLASS="unclassified"
3101
+ if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/admission.mjs" ]; then
3102
+ SAFETY_CLASS=$(node -e 'import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ classifyBatchSafety }) => process.stdout.write(classifyBatchSafety(process.argv[2]) || "routine"))' "$FORGE_ROOT/bin/engine/admission.mjs" "$(echo "$FINDING_DATA" | jq -r '.title + "\n" + .body + "\n" + (.labels | join(" "))')" 2>/dev/null || echo "unclassified")
3103
+ else
3104
+ echo "WARNING: ForgeDock install root unresolved or admission.mjs missing (set FORGEDOCK_HOME) — #${FINDING_NUM} classified unclassified (non-routine: may batch only with other unclassified findings on the same file, capped at 3)" >&2
3105
+ fi
2611
3106
  # Same file is not enough for security work: the class key prevents a
2612
3107
  # credential finding from sharing a batch with injection/auth hardening.
2613
3108
  SURFACE_KEY="${SAFETY_CLASS}:${FINDING_FILE}"
@@ -2619,7 +3114,7 @@ done
2619
3114
  # the batch issue in QUEUED_FINDINGS so the dispatch step below never double-dispatches
2620
3115
  # them. This is what makes SURFACE_BATCHED_FINDINGS a live control, not dead wiring. <!-- forge#1832, forge#1834 -->
2621
3116
  for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
2622
- MEMBERS=(${SURFACE_FILE_MEMBERS[$FILE]})
3117
+ MEMBERS=($(printf '%s\n' ${SURFACE_FILE_MEMBERS[$FILE]} | sort -un)) # dedupe: a finding listed twice must not form a self-batch
2623
3118
  [ "${#MEMBERS[@]}" -ge 2 ] || continue
2624
3119
 
2625
3120
  SAFETY_CLASS=${FILE%%:*}
@@ -2831,7 +3326,7 @@ A finding that fails classify-lane is removed from `QUEUED_FINDINGS` (mirrors St
2831
3326
 
2832
3327
  Track them in `DEFERRED_FINDINGS` for re-evaluation in Step 4F (Completion Sweep) after the DAG drains. Do NOT close or label them yet — the sweep will determine their final disposition.
2833
3328
 
2834
- **If no review-finding issues were spawned:** Continue monitoring for the next agent completion.
3329
+ **If no review-finding issues were spawned:** Still run the "Amplification settle" block above with an empty finding list (it records this unit's ratio observation, which is what lets the breaker release), then Continue monitoring for the next agent completion.
2835
3330
 
2836
3331
  ### Step 4C.5: Milestone lane-consistency check (periodic) <!-- Added: forge#901 -->
2837
3332
 
@@ -2992,12 +3487,19 @@ PERMANENT_DEFERRED=()
2992
3487
  SWEEP_CANDIDATES=()
2993
3488
  IDLE_DEFERRED=() # <!-- Added: forge#1814 -->
2994
3489
  TOKEN_GATED=() # <!-- Added: forge#1858 -->
3490
+ : "${BREAKER_DEFER_TAG:=amplification breaker}" # default if this block runs in a fresh shell (an empty tag would match every defer reason)
3491
+ AMPLIFICATION_BREAKER_HELD=() # amplification-breaker holds: bounded P3 batches only, never individually dispatched (forge#3060)
2995
3492
 
2996
3493
  for FINDING_NUM in "${DEFERRED_FINDINGS[@]}"; do
2997
3494
  DEFER_REASON="${DEFERRED_REASONS[$FINDING_NUM]}"
2998
3495
 
3496
+ # Amplification-breaker holds (forge#3060) are checked FIRST: the breaker reason is appended to
3497
+ # whatever rule deferred the finding earlier (idle policy, generation, same-file, token budget),
3498
+ # and such a finding must never fall into a bucket whose re-evaluation dispatches it individually.
3499
+ if echo "$DEFER_REASON" | grep -qiF "$BREAKER_DEFER_TAG"; then
3500
+ AMPLIFICATION_BREAKER_HELD+=($FINDING_NUM)
2999
3501
  # Generation >= 2 deferrals are PERMANENT — unbounded cascade prevention
3000
- if echo "$DEFER_REASON" | grep -qi "generation"; then
3502
+ elif echo "$DEFER_REASON" | grep -qi "generation"; then
3001
3503
  PERMANENT_DEFERRED+=($FINDING_NUM)
3002
3504
  # "Batch fully human-gated" deferrals (forge#1814) are their OWN bucket — they must NOT be
3003
3505
  # re-evaluated by the file-overlap logic in Step 4F.2 below, because the reason they were
@@ -3020,7 +3522,7 @@ for FINDING_NUM in "${DEFERRED_FINDINGS[@]}"; do
3020
3522
  fi
3021
3523
  done
3022
3524
 
3023
- echo "Completion sweep: ${#SWEEP_CANDIDATES[@]} re-evaluable, ${#PERMANENT_DEFERRED[@]} permanent, ${#IDLE_DEFERRED[@]} idle-gated, ${#TOKEN_GATED[@]} token-gated"
3525
+ echo "Completion sweep: ${#SWEEP_CANDIDATES[@]} re-evaluable, ${#PERMANENT_DEFERRED[@]} permanent, ${#IDLE_DEFERRED[@]} idle-gated, ${#TOKEN_GATED[@]} token-gated, ${#AMPLIFICATION_BREAKER_HELD[@]} breaker-held (P3 batches only)"
3024
3526
  ```
3025
3527
 
3026
3528
  **Step 4F.2: Re-evaluate sweep candidates**
@@ -3030,6 +3532,7 @@ Re-run the Step 4C heuristics against the now-empty DAG. Since all original batc
3030
3532
  ```bash
3031
3533
  SWEEP_EXECUTE=()
3032
3534
  SWEEP_STILL_DEFERRED=()
3535
+ AMPLIFICATION_BREAKER_SWEEP_HELD=()
3033
3536
 
3034
3537
  for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
3035
3538
  FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,state \
@@ -3051,6 +3554,17 @@ for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
3051
3554
  "" | ltrimstr("priority:")')
3052
3555
  TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
3053
3556
 
3557
+ # Amplification-breaker deferrals (forge#3060) are NEVER individually dispatched by the sweep:
3558
+ # draining the DAG does not make a P3 cascade bounded. Leave them open for the bounded P3
3559
+ # batch sweep (planP3BatchGroups) and report them to the operator instead.
3560
+ case "${DEFERRED_REASONS[$FINDING_NUM]:-}" in
3561
+ *"$BREAKER_DEFER_TAG"*)
3562
+ SWEEP_STILL_DEFERRED+=($FINDING_NUM)
3563
+ AMPLIFICATION_BREAKER_SWEEP_HELD+=($FINDING_NUM)
3564
+ echo "Sweep: #${FINDING_NUM} held for P3 batching (amplification breaker) — not dispatched individually"
3565
+ continue ;;
3566
+ esac
3567
+
3054
3568
  # Re-apply heuristics against the drained DAG (no active batch files)
3055
3569
  # Comment/typo heuristic still applies — these are cosmetic regardless of DAG state
3056
3570
  if echo "$TITLE" | grep -qi "comment\|typo"; then
@@ -3232,9 +3746,10 @@ The context-gathering phase can fetch this index to discover all investigation G
3232
3746
  **HOW REVIEW FINDINGS WORK**: /review-pr may create GitHub issues (with \`review-finding\` label) for findings it discovers. These are NOT blockers — they are separate work items that will go through their own /work-on pipeline later. The original PR should ALWAYS merge after review. The only exception is build errors (code doesn't compile) — those must be fixed before merging.
3233
3747
 
3234
3748
  **IMPORTANT RULES**:
3235
- - **MANDATORY**: You MUST use the Skill tool to invoke 'work-on' with args '${FINDING_NUM}'. Do NOT implement manually — /work-on handles the full pipeline including label state machine (workflow:investigating → workflow:building → workflow:in-review → workflow:merged), investigation reports, PR creation, and cleanup.
3236
- - For default repo issues: \`Skill(skill='work-on', args='${FINDING_NUM} --under-orchestration')\`
3237
- - For satellite repo issues: \`Skill(skill='work-on', args='{SATELLITE_PREFIX}:${FINDING_NUM} --under-orchestration')\` (prefix from forge.yaml → repos.satellites)
3749
+ - **MANDATORY**: You MUST use the Skill tool to invoke '{FORGE_SKILL_PREFIX}work-on' with args '${FINDING_NUM}'. Do NOT implement manually — /work-on handles the full pipeline including label state machine (workflow:investigating → workflow:building → workflow:in-review → workflow:merged), investigation reports, PR creation, and cleanup.
3750
+ - For default repo issues: \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='${FINDING_NUM} --under-orchestration')\`
3751
+ - For satellite repo issues: \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='<satellite-prefix>:${FINDING_NUM} --under-orchestration')\` (\`<satellite-prefix>\` from forge.yaml → repos.satellites)
3752
+ - **If \`{FORGE_SKILL_PREFIX}work-on\` is reported unknown or not found, this is a HARD ERROR: STOP and report 'skill not found: {FORGE_SKILL_PREFIX}work-on' as your final result. Do NOT run the pipeline phases inline or with the Agent tool — an inline run bypasses the phase trail.**
3238
3753
  - NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
3239
3754
  - NEVER target \`main\` for PRs targeting the default repo. Use \`{STAGING_BRANCH}\` for fast-lane issues, or \`milestone/{slug}\` for milestone issues.
3240
3755
  - Satellite repos (MCP, n8n) have no staging branch — fast-lane PRs go to \`main\` for those.
@@ -3244,20 +3759,20 @@ The context-gathering phase can fetch this index to discover all investigation G
3244
3759
  - **NEVER ask the user questions** — you are a background agent. If review finds issues, auto-fix simple ones and proceed, then let `/review-pr`'s own verdict decide: APPROVED (no unresolved CONFIRMED HIGH/CRITICAL finding) → merge to `staging` and create follow-up issues for the rest, **regardless of domain**. Domain alone (AUTH, BILLING, DATABASE, or any domain tagged as security-critical in Step 3B) is NOT a reason to add `needs-human` and stop — `staging` is reversible; the real human deploy gate is `staging → main`, not this merge. <!-- Added: forge#1815 --> `needs-human` is reserved for what the pipeline genuinely cannot do itself: spend/procurement decisions, real-environment validation it has no access to, product/architecture judgment calls a human must make, or `/review-pr`'s existing evidence-based escalations (spec-evolution guard, novel task-type/module-combo trust escalation, calibration-based overconfidence routing). An unresolved CONFIRMED HIGH/CRITICAL finding is `/review-pr`'s own withheld-APPROVED case, not a domain-driven `needs-human` halt — `/review-pr` already refuses to return APPROVED when that's true, so there is no separate domain check to perform here.
3245
3760
 
3246
3761
  **LABEL-STATE LOOP CONTRACT — enforce after EVERY Skill return**:
3247
- After EVERY \`Skill(skill='work-on', ...)\` call returns, immediately check the issue's current workflow label:
3762
+ After EVERY \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)\` call returns, immediately check the issue's current workflow label:
3248
3763
  \`\`\`bash
3249
3764
  gh issue view ${FINDING_NUM} -R {GH_REPO} --json labels --jq '[.labels[].name | select(startswith(\"workflow:\"))]'
3250
3765
  \`\`\`
3251
3766
  **Terminal labels** (only these allow you to stop): \`workflow:merged\`, \`workflow:invalid\`
3252
3767
  **Terminal condition also**: \`needs-human\` label present, \`workflow:awaiting-merge\` label present, OR issue state is \`closed\`
3253
- If the label is NOT terminal (e.g., \`workflow:investigating\`, \`workflow:ready-to-build\`, \`workflow:building\`, \`workflow:in-review\`), invoke \`Skill(skill='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.
3768
+ 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.
3254
3769
 
3255
3770
  **CRITICAL — SOURCE BRANCH DETECTION**:
3256
3771
  - If the issue has the \`review-finding\` label, read the issue body for \`**Code branch**: \\\`{branch}\\\`\`
3257
- - If found, that is the SOURCE_BRANCH — the code ONLY exists on that branch (e.g., \`staging\`), NOT on \`origin/main\`
3258
- - Investigation MUST use \`git show origin/{SOURCE_BRANCH}:{filepath}\` to verify the code exists
3259
- - Worktree MUST branch from \`origin/{SOURCE_BRANCH}\`, NOT \`origin/main\`
3260
- - PR target is \`{SOURCE_BRANCH}\` (the fix goes back to where the code lives)
3772
+ - If found, that branch is the source branch (`<source-branch>` below) — the code ONLY exists on that branch (e.g., \`staging\`), NOT on \`origin/main\`
3773
+ - Investigation MUST use \`git show origin/<source-branch>:<filepath>\` to verify the code exists
3774
+ - Worktree MUST branch from \`origin/<source-branch>\`, NOT \`origin/main\`
3775
+ - PR target is \`<source-branch>\` (the fix goes back to where the code lives)
3261
3776
 
3262
3777
  **LANE**: ${SWEEP_LANE[$FINDING_NUM]} (PR target: ${SWEEP_PR_BASE[$FINDING_NUM]})
3263
3778
  **Issue title**: ${FINDING_TITLE}