forgedock 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/runlog.mjs +11 -1
  9. package/bin/engine/state.mjs +20 -28
  10. package/bin/engine-cli.mjs +86 -5
  11. package/bin/engine.mjs +543 -36
  12. package/bin/forgedock.mjs +397 -59
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +191 -13
  15. package/bin/journey.mjs +586 -44
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +478 -10
  18. package/bin/watch-utils.mjs +258 -0
  19. package/commands/cleanup.md +34 -5
  20. package/commands/issue.md +14 -4
  21. package/commands/orchestrate/config.md +43 -1
  22. package/commands/orchestrate/phase-1-resolve.md +348 -11
  23. package/commands/orchestrate/phase-3-dependency.md +272 -49
  24. package/commands/orchestrate/phase-4-execution.md +622 -60
  25. package/commands/orchestrate/phase-5-cleanup.md +2 -0
  26. package/commands/quality-gate.md +6 -6
  27. package/commands/review-pr-staging.md +51 -8
  28. package/commands/review-pr.md +120 -39
  29. package/commands/upgrade-deps.md +7 -2
  30. package/commands/work-on/decompose.md +2 -0
  31. package/commands/work-on/investigate.md +22 -4
  32. package/commands/work-on/remediate.md +55 -4
  33. package/commands/work-on/review.md +40 -1
  34. package/commands/work-on.md +19 -4
  35. package/docs/CONFIG.md +120 -0
  36. package/docs/spec/eval-run-result.md +8 -4
  37. package/docs/spec/label-state-machine.md +9 -0
  38. package/package.json +2 -2
  39. package/packages/protocol/src/emit.js +5 -22
  40. package/packages/protocol/src/html-comment-escape.js +68 -0
  41. package/packages/protocol/src/index.js +1 -0
  42. package/packages/protocol/src/parse.js +5 -6
  43. package/packages/protocol/src/phases.js +105 -0
  44. package/packages/protocol/src/types.js +17 -0
  45. package/packages/protocol/src/validate.js +24 -1
  46. package/scripts/check-admission-parity.mjs +167 -0
  47. package/scripts/check-command-side-effects.sh +181 -66
  48. package/scripts/check-phase-registry-drift.mjs +118 -0
  49. package/scripts/check-spec-markers.sh +3 -0
  50. package/scripts/classify-lane.sh +29 -3
  51. package/scripts/derive-finding-milestone.sh +184 -0
  52. package/scripts/doctor-pipeline-state.sh +1 -0
  53. package/scripts/extract-affected-files.sh +232 -0
  54. package/scripts/extract-affected-files.test.sh +331 -0
  55. package/scripts/forge-run.sh +39 -4
  56. package/scripts/issue-dedup.sh +69 -8
  57. package/scripts/issue-dedup.test.sh +142 -0
  58. package/scripts/severity-to-priority.sh +81 -0
@@ -10,6 +10,94 @@ install: core
10
10
 
11
11
  **Entry requires `phase-3-dependency.md`'s Step 3D.6 completion gate to have passed.** <!-- Added: forge#1913 --> Every step below (budget init, dispatch, dependency resolution) reads `ISSUES[]`, `PREDECESSORS[]`, `ISSUE_DOMAIN[]`, `ISSUE_SCORE[]`, `ISSUE_COST_ESTIMATE[]`, and `ISSUE_HAS_PRIOR[]` as authoritative Phase 3 output — none of these are re-derived here. If Phase 4 is being entered without having actually run Phase 3's Steps 3A–3E.5 in this session (e.g. a fresh session resuming mid-batch), reconstruct from GitHub via the "Orchestrator state reconstruction on wake / after compaction" procedure in `phase-3-dependency.md` rather than assuming these variables are already populated.
12
12
 
13
+ ### Step 4A-pre.-1: Lease gate (MANDATORY, before any dispatch) <!-- Added: forge#2627 -->
14
+
15
+ **WHY THIS EXISTS**: Nothing today checks whether another live `/orchestrate` instance already holds this batch before dispatching (see issue #2627). Two concurrent loops (a stale survivor plus a restart, or two independent invocations) then both re-derive a ready set from GitHub and both dispatch — duplicate/backlog dispatch, and a restarted instance's own bookkeeping can diverge from what the first loop already did. This step closes that gap at the single entry point every dispatch group (initial ready set + every subsequent newly-unblocked batch) passes through, by extending the Step 3D.1 coordination issue into a single-instance lease (see `phase-3-dependency.md` Step 3D.2 for `check_orchestrator_lease()` and the `FORGE:LEASE`/`FORGE:LEASE_RELEASED` comment format).
16
+
17
+ **Run this once, before the first dispatch of any group in this session** — not per-chunk, per-issue, or per-newly-ready-batch.
18
+
19
+ ```bash
20
+ # --- Lease gate (uses check_orchestrator_lease() from phase-3-dependency.md Step 3D.2;
21
+ # re-declare it here if this context hasn't sourced that file) ---
22
+ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "${BATCH_ID:-}" ]; then
23
+ LEASE_STATE=$(check_orchestrator_lease "$COORD_ISSUE_NUMBER" "$BATCH_ID")
24
+ case "$LEASE_STATE" in
25
+ held:*)
26
+ HELD_BY="${LEASE_STATE#held:}"
27
+ echo "REFUSING TO DISPATCH: coordination issue #${COORD_ISSUE_NUMBER} shows an unexpired lease held by batch ${HELD_BY}, not this session's batch ${BATCH_ID}."
28
+ echo "Another live /orchestrate instance appears to be dispatching this batch already. Stop this invocation, or wait for the other lease to go stale."
29
+ exit 1
30
+ ;;
31
+ free|self)
32
+ HOSTNAME_ID=$(hostname 2>/dev/null || echo "unknown-host")
33
+ # GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
34
+ gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
35
+ **Holder Batch ID**: ${BATCH_ID}
36
+ **Holder**: ${HOSTNAME_ID} (pid ${$})
37
+ **Acquired/refreshed**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
38
+ **TTL**: ${LEASE_TTL_SECONDS:-900}s (refreshed once per dispatch chunk in Step 4A)" 2>/dev/null || \
39
+ echo "WARNING: failed to post FORGE:LEASE — continuing without a lease record (best-effort primitive)"
40
+ ;;
41
+ *)
42
+ # Defensive default (MANDATORY — do not remove): see the matching comment at
43
+ # phase-3-dependency.md Step 3D.2's acquisition case block. An unexpected LEASE_STATE
44
+ # here must warn loudly, not silently fall through and let dispatch proceed as if the
45
+ # lease gate had passed.
46
+ echo "WARNING: check_orchestrator_lease() returned unexpected value '${LEASE_STATE}' — lease gate could not be evaluated. Proceeding without a confirmed lease; investigate rather than ignore." >&2
47
+ ;;
48
+ esac
49
+ else
50
+ echo "INFO: no coordination issue / BATCH_ID available — lease enforcement disabled for this batch."
51
+ fi
52
+ # --- End lease gate ---
53
+ ```
54
+
55
+ **Lease release**: `LEASE_RELEASED_THIS_SESSION` (declared just below, alongside `release_orchestrator_lease()`) guards against double-posting `FORGE:LEASE_RELEASED`. The release call is invoked from both the **Step 4A-pre.-0.5 "Stopping the orchestrator"** procedure (interrupted path) and the **Termination condition** at the end of Step 4B (normal clean/paused drain) — both exit paths route through the same idempotent function so the lease is never left dangling either way.
56
+
57
+ ```bash
58
+ LEASE_RELEASED_THIS_SESSION=false
59
+
60
+ release_orchestrator_lease() {
61
+ # Idempotent — safe to call from both the interrupted-stop procedure and normal
62
+ # end-of-batch completion without double-posting FORGE:LEASE_RELEASED.
63
+ if [ "$LEASE_RELEASED_THIS_SESSION" = "true" ]; then
64
+ return
65
+ fi
66
+ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ]; then
67
+ # 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
68
+ gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE_RELEASED -->
69
+ **Holder Batch ID**: ${BATCH_ID:-unknown}
70
+ **Released**: $(date -u +%Y-%m-%dT%H:%M:%SZ)" 2>/dev/null || true
71
+ fi
72
+ LEASE_RELEASED_THIS_SESSION=true
73
+ }
74
+ ```
75
+
76
+ ### Step 4A-pre.-0.5: Backgrounded engine-child tracking + interruption handling ("Stopping the orchestrator") (MANDATORY) <!-- Added: forge#2627 -->
77
+
78
+ **WHY THIS EXISTS**: Step 4A's engine-first dispatch (fixed by forge#2466) already dispatches each `forgedock run-issue` invocation as its own `Bash(run_in_background=true, ...)` call and records the returned task id in `ENGINE_DISPATCH_MAP[{NUM}]` — this is a harness-managed background task, not a raw shell `&` job. **Because it is harness-managed, it cannot be reaped by a shell-level `trap`/`kill $PID`**: a bash `trap` only runs inside that one Bash tool invocation's own process and has no way to call back into the harness's own task-management surface (`TaskStop`). Reaping these tasks on interrupt is therefore something the orchestrating agent itself must do — as an explicit step in its own routing loop — not something a background shell script can do on its own behalf.
79
+
80
+ **Contract — the orchestrating agent (not a shell trap) is responsible for reaping**: Whenever the interactive `/orchestrate` session is being stopped (the operator sends an interrupt, or the harness delivers a stop signal to the top-level orchestrator agent) **before** all dispatched issues have reached a terminal `workflow:*` label, the orchestrator MUST, as part of handling that stop — not silently exit —:
81
+
82
+ 1. Enumerate every task id still tracked in `ENGINE_DISPATCH_MAP` whose issue has not yet reached a terminal `workflow:*` label (per Step 4B's `classify_predecessor_state()`).
83
+ 2. Call `TaskStop(task_id)` (the harness tool) for each one, in the same turn, before ending the session.
84
+ 3. Call `release_orchestrator_lease()` (Step 4A-pre.-1) so a future resume or a different operator is not blocked by a stale lease from this now-stopped session.
85
+ 4. Report which issues had in-flight dispatch stopped mid-pipeline (their `workflow:*` label will reflect whatever phase they reached — a future `/work-on {NUMBER}` or orchestrator resume picks them back up from GitHub state, per the Universal Phase Dispatcher in `commands/work-on.md`).
86
+
87
+ **This is a documentation/behavioral contract, not a bash block**: unlike the lease gate above, there is no shell construct that reliably intercepts "the orchestrator's own session is being stopped" — that is a harness-level event the orchestrating agent must handle in its own turn when it observes an interrupt, using its own tools (`TaskStop`), exactly the way `commands/orchestrate/phase-5-cleanup.md` already documents cleanup as an agent-driven procedure rather than a background script.
88
+
89
+ **Known limitation — Windows Git Bash (documented, not silently overclaimed)**: Even a harness-issued `TaskStop` against the tracked background task is not guaranteed to terminate the underlying `forgedock run-issue` Win32 process tree on Windows Git Bash — MSYS-launched child processes are not always attached to the parent's job object in a way that a stop signal reliably cascades through. `TaskStop` is the correct in-spec, harness-native mitigation and closes the common case (an operator deliberately stopping a live, still-running orchestrator session). It does **not** guarantee full termination in every case — per the issue's own report, an operator may still need to fall back to a manual, command-line-matched kill as a belt-and-suspenders measure:
90
+
91
+ ```powershell
92
+ # Windows fallback — only if a forgedock run-issue process is confirmed still running
93
+ # after TaskStop (e.g. `docker ps` / dispatch-state still show activity post-stop):
94
+ Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'forgedock run-issue' } | ForEach-Object {
95
+ Stop-Process -Id $_.ProcessId -Force
96
+ }
97
+ ```
98
+
99
+ Do not claim `TaskStop` eliminates every zombie-process scenario on Windows; it eliminates the scenario this issue's fix is scoped to (an operator or harness cleanly stopping a live orchestrator session that is still tracking its own dispatched tasks) — the PowerShell fallback above remains documented for the narrower case where the underlying process tree survives regardless.
100
+
13
101
  ### Step 4A-pre.0: Budget initialization (MANDATORY when --budget is set) <!-- Added: forge#1743 -->
14
102
 
15
103
  Initialize budget tracking state before the first dispatch. Read `--budget N` from the orchestrator's argument list (passed from the top-level `/orchestrate` invocation). When `--budget` is not set, `BUDGET_LIMIT` is `Infinity` (uncapped — current default behavior preserved).
@@ -276,6 +364,20 @@ Before building agent prompts, run `classify-lane.sh` for every issue in the cur
276
364
  declare -A ISSUE_LANE
277
365
  declare -A ISSUE_PR_BASE
278
366
 
367
+ # Batch-start T0 (forge#2628) — reuse the value phase-1-resolve.md captured at the very
368
+ # start of Phase 1 and persisted per "Predicate Persistence." Step 4C's run-spawned cascade
369
+ # time filter (Method 2 below) anchors to this, NOT to a rolling "N hours ago" window — a
370
+ # rolling window can still admit pre-existing backlog issues that happen to have been
371
+ # created recently for unrelated reasons. Degraded fallback only: if BATCH_T0 is not in
372
+ # context (e.g. a session resumed mid-run after compaction and Phase 1's capture was lost),
373
+ # capture one here — this is later than true batch start and will admit a narrower window
374
+ # than the real run, never wider, so it fails safe.
375
+ if [ -z "${BATCH_T0:-}" ]; then
376
+ echo "WARNING: BATCH_T0 not found in context — Phase 1 should have captured it. Falling back to capturing it now (narrower window than the true batch start, but never wider)."
377
+ BATCH_T0=$(date -u +%Y-%m-%dT%H:%M:%SZ)
378
+ fi
379
+ echo "Step 4A.pre: using BATCH_T0=${BATCH_T0} for Step 4C's run-spawned cascade time filter"
380
+
279
381
  # Batch-level accumulators for review-finding cascade control (Step 4C) and
280
382
  # Completion Sweep (Step 4F). Declared here so they persist across ALL agent
281
383
  # completions — Step 4C runs per-agent and must NOT re-initialize these.
@@ -283,6 +385,12 @@ DEFERRED_FINDINGS=()
283
385
  QUEUED_FINDINGS=()
284
386
  declare -A DEFERRED_REASONS
285
387
  declare -A AGENT_ISSUE_MAP
388
+ # Engine-first dispatch equivalent of AGENT_ISSUE_MAP (fixed forge#2466): keyed by issue
389
+ # number, holds the task id returned by each backgrounded `Bash(run_in_background=true,
390
+ # command="forgedock run-issue ...")` call from Step 4A's engine-first path. Step 4B reads
391
+ # this map to identify which issue a background-Bash completion notification belongs to,
392
+ # the same role AGENT_ISSUE_MAP plays for Agent-tool `agent_completed` notifications.
393
+ declare -A ENGINE_DISPATCH_MAP
286
394
 
287
395
  # Same-file current-state brief forwarding (forge#1860). Populated by the core streaming
288
396
  # dispatch loop below (Step 4B) whenever a Layer 1/2/3 structural predecessor edge (see
@@ -304,8 +412,76 @@ BATCH_FULLY_GATED=false
304
412
  # Step 4C's cascade dispatch of review-finding issues. Declared once here (batch scope,
305
413
  # not per-agent) so BATCH_TOKEN_SPEND accumulates correctly across every Step 4C run
306
414
  # this session performs.
307
- TOKEN_BUDGET=$(yq '.pipeline.token_budget_per_batch // 900000' forge.yaml 2>/dev/null || echo 900000)
415
+
416
+ # --- Cascade admission policy resolution (forge#2234) ---
417
+ # `orchestration.cascade` gives the admission rules below (rules 0/3/4/5) an
418
+ # independently-settable config surface, on top of a named preset. Absent
419
+ # section => `balanced`, which reproduces today's hardcoded behavior exactly
420
+ # (no-op for existing configs). See `bin/engine/admission.mjs` for the typed,
421
+ # unit-tested reference implementation of this same preset table.
422
+ CASCADE_POLICY_NAME=$(yq '.orchestration.cascade.policy // "balanced"' forge.yaml 2>/dev/null || echo "balanced")
423
+ [ "$CASCADE_POLICY_NAME" = "null" ] && CASCADE_POLICY_NAME="balanced"
424
+
425
+ case "$CASCADE_POLICY_NAME" in
426
+ all)
427
+ PRESET_MAX_GEN="unlimited"; PRESET_TOKEN_BUDGET="unlimited"; PRESET_DEFER_GATED="false"; PRESET_KEYWORD="false"; PRESET_P3_SAME_FILE="false" ;;
428
+ conservative)
429
+ PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=450000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
430
+ balanced)
431
+ PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
432
+ *)
433
+ echo "WARNING: forge.yaml → orchestration.cascade.policy \"${CASCADE_POLICY_NAME}\" is not one of: all, balanced, conservative — falling back to \"balanced\""
434
+ CASCADE_POLICY_NAME="balanced"
435
+ PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
436
+ esac
437
+
438
+ # max_generation is authoritatively resolved in phase-1-resolve.md (it only governs
439
+ # Phase 1 resolve-time cascade admission, never Step 4C's autonomous defer — see rule 1
440
+ # above). It is re-read here, read-only, ONLY to support the both-uncapped notice below —
441
+ # Step 4C does not otherwise consume MAX_GENERATION_FOR_NOTICE.
442
+ MAX_GENERATION_FOR_NOTICE=$(yq ".orchestration.cascade.max_generation // \"${PRESET_MAX_GEN}\"" forge.yaml 2>/dev/null || echo "$PRESET_MAX_GEN")
443
+ [ "$MAX_GENERATION_FOR_NOTICE" = "null" ] && MAX_GENERATION_FOR_NOTICE="$PRESET_MAX_GEN"
444
+ if [ "$MAX_GENERATION_FOR_NOTICE" != "unlimited" ] && ! echo "$MAX_GENERATION_FOR_NOTICE" | grep -qE '^[1-9][0-9]*$'; then
445
+ MAX_GENERATION_FOR_NOTICE="$PRESET_MAX_GEN"
446
+ fi
447
+
448
+ # token_budget precedence: orchestration.cascade.token_budget (new home) >
449
+ # pipeline.token_budget_per_batch (deprecated alias, forge#1858, kept working
450
+ # unchanged) > the resolved preset default. Accepts the "unlimited" sentinel —
451
+ # unlike a bare `// 900000` yq default, an explicit "unlimited" string must
452
+ # NOT be coerced back to the numeric default (see forge#2234 "Known Pitfalls":
453
+ # threading the sentinel through before wiring the levers, not after).
454
+ LEGACY_TOKEN_BUDGET=$(yq '.pipeline.token_budget_per_batch // ""' forge.yaml 2>/dev/null || echo "")
455
+ [ "$LEGACY_TOKEN_BUDGET" = "null" ] && LEGACY_TOKEN_BUDGET=""
456
+ TOKEN_BUDGET_FALLBACK="${LEGACY_TOKEN_BUDGET:-$PRESET_TOKEN_BUDGET}"
457
+ TOKEN_BUDGET=$(yq ".orchestration.cascade.token_budget // \"${TOKEN_BUDGET_FALLBACK}\"" forge.yaml 2>/dev/null || echo "$TOKEN_BUDGET_FALLBACK")
458
+ [ "$TOKEN_BUDGET" = "null" ] && TOKEN_BUDGET="$TOKEN_BUDGET_FALLBACK"
459
+ if [ "$TOKEN_BUDGET" != "unlimited" ] && ! echo "$TOKEN_BUDGET" | grep -qE '^[1-9][0-9]*$'; then
460
+ echo "WARNING: forge.yaml → orchestration.cascade.token_budget is not a positive integer or \"unlimited\" (\"${TOKEN_BUDGET}\") — falling back to default ${PRESET_TOKEN_BUDGET}"
461
+ TOKEN_BUDGET="$PRESET_TOKEN_BUDGET"
462
+ fi
463
+
308
464
  TOKEN_ESTIMATE_PER_FINDING=$(yq '.pipeline.token_estimate_per_finding // 150000' forge.yaml 2>/dev/null || echo 150000)
465
+
466
+ # Independent boolean levers — each accepts an explicit granular override on
467
+ # top of the resolved preset (preset supplies the default, not a hard value).
468
+ CASCADE_DEFER_ON_BATCH_GATED=$(yq ".orchestration.cascade.defer_on_batch_gated // ${PRESET_DEFER_GATED}" forge.yaml 2>/dev/null || echo "$PRESET_DEFER_GATED")
469
+ CASCADE_KEYWORD_HEURISTIC=$(yq ".orchestration.cascade.keyword_heuristic // ${PRESET_KEYWORD}" forge.yaml 2>/dev/null || echo "$PRESET_KEYWORD")
470
+ CASCADE_P3_SAME_FILE_DEFER=$(yq ".orchestration.cascade.p3_same_file_defer // ${PRESET_P3_SAME_FILE}" forge.yaml 2>/dev/null || echo "$PRESET_P3_SAME_FILE")
471
+
472
+ echo "Cascade admission policy resolved: policy=${CASCADE_POLICY_NAME} token_budget=${TOKEN_BUDGET} defer_on_batch_gated=${CASCADE_DEFER_ON_BATCH_GATED} keyword_heuristic=${CASCADE_KEYWORD_HEURISTIC} p3_same_file_defer=${CASCADE_P3_SAME_FILE_DEFER} (forge.yaml → orchestration.cascade; see docs/CONFIG.md)"
473
+
474
+ # Both-uncapped notice (loud, one-time, printed once per orchestrate invocation since
475
+ # this resolution block itself runs once at Step 4A.pre) — never a preset default except
476
+ # "all", which sets it deliberately. Surfacing this explicitly means an operator running
477
+ # an uncapped policy sees the tradeoff up front rather than discovering it from an
478
+ # unexpectedly long cascade tail (the exact gen-2→3→4 drift this config surface exists to
479
+ # make controllable — see forge#2234 issue body evidence).
480
+ if [ "$MAX_GENERATION_FOR_NOTICE" = "unlimited" ] && [ "$TOKEN_BUDGET" = "unlimited" ]; then
481
+ echo "⚠ WARNING: orchestration.cascade — both max_generation and token_budget are unlimited. Cascade admission has NO upper bound on generation depth or token spend this run (policy=${CASCADE_POLICY_NAME})."
482
+ fi
483
+ # --- End cascade admission policy resolution ---
484
+
309
485
  BATCH_TOKEN_SPEND=0
310
486
  TOKEN_DEFERRED=() # findings deferred by the token-budget rule this run (re-evaluable in Step 4F.2.6)
311
487
 
@@ -345,6 +521,8 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
345
521
 
346
522
  **Engine-first dispatch (default)**: When `forgedock` is in PATH, dispatch each ready issue via the durable engine rather than spawning prose Agent sub-agents. The engine's phase table enforces gate semantics in code — its fail-closed review gate and deterministic phase ordering are not subject to LLM interpretation.
347
523
 
524
+ **CRITICAL — never background via shell `&`/`wait`** (fixed forge#2466): A single `forgedock run-issue` invocation drives an issue through investigate → build → review → close and routinely runs 30+ minutes. Backgrounding the process at the *shell* level (`cmd &` … `wait`) does not escape the Bash tool's own per-invocation ceiling — `wait` is itself the foreground command the tool watches, and it blocks for the combined duration of every process in the chunk. This made engine-first dispatch effectively dead code: any chunk running longer than the ceiling was always killed, so every run silently fell through to the Agent-spawn fallback below. The fix: dispatch each `forgedock run-issue` invocation as its **own `Bash` tool call with `run_in_background=true`** — the harness's native "start it, don't wait, notify me on completion" primitive — never with shell-level `&`/`wait`. This is exactly the same async model the Agent-spawn-fallback path already uses (and Step 4B's notification-driven completion loop already expects), so one monitoring loop now covers both dispatch styles.
525
+
348
526
  ```bash
349
527
  # Engine-first dispatch: check CLI availability, then dispatch ready issues in score order
350
528
  # Uses SORTED_READY_SET[] from Step 3E.5 (descending value/cost) and budget gate from Step 4A-pre.0
@@ -360,30 +538,61 @@ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
360
538
  fi # else: added to DEFERRED_BUDGET_ISSUES[] by should_dispatch()
361
539
  done
362
540
 
363
- # Concurrency gate (forge#1912): dispatch in chunks of MAX_CONCURRENT, waiting for each
364
- # chunk to finish before starting the next. This is the code-form equivalent of the
365
- # Agent-spawn-fallback cap below — never more than MAX_CONCURRENT `forgedock run-issue`
366
- # processes in flight at once, regardless of how many issues are ready.
367
- IDX=0
368
- while [ "$IDX" -lt "${#DISPATCH_QUEUE[@]}" ]; do
369
- CHUNK=("${DISPATCH_QUEUE[@]:$IDX:$MAX_CONCURRENT}")
370
- echo "Dispatching chunk of ${#CHUNK[@]} issue(s) — concurrency cap ${MAX_CONCURRENT}"
371
-
372
- for NUM in "${CHUNK[@]}"; do
373
- LANE="${ISSUE_LANE[$NUM]}"
374
- PR_BASE="${ISSUE_PR_BASE[$NUM]}"
375
- COST="${ISSUE_COST_ESTIMATE[$NUM]:-0.35}"
376
-
377
- # Advance PROJECTED_SPEND before forking so subsequent iterations see the updated total
378
- PROJECTED_SPEND=$(echo "scale=4; $PROJECTED_SPEND + $COST" | bc 2>/dev/null || echo "$PROJECTED_SPEND")
379
-
380
- echo "Dispatching #$NUM via forgedock run-issue --lane $PR_BASE (score=${ISSUE_SCORE[$NUM]:-?} est_cost=\$${COST} projected_total=\$${PROJECTED_SPEND})"
381
- forgedock run-issue "$NUM" --lane "$PR_BASE" &
382
- done
383
- wait # cap enforced here — no more than MAX_CONCURRENT processes run concurrently
384
- IDX=$((IDX + MAX_CONCURRENT))
541
+ # Concurrency gate (forge#1912, mechanism fixed forge#2466): compute this dispatch batch the
542
+ # SAME way the Agent-spawn-fallback path below does — via dispatch_headroom()/
543
+ # DEFERRED_CONCURRENCY_ISSUES (Step 4A-pre.0.2) — NOT a shell `&`/`wait` chunk loop.
544
+ #
545
+ # MUST merge previously-deferred issues back in before recomputing, exactly like the
546
+ # Agent-spawn path's own batch computation does (CANDIDATES=("${DEFERRED_CONCURRENCY_ISSUES[@]}"
547
+ # ...); DEFERRED_CONCURRENCY_ISSUES=()). Without this merge-and-reset, an issue that lands in
548
+ # DEFERRED_CONCURRENCY_ISSUES on THIS path has no way back into a future DISPATCH_QUEUE — Step
549
+ # 4B item 5 only re-runs Step 4A for issues that just became DAG-unblocked, not for issues that
550
+ # were already ready but held back purely by the concurrency cap. DEFERRED_CONCURRENCY_ISSUES
551
+ # would become a write-only sink on the engine-first path, silently starving deferred work —
552
+ # the same class of bug this PR exists to fix, just relocated.
553
+ HEADROOM=$(dispatch_headroom)
554
+ CANDIDATES=("${DEFERRED_CONCURRENCY_ISSUES[@]}" "${DISPATCH_QUEUE[@]}")
555
+ DEFERRED_CONCURRENCY_ISSUES=()
556
+ DISPATCH_NOW=()
557
+ for NUM in "${CANDIDATES[@]}"; do
558
+ if [ "${#DISPATCH_NOW[@]}" -lt "$HEADROOM" ]; then
559
+ DISPATCH_NOW+=("$NUM")
560
+ else
561
+ DEFERRED_CONCURRENCY_ISSUES+=("$NUM")
562
+ fi
385
563
  done
386
- echo "Engine dispatch complete — advancing to Step 4B (completion sweep)"
564
+
565
+ if [ "${#DEFERRED_CONCURRENCY_ISSUES[@]}" -gt 0 ]; then
566
+ echo "CONCURRENCY DEFER: ${#DEFERRED_CONCURRENCY_ISSUES[@]} ready issue(s) held back — ${ACTIVE_DISPATCH_COUNT}/${MAX_CONCURRENT} already in flight. Will dispatch as slots free up: ${DEFERRED_CONCURRENCY_ISSUES[*]}"
567
+ fi
568
+ echo "Dispatching ${#DISPATCH_NOW[@]} issue(s) this message via forgedock run-issue (headroom was ${HEADROOM})"
569
+
570
+ for NUM in "${DISPATCH_NOW[@]}"; do
571
+ LANE="${ISSUE_LANE[$NUM]}"
572
+ PR_BASE="${ISSUE_PR_BASE[$NUM]}"
573
+ COST="${ISSUE_COST_ESTIMATE[$NUM]:-0.35}"
574
+
575
+ # Advance PROJECTED_SPEND before dispatching so subsequent iterations see the updated total
576
+ PROJECTED_SPEND=$(echo "scale=4; $PROJECTED_SPEND + $COST" | bc 2>/dev/null || echo "$PROJECTED_SPEND")
577
+
578
+ echo "Dispatching #$NUM via forgedock run-issue --lane $PR_BASE (score=${ISSUE_SCORE[$NUM]:-?} est_cost=\$${COST} projected_total=\$${PROJECTED_SPEND})"
579
+ done
580
+
581
+ # Lease heartbeat refresh (forge#2627) — once per dispatch chunk, so a long-running batch's
582
+ # lease never goes stale purely from elapsed wall-clock time while dispatch is still active.
583
+ if [ "${#DISPATCH_NOW[@]}" -gt 0 ] && [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && [ -n "${BATCH_ID:-}" ]; then
584
+ HOSTNAME_ID=$(hostname 2>/dev/null || echo "unknown-host")
585
+ # 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
586
+ gh issue comment "$COORD_ISSUE_NUMBER" -R {GH_REPO} --body "<!-- FORGE:LEASE -->
587
+ **Holder Batch ID**: ${BATCH_ID}
588
+ **Holder**: ${HOSTNAME_ID} (pid ${$})
589
+ **Acquired/refreshed**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
590
+ **TTL**: ${LEASE_TTL_SECONDS:-900}s (refreshed once per dispatch chunk in Step 4A)" 2>/dev/null || true
591
+ fi
592
+
593
+ if [ "${#DISPATCH_NOW[@]}" -eq 0 ]; then
594
+ echo "Engine dispatch: no headroom this cycle — waiting for the next completion notification (Step 4B) before dispatching more."
595
+ fi
387
596
  else
388
597
  echo "INFO: Using agent dispatch mode (forgedock CLI not in PATH — run \`npm install -g forgedock\` for engine-mode dispatch)"
389
598
  # Fall through to Agent-spawn template below. The SubagentStop hook (bin/hooks/interactive-engine.mjs)
@@ -391,6 +600,23 @@ else
391
600
  fi
392
601
  ```
393
602
 
603
+ **Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Issue one `Bash(...)` call per issue in `DISPATCH_NOW`, all in the same message, so they run concurrently within the headroom already computed above:
604
+
605
+ ```
606
+ Bash(command="forgedock run-issue {NUM} --lane {PR_BASE}", run_in_background=true, description="Engine-drive issue #{NUM}")
607
+ ```
608
+
609
+ Capture the task id each call returns into `ENGINE_DISPATCH_MAP[{NUM}]` (declared alongside `AGENT_ISSUE_MAP` below — Step 4B's completion handler uses this map to identify which issue a backgrounded engine-mode `Bash` completion notification belongs to, the same role `AGENT_ISSUE_MAP` plays for `agent_completed` notifications):
610
+
611
+ ```
612
+ # After the batch of Bash(run_in_background=true, ...) calls, capture each returned task id:
613
+ ENGINE_DISPATCH_MAP[{NUM}] = <task_id returned by Bash(run_in_background=true, ...)>
614
+ ```
615
+
616
+ `ENGINE_DISPATCH_MAP` starts empty and accumulates one entry per dispatched issue, exactly like `AGENT_ISSUE_MAP` below. After the batch, increment `ACTIVE_DISPATCH_COUNT` by `${#DISPATCH_NOW[@]}` — identical accounting to the Agent-spawn path's own post-batch increment.
617
+
618
+ If `DISPATCH_NOW` is empty (headroom is 0), do not dispatch any issues this cycle — wait for the next completion notification, exactly like the Agent-spawn path.
619
+
394
620
  **Agent-spawn path (fallback when forgedock CLI unavailable)**: When `FORGEDOCK_AVAILABLE=false`, spawn Agent sub-agents per issue using the template below. This preserves engine state via the SubagentStop hook even without the CLI.
395
621
 
396
622
  **REMINDER: You MUST use the template below verbatim when on the Agent-spawn fallback path. Only fill in `{VARIABLES}`. Do NOT rewrite the agent prompt. Do NOT write custom implementation instructions. The agent MUST invoke `/work-on` via the Skill tool — this is the HARD RULE from the top of this file.**
@@ -454,6 +680,7 @@ If the label is NOT terminal (e.g., `workflow:investigating`, `workflow:ready-to
454
680
  **LANE**: {LANE} (PR target: {PR_BASE})
455
681
  **Issue title**: {ISSUE_TITLE}
456
682
  {GIST_CONTEXT}
683
+ {SOURCE_PR_HINT_CONTEXT}
457
684
  "
458
685
  )
459
686
  ```
@@ -503,6 +730,20 @@ fi
503
730
 
504
731
  If `GIST_CONTEXT` is empty (no synthesis brief, no parent investigation, and no milestone index found), the variable resolves to a blank line in the template — no impact on the agent prompt. <!-- Updated: forge#341, forge#1192 -->
505
732
 
733
+ **`{SOURCE_PR_HINT_CONTEXT}` generation** <!-- Added: forge#2351 -->: For each issue being dispatched, thread the source-PR `likely-moot` triage hint computed by `phase-1-resolve.md`'s "Source-PR Triage Hint" step (`ISSUE_LIKELY_MOOT[$NUM]`, `ISSUE_SOURCE_PR[$NUM]`, `ISSUE_SOURCE_PR_STATE[$NUM]` — Phase 1 output, not re-derived here) into the dispatched agent's initial context, framed explicitly as a starting point to verify, never as a conclusion:
734
+
735
+ ```bash
736
+ # Build SOURCE_PR_HINT_CONTEXT for an issue — reads Phase 1's ISSUE_LIKELY_MOOT[]/ISSUE_SOURCE_PR[]/
737
+ # ISSUE_SOURCE_PR_STATE[] arrays (populated once in phase-1-resolve.md's pre-flight step).
738
+ SOURCE_PR_HINT_CONTEXT=""
739
+ if [ "${ISSUE_LIKELY_MOOT[{NUMBER}]:-unknown}" = "yes" ]; then
740
+ SOURCE_PR_HINT_CONTEXT="
741
+ **SOURCE-PR TRIAGE HINT (non-binding — verify, do not assume)**: This finding cites source PR #${ISSUE_SOURCE_PR[{NUMBER}]}, which closed WITHOUT merging (state: ${ISSUE_SOURCE_PR_STATE[{NUMBER}]}). This is a hint to check FIRST during investigation, not a verdict — do NOT conclude INVALID on this basis alone. Two outcomes are both possible and have both occurred in production: (a) the flagged change genuinely never landed by any route (verify with \`git log -S\`/\`git merge-base --is-ancestor\` against the target branch) — if so, this supports an INVALID verdict with evidence; (b) the flagged change reached the target branch anyway via a DIFFERENT, independently-merged PR — if so, this finding is still valid and must be investigated and resolved normally, exactly like #2346 (source PR #2337 closed unmerged, but the code landed via independently-merged PR #2261, commit 90376f5 — #2346 correctly ended at needs-human, not invalid). Reach your own evidence-based verdict; this hint only tells you where to look first."
742
+ fi
743
+ ```
744
+
745
+ If `ISSUE_LIKELY_MOOT[{NUMBER}]` is `unknown` or absent (no `**Source**: PR #{N}` citation found, source PR still open, source PR merged, or the lookup failed), `SOURCE_PR_HINT_CONTEXT` stays empty and resolves to a blank line in the template — no impact on the agent prompt, identical to the `GIST_CONTEXT` empty-case behavior above.
746
+
506
747
  **Claims board context injection** <!-- Added: forge#1736 -->: When a coordination issue exists for this batch (`FORGE_COORD_ISSUE` is set), append the claims board URL and the active-claims check instruction to the agent's context. This enables each `/work-on` agent to post its `FORGE:CLAIM` on build start.
507
748
 
508
749
  ```bash
@@ -606,9 +847,9 @@ If `DISPATCH_NOW` is empty (headroom is 0), do not spawn any agents this cycle
606
847
 
607
848
  ### Step 4B: Monitor completions and dispatch newly ready issues
608
849
 
609
- You will be automatically notified when each background agent completes. **Do NOT use `sleep` loops to poll for completion.** Instead, wait for the automatic notification. When you receive a notification that an agent completed, immediately process it.
850
+ You will be automatically notified when each background agent completes — or, for an engine-first dispatch (Step 4A, `FORGEDOCK_AVAILABLE=true`), when a backgrounded `Bash(run_in_background=true, command="forgedock run-issue ...")` call completes. Both are the same underlying "background task finished" notification the harness delivers; treat them identically for the purposes of this step. **Do NOT use `sleep` loops to poll for completion.** Instead, wait for the automatic notification. When one arrives, look up which issue it belongs to — `AGENT_ISSUE_MAP` for an `agent_completed` notification, `ENGINE_DISPATCH_MAP` (fixed forge#2466) for a background-Bash notification from the engine-first path — and immediately process it exactly the same way regardless of which map resolved it; every check below (`classify_predecessor_state()`, dependent dispatch, stall/staging checks) keys off GitHub labels/state, not which dispatch mechanism produced them. **Fallback**: if a runtime ever fails to deliver a background-Bash completion notification, poll `gh issue view --json labels` for the in-flight engine-dispatched issue's workflow label instead of blocking — the same degrade-gracefully behavior already used when `BACKGROUND_DISPATCH_ENABLED=false` for the Agent-spawn path (see `phase-3-dependency.md` → Background Dispatch Mode).
610
851
 
611
- **Concurrency slot release (MANDATORY — first action on every completion)** <!-- Added: forge#1912 -->: The instant an `agent_completed` notification arrives, decrement `ACTIVE_DISPATCH_COUNT` by 1 — a worker slot has just freed, regardless of what terminal state the issue ended up in. Do this before any stall-recovery/resume logic below. If that agent is then resumed (item 2 below, or Step 4B.5's stall recovery) because it stalled mid-pipeline rather than truly finishing, re-increment `ACTIVE_DISPATCH_COUNT` when the `Agent(resume=...)` call is issued — a resume re-occupies a worker slot exactly like a fresh dispatch does.
852
+ **Concurrency slot release (MANDATORY — first action on every completion)** <!-- Added: forge#1912 -->: The instant a completion notification arrives — `agent_completed`, or a background-Bash completion for an engine-dispatched issue — decrement `ACTIVE_DISPATCH_COUNT` by 1 — a worker slot has just freed, regardless of what terminal state the issue ended up in. Do this before any stall-recovery/resume logic below. If that agent is then resumed (item 2 below, or Step 4B.5's stall recovery) because it stalled mid-pipeline rather than truly finishing, re-increment `ACTIVE_DISPATCH_COUNT` when the `Agent(resume=...)` call is issued — a resume re-occupies a worker slot exactly like a fresh dispatch does. (Step 4B.5's stall-resume mechanism is `Agent(resume=...)`-specific and is unchanged by this fix — an engine-dispatched issue that stalls is not currently auto-resumed by Step 4B.5; it surfaces the same way any other stalled issue does, via the standard stall-detection alert, and `forgedock resume-stalled` remains available as a separate manual/scripted recovery path outside this file's scope.)
612
853
 
613
854
  **Ordering requirement (MANDATORY — prevents transient over-cap)**: For every agent that completed in this notification batch, finish its terminal-state check and, if applicable, its resume re-increment (items 1-2 below) BEFORE computing `dispatch_headroom` for item 5's newly-ready-issue dispatch. Computing headroom before a to-be-resumed agent's re-increment would let a genuinely-still-running agent's freed slot be double-booked — once by a fresh dispatch, once by the resume itself — transiently exceeding `MAX_CONCURRENT`. Process every completed agent's items 1-2 to a decision (terminal vs. resumed) first; only then compute headroom once for the batch's item 5 dispatch.
614
855
 
@@ -635,6 +876,19 @@ classify_predecessor_state() {
635
876
  echo "DONE"
636
877
  elif echo "$PRED_LABELS" | grep -qxE "needs-human|workflow:awaiting-merge"; then
637
878
  echo "GATED"
879
+ elif echo "$PRED_LABELS" | grep -qx "workflow:engine-error"; then
880
+ # forge#2261: the engine/tool itself broke on this run (e.g. a fail-fast
881
+ # CLI_BACKEND_FAILED, or an exhausted retry loop where the runner never
882
+ # once succeeded) — this is NOT a human-judgment block, so it must NOT
883
+ # classify GATED (that would wrongly track dependents as
884
+ # blocked-on-human-merge, waiting on a merge decision nobody needs to
885
+ # make). It is also not FAILED — the predecessor's content was never
886
+ # actually judged bad, the tool just needs to be re-run. Classifying it
887
+ # IN_PROGRESS means dependents simply keep waiting, exactly as if the
888
+ # predecessor were still mid-pipeline — which is accurate, since the
889
+ # stall-detection step below (item 2) auto-resumes/retries a completed
890
+ # agent run that ends in workflow:engine-error, same as any other stall.
891
+ echo "IN_PROGRESS"
638
892
  elif [ "$PRED_STATE" = "CLOSED" ]; then
639
893
  # Closed with no workflow:invalid/merged label (e.g. closed-not-planned) — treat as DONE,
640
894
  # not a new deadlock state; there is no pending code for dependents to wait on.
@@ -648,7 +902,7 @@ classify_predecessor_state() {
648
902
  - **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.
649
903
  - **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).
650
904
  - **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.
651
- - **IN_PROGRESS** — predecessor is still mid-pipeline (`investigating`/`ready-to-build`/`building`/`in-review`). Dependent simply continues waiting; no special tracking needed.
905
+ - **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.
652
906
 
653
907
  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.
654
908
 
@@ -832,7 +1086,7 @@ done
832
1086
  echo "#{NUM}: $FINAL_STATE"
833
1087
  ```
834
1088
 
835
- 2. **If the issue is NOT in a terminal-for-this-agent state** (`workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`), the agent stalled mid-pipeline. **Resume it immediately**:
1089
+ 2. **If the issue is NOT in a terminal-for-this-agent state** (`workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`), the agent stalled mid-pipeline. **Resume it immediately**. `workflow:engine-error` (forge#2261 — `bin/engine.mjs`'s headless engine terminating on an engine/tool-level failure rather than a genuine human-judgment block) is deliberately excluded from the terminal-for-this-agent set: a completed agent run ending there is treated exactly like any other stall and auto-resumed/retried here, up to the resume-cap in item 3 below — no human decision is pending, so there is nothing to gate on:
836
1090
  ```
837
1091
  Agent(
838
1092
  resume=AGENT_ISSUE_MAP[{NUMBER}],
@@ -895,9 +1149,15 @@ done
895
1149
 
896
1150
  Report every dependent whose skip was avoided this way — e.g. "#{DEP} — predecessor #{PRED} failed but never touched the shared file(s); edge dropped, #{DEP} not skipped." For all remaining dependents (real `EDGE_KIND` overlap confirmed, or a non-`EDGE_KIND` edge type), mark them "skipped — dependency #{X} failed" and report them. Do NOT dispatch them.
897
1151
 
898
- 6.4. **Auto-dispatch remediation against a `needs-human`-gated predecessor's own PR** <!-- Added: forge#1813 --> — item 6.5 below tracks the *dependents* of a `GATED` predecessor; this item handles the predecessor's own PR, which item 6.5/6.6 never re-drive on their own. Run this check whenever a completed agent's issue classifies `GATED` **specifically via `needs-human`** — NOT `workflow:awaiting-merge`. That second state already means "remediated and re-reviewed to a clean verdict, just needs a human's merge click" (see forge#1810's guard) — dispatching remediation again would be redundant, not just wasteful, since there is nothing left to fix.
1152
+ 6.4. **Auto-dispatch remediation against a `needs-human`-gated issue's own PR — runs unconditionally per completion, with or without dependents** <!-- Added: forge#1813, fixed: forge#2243 --> — item 6.5 below tracks the *dependents* of a `GATED` predecessor; this item handles the gated issue's own PR, which item 6.5/6.6 never re-drive on their own. **This check is bound directly to the issue that just completed — `PRED="$NUM"` (the same issue number carried from items 1-4 of this Step 4B sequence) — NOT to any `$PRED` produced by walking a dependent's predecessor list (item 5's readiness loop) or by item 6's FAILED-specific dependent-walk.** Run it for every completed agent, every cycle, regardless of whether that issue has any dependents in the DAG at all — a leaf issue (no dependents) is just as eligible as an issue that blocks others. Trigger condition: the completed issue classifies `GATED` **specifically via `needs-human`** — NOT `workflow:awaiting-merge`. That second state already means "remediated and re-reviewed to a clean verdict" (see forge#1810's guard) — dispatching remediation again would be redundant, not just wasteful, since there is nothing left to fix. A PR that reaches `workflow:awaiting-merge` now does so only when it targets `main` / the deploy gate (where `staging → main` is the genuine human gate and a human's merge click is required); a remediated-clean PR targeting a non-`main` base (`staging`, `milestone/*`) auto-lands to `workflow:merged` via remediate.md Phase M7's base-scoped auto-land bar (forge#2570) and never parks here. Either way this item's skip of `workflow:awaiting-merge` is unchanged.
899
1153
 
900
1154
  ```bash
1155
+ # Bind PRED to the issue that just completed — independent of item 5's dependent-walk loop
1156
+ # and item 6's FAILED-specific dependent-walk. This is the fix for forge#2243: a leaf issue
1157
+ # (no dependents) must still reach this check, since it never appears as a loop-bound $PRED
1158
+ # in either of those other contexts.
1159
+ PRED="$NUM"
1160
+
901
1161
  PRED_CURRENT_LABEL=$(gh issue view "$PRED" -R {GH_REPO} --json labels \
902
1162
  --jq '[.labels[].name | select(. == "needs-human" or . == "workflow:awaiting-merge")] | .[0] // empty' 2>/dev/null)
903
1163
 
@@ -938,7 +1198,7 @@ Do not ask the user questions — you are running autonomously in the background
938
1198
  fi
939
1199
  ```
940
1200
 
941
- This satisfies #1809 Q2 (the orchestrator auto-dispatches remediation against the gated predecessor itself — the exact gap forge#1812's item 6.5/6.6 left open, since those items only ever track and wake *dependents*, never the gated PR's own remediation). The remediation agent's outcome is picked up on the **next** completion-monitoring cycle of this same Step 4B loop: if it lands (`workflow:merged`), item 6.6 below fires normally and wakes any `blocked-on-human-merge` dependents; if it holds/re-escalates, the predecessor simply remains `GATED` and item 6.5 continues tracking its dependents unchanged.
1201
+ This satisfies #1809 Q2 (the orchestrator auto-dispatches remediation against the gated issue itself — the exact gap forge#1812's item 6.5/6.6 left open, since those items only ever track and wake *dependents*, never the gated PR's own remediation) — and closes forge#2243 (the gap that #1812's fix left open for *leaf* issues with no dependents: because this item's `$PRED` binding was never made explicit and self-contained, it was only ever reached while walking a dependent's predecessor list, so a `needs-human` issue that has no dependents never got remediation dispatched and its CHANGES-REQUESTED PR re-reviewed the same unchanged commit forever). The remediation agent's outcome is picked up on the **next** completion-monitoring cycle of this same Step 4B loop: if it lands (`workflow:merged`), item 6.6 below fires normally and wakes any `blocked-on-human-merge` dependents (if any exist — a leaf issue simply has none to wake); if it holds/re-escalates, the issue simply remains `GATED` and item 6.5 continues tracking its dependents (if any) unchanged.
942
1202
 
943
1203
  6.5. **Handle predecessor gating** (`GATED` — `needs-human` or `workflow:awaiting-merge`) <!-- Added: forge#1812 --> — if a completed agent's issue classifies as `GATED`, its direct dependents are neither dispatched nor marked failed/skipped. For each direct dependent `DEP` of the gated predecessor `PRED`:
944
1204
 
@@ -1074,7 +1334,7 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1074
1334
  ✓ #{NUMBER} — {title} → PR #{PR} merged to {target}
1075
1335
  ✗ #{NUMBER} — {title} → {reason for failure}
1076
1336
  ⚠ #{NUMBER} — {title} → PIPELINE BYPASS (no /work-on — PR invalid)
1077
- ⏸ #{NUMBER} — {title} → PR #{PR} awaiting-merge (remediated + re-approved — human merge only, no diagnosis needed)
1337
+ ⏸ #{NUMBER} — {title} → PR #{PR} awaiting-merge (remediated + re-approved, `main`/deploy-gate base — human merge only, no diagnosis needed; non-`main` bases now auto-land to `workflow:merged` via remediate.md M7, forge#2570)
1078
1338
  🔗 #{NUMBER} — {title} → blocked-on-human-merge (gated by #{PRED}, will auto-dispatch on #{PRED} merge)
1079
1339
  ⏳ Progress: {completed}/{total} complete, {active} active, {blocked} blocked
1080
1340
  → Dispatched #{NEWLY_READY} (predecessor #{PRED} completed)
@@ -1100,7 +1360,9 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1100
1360
 
1101
1361
  9. **Run staging integrity check** (from Step 4A-pre) if the completed agent merged a PR targeting staging.
1102
1362
 
1103
- **Termination condition**: All issues in the DAG have reached `DONE` or `FAILED` (merged, invalid, or skipped due to dependency failure) — OR are `blocked-on-human-merge` (item 6.5) with no further dispatchable work remaining in the batch. These two outcomes are reported differently: a batch where every issue is `DONE`/`FAILED` is a **clean drain**; a batch where one or more issues remain `blocked-on-human-merge` is a **paused drain** — the active dispatch loop stops (there is nothing left to do until a human merges a gating PR) but this MUST be reported as paused, not as fully complete (see `phase-6-report.md`'s `🔗 Blocked-on-Merge` section). `needs-human` predecessors with no open PR are neither — they remain GATED indefinitely until either a PR appears (dependent moves to blocked-on-human-merge) or the predecessor itself resolves; do not treat isolated `needs-human` issues with no dependents as blocking termination. When either drain condition is met, check whether deferred review-spawned findings exist (accumulated in `DEFERRED_FINDINGS` during Step 4C). If deferred findings exist → proceed to Step 4F (Completion Sweep). If no deferred findings → proceed to Phase 5.
1363
+ **Termination condition**: All issues in the DAG have reached `DONE` or `FAILED` (merged, invalid, or skipped due to dependency failure) — OR are `blocked-on-human-merge` (item 6.5) with no further dispatchable work remaining in the batch. These two outcomes are reported differently: a batch where every issue is `DONE`/`FAILED` is a **clean drain**; a batch where one or more issues remain `blocked-on-human-merge` is a **paused drain** — the active dispatch loop stops (there is nothing left to do until a human merges a gating PR) but this MUST be reported as paused, not as fully complete (see `phase-6-report.md`'s `🔗 Blocked-on-Merge` section). `needs-human` predecessors with no open PR are neither — they remain GATED indefinitely until either a PR appears (dependent moves to blocked-on-human-merge) or the predecessor itself resolves; do not treat isolated `needs-human` issues with no dependents as blocking termination. When either drain condition is met, check whether deferred review-spawned findings exist (accumulated in `DEFERRED_FINDINGS` during Step 4C). If deferred findings exist → proceed to Step 4F (Completion Sweep). If no deferred findings → **call `release_orchestrator_lease()` (Step 4A-pre.-1)** — this is a clean/paused drain of this session's own dispatch loop, so the lease should not be left held for a now-idle session — then proceed to Phase 5. <!-- Added: forge#2627 -->
1364
+
1365
+ **Reminder — this is the normal-exit lease release, distinct from the interrupted-stop procedure**: whether the drain is clean or paused, this session is no longer actively dispatching, so its lease must be released here (or, for a paused drain, at minimum have its heartbeat refresh stop — see Step 4A-pre.-1). This complements, but does not replace, the **Stopping the orchestrator** procedure (Step 4A-pre.-0.5) which handles the abnormal case of a mid-dispatch interrupt.
1104
1366
 
1105
1367
  **Relationship to `DEFERRED_CONCURRENCY_ISSUES` (forge#1912)**: A non-empty `DEFERRED_CONCURRENCY_ISSUES[]` never satisfies either termination condition above — it means dispatchable work exists but is temporarily held back by the concurrency cap, not that the DAG has stalled. Unlike `blocked-on-human-merge`, this is never reported as a "paused drain" requiring human action — it self-resolves automatically the moment any in-flight agent completes and frees a slot (Step 4A's `dispatch_headroom` recomputes every cycle). Only treat the DAG as drained once `DEFERRED_CONCURRENCY_ISSUES` is also empty.
1106
1368
 
@@ -1113,6 +1375,57 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1113
1375
  - Polling the same status check repeatedly on a timer
1114
1376
  - Waiting for a "batch" of completions before checking for newly ready issues — check after EVERY completion
1115
1377
 
1378
+ ### Step 4B.6: Predicate Re-Resolution (standing queries only)
1379
+
1380
+ <!-- Added: forge#2236 -->
1381
+
1382
+ **Purpose**: Phase 1 resolves `$ARGUMENTS` to an issue-number list exactly once (see `phase-1-resolve.md` "Predicate Persistence"). For a standing-query input (`ORIGINATING_QUERY_KIND == "query"` — everything except an explicit `#1 #2 #3` literal set), a new issue matching the same predicate can appear mid-run (a human files it, `/analytics` or `/signal-planner` create it, it gets added to the milestone) and is otherwise invisible to this batch until the operator re-invokes `/orchestrate` by hand. This step closes that gap for the *originating* issue set, distinct from Step 4C which folds in review-findings spawned *by* this batch's own agents.
1383
+
1384
+ **Skip entirely if** `ORIGINATING_QUERY_KIND == "literal"` (see `phase-1-resolve.md`) — an explicit issue-number list is the complete intent; there is nothing to re-resolve, ever.
1385
+
1386
+ **Trigger** (event-driven, same model as the rest of this file — no sleep/poll timer): run once per Step 4B completion cycle, immediately after the per-agent-completion handling above and before Step 4B.5's stall check.
1387
+
1388
+ ```bash
1389
+ # Gate on config + literal/query kind + round cap via bin/engine/resolve.mjs's
1390
+ # shouldReResolve (mirrors admission.mjs's role for Step 4C's admission rules).
1391
+ RERESOLVE_ENABLED=$(yq '.orchestration.reresolve.enabled // true' forge.yaml 2>/dev/null || echo "true")
1392
+ RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' forge.yaml 2>/dev/null || echo "unbounded")
1393
+
1394
+ node -e '
1395
+ import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ shouldReResolve }) => {
1396
+ const classified = { kind: process.argv[1], pattern: process.argv[2], args: [] };
1397
+ const config = {
1398
+ enabled: process.argv[3] === "false" ? false : process.argv[3],
1399
+ maxRounds: process.argv[4] === "unbounded" ? undefined : Number(process.argv[4]),
1400
+ };
1401
+ const rounds = Number(process.argv[5]);
1402
+ console.log(JSON.stringify(shouldReResolve(classified, config, rounds)));
1403
+ }, () => process.exit(0));
1404
+ ' "$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" "$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
1405
+ ```
1406
+
1407
+ 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).
1408
+
1409
+ **Re-run the original query** using the exact same resolution logic Phase 1 used for `$ORIGINATING_QUERY_PATTERN`/`$ORIGINATING_QUERY_ARGS` (e.g. re-run the `gh issue list --milestone`/`--label`/`priority:P*` fetch from `phase-1-resolve.md`'s "Fetch the issues" section), applying the exact same eligibility filter Phase 1 applied (`phase-4-execution.md` cross-reference: `phase-1-resolve.md` "Filter out ineligible issues" — closed/`needs-human`/`workflow:decomposed`/`workflow:awaiting-merge` excluded, same as T0).
1410
+
1411
+ **Fold new matches through the existing admission path — never a bypass**:
1412
+
1413
+ ```bash
1414
+ node -e '
1415
+ import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ foldNewMatches }) => {
1416
+ const reResolved = JSON.parse(process.argv[1]);
1417
+ const processed = JSON.parse(process.argv[2]);
1418
+ console.log(JSON.stringify(foldNewMatches(reResolved, processed)));
1419
+ }, () => process.exit(0));
1420
+ ' "$RERESOLVED_NUMBERS_JSON" "$ALL_BATCH_ISSUE_NUMBERS_JSON"
1421
+ ```
1422
+
1423
+ `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.
1424
+
1425
+ **Reporting (mandatory — do not let this become an alert-only dead-code path, per forge#1832)**: track which issues were T0-resolved vs. admitted via re-resolution, and which round each entered in. Surface this distinction in the final report (`phase-6-report.md`) rather than only in a log line — a run whose predicate still matches unadmitted work at exit (round cap or config disabled mid-run) must say so explicitly, not report a silent clean drain.
1426
+
1427
+ **Multi-repo**: for `all-repos`/satellite-scoped queries (`next <N> all-repos`, `mcp:fast`, etc.), re-run the query against every repo the original resolution covered — not just the default repo.
1428
+
1116
1429
  ### Step 4B.5: Time-Based Stall Detection
1117
1430
 
1118
1431
  **Purpose**: Catches agents that have stopped responding WITHOUT exiting (e.g., rate-limited, context-frozen, or silently hung). The reactive check in Step 4B only fires on agent completion — this check catches agents that never complete at all.
@@ -1204,6 +1517,15 @@ done
1204
1517
 
1205
1518
  After each agent reaches a terminal state, check if its `/work-on` run spawned review-finding issues during the review phase. These are new work items that should be added to the dependency DAG and dispatched when ready.
1206
1519
 
1520
+ **This step is the mid-run half of the run-spawned cascade mode** (`phase-1-resolve.md`
1521
+ "Cascade / Review-Finding Resolution" — mode (a)). It scopes to `BATCH_T0` for the same reason
1522
+ that resolve step does: this loop must only ever fold in `review-finding` issues *this batch's own
1523
+ agents* produced, never the pre-existing open backlog. There is no whole-backlog-sweep mode here —
1524
+ that mode only exists as the explicit, one-shot `--include-backlog` opt-in at Phase 1 resolve time
1525
+ (a human directly asking for it); Step 4C runs autonomously on every completion cycle and must
1526
+ never silently widen to the full backlog regardless of any flag, since nothing here is a
1527
+ human-in-the-loop request. <!-- Added: forge#2628 -->
1528
+
1207
1529
  ```bash
1208
1530
  # Method 1: Read TRAJECTORY comments from completed issues for "Finding issues" row
1209
1531
  for NUM in {completed_issue_number}; do
@@ -1212,10 +1534,14 @@ for NUM in {completed_issue_number}; do
1212
1534
  | grep -oP 'Finding issues\s*\|\s*#?\K\d+[^|]*' | grep -oP '\d+' | sort -u
1213
1535
  done
1214
1536
 
1215
- # Method 2 (fallback): Check for recently created review-finding issues that reference PRs from this batch
1216
- gh issue list -R {GH_REPO} --state open --label "review-finding" --limit 20 \
1217
- --json number,title,body,createdAt \
1218
- --jq "[.[] | select(.createdAt > \"$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ)\")]"
1537
+ # Method 2 (fallback): Check for review-finding issues created since this batch started
1538
+ # (BATCH_T0 — see Step 4A.pre above; reused from phase-1-resolve.md's Phase 1 capture, never
1539
+ # a rolling "N hours ago" window). A rolling window can still admit pre-existing backlog
1540
+ # issues that happen to have been created recently for reasons unrelated to this batch;
1541
+ # anchoring to BATCH_T0 cannot, by construction — anything created before this batch began
1542
+ # is excluded regardless of how recent it looks.
1543
+ gh issue list -R {GH_REPO} --state open --search "label:review-finding created:>=${BATCH_T0}" --limit 20 \
1544
+ --json number,title,body,createdAt
1219
1545
  ```
1220
1546
 
1221
1547
  **If review-finding issues were spawned:**
@@ -1225,12 +1551,12 @@ gh issue list -R {GH_REPO} --state open --label "review-finding" --limit 20 \
1225
1551
  For each spawned finding, determine whether it should be **executed** or **deferred**:
1226
1552
 
1227
1553
  **Evaluation order** (first matching rule wins):
1228
- 0. **Batch fully human-gated** (`BATCH_FULLY_GATED == true`, always defer, even for P1/P2) <!-- Added: forge#1814 -->: The original batch (see Step 4B item 6.7) has exhausted into DONE/FAILED/GATED with nothing left `IN_PROGRESS` — the real blockers are the GATED issues, not a lack of dispatchable findings. Dispatching a new review-finding here cannot produce net batch progress; it only inflates the open-issue count while the productive path waits on a human merge. Always defer, checked before generation and priority. Rationale: this is the idle/backpressure policy this issue adds — without it, rule 2 (below) unconditionally executes P1/P2 findings regardless of how gated the rest of the batch is, which is the root cause of the net-negative churn this policy exists to stop.
1229
- 1. **Generation ≥ 2** (always defer, even for P1/P2): Finding was spawned by an issue that was itself a review-finding. Check the source issue's labels for `review-finding` — if the source has that label, the new finding is generation 2. Always defer. Rationale: gen-2+ cascade is theoretically unbounded — cap it here.
1554
+ 0. **Batch fully human-gated** (`BATCH_FULLY_GATED == true`, always defer, even for P1/P2) <!-- Added: forge#1814 -->: The original batch (see Step 4B item 6.7) has exhausted into DONE/FAILED/GATED with nothing left `IN_PROGRESS` — the real blockers are the GATED issues, not a lack of dispatchable findings. Dispatching a new review-finding here cannot produce net batch progress; it only inflates the open-issue count while the productive path waits on a human merge. Always defer, checked before generation and priority. Rationale: this is the idle/backpressure policy this issue adds — without it, rule 2 (below) unconditionally executes P1/P2 findings regardless of how gated the rest of the batch is, which is the root cause of the net-negative churn this policy exists to stop. **Configurable** via `orchestration.cascade.defer_on_batch_gated` (default `true`; `false` under `policy: all` — forge#2234).
1555
+ 1. **Generation ≥ 2** (always defer, even for P1/P2, for autonomous mid-run cascade — see scope note below): Finding was spawned by an issue that was itself a review-finding. Check the source issue's labels for `review-finding` — if the source has that label, the new finding is generation 2. Always defer within this Step 4C triage pass. Rationale: gen-2+ cascade is theoretically unbounded — cap it here. **Scope**: this rule caps *autonomous* cascade — findings discovered and re-triaged automatically during an unattended run. It is not a cap on what a human can explicitly request. When an operator directly asks for cascade/review-finding work via `phase-1-resolve.md`'s dedicated `cascade`/`review-findings`/`findings` resolution (with `--include-deferred`/`--allow-gen2`, or the `orchestration.cascade.max_generation` config lever from #2234), those findings enter the DAG through Phase 1 resolution, not through this Step 4C mid-run triage — this rule still defers anything Step 4C itself discovers mid-run, including inside a human-requested gen≥2 batch (see recursion safety below). **Not configurable here** — `orchestration.cascade.max_generation` governs Phase 1 resolve-time admission only (see `phase-1-resolve.md` "Cascade / Review-Finding Resolution"); this Step 4C rule stays an absolute autonomous-cascade cap regardless of policy, including `policy: all`. <!-- Added: forge#2231 -->
1230
1556
  2. **Priority override** (P1 or P2 → always execute): If the finding is labeled P1 or P2, skip all remaining heuristics and execute. Rationale: high-priority findings must never be suppressed by keyword matching.
1231
- 3. **Comment/typo heuristic** (P3 and below only): Finding title contains the word "comment" or "typo" (case-insensitive). These are 1-line cosmetic fixes that do not block other work.
1232
- 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.
1233
- 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).
1557
+ 3. **Comment/typo heuristic** (P3 and below only): Finding title contains the word "comment" or "typo" (case-insensitive). These are 1-line cosmetic fixes that do not block other work. **Configurable** via `orchestration.cascade.keyword_heuristic` (default `true`; `false` under `policy: all` — forge#2234).
1558
+ 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).
1559
+ 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.
1234
1560
 
1235
1561
  **Defer** (do NOT add to the DAG) if rules 0, 1, 3, 4, or 5 match.
1236
1562
 
@@ -1266,18 +1592,156 @@ ALL_BATCH_FILES=$(echo "$ALL_BATCH_FILES" | sort -u | grep -v '^$')
1266
1592
  # NOTE: DEFERRED_FINDINGS, QUEUED_FINDINGS, and DEFERRED_REASONS are declared at
1267
1593
  # batch scope in Step 4A.pre — do NOT re-initialize them here (Step 4C runs per-agent).
1268
1594
  for FINDING_NUM in {spawned_finding_numbers}; do
1269
- FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body \
1270
- --jq '{labels: [.labels[].name], title: .title, body: .body}')
1595
+ FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,updatedAt \
1596
+ --jq '{labels: [.labels[].name], title: .title, body: .body, updatedAt: .updatedAt}')
1597
+
1598
+ # Code-branch repair guard (MANDATORY — before priority/defer heuristics below).
1599
+ # A review-finding with no **Code branch** annotation, whose parent PR's base is
1600
+ # not the staging fast lane, would otherwise silently fall through to
1601
+ # classify-lane.sh's implicit staging default — the one branch where the
1602
+ # finding's subject code is guaranteed absent (forge#2443: 5 findings from
1603
+ # milestone-lane PRs were misrouted this way in one batch; absent manual
1604
+ # correction, /work-on's investigation phase would have closed all 5 as
1605
+ # invalid). This is a synchronous per-finding check, distinct from the
1606
+ # periodic Step 4C.5 lane-consistency sweep below (that one audits an
1607
+ # entire milestone's PRs for base-branch drift after the fact; this one
1608
+ # repairs a single finding's missing provenance at discovery time).
1609
+ FINDING_BODY_RAW=$(echo "$FINDING_DATA" | jq -r '.body')
1610
+ # Snapshot freshness marker alongside the body read above — compared immediately
1611
+ # before the body-mutating write below to detect a concurrent edit (forge#2512).
1612
+ FINDING_UPDATED_AT_SNAPSHOT=$(echo "$FINDING_DATA" | jq -r '.updatedAt')
1613
+ if ! echo "$FINDING_BODY_RAW" | grep -q '\*\*Code branch\*\*:'; then
1614
+ # Portable (non-PCRE) extraction — PCRE grep lookbehinds are not supported by
1615
+ # Git Bash's grep build on Windows (same convention as
1616
+ # scripts/derive-finding-milestone.sh and scripts/code-index.sh), so use a
1617
+ # bash regex + BASH_REMATCH instead of a lookbehind.
1618
+ REPAIR_SOURCE_PR=""
1619
+ FINDING_BODY_LOWER=$(echo "$FINDING_BODY_RAW" | tr '[:upper:]' '[:lower:]')
1620
+ if [[ "$FINDING_BODY_LOWER" =~ \*\*source\*\*:[[:space:]]*pr[[:space:]]*#([0-9]+) ]]; then
1621
+ REPAIR_SOURCE_PR="${BASH_REMATCH[1]}"
1622
+ fi
1623
+ if [ -n "$REPAIR_SOURCE_PR" ]; then
1624
+ REPAIR_PARENT_BASE=$(gh pr view "$REPAIR_SOURCE_PR" -R {GH_REPO} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
1625
+ if [ -n "$REPAIR_PARENT_BASE" ] && [ "$REPAIR_PARENT_BASE" != "$STAGING_BRANCH" ]; then
1626
+ # GOVERNOR: cap the number of body-mutating repairs Step 4C will attempt
1627
+ # in a single run — bounds the blast radius of this new autonomous
1628
+ # `gh issue edit --body` mutation regardless of how many findings in the
1629
+ # batch happen to be missing Code branch. Declared with `:=` so it is
1630
+ # safe to reference whether or not a prior iteration already set it
1631
+ # (bash arithmetic increment below persists it across loop iterations).
1632
+ : "${REPAIR_GOVERNOR_COUNT:=0}"
1633
+ REPAIR_GOVERNOR_MAX=25
1634
+ if [ "$REPAIR_GOVERNOR_COUNT" -ge "$REPAIR_GOVERNOR_MAX" ]; then
1635
+ echo "REPAIR: #${FINDING_NUM} skipped — GOVERNOR cap reached (${REPAIR_GOVERNOR_MAX} repairs already attempted this run); flagging instead of repairing"
1636
+ gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # governor-cap path
1637
+ else
1638
+ REPAIR_GOVERNOR_COUNT=$((REPAIR_GOVERNOR_COUNT + 1))
1639
+ echo "REPAIR: #${FINDING_NUM} has no **Code branch** and parent PR #${REPAIR_SOURCE_PR} bases on '${REPAIR_PARENT_BASE}' (non-staging) — attempting repair (${REPAIR_GOVERNOR_COUNT}/${REPAIR_GOVERNOR_MAX} this run)"
1640
+ # Read-then-append: never blind-overwrite the existing body, only extend it.
1641
+ REPAIRED_BODY=$(printf '%s\n\n## Source Branch Context (repaired by orchestrate Step 4C — forge#2443)\n\n**Code branch**: `%s`\n**Worktree base**: `origin/%s`\n' \
1642
+ "$FINDING_BODY_RAW" "$REPAIR_PARENT_BASE" "$REPAIR_PARENT_BASE")
1643
+ # Concurrency guard (forge#2512 — TOCTOU fix): REPAIRED_BODY above was built purely
1644
+ # by string-appending to the FINDING_BODY_RAW snapshot captured at the top of this
1645
+ # loop iteration. The write below is a full-body overwrite — if the issue's body
1646
+ # changed since that read (a human edit, a second orchestrator run, or the finding's
1647
+ # own creation process still settling), a blind write here would silently clobber
1648
+ # that concurrent edit with no conflict signal. Re-fetch just `updatedAt` (cheap —
1649
+ # no body/labels payload) immediately before the write and compare against the
1650
+ # snapshot captured alongside FINDING_BODY_RAW. Bounded by the same
1651
+ # REPAIR_GOVERNOR_MAX cap as the write itself — at most one extra `gh issue view`
1652
+ # call per repair attempt.
1653
+ FINDING_UPDATED_AT_CURRENT=$(gh issue view "$FINDING_NUM" -R {GH_REPO} --json updatedAt --jq '.updatedAt' 2>/dev/null || echo "")
1654
+ if [ -z "$FINDING_UPDATED_AT_CURRENT" ]; then
1655
+ # forge#2565: the re-fetch itself failed (network error, API rate limit, or any
1656
+ # other non-zero `gh issue view` exit) — the `|| echo ""` fallback above produces
1657
+ # an empty string that is indistinguishable, to a plain `[ -n ... ] && [ ... != ... ]`
1658
+ # check, from "confirmed no concurrent edit." Treat an unverifiable freshness check
1659
+ # as its own outcome and fail closed, mirroring the concurrent-edit branch below
1660
+ # rather than silently falling through to the unprotected write.
1661
+ echo "REPAIR: #${FINDING_NUM} skipped — freshness re-fetch failed (network error or API rate limit); cannot verify no concurrent edit occurred, failing closed instead of risking a silent clobber"
1662
+ # forge#2584: stage the body in a mktemp file and deliver it via --body-file
1663
+ # instead of interpolating finding-derived variables (${FINDING_NUM},
1664
+ # ${REPAIR_SOURCE_PR}, ${REPAIR_PARENT_BASE}, ${FINDING_UPDATED_AT_SNAPSHOT})
1665
+ # directly into a double-quoted --body argument at the gh call site — mirrors
1666
+ # REPAIRED_BODY's existing printf-into-variable approach and the repo-wide
1667
+ # mktemp + --body-file convention (forge#2198). mktemp avoids /tmp path
1668
+ # collisions with other concurrently-running orchestrated agents.
1669
+ GATE_BODY_TMPFILE="$(mktemp)"
1670
+ printf '%s' "<!-- FORGE:GATE_FAILURE -->
1671
+ ## Code Branch Repair Skipped — Freshness Re-fetch Failed
1672
+
1673
+ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${REPAIR_SOURCE_PR} bases on \`${REPAIR_PARENT_BASE}\` — not the staging fast lane. Automatic repair was skipped because the freshness re-fetch (\`gh issue view --json updatedAt\`) failed, so the concurrency guard could not confirm the issue body is still at the snapshot captured earlier in this iteration (\`${FINDING_UPDATED_AT_SNAPSHOT}\`). Proceeding with the write here would risk silently clobbering a concurrent edit with no way to verify. Human review required to confirm the current body state and, if still needed, re-apply the Code branch stamp manually. <!-- forge#2565 -->" > "$GATE_BODY_TMPFILE"
1674
+ gh issue comment "$FINDING_NUM" -R {GH_REPO} --body-file "$GATE_BODY_TMPFILE" 2>/dev/null || true
1675
+ rm -f "$GATE_BODY_TMPFILE"
1676
+ gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # freshness-recheck-failure path
1677
+ elif [ "$FINDING_UPDATED_AT_CURRENT" != "$FINDING_UPDATED_AT_SNAPSHOT" ]; then
1678
+ echo "REPAIR: #${FINDING_NUM} skipped — concurrent edit detected (updatedAt changed from ${FINDING_UPDATED_AT_SNAPSHOT} to ${FINDING_UPDATED_AT_CURRENT} since this iteration's initial read); flagging instead of repairing"
1679
+ # forge#2584: mktemp + --body-file, same rationale as the freshness-recheck-failure path above.
1680
+ CONCURRENT_EDIT_BODY_TMPFILE="$(mktemp)"
1681
+ printf '%s' "<!-- FORGE:GATE_FAILURE -->
1682
+ ## Code Branch Repair Skipped — Concurrent Edit Detected
1683
+
1684
+ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${REPAIR_SOURCE_PR} bases on \`${REPAIR_PARENT_BASE}\` — not the staging fast lane. Automatic repair was skipped because the issue body was edited concurrently (\`updatedAt\` changed from \`${FINDING_UPDATED_AT_SNAPSHOT}\` to \`${FINDING_UPDATED_AT_CURRENT}\` between this run's initial read and the repair write). Overwriting the body now would have silently discarded that concurrent edit. Human review required to reconcile and, if still needed, re-apply the Code branch stamp manually. <!-- forge#2512 -->" > "$CONCURRENT_EDIT_BODY_TMPFILE"
1685
+ gh issue comment "$FINDING_NUM" -R {GH_REPO} --body-file "$CONCURRENT_EDIT_BODY_TMPFILE" 2>/dev/null || true
1686
+ rm -f "$CONCURRENT_EDIT_BODY_TMPFILE"
1687
+ gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # concurrent-edit path
1688
+ else
1689
+ if gh issue edit "$FINDING_NUM" -R {GH_REPO} --body "$REPAIRED_BODY" 2>/dev/null; then
1690
+ echo "REPAIR: #${FINDING_NUM} Code branch repaired to '${REPAIR_PARENT_BASE}'"
1691
+ # Re-fetch so the rest of this loop iteration (priority/defer heuristics
1692
+ # below) sees the repaired body, not the stale pre-repair one.
1693
+ FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,updatedAt \
1694
+ --jq '{labels: [.labels[].name], title: .title, body: .body, updatedAt: .updatedAt}')
1695
+ else
1696
+ # forge#2584: mktemp + --body-file, same rationale as the two failure paths above.
1697
+ REPAIR_FAILED_BODY_TMPFILE="$(mktemp)"
1698
+ printf '%s' "<!-- FORGE:GATE_FAILURE -->
1699
+ ## Code Branch Repair Failed
1700
+
1701
+ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${REPAIR_SOURCE_PR} bases on \`${REPAIR_PARENT_BASE}\` — not the staging fast lane. Automatic repair (\`gh issue edit --body\`) failed. Without this annotation, \`/work-on\`'s investigation phase may look for the code on the wrong branch (staging, where it is absent) and misclassify this confirmed finding as invalid. Human review required. <!-- forge#2443 -->" > "$REPAIR_FAILED_BODY_TMPFILE"
1702
+ gh issue comment "$FINDING_NUM" -R {GH_REPO} --body-file "$REPAIR_FAILED_BODY_TMPFILE" 2>/dev/null || true
1703
+ rm -f "$REPAIR_FAILED_BODY_TMPFILE"
1704
+ gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # repair-failure path
1705
+ fi
1706
+ fi
1707
+ fi
1708
+ fi
1709
+ fi
1710
+ fi
1271
1711
 
1272
- PRIORITY=$(echo "$FINDING_DATA" | jq -r '.labels[] | select(startswith("priority:P")) | ltrimstr("priority:")' | head -1)
1712
+ # Priority extraction accepts both label schemas: canonical `priority:P<n>` (the only
1713
+ # form review-pr.md ever creates) and bare `P<n>` (carried by some consumer repos'
1714
+ # externally/legacy-labeled issues). `priority:P<n>` wins if both are present on the
1715
+ # same issue. Normalizes to bare P<n> form since all downstream comparisons in this file
1716
+ # compare against bare "P1"/"P2"/"P3". Mirrored verbatim in the token-budget gate below,
1717
+ # the Step 4F.2 sweep loop, and phase-1-resolve.md / cleanup.md's batching queries —
1718
+ # keep all sites byte-identical (forge#1837: mirrored checks must not drift). <!-- Added: forge#2232 -->
1719
+ # NOTE: $FINDING_DATA.labels is already a flat array of label-name strings (see the
1720
+ # gh issue view --jq above: {labels: [.labels[].name], ...}) — so this reads `.labels[]`
1721
+ # directly, NOT `.labels[].name` (that would error: "Cannot index string with name").
1722
+ PRIORITY=$(echo "$FINDING_DATA" | jq -r '
1723
+ ([.labels[] | select(test("^priority:P[0-9]+$"))] | .[0]) //
1724
+ ([.labels[] | select(test("^P[0-9]+$"))] | .[0]) //
1725
+ "" | ltrimstr("priority:")')
1273
1726
  TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
1274
1727
 
1728
+ if [ -z "$PRIORITY" ]; then
1729
+ echo "WARNING: #${FINDING_NUM} has no parseable priority label (checked priority:P<n> and bare P<n>) — treating as untriaged, all priority-dependent rules below will not match"
1730
+ fi
1731
+
1275
1732
  # Rule 0: Batch fully human-gated — checked FIRST, overrides even the P1/P2 priority
1276
1733
  # override below. BATCH_FULLY_GATED is computed once per completion cycle in Step 4B
1277
1734
  # item 6.7; read it here, do not recompute. <!-- Added: forge#1814 -->
1278
- if [ "${BATCH_FULLY_GATED:-false}" = "true" ]; then
1735
+ # Gated by CASCADE_DEFER_ON_BATCH_GATED (orchestration.cascade, forge#2234) — "policy:
1736
+ # all" sets this to false so an operator draining a backlog is never idle-gated.
1737
+ if [ "$CASCADE_DEFER_ON_BATCH_GATED" = "true" ] && [ "${BATCH_FULLY_GATED:-false}" = "true" ]; then
1279
1738
  DEFER=true; DEFER_REASON="batch fully human-gated — idle policy"
1280
1739
  # Heuristic 1: Generation check — source issue has review-finding label (always defer, even for P1/P2)
1740
+ # NOT gated by orchestration.cascade.max_generation — this is Step 4C's autonomous
1741
+ # mid-run cascade cap, which stays absolute regardless of config (see forge#2231's
1742
+ # scope note above and phase-1-resolve.md's Cascade / Review-Finding Resolution
1743
+ # section). `orchestration.cascade.max_generation` governs what an explicit human
1744
+ # request admits at Phase 1 resolve time, not what this unattended triage pass defers.
1281
1745
  elif SOURCE_NUM=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oP '(?i)spawned from issue #\K\d+|source issue[: #]+\K\d+' | head -1) && \
1282
1746
  [ -n "$SOURCE_NUM" ] && \
1283
1747
  gh issue view $SOURCE_NUM -R {GH_REPO} --json labels --jq '[.labels[].name]' 2>/dev/null | grep -q "review-finding"; then
@@ -1286,10 +1750,12 @@ for FINDING_NUM in {spawned_finding_numbers}; do
1286
1750
  elif [ "$PRIORITY" = "P1" ] || [ "$PRIORITY" = "P2" ]; then
1287
1751
  DEFER=false
1288
1752
  # Heuristic 2: Comment/typo keyword (only applies to P3 and below)
1289
- elif echo "$TITLE" | grep -qi "comment\|typo"; then
1753
+ # Gated by CASCADE_KEYWORD_HEURISTIC (orchestration.cascade, forge#2234).
1754
+ elif [ "$CASCADE_KEYWORD_HEURISTIC" = "true" ] && echo "$TITLE" | grep -qi "comment\|typo"; then
1290
1755
  DEFER=true; DEFER_REASON="comment/typo heuristic"
1291
1756
  # Heuristic 3: P3 + same-file overlap
1292
- elif [ "$PRIORITY" = "P3" ]; then
1757
+ # Gated by CASCADE_P3_SAME_FILE_DEFER (orchestration.cascade, forge#2234).
1758
+ elif [ "$CASCADE_P3_SAME_FILE_DEFER" = "true" ] && [ "$PRIORITY" = "P3" ]; then
1293
1759
  # Extract file target from finding body (look for code block or backtick path)
1294
1760
  FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oP '`[^\`]+\.(py|ts|tsx|sh|md)`' | head -1 | tr -d '`')
1295
1761
  if [ -n "$FINDING_FILE" ] && echo "$ALL_BATCH_FILES" | grep -qF "$FINDING_FILE"; then
@@ -1347,14 +1813,40 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1347
1813
 
1348
1814
  # Safety exclusions — never batch, at any priority. Same jq test() engine and
1349
1815
  # patterns as phase-1-resolve.md's batching rule (single shared mechanism).
1816
+ # Word-boundary anchored (not bare substrings) and attribution-boilerplate
1817
+ # (**Confidence**/**Severity**/**Review comment** — see forge#2477 note below
1818
+ # for why **Source**/**Agent** are deliberately excluded from this list)
1819
+ # stripped from the body before scanning, so a finding naming its own
1820
+ # reviewing agent ("Security") or an identifier like `authority_source` no
1821
+ # longer false-positives.
1822
+ # `authentication|authorization|authn|authz` preserve real auth-domain coverage
1823
+ # as separate whole-word alternatives now that bare `auth` is boundary-anchored.
1824
+ # <!-- forge#2423 -->
1825
+ # Each stripped alternative is anchored to the field's real generator-output
1826
+ # shape (enum for Confidence/Severity, URL for Review comment) rather than a
1827
+ # bare label-prefix + `.*$` — matching on label shape alone lets
1828
+ # attacker-controlled body text on one of these lines get stripped along
1829
+ # with the label, smuggling banned keywords past the scan below. Source/Agent
1830
+ # are deliberately NOT stripped: both hold genuinely free-text generator
1831
+ # output (a PR title; an agent's self-description) with no fixed vocabulary,
1832
+ # so no shape bound can distinguish legitimate attribution from
1833
+ # attacker-authored payload placed in the same position — a length-bounded
1834
+ # free-text alternative for either field re-opens the exact smuggling gap
1835
+ # this fix closes (an attacker need only prefix their payload with a fake
1836
+ # "PR #N — " or agent name to satisfy the bound). Leaving them unstripped
1837
+ # trades a narrow, already-known false-positive (forge#2423's Agent-line
1838
+ # case; a P3 finding whose Source/Agent text happens to mention a domain
1839
+ # keyword is not auto-batched) for closing a real bypass — the safe
1840
+ # direction for a security-relevant exclusion. <!-- forge#2477 -->
1350
1841
  echo "$FINDING_DATA" | jq -e '
1351
- (.title | test("security|billing|anti-bot|auth"; "i"))
1352
- or (.body | test("## Problem[\\s\\S]{0,500}(security|billing|anti-bot|auth)"; "i"))
1842
+ (.title | test("\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i"))
1843
+ or ((.body | gsub("(?m)^\\*\\*(?:Confidence\\*\\*: (?:CONFIRMED|LIKELY|POSSIBLE)|Severity\\*\\*: (?:CRITICAL|HIGH|MEDIUM|LOW|INFO)|Review comment\\*\\*: https?://\\S+)$"; "")) | test("## Problem[\\s\\S]{0,500}\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i"))
1353
1844
  or ([.labels[]] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth"))
1354
1845
  ' >/dev/null && continue
1355
1846
 
1356
- # Only P3 findings are eligible (P1/P2 already dispatched individually above)
1357
- echo "$FINDING_DATA" | jq -e '[.labels[]] | any(. == "priority:P3")' >/dev/null || continue
1847
+ # Only P3 findings are eligible (P1/P2 already dispatched individually above).
1848
+ # Schema-tolerant: matches canonical priority:P3 or bare P3 (forge#2232).
1849
+ echo "$FINDING_DATA" | jq -e '[.labels[]] | any(test("^(priority:)?P3$"))' >/dev/null || continue
1358
1850
 
1359
1851
  FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
1360
1852
  [ -z "$FINDING_FILE" ] && continue
@@ -1389,8 +1881,31 @@ for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
1389
1881
  MEMBER_LINES="${MEMBER_LINES}- [ ] #${M}: ${MTITLE}"$'\n'
1390
1882
  done
1391
1883
 
1884
+ # Dedup-check with member exclusion (MANDATORY — forge#2432, identical mechanism to
1885
+ # phase-1-resolve.md's mirror block; this site keeps its existing direct-to-gh issue
1886
+ # creation below rather than migrating to /issue's Skill routing, since this loop runs
1887
+ # per-completion-cycle and a direct scripts/issue-dedup.sh call is cheaper here). A
1888
+ # batch title necessarily restates its own members' subject matter by construction —
1889
+ # excluding the cluster's own member numbers means Phase-2D-style dedup only fires on
1890
+ # a GENUINE non-member duplicate. --exclude narrows the candidate set; it is NOT a
1891
+ # --force equivalent.
1892
+ CHUNK_LIST=$(IFS=,; echo "${CHUNK[*]}")
1893
+ PROPOSED_BATCH_TITLE="fix(batch): P3 review findings — ${SAFE_SURFACE_AREA} (same-run batch)"
1894
+ DEDUP_RESULT=$(scripts/issue-dedup.sh "$PROPOSED_BATCH_TITLE" {GH_FLAG} --exclude "$CHUNK_LIST" 2>&1)
1895
+ DEDUP_EXIT=$?
1896
+
1897
+ if [ "$DEDUP_EXIT" -eq 1 ]; then
1898
+ # Genuine non-member duplicate — some other open issue already covers this exact
1899
+ # surface area. Do NOT create the batch; its members stay individually queued.
1900
+ echo "Batch dedup STOP (non-member match): $DEDUP_RESULT — skipping batch creation for $SAFE_SURFACE_AREA, members stay individually queued"
1901
+ continue
1902
+ elif [ "$DEDUP_EXIT" -eq 2 ]; then
1903
+ echo "Batch dedup usage error: $DEDUP_RESULT — skipping batch creation for $SAFE_SURFACE_AREA, members stay individually queued"
1904
+ continue
1905
+ fi
1906
+
1392
1907
  BATCH_ISSUE_NUM=$(gh issue create {GH_FLAG} \
1393
- --title "fix(batch): P3 review findings — ${SAFE_SURFACE_AREA} (same-run batch)" \
1908
+ --title "$PROPOSED_BATCH_TITLE" \
1394
1909
  --label "review-finding,priority:P3,batch" \
1395
1910
  --body "$(cat <<BATCH_EOF
1396
1911
  ## Problem
@@ -1434,8 +1949,16 @@ Rule 5 from the evaluation order above. Charges ONE `TOKEN_ESTIMATE_PER_FINDING`
1434
1949
  TOKEN_BUDGET_DEFERRED_THIS_RUN=()
1435
1950
 
1436
1951
  for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1952
+ # Schema-tolerant extraction (forge#2232) — same pattern as the Step 4C main loop above.
1953
+ # Defaults to P3 only when NEITHER label form is present (an empty labels match, not
1954
+ # merely a non-priority:P<n> label), so a bare-P3-labeled finding is read correctly
1955
+ # instead of falling through to this default.
1437
1956
  FINDING_PRIORITY=$(gh issue view "$FINDING_NUM" -R {GH_REPO} --json labels \
1438
- --jq '([.labels[].name | select(startswith("priority:P"))] | .[0] // "priority:P3") | ltrimstr("priority:")' 2>/dev/null || echo "P3")
1957
+ --jq '(
1958
+ ([.labels[].name | select(test("^priority:P[0-9]+$"))] | .[0]) //
1959
+ ([.labels[].name | select(test("^P[0-9]+$"))] | .[0]) //
1960
+ "priority:P3"
1961
+ ) | ltrimstr("priority:")' 2>/dev/null || echo "P3")
1439
1962
 
1440
1963
  # P1/P2 are NEVER gated by the token budget — they were queued by rule 2 above
1441
1964
  # regardless of budget headroom; skip them here and leave them in QUEUED_FINDINGS.
@@ -1444,7 +1967,9 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1444
1967
  fi
1445
1968
 
1446
1969
  PROJECTED_TOKEN_SPEND=$((BATCH_TOKEN_SPEND + TOKEN_ESTIMATE_PER_FINDING))
1447
- if [ "$PROJECTED_TOKEN_SPEND" -gt "$TOKEN_BUDGET" ]; then
1970
+ # TOKEN_BUDGET may be the "unlimited" sentinel (orchestration.cascade.token_budget /
1971
+ # policy: all) — never gate in that case, regardless of projected spend.
1972
+ if [ "$TOKEN_BUDGET" != "unlimited" ] && [ "$PROJECTED_TOKEN_SPEND" -gt "$TOKEN_BUDGET" ]; then
1448
1973
  TOKEN_DEFERRED+=("$FINDING_NUM")
1449
1974
  TOKEN_BUDGET_DEFERRED_THIS_RUN+=("$FINDING_NUM")
1450
1975
  DEFERRED_REASONS[$FINDING_NUM]="token budget exceeded (est. ${TOKEN_ESTIMATE_PER_FINDING} tokens would push batch spend to ${PROJECTED_TOKEN_SPEND} > ceiling ${TOKEN_BUDGET})"
@@ -1464,10 +1989,37 @@ if [ "${#TOKEN_BUDGET_DEFERRED_THIS_RUN[@]}" -gt 0 ]; then
1464
1989
  fi
1465
1990
  ```
1466
1991
 
1992
+ **Classify lane for each queued finding (MANDATORY — before dispatch)** <!-- Added: forge#2629 -->
1993
+
1994
+ Cascade-dispatched findings inherit NO lane assumption from the parent batch. Step 4A.pre's classify-lane loop (above) only covers `{ready_issue_numbers}` — the *original* dispatch group — and does not see findings discovered mid-run by this Step 4C pass. Without an explicit gate here, a finding reaches the Step 4A/4B dispatch loop with `${ISSUE_LANE[$NUM]}`/`${ISSUE_PR_BASE[$NUM]}` unset for its number, which is exactly the milestone-owned-finding-misrouted-to-staging risk this issue exists to close. Run this loop over `QUEUED_FINDINGS` (the post-clubbing, post-token-budget-gate list) before any of the numbered steps below, reusing the identical `classify-lane.sh` invocation and failure handling as Step 4A.pre — same script, same batch-scope `ISSUE_LANE`/`ISSUE_PR_BASE` arrays (declared once, line ~362-363), no new mechanism:
1995
+
1996
+ ```bash
1997
+ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1998
+ PR_BASE=$(bash ~/.claude/scripts/classify-lane.sh "$FINDING_NUM" -R {GH_REPO}) || {
1999
+ echo "ERROR: classify-lane.sh failed for #$FINDING_NUM — adding needs-human label and removing from QUEUED_FINDINGS" >&2
2000
+ # 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
2001
+ gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label "needs-human" 2>/dev/null || true
2002
+ QUEUED_FINDINGS=($(printf '%s\n' "${QUEUED_FINDINGS[@]}" | grep -vxF "$FINDING_NUM" || true))
2003
+ continue
2004
+ }
2005
+ # Derive LANE label from PR_BASE — identical rule to Step 4A.pre.
2006
+ if [ "$PR_BASE" = "staging" ]; then
2007
+ LANE="fast-lane"
2008
+ else
2009
+ LANE="feature-lane"
2010
+ fi
2011
+ ISSUE_LANE[$FINDING_NUM]="$LANE"
2012
+ ISSUE_PR_BASE[$FINDING_NUM]="$PR_BASE"
2013
+ echo "#$FINDING_NUM → lane=$LANE, PR_BASE=$PR_BASE (cascade finding)"
2014
+ done
2015
+ ```
2016
+
2017
+ A finding that fails classify-lane is removed from `QUEUED_FINDINGS` (mirrors Step 4A.pre's `continue`, which never admits a failed classification into the ready set) and flagged `needs-human` instead of silently falling through to the dispatch loop with an unset lane.
2018
+
1467
2019
  **For queued (non-deferred) findings:**
1468
2020
 
1469
2021
  1. **Add them to the dependency DAG.** They are implementation issues — same as issues spawned by investigations in Phase 2. Compute their predecessor sets using the same conflict detection (Step 3C Layers 1-4) against all remaining blocked/active issues.
1470
- 2. **Respect source branch context.** Review-finding issues have `**Code branch**: \`{branch}\`` in their body — the `/work-on` agent will read this and branch from the right origin. No special handling needed from the orchestrator.
2022
+ 2. **Respect source branch context.** Review-finding issues have `**Code branch**: \`{branch}\`` in their body — the `/work-on` agent will read this and branch from the right origin (the body annotation is repaired earlier in this step if missing — see the Code-branch repair guard above). Separately, and mandatorily, `${ISSUE_LANE[$NUM]}`/`${ISSUE_PR_BASE[$NUM]}` are now populated by the classify-lane loop immediately above this list — every queued finding is lane-classified individually, inheriting no lane assumption from the parent batch, before it reaches the standard Step 4A/4B dispatch loop.
1471
2023
  3. **Report to user:**
1472
2024
  ```
1473
2025
  Agent #{COMPLETED} spawned {count} new finding issues: #{A}, #{B}
@@ -1688,7 +2240,16 @@ for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
1688
2240
  STATE=$(echo "$FINDING_DATA" | jq -r '.state')
1689
2241
  [ "$STATE" = "CLOSED" ] && continue
1690
2242
 
1691
- PRIORITY=$(echo "$FINDING_DATA" | jq -r '.labels[] | select(startswith("priority:P")) | ltrimstr("priority:")' | head -1)
2243
+ # Schema-tolerant extraction (forge#2232), mirrors the Step 4C main loop above.
2244
+ # PRIORITY is not currently read by the branches below (comment/typo is title-only),
2245
+ # kept normalized for consistency and to avoid reintroducing the schema bug if a
2246
+ # future rule here starts branching on it. NOTE: $FINDING_DATA.labels here is already
2247
+ # a flat array of label-name strings (see the --jq above: {labels: [.labels[].name], ...}),
2248
+ # so this reads `.labels[]` directly, not `.labels[].name`.
2249
+ PRIORITY=$(echo "$FINDING_DATA" | jq -r '
2250
+ ([.labels[] | select(test("^priority:P[0-9]+$"))] | .[0]) //
2251
+ ([.labels[] | select(test("^P[0-9]+$"))] | .[0]) //
2252
+ "" | ltrimstr("priority:")')
1692
2253
  TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
1693
2254
 
1694
2255
  # Re-apply heuristics against the drained DAG (no active batch files)
@@ -1755,7 +2316,7 @@ if [ "${#TOKEN_GATED[@]}" -gt 0 ]; then
1755
2316
  [ "$STATE" = "CLOSED" ] && continue # resolved by another process — drop silently
1756
2317
 
1757
2318
  PROJECTED_SWEEP_SPEND=$((SWEEP_TOKEN_SPEND + TOKEN_ESTIMATE_PER_FINDING))
1758
- if [ "$PROJECTED_SWEEP_SPEND" -gt "$TOKEN_BUDGET" ]; then
2319
+ if [ "$TOKEN_BUDGET" != "unlimited" ] && [ "$PROJECTED_SWEEP_SPEND" -gt "$TOKEN_BUDGET" ]; then
1759
2320
  SWEEP_STILL_DEFERRED+=($FINDING_NUM)
1760
2321
  echo "Sweep: #${FINDING_NUM} still deferred (token budget — sweep allowance ${TOKEN_BUDGET} would be exceeded at ${PROJECTED_SWEEP_SPEND})"
1761
2322
  else
@@ -1786,6 +2347,7 @@ declare -A SWEEP_PR_BASE
1786
2347
  for FINDING_NUM in "${SWEEP_EXECUTE[@]}"; do
1787
2348
  SWEEP_BASE=$(bash ~/.claude/scripts/classify-lane.sh "$FINDING_NUM" -R {GH_REPO}) || {
1788
2349
  echo "ERROR: classify-lane.sh failed for #$FINDING_NUM — adding needs-human and skipping" >&2
2350
+ # 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
1789
2351
  gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label "needs-human" 2>/dev/null || true
1790
2352
  continue
1791
2353
  }
@@ -1915,18 +2477,18 @@ fi
1915
2477
  Completion Sweep Results:
1916
2478
  Dispatched: #{A}, #{B} (file overlap cleared after DAG drain)
1917
2479
  Still deferred (cosmetic): #{C} (comment/typo)
1918
- Permanently deferred (gen2): #{D} (generation >= 2 cascade cap)
2480
+ Permanently deferred (gen2): #{D} (generation >= 2 cascade cap — requires manual /work-on, or an explicit `cascade`/`review-findings --allow-gen2` request via phase-1-resolve.md; see #2231/#2234)
1919
2481
  Idle-gated — still deferred: #{E} (batch still fully human-gated — waiting on a merge)
1920
2482
  Idle-gated — cleared: #{F} (a gating PR merged since deferral — no longer idle)
1921
2483
  Token-gated — cleared: #{G} (fits fresh sweep token allowance — batch spend ${SWEEP_TOKEN_SPEND}/${TOKEN_BUDGET})
1922
2484
  Token-gated — still deferred: #{H} (sweep allowance also exhausted — re-evaluable next run)
1923
2485
  ```
1924
2486
 
1925
- **After sweep agents complete** (or if no findings were dispatched): output the budget deferred-issues report (if applicable), then proceed to Phase 5.
2487
+ **After sweep agents complete** (or if no findings were dispatched): output the budget deferred-issues report (if applicable), **call `release_orchestrator_lease()` (Step 4A-pre.-1)** — the sweep is this session's last dispatch activity, so the lease must not be left held for a now-idle session (the Termination condition's own release call, above, only fires on the no-deferred-findings branch; this is the equivalent release for the deferred-findings/Completion Sweep branch) <!-- Added: forge#2627 -->, then proceed to Phase 5.
1926
2488
 
1927
2489
  **Anti-patterns — DO NOT DO THIS:**
1928
2490
  - Re-sweeping findings spawned during the sweep itself — this creates unbounded recursion. Sweep is a single pass.
1929
- - Overriding generation >= 2 deferrals — the cascade cap is absolute.
2491
+ - Overriding generation >= 2 deferrals **inside this Step 4C mid-run triage** — the cascade cap is absolute for autonomous cascade: a sweep or triage pass must never promote a gen≥2 finding to executed just because it looks low-risk. This is distinct from an explicit human request made *before* the run starts: an operator may deliberately admit gen≥2 findings via `phase-1-resolve.md`'s `cascade`/`review-findings`/`findings` resolution with `--include-deferred`/`--allow-gen2` (or the `orchestration.cascade.max_generation` config lever from #2234) — that is the sanctioned override path, entered at Phase 1 resolution, not a violation of this rule. Do not use that human-request path as precedent to relax Step 4C itself. <!-- Added: forge#2231 -->
1930
2492
  - Skipping the sweep because "there are only a few" deferred findings — even one deferred finding represents unresolved work.
1931
2493
  - Clearing an idle-gated deferral (forge#1814) without recomputing `BATCH_FULLY_GATED` fresh at sweep time — a stale "not gated" read would re-introduce the exact net-negative churn this policy exists to stop.
1932
2494
  - Clearing a token-gated deferral (forge#1858) by reusing the drained `BATCH_TOKEN_SPEND` instead of the fresh `SWEEP_TOKEN_SPEND` allowance — the whole point of Step 4F.2.6 is that the *original* batch's spend is no longer relevant once it has drained.