forgedock 1.3.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 (45) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/state.mjs +20 -28
  9. package/bin/engine-cli.mjs +80 -5
  10. package/bin/engine.mjs +390 -18
  11. package/bin/forgedock.mjs +336 -59
  12. package/bin/hooks/interactive-engine.mjs +50 -24
  13. package/bin/hooks/pre-tool-use.mjs +169 -13
  14. package/bin/journey.mjs +8 -2
  15. package/bin/labels.json +5 -0
  16. package/bin/runner.mjs +321 -10
  17. package/bin/watch-utils.mjs +255 -0
  18. package/commands/cleanup.md +7 -3
  19. package/commands/orchestrate/config.md +41 -1
  20. package/commands/orchestrate/phase-1-resolve.md +234 -8
  21. package/commands/orchestrate/phase-3-dependency.md +27 -18
  22. package/commands/orchestrate/phase-4-execution.md +221 -22
  23. package/commands/quality-gate.md +6 -6
  24. package/commands/review-pr.md +23 -7
  25. package/commands/upgrade-deps.md +7 -2
  26. package/commands/work-on/decompose.md +2 -0
  27. package/commands/work-on/investigate.md +22 -4
  28. package/commands/work-on/remediate.md +2 -0
  29. package/commands/work-on/review.md +40 -1
  30. package/commands/work-on.md +19 -4
  31. package/docs/CONFIG.md +120 -0
  32. package/docs/spec/eval-run-result.md +8 -4
  33. package/docs/spec/label-state-machine.md +9 -0
  34. package/package.json +2 -2
  35. package/packages/protocol/src/emit.js +5 -22
  36. package/packages/protocol/src/html-comment-escape.js +68 -0
  37. package/packages/protocol/src/index.js +1 -0
  38. package/packages/protocol/src/parse.js +5 -6
  39. package/packages/protocol/src/phases.js +105 -0
  40. package/packages/protocol/src/types.js +17 -0
  41. package/scripts/check-admission-parity.mjs +167 -0
  42. package/scripts/check-command-side-effects.sh +181 -66
  43. package/scripts/check-phase-registry-drift.mjs +96 -0
  44. package/scripts/check-spec-markers.sh +1 -0
  45. package/scripts/forge-run.sh +35 -4
@@ -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
 
@@ -454,6 +522,7 @@ If the label is NOT terminal (e.g., `workflow:investigating`, `workflow:ready-to
454
522
  **LANE**: {LANE} (PR target: {PR_BASE})
455
523
  **Issue title**: {ISSUE_TITLE}
456
524
  {GIST_CONTEXT}
525
+ {SOURCE_PR_HINT_CONTEXT}
457
526
  "
458
527
  )
459
528
  ```
@@ -503,6 +572,20 @@ fi
503
572
 
504
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 -->
505
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
+
506
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.
507
590
 
508
591
  ```bash
@@ -635,6 +718,19 @@ classify_predecessor_state() {
635
718
  echo "DONE"
636
719
  elif echo "$PRED_LABELS" | grep -qxE "needs-human|workflow:awaiting-merge"; then
637
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"
638
734
  elif [ "$PRED_STATE" = "CLOSED" ]; then
639
735
  # Closed with no workflow:invalid/merged label (e.g. closed-not-planned) — treat as DONE,
640
736
  # not a new deadlock state; there is no pending code for dependents to wait on.
@@ -648,7 +744,7 @@ classify_predecessor_state() {
648
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.
649
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).
650
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.
651
- - **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.
652
748
 
653
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.
654
750
 
@@ -832,7 +928,7 @@ done
832
928
  echo "#{NUM}: $FINAL_STATE"
833
929
  ```
834
930
 
835
- 2. **If the issue is NOT in a terminal-for-this-agent state** (`workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`), the agent stalled mid-pipeline. **Resume it immediately**:
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:
836
932
  ```
837
933
  Agent(
838
934
  resume=AGENT_ISSUE_MAP[{NUMBER}],
@@ -895,9 +991,15 @@ done
895
991
 
896
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.
897
993
 
898
- 6.4. **Auto-dispatch remediation against a `needs-human`-gated predecessor's own PR** <!-- Added: forge#1813 --> — item 6.5 below tracks the *dependents* of a `GATED` predecessor; this item handles the predecessor's own PR, which item 6.5/6.6 never re-drive on their own. Run this check whenever a completed agent's issue classifies `GATED` **specifically via `needs-human`** — NOT `workflow:awaiting-merge`. That second state already means "remediated and re-reviewed to a clean verdict, just needs a human's merge click" (see forge#1810's guard) — dispatching remediation again would be redundant, not just wasteful, since there is nothing left to fix.
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.
899
995
 
900
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
+
901
1003
  PRED_CURRENT_LABEL=$(gh issue view "$PRED" -R {GH_REPO} --json labels \
902
1004
  --jq '[.labels[].name | select(. == "needs-human" or . == "workflow:awaiting-merge")] | .[0] // empty' 2>/dev/null)
903
1005
 
@@ -938,7 +1040,7 @@ Do not ask the user questions — you are running autonomously in the background
938
1040
  fi
939
1041
  ```
940
1042
 
941
- This satisfies #1809 Q2 (the orchestrator auto-dispatches remediation against the gated predecessor itself — the exact gap forge#1812's item 6.5/6.6 left open, since those items only ever track and wake *dependents*, never the gated PR's own remediation). The remediation agent's outcome is picked up on the **next** completion-monitoring cycle of this same Step 4B loop: if it lands (`workflow:merged`), item 6.6 below fires normally and wakes any `blocked-on-human-merge` dependents; if it holds/re-escalates, the predecessor simply remains `GATED` and item 6.5 continues tracking its dependents unchanged.
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.
942
1044
 
943
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`:
944
1046
 
@@ -1113,6 +1215,57 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1113
1215
  - Polling the same status check repeatedly on a timer
1114
1216
  - Waiting for a "batch" of completions before checking for newly ready issues — check after EVERY completion
1115
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
+
1116
1269
  ### Step 4B.5: Time-Based Stall Detection
1117
1270
 
1118
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.
@@ -1225,12 +1378,12 @@ gh issue list -R {GH_REPO} --state open --label "review-finding" --limit 20 \
1225
1378
  For each spawned finding, determine whether it should be **executed** or **deferred**:
1226
1379
 
1227
1380
  **Evaluation order** (first matching rule wins):
1228
- 0. **Batch fully human-gated** (`BATCH_FULLY_GATED == true`, always defer, even for P1/P2) <!-- Added: forge#1814 -->: The original batch (see Step 4B item 6.7) has exhausted into DONE/FAILED/GATED with nothing left `IN_PROGRESS` — the real blockers are the GATED issues, not a lack of dispatchable findings. Dispatching a new review-finding here cannot produce net batch progress; it only inflates the open-issue count while the productive path waits on a human merge. Always defer, checked before generation and priority. Rationale: this is the idle/backpressure policy this issue adds — without it, rule 2 (below) unconditionally executes P1/P2 findings regardless of how gated the rest of the batch is, which is the root cause of the net-negative churn this policy exists to stop.
1229
- 1. **Generation ≥ 2** (always defer, even for P1/P2): Finding was spawned by an issue that was itself a review-finding. Check the source issue's labels for `review-finding` — if the source has that label, the new finding is generation 2. Always defer. Rationale: gen-2+ cascade is theoretically unbounded — cap it here.
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 -->
1230
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.
1231
- 3. **Comment/typo heuristic** (P3 and below only): Finding title contains the word "comment" or "typo" (case-insensitive). These are 1-line cosmetic fixes that do not block other work.
1232
- 4. **P3 + same-file overlap**: Finding is labeled `P3` AND the file it targets overlaps with ANY file already in the current batch (active or queued in the DAG). Rationale: same-file P3 findings add predecessor edges that serialize agents — one finding per original issue increases wall-clock time with no proportional value.
1233
- 5. **Per-batch token budget** (P3 and below only, applied AFTER surface-area batching below — see "Per-batch token budget gate") <!-- Added: forge#1858 -->: Once `BATCH_TOKEN_SPEND` would exceed `TOKEN_BUDGET`, additional P3-and-below units (an unclubbed finding, or an already-clubbed batch issue) defer rather than dispatch. This is NOT part of the per-finding rule 0-4 chain immediately below — it is a quantity gate applied to the POST-clubbing `QUEUED_FINDINGS` list, so a same-run surface-area batch issue (surface-area batching below) is charged once for the whole cluster, not once per member. P1/P2 are NEVER gated by it (they are excluded by rule 2 before reaching this gate, same as they are excluded from rules 3-4).
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.
1234
1387
 
1235
1388
  **Defer** (do NOT add to the DAG) if rules 0, 1, 3, 4, or 5 match.
1236
1389
 
@@ -1269,15 +1422,39 @@ for FINDING_NUM in {spawned_finding_numbers}; do
1269
1422
  FINDING_DATA=$(gh issue view $FINDING_NUM -R {GH_REPO} --json labels,title,body \
1270
1423
  --jq '{labels: [.labels[].name], title: .title, body: .body}')
1271
1424
 
1272
- 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:")')
1273
1439
  TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
1274
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
+
1275
1445
  # Rule 0: Batch fully human-gated — checked FIRST, overrides even the P1/P2 priority
1276
1446
  # override below. BATCH_FULLY_GATED is computed once per completion cycle in Step 4B
1277
1447
  # item 6.7; read it here, do not recompute. <!-- Added: forge#1814 -->
1278
- 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
1279
1451
  DEFER=true; DEFER_REASON="batch fully human-gated — idle policy"
1280
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.
1281
1458
  elif SOURCE_NUM=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oP '(?i)spawned from issue #\K\d+|source issue[: #]+\K\d+' | head -1) && \
1282
1459
  [ -n "$SOURCE_NUM" ] && \
1283
1460
  gh issue view $SOURCE_NUM -R {GH_REPO} --json labels --jq '[.labels[].name]' 2>/dev/null | grep -q "review-finding"; then
@@ -1286,10 +1463,12 @@ for FINDING_NUM in {spawned_finding_numbers}; do
1286
1463
  elif [ "$PRIORITY" = "P1" ] || [ "$PRIORITY" = "P2" ]; then
1287
1464
  DEFER=false
1288
1465
  # Heuristic 2: Comment/typo keyword (only applies to P3 and below)
1289
- 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
1290
1468
  DEFER=true; DEFER_REASON="comment/typo heuristic"
1291
1469
  # Heuristic 3: P3 + same-file overlap
1292
- 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
1293
1472
  # Extract file target from finding body (look for code block or backtick path)
1294
1473
  FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oP '`[^\`]+\.(py|ts|tsx|sh|md)`' | head -1 | tr -d '`')
1295
1474
  if [ -n "$FINDING_FILE" ] && echo "$ALL_BATCH_FILES" | grep -qF "$FINDING_FILE"; then
@@ -1353,8 +1532,9 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1353
1532
  or ([.labels[]] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth"))
1354
1533
  ' >/dev/null && continue
1355
1534
 
1356
- # Only P3 findings are eligible (P1/P2 already dispatched individually above)
1357
- echo "$FINDING_DATA" | jq -e '[.labels[]] | any(. == "priority:P3")' >/dev/null || continue
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
1358
1538
 
1359
1539
  FINDING_FILE=$(echo "$FINDING_DATA" | jq -r '.body' | grep -oE '`[^`]+\.(py|tsx?|jsx?|sql|json|ya?ml|sh|md)`' | head -1 | tr -d '`')
1360
1540
  [ -z "$FINDING_FILE" ] && continue
@@ -1434,8 +1614,16 @@ Rule 5 from the evaluation order above. Charges ONE `TOKEN_ESTIMATE_PER_FINDING`
1434
1614
  TOKEN_BUDGET_DEFERRED_THIS_RUN=()
1435
1615
 
1436
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.
1437
1621
  FINDING_PRIORITY=$(gh issue view "$FINDING_NUM" -R {GH_REPO} --json labels \
1438
- --jq '([.labels[].name | select(startswith("priority:P"))] | .[0] // "priority:P3") | ltrimstr("priority:")' 2>/dev/null || echo "P3")
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")
1439
1627
 
1440
1628
  # P1/P2 are NEVER gated by the token budget — they were queued by rule 2 above
1441
1629
  # regardless of budget headroom; skip them here and leave them in QUEUED_FINDINGS.
@@ -1444,7 +1632,9 @@ for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
1444
1632
  fi
1445
1633
 
1446
1634
  PROJECTED_TOKEN_SPEND=$((BATCH_TOKEN_SPEND + TOKEN_ESTIMATE_PER_FINDING))
1447
- 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
1448
1638
  TOKEN_DEFERRED+=("$FINDING_NUM")
1449
1639
  TOKEN_BUDGET_DEFERRED_THIS_RUN+=("$FINDING_NUM")
1450
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})"
@@ -1688,7 +1878,16 @@ for FINDING_NUM in "${SWEEP_CANDIDATES[@]}"; do
1688
1878
  STATE=$(echo "$FINDING_DATA" | jq -r '.state')
1689
1879
  [ "$STATE" = "CLOSED" ] && continue
1690
1880
 
1691
- 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:")')
1692
1891
  TITLE=$(echo "$FINDING_DATA" | jq -r '.title')
1693
1892
 
1694
1893
  # Re-apply heuristics against the drained DAG (no active batch files)
@@ -1755,7 +1954,7 @@ if [ "${#TOKEN_GATED[@]}" -gt 0 ]; then
1755
1954
  [ "$STATE" = "CLOSED" ] && continue # resolved by another process — drop silently
1756
1955
 
1757
1956
  PROJECTED_SWEEP_SPEND=$((SWEEP_TOKEN_SPEND + TOKEN_ESTIMATE_PER_FINDING))
1758
- if [ "$PROJECTED_SWEEP_SPEND" -gt "$TOKEN_BUDGET" ]; then
1957
+ if [ "$TOKEN_BUDGET" != "unlimited" ] && [ "$PROJECTED_SWEEP_SPEND" -gt "$TOKEN_BUDGET" ]; then
1759
1958
  SWEEP_STILL_DEFERRED+=($FINDING_NUM)
1760
1959
  echo "Sweep: #${FINDING_NUM} still deferred (token budget — sweep allowance ${TOKEN_BUDGET} would be exceeded at ${PROJECTED_SWEEP_SPEND})"
1761
1960
  else
@@ -1915,7 +2114,7 @@ fi
1915
2114
  Completion Sweep Results:
1916
2115
  Dispatched: #{A}, #{B} (file overlap cleared after DAG drain)
1917
2116
  Still deferred (cosmetic): #{C} (comment/typo)
1918
- 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)
1919
2118
  Idle-gated — still deferred: #{E} (batch still fully human-gated — waiting on a merge)
1920
2119
  Idle-gated — cleared: #{F} (a gating PR merged since deferral — no longer idle)
1921
2120
  Token-gated — cleared: #{G} (fits fresh sweep token allowance — batch spend ${SWEEP_TOKEN_SPEND}/${TOKEN_BUDGET})
@@ -1926,7 +2125,7 @@ Completion Sweep Results:
1926
2125
 
1927
2126
  **Anti-patterns — DO NOT DO THIS:**
1928
2127
  - Re-sweeping findings spawned during the sweep itself — this creates unbounded recursion. Sweep is a single pass.
1929
- - Overriding generation >= 2 deferrals — the cascade cap is absolute.
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 -->
1930
2129
  - Skipping the sweep because "there are only a few" deferred findings — even one deferred finding represents unresolved work.
1931
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.
1932
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.
@@ -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
 
@@ -2229,13 +2229,30 @@ Post a welcome comment when all three conditions are true:
2229
2229
  # Read config — default enabled when key is absent
2230
2230
  WELCOME_ENABLED=$(yq '.community.welcome_new_contributors // true' forge.yaml 2>/dev/null || echo 'true')
2231
2231
 
2232
- if [ "$IS_FIRST_TIME_CONTRIBUTOR" = "true" ] && [ "$WELCOME_ENABLED" != "false" ]; then
2232
+ if [ "$IS_FIRST_TIME_CONTRIBUTOR" = "true" ] && [ "$WELCOME_ENABLED" != "false" ] && [ "$REVIEW_MODE" = "single-pr" ]; then
2233
2233
  # Read optional custom values from forge.yaml community section
2234
2234
  CONTRIBUTING_URL=$(yq '.community.contributing_url // "CONTRIBUTING.md"' forge.yaml 2>/dev/null || echo 'CONTRIBUTING.md')
2235
2235
  CUSTOM_CONVENTIONS=$(yq '.community.conventions // ""' forge.yaml 2>/dev/null || echo '')
2236
2236
  PROJECT_NAME_WELCOME=$(yq '.project.name // "this project"' forge.yaml 2>/dev/null || echo 'this project')
2237
2237
 
2238
- gh pr comment "$ARGUMENTS" --body "## Welcome to ${PROJECT_NAME_WELCOME}! 🎉
2238
+ # Resolve the conventions text into a plain shell variable BEFORE interpolating it into
2239
+ # the --body string below. A nested $(...) command substitution containing an escaped
2240
+ # \$VAR here is never expanded — see forge#2214.
2241
+ if [ -n "$CUSTOM_CONVENTIONS" ]; then
2242
+ CONVENTIONS_TEXT="$CUSTOM_CONVENTIONS"
2243
+ else
2244
+ CONVENTIONS_TEXT="- **Conventional commits**: prefix your commit messages with \`fix(scope):\`, \`feat(scope):\`, \`refactor(scope):\`, etc.
2245
+ - **DCO sign-off**: all commits must be signed off with \`git commit -s\` (Developer Certificate of Origin — required for the dual-license model).
2246
+ Fix unsigned commits: \`git commit --amend -s --no-edit && git push --force-with-lease\` <!-- allowlist:check-command-side-effects -->
2247
+ - **Issues, not inline fixes**: when you spot a bug or improvement opportunity, open a GitHub issue rather than fixing it inline — so it flows through the pipeline with full traceability."
2248
+ fi
2249
+
2250
+ # Unquoted heredoc (not the corpus's quoted <<'EOF' form) — this body needs live
2251
+ # ${VAR} interpolation, which a quoted heredoc would disable. Literal backticks in
2252
+ # the static text are still escaped (\`) since they remain live inside an unquoted
2253
+ # heredoc, same as inside a double-quoted string. See forge#2223.
2254
+ gh pr comment "$ARGUMENTS" --body "$(cat <<EOF
2255
+ ## Welcome to ${PROJECT_NAME_WELCOME}! 🎉
2239
2256
 
2240
2257
  Hi @${PR_AUTHOR} — this looks like your first merged contribution here. Thanks for opening a PR!
2241
2258
 
@@ -2245,17 +2262,16 @@ The automated review pipeline (/review-pr) ran against your PR. It spawned domai
2245
2262
 
2246
2263
  ### Key conventions
2247
2264
 
2248
- $([ -n "\$CUSTOM_CONVENTIONS" ] && echo "\$CUSTOM_CONVENTIONS" || echo "- **Conventional commits**: prefix your commit messages with \`fix(scope):\`, \`feat(scope):\`, \`refactor(scope):\`, etc.
2249
- - **DCO sign-off**: all commits must be signed off with \`git commit -s\` (Developer Certificate of Origin — required for the dual-license model).
2250
- Fix unsigned commits: \`git commit --amend -s --no-edit && git push --force-with-lease\`
2251
- - **Issues, not inline fixes**: when you spot a bug or improvement opportunity, open a GitHub issue rather than fixing it inline — so it flows through the pipeline with full traceability.")
2265
+ ${CONVENTIONS_TEXT}
2252
2266
 
2253
2267
  ### Getting started
2254
2268
 
2255
2269
  - Contributing guide: [\`${CONTRIBUTING_URL}\`](${CONTRIBUTING_URL})
2256
2270
  - Run \`npx forgedock\` to install the pipeline commands locally if you want to run /review-pr or /work-on yourself.
2257
2271
 
2258
- Welcome aboard!"
2272
+ Welcome aboard!
2273
+ EOF
2274
+ )"
2259
2275
  fi
2260
2276
  ```
2261
2277
 
@@ -376,7 +376,12 @@ Store created issue numbers as `CREATED_ISSUES` (empty when `DRY_RUN=true`).
376
376
  For each issue in `CREATED_ISSUES`, invoke `/work-on` via the Skill tool:
377
377
 
378
378
  ```
379
- Skill(skill: "work-on", args: "{ISSUE_NUMBER}")
379
+ # DRY_RUN guard — must stay above the Skill() call it guards (forge#1609).
380
+ if [ "$DRY_RUN" = "true" ]; then
381
+ echo "[DRY RUN] Would invoke: Skill(skill: \"work-on\", args: \"{ISSUE_NUMBER}\")"
382
+ else
383
+ Skill(skill: "work-on", args: "{ISSUE_NUMBER}")
384
+ fi
380
385
  ```
381
386
 
382
387
  Run **sequentially** — each `/work-on` invocation is heavyweight. If one fails, continue to the next and record the outcome.
@@ -389,7 +394,7 @@ FIX_RESULTS = [
389
394
  ]
390
395
  ```
391
396
 
392
- **DRY_RUN check**: If `DRY_RUN=true`, print what would be invoked but skip `Skill()` calls.
397
+ **DRY_RUN check**: enforced inline above — the guard wraps the `Skill()` call rather than being stated after it, so a dry run cannot invoke `/work-on` even if only this section is read in isolation (forge#1609).
393
398
 
394
399
  ---
395
400
 
@@ -12,6 +12,8 @@ argument-hint: "[issue number] [--repo GH_REPO] [--gh-flag GH_FLAG]"
12
12
  **Invoked by**: `work-on.md` routing loop, when `INVESTIGATE_RESULT.decompose = YES`.
13
13
  **Output**: Create sub-issues, update parent tracker, post `<!-- FORGE:DECOMPOSED -->` comment, set labels. STOP — each sub-issue runs its own /work-on.
14
14
 
15
+ **Engine coverage** (forge#2379): this subcommand's `command` name (`work-on/decompose`) and completion marker (`FORGE:DECOMPOSED:COMPLETE`) are now registered as a real phase in the headless engine's phase table — `decompose` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and the matching `decompose` entry in `bin/engine/phases.mjs`'s `PHASES` array. The engine's `investigate` phase hands off to it on `DECOMPOSE:YES` instead of terminating in place, so a headless `runIssue()` walk now actually dispatches this subcommand rather than stopping short.
16
+
15
17
  **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154. This file's mechanical bits (label transitions, sub-issue creation) stay at this tier because they're interleaved with the reasoning-heavy sub-issue design steps in the same `Skill()` invocation — see `work-on.md` section "Model and Effort Tiering — What Actually Applies". <!-- Added: forge#1827 -->
16
18
  **NEVER use plan mode (EnterPlanMode).**
17
19