forgedock 1.2.0 → 1.4.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 (78) hide show
  1. package/README.md +8 -2
  2. package/bin/batch-runner.mjs +31 -2
  3. package/bin/engine/admission.mjs +294 -0
  4. package/bin/engine/admission.test.mjs +265 -0
  5. package/bin/engine/invariants.mjs +17 -4
  6. package/bin/engine/phases.mjs +358 -42
  7. package/bin/engine/resolve.mjs +256 -0
  8. package/bin/engine/resolve.test.mjs +149 -0
  9. package/bin/engine/state.mjs +31 -7
  10. package/bin/engine-cli.mjs +138 -2
  11. package/bin/engine.mjs +414 -21
  12. package/bin/forgedock.mjs +552 -44
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +189 -19
  15. package/bin/journey.mjs +109 -11
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +321 -10
  18. package/bin/watch-utils.mjs +255 -0
  19. package/commands/cleanup.md +7 -3
  20. package/commands/forge-stats.md +5 -4
  21. package/commands/issue.md +5 -1
  22. package/commands/orchestrate/config.md +41 -1
  23. package/commands/orchestrate/phase-1-resolve.md +234 -8
  24. package/commands/orchestrate/phase-3-dependency.md +27 -18
  25. package/commands/orchestrate/phase-4-execution.md +223 -22
  26. package/commands/qa-sweep.md +17 -0
  27. package/commands/quality-gate.md +6 -6
  28. package/commands/replay.md +16 -2
  29. package/commands/review-pr-agents/spec-cli.md +179 -0
  30. package/commands/review-pr.md +65 -0
  31. package/commands/upgrade-deps.md +523 -0
  32. package/commands/work-on/build/validate.md +61 -1
  33. package/commands/work-on/build.md +9 -1
  34. package/commands/work-on/decompose.md +10 -1
  35. package/commands/work-on/investigate.md +22 -4
  36. package/commands/work-on/remediate.md +2 -0
  37. package/commands/work-on/review.md +40 -1
  38. package/commands/work-on.md +19 -4
  39. package/docs/AIDER.md +264 -0
  40. package/docs/AUTOPILOT-CRON.md +130 -0
  41. package/docs/CONFIG.md +156 -0
  42. package/docs/CURSOR.md +135 -0
  43. package/docs/OPENCODE.md +312 -0
  44. package/docs/demo.gif +0 -0
  45. package/docs/eval/README.md +4 -4
  46. package/docs/launch/show-hn.md +3 -3
  47. package/docs/site/architecture.md +180 -0
  48. package/docs/site/command-reference.md +17 -0
  49. package/docs/site/configuration.md +243 -0
  50. package/docs/site/for-companies.md +4 -4
  51. package/docs/site/forge-annotation-protocol.md +1 -1
  52. package/docs/site/github-is-the-memory.md +2 -2
  53. package/docs/site/how-it-works.md +1 -1
  54. package/docs/site/public/llms-full.txt +380 -0
  55. package/docs/site/public/llms.txt +35 -0
  56. package/docs/spec/eval-run-result.md +8 -4
  57. package/docs/spec/label-state-machine.md +9 -0
  58. package/docs/spec-graph-schema.md +3 -3
  59. package/package.json +3 -3
  60. package/packages/protocol/README.md +75 -0
  61. package/packages/protocol/src/card.js +114 -0
  62. package/packages/protocol/src/cli.js +4 -77
  63. package/packages/protocol/src/emit.js +5 -11
  64. package/packages/protocol/src/html-comment-escape.js +68 -0
  65. package/packages/protocol/src/index.js +3 -1
  66. package/packages/protocol/src/parse.js +19 -11
  67. package/packages/protocol/src/phases.js +105 -0
  68. package/packages/protocol/src/types.js +18 -1
  69. package/packages/protocol/src/validate.js +11 -0
  70. package/scripts/check-admission-parity.mjs +167 -0
  71. package/scripts/check-command-side-effects.sh +376 -0
  72. package/scripts/check-phase-registry-drift.mjs +96 -0
  73. package/scripts/check-spec-markers.sh +274 -0
  74. package/scripts/classify-lane.sh +29 -7
  75. package/scripts/forge-run.sh +353 -0
  76. package/templates/cross-repo-trigger.yml +282 -0
  77. package/templates/cursor/forge.mdc +148 -0
  78. package/templates/github-actions/forgedock-autopilot.yml +113 -0
@@ -304,8 +304,76 @@ BATCH_FULLY_GATED=false
304
304
  # Step 4C's cascade dispatch of review-finding issues. Declared once here (batch scope,
305
305
  # not per-agent) so BATCH_TOKEN_SPEND accumulates correctly across every Step 4C run
306
306
  # this session performs.
307
- TOKEN_BUDGET=$(yq '.pipeline.token_budget_per_batch // 900000' forge.yaml 2>/dev/null || echo 900000)
307
+
308
+ # --- Cascade admission policy resolution (forge#2234) ---
309
+ # `orchestration.cascade` gives the admission rules below (rules 0/3/4/5) an
310
+ # independently-settable config surface, on top of a named preset. Absent
311
+ # section => `balanced`, which reproduces today's hardcoded behavior exactly
312
+ # (no-op for existing configs). See `bin/engine/admission.mjs` for the typed,
313
+ # unit-tested reference implementation of this same preset table.
314
+ CASCADE_POLICY_NAME=$(yq '.orchestration.cascade.policy // "balanced"' forge.yaml 2>/dev/null || echo "balanced")
315
+ [ "$CASCADE_POLICY_NAME" = "null" ] && CASCADE_POLICY_NAME="balanced"
316
+
317
+ case "$CASCADE_POLICY_NAME" in
318
+ all)
319
+ PRESET_MAX_GEN="unlimited"; PRESET_TOKEN_BUDGET="unlimited"; PRESET_DEFER_GATED="false"; PRESET_KEYWORD="false"; PRESET_P3_SAME_FILE="false" ;;
320
+ conservative)
321
+ PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=450000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
322
+ balanced)
323
+ PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
324
+ *)
325
+ echo "WARNING: forge.yaml → orchestration.cascade.policy \"${CASCADE_POLICY_NAME}\" is not one of: all, balanced, conservative — falling back to \"balanced\""
326
+ CASCADE_POLICY_NAME="balanced"
327
+ PRESET_MAX_GEN=1; PRESET_TOKEN_BUDGET=900000; PRESET_DEFER_GATED="true"; PRESET_KEYWORD="true"; PRESET_P3_SAME_FILE="true" ;;
328
+ esac
329
+
330
+ # max_generation is authoritatively resolved in phase-1-resolve.md (it only governs
331
+ # Phase 1 resolve-time cascade admission, never Step 4C's autonomous defer — see rule 1
332
+ # above). It is re-read here, read-only, ONLY to support the both-uncapped notice below —
333
+ # Step 4C does not otherwise consume MAX_GENERATION_FOR_NOTICE.
334
+ MAX_GENERATION_FOR_NOTICE=$(yq ".orchestration.cascade.max_generation // \"${PRESET_MAX_GEN}\"" forge.yaml 2>/dev/null || echo "$PRESET_MAX_GEN")
335
+ [ "$MAX_GENERATION_FOR_NOTICE" = "null" ] && MAX_GENERATION_FOR_NOTICE="$PRESET_MAX_GEN"
336
+ if [ "$MAX_GENERATION_FOR_NOTICE" != "unlimited" ] && ! echo "$MAX_GENERATION_FOR_NOTICE" | grep -qE '^[1-9][0-9]*$'; then
337
+ MAX_GENERATION_FOR_NOTICE="$PRESET_MAX_GEN"
338
+ fi
339
+
340
+ # token_budget precedence: orchestration.cascade.token_budget (new home) >
341
+ # pipeline.token_budget_per_batch (deprecated alias, forge#1858, kept working
342
+ # unchanged) > the resolved preset default. Accepts the "unlimited" sentinel —
343
+ # unlike a bare `// 900000` yq default, an explicit "unlimited" string must
344
+ # NOT be coerced back to the numeric default (see forge#2234 "Known Pitfalls":
345
+ # threading the sentinel through before wiring the levers, not after).
346
+ LEGACY_TOKEN_BUDGET=$(yq '.pipeline.token_budget_per_batch // ""' forge.yaml 2>/dev/null || echo "")
347
+ [ "$LEGACY_TOKEN_BUDGET" = "null" ] && LEGACY_TOKEN_BUDGET=""
348
+ TOKEN_BUDGET_FALLBACK="${LEGACY_TOKEN_BUDGET:-$PRESET_TOKEN_BUDGET}"
349
+ TOKEN_BUDGET=$(yq ".orchestration.cascade.token_budget // \"${TOKEN_BUDGET_FALLBACK}\"" forge.yaml 2>/dev/null || echo "$TOKEN_BUDGET_FALLBACK")
350
+ [ "$TOKEN_BUDGET" = "null" ] && TOKEN_BUDGET="$TOKEN_BUDGET_FALLBACK"
351
+ if [ "$TOKEN_BUDGET" != "unlimited" ] && ! echo "$TOKEN_BUDGET" | grep -qE '^[1-9][0-9]*$'; then
352
+ echo "WARNING: forge.yaml → orchestration.cascade.token_budget is not a positive integer or \"unlimited\" (\"${TOKEN_BUDGET}\") — falling back to default ${PRESET_TOKEN_BUDGET}"
353
+ TOKEN_BUDGET="$PRESET_TOKEN_BUDGET"
354
+ fi
355
+
308
356
  TOKEN_ESTIMATE_PER_FINDING=$(yq '.pipeline.token_estimate_per_finding // 150000' forge.yaml 2>/dev/null || echo 150000)
357
+
358
+ # Independent boolean levers — each accepts an explicit granular override on
359
+ # top of the resolved preset (preset supplies the default, not a hard value).
360
+ CASCADE_DEFER_ON_BATCH_GATED=$(yq ".orchestration.cascade.defer_on_batch_gated // ${PRESET_DEFER_GATED}" forge.yaml 2>/dev/null || echo "$PRESET_DEFER_GATED")
361
+ CASCADE_KEYWORD_HEURISTIC=$(yq ".orchestration.cascade.keyword_heuristic // ${PRESET_KEYWORD}" forge.yaml 2>/dev/null || echo "$PRESET_KEYWORD")
362
+ 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")
363
+
364
+ 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)"
365
+
366
+ # Both-uncapped notice (loud, one-time, printed once per orchestrate invocation since
367
+ # this resolution block itself runs once at Step 4A.pre) — never a preset default except
368
+ # "all", which sets it deliberately. Surfacing this explicitly means an operator running
369
+ # an uncapped policy sees the tradeoff up front rather than discovering it from an
370
+ # unexpectedly long cascade tail (the exact gen-2→3→4 drift this config surface exists to
371
+ # make controllable — see forge#2234 issue body evidence).
372
+ if [ "$MAX_GENERATION_FOR_NOTICE" = "unlimited" ] && [ "$TOKEN_BUDGET" = "unlimited" ]; then
373
+ echo "⚠ WARNING: orchestration.cascade — both max_generation and token_budget are unlimited. Cascade admission has NO upper bound on generation depth or token spend this run (policy=${CASCADE_POLICY_NAME})."
374
+ fi
375
+ # --- End cascade admission policy resolution ---
376
+
309
377
  BATCH_TOKEN_SPEND=0
310
378
  TOKEN_DEFERRED=() # findings deferred by the token-budget rule this run (re-evaluable in Step 4F.2.6)
311
379
 
@@ -425,6 +493,8 @@ Agent(
425
493
  - For satellite repo issues: `Skill(skill='work-on', args='{SATELLITE_PREFIX}:{NUMBER} --under-orchestration')` (prefix from forge.yaml → repos.satellites)
426
494
  - 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).
427
495
  - NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
496
+ - **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)
497
+ - **`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)
428
498
  - NEVER target `main` for PRs targeting the default repo. Use `{STAGING_BRANCH}` for fast-lane issues, or `milestone/{slug}` for milestone issues.
429
499
  - Satellite repos (MCP, n8n) have no staging branch — fast-lane PRs go to `main` for those.
430
500
  - If the issue is INVALID after investigation, close it with a comment explaining why
@@ -452,6 +522,7 @@ If the label is NOT terminal (e.g., `workflow:investigating`, `workflow:ready-to
452
522
  **LANE**: {LANE} (PR target: {PR_BASE})
453
523
  **Issue title**: {ISSUE_TITLE}
454
524
  {GIST_CONTEXT}
525
+ {SOURCE_PR_HINT_CONTEXT}
455
526
  "
456
527
  )
457
528
  ```
@@ -501,6 +572,20 @@ fi
501
572
 
502
573
  If `GIST_CONTEXT` is empty (no synthesis brief, no parent investigation, and no milestone index found), the variable resolves to a blank line in the template — no impact on the agent prompt. <!-- Updated: forge#341, forge#1192 -->
503
574
 
575
+ **`{SOURCE_PR_HINT_CONTEXT}` generation** <!-- Added: forge#2351 -->: For each issue being dispatched, thread the source-PR `likely-moot` triage hint computed by `phase-1-resolve.md`'s "Source-PR Triage Hint" step (`ISSUE_LIKELY_MOOT[$NUM]`, `ISSUE_SOURCE_PR[$NUM]`, `ISSUE_SOURCE_PR_STATE[$NUM]` — Phase 1 output, not re-derived here) into the dispatched agent's initial context, framed explicitly as a starting point to verify, never as a conclusion:
576
+
577
+ ```bash
578
+ # Build SOURCE_PR_HINT_CONTEXT for an issue — reads Phase 1's ISSUE_LIKELY_MOOT[]/ISSUE_SOURCE_PR[]/
579
+ # ISSUE_SOURCE_PR_STATE[] arrays (populated once in phase-1-resolve.md's pre-flight step).
580
+ SOURCE_PR_HINT_CONTEXT=""
581
+ if [ "${ISSUE_LIKELY_MOOT[{NUMBER}]:-unknown}" = "yes" ]; then
582
+ SOURCE_PR_HINT_CONTEXT="
583
+ **SOURCE-PR TRIAGE HINT (non-binding — verify, do not assume)**: This finding cites source PR #${ISSUE_SOURCE_PR[{NUMBER}]}, which closed WITHOUT merging (state: ${ISSUE_SOURCE_PR_STATE[{NUMBER}]}). This is a hint to check FIRST during investigation, not a verdict — do NOT conclude INVALID on this basis alone. Two outcomes are both possible and have both occurred in production: (a) the flagged change genuinely never landed by any route (verify with \`git log -S\`/\`git merge-base --is-ancestor\` against the target branch) — if so, this supports an INVALID verdict with evidence; (b) the flagged change reached the target branch anyway via a DIFFERENT, independently-merged PR — if so, this finding is still valid and must be investigated and resolved normally, exactly like #2346 (source PR #2337 closed unmerged, but the code landed via independently-merged PR #2261, commit 90376f5 — #2346 correctly ended at needs-human, not invalid). Reach your own evidence-based verdict; this hint only tells you where to look first."
584
+ fi
585
+ ```
586
+
587
+ If `ISSUE_LIKELY_MOOT[{NUMBER}]` is `unknown` or absent (no `**Source**: PR #{N}` citation found, source PR still open, source PR merged, or the lookup failed), `SOURCE_PR_HINT_CONTEXT` stays empty and resolves to a blank line in the template — no impact on the agent prompt, identical to the `GIST_CONTEXT` empty-case behavior above.
588
+
504
589
  **Claims board context injection** <!-- Added: forge#1736 -->: When a coordination issue exists for this batch (`FORGE_COORD_ISSUE` is set), append the claims board URL and the active-claims check instruction to the agent's context. This enables each `/work-on` agent to post its `FORGE:CLAIM` on build start.
505
590
 
506
591
  ```bash
@@ -633,6 +718,19 @@ classify_predecessor_state() {
633
718
  echo "DONE"
634
719
  elif echo "$PRED_LABELS" | grep -qxE "needs-human|workflow:awaiting-merge"; then
635
720
  echo "GATED"
721
+ elif echo "$PRED_LABELS" | grep -qx "workflow:engine-error"; then
722
+ # forge#2261: the engine/tool itself broke on this run (e.g. a fail-fast
723
+ # CLI_BACKEND_FAILED, or an exhausted retry loop where the runner never
724
+ # once succeeded) — this is NOT a human-judgment block, so it must NOT
725
+ # classify GATED (that would wrongly track dependents as
726
+ # blocked-on-human-merge, waiting on a merge decision nobody needs to
727
+ # make). It is also not FAILED — the predecessor's content was never
728
+ # actually judged bad, the tool just needs to be re-run. Classifying it
729
+ # IN_PROGRESS means dependents simply keep waiting, exactly as if the
730
+ # predecessor were still mid-pipeline — which is accurate, since the
731
+ # stall-detection step below (item 2) auto-resumes/retries a completed
732
+ # agent run that ends in workflow:engine-error, same as any other stall.
733
+ echo "IN_PROGRESS"
636
734
  elif [ "$PRED_STATE" = "CLOSED" ]; then
637
735
  # Closed with no workflow:invalid/merged label (e.g. closed-not-planned) — treat as DONE,
638
736
  # not a new deadlock state; there is no pending code for dependents to wait on.
@@ -646,7 +744,7 @@ classify_predecessor_state() {
646
744
  - **DONE** — predecessor's code is in the base branch (`workflow:merged`), or the predecessor is closed with no pending code. Safe for dependents to dispatch.
647
745
  - **GATED** — predecessor is paused pending a human decision (`needs-human`) or pending only a human merge click (`workflow:awaiting-merge`). Its code is NOT yet in the base branch. Dependents are neither dispatched nor skipped — they move to the `blocked-on-human-merge` tracked state (item 6.5 below).
648
746
  - **FAILED** — predecessor was closed as `workflow:invalid`, or the agent explicitly reported a build/test error. Dependents are marked "skipped — dependency failed" (item 6 below) — unchanged from prior behavior.
649
- - **IN_PROGRESS** — predecessor is still mid-pipeline (`investigating`/`ready-to-build`/`building`/`in-review`). Dependent simply continues waiting; no special tracking needed.
747
+ - **IN_PROGRESS** — predecessor is still mid-pipeline (`investigating`/`ready-to-build`/`building`/`in-review`), or terminated `workflow:engine-error` (forge#2261 — an engine/tool failure, not a human-judgment block or a genuine content failure; treated as still-in-flight since stall-detection auto-resumes it). Dependent simply continues waiting; no special tracking needed.
650
748
 
651
749
  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.
652
750
 
@@ -830,7 +928,7 @@ done
830
928
  echo "#{NUM}: $FINAL_STATE"
831
929
  ```
832
930
 
833
- 2. **If the issue is NOT in a terminal-for-this-agent state** (`workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`), the agent stalled mid-pipeline. **Resume it immediately**:
931
+ 2. **If the issue is NOT in a terminal-for-this-agent state** (`workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`), the agent stalled mid-pipeline. **Resume it immediately**. `workflow:engine-error` (forge#2261 — `bin/engine.mjs`'s headless engine terminating on an engine/tool-level failure rather than a genuine human-judgment block) is deliberately excluded from the terminal-for-this-agent set: a completed agent run ending there is treated exactly like any other stall and auto-resumed/retried here, up to the resume-cap in item 3 below — no human decision is pending, so there is nothing to gate on:
834
932
  ```
835
933
  Agent(
836
934
  resume=AGENT_ISSUE_MAP[{NUMBER}],
@@ -893,9 +991,15 @@ done
893
991
 
894
992
  Report every dependent whose skip was avoided this way — e.g. "#{DEP} — predecessor #{PRED} failed but never touched the shared file(s); edge dropped, #{DEP} not skipped." For all remaining dependents (real `EDGE_KIND` overlap confirmed, or a non-`EDGE_KIND` edge type), mark them "skipped — dependency #{X} failed" and report them. Do NOT dispatch them.
895
993
 
896
- 6.4. **Auto-dispatch remediation against a `needs-human`-gated predecessor's own PR** <!-- Added: forge#1813 --> — item 6.5 below tracks the *dependents* of a `GATED` predecessor; this item handles the predecessor's own PR, which item 6.5/6.6 never re-drive on their own. Run this check whenever a completed agent's issue classifies `GATED` **specifically via `needs-human`** — NOT `workflow:awaiting-merge`. That second state already means "remediated and re-reviewed to a clean verdict, just needs a human's merge click" (see forge#1810's guard) — dispatching remediation again would be redundant, not just wasteful, since there is nothing left to fix.
994
+ 6.4. **Auto-dispatch remediation against a `needs-human`-gated issue's own PR — runs unconditionally per completion, with or without dependents** <!-- Added: forge#1813, fixed: forge#2243 --> — item 6.5 below tracks the *dependents* of a `GATED` predecessor; this item handles the gated issue's own PR, which item 6.5/6.6 never re-drive on their own. **This check is bound directly to the issue that just completed — `PRED="$NUM"` (the same issue number carried from items 1-4 of this Step 4B sequence) — NOT to any `$PRED` produced by walking a dependent's predecessor list (item 5's readiness loop) or by item 6's FAILED-specific dependent-walk.** Run it for every completed agent, every cycle, regardless of whether that issue has any dependents in the DAG at all — a leaf issue (no dependents) is just as eligible as an issue that blocks others. Trigger condition: the completed issue classifies `GATED` **specifically via `needs-human`** — NOT `workflow:awaiting-merge`. That second state already means "remediated and re-reviewed to a clean verdict, just needs a human's merge click" (see forge#1810's guard) — dispatching remediation again would be redundant, not just wasteful, since there is nothing left to fix.
897
995
 
898
996
  ```bash
997
+ # Bind PRED to the issue that just completed — independent of item 5's dependent-walk loop
998
+ # and item 6's FAILED-specific dependent-walk. This is the fix for forge#2243: a leaf issue
999
+ # (no dependents) must still reach this check, since it never appears as a loop-bound $PRED
1000
+ # in either of those other contexts.
1001
+ PRED="$NUM"
1002
+
899
1003
  PRED_CURRENT_LABEL=$(gh issue view "$PRED" -R {GH_REPO} --json labels \
900
1004
  --jq '[.labels[].name | select(. == "needs-human" or . == "workflow:awaiting-merge")] | .[0] // empty' 2>/dev/null)
901
1005
 
@@ -936,7 +1040,7 @@ Do not ask the user questions — you are running autonomously in the background
936
1040
  fi
937
1041
  ```
938
1042
 
939
- This satisfies #1809 Q2 (the orchestrator auto-dispatches remediation against the gated predecessor itself — the exact gap forge#1812's item 6.5/6.6 left open, since those items only ever track and wake *dependents*, never the gated PR's own remediation). The remediation agent's outcome is picked up on the **next** completion-monitoring cycle of this same Step 4B loop: if it lands (`workflow:merged`), item 6.6 below fires normally and wakes any `blocked-on-human-merge` dependents; if it holds/re-escalates, the predecessor simply remains `GATED` and item 6.5 continues tracking its dependents unchanged.
1043
+ This satisfies #1809 Q2 (the orchestrator auto-dispatches remediation against the gated issue itself — the exact gap forge#1812's item 6.5/6.6 left open, since those items only ever track and wake *dependents*, never the gated PR's own remediation) — and closes forge#2243 (the gap that #1812's fix left open for *leaf* issues with no dependents: because this item's `$PRED` binding was never made explicit and self-contained, it was only ever reached while walking a dependent's predecessor list, so a `needs-human` issue that has no dependents never got remediation dispatched and its CHANGES-REQUESTED PR re-reviewed the same unchanged commit forever). The remediation agent's outcome is picked up on the **next** completion-monitoring cycle of this same Step 4B loop: if it lands (`workflow:merged`), item 6.6 below fires normally and wakes any `blocked-on-human-merge` dependents (if any exist — a leaf issue simply has none to wake); if it holds/re-escalates, the issue simply remains `GATED` and item 6.5 continues tracking its dependents (if any) unchanged.
940
1044
 
941
1045
  6.5. **Handle predecessor gating** (`GATED` — `needs-human` or `workflow:awaiting-merge`) <!-- Added: forge#1812 --> — if a completed agent's issue classifies as `GATED`, its direct dependents are neither dispatched nor marked failed/skipped. For each direct dependent `DEP` of the gated predecessor `PRED`:
942
1046
 
@@ -1111,6 +1215,57 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1111
1215
  - Polling the same status check repeatedly on a timer
1112
1216
  - Waiting for a "batch" of completions before checking for newly ready issues — check after EVERY completion
1113
1217
 
1218
+ ### Step 4B.6: Predicate Re-Resolution (standing queries only)
1219
+
1220
+ <!-- Added: forge#2236 -->
1221
+
1222
+ **Purpose**: Phase 1 resolves `$ARGUMENTS` to an issue-number list exactly once (see `phase-1-resolve.md` "Predicate Persistence"). For a standing-query input (`ORIGINATING_QUERY_KIND == "query"` — everything except an explicit `#1 #2 #3` literal set), a new issue matching the same predicate can appear mid-run (a human files it, `/analytics` or `/signal-planner` create it, it gets added to the milestone) and is otherwise invisible to this batch until the operator re-invokes `/orchestrate` by hand. This step closes that gap for the *originating* issue set, distinct from Step 4C which folds in review-findings spawned *by* this batch's own agents.
1223
+
1224
+ **Skip entirely if** `ORIGINATING_QUERY_KIND == "literal"` (see `phase-1-resolve.md`) — an explicit issue-number list is the complete intent; there is nothing to re-resolve, ever.
1225
+
1226
+ **Trigger** (event-driven, same model as the rest of this file — no sleep/poll timer): run once per Step 4B completion cycle, immediately after the per-agent-completion handling above and before Step 4B.5's stall check.
1227
+
1228
+ ```bash
1229
+ # Gate on config + literal/query kind + round cap via bin/engine/resolve.mjs's
1230
+ # shouldReResolve (mirrors admission.mjs's role for Step 4C's admission rules).
1231
+ RERESOLVE_ENABLED=$(yq '.orchestration.reresolve.enabled // true' forge.yaml 2>/dev/null || echo "true")
1232
+ RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' forge.yaml 2>/dev/null || echo "unbounded")
1233
+
1234
+ node -e '
1235
+ import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ shouldReResolve }) => {
1236
+ const classified = { kind: process.argv[1], pattern: process.argv[2], args: [] };
1237
+ const config = {
1238
+ enabled: process.argv[3] === "false" ? false : process.argv[3],
1239
+ maxRounds: process.argv[4] === "unbounded" ? undefined : Number(process.argv[4]),
1240
+ };
1241
+ const rounds = Number(process.argv[5]);
1242
+ console.log(JSON.stringify(shouldReResolve(classified, config, rounds)));
1243
+ }, () => process.exit(0));
1244
+ ' "$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" "$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
1245
+ ```
1246
+
1247
+ If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR` has reached `max_rounds` — bounded termination per the issue's acceptance criteria), skip this step for the current cycle and log the reason. `RERESOLVE_ROUNDS_SO_FAR` starts at 0 for the batch and increments once per cycle this step actually runs (declare it alongside the other Step 4A.pre batch-scope accumulators — do not re-initialize per completion).
1248
+
1249
+ **Re-run the original query** using the exact same resolution logic Phase 1 used for `$ORIGINATING_QUERY_PATTERN`/`$ORIGINATING_QUERY_ARGS` (e.g. re-run the `gh issue list --milestone`/`--label`/`priority:P*` fetch from `phase-1-resolve.md`'s "Fetch the issues" section), applying the exact same eligibility filter Phase 1 applied (`phase-4-execution.md` cross-reference: `phase-1-resolve.md` "Filter out ineligible issues" — closed/`needs-human`/`workflow:decomposed`/`workflow:awaiting-merge` excluded, same as T0).
1250
+
1251
+ **Fold new matches through the existing admission path — never a bypass**:
1252
+
1253
+ ```bash
1254
+ node -e '
1255
+ import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ foldNewMatches }) => {
1256
+ const reResolved = JSON.parse(process.argv[1]);
1257
+ const processed = JSON.parse(process.argv[2]);
1258
+ console.log(JSON.stringify(foldNewMatches(reResolved, processed)));
1259
+ }, () => process.exit(0));
1260
+ ' "$RERESOLVED_NUMBERS_JSON" "$ALL_BATCH_ISSUE_NUMBERS_JSON"
1261
+ ```
1262
+
1263
+ `newMatches` from `foldNewMatches` are candidate issues, not admitted ones. Each one MUST be dispatched through the exact same path a T0-resolved issue takes — DAG dependency analysis (`phase-3-dependency.md`), then Step 4A/4B's standard `dispatch_headroom`-gated dispatch — **not** through Step 4C's review-finding-specific `evaluateCascadeFinding` chain (that gate's rules, e.g. the comment/typo keyword heuristic, are shaped for cascade-spawned findings, not arbitrary re-resolved issues). This is the same non-bypass requirement Step 4C already satisfies for its own admission stream; this step must not become a second, ungated entry point into the DAG. Add every `newMatch` to `ALL_BATCH_ISSUE_NUMBERS` (so a later re-resolution round or Step 4C sees it as already processed) and to `SORTED_READY_SET`/the DAG exactly as Step 4A.pre.0 processes a T0-resolved issue.
1264
+
1265
+ **Reporting (mandatory — do not let this become an alert-only dead-code path, per forge#1832)**: track which issues were T0-resolved vs. admitted via re-resolution, and which round each entered in. Surface this distinction in the final report (`phase-6-report.md`) rather than only in a log line — a run whose predicate still matches unadmitted work at exit (round cap or config disabled mid-run) must say so explicitly, not report a silent clean drain.
1266
+
1267
+ **Multi-repo**: for `all-repos`/satellite-scoped queries (`next <N> all-repos`, `mcp:fast`, etc.), re-run the query against every repo the original resolution covered — not just the default repo.
1268
+
1114
1269
  ### Step 4B.5: Time-Based Stall Detection
1115
1270
 
1116
1271
  **Purpose**: Catches agents that have stopped responding WITHOUT exiting (e.g., rate-limited, context-frozen, or silently hung). The reactive check in Step 4B only fires on agent completion — this check catches agents that never complete at all.
@@ -1223,12 +1378,12 @@ gh issue list -R {GH_REPO} --state open --label "review-finding" --limit 20 \
1223
1378
  For each spawned finding, determine whether it should be **executed** or **deferred**:
1224
1379
 
1225
1380
  **Evaluation order** (first matching rule wins):
1226
- 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.
1227
- 1. **Generation ≥ 2** (always defer, even for P1/P2): Finding was spawned by an issue that was itself a review-finding. Check the source issue's labels for `review-finding` — if the source has that label, the new finding is generation 2. Always defer. Rationale: gen-2+ cascade is theoretically unbounded — cap it here.
1381
+ 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).
1382
+ 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 -->
1228
1383
  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.
1229
- 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.
1230
- 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.
1231
- 5. **Per-batch token budget** (P3 and below only, applied AFTER surface-area batching below — see "Per-batch token budget gate") <!-- Added: forge#1858 -->: Once `BATCH_TOKEN_SPEND` would exceed `TOKEN_BUDGET`, additional P3-and-below units (an unclubbed finding, or an already-clubbed batch issue) defer rather than dispatch. This is NOT part of the per-finding rule 0-4 chain immediately below — it is a quantity gate applied to the POST-clubbing `QUEUED_FINDINGS` list, so a same-run surface-area batch issue (surface-area batching below) is charged once for the whole cluster, not once per member. P1/P2 are NEVER gated by it (they are excluded by rule 2 before reaching this gate, same as they are excluded from rules 3-4).
1384
+ 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).
1385
+ 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).
1386
+ 5. **Per-batch token budget** (P3 and below only, applied AFTER surface-area batching below — see "Per-batch token budget gate") <!-- Added: forge#1858 -->: Once `BATCH_TOKEN_SPEND` would exceed `TOKEN_BUDGET`, additional P3-and-below units (an unclubbed finding, or an already-clubbed batch issue) defer rather than dispatch. This is NOT part of the per-finding rule 0-4 chain immediately below — it is a quantity gate applied to the POST-clubbing `QUEUED_FINDINGS` list, so a same-run surface-area batch issue (surface-area batching below) is charged once for the whole cluster, not once per member. P1/P2 are NEVER gated by it (they are excluded by rule 2 before reaching this gate, same as they are excluded from rules 3-4). **Configurable** via `orchestration.cascade.token_budget` (default `900000`, deprecated alias `pipeline.token_budget_per_batch`; `unlimited` under `policy: all` — forge#2234). This is a distinct, independent lever from rule 1's generation cap — "admit gen-2, stop at gen-3" (`max_generation: 3`) and "admit cascade until N tokens" (`token_budget: N`) can each be set without the other.
1232
1387
 
1233
1388
  **Defer** (do NOT add to the DAG) if rules 0, 1, 3, 4, or 5 match.
1234
1389
 
@@ -1267,15 +1422,39 @@ for FINDING_NUM in {spawned_finding_numbers}; do
1267
1422
  FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body \
1268
1423
  --jq '{labels: [.labels[].name], title: .title, body: .body}')
1269
1424
 
1270
- PRIORITY=$(echo "$FINDING_DATA" | jq -r '.labels[] | select(startswith("priority:P")) | ltrimstr("priority:")' | head -1)
1425
+ # Priority extraction accepts both label schemas: canonical `priority:P<n>` (the only
1426
+ # form review-pr.md ever creates) and bare `P<n>` (carried by some consumer repos'
1427
+ # externally/legacy-labeled issues). `priority:P<n>` wins if both are present on the
1428
+ # same issue. Normalizes to bare P<n> form since all downstream comparisons in this file
1429
+ # compare against bare "P1"/"P2"/"P3". Mirrored verbatim in the token-budget gate below,
1430
+ # the Step 4F.2 sweep loop, and phase-1-resolve.md / cleanup.md's batching queries —
1431
+ # keep all sites byte-identical (forge#1837: mirrored checks must not drift). <!-- Added: forge#2232 -->
1432
+ # NOTE: $FINDING_DATA.labels is already a flat array of label-name strings (see the
1433
+ # gh issue view --jq above: {labels: [.labels[].name], ...}) — so this reads `.labels[]`
1434
+ # directly, NOT `.labels[].name` (that would error: "Cannot index string with name").
1435
+ PRIORITY=$(echo "$FINDING_DATA" | jq -r '
1436
+ ([.labels[] | select(test("^priority:P[0-9]+$"))] | .[0]) //
1437
+ ([.labels[] | select(test("^P[0-9]+$"))] | .[0]) //
1438
+ "" | ltrimstr("priority:")')
1271
1439
  TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
1272
1440
 
1441
+ if [ -z "$PRIORITY" ]; then
1442
+ echo "WARNING: #${FINDING_NUM} has no parseable priority label (checked priority:P<n> and bare P<n>) — treating as untriaged, all priority-dependent rules below will not match"
1443
+ fi
1444
+
1273
1445
  # Rule 0: Batch fully human-gated — checked FIRST, overrides even the P1/P2 priority
1274
1446
  # override below. BATCH_FULLY_GATED is computed once per completion cycle in Step 4B
1275
1447
  # item 6.7; read it here, do not recompute. <!-- Added: forge#1814 -->
1276
- if [ "${BATCH_FULLY_GATED:-false}" = "true" ]; then
1448
+ # Gated by CASCADE_DEFER_ON_BATCH_GATED (orchestration.cascade, forge#2234) — "policy:
1449
+ # all" sets this to false so an operator draining a backlog is never idle-gated.
1450
+ if [ "$CASCADE_DEFER_ON_BATCH_GATED" = "true" ] && [ "${BATCH_FULLY_GATED:-false}" = "true" ]; then
1277
1451
  DEFER=true; DEFER_REASON="batch fully human-gated — idle policy"
1278
1452
  # Heuristic 1: Generation check — source issue has review-finding label (always defer, even for P1/P2)
1453
+ # NOT gated by orchestration.cascade.max_generation — this is Step 4C's autonomous
1454
+ # mid-run cascade cap, which stays absolute regardless of config (see forge#2231's
1455
+ # scope note above and phase-1-resolve.md's Cascade / Review-Finding Resolution
1456
+ # section). `orchestration.cascade.max_generation` governs what an explicit human
1457
+ # request admits at Phase 1 resolve time, not what this unattended triage pass defers.
1279
1458
  elif SOURCE_NUM=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oP '(?i)spawned from issue #\K\d+|source issue[: #]+\K\d+' | head -1) && \
1280
1459
  [ -n "$SOURCE_NUM" ] && \
1281
1460
  gh issue view $SOURCE_NUM -R {GH_REPO} --json labels --jq '[.labels[].name]' 2>/dev/null | grep -q "review-finding"; then
@@ -1284,10 +1463,12 @@ for FINDING_NUM in {spawned_finding_numbers}; do
1284
1463
  elif [ "$PRIORITY" = "P1" ] || [ "$PRIORITY" = "P2" ]; then
1285
1464
  DEFER=false
1286
1465
  # Heuristic 2: Comment/typo keyword (only applies to P3 and below)
1287
- elif echo "$TITLE" | grep -qi "comment\|typo"; then
1466
+ # Gated by CASCADE_KEYWORD_HEURISTIC (orchestration.cascade, forge#2234).
1467
+ elif [ "$CASCADE_KEYWORD_HEURISTIC" = "true" ] && echo "$TITLE" | grep -qi "comment\|typo"; then
1288
1468
  DEFER=true; DEFER_REASON="comment/typo heuristic"
1289
1469
  # Heuristic 3: P3 + same-file overlap
1290
- elif [ "$PRIORITY" = "P3" ]; then
1470
+ # Gated by CASCADE_P3_SAME_FILE_DEFER (orchestration.cascade, forge#2234).
1471
+ elif [ "$CASCADE_P3_SAME_FILE_DEFER" = "true" ] && [ "$PRIORITY" = "P3" ]; then
1291
1472
  # Extract file target from finding body (look for code block or backtick path)
1292
1473
  FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oP '`[^\`]+\.(py|ts|tsx|sh|md)`' | head -1 | tr -d '`')
1293
1474
  if [ -n "$FINDING_FILE" ] && echo "$ALL_BATCH_FILES" | grep -qF "$FINDING_FILE"; then
@@ -1351,8 +1532,9 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1351
1532
  or ([.labels[]] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth"))
1352
1533
  ' >/dev/null && continue
1353
1534
 
1354
- # Only P3 findings are eligible (P1/P2 already dispatched individually above)
1355
- echo "$FINDING_DATA" | jq -e '[.labels[]] | any(. == "priority:P3")' >/dev/null || continue
1535
+ # Only P3 findings are eligible (P1/P2 already dispatched individually above).
1536
+ # Schema-tolerant: matches canonical priority:P3 or bare P3 (forge#2232).
1537
+ echo "$FINDING_DATA" | jq -e '[.labels[]] | any(test("^(priority:)?P3$"))' >/dev/null || continue
1356
1538
 
1357
1539
  FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
1358
1540
  [ -z "$FINDING_FILE" ] && continue
@@ -1432,8 +1614,16 @@ Rule 5 from the evaluation order above. Charges ONE `TOKEN_ESTIMATE_PER_FINDING`
1432
1614
  TOKEN_BUDGET_DEFERRED_THIS_RUN=()
1433
1615
 
1434
1616
  for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1617
+ # Schema-tolerant extraction (forge#2232) — same pattern as the Step 4C main loop above.
1618
+ # Defaults to P3 only when NEITHER label form is present (an empty labels match, not
1619
+ # merely a non-priority:P<n> label), so a bare-P3-labeled finding is read correctly
1620
+ # instead of falling through to this default.
1435
1621
  FINDING_PRIORITY=$(gh issue view "$FINDING_NUM" -R {GH_REPO} --json labels \
1436
- --jq '([.labels[].name | select(startswith("priority:P"))] | .[0] // "priority:P3") | ltrimstr("priority:")' 2>/dev/null || echo "P3")
1622
+ --jq '(
1623
+ ([.labels[].name | select(test("^priority:P[0-9]+$"))] | .[0]) //
1624
+ ([.labels[].name | select(test("^P[0-9]+$"))] | .[0]) //
1625
+ "priority:P3"
1626
+ ) | ltrimstr("priority:")' 2>/dev/null || echo "P3")
1437
1627
 
1438
1628
  # P1/P2 are NEVER gated by the token budget — they were queued by rule 2 above
1439
1629
  # regardless of budget headroom; skip them here and leave them in QUEUED_FINDINGS.
@@ -1442,7 +1632,9 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1442
1632
  fi
1443
1633
 
1444
1634
  PROJECTED_TOKEN_SPEND=$((BATCH_TOKEN_SPEND + TOKEN_ESTIMATE_PER_FINDING))
1445
- if [ "$PROJECTED_TOKEN_SPEND" -gt "$TOKEN_BUDGET" ]; then
1635
+ # TOKEN_BUDGET may be the "unlimited" sentinel (orchestration.cascade.token_budget /
1636
+ # policy: all) — never gate in that case, regardless of projected spend.
1637
+ if [ "$TOKEN_BUDGET" != "unlimited" ] && [ "$PROJECTED_TOKEN_SPEND" -gt "$TOKEN_BUDGET" ]; then
1446
1638
  TOKEN_DEFERRED+=("$FINDING_NUM")
1447
1639
  TOKEN_BUDGET_DEFERRED_THIS_RUN+=("$FINDING_NUM")
1448
1640
  DEFERRED_REASONS[$FINDING_NUM]="token budget exceeded (est. ${TOKEN_ESTIMATE_PER_FINDING} tokens would push batch spend to ${PROJECTED_TOKEN_SPEND} > ceiling ${TOKEN_BUDGET})"
@@ -1686,7 +1878,16 @@ for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
1686
1878
  STATE=$(echo "$FINDING_DATA" | jq -r '.state')
1687
1879
  [ "$STATE" = "CLOSED" ] && continue
1688
1880
 
1689
- PRIORITY=$(echo "$FINDING_DATA" | jq -r '.labels[] | select(startswith("priority:P")) | ltrimstr("priority:")' | head -1)
1881
+ # Schema-tolerant extraction (forge#2232), mirrors the Step 4C main loop above.
1882
+ # PRIORITY is not currently read by the branches below (comment/typo is title-only),
1883
+ # kept normalized for consistency and to avoid reintroducing the schema bug if a
1884
+ # future rule here starts branching on it. NOTE: $FINDING_DATA.labels here is already
1885
+ # a flat array of label-name strings (see the --jq above: {labels: [.labels[].name], ...}),
1886
+ # so this reads `.labels[]` directly, not `.labels[].name`.
1887
+ PRIORITY=$(echo "$FINDING_DATA" | jq -r '
1888
+ ([.labels[] | select(test("^priority:P[0-9]+$"))] | .[0]) //
1889
+ ([.labels[] | select(test("^P[0-9]+$"))] | .[0]) //
1890
+ "" | ltrimstr("priority:")')
1690
1891
  TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
1691
1892
 
1692
1893
  # Re-apply heuristics against the drained DAG (no active batch files)
@@ -1753,7 +1954,7 @@ if [ "${#TOKEN_GATED[@]}" -gt 0 ]; then
1753
1954
  [ "$STATE" = "CLOSED" ] && continue # resolved by another process — drop silently
1754
1955
 
1755
1956
  PROJECTED_SWEEP_SPEND=$((SWEEP_TOKEN_SPEND + TOKEN_ESTIMATE_PER_FINDING))
1756
- if [ "$PROJECTED_SWEEP_SPEND" -gt "$TOKEN_BUDGET" ]; then
1957
+ if [ "$TOKEN_BUDGET" != "unlimited" ] && [ "$PROJECTED_SWEEP_SPEND" -gt "$TOKEN_BUDGET" ]; then
1757
1958
  SWEEP_STILL_DEFERRED+=($FINDING_NUM)
1758
1959
  echo "Sweep: #${FINDING_NUM} still deferred (token budget — sweep allowance ${TOKEN_BUDGET} would be exceeded at ${PROJECTED_SWEEP_SPEND})"
1759
1960
  else
@@ -1913,7 +2114,7 @@ fi
1913
2114
  Completion Sweep Results:
1914
2115
  Dispatched: #{A}, #{B} (file overlap cleared after DAG drain)
1915
2116
  Still deferred (cosmetic): #{C} (comment/typo)
1916
- Permanently deferred (gen2): #{D} (generation >= 2 cascade cap)
2117
+ Permanently deferred (gen2): #{D} (generation >= 2 cascade cap — requires manual /work-on, or an explicit `cascade`/`review-findings --allow-gen2` request via phase-1-resolve.md; see #2231/#2234)
1917
2118
  Idle-gated — still deferred: #{E} (batch still fully human-gated — waiting on a merge)
1918
2119
  Idle-gated — cleared: #{F} (a gating PR merged since deferral — no longer idle)
1919
2120
  Token-gated — cleared: #{G} (fits fresh sweep token allowance — batch spend ${SWEEP_TOKEN_SPEND}/${TOKEN_BUDGET})
@@ -1924,7 +2125,7 @@ Completion Sweep Results:
1924
2125
 
1925
2126
  **Anti-patterns — DO NOT DO THIS:**
1926
2127
  - Re-sweeping findings spawned during the sweep itself — this creates unbounded recursion. Sweep is a single pass.
1927
- - Overriding generation >= 2 deferrals — the cascade cap is absolute.
2128
+ - Overriding generation >= 2 deferrals **inside this Step 4C mid-run triage** — the cascade cap is absolute for autonomous cascade: a sweep or triage pass must never promote a gen≥2 finding to executed just because it looks low-risk. This is distinct from an explicit human request made *before* the run starts: an operator may deliberately admit gen≥2 findings via `phase-1-resolve.md`'s `cascade`/`review-findings`/`findings` resolution with `--include-deferred`/`--allow-gen2` (or the `orchestration.cascade.max_generation` config lever from #2234) — that is the sanctioned override path, entered at Phase 1 resolution, not a violation of this rule. Do not use that human-request path as precedent to relax Step 4C itself. <!-- Added: forge#2231 -->
1928
2129
  - Skipping the sweep because "there are only a few" deferred findings — even one deferred finding represents unresolved work.
1929
2130
  - Clearing an idle-gated deferral (forge#1814) without recomputing `BATCH_FULLY_GATED` fresh at sweep time — a stale "not gated" read would re-introduce the exact net-negative churn this policy exists to stop.
1930
2131
  - Clearing a token-gated deferral (forge#1858) by reusing the drained `BATCH_TOKEN_SPEND` instead of the fresh `SWEEP_TOKEN_SPEND` allowance — the whole point of Step 4F.2.6 is that the *original* batch's spend is no longer relevant once it has drained.
@@ -36,6 +36,23 @@ See README.md Requirements section for full setup instructions.
36
36
 
37
37
  ---
38
38
 
39
+ ## Playwright MCP Prerequisite (MANDATORY — check before proceeding)
40
+
41
+ Playwright MCP is a **guaranteed ForgeDock dependency** for all browser automation commands. Before running any qa-sweep phase, verify it is available:
42
+
43
+ **Check**: Confirm `mcp__playwright__*` tools are available in your tool context (e.g., `mcp__playwright__browser_navigate`, `mcp__playwright__browser_snapshot`).
44
+
45
+ **If Playwright MCP tools are NOT available**: STOP immediately. Do not attempt to run qa-sweep — all browser automation steps will fail silently.
46
+
47
+ **Fix**:
48
+ 1. Register Playwright MCP: `claude mcp add playwright npx @playwright/mcp@latest`
49
+ 2. Restart Claude Code to reload MCP servers
50
+ 3. Verify with: `npx forgedock doctor` (Check 9 confirms registration)
51
+
52
+ See README.md Requirements section for full setup instructions.
53
+
54
+ ---
55
+
39
56
  ## Browser Tool Reference
40
57
 
41
58
  All browser automation uses Playwright MCP tools (`mcp__playwright__*`). Playwright MCP is a guaranteed ForgeDock dependency — see the Playwright MCP Prerequisite section above.
@@ -1226,7 +1226,7 @@ fi
1226
1226
 
1227
1227
  **Triggered when**: BILLING domain is set (changed files are under `billing/`, `credits/`, `subscription`, `ledger`, or `payment` paths).
1228
1228
 
1229
- **Why this matters**: Billing code has three reliably grep-detectable defect classes that account for ~27% of review findings in the AlterLab health window: (1) `org_id` not propagated to debit/refund ledger write paths (silent data ownership loss — charges appear with wrong tenant); (2) `asyncio.sleep` / blocking sleep called inside an advisory-lock or open-transaction scope (holds DB connection/lock for the full sleep duration, causing connection exhaustion and deadlocks under load); (3) stdlib `logging.*()` calls passed structlog-style keyword arguments (`event=`, named context keys) that are not valid stdlib kwargs — crashes at runtime with `TypeError: unexpected keyword argument`. <!-- Added: forge#1329 --> <!-- allowlist: AlterLab -->
1229
+ **Why this matters**: Billing code has three reliably grep-detectable defect classes that recur often enough in production billing code to justify a deterministic check: (1) `org_id` not propagated to debit/refund ledger write paths (silent data ownership loss — charges appear with wrong tenant); (2) `asyncio.sleep` / blocking sleep called inside an advisory-lock or open-transaction scope (holds DB connection/lock for the full sleep duration, causing connection exhaustion and deadlocks under load); (3) stdlib `logging.*()` calls passed structlog-style keyword arguments (`event=`, named context keys) that are not valid stdlib kwargs — crashes at runtime with `TypeError: unexpected keyword argument`. <!-- Added: forge#1329 -->
1230
1230
 
1231
1231
  **2Q-1: org_id propagation in ledger write functions**
1232
1232
 
@@ -1241,7 +1241,7 @@ while IFS= read -r f; do
1241
1241
  # Read the full function signature (up to 3 lines to cover multi-line sigs)
1242
1242
  sig=$(sed -n "${lineno},$((lineno+2))p" "$f" 2>/dev/null)
1243
1243
  echo "$sig" | grep -qE '\borg_id\b' || \
1244
- echo "BILLING-1 | MEDIUM | $f:$lineno | Ledger write function '$(echo "$rest" | grep -oE 'def \w+' | head -1)' signature does not include org_id parameter — verify org_id is propagated to all debit/refund paths (AlterLab#25348 pattern)" <!-- allowlist: AlterLab -->
1244
+ echo "BILLING-1 | MEDIUM | $f:$lineno | Ledger write function '$(echo "$rest" | grep -oE 'def \w+' | head -1)' signature does not include org_id parameter — verify org_id is propagated to all debit/refund paths"
1245
1245
  done
1246
1246
  done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1247
1247
  ```
@@ -1252,14 +1252,14 @@ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1252
1252
  # Grep billing Python files for asyncio.sleep / time.sleep called inside an advisory-lock
1253
1253
  # or open-transaction context (within 20 lines after lock acquisition or BEGIN/session open).
1254
1254
  # Holding a lock or DB connection across a sleep starves the connection pool and causes
1255
- # deadlocks under concurrent load (AlterLab#25725 pattern). <!-- allowlist: AlterLab -->
1255
+ # deadlocks under concurrent load.
1256
1256
  while IFS= read -r f; do
1257
1257
  [ -z "$f" ] && continue
1258
1258
  grep -nE "\basyncio\.sleep\b|\btime\.sleep\b" "$f" 2>/dev/null | while IFS=: read -r lineno rest; do
1259
1259
  start=$((lineno > 20 ? lineno - 20 : 1))
1260
1260
  window=$(sed -n "${start},$((lineno-1))p" "$f" 2>/dev/null)
1261
1261
  echo "$window" | grep -qE 'advisory_lock|BEGIN\b|async with.*session|async with.*conn|async with.*transaction|with.*engine\b' && \
1262
- echo "BILLING-2 | HIGH | $f:$lineno | asyncio.sleep/time.sleep inside advisory-lock or open-transaction scope — holds DB connection/lock for full sleep duration, causing connection exhaustion and deadlocks under load (AlterLab#25725 pattern). Move sleep outside lock/transaction scope." <!-- allowlist: AlterLab -->
1262
+ echo "BILLING-2 | HIGH | $f:$lineno | asyncio.sleep/time.sleep inside advisory-lock or open-transaction scope — holds DB connection/lock for full sleep duration, causing connection exhaustion and deadlocks under load. Move sleep outside lock/transaction scope."
1263
1263
  done
1264
1264
  done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1265
1265
  ```
@@ -1271,7 +1271,7 @@ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1271
1271
  # that are NOT valid stdlib logging kwargs (exc_info, stack_info, stacklevel, extra).
1272
1272
  # Structlog-style kwargs (event=, extra_=, named context keys) are NOT accepted by stdlib
1273
1273
  # Logger methods and crash at runtime with TypeError: unexpected keyword argument.
1274
- # (AlterLab#25715, #25837, #25819 pattern) <!-- allowlist: AlterLab -->
1274
+ # This is a recurring, well-observed pattern in production billing code.
1275
1275
  while IFS= read -r f; do
1276
1276
  [ -z "$f" ] && continue
1277
1277
  # Match logging.info/warning/error/debug/critical and logger.info/etc. calls with
@@ -1280,7 +1280,7 @@ while IFS= read -r f; do
1280
1280
  grep -nE "\b(logging|logger)\.(info|warning|error|debug|critical|exception)\s*\(.*\b\w+\s*=" "$f" 2>/dev/null | \
1281
1281
  grep -vE "\b(exc_info|stack_info|stacklevel|extra)\s*=" | \
1282
1282
  grep -v "^\s*#" && \
1283
- echo "BILLING-3 | HIGH | $f | stdlib logging call with non-stdlib keyword argument detected — stdlib Logger.info/warning/error/etc. only accept exc_info, stack_info, stacklevel, extra as kwargs. Structlog-style kwargs (event=, named context keys) cause TypeError at runtime (AlterLab#25715 pattern). Use structlog.get_logger() instead of logging if structlog kwargs are needed." <!-- allowlist: AlterLab -->
1283
+ echo "BILLING-3 | HIGH | $f | stdlib logging call with non-stdlib keyword argument detected — stdlib Logger.info/warning/error/etc. only accept exc_info, stack_info, stacklevel, extra as kwargs. Structlog-style kwargs (event=, named context keys) cause TypeError at runtime. Use structlog.get_logger() instead of logging if structlog kwargs are needed."
1284
1284
  done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1285
1285
  ```
1286
1286
 
@@ -95,7 +95,7 @@ fi
95
95
 
96
96
  ```bash
97
97
  # Filter to comments that contain a FORGE pipeline annotation
98
- FORGE_COMMENTS=$(echo "$ALL_COMMENTS" | jq -c '[.[] | select(.body | test("<!-- FORGE:(INVESTIGATOR|CONTRACT|CONTEXT|ARCHITECT|BUILDER|DECOMPOSED|TRAJECTORY)"))]')
98
+ FORGE_COMMENTS=$(echo "$ALL_COMMENTS" | jq -c '[.[] | select(.body | test("<!-- FORGE:(INVESTIGATOR|CONTRACT|CONTEXT|ARCHITECT|BUILDER|DECOMPOSED|SUMMARY|TRAJECTORY)"))]')
99
99
  FORGE_COUNT=$(echo "$FORGE_COMMENTS" | jq 'length')
100
100
 
101
101
  if [ "$FORGE_COUNT" -eq 0 ]; then
@@ -115,7 +115,7 @@ echo ""
115
115
 
116
116
  ## Step 5: Display Each Annotation
117
117
 
118
- Phase mapping (inline): `INVESTIGATOR`→Phase 1, `CONTRACT`→Phase 3C, `CONTEXT`→Phase 3C.5, `ARCHITECT`→Phase 3C.6, `BUILDER`→Phase 3M, `DECOMPOSED`→Phase 2, `TRAJECTORY`→Phase 7.
118
+ Phase mapping (inline): `INVESTIGATOR`→Phase 1, `CONTRACT`→Phase 3C, `CONTEXT`→Phase 3C.5, `ARCHITECT`→Phase 3C.6, `BUILDER`→Phase 3M, `DECOMPOSED`→Phase 2, `SUMMARY`→Phase C4.5, `TRAJECTORY`→Phase 7.
119
119
 
120
120
  For each FORGE comment in chronological order, print a formatted block. Use Python for robust multiline extraction from the comment body.
121
121
 
@@ -136,6 +136,7 @@ echo "$FORGE_COMMENTS" | jq -c '.[]' | while IFS= read -r COMMENT; do
136
136
  "FORGE:ARCHITECT") PHASE_LABEL="Phase 3C.6 — Architecture Plan" ;;
137
137
  "FORGE:BUILDER") PHASE_LABEL="Phase 3M — Implementation" ;;
138
138
  "FORGE:DECOMPOSED") PHASE_LABEL="Phase 2 — Decomposition" ;;
139
+ "FORGE:SUMMARY") PHASE_LABEL="Phase C4.5 — Shareable Summary" ;;
139
140
  "FORGE:TRAJECTORY") PHASE_LABEL="Phase 7 — Pipeline Trajectory" ;;
140
141
  *) PHASE_LABEL="$ANNOTATION" ;;
141
142
  esac
@@ -216,6 +217,19 @@ elif annotation == "FORGE:DECOMPOSED":
216
217
  for s in subs[:5]:
217
218
  print(f"│ {s}")
218
219
 
220
+ elif annotation == "FORGE:SUMMARY":
221
+ lane = extract(r'\*\*Lane\*\*\s*\|\s*([^\|]+)')
222
+ verdict = extract(r'\*\*Investigation verdict\*\*\s*\|\s*([^\|]+)')
223
+ task_type = extract(r'\*\*Task type\*\*\s*\|\s*([^\|]+)')
224
+ review = extract(r'\*\*Review verdict\*\*\s*\|\s*([^\|]+)')
225
+ files = extract(r'\*\*Files changed\*\*\s*\|\s*([^\|]+)')
226
+ pr_link = extract(r'\[View PR #(\d+)\]')
227
+ print(f"│ Lane : {lane.strip()[:60]}")
228
+ print(f"│ Verdict : {verdict.strip()[:60]}")
229
+ print(f"│ Task type : {task_type.strip()[:60]}")
230
+ print(f"│ Review : {review.strip()[:60]}")
231
+ print(f"│ Files : {files.strip()[:30]} | PR: #{pr_link}")
232
+
219
233
  elif annotation == "FORGE:TRAJECTORY":
220
234
  # Print the phase table lines verbatim (they're already formatted)
221
235
  in_table = False