forgedock 1.7.0 → 1.8.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 (49) hide show
  1. package/README.md +2 -1
  2. package/bin/engine/admission.mjs +242 -7
  3. package/bin/engine/admission.test.mjs +183 -2
  4. package/bin/engine/phases.mjs +31 -22
  5. package/bin/engine/resolve.mjs +8 -1
  6. package/bin/engine/resolve.test.mjs +5 -0
  7. package/bin/engine-cli.mjs +2 -0
  8. package/bin/engine.mjs +26 -17
  9. package/bin/forgedock.mjs +124 -48
  10. package/bin/hooks/pre-tool-use.mjs +573 -2
  11. package/bin/journey.mjs +11 -7
  12. package/bin/labels.json +5 -0
  13. package/bin/opencode-adapter.mjs +652 -92
  14. package/bin/orchestrate-preflight.mjs +434 -0
  15. package/bin/runner.mjs +77 -9
  16. package/commands/audit-agents.md +75 -26
  17. package/commands/autopilot.md +4 -4
  18. package/commands/cleanup.md +10 -5
  19. package/commands/issue.md +95 -35
  20. package/commands/orchestrate/phase-1-resolve.md +70 -32
  21. package/commands/orchestrate/phase-3-dependency.md +167 -22
  22. package/commands/orchestrate/phase-4-execution.md +691 -62
  23. package/commands/orchestrate/phase-5-cleanup.md +5 -11
  24. package/commands/orchestrate/phase-6-report.md +27 -1
  25. package/commands/orchestrate/safety.md +1 -1
  26. package/commands/orchestrate.md +39 -1
  27. package/commands/quality-gate.md +30 -10
  28. package/commands/review-pr-agents/protocols.md +13 -0
  29. package/commands/review-pr-agents.md +8 -0
  30. package/commands/review-pr-staging.md +117 -17
  31. package/commands/review-pr.md +95 -23
  32. package/commands/work-on/build/validate.md +25 -4
  33. package/commands/work-on/close.md +40 -10
  34. package/commands/work-on/decompose.md +33 -1
  35. package/commands/work-on/investigate.md +33 -0
  36. package/commands/work-on/remediate.md +40 -5
  37. package/commands/work-on/review.md +27 -0
  38. package/commands/work-on.md +48 -5
  39. package/docs/CONFIG.md +51 -15
  40. package/docs/OPENCODE.md +128 -25
  41. package/docs/spec/review-protocol.md +7 -0
  42. package/package.json +1 -1
  43. package/scripts/check-admission-parity.mjs +1 -0
  44. package/scripts/check-command-side-effects.sh +14 -13
  45. package/scripts/check-shell-portability.sh +36 -0
  46. package/scripts/check-spec-markers.sh +21 -11
  47. package/scripts/checkbox-sections.test.sh +67 -0
  48. package/scripts/extract-affected-files.sh +85 -5
  49. package/scripts/extract-affected-files.test.sh +154 -2
@@ -189,6 +189,8 @@ should_dispatch() {
189
189
 
190
190
  **WHY THIS EXISTS** <!-- Added: forge#1912 -->: Phase 4's dispatch loops previously launched every DAG-ready issue in one shot — the engine-first bash loop backgrounded every `should_dispatch()`-passing issue with no count limit, and the Agent-spawn-fallback path was explicitly instructed to "launch all ready agents simultaneously." On a large ready set this saturates the Anthropic API rate limit in one burst, causing cascading failures across most of the batch. This is a distinct, count-denominated gate from the dollar-denominated `--budget` gate above (Step 4A-pre.0) — an issue can be deferred for budget reasons, concurrency reasons, or both; each gate accumulates its own deferred list independently.
191
191
 
192
+ `MAX_CONCURRENT` caps top-level `/work-on` dispatches, not the total number of harness subagents. A normal worker can spawn context, architect, implement, validate, quality-gate, and review children. Budget **8 total subagent spawns per worker** when choosing this value: `max_concurrent <= session_subagent_budget / 8`. This is a conservative planning multiplier, not a second runtime semaphore; the harness does not expose a reliable cross-agent spawn counter to this shell-level dispatcher.
193
+
192
194
  Initialize the concurrency cap and its batch-scope tracking state before the first dispatch:
193
195
 
194
196
  ```bash
@@ -209,8 +211,12 @@ fi
209
211
 
210
212
  ACTIVE_DISPATCH_COUNT=0 # in-flight dispatched-but-not-yet-completed agents, this batch
211
213
  DEFERRED_CONCURRENCY_ISSUES=() # ready issues held back because no headroom was available
214
+ SUBAGENT_SPAWN_BUDGET_PER_WORKER=8 # /work-on + build/review fan-out planning estimate
215
+ TOP_LEVEL_DISPATCH_TOTAL=0
216
+ PLANNED_SUBAGENT_SPAWNS=0
217
+ OBSERVED_SUBAGENT_SPAWNS="unavailable" # replace only with harness telemetry; never infer
212
218
 
213
- echo "Concurrency gate initialized: MAX_CONCURRENT=${MAX_CONCURRENT} (forge.yaml → orchestration.max_concurrent; default 12 when unset or invalid)"
219
+ echo "Concurrency gate initialized: MAX_CONCURRENT=${MAX_CONCURRENT}; plan for up to $((MAX_CONCURRENT * SUBAGENT_SPAWN_BUDGET_PER_WORKER)) subagent spawns in flight (8 per worker)"
214
220
  # --- End concurrency gate initialization ---
215
221
  ```
216
222
 
@@ -226,6 +232,31 @@ dispatch_headroom() {
226
232
 
227
233
  **This is a hard cap, not a suggestion.** No dispatch site in Phase 4 may background/spawn more than `dispatch_headroom` new agents without first waiting for in-flight agents to complete. Issues that cannot be dispatched due to the cap go into `DEFERRED_CONCURRENCY_ISSUES[]` and are released — in the order they were deferred — as running agents complete (Step 4B), the same event-driven model (no sleep/poll loops) the file already uses for DAG-readiness and budget gating.
228
234
 
235
+ **Dispatch accounting**: Immediately after each successful top-level dispatch, increment `TOP_LEVEL_DISPATCH_TOTAL` and add `SUBAGENT_SPAWN_BUDGET_PER_WORKER` to `PLANNED_SUBAGENT_SPAWNS`. When the runtime reports child-spawn telemetry, replace `OBSERVED_SUBAGENT_SPAWNS` with that measured total; otherwise leave it `unavailable` rather than fabricating a measured value.
236
+
237
+ ### Step 4A-pre.0.3: Secondary content-creation backpressure (MANDATORY)
238
+
239
+ GitHub's `resources.core` quota does not report secondary content-creation throttles. Treat any GitHub API response with HTTP `403` whose body contains `secondary rate limit` as a batch-wide backpressure signal.
240
+
241
+ ```bash
242
+ # Call from every dispatcher-owned gh write/create wrapper and from a child completion
243
+ # report that includes its final GitHub API error. Do not retry the failed request here.
244
+ SECONDARY_RATE_LIMITED=false
245
+ SECONDARY_RATE_LIMIT_MESSAGE=""
246
+
247
+ record_secondary_rate_limit() {
248
+ local STATUS="$1"
249
+ local BODY="$2"
250
+ if [ "$STATUS" = "403" ] && echo "$BODY" | grep -qi 'secondary rate limit'; then
251
+ SECONDARY_RATE_LIMITED=true
252
+ SECONDARY_RATE_LIMIT_MESSAGE="$BODY"
253
+ echo "SECONDARY RATE LIMIT: pausing all new dispatch. Do not retry GitHub content creation until an operator resumes the batch." >&2
254
+ fi
255
+ }
256
+ ```
257
+
258
+ Before every dispatch site computes headroom or launches a worker, check `SECONDARY_RATE_LIMITED`. When true, preserve the ready/deferred queues, launch no more workers, and end the active dispatch cycle. Do not poll or retry GitHub calls to discover recovery: an operator resumes the batch after the throttle clears. In-flight agents receive this contract in the dispatch prompt: on a secondary-limit 403, they must stop their GitHub write/create retry loop, report the response to the orchestrator, and exit their current phase without creating replacement work.
259
+
229
260
  ### Step 4A-pre: Staging baseline tracking (MANDATORY — continuous)
230
261
 
231
262
  **WHY THIS EXISTS**: Milestone-code-onto-staging contamination incidents (see issue #150) produce unexpected growth on the staging branch that is otherwise invisible until after a deploy. In the streaming DAG model, there are no discrete wave boundaries — instead, track a running baseline and check after each agent completion.
@@ -409,6 +440,16 @@ echo "Step 4A.pre: using BATCH_T0=${BATCH_T0} for Step 4C's run-spawned cascade
409
440
  DEFERRED_FINDINGS=()
410
441
  QUEUED_FINDINGS=()
411
442
  declare -A DEFERRED_REASONS
443
+ # Count every finding regardless of admission, so a deferred refinement never
444
+ # makes the reported amplification ratio look better than it was.
445
+ FINDINGS_SPAWNED=0
446
+ MERGED_UNITS=0
447
+ AMPLIFICATION_RATIO_HISTORY=()
448
+ AMPLIFICATION_DEFERRED=()
449
+ declare -A FINDINGS_BY_SOURCE_PR
450
+ declare -A REFINEMENT_FINDINGS
451
+ declare -A NEW_SURFACE_FINDINGS
452
+ declare -A AMPLIFICATION_FINDING_SEEN
412
453
  declare -A AGENT_ISSUE_MAP
413
454
  # Engine-first dispatch equivalent of AGENT_ISSUE_MAP (fixed forge#2466): keyed by issue
414
455
  # number, holds the task id returned by each backgrounded `Bash(run_in_background=true,
@@ -416,6 +457,29 @@ declare -A AGENT_ISSUE_MAP
416
457
  # this map to identify which issue a background-Bash completion notification belongs to,
417
458
  # the same role AGENT_ISSUE_MAP plays for Agent-tool `agent_completed` notifications.
418
459
  declare -A ENGINE_DISPATCH_MAP
460
+ # OpenCode native task equivalent of ENGINE_DISPATCH_MAP. Keys are issue numbers;
461
+ # values are the session ids returned by task(background=true). This is a live
462
+ # cache only; every entry must also be persisted as a FORGE:DISPATCH comment.
463
+ declare -A OPENCODE_DISPATCH_MAP
464
+
465
+ # Reconstruct the live map after compaction/restart from the durable dispatch
466
+ # records. The issue association is the map key; only the latest running record
467
+ # is restored, because a later completed/error record has already released it.
468
+ for NUM in "${ISSUES[@]}"; do
469
+ DISPATCH_BODY=$(gh api repos/{GH_REPO}/issues/"$NUM"/comments \
470
+ --jq '[.[] | select(.body | contains("FORGE:DISPATCH")) | .body] | last // ""' 2>/dev/null || true)
471
+ TASK_ID=$(printf '%s' "$DISPATCH_BODY" | jq -Rr 'try capture("\\\"runtime\\\":\\\"opencode\\\",\\\"child_session_id\\\":\\\"(?<id>[^\\\"]+)\\\".*\\\"state\\\":\\\"running\\\"").id catch ""')
472
+ if [ -n "$TASK_ID" ]; then
473
+ OPENCODE_DISPATCH_MAP["$NUM"]="$TASK_ID"
474
+ fi
475
+ done
476
+
477
+ # Full-repository intake is intentionally separate from Step 4C's T0-scoped
478
+ # review-finding cascade. It is enabled only for an operator-authorised
479
+ # policy: all run; records every open issue already observed so CI-created
480
+ # actionable work is admitted once rather than rediscovered every cycle.
481
+ declare -A SEEN_OPEN_ISSUES
482
+ FULL_REPO_SWEEP_COUNT=0
419
483
 
420
484
  # Same-file current-state brief forwarding (forge#1860). Populated by the core streaming
421
485
  # dispatch loop below (Step 4B) whenever a Layer 1/2/3 structural predecessor edge (see
@@ -425,6 +489,42 @@ declare -A ENGINE_DISPATCH_MAP
425
489
  declare -A SAME_FILE_BRIEF
426
490
  declare -A EDGE_BRIEFED
427
491
 
492
+ # DONE-path edge re-verification memo (forge#2848). A conclusive re-derivation is
493
+ # memoized in EDGE_REDERIVED; inconclusive results get one retry, capped by
494
+ # EDGE_REDERIVE_ATTEMPTS. This avoids caching a transient failure while bounding
495
+ # extraction to two attempts per descendant per batch.
496
+ declare -A EDGE_REDERIVED
497
+ declare -A EDGE_REDERIVE_ATTEMPTS
498
+
499
+ # Affected-file extraction helper, resolved for the DONE-path cohort re-derivation
500
+ # (forge#2848). Same resolver precedence as phase-3-dependency.md Step 3C Layer 1 —
501
+ # ForgeDock's runtime installation before the target repository — because the
502
+ # orchestrator runs inside the project being worked on, where a bare
503
+ # `bash scripts/extract-affected-files.sh` silently fails when that project has not
504
+ # copied ForgeDock's helper scripts into its own repository (#2794/#2791).
505
+ resolve_extract_affected_files() {
506
+ local candidates=()
507
+ [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/extract-affected-files.sh")
508
+ [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/extract-affected-files.sh")
509
+ candidates+=("$PWD/scripts/extract-affected-files.sh")
510
+
511
+ local candidate
512
+ for candidate in "${candidates[@]}"; do
513
+ if [ -f "$candidate" ]; then
514
+ printf '%s\n' "$candidate"
515
+ return 0
516
+ fi
517
+ done
518
+ echo "ERROR: extract-affected-files.sh is not installed in any configured runtime path." >&2
519
+ return 1
520
+ }
521
+
522
+ # Non-fatal here, unlike Phase 3's hard exit: Phase 3 cannot build a DAG at all without
523
+ # this helper, whereas Phase 4 only needs it for the OPTIONAL re-derivation optimization.
524
+ # If it is missing, re-derivation is skipped and edges stay as planned — the conservative
525
+ # direction, and identical to this file's pre-forge#2848 behavior.
526
+ AFFECTED_FILES_SCRIPT=$(resolve_extract_affected_files) || AFFECTED_FILES_SCRIPT=""
527
+
428
528
  # Human-gated idle/backpressure flag (Step 4B item 6.7, forge#1814). Starts false —
429
529
  # recomputed every completion cycle over {all_batch_issue_numbers}. Declared at batch
430
530
  # scope (not per-agent) so Step 4C can read the latest value on every iteration.
@@ -449,15 +549,15 @@ CASCADE_POLICY_NAME=$(yq '.orchestration.cascade.policy // "balanced"' forge.yam
449
549
 
450
550
  case "$CASCADE_POLICY_NAME" in
451
551
  all)
452
- PRESET_MAX_GEN="unlimited"; PRESET_TOKEN_BUDGET="unlimited"; PRESET_DEFER_GATED="false"; PRESET_KEYWORD="false"; PRESET_P3_SAME_FILE="false" ;;
552
+ PRESET_MAX_GEN="unlimited"; PRESET_BATCH_MAX_GEN=2; PRESET_TOKEN_BUDGET="unlimited"; PRESET_DEFER_GATED="false"; PRESET_KEYWORD="false"; PRESET_P3_SAME_FILE="false" ;;
453
553
  conservative)
454
- PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=450000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
554
+ PRESET_MAX_GEN=1; PRESET_BATCH_MAX_GEN=2; PRESET_TOKEN_BUDGET=450000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
455
555
  balanced)
456
- PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
556
+ PRESET_MAX_GEN=1; PRESET_BATCH_MAX_GEN=2; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
457
557
  *)
458
558
  echo "WARNING: forge.yaml → orchestration.cascade.policy \"${CASCADE_POLICY_NAME}\" is not one of: all, balanced, conservative — falling back to \"balanced\""
459
559
  CASCADE_POLICY_NAME="balanced"
460
- PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
560
+ PRESET_MAX_GEN=1; PRESET_BATCH_MAX_GEN=2; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
461
561
  esac
462
562
 
463
563
  # max_generation is authoritatively resolved in phase-1-resolve.md (it only governs
@@ -470,6 +570,14 @@ if [ "$MAX_GENERATION_FOR_NOTICE" != "unlimited" ] && ! echo "$MAX_GENERATION_FO
470
570
  MAX_GENERATION_FOR_NOTICE="$PRESET_MAX_GEN"
471
571
  fi
472
572
 
573
+ # P3 batching is a distinct, bounded aggregation exception to autonomous cascade
574
+ # admission. Unlike max_generation, it must always be finite, including policy: all.
575
+ BATCH_MAX_GENERATION=$(yq ".orchestration.cascade.batch_max_generation // ${PRESET_BATCH_MAX_GEN}" forge.yaml 2>/dev/null || echo "$PRESET_BATCH_MAX_GEN")
576
+ if ! echo "$BATCH_MAX_GENERATION" | grep -qE '^[1-9][0-9]*$'; then
577
+ echo "WARNING: forge.yaml → orchestration.cascade.batch_max_generation is not a positive integer (\"${BATCH_MAX_GENERATION}\") — falling back to default ${PRESET_BATCH_MAX_GEN}"
578
+ BATCH_MAX_GENERATION="$PRESET_BATCH_MAX_GEN"
579
+ fi
580
+
473
581
  # token_budget precedence: orchestration.cascade.token_budget (new home) >
474
582
  # pipeline.token_budget_per_batch (deprecated alias, forge#1858, kept working
475
583
  # unchanged) > the resolved preset default. Accepts the "unlimited" sentinel —
@@ -488,13 +596,30 @@ fi
488
596
 
489
597
  TOKEN_ESTIMATE_PER_FINDING=$(yq '.pipeline.token_estimate_per_finding // 150000' forge.yaml 2>/dev/null || echo 150000)
490
598
 
599
+ # Amplification is observational by default. The optional ceiling is evaluated
600
+ # only against same-lineage refinements in Step 4C, never against new surface.
601
+ CASCADE_MAX_AMPLIFICATION=$(yq '.orchestration.cascade.max_amplification // "off"' forge.yaml 2>/dev/null || echo "off")
602
+ if [ "$CASCADE_MAX_AMPLIFICATION" != "off" ]; then
603
+ if ! echo "$CASCADE_MAX_AMPLIFICATION" | grep -qE '^[0-9]+(\.[0-9]+)?$' || \
604
+ ! awk "BEGIN { exit !($CASCADE_MAX_AMPLIFICATION > 0) }"; then
605
+ echo "WARNING: forge.yaml → orchestration.cascade.max_amplification must be a positive number or off — disabling the bound"
606
+ CASCADE_MAX_AMPLIFICATION="off"
607
+ fi
608
+ fi
609
+ CONVERGENCE_WINDOW=$(yq '.orchestration.cascade.convergence_window // 3' forge.yaml 2>/dev/null || echo 3)
610
+ if ! echo "$CONVERGENCE_WINDOW" | grep -qE '^[1-9][0-9]*$'; then
611
+ echo "WARNING: forge.yaml → orchestration.cascade.convergence_window must be a positive integer — falling back to 3"
612
+ CONVERGENCE_WINDOW=3
613
+ fi
614
+ echo "Cascade amplification: max_amplification=${CASCADE_MAX_AMPLIFICATION} convergence_window=${CONVERGENCE_WINDOW} (off preserves current admission behavior)"
615
+
491
616
  # Independent boolean levers — each accepts an explicit granular override on
492
617
  # top of the resolved preset (preset supplies the default, not a hard value).
493
618
  CASCADE_DEFER_ON_BATCH_GATED=$(yq ".orchestration.cascade.defer_on_batch_gated // ${PRESET_DEFER_GATED}" forge.yaml 2>/dev/null || echo "$PRESET_DEFER_GATED")
494
619
  CASCADE_KEYWORD_HEURISTIC=$(yq ".orchestration.cascade.keyword_heuristic // ${PRESET_KEYWORD}" forge.yaml 2>/dev/null || echo "$PRESET_KEYWORD")
495
620
  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")
496
621
 
497
- 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)"
622
+ echo "Cascade admission policy resolved: policy=${CASCADE_POLICY_NAME} token_budget=${TOKEN_BUDGET} batch_max_generation=${BATCH_MAX_GENERATION} 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)"
498
623
 
499
624
  # Both-uncapped notice (loud, one-time, printed once per orchestrate invocation since
500
625
  # this resolution block itself runs once at Step 4A.pre) — never a preset default except
@@ -521,6 +646,8 @@ TOKEN_DEFERRED=() # findings deferred by the token-budget rule this run (re-ev
521
646
  # alone. <!-- Added: forge#1909 -->
522
647
  SURFACE_BATCHED_FINDINGS=() # all member issue numbers absorbed into a batch across the run
523
648
  SURFACE_BATCH_COUNT=0 # count of batch issues created across the run
649
+ BATCHABLE_DEFERRED_P3=() # gen-2+ P3s eligible only for bounded P3 aggregation (forge#2849)
650
+ declare -A FINDING_GENERATIONS
524
651
 
525
652
  for NUM in {ready_issue_numbers}; do
526
653
  PR_BASE=$(bash "$CLASSIFY_LANE_SCRIPT" "$NUM" -R {GH_REPO}) || {
@@ -544,6 +671,97 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
544
671
 
545
672
  ### Step 4A: Dispatch ready issues
546
673
 
674
+ ### Step 4A.0: Probe Knowledge Gist capability once
675
+
676
+ Probe the authenticated identity once for this orchestration run before any engine, Agent, or
677
+ OpenCode worker is dispatched. A GitHub App installation token identifies as `Bot` and cannot use
678
+ the Gists API; cache that fact rather than letting every worker rediscover it by attempting a
679
+ write. An unavailable identity probe preserves existing behavior for PAT-authenticated runs.
680
+
681
+ ```bash
682
+ if [ -z "${FORGE_GIST_CAPABLE+x}" ]; then
683
+ GIST_AUTH_TYPE=$(gh api user --jq '.type' 2>/dev/null || true)
684
+ if [ "$GIST_AUTH_TYPE" = "Bot" ]; then
685
+ FORGE_GIST_CAPABLE=false
686
+ else
687
+ FORGE_GIST_CAPABLE=true
688
+ fi
689
+ export FORGE_GIST_CAPABLE
690
+ fi
691
+
692
+ if [ "$FORGE_GIST_CAPABLE" = "true" ]; then
693
+ echo "Knowledge Gist capability available"
694
+ else
695
+ echo "INFO: Knowledge Gist subsystem unavailable for this authentication; workers will skip it"
696
+ fi
697
+ ```
698
+
699
+ Carry `FORGE_GIST_CAPABLE` unchanged through every dispatch path. Do not probe it in individual
700
+ workers dispatched by this run. Phase 6 reports a false value once at batch level.
701
+
702
+ **Claims-board dispatch gate (MANDATORY, before every individual dispatch)** <!-- Added: forge#2844 -->: The coordination issue is the durable authority for file ownership. Do not use `EDGE_FILES`, `ISSUE_FILES`, or a remembered prior read as evidence that a claim is free. Immediately before dispatching each issue, re-read the full claims board and refuse that dispatch when the issue's declared file set intersects a live claim held by another issue. This applies equally to engine, Claude Agent, and OpenCode task dispatches, including newly-ready issues and wake reconstruction.
703
+
704
+ ```bash
705
+ # Returns unreleased claims as [{holder: "123", files: "..."}]. A release is a separate,
706
+ # later comment, so it must be paired with its claim by holder rather than searched for in the
707
+ # claim comment itself. Terminal holder states also self-heal claims left behind by dead agents.
708
+ read_active_claims() {
709
+ local COORD_NUM="$1"
710
+ local CLAIMS HOLDER TERMINAL
711
+ CLAIMS=$(gh api --paginate --slurp "repos/{GH_REPO}/issues/${COORD_NUM}/comments" 2>/dev/null \
712
+ | jq -c '
713
+ flatten as $comments |
714
+ [$comments[]
715
+ | select(.body | contains("<!-- FORGE:CLAIM -->"))
716
+ | . as $claim
717
+ | ($claim.body | capture("\\*\\*Holder\\*\\*: #(?<holder>[0-9]+)").holder) as $holder
718
+ | select([$comments[]
719
+ | select(.body | contains("<!-- FORGE:CLAIM_RELEASED -->"))
720
+ | select((.body | capture("\\*\\*Holder\\*\\*: #(?<holder>[0-9]+)").holder) == $holder)
721
+ | select(.created_at > $claim.created_at)] | length == 0)
722
+ | {holder: $holder,
723
+ files: ($claim.body | capture("\\*\\*Files\\*\\*: (?<files>[\\s\\S]*?)(?:\\n\\*\\*Interfaces\\*\\*:|$)").files)}]') || return 1
724
+
725
+ for HOLDER in $(echo "$CLAIMS" | jq -r '.[].holder'); do
726
+ TERMINAL=$(gh issue view "$HOLDER" -R {GH_REPO} --json labels --jq \
727
+ '[.labels[].name | select(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:awaiting-merge" or . == "needs-human")] | length > 0' 2>/dev/null)
728
+ [ "$TERMINAL" = "true" ] && CLAIMS=$(echo "$CLAIMS" | jq --arg holder "$HOLDER" '[.[] | select(.holder != $holder)]')
729
+ done
730
+ echo "$CLAIMS"
731
+ }
732
+
733
+ claim_conflicts_with_live_holder() {
734
+ local NUM="$1" TARGET_FILES CLAIM HOLDER CLAIM_FILES OVERLAP
735
+ TARGET_FILES=$(printf '%s\n' "${ISSUE_FILES[$NUM]:-}" | sed -E '/^[[:space:]]*$/d; s/^[[:space:]]*[-*][[:space:]]*//; s/`//g' | sort -u)
736
+ [ -z "$TARGET_FILES" ] && return 1 # No declared paths: normal conservative DAG rules still apply.
737
+
738
+ ACTIVE_CLAIMS=$(read_active_claims "$COORD_ISSUE_NUMBER") || {
739
+ echo "REFUSING TO DISPATCH #${NUM}: could not read the durable claims board." >&2
740
+ return 0
741
+ }
742
+ while IFS= read -r CLAIM; do
743
+ HOLDER=$(echo "$CLAIM" | jq -r '.holder')
744
+ [ "$HOLDER" = "$NUM" ] && continue
745
+ CLAIM_FILES=$(echo "$CLAIM" | jq -r '.files' | sed -E '/^[[:space:]]*$/d; s/^[[:space:]]*[-*][[:space:]]*//; s/`//g' | sort -u)
746
+ OVERLAP=$(comm -12 <(printf '%s\n' "$TARGET_FILES") <(printf '%s\n' "$CLAIM_FILES"))
747
+ if [ -n "$OVERLAP" ]; then
748
+ echo "DEFERRING #${NUM}: declared files overlap live claim held by #${HOLDER}: ${OVERLAP//$'\n'/, }"
749
+ return 0
750
+ fi
751
+ done < <(echo "$ACTIVE_CLAIMS" | jq -c '.[]')
752
+ return 1
753
+ }
754
+
755
+ # Run this immediately before each engine Bash, Agent(), or OpenCode task call. Do not batch the
756
+ # board read: another worker can claim a file between two dispatches in the same ready set.
757
+ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ]; then
758
+ if claim_conflicts_with_live_holder "$NUM"; then
759
+ DEFERRED_CLAIM_ISSUES+=("$NUM")
760
+ continue
761
+ fi
762
+ fi
763
+ ```
764
+
547
765
  **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.
548
766
 
549
767
  **OpenCode dispatch (runtime-neutral adapter)**: When `FORGE_RUNTIME=opencode` or
@@ -556,10 +774,45 @@ and continue until it reaches
556
774
  `workflow:merged`, `workflow:invalid`, `needs-human`, or
557
775
  `workflow:awaiting-merge`.
558
776
 
777
+ The native call is asynchronous and must be issued once per issue in
778
+ `DISPATCH_NOW`, in the same assistant message where the batch is dispatched:
779
+
780
+ ```
781
+ task(
782
+ description="Work on {PROJECT_PREFIX}#{NUMBER}",
783
+ subagent_type="general",
784
+ background=true,
785
+ prompt="Use the same Phase 4A work-on template below. Before invoking it, run `export FORGE_GIST_CAPABLE={FORGE_GIST_CAPABLE}` so the cached orchestration capability is preserved. Invoke Skill(skill='work-on', args='{PROJECT_PREFIX}{NUMBER} --under-orchestration') and continue until a terminal workflow state."
786
+ )
787
+ ```
788
+
789
+ Capture the returned `<task id="..." state="running">` id in
790
+ `OPENCODE_DISPATCH_MAP[{NUMBER}]` and immediately persist it to the issue:
791
+
792
+ ```bash
793
+ ATTEMPT=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
794
+ --jq '[.[] | select(.body | contains("FORGE:DISPATCH") and contains("\"state\":\"running\""))] | length + 1')
795
+ if [ "${DRY_RUN:-false}" = "false" ]; then
796
+ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DISPATCH -->
797
+ \`\`\`json
798
+ {\"runtime\":\"opencode\",\"child_session_id\":\"{TASK_ID}\",\"attempt\":${ATTEMPT},\"state\":\"running\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}
799
+ \`\`\`"
800
+ fi
801
+ ```
802
+
803
+ `background=true` is required for streaming DAG behavior; a foreground task
804
+ makes the orchestrator wait for that issue and reintroduces a wave barrier. The ForgeDock OpenCode plugin opts into
805
+ `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` by default. An explicit
806
+ `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false` is a supported degraded mode,
807
+ but it must be reported as non-streaming rather than treated as equivalent to
808
+ Claude background dispatch. Do not wait for the slowest sibling before handling
809
+ an earlier task-result event.
810
+
559
811
  ```bash
560
812
  if [ "${FORGE_RUNTIME:-}" = "opencode" ]; then
561
813
  echo "OpenCode native task dispatch selected — load commands/work-on.md for each ready issue."
562
814
  echo "Do not call forgedock run-issue or the Claude Agent(...) fallback in this branch."
815
+ echo "Use task(subagent_type=general, background=true) once per ready issue and process each task-result event immediately."
563
816
  echo "If native task dispatch cannot run, post FORGE:OPENCODE_BLOCKED with the missing capability and add needs-human."
564
817
  fi
565
818
  ```
@@ -567,6 +820,13 @@ fi
567
820
  This branch is additive. When OpenCode is not selected, continue through the
568
821
  existing engine-first and Claude Agent-spawn paths below without modification.
569
822
 
823
+ The generated OpenCode plugin is an executable boundary, not just prompt
824
+ guidance: its `tool.execute.before` hook rejects `claude`, `forgedock
825
+ run-issue`, `npx forgedock run-issue`, and recursive `opencode run` shell
826
+ commands with `FORGE_OPENCODE_CAPABILITY_ERROR`. If a native Skill or Task is
827
+ unavailable, stop with that bounded error and preserve the GitHub label state;
828
+ never compensate by starting a second controller or the Claude-backed engine.
829
+
570
830
  **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.
571
831
 
572
832
  ```bash
@@ -634,6 +894,18 @@ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
634
894
  if [ "${#DEFERRED_CONCURRENCY_ISSUES[@]}" -gt 0 ]; then
635
895
  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[*]}"
636
896
  fi
897
+ # Filter against the durable board once while forming this engine batch. The tool-call
898
+ # instruction below re-checks immediately before each launch to close the remaining TOCTOU gap.
899
+ CLAIM_SAFE_DISPATCH=()
900
+ for NUM in "${DISPATCH_NOW[@]}"; do
901
+ if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_ISSUE_NUMBER:-}" ] && claim_conflicts_with_live_holder "$NUM"; then
902
+ DEFERRED_CLAIM_ISSUES+=("$NUM")
903
+ continue
904
+ fi
905
+ CLAIM_SAFE_DISPATCH+=("$NUM")
906
+ done
907
+ DISPATCH_NOW=("${CLAIM_SAFE_DISPATCH[@]}")
908
+
637
909
  echo "Dispatching ${#DISPATCH_NOW[@]} issue(s) this message via forgedock run-issue (headroom was ${HEADROOM})"
638
910
 
639
911
  for NUM in "${DISPATCH_NOW[@]}"; do
@@ -670,10 +942,10 @@ fi
670
942
  fi
671
943
  ```
672
944
 
673
- **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:
945
+ **Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Immediately before each call, run `claim_conflicts_with_live_holder "{NUM}"`; if it returns success, defer that issue rather than dispatching it. Issue one `Bash(...)` call per remaining issue in `DISPATCH_NOW`, all in the same message, so they run concurrently within the headroom already computed above:
674
946
 
675
947
  ```
676
- Bash(command="forgedock run-issue {NUM} --lane {PR_BASE}", run_in_background=true, description="Engine-drive issue #{NUM}")
948
+ Bash(command="FORGE_GIST_CAPABLE=${FORGE_GIST_CAPABLE} forgedock run-issue {NUM} --lane {PR_BASE}", run_in_background=true, description="Engine-drive issue #{NUM}")
677
949
  ```
678
950
 
679
951
  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):
@@ -687,7 +959,16 @@ ENGINE_DISPATCH_MAP[{NUM}] = <task_id returned by Bash(run_in_background=true, .
687
959
 
688
960
  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.
689
961
 
690
- **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.
962
+ **Agent-spawn path (Claude fallback when forgedock CLI unavailable)**: When `FORGEDOCK_AVAILABLE=false` and the runtime is not OpenCode, spawn Agent sub-agents per issue using the template below. This preserves engine state via the SubagentStop hook even without the CLI.
963
+
964
+ When the runtime is OpenCode, skip this Claude-only `Agent(...)` path entirely.
965
+ Use the native `task` call above for initial and newly-ready issue dispatches.
966
+ Translate only those OpenCode orchestration dispatches to `task(...)` with
967
+ `subagent_type="general"` and `background=true`. Review and remediation are
968
+ load-bearing child operations and must run foreground. Do not claim task-id
969
+ resume support: after a restart, reconcile the latest `FORGE:DISPATCH` record
970
+ with durable GitHub workflow state, then dispatch a fresh work-on continuation
971
+ only when the issue is still non-terminal.
691
972
 
692
973
  **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.**
693
974
 
@@ -709,6 +990,8 @@ Agent(
709
990
  **Repository**: {GH_REPO}
710
991
  **Repo path**: {REPO_PATH}
711
992
 
993
+ **KNOWLEDGE GIST CAPABILITY**: This orchestration already probed it: `{FORGE_GIST_CAPABLE}`. Before invoking `/work-on`, run `export FORGE_GIST_CAPABLE={FORGE_GIST_CAPABLE}`. Do not re-probe or attempt Gist creation when it is `false`.
994
+
712
995
  **YOUR MISSION**: Invoke `/work-on` via the Skill tool and let it run to completion. `/work-on` is a self-contained routing loop that handles the ENTIRE pipeline: investigate → build (context → architect → implement → validate) → review (push → PR → /review-pr --auto-merge) → close (project board → trajectory log → worktree cleanup). Do NOT intervene, compensate, or manually close issues — `/work-on` handles everything including issue closure and label updates in its close phase.
713
996
 
714
997
  **CRITICAL — DO NOT STOP EARLY**: /work-on runs as a multi-phase routing loop. Each phase (investigate, build, review, close) returns an intermediate result — these are NOT completion signals. You are NOT done until the issue reaches a terminal state: `workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`. If /work-on returns after only one phase (e.g., investigation), you MUST invoke it again immediately — it will re-read GitHub state and continue to the next phase. Keep invoking /work-on until it reaches a terminal state. Never output 'done' or stop after an intermediate result.
@@ -721,7 +1004,9 @@ Agent(
721
1004
  - For satellite repo issues: `Skill(skill='work-on', args='{SATELLITE_PREFIX}:{NUMBER} --under-orchestration')` (prefix from forge.yaml → repos.satellites)
722
1005
  - The `--under-orchestration` flag tells `/work-on` to post its phase-entry `FORGE:HEARTBEAT` comments (Phases 0/1/3/5) — this orchestrator's Step 4B.5 stall detector depends on those timestamps. A solo `/work-on` run omits the flag and skips those writes entirely (see `commands/work-on.md` → Orchestration Flag).
723
1006
  - NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
724
- - **Temp files — ALWAYS use `mktemp`, NEVER a fixed path**: You are one of several agents running concurrently on this host and you share its `/tmp` with all of them. Any time you stage content in a temp file before passing it to `gh` (e.g. `--body-file`), create that path with `mktemp` (e.g. `BODY_FILE="$(mktemp)"`) — never a fixed literal like `/tmp/body.md` or `/tmp/issue.json`. A fixed path collides with another concurrently-running agent and can silently overwrite the content you staged (or you can silently overwrite theirs) before either of you reads it back — this has caused real cross-agent GitHub issue-body corruption. `mktemp` costs nothing and eliminates the collision. (forge#2198)
1007
+ - **File-backed GitHub bodies — entity scope plus read-back is mandatory**: Never stage a `gh --body-file` body at a generic shared `/tmp` path, including one made by bare `mktemp`, and never hand-roll a root-level path such as `/tmp_invbody_31076.txt`, which can hang unattended cleanup. Prefer the session scratchpad or a repo-relative scratch directory on Windows: a native Windows `gh` may not resolve Git Bash `/tmp` reliably. Create a filename that contains the target issue/PR number and an agent-unique token, then use `mktemp` for its random suffix. Put one caller-chosen marker such as `<!-- FORGE:BODY-INTEGRITY:${NUMBER}_investigator_${AGENT_TOKEN} -->` in the body. After every `gh issue create|edit` or `gh pr create|comment` using `--body-file`, re-read the target object and assert the exact marker is present; a mismatch is a hard error. Unique names reduce collisions, but only read-back detects a collision that substitutes plausible-looking content from another agent. Do not rely on eyeballing. (forge#2843, forge#2855) <!-- allowlist:check-command-side-effects -->
1008
+ - **GitHub secondary rate limit**: If a GitHub API call returns HTTP 403 with `secondary rate limit` in its response, do NOT retry it or start a polling loop. Stop GitHub content creation for this phase, report the status and response body to the orchestrator in your final result, and wait for a later batch resume. Retrying extends the throttle for every sibling.
1009
+ - **Temp files — ALWAYS use `mktemp`, NEVER hand-roll a path**: You are one of several agents running concurrently on this host and you share its `/tmp` with all of them. Any time you stage content in a temp file before passing it to `gh` (e.g. `--body-file`), create that path with `mktemp` (e.g. `BODY_FILE="$(mktemp)"`). Never write a temp file to a single-segment root path such as `/tmp_invbody_31076.txt`: Claude Code treats removing it as dangerous and requires an explicit approval that bypass mode cannot clear, hanging unattended runs. Also never use a fixed literal such as `/tmp/body.md` or `/tmp/issue.json`: a fixed path collides with another concurrently-running agent and can silently overwrite the content you staged (or you can silently overwrite theirs) before either of you reads it back. Safe: `BODY_FILE="$(mktemp)"`. Unsafe: `/tmp_invbody_31076.txt` (a missing slash that creates a root-level path) and `/tmp/body.md` (a fixed path). `mktemp` costs nothing and prevents both failures. (forge#2198, forge#2855)
725
1010
  - **`docker cp` — NEVER write into a bind-mounted shared container**: If the project you're working on runs its dev/test containers with a bind mount to the main (non-worktree) checkout, `docker cp` into that container writes through the mount into the main checkout — not your isolated per-issue worktree. Before running `docker cp` into any container, confirm its mount source is your own worktree, not a shared/main one; if you can't confirm that, don't assume an in-container test run reflects your feature branch. (forge#2198)
726
1011
  - NEVER target `main` for PRs targeting the default repo. Use `{STAGING_BRANCH}` for fast-lane issues, or `milestone/{slug}` for milestone issues.
727
1012
  - Satellite repos (MCP, n8n) have no staging branch — fast-lane PRs go to `main` for those.
@@ -911,15 +1196,28 @@ fi
911
1196
  echo "Dispatching ${#DISPATCH_NOW[@]} issue(s) this message (headroom was ${HEADROOM})"
912
1197
  ```
913
1198
 
914
- Spawn `Agent()` for every issue in `DISPATCH_NOW` (not the raw ready set) using the template above. After the batch spawn, increment `ACTIVE_DISPATCH_COUNT` by `${#DISPATCH_NOW[@]}` — this happens in addition to, not instead of, capturing each returned agent ID into `AGENT_ISSUE_MAP` above.
1199
+ Immediately before every Claude `Agent()` or OpenCode `task()` call, run `claim_conflicts_with_live_holder "{NUM}"`; when it returns success, append the issue to `DEFERRED_CLAIM_ISSUES[]` and do not make that call. Spawn `Agent()` for every remaining issue in `DISPATCH_NOW` (not the raw ready set) using the template above. After the batch spawn, increment `ACTIVE_DISPATCH_COUNT` by the number actually dispatched — this happens in addition to, not instead of, capturing each returned agent ID into `AGENT_ISSUE_MAP` above.
915
1200
 
916
1201
  If `DISPATCH_NOW` is empty (headroom is 0), do not spawn any agents this cycle — wait for the next `agent_completed` notification, which frees a slot and re-triggers this dispatch computation (Step 4B).
917
1202
 
918
1203
  ### Step 4B: Monitor completions and dispatch newly ready issues
919
1204
 
920
- 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).
1205
+ 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. In OpenCode, a native background `task` first returns a `state="running"` result and later injects a synthetic `<task id="..." state="completed">` or `state="error"` result into this same parent session. The later result is the completion event; the initial running result is not completion. Both Claude notifications and OpenCode task-result events must trigger the same per-issue handling immediately. **Do NOT use `sleep` loops, wait for the slowest sibling, or poll before processing an event.** When one arrives, look up which issue it belongs to — `AGENT_ISSUE_MAP` for a Claude `agent_completed` notification, `ENGINE_DISPATCH_MAP` for a background-Bash notification, or `OPENCODE_DISPATCH_MAP` for an OpenCode task result — 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. For an OpenCode completion, append this terminal record before releasing capacity:
1206
+
1207
+ ```bash
1208
+ if [ "${DRY_RUN:-false}" = "false" ]; then
1209
+ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DISPATCH -->
1210
+ \`\`\`json
1211
+ {\"runtime\":\"opencode\",\"child_session_id\":\"{TASK_ID}\",\"attempt\":${ATTEMPT},\"state\":\"{completed|error}\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}
1212
+ \`\`\`"
1213
+ fi
1214
+ ```
1215
+
1216
+ **Fallback**: if a runtime ever fails to deliver a background completion notification, read the latest `FORGE:DISPATCH` record and use the documented label-state recovery path, but do not convert the normal OpenCode path to foreground tasks or a wave barrier.
1217
+
1218
+ For an OpenCode completion, retain `OPENCODE_DISPATCH_MAP[{NUMBER}]` until the terminal `FORGE:DISPATCH` record above has been posted successfully. Then remove that map entry and release capacity. This makes a restart deterministic: a latest `running` record restores the task-to-issue association, while a latest `completed` or `error` record cannot release the same slot twice.
921
1219
 
922
- **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 or fallback-dispatched (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=...)` (or, per item 2b below, the fresh `Agent(...)` fallback dispatch) call is issued — either re-occupies a worker slot exactly like a fresh dispatch does. (Step 4B.5's TIME-BASED hang detector — an agent that never completes at all, as opposed to one that completes at `workflow:engine-error` — remains `Agent(resume=...)`-specific and out of scope for this fix: an engine-dispatched issue that silently hangs still surfaces only via the standard stall-detection alert, and `forgedock resume-stalled` remains available as a separate manual/scripted recovery path for that case. The completion-triggered case — an engine-dispatched issue that DOES complete, at `workflow:engine-error` with empty committed state — is now auto-fallen-back to Agent-spawn by item 2b below, fixed forge#2743.)
1220
+ **Concurrency slot release (MANDATORY — first action on every completion)** <!-- Added: forge#1912 -->: The instant a completion notification arrives — `agent_completed`, a background-Bash completion, or an OpenCode `task` result with `state="completed"`/`state="error"` — 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 or fallback-dispatched (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=...)` or a fresh OpenCode `task(background=true)` continuation is issued — either re-occupies a worker slot exactly like a fresh dispatch does. (Step 4B.5's TIME-BASED hang detector — an agent that never completes at all, as opposed to one that completes at `workflow:engine-error` — remains resume-specific and out of scope for this fix: an engine-dispatched issue that silently hangs still surfaces only via the standard stall-detection alert, and `forgedock resume-stalled` remains available as a separate manual/scripted recovery path for that case. The completion-triggered case — an engine-dispatched issue that DOES complete at `workflow:engine-error` with empty committed state — is still auto-fallen-back by item 2b below, fixed forge#2743.)
923
1221
 
924
1222
  **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.
925
1223
 
@@ -980,7 +1278,7 @@ classify_predecessor_state() {
980
1278
 
981
1279
  A GATED predecessor whose PR later merges reclassifies to DONE the next time `classify_predecessor_state` runs (its label flips to `workflow:merged`) — this is exactly what the merge-triggered wake check (item 6.6 below) relies on.
982
1280
 
983
- **File-overlap edge re-verification** <!-- Added: forge#1904 --> — `classify_predecessor_state()` answers "is the predecessor resolved enough to proceed," but it says nothing about whether a specific Layer 1/2/3 file-overlap edge (`EDGE_KIND`/`EDGE_FILES`, tagged in `phase-3-dependency.md` Step 3C/3D) was ever real. Both were built from a **pre-build guess** — the predecessor's `FORGE:INVESTIGATOR` "Affected Files" list, or a raw issue-body parse if no investigation existed yet. Once a predecessor reaches FAILED or GATED, that guess must be checked against ground truth (the predecessor's actual PR diff, or the absence of any PR) before the edge is allowed to keep blocking a dependent. Without this, a predecessor that reaches `needs-human` or `workflow:invalid` having never touched the guessed shared file (or never opened a PR at all) leaves its dependents gated/skipped on a conflict that never existed.
1281
+ **File-overlap edge re-verification** <!-- Added: forge#1904 --> — `classify_predecessor_state()` answers "is the predecessor resolved enough to proceed," but it says nothing about whether a specific Layer 1/2/3 file-overlap edge (`EDGE_KIND`/`EDGE_FILES`, tagged in `phase-3-dependency.md` Step 3C/3D) was ever real. Both were built from a **pre-build guess** — the predecessor's `FORGE:INVESTIGATOR` "Affected Files" list, or a raw issue-body parse if no investigation existed yet. Once a predecessor reaches FAILED, GATED, **or DONE**, that guess must be checked against ground truth (the predecessor's actual PR diff, or the absence of any PR) before the edge is allowed to keep blocking a dependent or to inject a same-file brief. Without this, a predecessor that reaches `needs-human` or `workflow:invalid` having never touched the guessed shared file (or never opened a PR at all) leaves its dependents gated/skipped on a conflict that never existed — and on the happy path, an over-serialized chain is never reconciled at all and stays serialized for its entire life (forge#2848).
984
1282
 
985
1283
  ```bash
986
1284
  verify_file_overlap_edge() {
@@ -1010,9 +1308,25 @@ verify_file_overlap_edge() {
1010
1308
  # a MERGED PR would already classify the predecessor DONE, not FAILED/GATED — so this filter
1011
1309
  # also guarantees `gh pr diff` below targets a live, still-open branch rather than an
1012
1310
  # abandoned PR whose branch may since have been force-deleted (e.g. by /cleanup).
1311
+ #
1312
+ # forge#2848 — the DONE call site widens what this can surface: for a DONE predecessor
1313
+ # the live PR is MERGED, not OPEN. That is the *better* case, not a problem — a merged
1314
+ # PR's diff is ground truth rather than a still-moving branch, and `gh pr diff` serves it
1315
+ # from the API, so it keeps working after the head branch is deleted on merge. The
1316
+ # CLOSED-unmerged exclusion is unchanged and still correct for all three call sites.
1013
1317
  local PRED_PR
1318
+ local PRED_PR_EXIT
1014
1319
  PRED_PR=$(gh pr list -R {GH_REPO} --state all --search "\"Closes #${PRED}\" in:body" \
1015
- --json number,state --jq '[.[] | select(.state != "CLOSED")][0].number // empty' 2>/dev/null || echo "")
1320
+ --json number,state --jq '[.[] | select(.state != "CLOSED")][0].number // empty' 2>/dev/null)
1321
+ PRED_PR_EXIT=$?
1322
+
1323
+ if [ "$PRED_PR_EXIT" -ne 0 ]; then
1324
+ # Fail-safe: could not determine whether a live predecessor PR exists (transient API
1325
+ # error, rate limit, or search-index lag). Keep the edge rather than conflating a failed
1326
+ # lookup with confirmed absence of a PR and dropping a real conflict.
1327
+ echo "KEEP"
1328
+ return
1329
+ fi
1016
1330
 
1017
1331
  if [ -z "$PRED_PR" ]; then
1018
1332
  # Predecessor reached a terminal/gated state having never opened a PR (or only ever had
@@ -1062,16 +1376,28 @@ verify_file_overlap_edge() {
1062
1376
  }
1063
1377
  ```
1064
1378
 
1065
- **Call sites**: item 6 (FAILED handling, below) and item 6.5 (GATED handling, below) both call this helper — once per `EDGE_KIND`-tagged predecessor edge — before cascading a skip or tracking `blocked-on-human-merge`. `phase-3-dependency.md`'s wake/compaction reconstruction block calls the identical logic (mirrored verbatim, not re-derived) for the case where the predecessor resolves after the orchestrator session has already ended — see that file's "Orchestrator state reconstruction on wake / after compaction" section. This mirroring is deliberate: keeping the check in exactly one function definition, called (not reimplemented) from both files, avoids the same drift class forge#1812 had to fix once already for DONE/GATED/FAILED classification itself.
1379
+ **Call sites**: item 6 (FAILED handling, below), item 6.5 (GATED handling, below), and the DONE arm of the core streaming dispatch loop (below — see "DONE-path edge re-verification") all call this helper — once per `EDGE_KIND`-tagged predecessor edge — before cascading a skip or tracking `blocked-on-human-merge`. `phase-3-dependency.md`'s wake/compaction reconstruction block calls the identical logic (mirrored verbatim, not re-derived) for the case where the predecessor resolves after the orchestrator session has already ended — see that file's "Orchestrator state reconstruction on wake / after compaction" section. This mirroring is deliberate: keeping the check in exactly one function definition, called (not reimplemented) from both files, avoids the same drift class forge#1812 had to fix once already for DONE/GATED/FAILED classification itself.
1066
1380
 
1067
1381
  **Recursive cascade note (item 6 only)**: when a dependent `DEP` is itself marked "skipped — dependency failed" (edge confirmed `KEEP`, not dropped), `DEP` becomes the new FAILED anchor for its own direct dependents — re-run this same `verify_file_overlap_edge` check at that next hop too, rather than assuming every transitive descendant is unconditionally skipped. A grandchild dependent whose only path to the failure runs through a spurious (droppable) edge one hop down must not be skipped just because its parent was.
1068
1382
 
1069
- **Never called from**: the DONE branch (that predecessor's code is already merged — the edge, if real, is already resolved) or the `IN_PROGRESS` branch (nothing to re-verify yet — the predecessor hasn't concluded).
1383
+ **Never called from**: the `IN_PROGRESS` branch (nothing to re-verify yet — the predecessor hasn't concluded).
1384
+
1385
+ **DONE-path edge re-verification** <!-- Added: forge#2848 --> — the DONE branch *does* call this helper, and it is the only call site that fires on the happy path. Be precise about what it buys, because the obvious reading is wrong:
1386
+
1387
+ **It does NOT release the dependent.** A DONE predecessor already satisfies its own edge — see the `;; # satisfied` arm and the `ALL_PREDS_DONE` gate below. Dropping an edge whose predecessor is already DONE releases nobody, so re-verification alone cannot re-parallelize anything. What it actually buys is two things:
1388
+
1389
+ 1. **Suppressing a spurious same-file brief.** The DONE arm spends up to three `gh api` comment fetches per edge and then injects a `SAME_FILE_BRIEF` excerpt into the dependent's dispatch context. When the predecessor's real diff never touched the guessed file, that brief is not merely wasted tokens — it actively misleads the agent into coordinating on a file the predecessor never wrote. Verifying **before** those fetches skips all three calls and the brief.
1390
+ 2. **Producing the disproof signal that enables re-derivation.** This is the part that actually re-parallelizes. A `DROP` on a `body-fallback`-provenance edge is evidence that the whole cohort's extraction is suspect: the first real diff has just contradicted the guess the chain was built on. By that point the still-blocked descendants have been investigated and possibly contracted, so re-running Layer 1 extraction against those now-higher-provenance sources and dropping edges that no longer hold is what collapses the chain. This is the step an operator performed by hand in the batch that motivated forge#2848; wiring it here makes it automatic.
1070
1391
 
1071
1392
  **Core streaming dispatch loop**: After processing each agent completion, check the DAG for newly unblocked issues. If any issue now has all predecessors classified `DONE`, dispatch it immediately (run Steps 4A.pre.0, 4A.pre, and 4A for the newly ready issues). This is the key difference from the wave model — issues dispatch as soon as their specific predecessors complete, not after an entire group finishes.
1072
1393
 
1073
1394
  ```bash
1074
1395
  # After each agent completion, check for newly ready issues:
1396
+ READINESS_RESCAN=true
1397
+ while [ "$READINESS_RESCAN" = "true" ]; do
1398
+ # Re-derivation can remove an unresolved predecessor from an issue that this
1399
+ # pass has already visited. Repeat the scan so it can dispatch this cycle.
1400
+ READINESS_RESCAN=false
1075
1401
  for BLOCKED_NUM in {all_blocked_issue_numbers}; do
1076
1402
  ALL_PREDS_DONE=true
1077
1403
  ANY_PRED_GATED=false
@@ -1089,6 +1415,100 @@ for BLOCKED_NUM in {all_blocked_issue_numbers}; do
1089
1415
  EDGE_TYPE="${EDGE_KIND["${PRED}:${BLOCKED_NUM}"]:-}"
1090
1416
  if [ -n "$EDGE_TYPE" ] && [ -z "${EDGE_BRIEFED["${PRED}:${BLOCKED_NUM}"]:-}" ]; then
1091
1417
  EDGE_BRIEFED["${PRED}:${BLOCKED_NUM}"]=1
1418
+
1419
+ # DONE-path edge re-verification (forge#2848). Runs FIRST — before the three
1420
+ # `gh api` brief fetches below — so a disproven edge costs zero extra API calls
1421
+ # and injects no brief. EDGE_BRIEFED is already set above, so a DROP is not
1422
+ # retried on the next completion cycle.
1423
+ #
1424
+ # Fail-safe direction is unchanged: verify_file_overlap_edge returns KEEP on any
1425
+ # fetch failure, so a transient API error can only ever preserve the brief, never
1426
+ # silently suppress a real one.
1427
+ EDGE_VERDICT=$(verify_file_overlap_edge "$PRED" "$BLOCKED_NUM")
1428
+ if [ "$EDGE_VERDICT" = "DROP" ]; then
1429
+ echo "DONE-path re-verification: edge #${PRED} → #${BLOCKED_NUM} (${EDGE_TYPE}, files: ${EDGE_FILES["${PRED}:${BLOCKED_NUM}"]:-?}) DROPPED — #${PRED}'s merged diff never touched the guessed file. Skipping same-file brief."
1430
+
1431
+ # Leave SAME_FILE_BRIEF[$BLOCKED_NUM] UNSET rather than setting it empty —
1432
+ # the dispatch-context builder treats unset as the no-op case, and an empty
1433
+ # string would render an orphaned "shared file context" heading with nothing
1434
+ # under it.
1435
+
1436
+ # Cohort re-derivation (forge#2848) — the step that actually releases
1437
+ # dependents. Gated on `body-fallback` provenance only: a DROP against a
1438
+ # contract- or investigation-derived list is a one-off miss, but a DROP
1439
+ # against a raw-issue-body scrape is evidence the whole cohort's extraction
1440
+ # is suspect (see phase-3-dependency.md Layer 4's cohort-confidence guidance).
1441
+ if [ "${FILE_SOURCE[$PRED]:-}" = "body-fallback" ] && [ -n "$AFFECTED_FILES_SCRIPT" ]; then
1442
+ # Re-derive only direct descendants of this DONE predecessor that
1443
+ # are still blocked now. This intentionally excludes unrelated
1444
+ # blocked issues and descendants whose PRED edge was already removed.
1445
+ STILL_BLOCKED_DESCENDANTS=()
1446
+ for CANDIDATE in {all_blocked_issue_numbers}; do
1447
+ case " ${PREDECESSORS[$CANDIDATE]:-} " in
1448
+ *" $PRED "*) STILL_BLOCKED_DESCENDANTS+=("$CANDIDATE") ;;
1449
+ esac
1450
+ done
1451
+ for DESC in "${STILL_BLOCKED_DESCENDANTS[@]}"; do
1452
+ # Memoize conclusive results per descendant per batch. An inconclusive
1453
+ # extraction gets one retry so a transient failure or pre-contract
1454
+ # attempt does not permanently foreclose re-derivation. The two-attempt
1455
+ # cap preserves an O(descendants) API budget for the hot dispatch loop.
1456
+ [ -n "${EDGE_REDERIVED[$DESC]:-}" ] && continue
1457
+ REDERIVE_ATTEMPTS=${EDGE_REDERIVE_ATTEMPTS[$DESC]:-0}
1458
+ [ "$REDERIVE_ATTEMPTS" -ge 2 ] && continue
1459
+ EDGE_REDERIVE_ATTEMPTS[$DESC]=$((REDERIVE_ATTEMPTS + 1))
1460
+
1461
+ # Re-run Layer 1 extraction. By now DESC has been investigated and
1462
+ # possibly contracted, so this typically returns a higher-provenance
1463
+ # list than the body scrape the original edge was built from. Resolve
1464
+ # the helper through the runtime path, never a bare `bash scripts/...`
1465
+ # (#2794) — same resolver precedence as phase-3-dependency.md Layer 1.
1466
+ REDERIVE_OUT=$(bash "$AFFECTED_FILES_SCRIPT" "$DESC" -R {GH_REPO})
1467
+ REDERIVE_PROV=$(echo "$REDERIVE_OUT" | head -1 | sed 's/^PROVENANCE=//')
1468
+ REDERIVE_FILES=$(echo "$REDERIVE_OUT" | tail -n +2)
1469
+
1470
+ # Fail-safe: only act on a CONFIRMED-empty overlap. `error` means the
1471
+ # extraction was inconclusive (a `gh` call failed — forge#2504), and
1472
+ # `none`/`body-fallback` mean we learned nothing better than what we
1473
+ # already had. In all three cases keep every edge untouched.
1474
+ case "$REDERIVE_PROV" in
1475
+ contract-deliverables|affected-files-section) EDGE_REDERIVED[$DESC]=1 ;;
1476
+ *) echo " re-derivation for #${DESC} inconclusive (provenance: ${REDERIVE_PROV}) — keeping all edges"; continue ;;
1477
+ esac
1478
+
1479
+ # Re-derive only PRED's edge into DESC. PRED is the DONE predecessor
1480
+ # whose merged diff just disproved this edge; other predecessors may
1481
+ # still be building and must retain their serialization edges.
1482
+ for DESC_PRED in "$PRED"; do
1483
+ [ -z "${EDGE_KIND["${DESC_PRED}:${DESC}"]:-}" ] && continue
1484
+ STILL_OVERLAPS=false
1485
+ for EF in ${EDGE_FILES["${DESC_PRED}:${DESC}"]:-}; do
1486
+ [ -z "$EF" ] && continue
1487
+ EF_CLEAN="${EF%/}"
1488
+ EF_ESCAPED=$(printf '%s' "$EF_CLEAN" | sed 's/[.[\*^$()+?{|]/\\&/g')
1489
+ if echo "$REDERIVE_FILES" | grep -qE "(^|/)${EF_ESCAPED}(/|$)"; then
1490
+ STILL_OVERLAPS=true
1491
+ break
1492
+ fi
1493
+ done
1494
+ if [ "$STILL_OVERLAPS" = "false" ]; then
1495
+ echo " re-derived edge #${DESC_PRED} → #${DESC}: DROPPED — '${EDGE_FILES["${DESC_PRED}:${DESC}"]}' absent from #${DESC}'s refreshed ${REDERIVE_PROV} file list"
1496
+ unset 'EDGE_KIND['"${DESC_PRED}:${DESC}"']'
1497
+ unset 'EDGE_FILES['"${DESC_PRED}:${DESC}"']'
1498
+ # Remove DESC_PRED from PREDECESSORS[$DESC] so the ALL_PREDS_DONE
1499
+ # gate below stops waiting on it. DESC becomes dispatch-eligible on
1500
+ # this same cycle if that was its last outstanding predecessor.
1501
+ PREDECESSORS[$DESC]=$(echo "${PREDECESSORS[$DESC]}" | tr ' ' '\n' | grep -vx "$DESC_PRED" | tr '\n' ' ')
1502
+ READINESS_RESCAN=true
1503
+ fi
1504
+ done
1505
+ done
1506
+ fi
1507
+
1508
+ # Edge disproven — nothing further to do for this (PRED, BLOCKED_NUM) pair.
1509
+ continue
1510
+ fi
1511
+
1092
1512
  SHARED_FILE="${EDGE_FILES["${PRED}:${BLOCKED_NUM}"]:-}"
1093
1513
 
1094
1514
  # Prefer FORGE:BUILDER (the concrete "what changed" record). Fall back to
@@ -1140,6 +1560,7 @@ for BLOCKED_NUM in {all_blocked_issue_numbers}; do
1140
1560
  # Do NOT dispatch. Do NOT mark skipped. Tracked via item 6.5.
1141
1561
  fi
1142
1562
  done
1563
+ done
1143
1564
  # Run Steps 4A.pre.0 → 4A.pre → 4A for newly ready issues. Step 4A's own dispatch-batch
1144
1565
  # computation (the HEADROOM/DISPATCH_NOW logic, forge#1912) is what actually caps this —
1145
1566
  # "newly ready" here just means "eligible," not "guaranteed to dispatch this cycle." Any
@@ -1299,11 +1720,7 @@ done
1299
1720
  # After CLAIM_RELEASED for issue NUM:
1300
1721
  # Read all active FORGE:CLAIM annotations from coordination issue
1301
1722
  if [ -n "${FORGE_COORD_ISSUE:-}" ] && [ -n "${COORD_NUM:-}" ]; then
1302
- ACTIVE_CLAIMS=$(gh api repos/{GH_REPO}/issues/${COORD_NUM}/comments \
1303
- --jq '[.[] | select(.body | contains("<!-- FORGE:CLAIM -->")) |
1304
- select(.body | contains("<!-- FORGE:CLAIM_RELEASED -->") | not)] |
1305
- map({holder: (.body | capture("\\*\\*Holder\\*\\*: (?P<h>[^\\n]+)").h),
1306
- files: (.body | capture("\\*\\*Files\\*\\*: (?P<f>[^\\n]+)").f)})' 2>/dev/null || echo '[]')
1723
+ ACTIVE_CLAIMS=$(read_active_claims "$COORD_NUM" 2>/dev/null || echo '[]')
1307
1724
  # For each still-blocked issue in a Layer-2/4 pair: check if its claim's file set
1308
1725
  # is disjoint from all remaining active claims. If so, mark it ready.
1309
1726
  # (Layer-1 and Layer-3 edges are never relaxed — this check is Layer-2/4 only.)
@@ -1367,7 +1784,7 @@ done
1367
1784
  run_in_background=true,
1368
1785
  prompt="You are remediating GitHub PR #{GATING_PR} for the {PROJECT_NAME} project (repo: {GH_REPO}), which is currently held at `needs-human` on its linked issue #{PRED}.
1369
1786
 
1370
- **YOUR MISSION**: Invoke `Skill(skill='work-on', args='{GATING_PR} --remediate --issue {PRED} --repo {GH_REPO} --gh-flag {GH_FLAG}')` and let it run to completion. This is a self-contained flow: it checks out the PR branch, fixes any fixable review findings, re-reviews, and either auto-lands the PR, holds it at `workflow:awaiting-merge` for a human, re-escalates back to `needs-human`, or reports the block as policy-level and unfixable. Do NOT intervene manually — do not run raw git/gh commands yourself.
1787
+ **YOUR MISSION**: Invoke `Skill(skill='work-on', args='{GATING_PR} --remediate --issue {PRED} --repo {GH_REPO} --gh-flag {GH_FLAG}')` and let it run to completion. This is a self-contained flow: it classifies the gate first, replaces `needs-human` with `workflow:in-review` only for fixable remediation, checks out the PR branch, fixes any fixable review findings, re-reviews, and either auto-lands the PR, holds it at `workflow:awaiting-merge` for a human, re-escalates back to `needs-human`, or reports the block as policy-level and unfixable. Do NOT intervene manually — do not run raw git/gh commands yourself.
1371
1788
 
1372
1789
  **DO NOT STOP EARLY**: if the Skill call returns without a terminal `REMEDIATE_RESULT.status`, invoke it again — it re-reads GitHub state and resumes. Terminal statuses are: `COMPLETE`, `ALREADY_DONE`, `UNFIXABLE`, `BLOCKED`.
1373
1790
 
@@ -1513,6 +1930,7 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1513
1930
  ```
1514
1931
  ✓ #{NUMBER} — {title} → PR #{PR} merged to {target}
1515
1932
  ✗ #{NUMBER} — {title} → {reason for failure}
1933
+ ⚠ #{NUMBER} — {title} → PR #{PR} blocked: review panel degraded (full fresh-session re-review required)
1516
1934
  ⚠ #{NUMBER} — {title} → PIPELINE BYPASS (no /work-on — PR invalid)
1517
1935
  ⏸ #{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)
1518
1936
  🔗 #{NUMBER} — {title} → blocked-on-human-merge (gated by #{PRED}, will auto-dispatch on #{PRED} merge)
@@ -1538,6 +1956,8 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1538
1956
  dependent itself has no problem at all, it is simply waiting on someone else's merge. See Phase 6's
1539
1957
  "Merge-Ready" report section (`phase-6-report.md` Step 6A.5/6B) for the batch-level rollup.
1540
1958
 
1959
+ Before printing a terminal status for a PR, inspect its labels. If it has `review-degraded`, print the dedicated blocked line above and retain the issue's `needs-human` state. Never report a degraded review as merged or awaiting-merge.
1960
+
1541
1961
  9. **Run staging integrity check** (from Step 4A-pre) if the completed agent merged a PR targeting staging.
1542
1962
 
1543
1963
  **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 -->
@@ -1698,6 +2118,56 @@ that mode only exists as the explicit, one-shot `--include-backlog` opt-in at Ph
1698
2118
  never silently widen to the full backlog regardless of any flag, since nothing here is a
1699
2119
  human-in-the-loop request. <!-- Added: forge#2628 -->
1700
2120
 
2121
+ ### Step 4C.0: Full-repository actionable-issue intake (`policy: all` only)
2122
+
2123
+ An operator-authorised `orchestration.cascade.policy: all` run must also check for newly-created
2124
+ open issues that its own agents did not report and that lack `review-finding`. Run this sweep
2125
+ after every five terminal completions, before the next dispatch cycle. It is not a cascade
2126
+ replacement: Step 4C's `BATCH_T0` filter still governs review-finding recursion.
2127
+
2128
+ At batch start, seed `SEEN_OPEN_ISSUES` with the initial resolved issue set. On each sweep, list
2129
+ the complete open set (`gh issue list --state open --limit 500 --json number,title,labels`), diff
2130
+ it against `SEEN_OPEN_ISSUES`, then mark every returned number seen whether or not it is admitted.
2131
+ For each newly-seen issue, add it to the normal Phase 3 DAG intake only when it has no terminal or
2132
+ in-progress workflow label, `needs-human`, or `workflow:decomposed`; preserve its labels and
2133
+ record why every excluded issue was skipped. Do not filter on `review-finding`, author, creation
2134
+ time, or agent trajectory. This is how CI-created issues enter an all-policy run without silently
2135
+ adopting pre-existing work in other policies.
2136
+
2137
+ ```bash
2138
+ if [ "$CASCADE_POLICY_NAME" = "all" ] && [ "$((TERMINAL_COMPLETIONS % 5))" -eq 0 ] && \
2139
+ [ "$TERMINAL_COMPLETIONS" -gt "$FULL_REPO_SWEEP_COUNT" ]; then
2140
+ FULL_REPO_SWEEP_COUNT=$TERMINAL_COMPLETIONS
2141
+ FULL_REPO_OPEN=$(gh issue list -R {GH_REPO} --state open --limit 500 --json number,title,labels)
2142
+ while IFS= read -r ISSUE; do
2143
+ NUM=$(echo "$ISSUE" | jq -r '.number')
2144
+ [ -n "${SEEN_OPEN_ISSUES[$NUM]:-}" ] && continue
2145
+ SEEN_OPEN_ISSUES[$NUM]=1
2146
+ LABELS=$(echo "$ISSUE" | jq -r '[.labels[].name] | join(",")')
2147
+ if echo "$LABELS" | grep -qE '(^|,)(workflow:(merged|invalid|awaiting-merge|building|in-review|decomposed)|needs-human)(,|$)'; then
2148
+ echo "Full-repo intake: #${NUM} seen but not admitted (terminal, in-progress, or human-gated)"
2149
+ continue
2150
+ fi
2151
+ FULL_REPO_INTAKE+=("$NUM")
2152
+ echo "Full-repo intake: admitted newly-seen actionable issue #${NUM}"
2153
+ done < <(echo "$FULL_REPO_OPEN" | jq -c '.[]')
2154
+ # Feed FULL_REPO_INTAKE through the existing Phase 3 extraction/DAG/claims gates.
2155
+ fi
2156
+ ```
2157
+
2158
+ ### Step 4C.1: Mechanical automated-alert deduplication (`policy: all` opt-in)
2159
+
2160
+ `orchestration.cascade.dedup_automated: true` permits a narrow exception to the normal
2161
+ no-dedup rule for newly-seen automated alerts. Select one lowest-numbered canonical issue and
2162
+ close another issue only when all of these are true: both authors are bot/app accounts; normalized
2163
+ titles are identical; the generator identity is identical; the triggering condition is identical;
2164
+ and the closing comment links the retained canonical issue. Never apply title similarity to a
2165
+ human-authored report, or when generator/trigger provenance is absent or differs. The reference
2166
+ predicate is `canDeduplicateAutomatedAlert()` in `bin/engine/admission.mjs`.
2167
+
2168
+ This opt-in handles mechanically identical alerts, not substantive bug adjudication. All other
2169
+ possible duplicates remain separate and go through investigation.
2170
+
1701
2171
  ```bash
1702
2172
  # Method 1: Read TRAJECTORY comments from completed issues for "Finding issues" row
1703
2173
  for NUM in {completed_issue_number}; do
@@ -1718,13 +2188,37 @@ gh issue list -R {GH_REPO} --state open --search "label:review-finding created:>
1718
2188
 
1719
2189
  **If review-finding issues were spawned:**
1720
2190
 
2191
+ **Amplification accounting (MANDATORY, before cascade admission):** Count every finding created by this batch, including findings later deferred by any cascade rule. After each merged unit, compute `FINDINGS_SPAWNED / MERGED_UNITS`, append the value to `AMPLIFICATION_RATIO_HISTORY`, and print a convergence warning when the latest `CONVERGENCE_WINDOW` observations are all `>= 1.0`. A high ratio is a signal, not a failure: valuable deep review findings can legitimately raise it.
2192
+
2193
+ For each finding, extract its source PR from `**Source**: PR #N` and increment `FINDINGS_BY_SOURCE_PR[N]`. Classify it as a **same-lineage refinement** only when that source PR closes a `review-finding` issue and both that parent finding and this finding name the same affected file; otherwise classify it as **new surface**. Record the finding number in `REFINEMENT_FINDINGS` or `NEW_SURFACE_FINDINGS` respectively. If provenance or a file path cannot be established, classify as new surface conservatively; never suppress it as a refinement.
2194
+
2195
+ When `CASCADE_MAX_AMPLIFICATION` is not `off`, and the current ratio is greater than that ceiling, defer only newly discovered entries in `REFINEMENT_FINDINGS` with reason `amplification bound exceeded (same-lineage refinement)`. Add them to `DEFERRED_FINDINGS` and `DEFERRED_REASONS` so Step 4F re-evaluates them after the batch drains. Do not apply this bound to new-surface findings, P1/P2 findings, or any finding when the option is `off`.
2196
+
2197
+ ```bash
2198
+ # Run once for each completed issue before collecting its findings. Only merged
2199
+ # units advance the denominator; invalid/skipped units do not imply delivered work.
2200
+ if gh issue view "$NUM" -R {GH_REPO} --json labels \
2201
+ --jq '[.labels[].name | select(. == "workflow:merged")] | length' | grep -qx '1'; then
2202
+ MERGED_UNITS=$((MERGED_UNITS + 1))
2203
+ AMPLIFICATION_RATIO=$(awk "BEGIN { printf \"%.2f\", $FINDINGS_SPAWNED / $MERGED_UNITS }")
2204
+ AMPLIFICATION_RATIO_HISTORY+=("$AMPLIFICATION_RATIO")
2205
+ if [ "${#AMPLIFICATION_RATIO_HISTORY[@]}" -ge "$CONVERGENCE_WINDOW" ]; then
2206
+ RECENT_RATIOS=("${AMPLIFICATION_RATIO_HISTORY[@]: -$CONVERGENCE_WINDOW}")
2207
+ if printf '%s\n' "${RECENT_RATIOS[@]}" | awk '$1 < 1 { exit 1 }'; then
2208
+ echo "CONVERGENCE WARNING: amplification has remained >= 1.0 for ${CONVERGENCE_WINDOW} merged units (${AMPLIFICATION_RATIO} current). This can be productive review refinement, but the batch is not shrinking."
2209
+ fi
2210
+ fi
2211
+ fi
2212
+
2213
+ ```
2214
+
1721
2215
  **Cascade control (MANDATORY — run before folding findings into the DAG):**
1722
2216
 
1723
2217
  For each spawned finding, determine whether it should be **executed** or **deferred**:
1724
2218
 
1725
2219
  **Evaluation order** (first matching rule wins):
1726
2220
  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).
1727
- 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 -->
2221
+ 1. **Generation ≥ 2** (always defer from individual autonomous dispatch, even for P1/P2, for mid-run cascade — see scope note below): Finding was spawned by an issue that was itself a review-finding. A P3 finding at or below the finite `orchestration.cascade.batch_max_generation` ceiling (default 2) is retained only as a candidate for the bounded P3 batch pass; it remains deferred if no batch forms. A formed batch records `FORGE:BATCH_MAX_GENERATION` and its generation-2+ members. No P1/P2, or finding above that ceiling, takes this exception. Rationale: gen-2+ individual cascade is theoretically unbounded — cap it here while permitting one auditable aggregation unit. **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. `batch_max_generation` is separate from `max_generation`, and remains finite even under `policy: all`. <!-- Added: forge#2231, forge#2849 -->
1728
2222
  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.
1729
2223
  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).
1730
2224
  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).
@@ -1760,6 +2254,22 @@ ALL_BATCH_FILES=$(echo "$ALL_BATCH_FILES" | sort -u | grep -v '^$')
1760
2254
  ```
1761
2255
 
1762
2256
  ```bash
2257
+ # Use the same bounded ancestor walk as Phase 1 so batching records actual depth,
2258
+ # not just whether the immediate source is a review finding.
2259
+ compute_finding_generation() {
2260
+ local body="$1" generation=1 hops=0 source_num source_data
2261
+ while [ "$hops" -lt 10 ]; do
2262
+ source_num=$(echo "$body" | grep -ioE 'spawned from issue #[0-9]+|source issue[: #]+[0-9]+' | head -1 | grep -oE '[0-9]+$')
2263
+ [ -z "$source_num" ] && break
2264
+ source_data=$(gh issue view "$source_num" -R {GH_REPO} --json labels,body 2>/dev/null) || break
2265
+ echo "$source_data" | jq -e '[.labels[].name] | index("review-finding")' >/dev/null 2>&1 || break
2266
+ generation=$((generation + 1))
2267
+ body=$(echo "$source_data" | jq -r '.body')
2268
+ hops=$((hops + 1))
2269
+ done
2270
+ echo "$generation"
2271
+ }
2272
+
1763
2273
  # For each finding, check its priority label and generation
1764
2274
  # NOTE: DEFERRED_FINDINGS, QUEUED_FINDINGS, and DEFERRED_REASONS are declared at
1765
2275
  # batch scope in Step 4A.pre — do NOT re-initialize them here (Step 4C runs per-agent).
@@ -1767,6 +2277,45 @@ for FINDING_NUM in {spawned_finding_numbers}; do
1767
2277
  FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,updatedAt \
1768
2278
  --jq '{labels: [.labels[].name], title: .title, body: .body, updatedAt: .updatedAt}')
1769
2279
 
2280
+ # Count each finding once and retain source provenance for the final per-PR
2281
+ # breakdown. Missing source/file evidence fails open as new surface.
2282
+ FINDING_BODY=$(echo "$FINDING_DATA" | jq -r '.body')
2283
+ SOURCE_PR=""
2284
+ if [[ "$FINDING_BODY" =~ \*\*[Ss]ource\*\*:[[:space:]]*[Pp][Rr][[:space:]]*\#([0-9]+) ]]; then
2285
+ SOURCE_PR="${BASH_REMATCH[1]}"
2286
+ fi
2287
+ if [ -z "${AMPLIFICATION_FINDING_SEEN[$FINDING_NUM]:-}" ]; then
2288
+ AMPLIFICATION_FINDING_SEEN[$FINDING_NUM]=1
2289
+ FINDINGS_SPAWNED=$((FINDINGS_SPAWNED + 1))
2290
+ [ -n "$SOURCE_PR" ] && FINDINGS_BY_SOURCE_PR[$SOURCE_PR]=$(( ${FINDINGS_BY_SOURCE_PR[$SOURCE_PR]:-0} + 1 ))
2291
+ fi
2292
+ FINDING_IS_REFINEMENT=false
2293
+ FINDING_FILE_FOR_LINEAGE=$(echo "$FINDING_BODY" | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
2294
+ if [ -n "$SOURCE_PR" ] && [ -n "$FINDING_FILE_FOR_LINEAGE" ]; then
2295
+ SOURCE_ISSUE=""
2296
+ SOURCE_PR_BODY=$(gh pr view "$SOURCE_PR" -R {GH_REPO} --json body --jq '.body' 2>/dev/null || echo "")
2297
+ SOURCE_PR_BODY_LOWER=$(echo "$SOURCE_PR_BODY" | tr '[:upper:]' '[:lower:]')
2298
+ if [[ "$SOURCE_PR_BODY_LOWER" =~ (close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]+\#([0-9]+) ]]; then
2299
+ SOURCE_ISSUE="${BASH_REMATCH[3]}"
2300
+ fi
2301
+ if [ -n "$SOURCE_ISSUE" ]; then
2302
+ PARENT_DATA=$(gh issue view "$SOURCE_ISSUE" -R {GH_REPO} --json labels,body \
2303
+ --jq '{labels: [.labels[].name], body: .body}' 2>/dev/null || echo '{}')
2304
+ PARENT_FILE=$(echo "$PARENT_DATA" | jq -r '.body // ""' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
2305
+ if echo "$PARENT_DATA" | jq -e '[.labels[] | select(. == "review-finding")] | length > 0' >/dev/null && \
2306
+ [ "$PARENT_FILE" = "$FINDING_FILE_FOR_LINEAGE" ]; then
2307
+ FINDING_IS_REFINEMENT=true
2308
+ REFINEMENT_FINDINGS[$FINDING_NUM]="$SOURCE_PR"
2309
+ else
2310
+ NEW_SURFACE_FINDINGS[$FINDING_NUM]="$SOURCE_PR"
2311
+ fi
2312
+ else
2313
+ NEW_SURFACE_FINDINGS[$FINDING_NUM]="$SOURCE_PR"
2314
+ fi
2315
+ else
2316
+ NEW_SURFACE_FINDINGS[$FINDING_NUM]="${SOURCE_PR:-unknown}"
2317
+ fi
2318
+
1770
2319
  # Code-branch repair guard (MANDATORY — before priority/defer heuristics below).
1771
2320
  # A review-finding with no **Code branch** annotation, whose parent PR's base is
1772
2321
  # not the staging fast lane, would otherwise silently fall through to
@@ -1831,30 +2380,33 @@ for FINDING_NUM in {spawned_finding_numbers}; do
1831
2380
  # as its own outcome and fail closed, mirroring the concurrent-edit branch below
1832
2381
  # rather than silently falling through to the unprotected write.
1833
2382
  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"
1834
- # forge#2584: stage the body in a mktemp file and deliver it via --body-file
1835
- # instead of interpolating finding-derived variables (${FINDING_NUM},
1836
- # ${REPAIR_SOURCE_PR}, ${REPAIR_PARENT_BASE}, ${FINDING_UPDATED_AT_SNAPSHOT})
1837
- # directly into a double-quoted --body argument at the gh call site — mirrors
1838
- # REPAIRED_BODY's existing printf-into-variable approach and the repo-wide
1839
- # mktemp + --body-file convention (forge#2198). mktemp avoids /tmp path
1840
- # collisions with other concurrently-running orchestrated agents.
1841
- GATE_BODY_TMPFILE="$(mktemp)"
2383
+ # Scope the scratch name to both this finding and this orchestrator process.
2384
+ # The marker read-back below detects a plausible-looking body substitution.
2385
+ SCRATCHPAD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"
2386
+ AGENT_TOKEN="${AGENT_ID:-${HOSTNAME:-orchestrator}-$$}"
2387
+ mkdir -p "$SCRATCHPAD"
2388
+ GATE_BODY_MARKER="FORGE:BODY-INTEGRITY:${FINDING_NUM}_freshness-gate_${AGENT_TOKEN}"
2389
+ GATE_BODY_TMPFILE="$(mktemp "$SCRATCHPAD/${FINDING_NUM}_freshness-gate_${AGENT_TOKEN}.XXXXXX.md")"
1842
2390
  printf '%s' "<!-- FORGE:GATE_FAILURE -->
1843
2391
  ## Code Branch Repair Skipped — Freshness Re-fetch Failed
1844
2392
 
1845
- 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"
2393
+ 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 -->
2394
+ <!-- ${GATE_BODY_MARKER} -->" > "$GATE_BODY_TMPFILE"
1846
2395
  gh issue comment "$FINDING_NUM" -R {GH_REPO} --body-file "$GATE_BODY_TMPFILE" 2>/dev/null || true
2396
+ gh api "repos/{GH_REPO}/issues/${FINDING_NUM}/comments" --jq '.[].body' | grep -Fqx "<!-- ${GATE_BODY_MARKER} -->" || { echo "ERROR: freshness-gate comment marker missing" >&2; exit 1; }
1847
2397
  rm -f "$GATE_BODY_TMPFILE"
1848
2398
  gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # freshness-recheck-failure path
1849
2399
  elif [ "$FINDING_UPDATED_AT_CURRENT" != "$FINDING_UPDATED_AT_SNAPSHOT" ]; then
1850
2400
  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"
1851
- # forge#2584: mktemp + --body-file, same rationale as the freshness-recheck-failure path above.
1852
- CONCURRENT_EDIT_BODY_TMPFILE="$(mktemp)"
2401
+ CONCURRENT_EDIT_BODY_MARKER="FORGE:BODY-INTEGRITY:${FINDING_NUM}_concurrent-edit_${AGENT_TOKEN}"
2402
+ CONCURRENT_EDIT_BODY_TMPFILE="$(mktemp "$SCRATCHPAD/${FINDING_NUM}_concurrent-edit_${AGENT_TOKEN}.XXXXXX.md")"
1853
2403
  printf '%s' "<!-- FORGE:GATE_FAILURE -->
1854
2404
  ## Code Branch Repair Skipped — Concurrent Edit Detected
1855
2405
 
1856
- 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"
2406
+ 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 -->
2407
+ <!-- ${CONCURRENT_EDIT_BODY_MARKER} -->" > "$CONCURRENT_EDIT_BODY_TMPFILE"
1857
2408
  gh issue comment "$FINDING_NUM" -R {GH_REPO} --body-file "$CONCURRENT_EDIT_BODY_TMPFILE" 2>/dev/null || true
2409
+ gh api "repos/{GH_REPO}/issues/${FINDING_NUM}/comments" --jq '.[].body' | grep -Fqx "<!-- ${CONCURRENT_EDIT_BODY_MARKER} -->" || { echo "ERROR: concurrent-edit comment marker missing" >&2; exit 1; }
1858
2410
  rm -f "$CONCURRENT_EDIT_BODY_TMPFILE"
1859
2411
  gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # concurrent-edit path
1860
2412
  else
@@ -1865,13 +2417,15 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
1865
2417
  FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body,updatedAt \
1866
2418
  --jq '{labels: [.labels[].name], title: .title, body: .body, updatedAt: .updatedAt}')
1867
2419
  else
1868
- # forge#2584: mktemp + --body-file, same rationale as the two failure paths above.
1869
- REPAIR_FAILED_BODY_TMPFILE="$(mktemp)"
2420
+ REPAIR_FAILED_BODY_MARKER="FORGE:BODY-INTEGRITY:${FINDING_NUM}_repair-failure_${AGENT_TOKEN}"
2421
+ REPAIR_FAILED_BODY_TMPFILE="$(mktemp "$SCRATCHPAD/${FINDING_NUM}_repair-failure_${AGENT_TOKEN}.XXXXXX.md")"
1870
2422
  printf '%s' "<!-- FORGE:GATE_FAILURE -->
1871
2423
  ## Code Branch Repair Failed
1872
2424
 
1873
- 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"
2425
+ 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 -->
2426
+ <!-- ${REPAIR_FAILED_BODY_MARKER} -->" > "$REPAIR_FAILED_BODY_TMPFILE"
1874
2427
  gh issue comment "$FINDING_NUM" -R {GH_REPO} --body-file "$REPAIR_FAILED_BODY_TMPFILE" 2>/dev/null || true
2428
+ gh api "repos/{GH_REPO}/issues/${FINDING_NUM}/comments" --jq '.[].body' | grep -Fqx "<!-- ${REPAIR_FAILED_BODY_MARKER} -->" || { echo "ERROR: repair-failure comment marker missing" >&2; exit 1; }
1875
2429
  rm -f "$REPAIR_FAILED_BODY_TMPFILE"
1876
2430
  gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label needs-human 2>/dev/null || true # repair-failure path
1877
2431
  fi
@@ -1908,7 +2462,8 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
1908
2462
  # all" sets this to false so an operator draining a backlog is never idle-gated.
1909
2463
  if [ "$CASCADE_DEFER_ON_BATCH_GATED" = "true" ] && [ "${BATCH_FULLY_GATED:-false}" = "true" ]; then
1910
2464
  DEFER=true; DEFER_REASON="batch fully human-gated — idle policy"
1911
- # Heuristic 1: Generation check — source issue has review-finding label (always defer, even for P1/P2)
2465
+ # Heuristic 1: Generation check — source issue has review-finding label. Always defer
2466
+ # individual dispatch; only bounded P3 aggregation can consume an eligible member.
1912
2467
  # NOT gated by orchestration.cascade.max_generation — this is Step 4C's autonomous
1913
2468
  # mid-run cascade cap, which stays absolute regardless of config (see forge#2231's
1914
2469
  # scope note above and phase-1-resolve.md's Cascade / Review-Finding Resolution
@@ -1917,9 +2472,17 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
1917
2472
  elif SOURCE_NUM=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oP '(?i)spawned from issue #\K\d+|source issue[: #]+\K\d+' | head -1) && \
1918
2473
  [ -n "$SOURCE_NUM" ] && \
1919
2474
  gh issue view $SOURCE_NUM -R {GH_REPO} --json labels --jq '[.labels[].name]' 2>/dev/null | grep -q "review-finding"; then
1920
- DEFER=true; DEFER_REASON="generation >= 2 (source #${SOURCE_NUM} is also a review-finding)"
1921
- # Priority override: P1 or P2 always execute — skip remaining heuristics
1922
- elif [ "$PRIORITY" = "P1" ] || [ "$PRIORITY" = "P2" ]; then
2475
+ FINDING_GENERATION=$(compute_finding_generation "$(echo "$FINDING_DATA" | jq -r '.body')")
2476
+ FINDING_GENERATIONS[$FINDING_NUM]="$FINDING_GENERATION"
2477
+ DEFER=true; DEFER_REASON="generation ${FINDING_GENERATION} (source #${SOURCE_NUM} is also a review-finding)"
2478
+ if [ "$PRIORITY" = "P3" ] && [ "$FINDING_GENERATION" -le "$BATCH_MAX_GENERATION" ]; then
2479
+ # Keep this out of individual cascade dispatch, but offer it to the P3 batching
2480
+ # pass below. A singleton remains deferred; only a formed batch is admitted.
2481
+ BATCHABLE_DEFERRED_P3+=("$FINDING_NUM")
2482
+ DEFER_REASON="generation ${FINDING_GENERATION} — eligible for bounded P3 batching only"
2483
+ fi
2484
+ # Priority override: P0/P1 always execute; P2 is eligible for batch planning.
2485
+ elif [ "$PRIORITY" = "P0" ] || [ "$PRIORITY" = "P1" ]; then
1923
2486
  DEFER=false
1924
2487
  # Heuristic 2: Comment/typo keyword (only applies to P3 and below)
1925
2488
  # Gated by CASCADE_KEYWORD_HEURISTIC (orchestration.cascade, forge#2234).
@@ -1939,6 +2502,20 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
1939
2502
  DEFER=false
1940
2503
  fi
1941
2504
 
2505
+ [ -n "${FINDING_GENERATIONS[$FINDING_NUM]:-}" ] || FINDING_GENERATIONS[$FINDING_NUM]=1
2506
+
2507
+ # Opt-in only: never defer a new-surface or P1/P2 finding because another
2508
+ # lineage is noisy. Step 4F re-evaluates this explicit deferral after drain.
2509
+ AMPLIFICATION_RATIO=$(awk "BEGIN { if ($MERGED_UNITS) printf \"%.2f\", $FINDINGS_SPAWNED / $MERGED_UNITS; else print 0 }")
2510
+ if [ "$DEFER" = "false" ] && [ "$FINDING_IS_REFINEMENT" = "true" ] && \
2511
+ [ "$PRIORITY" != "P1" ] && [ "$PRIORITY" != "P2" ] && \
2512
+ [ "$CASCADE_MAX_AMPLIFICATION" != "off" ] && \
2513
+ awk "BEGIN { exit !($AMPLIFICATION_RATIO > $CASCADE_MAX_AMPLIFICATION) }"; then
2514
+ DEFER=true
2515
+ DEFER_REASON="amplification bound exceeded (same-lineage refinement; ${AMPLIFICATION_RATIO} > ${CASCADE_MAX_AMPLIFICATION})"
2516
+ AMPLIFICATION_DEFERRED+=("$FINDING_NUM")
2517
+ fi
2518
+
1942
2519
  if [ "$DEFER" = "true" ]; then
1943
2520
  DEFERRED_FINDINGS+=($FINDING_NUM)
1944
2521
  DEFERRED_REASONS[$FINDING_NUM]="$DEFER_REASON"
@@ -1949,9 +2526,11 @@ Finding #${FINDING_NUM} has no **Code branch** annotation and its parent PR #${R
1949
2526
  done
1950
2527
  ```
1951
2528
 
1952
- **Surface-area batching for queued P3 findings (MANDATORY check before dispatch):** <!-- Added: forge#1818 -->
2529
+ **Concern-level batching for queued P3 findings (MANDATORY check before dispatch):** <!-- Added: forge#1818 -->
1953
2530
 
1954
- Cascade-spawned findings collected within a single `/orchestrate` run never pass back through Phase 1's batching rule — Phase 1 only runs once, at the start. Without a check here, same-file P3 findings spawned mid-run always dispatch individually, defeating the batching policy for exactly the findings it exists to catch. Apply the same grouping rule from `commands/orchestrate/phase-1-resolve.md` ("P3 Review-Finding Batching") to `QUEUED_FINDINGS` before the dispatch step below:
2531
+ Cascade-spawned findings collected within a single `/orchestrate` run must be reconsidered against the complete open, unbatched, undispatched candidate registry, not only `QUEUED_FINDINGS` from this completion cycle. At initial resolution and after every five completions (or whenever `DEFERRED_FINDINGS` reaches `MAX_CONCURRENT`), collect retained ungrouped candidates, new findings, and open batch membership; call `planP3BatchGroups()` with that registry and `batchExclusionReason()` danger-zone inputs. Execute returned `extensions` before new groups, retain `ungrouped` members for the next sweep, and record group kinds or exclusion predicates in the run summary. <!-- Added: forge#2858; extended: forge#2851, forge#2852 -->
2532
+
2533
+ The per-cycle `QUEUED_FINDINGS` loop below remains the action-creation mechanism, but it MUST consume the complete-sweep planner output rather than decide groups from its local same-file map. Do not dispatch a locally ungrouped member until the next periodic sweep has considered it against the retained candidate set.
1955
2534
 
1956
2535
  ```bash
1957
2536
  # Group QUEUED_FINDINGS by exact affected file, reusing the SAME safety
@@ -1967,13 +2546,17 @@ Cascade-spawned findings collected within a single `/orchestrate` run never pass
1967
2546
  unset SURFACE_FILE_MEMBERS
1968
2547
  declare -A SURFACE_FILE_MEMBERS
1969
2548
 
1970
- # Defensive cap on gh issue view fan-out. QUEUED_FINDINGS is already bounded by
2549
+ # Include generation-2 P3 findings only as batch candidates. They remain deferred
2550
+ # unless this pass actually forms a bounded batch; no member is individually queued.
2551
+ BATCHING_CANDIDATES=("${QUEUED_FINDINGS[@]}" "${BATCHABLE_DEFERRED_P3[@]}")
2552
+
2553
+ # Defensive cap on gh issue view fan-out. BATCHING_CANDIDATES is already bounded by
1971
2554
  # upstream cascade control; this cap holds even if that bound is later loosened,
1972
2555
  # so the loop can never scale API calls linearly with cascade-seeded findings. <!-- forge#1836 -->
1973
2556
  MAX_BATCH_SCAN=50
1974
2557
  SCANNED=0
1975
2558
 
1976
- for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
2559
+ for FINDING_NUM in "${BATCHING_CANDIDATES[@]}"; do
1977
2560
  SCANNED=$((SCANNED + 1))
1978
2561
  if [ "$SCANNED" -gt "$MAX_BATCH_SCAN" ]; then
1979
2562
  echo "Surface-area batching: reached MAX_BATCH_SCAN=$MAX_BATCH_SCAN — remaining findings stay individually queued"
@@ -1983,7 +2566,8 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1983
2566
  FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json title,body,labels \
1984
2567
  --jq '{title: .title, body: .body, labels: [.labels[].name]}')
1985
2568
 
1986
- # Safety exclusions — never batch, at any priority. Same jq test() engine and
2569
+ # Billing is never batched. Security findings are classified below and may
2570
+ # batch only with their exact same class. Same jq test() engine and
1987
2571
  # patterns as phase-1-resolve.md's batching rule (single shared mechanism).
1988
2572
  # Word-boundary anchored (not bare substrings) and attribution-boilerplate
1989
2573
  # (**Confidence**/**Severity**/**Review comment** — see forge#2477 note below
@@ -2011,9 +2595,9 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
2011
2595
  # keyword is not auto-batched) for closing a real bypass — the safe
2012
2596
  # direction for a security-relevant exclusion. <!-- forge#2477 -->
2013
2597
  echo "$FINDING_DATA" | jq -e '
2014
- (.title | test("\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i"))
2015
- 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"))
2016
- or ([.labels[]] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth"))
2598
+ (.title | test("\\b(billing|operator-only|manual action required|human action required)\\b"; "i"))
2599
+ 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(billing|operator-only|manual action required|human action required)\\b"; "i"))
2600
+ or ([.labels[]] | any(. == "billing" or . == "needs-human" or . == "blocked" or . == "operator-only"))
2017
2601
  ' >/dev/null && continue
2018
2602
 
2019
2603
  # Only P3 findings are eligible (P1/P2 already dispatched individually above).
@@ -2023,7 +2607,11 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
2023
2607
  FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
2024
2608
  [ -z "$FINDING_FILE" ] && continue
2025
2609
 
2026
- SURFACE_FILE_MEMBERS["$FINDING_FILE"]="${SURFACE_FILE_MEMBERS[$FINDING_FILE]} $FINDING_NUM"
2610
+ SAFETY_CLASS=$(node -e 'import("./bin/engine/admission.mjs").then(({ classifyBatchSafety }) => process.stdout.write(classifyBatchSafety(process.argv[1]) || "routine"))' "$(echo "$FINDING_DATA" | jq -r '.title + "\n" + .body + "\n" + (.labels | join(" "))')")
2611
+ # Same file is not enough for security work: the class key prevents a
2612
+ # credential finding from sharing a batch with injection/auth hardening.
2613
+ SURFACE_KEY="${SAFETY_CLASS}:${FINDING_FILE}"
2614
+ SURFACE_FILE_MEMBERS["$SURFACE_KEY"]="${SURFACE_FILE_MEMBERS[$SURFACE_KEY]} $FINDING_NUM"
2027
2615
  done
2028
2616
 
2029
2617
  # For each same-file cluster of 2+, actually CREATE the batch issue (executable —
@@ -2034,23 +2622,39 @@ for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
2034
2622
  MEMBERS=(${SURFACE_FILE_MEMBERS[$FILE]})
2035
2623
  [ "${#MEMBERS[@]}" -ge 2 ] || continue
2036
2624
 
2625
+ SAFETY_CLASS=${FILE%%:*}
2626
+ SURFACE_FILE=${FILE#*:}
2627
+
2037
2628
  # Sanitize the affected-file path before interpolating it into the issue title/body.
2038
2629
  # Git filenames can legally carry shell metacharacters (`$()`, backticks, quotes);
2039
2630
  # restrict to a validated charset so the value cannot break the gh argument
2040
2631
  # boundary from an untrusted issue body. Shared guard with phase-1-resolve.md. <!-- forge#1833, forge#1835 -->
2041
- SAFE_SURFACE_AREA=$(printf '%s' "$FILE" | tr -cd 'A-Za-z0-9._/-')
2632
+ SAFE_SURFACE_AREA=$(printf '%s' "$SURFACE_FILE" | tr -cd 'A-Za-z0-9._/-')
2042
2633
 
2043
- echo "Same-run surface-area cluster: ${#MEMBERS[@]} P3 findings share $FILE — creating batch issue(s)"
2634
+ echo "Same-run surface-area cluster: ${#MEMBERS[@]} ${SAFETY_CLASS} findings share $SURFACE_FILE — creating batch issue(s)"
2044
2635
 
2045
- # Cap at 8 members per batch (phase-1-resolve.md limit); split into batches of <=8.
2046
- for START in $(seq 0 8 $(( ${#MEMBERS[@]} - 1 ))); do
2047
- CHUNK=("${MEMBERS[@]:$START:8}")
2636
+ # Routine batches cap at 8; security-class batches cap at 3 and their body
2637
+ # must state a live-vector or defence-in-depth verdict for every member.
2638
+ BATCH_CAP=8
2639
+ [ "$SAFETY_CLASS" != "routine" ] && BATCH_CAP=3
2640
+ for START in $(seq 0 "$BATCH_CAP" $(( ${#MEMBERS[@]} - 1 ))); do
2641
+ CHUNK=("${MEMBERS[@]:$START:$BATCH_CAP}")
2048
2642
  [ "${#CHUNK[@]}" -ge 2 ] || continue
2049
2643
 
2050
2644
  MEMBER_LINES=""
2645
+ BATCH_MEMBER_GENERATION=1
2646
+ GEN2_MEMBER_LINES=""
2051
2647
  for M in "${CHUNK[@]}"; do
2052
2648
  MTITLE=$(gh issue view "$M" -R {GH_REPO} --json title --jq '.title' 2>/dev/null || echo "")
2053
2649
  MEMBER_LINES="${MEMBER_LINES}- [ ] #${M}: ${MTITLE}"$'\n'
2650
+ MEMBER_GENERATION="${FINDING_GENERATIONS[$M]:-1}"
2651
+ if [ "$MEMBER_GENERATION" -gt "$BATCH_MEMBER_GENERATION" ]; then
2652
+ BATCH_MEMBER_GENERATION="$MEMBER_GENERATION"
2653
+ fi
2654
+ if [ "$MEMBER_GENERATION" -ge 2 ]; then
2655
+ GEN2_MEMBER_LINES="${GEN2_MEMBER_LINES}- #${M}: generation ${MEMBER_GENERATION}"$'\n'
2656
+ fi
2657
+ [ "$SAFETY_CLASS" = "routine" ] || MEMBER_LINES="${MEMBER_LINES} - **Verdict**: [ ] live vector [ ] defence-in-depth"$'\n'
2054
2658
  done
2055
2659
 
2056
2660
  # Dedup-check with member exclusion (MANDATORY — forge#2432, identical mechanism to
@@ -2076,10 +2680,8 @@ for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
2076
2680
  continue
2077
2681
  fi
2078
2682
 
2079
- BATCH_ISSUE_NUM=$(gh issue create {GH_FLAG} \
2080
- --title "$PROPOSED_BATCH_TITLE" \
2081
- --label "review-finding,priority:P3,batch" \
2082
- --body "$(cat <<BATCH_EOF
2683
+ CREATE_TOKEN="forge-create-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM"
2684
+ CREATE_BODY="$(cat <<BATCH_EOF
2083
2685
  ## Problem
2084
2686
 
2085
2687
  Batch of P3 review findings in **${SAFE_SURFACE_AREA}** (same file), clustered mid-run by phase-4-execution.md to reduce per-finding pipeline overhead.
@@ -2089,6 +2691,12 @@ Batch of P3 review findings in **${SAFE_SURFACE_AREA}** (same file), clustered m
2089
2691
  <!-- FORGE:BATCH_MEMBERS -->
2090
2692
  ${MEMBER_LINES}<!-- /FORGE:BATCH_MEMBERS -->
2091
2693
 
2694
+ **Maximum member generation**: ${BATCH_MEMBER_GENERATION}
2695
+ <!-- FORGE:BATCH_MAX_GENERATION: ${BATCH_MEMBER_GENERATION} --> <!-- allowlist:check-spec-markers -->
2696
+
2697
+ **Generation >= 2 members admitted by bounded batching**:
2698
+ ${GEN2_MEMBER_LINES:-none}
2699
+
2092
2700
  ## Acceptance Criteria
2093
2701
 
2094
2702
  - [ ] All member findings addressed or closed as false-positive
@@ -2097,7 +2705,26 @@ ${MEMBER_LINES}<!-- /FORGE:BATCH_MEMBERS -->
2097
2705
 
2098
2706
  <!-- FORGE:BATCHABLE -->
2099
2707
  BATCH_EOF
2100
- )" --json number --jq '.number')
2708
+ )"
2709
+ CREATE_BODY="${CREATE_BODY}
2710
+
2711
+ <!-- issue-create-token:${CREATE_TOKEN} -->"
2712
+ CREATE_RESPONSE=$(gh api "repos/{GH_REPO}/issues" --method POST \
2713
+ -f title="$PROPOSED_BATCH_TITLE" -f body="$CREATE_BODY" \
2714
+ -f 'labels[]=review-finding' -f 'labels[]=priority:P3' -f 'labels[]=batch') || {
2715
+ echo "ERROR: GitHub rejected same-run batch creation; members remain queued." >&2
2716
+ continue
2717
+ }
2718
+ BATCH_ISSUE_NUM=$(echo "$CREATE_RESPONSE" | jq -r '.number // empty')
2719
+ if [ -z "$BATCH_ISSUE_NUM" ]; then
2720
+ echo "ERROR: same-run batch creation returned no issue number; members remain queued." >&2
2721
+ continue
2722
+ fi
2723
+ CREATED_BODY=$(gh issue view "$BATCH_ISSUE_NUM" -R {GH_REPO} --json body --jq '.body') || continue
2724
+ if ! printf '%s' "$CREATED_BODY" | grep -qF "issue-create-token:${CREATE_TOKEN}"; then
2725
+ echo "ERROR: batch issue #${BATCH_ISSUE_NUM} failed create-token read-back; members remain queued." >&2
2726
+ continue
2727
+ fi
2101
2728
 
2102
2729
  # Consume the cluster: record members and REPLACE them in QUEUED_FINDINGS
2103
2730
  # with the single batch issue, so the dispatch step below operates on the
@@ -2596,6 +3223,8 @@ The context-gathering phase can fetch this index to discover all investigation G
2596
3223
  **Repository**: {GH_REPO}
2597
3224
  **Repo path**: {REPO_PATH}
2598
3225
 
3226
+ **KNOWLEDGE GIST CAPABILITY**: This orchestration already probed it: `${FORGE_GIST_CAPABLE}`. Before invoking `/work-on`, run `export FORGE_GIST_CAPABLE=${FORGE_GIST_CAPABLE}`. Do not re-probe or attempt Gist creation when it is `false`.
3227
+
2599
3228
  **YOUR MISSION**: Invoke \`/work-on\` via the Skill tool and let it run to completion. \`/work-on\` is a self-contained routing loop that handles the ENTIRE pipeline: investigate → build (context → architect → implement → validate) → review (push → PR → /review-pr --auto-merge) → close (project board → trajectory log → worktree cleanup). Do NOT intervene, compensate, or manually close issues — \`/work-on\` handles everything including issue closure and label updates in its close phase.
2600
3229
 
2601
3230
  **CRITICAL — DO NOT STOP EARLY**: /work-on runs as a multi-phase routing loop. Each phase (investigate, build, review, close) returns an intermediate result — these are NOT completion signals. You are NOT done until the issue reaches a terminal state: \`workflow:merged\`, \`workflow:invalid\`, \`needs-human\`, or \`workflow:awaiting-merge\`. If /work-on returns after only one phase (e.g., investigation), you MUST invoke it again immediately — it will re-read GitHub state and continue to the next phase. Keep invoking /work-on until it reaches a terminal state. Never output 'done' or stop after an intermediate result.