forgedock 1.8.4 → 1.9.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 (38) hide show
  1. package/bin/engine/admission.mjs +23 -4
  2. package/bin/engine/admission.test.mjs +37 -0
  3. package/commands/orchestrate/phase-1-resolve.md +40 -24
  4. package/commands/orchestrate/phase-3-dependency.md +56 -11
  5. package/commands/orchestrate/phase-4-execution.md +349 -111
  6. package/commands/orchestrate/phase-5-cleanup.md +17 -0
  7. package/commands/orchestrate/phase-6-report.md +1 -1
  8. package/commands/quality-gate.md +51 -0
  9. package/commands/recover-orphans.md +3 -3
  10. package/commands/review-pr.md +87 -16
  11. package/commands/work-on/build/context.md +25 -4
  12. package/commands/work-on/build/validate.md +1 -0
  13. package/commands/work-on/build.md +26 -9
  14. package/commands/work-on/close.md +104 -40
  15. package/commands/work-on/decompose.md +13 -7
  16. package/commands/work-on/investigate.md +13 -7
  17. package/commands/work-on/remediate.md +15 -4
  18. package/commands/work-on/review.md +48 -19
  19. package/commands/work-on.md +62 -9
  20. package/docs/CODEX.md +12 -0
  21. package/docs/CONFIG.md +49 -1
  22. package/docs/RELEASE-NOTES.md +52 -0
  23. package/package.json +1 -1
  24. package/scripts/amplification-breaker.sh +89 -0
  25. package/scripts/amplification-breaker.test.sh +39 -0
  26. package/scripts/check-admission-parity.mjs +74 -1
  27. package/scripts/check-spec-markers.sh +67 -3
  28. package/scripts/classify-finding.sh +120 -0
  29. package/scripts/classify-finding.test.sh +74 -0
  30. package/scripts/close-dossier-no-main-commit.test.sh +139 -0
  31. package/scripts/forge-root.test.sh +84 -27
  32. package/scripts/lint-build-validate-files.sh +29 -0
  33. package/scripts/lint-build-validate-files.test.sh +25 -0
  34. package/scripts/lint-dispatch-prompt.test.sh +2 -1
  35. package/scripts/recovery-claim-live.sh +73 -0
  36. package/scripts/recovery-claim-live.test.sh +66 -0
  37. package/scripts/verify-phase-trail.sh +183 -4
  38. package/scripts/verify-phase-trail.test.sh +211 -1
@@ -80,6 +80,23 @@ fi
80
80
 
81
81
  **Idempotency**: `gh issue close` on an already-closed issue succeeds as a no-op (no error) — safe to re-run this step on a resumed/compacted session without an extra pre-check.
82
82
 
83
+ ### Step 5C.5: Remove the trail cache directory <!-- Added: forge#3206 -->
84
+
85
+ Defense in depth for the per-run `$TRAIL_CACHE_DIR` (see `phase-4-execution.md` "Cycle-start cache reset"). Phase 4 already calls `cleanup_trail_cache` on Termination, Step 4F and "Stopping the orchestrator"; this step guarantees removal when Phase 5 is reached by any other path. It is idempotent and a no-op when the directory is absent. Re-declare the path helper in the same Bash block (each Bash call is a fresh shell; never trust a pre-set `TRAIL_CACHE_DIR` before `rm -rf`):
86
+
87
+ ```bash
88
+ trail_cache_dir() {
89
+ printf '%s' "${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/forge-trail-$(id -u)-$(printf '%s' "{GH_REPO}" | tr -c 'A-Za-z0-9._-' '_')"
90
+ }
91
+ cleanup_trail_cache() {
92
+ local D; D="$(trail_cache_dir)"
93
+ case "$D" in */forge-trail-*) ;; *) return 0 ;; esac
94
+ case "$D" in *..*) return 0 ;; esac
95
+ rm -rf -- "$D"; unset TRAIL_CACHE_DIR; return 0
96
+ }
97
+ cleanup_trail_cache
98
+ ```
99
+
83
100
  ### Step 5D: Report cleanup results
84
101
 
85
102
  Include the cleanup summary in the final report (Phase 6), including whether the coordination issue was closed this run (see Step 5C). If cleanup found problems, call them out — they indicate agent pipeline failures that may need investigation.
@@ -130,7 +130,7 @@ for NUM in {all_batch_issue_numbers}; do
130
130
 
131
131
  TITLE=$(gh issue view "$NUM" -R {GH_REPO} --json title --jq '.title' 2>/dev/null || echo "")
132
132
  GATING_PRED=$(gh api repos/{GH_REPO}/issues/${NUM}/comments \
133
- --jq '[.[] | select(.body | contains("FORGE:BLOCKED_ON_HUMAN_MERGE"))] | last | (.body | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p) // ""' 2>/dev/null || echo "")
133
+ --jq '[.[] | select((.body // "") | contains("FORGE:BLOCKED_ON_HUMAN_MERGE"))] | last | ((.body // "") | capture("Gating predecessor\\*\\*: #(?<p>[0-9]+)").p) // ""' 2>/dev/null || echo "")
134
134
  GATING_PR=""
135
135
  if [ -n "$GATING_PRED" ]; then
136
136
  GATING_PR=$(gh pr list -R {GH_REPO} --state open --search "\"Closes #${GATING_PRED}\" in:body" \
@@ -104,6 +104,7 @@ Before running checks, classify the changed files into domains. This avoids runn
104
104
  | `*.py` files with new module-level `dict`, `set`, `Counter`, `Lock`, or `defaultdict` assignments | STATE |
105
105
  | `*.py` in `services/worker/`, `infra/`, or `browser/` with new assignments to variables containing `MB`, `SIZE`, `MAX`, `LIMIT`, `THRESHOLD`, or `TIMEOUT` | CAPACITY *(scraping pack — opt-in, see 2K)* |
106
106
  | `*.yml` in `.github/workflows/` | WORKFLOW |
107
+ | ANY diff (universal — every changed file set) | COVERAGE_REDUCTION *(see 2U)* <!-- Added: forge#3257 --> |
107
108
  | `*.py` with new `from app.` import statements added outside `try:` blocks | IMPORT_RESOLUTION |
108
109
  | `Dockerfile*` or `entrypoint*.sh` with added/changed `USER`, `su-exec`, `gosu`, or `setuid` | INFRA |
109
110
  | `*.py` in `routers/` where the diff removes a line containing an or-gate condition (lines starting with `-` containing `if.*or`) | ROUTER_BUG |
@@ -158,6 +159,9 @@ for f in "${CHANGED_FILES_ARR[@]}"; do
158
159
  esac
159
160
  done
160
161
 
162
+ # COVERAGE_REDUCTION is universal: a deleted test or workflow step can ride along with any file set (see 2U)
163
+ DOMAINS="$DOMAINS COVERAGE_REDUCTION"
164
+
161
165
  # Check for asyncio concurrency patterns in Python files
162
166
  grep -lE "asyncio\.shield|asyncio\.wait_for|Task\.cancel" {CHANGED_FILES} 2>/dev/null | grep -qE '\.py$' && DOMAINS="$DOMAINS CONCURRENCY"
163
167
 
@@ -274,6 +278,7 @@ the gate rather than leaving the workflow blocked.
274
278
  - **2R.5 (Adaptive gate.d checks)**: ALWAYS run when `adaptive_scripts.enabled` — executes `.forgedock/scripts/gate.d/*.sh`; each script is repo-specific and was promoted from a recurring finding pattern <!-- Added: forge#1739 -->
275
279
  - **2G.8 (Wire-through proof)**: Run if `WIRE_THROUGH` in DOMAINS — demands each newly added conditional path be demonstrably reachable <!-- Added: forge#1731 -->
276
280
  - **2G.9 (Danger-zone recurrence)**: Run if `FORGE_GRAPH` in DOMAINS — cross-references injected danger-zone rule cards from FORGE:CONTEXT against the diff; violations tagged `known-pattern-recurrence` (HIGH) <!-- Added: forge#1744 -->
281
+ - **2U (Coverage-reduction check)**: ALWAYS run (`COVERAGE_REDUCTION` in DOMAINS) — flags diffs that delete test files, remove test cases, add skip markers, or remove/disable workflow test steps <!-- Added: forge#3257 -->
277
282
  - **2S (Test failure classification)**: Run if any Step 2 check invoked a test suite and it failed — classifies the failure as PRE_BROKEN, FLAKY, or REAL using `scripts/flaky-quarantine.sh` <!-- Added: forge#1336 -->
278
283
  - **2T (Subscribed pattern card rules)**: ALWAYS run when `pattern_feeds.enabled` is true — injects Tier-B (warning-only) learned rules from subscribed exchange cards that have a `gate.d` check template matching any changed file's stack <!-- Added: forge#1746 -->
279
284
 
@@ -1298,6 +1303,51 @@ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1298
1303
 
1299
1304
  **Note**: 2Q-3 (stdlib logging check) fires on ALL changed Python files regardless of path — stdlib logger misuse is not specific to billing paths and is a universal crash class. 2Q-1 and 2Q-2 fire only on billing-domain files.
1300
1305
 
1306
+ ### 2U: Coverage-reduction check (ALWAYS — a red check must be fixed, not silenced)
1307
+
1308
+ <!-- Added: forge#3257 -->
1309
+
1310
+ **Bug class**: a change turns a failing check green by shrinking what is checked (deleting a test file, dropping a test case, adding a skip marker, or removing/disabling a workflow test step) instead of fixing the code under test. Coverage silently disappears and nothing downstream notices.
1311
+
1312
+ Run against the diff versus the merge-base of `origin/{PR_BASE}` and `HEAD` (`PR_BASE` defaults to `staging`), so changes merged to the base after the branch point are not misread as PR-side deletions. A missing base ref is an error finding, never a silent pass. Use `--find-renames` so a rename is NOT treated as a deletion. Bash must stay portable (no `grep -P`, no GNU-only flags).
1313
+
1314
+ ```bash
1315
+ cd {WORKTREE_PATH}
1316
+ PR_BASE="${PR_BASE:-staging}"
1317
+ if ! git rev-parse --verify --quiet "origin/$PR_BASE^{commit}" >/dev/null; then
1318
+ echo "COVERAGE-ERR | HIGH | quality-gate | base ref origin/$PR_BASE not found — fetch it; coverage-reduction check could not run"
1319
+ else
1320
+ BASE_REF=$(git merge-base "origin/$PR_BASE" HEAD) || { echo "COVERAGE-ERR | HIGH | quality-gate | merge-base failed for origin/$PR_BASE"; BASE_REF=""; }
1321
+ fi
1322
+ if [ -n "${BASE_REF:-}" ]; then
1323
+ TEST_PATH_RE='(^|/)(test_[^/]*|[^/]*_test\.[^/]*|[^/]*\.test\.[^/]*|[^/]*\.spec\.[^/]*)$|(^|/)(tests|__tests__)/'
1324
+
1325
+ # (a) deleted test files (renames excluded via --find-renames)
1326
+ git diff --find-renames --diff-filter=D --name-only "$BASE_REF" | grep -E "$TEST_PATH_RE" \
1327
+ && echo "COVERAGE-1: deleted test file(s)"
1328
+
1329
+ # (b) removed test cases with no added counterpart, (c) newly added skip markers
1330
+ git diff -U0 "$BASE_REF" -- . | grep -E '^-[[:space:]]*(it|test|describe)\(|^-[[:space:]]*(async )?def test_' \
1331
+ && echo "COVERAGE-1: removed test case line(s) — confirm each has an added counterpart in the diff"
1332
+ git diff -U0 "$BASE_REF" -- . | grep -E '^\+.*(\.skip\(|@pytest\.mark\.skip|@pytest\.mark\.xfail|xfail|continue-on-error:[[:space:]]*true|if:[[:space:]]*false)' \
1333
+ && echo "COVERAGE-1: newly added skip/xfail/continue-on-error/if: false marker"
1334
+
1335
+ # (d) removed or disabled workflow test steps
1336
+ git diff -U0 "$BASE_REF" -- .github/workflows | grep -E '^-.*run:.*(test|pytest|jest|vitest|mocha|\.test\.sh|npm t|go test|cargo test)' \
1337
+ && echo "COVERAGE-1: removed workflow test step"
1338
+ fi
1339
+ ```
1340
+
1341
+ Also treat a test command that is commented out (`+#` of a previously live `run:` test line) as a removal.
1342
+
1343
+ **Finding**: a `COVERAGE-ERR` line (base ref missing) is a HIGH finding returned as-is — never treat it as clean. Otherwise emit `COVERAGE-1 | HIGH | {file} | coverage reduction: {removed test | deleted test | disabled workflow test step} — restore it and fix the code under test`. Scan results are candidates, not verdicts: confirm each by reading the diff hunk before reporting.
1344
+
1345
+ **Exemption (only one)**: a legitimate deletion is allowed ONLY when the commit message or PR body gives an explicit justification tying the removal to the removal of the tested code (the code the test covered is also deleted in this diff). "Flaky", "failing", "Windows-only", or "to get CI green" are NOT justifications. When exempted, record the justification in the finding as a LOW (advisory, not returned).
1346
+
1347
+ **Fix rule**: a COVERAGE-1 finding is fixed by restoring the test or step and fixing the cause — never by suppressing the finding or re-wording the diff. If the cause cannot be fixed in scope, the caller escalates (`needs-human`) naming the failing tests/assertions.
1348
+
1349
+ ---
1350
+
1301
1351
  ### 2R: Registry checks (ALWAYS — promoted patterns from recurring review findings)
1302
1352
 
1303
1353
  <!-- Added: forge#1331 -->
@@ -1698,6 +1748,7 @@ OR:
1698
1748
  6. SHELL-1 | HIGH | deploy.sh | Hardcoded 'localhost' in DB connection function — use ${POSTGRES_HOST:-localhost} pattern
1699
1749
  7. INFRA-1 | HIGH | Dockerfile | Filesystem write ops found under named volume mount — add chown before privilege drop in entrypoint
1700
1750
  8. ROUTER-1 | HIGH | routers/handler.py | Residual unfixed gate condition pattern found in sibling router file routers/other_handler.py:N — fix may be incomplete across all callers
1751
+ 9. COVERAGE-1 | HIGH | .github/workflows/ci.yml | coverage reduction: removed workflow test step — restore it and fix the code under test
1701
1752
  ```
1702
1753
 
1703
1754
  **Severity classification:**
@@ -317,7 +317,7 @@ done
317
317
 
318
318
  For each diagnosed issue, apply the recovery action. All mutating actions are skipped when `DRY_RUN=true`.
319
319
 
320
- **Claim before re-entering the pipeline** (forge#3158): the `review-pr` first-refusal resume and `create-pr` re-enter `/work-on` inline. A repeated or concurrent sweep, or a live `/orchestrate` agent, could otherwise run the pipeline twice on one issue. Every inline `/work-on` resume MUST go through `claim_orphan` first and run inside `resume_orphan_inline`, which releases the claim on every exit. A sweep is not an orchestrator, so it takes an issue-scoped `FORGE:RECOVERY_CLAIM` marker (visible to other sweeps) and defers to any live orchestrator signal (a fresh `FORGE:HEARTBEAT`, which `/work-on --under-orchestration` posts at every phase entry).
320
+ **Claim before re-entering the pipeline** (forge#3158): the `review-pr` first-refusal resume and `create-pr` re-enter `/work-on` inline. A repeated or concurrent sweep, or a live `/orchestrate` agent, could otherwise run the pipeline twice on one issue. Every inline `/work-on` resume MUST go through `claim_orphan` first and run inside `resume_orphan_inline`, which releases the claim on every exit. A sweep is not an orchestrator, so it takes an issue-scoped `FORGE:RECOVERY_CLAIM` marker (visible to other sweeps) and defers to any live orchestrator signal (a fresh `FORGE:HEARTBEAT`, which `/work-on --under-orchestration` posts at every phase entry). The claim is honored in the other direction too (forge#3172): `/work-on` Phase 0A.4 and `/orchestrate` Phase 4 dispatch consult the shared `scripts/recovery-claim-live.sh` and defer to a live claim, so the inline resume passes `--recovery-sweep ${SWEEP_ID}` to exempt its own claim.
321
321
 
322
322
  **Claim refresh and guaranteed release** (forge#3171): liveness is judged from the claim comment's `updated_at`, and an inline resume posts no `FORGE:HEARTBEAT` (that is gated on `--under-orchestration`, which sweeps do not pass). So the claim is kept alive by editing it: `claim_orphan` records the claim comment id, and `start_claim_keepalive` runs `refresh_orphan` in a background loop every `RECOVERY_CLAIM_TTL_MIN / 3` minutes while the resume runs. `refresh_orphan` PATCHes the claim body, keeping the `**Sweep: id**` line and appending a changing `Refreshed: <UTC>` line, so `updated_at` always advances. Refresh is best-effort; if the keepalive is lost the claim degrades to the plain TTL behavior. `resume_orphan_inline` registers `trap ... release_orphan ... EXIT` in a subshell, so the claim is released always, on success and when the `Skill(...)` call fails, and `release_orphan` stops the keepalive first and is idempotent (it never posts the release marker twice). The keepalive and the `Skill(...)` call belong to the same sweep run. DRY_RUN makes every one of these helpers a no-op.
323
323
 
@@ -423,7 +423,7 @@ resume_orphan_inline() {
423
423
  trap 'release_orphan "$num"' EXIT
424
424
  start_claim_keepalive "$num"
425
425
  refresh_orphan "$num"
426
- Skill(skill="{FORGE_SKILL_PREFIX}work-on", args="${num}")
426
+ Skill(skill="{FORGE_SKILL_PREFIX}work-on", args="${num} --recovery-sweep ${SWEEP_ID}")
427
427
  )
428
428
  }
429
429
  ```
@@ -590,7 +590,7 @@ See the latest FORGE:PHASE_TRAIL_FAILED comment for the missing phases. Re-run e
590
590
  # Branch has commits but no PR — resume /work-on to create PR
591
591
  echo " Applying create-pr: resuming /work-on to advance from build to PR creation"
592
592
  if [ "$DRY_RUN" = "true" ]; then
593
- echo " [DRY-RUN] Would: Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='$NUM')"
593
+ echo " [DRY-RUN] Would: Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='$NUM --recovery-sweep $SWEEP_ID')"
594
594
  RECOVERY_RESULTS="${RECOVERY_RESULTS}| #${NUM} | create-pr | Would resume /work-on — branch $BRANCH has commits, no PR |\n"
595
595
  elif claim_orphan "$NUM"; then
596
596
  resume_orphan_inline "$NUM" # releases the claim on every exit
@@ -25,7 +25,7 @@ allowed-tools: Task, Agent, Bash, Read, Grep, Glob, WebFetch, Skill
25
25
 
26
26
  2. **Post the FORGE:REVIEW verdict regardless of finding severity.** A review that completes but posts no `<!-- FORGE:REVIEW -->` comment is invisible to the pipeline. Even a PASS verdict must be posted.
27
27
 
28
- 3. **Review findings do NOT block merge UNLESS they meet the Blocking Criteria in §7B** (a CONFIRMED HIGH/CRITICAL finding, a purpose regression, a merge conflict, or a build/type/test failure) **or the calibration threshold check in §7B.5 sets `CALIBRATION_NEEDS_HUMAN=true`** (HIGH-confidence task type with historical survival < 80%). File every finding that survives the §6B.5 note disposition (MEDIUM+ severity, or CONFIRMED/LIKELY above LOW; security/billing always) as a GitHub issue with the `review-finding` label. LOW/POSSIBLE notes are fixed in-PR, listed in the PR body, or dropped — never filed as standalone issues. Minor/style findings never block; §7B's and §7B.5's blocking conditions always do — including under `--auto-merge`. <!-- forge#1741 -->
28
+ 3. **Review findings do NOT block merge UNLESS they meet the Blocking Criteria in §7B** (a CONFIRMED HIGH/CRITICAL finding, a purpose regression, a merge conflict, or a build/type/test failure) **or the calibration threshold check in §7B.5 sets `CALIBRATION_NEEDS_HUMAN=true`** (HIGH-confidence task type with historical survival < 80%). File every finding that survives the §6B.5 note disposition (`scripts/classify-finding.sh`: HIGH+ always; MEDIUM CONFIRMED/LIKELY; LOW/POSSIBLE only when security/billing content matches; stricter on review-finding lineage) as a GitHub issue with the `review-finding` label. LOW/POSSIBLE notes are fixed in-PR, listed in the PR body, or dropped — never filed as standalone issues. Minor/style findings never block; §7B's and §7B.5's blocking conditions always do — including under `--auto-merge`. <!-- forge#1741 -->
29
29
 
30
30
  4. **Route correctly at Phase 0.** If the input is "staging" or the PR targets `main`, invoke `Skill("{FORGE_SKILL_PREFIX}review-pr-staging", ...)` — do NOT run the standard PR review pipeline against a staging→main PR.
31
31
 
@@ -1657,7 +1657,7 @@ If synthesis needed, launch a `general-purpose` Task (model: `"{SUBAGENT_MODEL}"
1657
1657
 
1658
1658
  ## Phase 6: Finding Triage & Issue Creation (MANDATORY)
1659
1659
 
1660
- **STOP. DO NOT skip this phase. DO NOT post summary first.** Every finding MUST become a GitHub issue BEFORE the summary.
1660
+ **STOP. DO NOT skip this phase. DO NOT post summary first.** Every finding that §6B.5 classifies as ISSUE MUST become a GitHub issue BEFORE the summary; every NOTE gets a recorded disposition instead (§6B.5).
1661
1661
 
1662
1662
  ### 6A: Extract Findings
1663
1663
 
@@ -1689,13 +1689,29 @@ If still 0: review is clean — skip to Phase 7.
1689
1689
 
1690
1690
  ### 6B.5: Non-blocking note disposition (MANDATORY before 6C — forge#3060)
1691
1691
 
1692
- Filing a standalone `review-finding` issue for every LOW/POSSIBLE reviewer note makes the cascade amplify (each merged fix PR spawns its own P3 issues, which spawn more). Classify each deduped finding before 6C:
1692
+ Filing a standalone `review-finding` issue for every LOW/POSSIBLE reviewer note makes the cascade amplify (each merged fix PR spawns its own P3 issues, which spawn more). Classify each deduped finding before 6C with the deterministic classifier — do NOT hand-classify when the script resolves:
1693
1693
 
1694
- - **NOTE** (does NOT become an issue): `**Severity**: LOW`, OR `**Confidence**: POSSIBLE` with Severity below HIGH.
1695
- - **ISSUE** (continues to 6C unchanged): everything else — MEDIUM+ severity and CONFIRMED/LIKELY findings above LOW, i.e. P0/P1/P2 behaviour is exactly as before.
1696
- - **Safety exemption**: a finding is never demoted to a NOTE (it is filed as before) if it came from the Security, Auth, Billing, Concurrency or Database review agent, OR its file path / title / body matches `\b(security|auth|authz|authn|billing|payment|stripe|charge|invoice|injection|xss|csrf|ssrf|idor|secrets?|credentials?|permissions?|sql)\b` (matched case-insensitively on word or underscore-separated parts, so `auth_service` and `billing_handler` match but `author`/`tokenizer` do not; also exempt: `token`, `password`, `redact`). Domain of origin is checked first so a generically titled auth/IDOR/injection finding cannot be dropped.
1697
- - **Precedence**: the safety exemption above ALWAYS wins over the P3-lineage rule below and over every other NOTE rule. A finding with missing or unparseable severity defaults to ISSUE.
1698
- - **Stricter rule on P3-lineage PRs** (applies only to findings not covered by the safety exemption): when the PR's linked issue (`MERGE_ISSUE`/`Closes #N`) carries the `review-finding` label and `priority:P3`, only CONFIRMED findings of MEDIUM+ severity, or any finding of HIGH+ severity, become issues. Everything else is a NOTE. A fix for a polish finding must not mint new polish findings.
1694
+ - **ISSUE always**: `**Severity**: HIGH` or `CRITICAL`, or a missing/unparseable severity.
1695
+ - **Safety exemption is content-based**: a LOW or POSSIBLE finding is still filed when its file path / title / body matches the security/billing keyword set (`security|auth|authz|authn|billing|payment|stripe|charge|invoice|injection|xss|csrf|ssrf|idor|secret(s)|credential(s)|permission(s)|sql|token|password|redact`, whole words, `_`/`-`/`/` separate words — so `auth_service` matches but `author`/`tokenizer` do not), or when it came from a dedicated, signal-selected domain agent (Auth, Billing, Concurrency, Database). **Origin from the always-on General Security & Quality agent alone does NOT exempt a finding**: that agent runs on every PR, so origin-based exemption filed nearly every LOW note it raised (the 2026-10-08 cascade audit: 50 of 60 would-be notes in one batch were filed only for that reason).
1696
+ - **Review-finding lineage** (the PR's linked issue `MERGE_ISSUE` carries the `review-finding` label, any priority — this PR is itself a fix for a finding): only MEDIUM findings that are CONFIRMED, or LIKELY and safety-exempt, become issues; LOW and POSSIBLE are always NOTEs. A fix for a finding must not mint a new generation of findings.
1697
+ - **Otherwise**: `**Severity**: LOW`, or `**Confidence**: POSSIBLE` below HIGH, is a NOTE unless safety-exempt; everything else (MEDIUM CONFIRMED/LIKELY) is an ISSUE.
1698
+
1699
+ ```bash
1700
+ # Resolve the classifier: running plugin first, then the resolved ForgeDock root, then the repo copy.
1701
+ CLASSIFY_SCRIPT=""
1702
+ for _cs in '${CLAUDE_PLUGIN_ROOT}/scripts/classify-finding.sh' "${FORGE_ROOT:+$FORGE_ROOT/scripts/classify-finding.sh}" "scripts/classify-finding.sh"; do
1703
+ case "$_cs" in /*|scripts/*) [ -f "$_cs" ] && { CLASSIFY_SCRIPT="$_cs"; break; } ;; esac
1704
+ done
1705
+ FINDING_LINEAGE="none"
1706
+ if [ -n "${MERGE_ISSUE:-}" ] && gh issue view "$MERGE_ISSUE" -R {GH_REPO} --json labels --jq '.labels[].name' 2>/dev/null | grep -qx 'review-finding'; then
1707
+ FINDING_LINEAGE="review-finding"
1708
+ fi
1709
+ # Per deduped finding (FINDING_TEXT_FILE = mktemp file holding title + body + affected paths):
1710
+ # DISPOSITION=$(bash "$CLASSIFY_SCRIPT" --severity "$FINDING_SEVERITY" --confidence "$FINDING_CONFIDENCE" \
1711
+ # --agent "$FINDING_AGENT" --lineage "$FINDING_LINEAGE" --text-file "$FINDING_TEXT_FILE")
1712
+ # case "$DISPOSITION" in ISSUE*) ;; NOTE*) ;; esac # first word is the class, the rest is the reason
1713
+ # If CLASSIFY_SCRIPT is empty, apply the four rules above by hand and record classifier=manual below.
1714
+ ```
1699
1715
 
1700
1716
  Each NOTE gets exactly one disposition, in this preference order:
1701
1717
  1. **Fix in this PR** — when the fix is cheap (a few lines, same files already in the diff, no new behaviour) and in scope, apply it as a follow-up commit on the PR branch before merge — ONLY for comment, documentation or test-only changes. Any change to executable code is not a note fix: it must be filed as an issue or fixed with a full re-review of the new HEAD before merge (never push unreviewed code under `--auto-merge`).
@@ -1706,6 +1722,15 @@ Each NOTE gets exactly one disposition, in this preference order:
1706
1722
 
1707
1723
  Every NOTE disposition must be recorded (never silently dropped): list each NOTE (id, file:line, one-line reason) with its disposition in the review summary comment, including dropped ones, so a reviewer can audit the decision. NOTES are never passed to `Skill(issue)`. Record counts in the review summary (`notes_fixed`, `notes_listed`, `notes_dropped`, `findings_filed`). If no finding is an ISSUE after this step, skip 6C and continue to Phase 7.
1708
1724
 
1725
+ **Disposition marker (MANDATORY whenever Phase 6A extracted at least one finding):** post one PR comment whose first line is the machine-readable record below, followed by the NOTE list. Phase 8 refuses to auto-merge a PR that has `FINDING:` markers but no `FORGE:NOTE_DISPOSITION` comment — the disposition step is enforced, not advisory.
1726
+
1727
+ ```bash
1728
+ CLASSIFIER_MODE=$([ -n "$CLASSIFY_SCRIPT" ] && echo script || echo manual)
1729
+ # NOTE_LIST_FILE: mktemp file named for the PR number, holding one line per NOTE (id, file:line, disposition, reason).
1730
+ gh pr comment {PR_NUMBER} -R {GH_REPO} --body "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=${NOTES_FIXED:-0} notes_listed=${NOTES_LISTED:-0} notes_dropped=${NOTES_DROPPED:-0} findings_filed=${FINDINGS_FILED:-0} lineage=${FINDING_LINEAGE} classifier=${CLASSIFIER_MODE} -->
1731
+ $(cat "$NOTE_LIST_FILE" 2>/dev/null)" # allowlist:check-command-side-effects
1732
+ ```
1733
+
1709
1734
  ### 6C: Create Issues
1710
1735
 
1711
1736
  ```bash
@@ -2071,6 +2096,8 @@ Verdict determined by standard blocking criteria.
2071
2096
  4. `MERGE_HEALTH == "CONFLICTING"` OR `MERGE_HEALTH_STATE` in {`DIRTY`, `BLOCKED`} — PR cannot be merged cleanly into its base branch <!-- Added: forge#194 -->
2072
2097
  - Verdict: CHANGES REQUESTED. Message: "Merge conflict with `{base}`. Rebase `{head}` onto `origin/{base}`, resolve the conflicting files, then re-run /review-pr."
2073
2098
  - If `MERGE_HEALTH == "UNKNOWN"` after retries: emit a WARNING in the verdict body (do NOT treat as a block — GitHub may still be computing it).
2099
+ 5. A CONFIRMED coverage reduction (deleted test, removed test case, or disabled/removed workflow test step) in a PR that responds to a red check, or that has no justification tying it to removal of the tested code <!-- Added: forge#3257 -->
2100
+ - Verdict: CHANGES REQUESTED. Message: "Coverage reduction: a deleted test or removed workflow test step is not a fix for a red check. Restore it and fix the code under test, or escalate naming the failing assertions." Detection follows quality-gate 2U (`COVERAGE-1`); never dedup this finding against the PR's own issue.
2074
2101
 
2075
2102
  ```bash
2076
2103
  # Determine if mergeability is a blocker (MERGE_HEALTH/MERGE_HEALTH_STATE set in Phase 1A; BASE/HEAD set in Phase 0 Mode 3)
@@ -2204,6 +2231,25 @@ fi
2204
2231
 
2205
2232
  **Skip if** `AUTO_MERGE=false`.
2206
2233
 
2234
+ **Note-disposition preflight (MANDATORY before any merge attempt)**: §6B.5 is what bounds the review-finding cascade, so a review that extracted findings but never recorded their disposition must not auto-merge. Fail closed on an unreadable comment list.
2235
+
2236
+ ```bash
2237
+ DISPO_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null) || DISPO_JSON=""
2238
+ if [ -z "$DISPO_JSON" ]; then
2239
+ echo "NOTE DISPOSITION: could not read PR comments — refusing to merge (fail closed)" >&2
2240
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "note disposition unreadable".
2241
+ exit 1
2242
+ fi
2243
+ FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test("<!-- FINDING:"))] | length')
2244
+ DISPOSITION_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.author_association == "OWNER" or .author_association == "MEMBER" or .author_association == "COLLABORATOR") | select(.body | test("^<!-- FORGE:NOTE_DISPOSITION"))] | length')
2245
+ if [ "${FINDING_COUNT:-0}" -gt 0 ] && [ "${DISPOSITION_COUNT:-0}" -eq 0 ]; then
2246
+ echo "NOTE DISPOSITION: ${FINDING_COUNT} finding comment(s) but no FORGE:NOTE_DISPOSITION record — run §6B.5 before merging" >&2
2247
+ gh pr comment {PR_NUMBER} {MERGE_GH_FLAG} --body "Auto-merge skipped: review findings exist but §6B.5 note disposition was not recorded. Re-run \`/review-pr {PR_NUMBER} --auto-merge --issue <N>\`." 2>/dev/null || true # allowlist:check-command-side-effects
2248
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "note disposition missing".
2249
+ exit 1
2250
+ fi
2251
+ ```
2252
+
2207
2253
  **Phase-trail preflight (MANDATORY before any merge attempt)** <!-- Added: forge#3061 -->: a reviewer verdict alone must not merge work whose earlier pipeline phases were skipped. Run the deterministic verifier against the linked issue; do NOT run the merge block below unless it exits 0.
2208
2254
 
2209
2255
  ```bash
@@ -2216,14 +2262,20 @@ else
2216
2262
  # Same resolution as work-on/review.md Phase R1.5: the verifier ships with ForgeDock, not the consumer repo.
2217
2263
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2218
2264
  FORGE_ROOT=""
2219
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
2220
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
2221
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
2222
- # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
2223
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
2224
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
2265
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
2266
+ _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
2267
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
2268
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
2269
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
2270
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
2271
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
2272
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
2273
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
2274
+ # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
2275
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
2276
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
2225
2277
  # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
2226
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
2278
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
2227
2279
  while IFS= read -r _c; do
2228
2280
  case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
2229
2281
  done <<< "$_k"
@@ -2247,7 +2299,20 @@ else
2247
2299
  echo "PHASE TRAIL: verify-phase-trail.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install) — refusing to merge" >&2
2248
2300
  TRAIL="PHASE_TRAIL: ERROR (verifier not resolvable)"; TRAIL_RC=127
2249
2301
  else
2250
- TRAIL=$(bash "$TRAIL_SCRIPT" "$MERGE_ISSUE" -R {GH_REPO} $DOCS_ONLY_FLAG $CODE_DIFF_FLAG); TRAIL_RC=$?
2302
+ # Bind the human break-glass override (forge#3152) to the PR head. An unreadable head omits the flag, so no override is offered (fails closed).
2303
+ PR_HEAD_SHA=$(gh api "repos/{GH_REPO}/pulls/{PR_NUMBER}" --jq .head.sha 2>/dev/null) || PR_HEAD_SHA=""
2304
+ HEAD_SHA_ARGS=()
2305
+ if [[ "$PR_HEAD_SHA" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then HEAD_SHA_ARGS=(--head-sha "$PR_HEAD_SHA"); fi
2306
+ TRAIL=$(bash "$TRAIL_SCRIPT" "$MERGE_ISSUE" -R {GH_REPO} $DOCS_ONLY_FLAG $CODE_DIFF_FLAG ${HEAD_SHA_ARGS[@]+"${HEAD_SHA_ARGS[@]}"}); TRAIL_RC=$?
2307
+ if [ "$TRAIL_RC" -eq 0 ]; then
2308
+ # An `OVERRIDE:` line means the verifier accepted a human override. Record it on the PR from the verifier's output (never agent prose).
2309
+ OVR=$(printf '%s\n' "$TRAIL" | sed -n 's/^OVERRIDE: //p' | sed -n '1p')
2310
+ OVERRIDE_BOUND_HEAD=""
2311
+ if [ -n "$OVR" ]; then
2312
+ OVERRIDE_BOUND_HEAD="$PR_HEAD_SHA" # forge#3273: the merge below must land exactly this commit
2313
+ gh pr comment {PR_NUMBER} -R {GH_REPO} --body "$(printf '<!-- FORGE:PHASE_TRAIL_OVERRIDE_APPLIED -->\nThe phase-trail gate was waived by a human break-glass override.\n\n%s\n%s\n%s\n' '```' "$OVR" '```')" # <!-- allowlist:check-command-side-effects -->
2314
+ fi
2315
+ fi
2251
2316
  fi
2252
2317
  if [ "$TRAIL_RC" -ge 2 ]; then
2253
2318
  # rc 2 (trail unreadable) / 127 (verifier not resolvable) is an infrastructure failure, NOT missing phases:
@@ -2407,6 +2472,12 @@ else
2407
2472
  fi
2408
2473
  fi
2409
2474
  fi
2475
+ # WIRE:PROVEN — spec prose; OVERRIDE_BOUND_HEAD is set in the verifier block above and consumed here (reviewed by trace)
2476
+ # forge#3273: an accepted break-glass override is bound to one exact head. If the commit being merged
2477
+ # differs (a push after the verifier ran, even a docs-only one), the override does not cover it.
2478
+ if [ "$CI_GATE_RC" -eq 0 ] && [ -n "${OVERRIDE_BOUND_HEAD:-}" ] && [ "$GATED_HEAD" != "$OVERRIDE_BOUND_HEAD" ]; then
2479
+ CI_GATE_RC=4; CI_GATE_OUT="STALE_REVIEW: break-glass override was bound to ${OVERRIDE_BOUND_HEAD:0:7} but the gated head is ${GATED_HEAD:0:7}"
2480
+ fi
2410
2481
  if [ "$CI_GATE_RC" -eq 4 ]; then
2411
2482
  # Not a human problem: the caller re-runs /review-pr on the new head (work-on/review.md R4).
2412
2483
  echo "REVIEW_RESULT: status: BLOCKED, blocker: stale review — ${CI_GATE_OUT}; re-run /review-pr on the new head"
@@ -861,6 +861,7 @@ CONTEXT_BODY=$(node packages/protocol/src/cli.js emit CONTEXT)
861
861
  Post the following as a GitHub comment on `{NUMBER}`:
862
862
 
863
863
  ```bash
864
+ # DRY_RUN: when DRY_RUN=true, print this body instead of running the command below.
864
865
  gh issue comment {NUMBER} -R {GH_REPO} --body "<!-- FORGE:CONTEXT -->
865
866
  ## Implementation Context for #{NUMBER}
866
867
 
@@ -948,13 +949,33 @@ Skip the context gathering (print `CONTEXT_RESULT:` with `status: SKIPPED`) if:
948
949
  **Minimal marker on every non-TRIVIAL skip (forge#3123)**: for STANDARD/COMPLEX the phase-trail gate (`scripts/verify-phase-trail.sh`) requires a `FORGE:CONTEXT` marker, so a skip must still post an empty marker comment (idempotent: skip if a `FORGE:CONTEXT` comment already exists), otherwise a re-run can never satisfy the gate:
949
950
 
950
951
  ```bash
951
- EXISTING_CONTEXT=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
952
- --jq '[.[] | select(.body | contains("<!-- FORGE:CONTEXT -->"))] | length' \
953
- | awk '{s+=$1} END {print s+0}')
952
+ # Anchored match mirrors scripts/verify-phase-trail.sh (marker must open the comment) AND its
953
+ # trusted-author predicate (author_association in FORGE_TRAIL_TRUSTED_ASSOCIATIONS, Bot user, or login in
954
+ # FORGE_TRAIL_TRUSTED_LOGINS): an untrusted commenter's pre-posted marker must not suppress the skip
955
+ # marker, because the gate ignores it and would then fail closed on every re-run (forge#3289).
956
+ # A failed lookup sets LOOKUP_FAILED and posts nothing (unknown is not zero): the
957
+ # phase-trail gate then flags the missing marker and a re-run retries.
958
+ LOOKUP_FAILED=0
959
+ TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"
960
+ TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"
961
+ EXISTING_JSON=$(set -o pipefail; gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate | jq -r \
962
+ --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" '
963
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
964
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
965
+ | [.[]
966
+ | select((.body // "") | startswith("<!-- FORGE:CONTEXT -->"))
967
+ | select(
968
+ ((.author_association // "") as $x | $A | index($x) != null)
969
+ or ((.user.type // "") == "Bot")
970
+ or ((.user.login // "") as $x | $L | index($x) != null)
971
+ )] | length') || LOOKUP_FAILED=1
972
+ EXISTING_CONTEXT=$(printf '%s\n' "$EXISTING_JSON" | awk '{s+=$1} END {print s+0}')
954
973
  if [ "${DRY_RUN:-false}" = "true" ]; then
955
974
  echo "[DRY_RUN] would post minimal FORGE:CONTEXT marker on #{NUMBER}"
975
+ elif [ "$LOOKUP_FAILED" -eq 1 ]; then
976
+ echo "WARNING: FORGE:CONTEXT lookup failed (LOOKUP_FAILED=1) — not posting a marker; re-run to retry" >&2
956
977
  elif [ "$EXISTING_CONTEXT" -eq 0 ]; then
957
- gh issue comment "{NUMBER}" -R "{GH_REPO}" --body "<!-- FORGE:CONTEXT -->
978
+ gh issue comment {NUMBER} -R {GH_REPO} --body "<!-- FORGE:CONTEXT -->
958
979
  ## Implementation Context for #{NUMBER}
959
980
 
960
981
  Context gathering skipped: {REASON — config/docs-only edit | new files, no git history | empty affected-file list}. No institutional memory to surface.
@@ -127,6 +127,7 @@ Skill("{FORGE_SKILL_PREFIX}quality-gate", args="{CHANGED_FILES} --worktree {WORK
127
127
  - Re-run after EVERY fix pass — never trust that fixes resolved findings without verification
128
128
  - Each iteration re-scans ALL changed files — fixes can introduce new issues
129
129
  - Only HIGH and MEDIUM findings must be fixed; LOW findings are advisory only
130
+ - `COVERAGE-1 | HIGH` findings (quality-gate 2U coverage reduction) are fixed by restoring the deleted/skipped test or workflow step — never by `--no-verify` or suppression <!-- Added: forge#3257 -->
130
131
  - `TEST-QUARANTINE | LOW` findings (pre-broken or flaky tests classified by Step 2R) do **not** require fixing and do **not** count toward gate failure — include them in the V5 commit comment for reviewer visibility
131
132
  - If the `quality-gate` skill is not found under either name, STOP and print `VALIDATE_RESULT` with `gate_passed: false`, `blocker: skill not found: quality-gate` — never run the gate inline
132
133
 
@@ -24,6 +24,8 @@ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --base {PR_BAS
24
24
 
25
25
  **CRITICAL: You MUST execute ALL phases B0–B6.5 in order. Every child (B3 context, B4 architect, B5 implement, B6 validate) is invoked via `Skill(...)` — each is a forked sub-skill with its own isolated context. B3 and B4 are invoked for every complexity band except where the band is TRIVIAL or INVESTIGATION (B2/B2.1/B2.5 are also skipped for INVESTIGATION); the children post their own skip markers, so a skipped child is still visible on the issue. Skipping a child without the band justification degrades build quality and fails the phase-trail check.**
26
26
 
27
+ **Synchronous child consumption**: each child `Skill(...)` call (B3-B6) runs to completion in this phase's own turn, and you consume its result in that same turn. Never end or yield your turn to wait for a child, and never wait for a completion notification: notifications for forked children go to the root session, not to this phase, so a turn that yields is never resumed. A child return with no `*_RESULT:` block (running, backgrounded, empty) is not a result — re-read the GitHub markers for that child (`FORGE:CONTEXT`, `FORGE:ARCHITECT`, `FORGE:BUILDER`, `FORGE:VALIDATE`/`FORGE:QUALITY_GATE`) and apply the **bounded re-invoke rule**: re-invoke the same child with the same args at most 2 times per child on running/backgrounded/empty returns (the counter is per child and is not reset by a re-invoke). Before re-invoking, check state: if the child's marker is present, consume it instead of re-invoking; for implement and validate, also check the worktree (`git status --short` and `git log origin/{PR_BASE}..HEAD` in `{WORKTREE_PATH}`) — uncommitted in-flight edits or a fresh commit mean the original child may still be live, so wait for it by re-reading state rather than launching a second writer on the same worktree, and count that as a re-invoke attempt. After the cap is exhausted, take the Blocked exit (see Result emission and Blocked exit) with `blocker: child-stalled: <child>` (child = context|architect|implement|validate); it is terminal and is not re-invoked by the router.
28
+
27
29
  <!-- FORGE:SPEC_LOADED — work-on/build.md loaded and active. Agent is bound by this spec. -->
28
30
 
29
31
  ---
@@ -69,14 +71,20 @@ fi
69
71
  # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
70
72
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
71
73
  FORGE_ROOT=""
72
- if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
73
- # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
74
- _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
75
- # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
76
- _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
77
- _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
74
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
75
+ _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
76
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
77
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
78
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
79
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
80
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
81
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
82
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
83
+ # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
84
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
85
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
78
86
  # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
79
- _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
87
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
80
88
  while IFS= read -r _c; do
81
89
  case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
82
90
  done <<< "$_k"
@@ -466,6 +474,7 @@ If `FUNCTION_NAMES` is empty, omit `--functions`. The child posts its own `FORGE
466
474
  - `status: COMPLETE | PARTIAL | SKIPPED` → continue to B4
467
475
  - No `CONTEXT_RESULT:` block, timeout or error → log a warning and continue to B4 (context is advisory and non-blocking)
468
476
  - Skill not found → Blocked exit, blocker "skill not found: work-on:build:context"
477
+ - Returned running/backgrounded/empty (no `CONTEXT_RESULT:`) → do not end the turn; re-read for a `FORGE:CONTEXT` marker, re-invoke the same child if absent, per the bounded re-invoke rule (at most 2; check the marker before re-invoking) (advisory rules above still apply once it returns)
469
478
  # MUST CONTINUE to Phase B4 — context result is intermediate, NOT terminal.
470
479
 
471
480
  ---
@@ -485,6 +494,7 @@ The child posts its own `FORGE:ARCHITECT` comment (a "Skipped" marker + `:COMPLE
485
494
  - `status: BLOCKED` (conflicting constraints that cannot be resolved) → Blocked exit with the child's `blocker`
486
495
  - No `ARCHITECT_RESULT:` block → re-read the issue: if a `FORGE:ARCHITECT:COMPLETE` marker exists continue to B5, otherwise Blocked exit "architect produced no result"
487
496
  - Skill not found → Blocked exit, blocker "skill not found: work-on:build:architect"
497
+ - Returned running/backgrounded/empty → do not end the turn to wait; re-read markers and re-invoke the same child per the bounded re-invoke rule (at most 2; the marker fallback above applies only after a real return)
488
498
  # MUST CONTINUE to Phase B5 — architect result is intermediate, NOT terminal.
489
499
 
490
500
  ---
@@ -503,6 +513,7 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:implement", args="{NUMBER} --repo
503
513
  - `status: INVESTIGATION_COMPLETE` → issues were created as deliverables and the original closed; print `BUILD_RESULT: status: INVESTIGATION_COMPLETE` (skip B6/B6.5)
504
514
  - `status: BLOCKED` → Blocked exit with the child's `blocker`
505
515
  - Skill not found → Blocked exit, blocker "skill not found: work-on:build:implement"
516
+ - Returned running/backgrounded/empty (no `IMPLEMENT_RESULT:`) → do not end the turn to wait; re-read the `FORGE:BUILDER` comment and the worktree state; if `FORGE:BUILDER:COMPLETE` is present consume it, and if the worktree shows in-flight edits or a fresh commit treat the child as possibly live; before re-invoking, apply the bounded re-invoke rule (at most 2, then `child-stalled: implement`)
506
517
  # MUST CONTINUE to Phase B6 — implement result is intermediate, NOT terminal (validation still required).
507
518
 
508
519
  ---
@@ -522,9 +533,10 @@ CHANGED_FILES=$(git -C "{WORKTREE_PATH}" diff --name-only "origin/{PR_BASE}...HE
522
533
  ```
523
534
 
524
535
  **After subcommand returns** (read its `VALIDATE_RESULT:` block):
525
- - `gate_passed: true` → verify the `FORGE:QUALITY_GATE` marker exists on the issue (posted by validate V5; docs-only changes exempt). If absent, re-invoke validate once; a missing marker is not a pass. Then continue to Phase B6.5 (acceptance gate)
536
+ - `gate_passed: true` → verify the `FORGE:QUALITY_GATE` marker exists on the issue (posted by validate V5; docs-only changes exempt). If absent, re-derive `{CHANGED_FILES}` with the B6 command above and re-invoke validate once with the full B6 `Skill(...)` call, including `--files "{CHANGED_FILES}"` (never omit `--files`; an empty list falls back to the git diff); a missing marker is not a pass. Then continue to Phase B6.5 (acceptance gate)
526
537
  - `gate_passed: false` → the subcommand has already posted its comment and added `needs-human`; print `BUILD_RESULT: status: BLOCKED` with the child's `blocker` (run the Blocked exit if no comment was posted)
527
538
  - Skill not found → Blocked exit, blocker "skill not found: work-on:build:validate"
539
+ - Returned running/backgrounded/empty (no `VALIDATE_RESULT:`) → do not end the turn to wait; re-read the `FORGE:QUALITY_GATE` marker (consume it if present) and the worktree state before re-invoking, then re-invoke the same child under the bounded re-invoke rule (at most 2, then `child-stalled: validate`); this cap is separate from the single marker-absent re-invoke above
528
540
 
529
541
  ---
530
542
 
@@ -683,7 +695,12 @@ If `GATE_PASS = false`, the build repairs itself **once** before anything escala
683
695
  1. Count `<!-- FORGE:ACCEPTANCE_REPAIR: issue={NUMBER} -->` comments on the issue. If one already exists, skip to step 4.
684
696
  2. Post that marker (with the failed check ids), remove `needs-human` if this gate added it, then invoke
685
697
  `Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:implement", args="{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE} --fix-acceptance \"<failed check ids and details>\"")`
686
- followed by `work-on:build:validate` with the same args as B6 (the new code gets a fresh quality gate and commit).
698
+ then re-derive `{CHANGED_FILES}` from the repaired worktree (never reuse the pre-repair value, and never omit `--files`; an empty list falls back to this diff):
699
+ ```bash
700
+ CHANGED_FILES=$(git -C "{WORKTREE_PATH}" diff --name-only "origin/{PR_BASE}...HEAD" 2>/dev/null | tr '\n' ' ' | xargs)
701
+ ```
702
+ and invoke validate explicitly (the new code gets a fresh quality gate and commit):
703
+ `Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:validate", args="{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE} --files \"{CHANGED_FILES}\"")`
687
704
  3. Re-run this whole B6.5 gate. PASS → continue to the checkpoint.
688
705
  4. Still failing (or already repaired once) → leave `needs-human` and print `BUILD_RESULT: status: BLOCKED`, blocker: "Acceptance gate failed after one repair — see FORGE:ACCEPTANCE_GATE comment".
689
706