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.
- package/README.md +8 -2
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +358 -42
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +31 -7
- package/bin/engine-cli.mjs +138 -2
- package/bin/engine.mjs +414 -21
- package/bin/forgedock.mjs +552 -44
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +189 -19
- package/bin/journey.mjs +109 -11
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +223 -22
- package/commands/qa-sweep.md +17 -0
- package/commands/quality-gate.md +6 -6
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +65 -0
- package/commands/upgrade-deps.md +523 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +10 -1
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +156 -0
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +17 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +3 -3
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +5 -11
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +3 -1
- package/packages/protocol/src/parse.js +19 -11
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +18 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +376 -0
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +274 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +353 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/commands/qa-sweep.md
CHANGED
|
@@ -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.
|
package/commands/quality-gate.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
|
package/commands/replay.md
CHANGED
|
@@ -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
|