forgedock 1.8.1 → 1.8.3

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 (90) 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/design-bench.md +1 -0
  11. package/commands/design-render-critique-loop.md +1 -0
  12. package/commands/design.md +1 -0
  13. package/commands/forge-stats.md +1 -0
  14. package/commands/geo-audit.md +3 -1
  15. package/commands/incident-response.md +3 -1
  16. package/commands/issue.md +4 -2
  17. package/commands/milestone.md +8 -6
  18. package/commands/orchestrate/config.md +3 -2
  19. package/commands/orchestrate/phase-1-resolve.md +57 -12
  20. package/commands/orchestrate/phase-2-triage.md +1 -0
  21. package/commands/orchestrate/phase-2.5-synthesis.md +1 -0
  22. package/commands/orchestrate/phase-3-dependency.md +17 -5
  23. package/commands/orchestrate/phase-4-execution.md +625 -78
  24. package/commands/orchestrate/phase-5-cleanup.md +5 -2
  25. package/commands/orchestrate/phase-6-report.md +8 -3
  26. package/commands/orchestrate/safety.md +1 -0
  27. package/commands/orchestrate.md +13 -9
  28. package/commands/pipeline-health/config.md +1 -0
  29. package/commands/pipeline-health/phase-1-context.md +1 -0
  30. package/commands/pipeline-health/phase-2-metrics.md +1 -0
  31. package/commands/pipeline-health/phase-3-analyze.md +1 -0
  32. package/commands/pipeline-health/phase-4-proposals.md +7 -4
  33. package/commands/pipeline-health/phase-5-report.md +5 -2
  34. package/commands/pipeline-health/phase-6-summary.md +1 -0
  35. package/commands/pipeline-health.md +9 -7
  36. package/commands/pipeline-resume.md +4 -2
  37. package/commands/qa-sweep.md +3 -1
  38. package/commands/quality-gate.md +4 -3
  39. package/commands/recover-orphans.md +219 -13
  40. package/commands/review-pr-agents/api.md +1 -0
  41. package/commands/review-pr-agents/auth.md +1 -0
  42. package/commands/review-pr-agents/billing.md +1 -0
  43. package/commands/review-pr-agents/concurrency.md +1 -0
  44. package/commands/review-pr-agents/database.md +1 -0
  45. package/commands/review-pr-agents/frontend.md +1 -0
  46. package/commands/review-pr-agents/infra.md +1 -0
  47. package/commands/review-pr-agents/protocols.md +1 -0
  48. package/commands/review-pr-agents/scraper.md +1 -0
  49. package/commands/review-pr-agents/security.md +1 -0
  50. package/commands/review-pr-agents/spec-cli.md +1 -0
  51. package/commands/review-pr-agents.md +10 -4
  52. package/commands/review-pr-staging.md +14 -5
  53. package/commands/review-pr.md +187 -19
  54. package/commands/rollback.md +3 -1
  55. package/commands/security-audit.md +3 -1
  56. package/commands/signal-planner.md +6 -4
  57. package/commands/sync-ecosystem.md +4 -2
  58. package/commands/test-gate.md +4 -2
  59. package/commands/upgrade-deps.md +3 -1
  60. package/commands/validate.md +3 -1
  61. package/commands/work-on/build/architect.md +82 -19
  62. package/commands/work-on/build/context.md +62 -18
  63. package/commands/work-on/build/implement.md +63 -18
  64. package/commands/work-on/build/validate.md +241 -60
  65. package/commands/work-on/build.md +345 -146
  66. package/commands/work-on/close.md +618 -246
  67. package/commands/work-on/decompose.md +240 -41
  68. package/commands/work-on/investigate.md +378 -61
  69. package/commands/work-on/remediate.md +43 -9
  70. package/commands/work-on/review.md +241 -29
  71. package/commands/work-on.md +180 -2014
  72. package/docs/CODEX.md +2 -0
  73. package/docs/CONFIG.md +11 -0
  74. package/package.json +1 -1
  75. package/scripts/build-spec-graph.mjs +11 -4
  76. package/scripts/check-spec-markers.sh +21 -1
  77. package/scripts/checkbox-sections.test.sh +8 -23
  78. package/scripts/extract-affected-files.sh +5 -2
  79. package/scripts/extract-affected-files.test.sh +27 -0
  80. package/scripts/forge-root.test.sh +176 -0
  81. package/scripts/is-docs-only.sh +31 -0
  82. package/scripts/is-docs-only.test.sh +64 -0
  83. package/scripts/lint-dispatch-prompt.sh +217 -0
  84. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  85. package/scripts/validate-spec-graph.sh +3 -2
  86. package/scripts/verify-phase-trail.sh +282 -0
  87. package/scripts/verify-phase-trail.test.sh +298 -0
  88. package/scripts/wait-ci-green.sh +98 -0
  89. package/scripts/wait-ci-green.test.sh +99 -0
  90. package/commands/work-on-monolithic.md +0 -326
@@ -1,4 +1,5 @@
1
1
  ---
2
+ user-invocable: false
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -29,7 +30,7 @@ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "
29
30
  exit 1
30
31
  ;;
31
32
  free|self)
32
- HOSTNAME_ID=$(hostname 2>/dev/null || echo "unknown-host")
33
+ HOSTNAME_ID=$(hostname 2>/dev/null || uname -n 2>/dev/null || true); HOSTNAME_ID="${HOSTNAME_ID:-unknown-host}"
33
34
  # 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
35
  gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
35
36
  **Holder Batch ID**: ${BATCH_ID}
@@ -392,10 +393,12 @@ resolve_classify_lane() {
392
393
  [ -n "${OPENCODE_SESSION_ID:-}" ] ||
393
394
  [ -n "${OPENCODE_PID:-}" ] ||
394
395
  [ -n "${OPENCODE:-}" ]; then
396
+ case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/classify-lane.sh') ;; esac # running plugin first
395
397
  [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
396
398
  [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
397
399
  candidates+=("$HOME/.opencode/scripts/classify-lane.sh")
398
400
  else
401
+ case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/classify-lane.sh') ;; esac # running plugin first
399
402
  [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
400
403
  candidates+=("$HOME/.claude/scripts/classify-lane.sh")
401
404
  [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
@@ -444,8 +447,11 @@ declare -A DEFERRED_REASONS
444
447
  # makes the reported amplification ratio look better than it was.
445
448
  FINDINGS_SPAWNED=0
446
449
  MERGED_UNITS=0
450
+ UNIT_MERGED_THIS_CYCLE=false # set per completed unit by the Step 4C accounting block (forge#3060)
447
451
  AMPLIFICATION_RATIO_HISTORY=()
448
452
  AMPLIFICATION_DEFERRED=()
453
+ AMPLIFICATION_BREAKER_DEFERRED=() # findings paused by the on-by-default breaker (forge#3060)
454
+ BREAKER_DEFER_TAG="amplification breaker" # single source for the defer-reason text matched in Step 4F
449
455
  declare -A FINDINGS_BY_SOURCE_PR
450
456
  declare -A REFINEMENT_FINDINGS
451
457
  declare -A NEW_SURFACE_FINDINGS
@@ -504,6 +510,7 @@ declare -A EDGE_REDERIVE_ATTEMPTS
504
510
  # copied ForgeDock's helper scripts into its own repository (#2794/#2791).
505
511
  resolve_extract_affected_files() {
506
512
  local candidates=()
513
+ case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/extract-affected-files.sh') ;; esac # running plugin first
507
514
  [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/extract-affected-files.sh")
508
515
  [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/extract-affected-files.sh")
509
516
  candidates+=("$PWD/scripts/extract-affected-files.sh")
@@ -596,7 +603,7 @@ fi
596
603
 
597
604
  TOKEN_ESTIMATE_PER_FINDING=$(yq '.pipeline.token_estimate_per_finding // 150000' forge.yaml 2>/dev/null || echo 150000)
598
605
 
599
- # Amplification is observational by default. The optional ceiling is evaluated
606
+ # Amplification is observational by default for the same-lineage ceiling (the breaker below is not). The optional ceiling is evaluated
600
607
  # only against same-lineage refinements in Step 4C, never against new surface.
601
608
  CASCADE_MAX_AMPLIFICATION=$(yq '.orchestration.cascade.max_amplification // "off"' forge.yaml 2>/dev/null || echo "off")
602
609
  if [ "$CASCADE_MAX_AMPLIFICATION" != "off" ]; then
@@ -611,7 +618,23 @@ if ! echo "$CONVERGENCE_WINDOW" | grep -qE '^[1-9][0-9]*$'; then
611
618
  echo "WARNING: forge.yaml → orchestration.cascade.convergence_window must be a positive integer — falling back to 3"
612
619
  CONVERGENCE_WINDOW=3
613
620
  fi
614
- echo "Cascade amplification: max_amplification=${CASCADE_MAX_AMPLIFICATION} convergence_window=${CONVERGENCE_WINDOW} (off preserves current admission behavior)"
621
+ # On-by-default amplification circuit breaker (forge#3060). Unlike max_amplification (opt-in,
622
+ # same-lineage refinements only), this counts ALL cascade findings and pauses P3-and-below
623
+ # admission when the ratio stays >= 1.0 for CONVERGENCE_WINDOW merged units. P1/P2 are never
624
+ # affected. It is independent of orchestration.cascade.policy, so `policy: all` and the
625
+ # --max-generation/--token-budget unlimited CLI overrides do NOT disable it. Opt out only
626
+ # with an explicit `orchestration.cascade.amplification_breaker: off`.
627
+ # NOTE: do NOT use yq's `// "on"` here — the alternative operator treats boolean `false` as falsy and
628
+ # would turn `amplification_breaker: false` into "on". Read the raw value and default null/empty to "on".
629
+ AMPLIFICATION_BREAKER=$(yq '.orchestration.cascade.amplification_breaker' forge.yaml 2>/dev/null || echo "on")
630
+ case "$AMPLIFICATION_BREAKER" in ""|null) AMPLIFICATION_BREAKER="on" ;; esac
631
+ case "$(echo "$AMPLIFICATION_BREAKER" | tr '[:upper:]' '[:lower:]')" in
632
+ on|true) AMPLIFICATION_BREAKER="on" ;;
633
+ off|false) AMPLIFICATION_BREAKER="off" ;;
634
+ *) echo "WARNING: forge.yaml → orchestration.cascade.amplification_breaker must be on or off (\"${AMPLIFICATION_BREAKER}\") — keeping the breaker on"; AMPLIFICATION_BREAKER="on" ;;
635
+ esac
636
+ AMPLIFICATION_BREAKER_TRIPPED=false
637
+ 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
638
 
616
639
  # Independent boolean levers — each accepts an explicit granular override on
617
640
  # top of the resolved preset (preset supplies the default, not a hard value).
@@ -674,17 +697,21 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
674
697
  ### Step 4A.0: Probe Knowledge Gist capability once
675
698
 
676
699
  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.
700
+ OpenCode worker is dispatched. A GitHub App installation token cannot use the Gists API (and cannot even read `/user`); cache
701
+ that fact rather than letting every worker rediscover it by attempting a write. The probe fails
702
+ closed: Gists stay enabled only when `/user` positively reports a `User` account.
680
703
 
681
704
  ```bash
682
705
  if [ -z "${FORGE_GIST_CAPABLE+x}" ]; then
706
+ # Fail closed: only a positively identified user account can use the Gists API. A GitHub App
707
+ # installation token cannot read /user at all (HTTP 403, empty type), so "not Bot" is NOT
708
+ # evidence of capability; an unavailable probe disables Gists rather than letting every
709
+ # worker fail on its first Gist write.
683
710
  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
711
+ if [ "$GIST_AUTH_TYPE" = "User" ]; then
687
712
  FORGE_GIST_CAPABLE=true
713
+ else
714
+ FORGE_GIST_CAPABLE=false
688
715
  fi
689
716
  export FORGE_GIST_CAPABLE
690
717
  fi
@@ -782,7 +809,7 @@ task(
782
809
  description="Work on {PROJECT_PREFIX}#{NUMBER}",
783
810
  subagent_type="general",
784
811
  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."
812
+ 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
813
  )
787
814
  ```
788
815
 
@@ -922,7 +949,7 @@ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
922
949
  # Lease heartbeat refresh (forge#2627) — once per dispatch chunk, so a long-running batch's
923
950
  # lease never goes stale purely from elapsed wall-clock time while dispatch is still active.
924
951
  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")
952
+ HOSTNAME_ID=$(hostname 2>/dev/null || uname -n 2>/dev/null || true); HOSTNAME_ID="${HOSTNAME_ID:-unknown-host}"
926
953
  # 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
954
  gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
928
955
  **Holder Batch ID**: ${BATCH_ID}
@@ -976,6 +1003,8 @@ For each **ready** issue (all predecessors resolved or no predecessors), spawn a
976
1003
 
977
1004
  **One agent per issue.** Do NOT group multiple issues into a single agent. `/work-on` handles branching, labels, and PRs per-issue.
978
1005
 
1006
+ **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.
1007
+
979
1008
  **Copy this template. Fill in variables. Do not modify the structure:**
980
1009
 
981
1010
  ```
@@ -999,12 +1028,13 @@ Agent(
999
1028
  **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
1029
 
1001
1030
  **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)
1031
+ - **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.
1032
+ - For default repo issues: `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{NUMBER} --under-orchestration')`
1033
+ - For satellite repo issues: `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='<satellite-prefix>:{NUMBER} --under-orchestration')` (`<satellite-prefix>` from forge.yaml → repos.satellites)
1034
+ - **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
1035
  - 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
1036
  - 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 -->
1037
+ - **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
1038
  - **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
1039
  - **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
1040
  - **`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 +1046,115 @@ Agent(
1016
1046
  - **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
1047
 
1018
1048
  **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:
1049
+ After EVERY `Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)` call returns, immediately check the issue's current workflow label:
1020
1050
  ```bash
1021
1051
  gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name | select(startswith("workflow:"))]'
1022
1052
  ```
1023
1053
  **Terminal labels** (only these allow you to stop): `workflow:merged`, `workflow:invalid`
1024
1054
  **Terminal condition also**: `needs-human` label present, `workflow:awaiting-merge` label present, OR issue state is `closed`
1025
1055
  `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.
1056
+ 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
1057
 
1028
1058
  **CRITICAL — SOURCE BRANCH DETECTION**:
1029
1059
  - 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)
1060
+ - 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`
1061
+ - Investigation MUST use `git show origin/<source-branch>:<filepath>` to verify the code exists
1062
+ - Worktree MUST branch from `origin/<source-branch>`, NOT `origin/main`
1063
+ - PR target is `<source-branch>` (the fix goes back to where the code lives)
1034
1064
 
1035
1065
  **LANE**: {LANE} (PR target: {PR_BASE})
1036
1066
  **Issue title**: {ISSUE_TITLE}
1067
+ <!-- DISPATCH_CONTEXT:BEGIN -->
1037
1068
  {GIST_CONTEXT}
1038
1069
  {SOURCE_PR_HINT_CONTEXT}
1070
+ <!-- DISPATCH_CONTEXT:END -->
1039
1071
  "
1040
1072
  )
1041
1073
  ```
1042
1074
 
1075
+ **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.
1076
+
1077
+ ```bash
1078
+ # Bind the lint inputs FIRST (forge#3149). Every value is the SAME one substituted into the Step 4A template for this
1079
+ # issue (config.md / Step 4A resolve them) - substitute the literal values, never re-derive them. The rendered
1080
+ # prompt goes in through a quoted heredoc so no shell expansion touches it. A value containing a single quote is
1081
+ # written as '\'' . PROJECT_PREFIX, SATELLITE_PREFIX, FORGE_SKILL_PREFIX and SOURCE_BRANCH may be empty.
1082
+ PROJECT_NAME='{PROJECT_NAME}'; GH_REPO='{GH_REPO}'; REPO_PATH='{REPO_PATH}'
1083
+ LANE='{LANE}'; PR_BASE='{PR_BASE}'; STAGING_BRANCH='{STAGING_BRANCH}'
1084
+ SOURCE_BRANCH='{SOURCE_BRANCH}'; FORGE_GIST_CAPABLE='{FORGE_GIST_CAPABLE}'; SUBAGENT_MODEL='{SUBAGENT_MODEL}'
1085
+ PROJECT_PREFIX='{PROJECT_PREFIX}'; SATELLITE_PREFIX='{SATELLITE_PREFIX}'; FORGE_SKILL_PREFIX='{FORGE_SKILL_PREFIX}'
1086
+ # Heredoc into a file, then read it back: a heredoc body inside $( ) is parsed for quote balance by bash 3.2 (macOS),
1087
+ # and the rendered prompt contains apostrophes and parentheses.
1088
+ # The delimiter MUST be a string that occurs nowhere in the rendered prompt (the GIST/SOURCE_PR_HINT context blocks carry
1089
+ # untrusted text): replace RENDERED_PROMPT_EOF below with RENDERED_PROMPT_EOF_ plus 16 random hex chars, in BOTH places.
1090
+ RENDERED_PROMPT_SRC="$(mktemp)"
1091
+ cat > "$RENDERED_PROMPT_SRC" <<'RENDERED_PROMPT_EOF'
1092
+ {the fully rendered Step 4A prompt text for this issue, exactly as passed to Agent(prompt=...)}
1093
+ RENDERED_PROMPT_EOF
1094
+ RENDERED_PROMPT=$(cat "$RENDERED_PROMPT_SRC"); rm -f "$RENDERED_PROMPT_SRC"
1095
+ ```
1096
+
1097
+ ```bash
1098
+ # Runs inside the per-issue dispatch loop (same loop as claim_conflicts_with_live_holder above),
1099
+ # immediately before that issue's Agent()/task() call. LINT_REFUSED_ISSUES is initialised once, empty.
1100
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
1101
+ FORGE_ROOT=""
1102
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
1103
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
1104
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
1105
+ # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
1106
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
1107
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
1108
+ # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
1109
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
1110
+ while IFS= read -r _c; do
1111
+ case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
1112
+ done <<< "$_k"
1113
+ fi
1114
+ # Fail closed on unbound inputs (forge#3149): an empty rendered prompt or a missing resolved value would lint an empty file
1115
+ # (rc=2) or compare against nothing; refuse with a distinct reason instead of a misleading lint verdict.
1116
+ if [ -z "$RENDERED_PROMPT" ] || [ -z "$PROJECT_NAME" ] || [ -z "$GH_REPO" ] || [ -z "$REPO_PATH" ] || [ -z "$LANE" ] \
1117
+ || [ -z "$PR_BASE" ] || [ -z "$STAGING_BRANCH" ] || [ -z "$FORGE_GIST_CAPABLE" ] || [ -z "$SUBAGENT_MODEL" ]; then
1118
+ 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
1119
+ LINT_REFUSED_ISSUES+=("{NUMBER}:lint-inputs-unbound")
1120
+ continue
1121
+ fi
1122
+ LINT_SCRIPT="$FORGE_ROOT/scripts/lint-dispatch-prompt.sh"
1123
+ if [ -z "$FORGE_ROOT" ] || [ ! -r "$LINT_SCRIPT" ]; then
1124
+ # Fail closed: a missing lint script is NOT a pass and NOT a lint verdict.
1125
+ 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
1126
+ LINT_REFUSED_ISSUES+=("{NUMBER}:lint-script-not-found")
1127
+ continue
1128
+ fi
1129
+ PROMPT_FILE="$(mktemp)"
1130
+ printf '%s' "$RENDERED_PROMPT" > "$PROMPT_FILE"
1131
+ # Exact-match expectations: every slot the orchestrator resolved itself must equal, byte for byte, the
1132
+ # value it substituted into the template (the SAME variables, never re-derived). One quoted argument per
1133
+ # slot, so spaces, parentheses, backslashes and non-ASCII are safe. A slot present in the prompt with no
1134
+ # --expect is refused (fail closed). PROJECT_PREFIX / FORGE_SKILL_PREFIX may legitimately be empty.
1135
+ LINT_EXPECT=(
1136
+ --expect "PROJECT_NAME=$PROJECT_NAME" --expect "GH_REPO=$GH_REPO"
1137
+ --expect "REPO_PATH=$REPO_PATH" --expect "LANE=$LANE"
1138
+ --expect "PR_BASE=$PR_BASE" --expect "STAGING_BRANCH=$STAGING_BRANCH"
1139
+ --expect "SOURCE_BRANCH=$SOURCE_BRANCH" --expect "NUMBER={NUMBER}"
1140
+ --expect "FORGE_GIST_CAPABLE=$FORGE_GIST_CAPABLE" --expect "SUBAGENT_MODEL=$SUBAGENT_MODEL"
1141
+ --expect "PROJECT_PREFIX=$PROJECT_PREFIX" --expect "SATELLITE_PREFIX=$SATELLITE_PREFIX"
1142
+ --expect "FORGE_SKILL_PREFIX=$FORGE_SKILL_PREFIX"
1143
+ )
1144
+ bash "$LINT_SCRIPT" "${LINT_EXPECT[@]}" "$PROMPT_FILE"; LINT_RC=$?
1145
+ rm -f "$PROMPT_FILE"
1146
+ if [ "$LINT_RC" -ne 0 ]; then
1147
+ # rc 1 = template deviation (violations printed above); rc 2 = unreadable/empty prompt; any other rc = lint crashed.
1148
+ echo "DISPATCH REFUSED for #{NUMBER}: prompt lint rc=$LINT_RC (Hard Rule 1)" >&2
1149
+ LINT_REFUSED_ISSUES+=("{NUMBER}:lint-rc-$LINT_RC")
1150
+ continue # skip this issue's Agent()/task() call; never launch the failing prompt
1151
+ fi
1152
+ ```
1153
+
1154
+ **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`.
1155
+
1156
+ 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`.
1157
+
1043
1158
  **`{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
1159
 
1045
1160
  ```bash
@@ -1111,7 +1226,7 @@ Claims board issue URL: ${FORGE_COORD_ISSUE}
1111
1226
 
1112
1227
  On build start (Phase B2 / Phase 3C of /work-on), post a FORGE:CLAIM annotation on the
1113
1228
  coordination issue above. Required fields:
1114
- Holder: ##{NUMBER} / batch-$(date -u +%Y%m%dT%H%M%S)
1229
+ **Holder**: #{NUMBER} / batch-$(date -u +%Y%m%dT%H%M%S) (exact form — read_active_claims parses `**Holder**: #N`)
1115
1230
  Files: (list of files from your FORGE:CONTRACT deliverables table, one per line)
1116
1231
  Interfaces: (public function/type signatures you will modify or that callers must preserve)
1117
1232
  TTL: terminal state of Holder issue ##{NUMBER}
@@ -1226,9 +1341,106 @@ For an OpenCode completion, retain `OPENCODE_DISPATCH_MAP[{NUMBER}]` until the t
1226
1341
  **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
1342
 
1228
1343
  ```bash
1344
+ # hold_merged_trail <issue> <trail-text> (forge#3156): the single escalation path for a merged issue whose phase
1345
+ # trail failed or could not be verified. Used by Step 4B item 0 and by the classifier's wake re-verification.
1346
+ # Order matters (forge#3169): the escalation COMMENT is posted FIRST (re-read immediately before each post, skipped when
1347
+ # an ACTIVE escalation already exists, verified ACTIVE by re-read after), then the `needs-human` label is written
1348
+ # (read-back verified, 3 tries). The comment is the durable audit record, independent of the verifier, so a label-only
1349
+ # hold is not the normal state and a human release always has an anchor to be recorded against. Returns 0 only when both
1350
+ # halves are verified; otherwise returns 1 and stderr names which half failed (the label is still attempted after a
1351
+ # comment failure, because holding dependents is the safe side).
1352
+ hold_merged_trail() {
1353
+ local N="$1" TXT="$2" TRY COMMENTED=1 HELD=1
1354
+ for TRY in 1 2; do
1355
+ # forge#3157: dedupe only against an ACTIVE escalation, so a re-escalation after a recorded release posts a new one.
1356
+ # forge#3169: re-read right before each post (cache entry removed) to narrow the concurrent-classifier duplicate window.
1357
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1358
+ if [ "$(trail_escalation_state "$N")" = "ACTIVE" ]; then COMMENTED=0; break; fi
1359
+ gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_FAILED -->
1360
+ #${N} merged with an incomplete phase trail (or one that could not be verified), detected by /orchestrate.
1361
+
1362
+ \`\`\`
1363
+ ${TXT}
1364
+ \`\`\`
1365
+
1366
+ The PR is already merged, so \`/work-on\` cannot re-run the missing phases. Review the merged change, then remove \`needs-human\` to release dependents held as \`blocked-on-human-merge\`." >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
1367
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1368
+ if [ "$(trail_escalation_state "$N")" = "ACTIVE" ]; then COMMENTED=0; break; fi
1369
+ done
1370
+ for TRY in 1 2 3; do
1371
+ gh issue edit "$N" -R {GH_REPO} --add-label "needs-human" >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
1372
+ if gh issue view "$N" -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null \
1373
+ | grep -qE '(^|,)needs-human(,|$)'; then HELD=0; break; fi
1374
+ done
1375
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1376
+ [ "$COMMENTED" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — escalation comment could not be verified (audit record missing); hold dependents manually." >&2
1377
+ [ "$HELD" -ne 0 ] && echo "PHASE TRAIL HOLD FAILED: #${N} — needs-human label could not be verified; dependents are NOT held. Hold them manually." >&2
1378
+ [ "$COMMENTED" -eq 0 ] && [ "$HELD" -eq 0 ]
1379
+ }
1380
+
1381
+ # resolve_orch_login (forge#3168): prints the login of the identity that authors the orchestrator's own FAILED/RELEASED
1382
+ # marker comments, so trail_escalation_state can trust them even when the bot reports author_association NONE or
1383
+ # CONTRIBUTOR. Resolution order: FORGE_BOT_LOGIN env override, then the git identity (`git config user.name`, which for a
1384
+ # GitHub App is its `<app>[bot]` login). Never calls `/user` (403s for App tokens). An unresolved value prints nothing and
1385
+ # the caller fails closed: only the author_association set below is trusted, never "trust every author".
1386
+ resolve_orch_login() {
1387
+ local L="${FORGE_BOT_LOGIN:-}"
1388
+ [ -n "$L" ] || L=$(git config user.name 2>/dev/null || true)
1389
+ printf '%s' "$L"
1390
+ }
1391
+
1392
+ # trail_escalation_state <issue> (forge#3157): prints ABSENT (no merged-trail escalation comment), ACTIVE (newest
1393
+ # FORGE:PHASE_TRAIL_FAILED comment id is greater than the newest FORGE:PHASE_TRAIL_RELEASED id), RELEASED (a human
1394
+ # release was recorded after the last escalation, so a later unrelated `needs-human` no longer gates), or UNREADABLE.
1395
+ # forge#3168: only TRUSTED comments count, for BOTH markers. Trusted = author_association OWNER/MEMBER/COLLABORATOR, or
1396
+ # `.user.login` equal to the orchestrator identity (resolve_orch_login, used only when non-empty). A marker string posted
1397
+ # by anyone else is ignored (treated as absent), so an outside commenter can neither forge a release nor a failure.
1398
+ # The comment read is cached per issue for the current monitoring cycle: the cache is a directory of files
1399
+ # (`$(...)` subshells lose shell arrays), `TRAIL_CACHE_DIR=$(mktemp -d)` and is recreated at the start of every
1400
+ # monitoring cycle (Step 4B item 6.6), so a human edit made between cycles is always seen. Only successful reads are cached.
1401
+ trail_escalation_state() {
1402
+ local N="$1" F="${TRAIL_CACHE_DIR:+$TRAIL_CACHE_DIR/trail-$1}" RAW OUT FAILED_ID RELEASED_ID RES
1403
+ if [ -n "$F" ] && [ -s "$F" ]; then cat "$F"; return; fi
1404
+ if ! RAW=$(gh api --paginate "repos/{GH_REPO}/issues/${N}/comments" --jq '.[] | {id: .id, body: (.body // ""), login: (.user.login // ""), assoc: (.author_association // "")}' 2>/dev/null); then
1405
+ echo "UNREADABLE"; return
1406
+ fi
1407
+ if ! OUT=$(printf '%s\n' "$RAW" | jq -r --arg L "$(resolve_orch_login)" 'select((.assoc | IN("OWNER","MEMBER","COLLABORATOR")) or ($L != "" and .login == $L)) | if (.body | contains("FORGE:PHASE_TRAIL_FAILED") and contains("merged with an incomplete phase trail")) then "F \(.id)" elif (.body | contains("<!-- FORGE:PHASE_TRAIL_RELEASED -->")) then "R \(.id)" else empty end' 2>/dev/null); then
1408
+ echo "UNREADABLE"; return
1409
+ fi
1410
+ FAILED_ID=$(printf '%s\n' "$OUT" | awk '$1=="F"&&$2+0>m{m=$2+0}END{print m+0}')
1411
+ RELEASED_ID=$(printf '%s\n' "$OUT" | awk '$1=="R"&&$2+0>m{m=$2+0}END{print m+0}')
1412
+ if [ "$FAILED_ID" -eq 0 ]; then RES="ABSENT"
1413
+ elif [ "$RELEASED_ID" -gt "$FAILED_ID" ]; then RES="RELEASED"
1414
+ else RES="ACTIVE"; fi
1415
+ [ -n "$F" ] && printf '%s\n' "$RES" > "$F"
1416
+ echo "$RES"
1417
+ }
1418
+
1419
+ # release_merged_trail <issue> (forge#3157): record that a human cleared a merged-trail hold, so the escalation
1420
+ # stops gating. Call only after classify_predecessor_state reported DONE for a merged issue whose escalation is ACTIVE.
1421
+ # Only an ACTIVE escalation is released here: hold_merged_trail posts the comment before the label (forge#3169), so a
1422
+ # human release of a label-only (ABSENT) hold is not expected and is not recorded (avoids a stray RELEASED marker).
1423
+ # Idempotent (no-op unless ACTIVE) and never run in dry-run. The comment must not contain the escalation marker text.
1424
+ release_merged_trail() {
1425
+ local N="$1"
1426
+ [ "${DRY_RUN:-false}" = "true" ] && return 0
1427
+ [ "$(trail_escalation_state "$N")" = "ACTIVE" ] || return 0
1428
+ gh issue comment "$N" -R {GH_REPO} --body "<!-- FORGE:PHASE_TRAIL_RELEASED -->
1429
+ The merged-trail hold on #${N} was released by a human (needs-human cleared or trail re-verified). Dependents held as \`blocked-on-human-merge\` are released. A later, unrelated \`needs-human\` on this merged issue no longer gates them." >/dev/null 2>&1 || true # <!-- allowlist:check-command-side-effects -->
1430
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$N"
1431
+ }
1432
+
1433
+ # classify_predecessor_state <issue> (forge#3168): the helpers hold_merged_trail, resolve_orch_login,
1434
+ # trail_escalation_state and release_merged_trail above MUST be declared in the SAME shell before this function runs
1435
+ # (reverify_merged_trail is nested below). It has exactly ONE guarded write (the DRY_RUN-guarded hold_merged_trail
1436
+ # call in the unverified-merged branch); every other path is read-only.
1229
1437
  classify_predecessor_state() {
1230
1438
  local PRED="$1"
1231
- local PRED_INFO
1439
+ local PRED_INFO H
1440
+ for H in hold_merged_trail resolve_orch_login trail_escalation_state release_merged_trail; do
1441
+ # Fail loudly (stderr) but closed: a missing helper must never read as DONE.
1442
+ 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; }
1443
+ done
1232
1444
  # NOTE: the `workflow` array below deliberately also keeps `needs-human` — that label has NO
1233
1445
  # `workflow:` prefix (see bin/labels.json), so a bare `select(startswith("workflow:"))` would
1234
1446
  # drop it and the GATED branch's `needs-human` case would be dead code (forge#1812 primary case).
@@ -1238,10 +1450,83 @@ classify_predecessor_state() {
1238
1450
  PRED_STATE=$(echo "$PRED_INFO" | jq -r '.state // "OPEN"')
1239
1451
  PRED_LABELS=$(echo "$PRED_INFO" | jq -r '.workflow[]?' 2>/dev/null)
1240
1452
 
1453
+ # forge#3156: an empty/failed label read must never fall through to IN_PROGRESS/DONE for a merged issue. PRED_INFO
1454
+ # is '{}' on a failed read; re-read once, then fail closed (GATED) rather than guessing from absent labels.
1455
+ if [ "$PRED_INFO" = '{}' ]; then
1456
+ PRED_INFO=$(gh issue view "$PRED" -R {GH_REPO} --json labels,state \
1457
+ --jq '{state: .state, workflow: [.labels[].name | select(startswith("workflow:") or . == "needs-human")]}' 2>/dev/null || echo '{}')
1458
+ if [ "$PRED_INFO" = '{}' ]; then echo "GATED"; return; fi
1459
+ PRED_STATE=$(echo "$PRED_INFO" | jq -r '.state // "OPEN"')
1460
+ PRED_LABELS=$(echo "$PRED_INFO" | jq -r '.workflow[]?' 2>/dev/null)
1461
+ fi
1462
+
1463
+ # reverify_merged_trail: re-run the phase-trail verifier for a merged predecessor that has NO escalation
1464
+ # record (forge#3156), so wake reconstruction does not trust the `workflow:merged` label alone. Prints OK
1465
+ # when the trail verifies, UNVERIFIED otherwise. Fails closed: a wake runs in a fresh shell where FORGE_ROOT is
1466
+ # unset, so the helper resolves it itself (canonical bootstrap, only when unset); if it is still unresolved or
1467
+ # the verifier is missing, the result is UNVERIFIED for every predecessor, never OK.
1468
+ reverify_merged_trail() {
1469
+ local P="$1" RC
1470
+ if [ -z "${FORGE_ROOT:-}" ]; then
1471
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
1472
+ FORGE_ROOT=""
1473
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
1474
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
1475
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
1476
+ # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
1477
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
1478
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
1479
+ # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
1480
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
1481
+ while IFS= read -r _c; do
1482
+ case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
1483
+ done <<< "$_k"
1484
+ fi
1485
+ fi
1486
+ if [ -z "${FORGE_ROOT:-}" ] || [ ! -f "$FORGE_ROOT/scripts/verify-phase-trail.sh" ]; then
1487
+ echo "UNVERIFIED"; return
1488
+ fi
1489
+ bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" "$P" -R {GH_REPO} --code-diff >/dev/null 2>&1; RC=$?
1490
+ [ "$RC" -eq 0 ] && echo "OK" || echo "UNVERIFIED"
1491
+ }
1492
+
1241
1493
  if echo "$PRED_LABELS" | grep -qx "workflow:invalid"; then
1242
1494
  echo "FAILED"
1495
+ elif echo "$PRED_LABELS" | grep -qx "workflow:merged" && echo "$PRED_LABELS" | grep -qx "needs-human"; then
1496
+ # forge#3148: GATED when Step 4B item 0 escalated a merged issue with an incomplete phase trail
1497
+ # (the code is in the base branch, but /work-on cannot repair a merged issue, so a human must review it;
1498
+ # dependents wait until a human clears needs-human). forge#3169: the hold is NOT keyed on the verifier. A passing
1499
+ # verifier does not mean "not held" (an agent-reported bypass verifies clean), and hold_merged_trail posts the
1500
+ # comment before the label, so merged + needs-human with no record is a failed/partial hold: fail closed (GATED).
1501
+ # A human release is the removal of needs-human, recorded by the release sweep via release_merged_trail.
1502
+ local TRAIL_ESC
1503
+ TRAIL_ESC=$(trail_escalation_state "$PRED")
1504
+ case "$TRAIL_ESC" in
1505
+ ABSENT) echo "GATED" ;; # forge#3169: no record yet; never trust the verifier to release a labelled hold
1506
+ RELEASED) echo "DONE" ;; # forge#3157: a human release was recorded after the last escalation; this needs-human is unrelated
1507
+ *) echo "GATED" ;; # ACTIVE or UNREADABLE: fail closed
1508
+ esac
1243
1509
  elif echo "$PRED_LABELS" | grep -qx "workflow:merged"; then
1244
- echo "DONE"
1510
+ # forge#3156: no needs-human. A recorded escalation (ACTIVE or RELEASED) means a human already released the hold
1511
+ # (DONE, forge#3157: the sweep records it via release_merged_trail); otherwise re-verify the trail so a merged
1512
+ # predecessor that item 0 never got to escalate (orchestrator died mid-hold, wake reconstruction) is not trusted
1513
+ # on the label alone. forge#3169: hold only when the record re-reads exactly ABSENT immediately before (an
1514
+ # UNREADABLE read classifies GATED without writing), so a concurrent classifier or a recorded release is not
1515
+ # overridden and at most one escalation comment is posted in the common case.
1516
+ case "$(trail_escalation_state "$PRED")" in
1517
+ ACTIVE|RELEASED) echo "DONE" ;;
1518
+ UNREADABLE) echo "GATED" ;;
1519
+ *)
1520
+ if [ "$(reverify_merged_trail "$PRED")" = "OK" ]; then
1521
+ echo "DONE"
1522
+ else
1523
+ # Unverified trail with no hold on record: establish it now so a human can release it.
1524
+ # Output is discarded (stdout is the classification), but a failed hold is reported on stderr, not swallowed.
1525
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -f "$TRAIL_CACHE_DIR/trail-$PRED"
1526
+ [ "${DRY_RUN:-false}" = "true" ] || [ "$(trail_escalation_state "$PRED")" != "ABSENT" ] || hold_merged_trail "$PRED" "PHASE_TRAIL: FAIL (re-verified at wake classification)" >/dev/null || echo "classify_predecessor_state: hold on #${PRED} could not be verified" >&2
1527
+ echo "GATED"
1528
+ fi ;;
1529
+ esac
1245
1530
  elif echo "$PRED_LABELS" | grep -qxE "needs-human|workflow:awaiting-merge"; then
1246
1531
  echo "GATED"
1247
1532
  elif echo "$PRED_LABELS" | grep -qx "workflow:engine-error"; then
@@ -1271,12 +1556,12 @@ classify_predecessor_state() {
1271
1556
  }
1272
1557
  ```
1273
1558
 
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).
1559
+ - **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.
1560
+ - **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
1561
  - **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
1562
  - **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
1563
 
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.
1564
+ 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
1565
 
1281
1566
  **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
1567
 
@@ -1570,6 +1855,85 @@ done
1570
1855
 
1571
1856
  **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
1857
 
1858
+ 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):
1859
+ ```bash
1860
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
1861
+ FORGE_ROOT=""
1862
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
1863
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
1864
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
1865
+ # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
1866
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
1867
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
1868
+ # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
1869
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
1870
+ while IFS= read -r _c; do
1871
+ case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
1872
+ done <<< "$_k"
1873
+ fi
1874
+ # forge#3102: only merged completions carry a full trail; invalid/decomposed/needs-human/awaiting-merge/paused do not.
1875
+ # Fail closed: a failed label read is an ERROR, never a skip.
1876
+ 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
1877
+ if ! TRAIL_LABELS=$(gh issue view {NUMBER} -R {GH_REPO} --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null); then
1878
+ TRAIL="PHASE_TRAIL: ERROR (could not read issue labels)"; TRAIL_RC=2 # infrastructure error, not a bypass (forge#3147)
1879
+ elif TRAIL_LABELS_READ_OK=1; ! echo ",$TRAIL_LABELS," | grep -q ',workflow:merged,'; then
1880
+ TRAIL="PHASE_TRAIL: SKIPPED (issue not workflow:merged — classified by label)"; TRAIL_RC=0
1881
+ elif [ -z "$FORGE_ROOT" ] || [ ! -f "$FORGE_ROOT/scripts/verify-phase-trail.sh" ]; then
1882
+ TRAIL="PHASE_TRAIL: ERROR (verify-phase-trail.sh not resolvable; set FORGEDOCK_HOME)"; TRAIL_RC=127 # fail closed
1883
+ else
1884
+ # forge#3123: a docs-only merged diff legitimately has no FORGE:QUALITY_GATE marker. Derive --docs-only from the
1885
+ # 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.
1886
+ 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
1887
+ # forge#3133: the text search is fuzzy and unordered, so `.[0]` can be an unrelated docs-only PR. Evaluate EVERY
1888
+ # matching merged PR and set the flag only if at least one matched AND all matched diffs are non-empty and docs-only.
1889
+ # forge#3149: the search is a fuzzy prefilter; the jq test below keeps only PRs whose body has the exact token
1890
+ # "Closes #{NUMBER}" NOT followed by another digit (so #31490 never matches #3149). --limit 100 is a generous cap on
1891
+ # merged PRs mentioning one issue (a pipeline issue normally has one closing PR).
1892
+ 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)
1893
+ if [ -n "$TRAIL_PRS" ]; then
1894
+ TRAIL_ALL_DOCS=1
1895
+ for TRAIL_PR in $TRAIL_PRS; do
1896
+ # forge#3145: both sides of a rename (filename + previous_filename); `gh pr diff --name-only` collapses a rename to its destination.
1897
+ TRAIL_FILES=$(gh api --paginate "repos/{GH_REPO}/pulls/${TRAIL_PR}/files" --jq '.[] | .filename, (.previous_filename // empty)' 2>/dev/null) || TRAIL_FILES=""
1898
+ # Fail closed on truncation: files API caps at 3000 files; at/over the cap or an unreadable count -> not docs-only.
1899
+ TRAIL_CHANGED_N=$(gh api "repos/{GH_REPO}/pulls/${TRAIL_PR}" --jq .changed_files 2>/dev/null) || TRAIL_CHANGED_N=""
1900
+ case "$TRAIL_CHANGED_N" in ''|*[!0-9]*) TRAIL_FILES="" ;; *) [ "$TRAIL_CHANGED_N" -ge 3000 ] && TRAIL_FILES="" ;; esac
1901
+ 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
1902
+ done
1903
+ [ "$TRAIL_ALL_DOCS" = "1" ] && TRAIL_DOCS_FLAG="--docs-only"
1904
+ fi
1905
+ TRAIL=$(bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" {NUMBER} -R {GH_REPO} $TRAIL_DOCS_FLAG 2>&1); TRAIL_RC=$?
1906
+ fi
1907
+ echo "$TRAIL"
1908
+ ```
1909
+ Route on `TRAIL_RC` (forge#3147). Both non-zero cases fail closed: never `DONE`, never cascaded as a satisfied predecessor.
1910
+ - `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.
1911
+ - `TRAIL_RC=1` (`PHASE_TRAIL: FAIL`) → classify as a **bypass** (handling below).
1912
+
1913
+ 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.
1914
+
1915
+ **Do NOT re-dispatch a merged issue** (forge#3148): `/work-on` Phase 0B stops on a closed or `workflow:merged` issue before its resume preflight, so a fresh agent exits without repairing anything and the predecessor would stay unresolved forever. The PR is already merged, so the gap can only be resolved by a human. Escalate it instead, which classifies it **GATED** (`classify_predecessor_state` holds `workflow:merged` + `needs-human` with no recorded release, regardless of the verifier; the comment is posted first, then the label, both verified), so item 6.5 tracks its dependents as `blocked-on-human-merge` instead of dispatching them. A merged issue whose trail could not be read (`TRAIL_RC -ge 2`, handled above) is escalated by the same block, so it is gated the same way. A bypass reported by the agent on a `workflow:merged` issue whose trail verifies is handled the same way (set `TRAIL_RC=1` before this block):
1916
+ ```bash
1917
+ # Only a merged issue is escalated here; an unmerged bypass is resumed by item 2 (see below).
1918
+ # Any non-zero rc on a merged issue escalates: rc 1 (missing phases) and rc >= 2 (unverifiable trail) alike,
1919
+ # because classify_predecessor_state() gates a merged predecessor on the escalation record plus the needs-human label.
1920
+ # forge#3156: also escalate when the label read itself failed: an unreadable issue cannot be proven unmerged, so
1921
+ # fail closed instead of posting nothing. forge#3168: key on TRAIL_LABELS_READ_OK, not an empty TRAIL_LABELS, so a
1922
+ # successfully read, label-less, non-merged issue is never escalated (read OK + empty labels => no escalation).
1923
+ if [ "$TRAIL_RC" -ne 0 ] && { [ "${TRAIL_LABELS_READ_OK:-0}" != "1" ] || echo ",${TRAIL_LABELS:-}," | grep -q ',workflow:merged,'; }; then
1924
+ if [ "${DRY_RUN:-false}" = "true" ]; then
1925
+ echo "[DRY-RUN] Would add needs-human and post FORGE:PHASE_TRAIL_FAILED on merged #{NUMBER}"
1926
+ else
1927
+ # hold_merged_trail (defined with classify_predecessor_state) posts the escalation comment FIRST (re-read before
1928
+ # posting, verified), then writes `needs-human` (read-back verified, retried). A merged + needs-human issue with no
1929
+ # comment classifies GATED regardless of the verifier. A failed half is reported on stderr and the operator must
1930
+ # hold dependents manually (residual: a human clearing the label after both comment attempts failed).
1931
+ hold_merged_trail {NUMBER} "${TRAIL}" || echo "PHASE TRAIL ESCALATION FAILED: #{NUMBER} — comment or needs-human not verified." >&2
1932
+ fi
1933
+ fi
1934
+ ```
1935
+ 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.
1936
+
1573
1937
  1. **Check if the agent completed the FULL pipeline** — not just one phase:
1574
1938
  ```bash
1575
1939
  # Check final workflow state — workflow:merged, workflow:invalid, needs-human, and
@@ -1591,10 +1955,12 @@ done
1591
1955
  resume=AGENT_ISSUE_MAP[{NUMBER}],
1592
1956
  description="Resume #{NUMBER} pipeline",
1593
1957
  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."
1958
+ prompt="{UNMODIFIED_STEP_4A_TEMPLATE_RENDERED_FOR_{NUMBER}}"
1595
1959
  )
1596
1960
  ```
1597
1961
 
1962
+ **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.
1963
+
1598
1964
  **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
1965
  ```bash
1600
1966
  # Safety gate: only fall back when the engine run committed NOTHING — no branch, no PR, no
@@ -1784,7 +2150,7 @@ done
1784
2150
  run_in_background=true,
1785
2151
  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
2152
 
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.
2153
+ **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
2154
 
1789
2155
  **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
2156
 
@@ -1836,33 +2202,43 @@ Do not ask the user questions — you are running autonomously in the background
1836
2202
  if [ "$ALREADY_TRACKED" -eq 0 ]; then
1837
2203
  gh issue comment "$DEP" -R {GH_REPO} --body "<!-- FORGE:BLOCKED_ON_HUMAN_MERGE -->
1838
2204
  **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\`."
2205
+ **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
2206
  gh issue edit "$DEP" -R {GH_REPO} --add-label "blocked-on-human-merge" 2>/dev/null || true
1841
2207
  fi
1842
2208
  ```
1843
2209
  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
2210
 
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:
2211
+ 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
2212
  ```bash
2213
+ # New per-cycle comment cache for trail_escalation_state (human edits between cycles are always re-read).
2214
+ [ -n "${TRAIL_CACHE_DIR:-}" ] && rm -rf "$TRAIL_CACHE_DIR"
2215
+ TRAIL_CACHE_DIR=$(mktemp -d)
1847
2216
  WOKEN=$(gh issue list -R {GH_REPO} --state open --label "blocked-on-human-merge" --json number \
1848
2217
  --jq '.[].number' 2>/dev/null || echo "")
1849
2218
  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
2219
+ GATING_PREDS=$(gh api repos/{GH_REPO}/issues/${DEP}/comments \
2220
+ --jq '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE")) | (.body | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p)] | unique | .[]' 2>/dev/null || echo "")
2221
+ [ -n "$GATING_PREDS" ] || continue
2222
+ STILL_GATED=false
2223
+ for GPRED in $GATING_PREDS; do
2224
+ [ "$(classify_predecessor_state "$GPRED")" = "DONE" ] || STILL_GATED=true
2225
+ done
2226
+ [ "$STILL_GATED" = "false" ] || continue
1853
2227
  # Idempotency: only dispatch if DEP hasn't already been dispatched by another path.
1854
2228
  DEP_ALREADY_DISPATCHED=$(gh issue view "$DEP" -R {GH_REPO} --json labels \
1855
2229
  --jq '[.labels[].name | select(startswith("workflow:"))] | length' 2>/dev/null || echo "0")
1856
2230
  if [ "$DEP_ALREADY_DISPATCHED" -eq 0 ]; then
2231
+ # Record a human release of a trail-held merged predecessor so the escalation stops gating (decay).
2232
+ for GPRED in $GATING_PREDS; do release_merged_trail "$GPRED"; done
1857
2233
  gh issue edit "$DEP" -R {GH_REPO} --remove-label "blocked-on-human-merge" 2>/dev/null || true
1858
2234
  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)."
2235
+ 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.)"
2236
+ echo "#{DEP} unblocked (gating predecessors resolved) — dispatching immediately (Steps 4A.pre.0 → 4A.pre → 4A)."
1861
2237
  # Add DEP to the same-response dispatch batch
1862
2238
  fi
1863
2239
  done
1864
2240
  ```
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.
2241
+ `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
2242
 
1867
2243
  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
2244
 
@@ -1991,9 +2367,29 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1991
2367
  RERESOLVE_ENABLED=$(yq '.orchestration.reresolve.enabled // true' forge.yaml 2>/dev/null || echo "true")
1992
2368
  RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' forge.yaml 2>/dev/null || echo "unbounded")
1993
2369
 
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"
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
+ # Run ForgeDock's OWN orchestrate-canary.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}.
2385
+ # Unresolved/missing => no result, so reResolve is treated as false for this cycle (fail closed, loudly).
2386
+ if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/orchestrate-canary.mjs" ]; then
2387
+ node "$FORGE_ROOT/bin/engine/orchestrate-canary.mjs" \
2388
+ "$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" \
2389
+ "$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
2390
+ else
2391
+ echo "WARNING: ForgeDock install root unresolved or orchestrate-canary.mjs missing (set FORGEDOCK_HOME) — skipping re-resolution this cycle" >&2
2392
+ fi
1997
2393
  ```
1998
2394
 
1999
2395
  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 +2399,33 @@ If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR`
2003
2399
  **Fold new matches through the existing admission path — never a bypass**:
2004
2400
 
2005
2401
  ```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"
2402
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2403
+ FORGE_ROOT=""
2404
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
2405
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
2406
+ _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")")"
2407
+ # 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)
2408
+ _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-)"
2409
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
2410
+ # '${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.
2411
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
2412
+ while IFS= read -r _c; do
2413
+ 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
2414
+ done <<< "$_k"
2415
+ fi
2416
+ # Import ForgeDock's OWN resolve.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}.
2417
+ # Unresolved/missing => no output, so no new matches are folded this cycle (fail closed, loudly).
2418
+ if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/resolve.mjs" ]; then
2419
+ node -e '
2420
+ import(require("node:url").pathToFileURL(process.argv[1]).href).then(({ foldNewMatches }) => {
2421
+ const reResolved = JSON.parse(process.argv[2]);
2422
+ const processed = JSON.parse(process.argv[3]);
2423
+ console.log(JSON.stringify(foldNewMatches(reResolved, processed)));
2424
+ }, () => process.exit(0));
2425
+ ' "$FORGE_ROOT/bin/engine/resolve.mjs" "$RERESOLVED_NUMBERS_JSON" "$ALL_BATCH_ISSUE_NUMBERS_JSON"
2426
+ else
2427
+ echo "WARNING: ForgeDock install root unresolved or resolve.mjs missing (set FORGEDOCK_HOME) — re-resolved matches not folded this cycle" >&2
2428
+ fi
2013
2429
  ```
2014
2430
 
2015
2431
  `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 +2602,38 @@ gh issue list -R {GH_REPO} --state open --search "label:review-finding created:>
2186
2602
  --json number,title,body,createdAt
2187
2603
  ```
2188
2604
 
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
2605
  ```bash
2198
2606
  # Run once for each completed issue before collecting its findings. Only merged
2199
2607
  # units advance the denominator; invalid/skipped units do not imply delivered work.
2608
+ # The ratio observation for this unit is recorded AFTER its findings are counted, in the
2609
+ # "Amplification settle" block that follows the Step 4C rule loop (forge#3060) — recording it
2610
+ # here would lag by one unit and could never reach 1.0 at exactly one finding per merged unit.
2611
+ UNIT_MERGED_THIS_CYCLE=false
2200
2612
  if gh issue view "$NUM" -R {GH_REPO} --json labels \
2201
2613
  --jq '[.labels[].name | select(. == "workflow:merged")] | length' | grep -qx '1'; then
2202
2614
  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
2615
+ UNIT_MERGED_THIS_CYCLE=true
2211
2616
  fi
2212
2617
 
2213
2618
  ```
2214
2619
 
2620
+ **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.
2621
+
2622
+ **If review-finding issues were spawned:**
2623
+
2624
+ **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.
2625
+
2626
+ 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.
2627
+
2628
+ 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`.
2629
+
2630
+ **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.
2631
+
2632
+ **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.
2633
+
2634
+ **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.
2635
+
2636
+
2215
2637
  **Cascade control (MANDATORY — run before folding findings into the DAG):**
2216
2638
 
2217
2639
  For each spawned finding, determine whether it should be **executed** or **deferred**:
@@ -2224,7 +2646,9 @@ For each spawned finding, determine whether it should be **executed** or **defer
2224
2646
  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
2647
  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
2648
 
2227
- **Defer** (do NOT add to the DAG) if rules 0, 1, 3, 4, or 5 match.
2649
+ 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).
2650
+
2651
+ **Defer** (do NOT add to the DAG) if rules 0, 1, 3, 4, 5, or 6 match.
2228
2652
 
2229
2653
  **Execute** (add to the DAG) if:
2230
2654
  - Rule 2 matches (P1 or P2) — AND rule 0 did not already match (rule 0 is checked first and overrides rule 2)
@@ -2516,6 +2940,65 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
2516
2940
  AMPLIFICATION_DEFERRED+=("$FINDING_NUM")
2517
2941
  fi
2518
2942
 
2943
+ # Rule 6 (forge#3060): on-by-default breaker over ALL cascade findings. Applies ONLY to
2944
+ # P3-and-below (P0/P1/P2 and untriaged/unparseable priorities are never gated by it) and NEVER
2945
+ # to security/billing-class findings (classifyBatchSafety != routine): those can neither batch
2946
+ # with routine work nor be individually dispatched by the sweep, so pausing them would starve
2947
+ # them; they keep their pre-existing individual triage path unchanged.
2948
+ # Evaluated live per finding. When a unit merged this cycle (UNIT_MERGED_THIS_CYCLE), its ratio
2949
+ # observation is not yet in the history (recorded by "Amplification settle"), so the live
2950
+ # unrounded ratio plus the last CONVERGENCE_WINDOW-1 recorded observations form the window.
2951
+ # Applied even when another rule already deferred the finding, and APPENDED to the reason, so
2952
+ # Step 4F's `*"amplification breaker"*` hold (checked FIRST) still catches it.
2953
+ if [ "$AMPLIFICATION_BREAKER" = "on" ] && echo "$PRIORITY" | grep -qE '^P[3-9]$'; then
2954
+ # Fail CLOSED: if the classifier cannot run (missing module, bad cwd), treat the finding as
2955
+ # non-routine so a security/billing finding is never paused. Import via {REPO_PATH} and pass
2956
+ # the text through the environment (argv text beginning with "-" is parsed as a node option).
2957
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2958
+ FORGE_ROOT=""
2959
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
2960
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
2961
+ _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")")"
2962
+ # 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)
2963
+ _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-)"
2964
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
2965
+ # '${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.
2966
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
2967
+ while IFS= read -r _c; do
2968
+ 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
2969
+ done <<< "$_k"
2970
+ fi
2971
+ # Import ForgeDock's OWN admission.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}
2972
+ # (inert on non-ForgeDock repos, and would execute repo-controlled JS). Unresolved/missing => fail closed, loudly.
2973
+ BREAKER_SAFETY="unclassified"
2974
+ if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/admission.mjs" ]; then
2975
+ BREAKER_SAFETY=$(FINDING_TEXT="$(echo "$FINDING_DATA" | jq -r '(.title // "") + "\n" + (.body // "")')" \
2976
+ 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")
2977
+ else
2978
+ echo "WARNING: ForgeDock install root unresolved or admission.mjs missing (set FORGEDOCK_HOME) — breaker classifying #${FINDING_NUM} as unclassified (fail closed: not deferred)" >&2
2979
+ fi
2980
+ BREAKER_LIVE_TRIPPED="$AMPLIFICATION_BREAKER_TRIPPED"
2981
+ if [ "$BREAKER_LIVE_TRIPPED" = "false" ] && [ "$UNIT_MERGED_THIS_CYCLE" = "true" ] && [ "$MERGED_UNITS" -gt 0 ] && \
2982
+ awk "BEGIN { exit !($FINDINGS_SPAWNED / $MERGED_UNITS >= 1) }"; then
2983
+ if [ "$CONVERGENCE_WINDOW" -le 1 ] || \
2984
+ { [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge $((CONVERGENCE_WINDOW - 1)) ] && \
2985
+ printf '%s\n' "${AMPLIFICATION_RATIO_HISTORY[@]: -$((CONVERGENCE_WINDOW - 1))}" | awk '$1 < 1 { exit 1 }'; }; then
2986
+ BREAKER_LIVE_TRIPPED=true
2987
+ AMPLIFICATION_BREAKER_TRIPPED=true
2988
+ fi
2989
+ fi
2990
+ if [ "$BREAKER_LIVE_TRIPPED" = "true" ] && [ "$BREAKER_SAFETY" = "routine" ]; then
2991
+ DEFER_REASON="${DEFER_REASON:+${DEFER_REASON}; }${BREAKER_DEFER_TAG} tripped (ratio >= 1.0 for ${CONVERGENCE_WINDOW} merged units) — held for bounded P3 batches"
2992
+ DEFER=true
2993
+ case " ${AMPLIFICATION_BREAKER_DEFERRED[*]} " in *" $FINDING_NUM "*) ;; *) AMPLIFICATION_BREAKER_DEFERRED+=("$FINDING_NUM") ;; esac
2994
+ # Route to the bounded P3 batch planner (never individual dispatch); dedupe so a finding
2995
+ # already added by the generation rule is not listed twice.
2996
+ if [ "$PRIORITY" = "P3" ]; then
2997
+ case " ${BATCHABLE_DEFERRED_P3[*]} " in *" $FINDING_NUM "*) ;; *) BATCHABLE_DEFERRED_P3+=("$FINDING_NUM") ;; esac
2998
+ fi
2999
+ fi
3000
+ fi
3001
+
2519
3002
  if [ "$DEFER" = "true" ]; then
2520
3003
  DEFERRED_FINDINGS+=($FINDING_NUM)
2521
3004
  DEFERRED_REASONS[$FINDING_NUM]="$DEFER_REASON"
@@ -2526,6 +3009,30 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
2526
3009
  done
2527
3010
  ```
2528
3011
 
3012
+ **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.
3013
+
3014
+ ```bash
3015
+ if [ "$UNIT_MERGED_THIS_CYCLE" = "true" ]; then
3016
+ AMPLIFICATION_RATIO=$(awk "BEGIN { printf \"%.2f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")
3017
+ AMPLIFICATION_RATIO_HISTORY+=("$(awk "BEGIN { printf \"%.6f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")")
3018
+ if [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge "$CONVERGENCE_WINDOW" ]; then
3019
+ RECENT_RATIOS=("${AMPLIFICATION_RATIO_HISTORY[@]: -$CONVERGENCE_WINDOW}")
3020
+ if printf '%s\n' "${RECENT_RATIOS[@]}" | awk '$1 < 1 { exit 1 }'; then
3021
+ 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."
3022
+ if [ "$AMPLIFICATION_BREAKER" = "on" ] && [ "$AMPLIFICATION_BREAKER_TRIPPED" = "false" ]; then
3023
+ AMPLIFICATION_BREAKER_TRIPPED=true
3024
+ # One operator report per trip: ratio and queue size, no per-finding spam.
3025
+ 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"
3026
+ fi
3027
+ else
3028
+ # Newest observation fell below 1.0 — the cascade is converging again; release the breaker.
3029
+ AMPLIFICATION_BREAKER_TRIPPED=false
3030
+ fi
3031
+ fi
3032
+ fi
3033
+ UNIT_MERGED_THIS_CYCLE=false
3034
+ ```
3035
+
2529
3036
  **Concern-level batching for queued P3 findings (MANDATORY check before dispatch):** <!-- Added: forge#1818 -->
2530
3037
 
2531
3038
  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 +3114,27 @@ for FINDING_NUM in "${BATCHING_CANDIDATES[@]}"; do
2607
3114
  FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
2608
3115
  [ -z "$FINDING_FILE" ] && continue
2609
3116
 
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(" "))')")
3117
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
3118
+ FORGE_ROOT=""
3119
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
3120
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
3121
+ _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")")"
3122
+ # 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)
3123
+ _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-)"
3124
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
3125
+ # '${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.
3126
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
3127
+ while IFS= read -r _c; do
3128
+ 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
3129
+ done <<< "$_k"
3130
+ fi
3131
+ # Import from ForgeDock's own root, never the consumer cwd; fail closed (non-routine) when unresolved.
3132
+ SAFETY_CLASS="unclassified"
3133
+ if [ -n "$FORGE_ROOT" ] && [ -r "$FORGE_ROOT/bin/engine/admission.mjs" ]; then
3134
+ 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")
3135
+ else
3136
+ 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
3137
+ fi
2611
3138
  # Same file is not enough for security work: the class key prevents a
2612
3139
  # credential finding from sharing a batch with injection/auth hardening.
2613
3140
  SURFACE_KEY="${SAFETY_CLASS}:${FINDING_FILE}"
@@ -2619,7 +3146,7 @@ done
2619
3146
  # the batch issue in QUEUED_FINDINGS so the dispatch step below never double-dispatches
2620
3147
  # them. This is what makes SURFACE_BATCHED_FINDINGS a live control, not dead wiring. <!-- forge#1832, forge#1834 -->
2621
3148
  for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
2622
- MEMBERS=(${SURFACE_FILE_MEMBERS[$FILE]})
3149
+ MEMBERS=($(printf '%s\n' ${SURFACE_FILE_MEMBERS[$FILE]} | sort -un)) # dedupe: a finding listed twice must not form a self-batch
2623
3150
  [ "${#MEMBERS[@]}" -ge 2 ] || continue
2624
3151
 
2625
3152
  SAFETY_CLASS=${FILE%%:*}
@@ -2831,7 +3358,7 @@ A finding that fails classify-lane is removed from `QUEUED_FINDINGS` (mirrors St
2831
3358
 
2832
3359
  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
3360
 
2834
- **If no review-finding issues were spawned:** Continue monitoring for the next agent completion.
3361
+ **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
3362
 
2836
3363
  ### Step 4C.5: Milestone lane-consistency check (periodic) <!-- Added: forge#901 -->
2837
3364
 
@@ -2992,12 +3519,19 @@ PERMANENT_DEFERRED=()
2992
3519
  SWEEP_CANDIDATES=()
2993
3520
  IDLE_DEFERRED=() # <!-- Added: forge#1814 -->
2994
3521
  TOKEN_GATED=() # <!-- Added: forge#1858 -->
3522
+ : "${BREAKER_DEFER_TAG:=amplification breaker}" # default if this block runs in a fresh shell (an empty tag would match every defer reason)
3523
+ AMPLIFICATION_BREAKER_HELD=() # amplification-breaker holds: bounded P3 batches only, never individually dispatched (forge#3060)
2995
3524
 
2996
3525
  for FINDING_NUM in "${DEFERRED_FINDINGS[@]}"; do
2997
3526
  DEFER_REASON="${DEFERRED_REASONS[$FINDING_NUM]}"
2998
3527
 
3528
+ # Amplification-breaker holds (forge#3060) are checked FIRST: the breaker reason is appended to
3529
+ # whatever rule deferred the finding earlier (idle policy, generation, same-file, token budget),
3530
+ # and such a finding must never fall into a bucket whose re-evaluation dispatches it individually.
3531
+ if echo "$DEFER_REASON" | grep -qiF "$BREAKER_DEFER_TAG"; then
3532
+ AMPLIFICATION_BREAKER_HELD+=($FINDING_NUM)
2999
3533
  # Generation >= 2 deferrals are PERMANENT — unbounded cascade prevention
3000
- if echo "$DEFER_REASON" | grep -qi "generation"; then
3534
+ elif echo "$DEFER_REASON" | grep -qi "generation"; then
3001
3535
  PERMANENT_DEFERRED+=($FINDING_NUM)
3002
3536
  # "Batch fully human-gated" deferrals (forge#1814) are their OWN bucket — they must NOT be
3003
3537
  # re-evaluated by the file-overlap logic in Step 4F.2 below, because the reason they were
@@ -3020,7 +3554,7 @@ for FINDING_NUM in "${DEFERRED_FINDINGS[@]}"; do
3020
3554
  fi
3021
3555
  done
3022
3556
 
3023
- echo "Completion sweep: ${#SWEEP_CANDIDATES[@]} re-evaluable, ${#PERMANENT_DEFERRED[@]} permanent, ${#IDLE_DEFERRED[@]} idle-gated, ${#TOKEN_GATED[@]} token-gated"
3557
+ 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
3558
  ```
3025
3559
 
3026
3560
  **Step 4F.2: Re-evaluate sweep candidates**
@@ -3030,6 +3564,7 @@ Re-run the Step 4C heuristics against the now-empty DAG. Since all original batc
3030
3564
  ```bash
3031
3565
  SWEEP_EXECUTE=()
3032
3566
  SWEEP_STILL_DEFERRED=()
3567
+ AMPLIFICATION_BREAKER_SWEEP_HELD=()
3033
3568
 
3034
3569
  for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
3035
3570
  FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,state \
@@ -3051,6 +3586,17 @@ for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
3051
3586
  "" | ltrimstr("priority:")')
3052
3587
  TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
3053
3588
 
3589
+ # Amplification-breaker deferrals (forge#3060) are NEVER individually dispatched by the sweep:
3590
+ # draining the DAG does not make a P3 cascade bounded. Leave them open for the bounded P3
3591
+ # batch sweep (planP3BatchGroups) and report them to the operator instead.
3592
+ case "${DEFERRED_REASONS[$FINDING_NUM]:-}" in
3593
+ *"$BREAKER_DEFER_TAG"*)
3594
+ SWEEP_STILL_DEFERRED+=($FINDING_NUM)
3595
+ AMPLIFICATION_BREAKER_SWEEP_HELD+=($FINDING_NUM)
3596
+ echo "Sweep: #${FINDING_NUM} held for P3 batching (amplification breaker) — not dispatched individually"
3597
+ continue ;;
3598
+ esac
3599
+
3054
3600
  # Re-apply heuristics against the drained DAG (no active batch files)
3055
3601
  # Comment/typo heuristic still applies — these are cosmetic regardless of DAG state
3056
3602
  if echo "$TITLE" | grep -qi "comment\|typo"; then
@@ -3232,9 +3778,10 @@ The context-gathering phase can fetch this index to discover all investigation G
3232
3778
  **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
3779
 
3234
3780
  **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)
3781
+ - **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.
3782
+ - For default repo issues: \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='${FINDING_NUM} --under-orchestration')\`
3783
+ - 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)
3784
+ - **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
3785
  - NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
3239
3786
  - NEVER target \`main\` for PRs targeting the default repo. Use \`{STAGING_BRANCH}\` for fast-lane issues, or \`milestone/{slug}\` for milestone issues.
3240
3787
  - Satellite repos (MCP, n8n) have no staging branch — fast-lane PRs go to \`main\` for those.
@@ -3244,20 +3791,20 @@ The context-gathering phase can fetch this index to discover all investigation G
3244
3791
  - **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
3792
 
3246
3793
  **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:
3794
+ After EVERY \`Skill(skill='{FORGE_SKILL_PREFIX}work-on', ...)\` call returns, immediately check the issue's current workflow label:
3248
3795
  \`\`\`bash
3249
3796
  gh issue view ${FINDING_NUM} -R {GH_REPO} --json labels --jq '[.labels[].name | select(startswith(\"workflow:\"))]'
3250
3797
  \`\`\`
3251
3798
  **Terminal labels** (only these allow you to stop): \`workflow:merged\`, \`workflow:invalid\`
3252
3799
  **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.
3800
+ 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
3801
 
3255
3802
  **CRITICAL — SOURCE BRANCH DETECTION**:
3256
3803
  - 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)
3804
+ - 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\`
3805
+ - Investigation MUST use \`git show origin/<source-branch>:<filepath>\` to verify the code exists
3806
+ - Worktree MUST branch from \`origin/<source-branch>\`, NOT \`origin/main\`
3807
+ - PR target is \`<source-branch>\` (the fix goes back to where the code lives)
3261
3808
 
3262
3809
  **LANE**: ${SWEEP_LANE[$FINDING_NUM]} (PR target: ${SWEEP_PR_BASE[$FINDING_NUM]})
3263
3810
  **Issue title**: ${FINDING_TITLE}