forgedock 1.8.4 → 1.9.1

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 (40) 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 +359 -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 +80 -0
  9. package/commands/recover-orphans.md +3 -3
  10. package/commands/review-pr.md +98 -16
  11. package/commands/work-on/build/context.md +25 -4
  12. package/commands/work-on/build/validate.md +16 -9
  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 +61 -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-bash.sh +120 -0
  28. package/scripts/check-spec-bash.test.sh +90 -0
  29. package/scripts/check-spec-markers.sh +67 -3
  30. package/scripts/classify-finding.sh +123 -0
  31. package/scripts/classify-finding.test.sh +77 -0
  32. package/scripts/close-dossier-no-main-commit.test.sh +139 -0
  33. package/scripts/forge-root.test.sh +84 -27
  34. package/scripts/lint-build-validate-files.sh +29 -0
  35. package/scripts/lint-build-validate-files.test.sh +25 -0
  36. package/scripts/lint-dispatch-prompt.test.sh +2 -1
  37. package/scripts/recovery-claim-live.sh +73 -0
  38. package/scripts/recovery-claim-live.test.sh +66 -0
  39. package/scripts/verify-phase-trail.sh +183 -4
  40. 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
 
@@ -273,7 +277,9 @@ the gate rather than leaving the workflow blocked.
273
277
  - **2R (Registry checks)**: ALWAYS run — executes all promoted checks from `scripts/check-registry/manifest.json` <!-- Added: forge#1331 -->
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 -->
280
+ - **2G.10 (Spec bash syntax)**: Run if `FORGE_GRAPH` in DOMAINS and any `commands/**/*.md` changed — `bash -n` on changed ```bash blocks in specs (HIGH); shellcheck advisory
276
281
  - **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 -->
282
+ - **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
283
  - **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
284
  - **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
285
 
@@ -713,6 +719,34 @@ fi
713
719
 
714
720
  Do NOT suppress the finding by making the check less strict or adding fake no-op tests. The intent is a real proof that the path is reachable. <!-- Added: forge#1731 -->
715
721
 
722
+ ### 2G.10: Spec bash syntax check (FORGE_GRAPH domain)
723
+
724
+ **Triggered when**: changed files include `commands/**/*.md`. Skips silently in any project without `scripts/check-spec-bash.sh`.
725
+
726
+ **Why this matters**: in ForgeDock the pipeline code is bash inside Markdown specs. The executable-file checks above exclude `.md` files, and acceptance checks are mostly greps, so spec bash used to merge without ever being parsed. Reviewers were then the first to run it, which drove most review findings (2026-10-08 cascade audit). This check runs `bash -n` on every ```bash block that contains a changed line, after neutralising `{PLACEHOLDER}` notation. Blocks with pseudo tool calls (`Skill(`/`Agent(`/`Task(`) and blocks preceded by `<!-- allowlist:check-spec-bash -->` are skipped. Pre-existing blocks are never checked, so the baseline cannot block a change. `shellcheck -S error` results are advisory only.
727
+
728
+ ```bash
729
+ SPEC_BASH_CHECKER="$FORGEDOCK_SCRIPTS/check-spec-bash.sh"
730
+ [ -f "$SPEC_BASH_CHECKER" ] || SPEC_BASH_CHECKER="{WORKTREE_PATH}/scripts/check-spec-bash.sh"
731
+ SPEC_MD_FILES=$(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '^commands/.*\.md$' || true)
732
+ if [ -f "$SPEC_BASH_CHECKER" ] && [ -n "$SPEC_MD_FILES" ]; then
733
+ PR_BASE="${PR_BASE:-staging}"
734
+ SPEC_BASH_BASE=$(git -C "{WORKTREE_PATH}" merge-base "origin/${PR_BASE}" HEAD 2>/dev/null || true)
735
+ if [ -z "$SPEC_BASH_BASE" ]; then
736
+ echo "FORGE_GRAPH | HIGH | spec-bash | cannot resolve merge-base with origin/${PR_BASE} — spec bash not checked (fail closed)"
737
+ else
738
+ # shellcheck disable=SC2086 # SPEC_MD_FILES is a newline list of repo-relative paths without spaces
739
+ SPEC_BASH_REPORT=$(cd "{WORKTREE_PATH}" && bash "$SPEC_BASH_CHECKER" --base "$SPEC_BASH_BASE" --shellcheck $SPEC_MD_FILES 2>&1)
740
+ echo "$SPEC_BASH_REPORT"
741
+ echo "$SPEC_BASH_REPORT" | grep -E '^FAIL ' | while IFS= read -r hit; do
742
+ echo "FORGE_GRAPH | HIGH | spec-bash | ${hit#FAIL }"
743
+ done
744
+ fi
745
+ fi
746
+ ```
747
+
748
+ Report each `FAIL` line as **HIGH**: the changed block does not parse, so any agent that runs it hits a syntax error. Fix the bash. If the block is an intentional fragment (for example an `if` opened in one block and closed in the next), put `<!-- allowlist:check-spec-bash -->` on the line before the fence. `WARN` (shellcheck) lines are advisory and go in the report without blocking.
749
+
716
750
  ### 2G.9: Danger-zone recurrence check (FORGE_GRAPH domain) <!-- Added: forge#1744 -->
717
751
 
718
752
  **Triggered when**: `FORGE_GRAPH` in DOMAINS (changed files include `commands/*.md` specs or `scripts/*` files). Skips silently when the FORGE:CONTEXT comment on the issue contains no `### Danger-Zone Rule Cards` section (i.e., Phase C0.5 injected zero cards — nothing to cross-reference).
@@ -1298,6 +1332,51 @@ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1298
1332
 
1299
1333
  **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
1334
 
1335
+ ### 2U: Coverage-reduction check (ALWAYS — a red check must be fixed, not silenced)
1336
+
1337
+ <!-- Added: forge#3257 -->
1338
+
1339
+ **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.
1340
+
1341
+ 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).
1342
+
1343
+ ```bash
1344
+ cd {WORKTREE_PATH}
1345
+ PR_BASE="${PR_BASE:-staging}"
1346
+ if ! git rev-parse --verify --quiet "origin/$PR_BASE^{commit}" >/dev/null; then
1347
+ echo "COVERAGE-ERR | HIGH | quality-gate | base ref origin/$PR_BASE not found — fetch it; coverage-reduction check could not run"
1348
+ else
1349
+ BASE_REF=$(git merge-base "origin/$PR_BASE" HEAD) || { echo "COVERAGE-ERR | HIGH | quality-gate | merge-base failed for origin/$PR_BASE"; BASE_REF=""; }
1350
+ fi
1351
+ if [ -n "${BASE_REF:-}" ]; then
1352
+ TEST_PATH_RE='(^|/)(test_[^/]*|[^/]*_test\.[^/]*|[^/]*\.test\.[^/]*|[^/]*\.spec\.[^/]*)$|(^|/)(tests|__tests__)/'
1353
+
1354
+ # (a) deleted test files (renames excluded via --find-renames)
1355
+ git diff --find-renames --diff-filter=D --name-only "$BASE_REF" | grep -E "$TEST_PATH_RE" \
1356
+ && echo "COVERAGE-1: deleted test file(s)"
1357
+
1358
+ # (b) removed test cases with no added counterpart, (c) newly added skip markers
1359
+ git diff -U0 "$BASE_REF" -- . | grep -E '^-[[:space:]]*(it|test|describe)\(|^-[[:space:]]*(async )?def test_' \
1360
+ && echo "COVERAGE-1: removed test case line(s) — confirm each has an added counterpart in the diff"
1361
+ git diff -U0 "$BASE_REF" -- . | grep -E '^\+.*(\.skip\(|@pytest\.mark\.skip|@pytest\.mark\.xfail|xfail|continue-on-error:[[:space:]]*true|if:[[:space:]]*false)' \
1362
+ && echo "COVERAGE-1: newly added skip/xfail/continue-on-error/if: false marker"
1363
+
1364
+ # (d) removed or disabled workflow test steps
1365
+ git diff -U0 "$BASE_REF" -- .github/workflows | grep -E '^-.*run:.*(test|pytest|jest|vitest|mocha|\.test\.sh|npm t|go test|cargo test)' \
1366
+ && echo "COVERAGE-1: removed workflow test step"
1367
+ fi
1368
+ ```
1369
+
1370
+ Also treat a test command that is commented out (`+#` of a previously live `run:` test line) as a removal.
1371
+
1372
+ **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.
1373
+
1374
+ **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).
1375
+
1376
+ **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.
1377
+
1378
+ ---
1379
+
1301
1380
  ### 2R: Registry checks (ALWAYS — promoted patterns from recurring review findings)
1302
1381
 
1303
1382
  <!-- Added: forge#1331 -->
@@ -1698,6 +1777,7 @@ OR:
1698
1777
  6. SHELL-1 | HIGH | deploy.sh | Hardcoded 'localhost' in DB connection function — use ${POSTGRES_HOST:-localhost} pattern
1699
1778
  7. INFRA-1 | HIGH | Dockerfile | Filesystem write ops found under named volume mount — add chown before privilege drop in entrypoint
1700
1779
  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
1780
+ 9. COVERAGE-1 | HIGH | .github/workflows/ci.yml | coverage reduction: removed workflow test step — restore it and fix the code under test
1701
1781
  ```
1702
1782
 
1703
1783
  **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,40 @@ 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) AND is MEDIUM+ or CONFIRMED — a LOW/POSSIBLE finding stays a NOTE whichever reviewer raised it. **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, FORGE_ROOT, FORGEDOCK_HOME, newest pinned forgedock plugin cache
1701
+ # under the active config dir (CLAUDE_CONFIG_DIR) then ~/.claude, then the repo's own scripts/ (ForgeDock itself).
1702
+ # The plugin-root placeholder is not always substituted in forked review runs, so the cache scan is what makes
1703
+ # consumer repos resolve the script instead of falling back to classifier=manual.
1704
+ CLASSIFY_SCRIPT=""
1705
+ _cands="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}")"
1706
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
1707
+ [ -n "$_cfg" ] || continue
1708
+ _cands="$_cands
1709
+ $(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$0}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
1710
+ done
1711
+ _cands="$_cands
1712
+ $PWD"
1713
+ while IFS= read -r _c; do
1714
+ case "$_c" in /*) [ -z "$CLASSIFY_SCRIPT" ] && [ -f "$_c/scripts/classify-finding.sh" ] && CLASSIFY_SCRIPT="$_c/scripts/classify-finding.sh" ;; esac
1715
+ done <<< "$_cands"
1716
+ FINDING_LINEAGE="none"
1717
+ 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
1718
+ FINDING_LINEAGE="review-finding"
1719
+ fi
1720
+ # Per deduped finding (FINDING_TEXT_FILE = mktemp file holding title + body + affected paths):
1721
+ # DISPOSITION=$(bash "$CLASSIFY_SCRIPT" --severity "$FINDING_SEVERITY" --confidence "$FINDING_CONFIDENCE" \
1722
+ # --agent "$FINDING_AGENT" --lineage "$FINDING_LINEAGE" --text-file "$FINDING_TEXT_FILE")
1723
+ # case "$DISPOSITION" in ISSUE*) ;; NOTE*) ;; esac # first word is the class, the rest is the reason
1724
+ # If CLASSIFY_SCRIPT is empty, apply the four rules above by hand and record classifier=manual below.
1725
+ ```
1699
1726
 
1700
1727
  Each NOTE gets exactly one disposition, in this preference order:
1701
1728
  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 +1733,15 @@ Each NOTE gets exactly one disposition, in this preference order:
1706
1733
 
1707
1734
  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
1735
 
1736
+ **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.
1737
+
1738
+ ```bash
1739
+ CLASSIFIER_MODE=$([ -n "$CLASSIFY_SCRIPT" ] && echo script || echo manual)
1740
+ # NOTE_LIST_FILE: mktemp file named for the PR number, holding one line per NOTE (id, file:line, disposition, reason).
1741
+ 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} -->
1742
+ $(cat "$NOTE_LIST_FILE" 2>/dev/null)" # allowlist:check-command-side-effects
1743
+ ```
1744
+
1709
1745
  ### 6C: Create Issues
1710
1746
 
1711
1747
  ```bash
@@ -2071,6 +2107,8 @@ Verdict determined by standard blocking criteria.
2071
2107
  4. `MERGE_HEALTH == "CONFLICTING"` OR `MERGE_HEALTH_STATE` in {`DIRTY`, `BLOCKED`} — PR cannot be merged cleanly into its base branch <!-- Added: forge#194 -->
2072
2108
  - Verdict: CHANGES REQUESTED. Message: "Merge conflict with `{base}`. Rebase `{head}` onto `origin/{base}`, resolve the conflicting files, then re-run /review-pr."
2073
2109
  - 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).
2110
+ 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 -->
2111
+ - 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
2112
 
2075
2113
  ```bash
2076
2114
  # 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 +2242,25 @@ fi
2204
2242
 
2205
2243
  **Skip if** `AUTO_MERGE=false`.
2206
2244
 
2245
+ **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.
2246
+
2247
+ ```bash
2248
+ DISPO_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null) || DISPO_JSON=""
2249
+ if [ -z "$DISPO_JSON" ]; then
2250
+ echo "NOTE DISPOSITION: could not read PR comments — refusing to merge (fail closed)" >&2
2251
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "note disposition unreadable".
2252
+ exit 1
2253
+ fi
2254
+ FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test("<!-- FINDING:"))] | length')
2255
+ 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')
2256
+ if [ "${FINDING_COUNT:-0}" -gt 0 ] && [ "${DISPOSITION_COUNT:-0}" -eq 0 ]; then
2257
+ echo "NOTE DISPOSITION: ${FINDING_COUNT} finding comment(s) but no FORGE:NOTE_DISPOSITION record — run §6B.5 before merging" >&2
2258
+ 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
2259
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "note disposition missing".
2260
+ exit 1
2261
+ fi
2262
+ ```
2263
+
2207
2264
  **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
2265
 
2209
2266
  ```bash
@@ -2216,14 +2273,20 @@ else
2216
2273
  # Same resolution as work-on/review.md Phase R1.5: the verifier ships with ForgeDock, not the consumer repo.
2217
2274
  # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
2218
2275
  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)"
2276
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
2277
+ _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
2278
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
2279
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
2280
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
2281
+ # 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.
2282
+ _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")")"
2283
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
2284
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
2285
+ # 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)
2286
+ _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)"
2287
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
2225
2288
  # '${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")"
2289
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
2227
2290
  while IFS= read -r _c; do
2228
2291
  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
2292
  done <<< "$_k"
@@ -2247,7 +2310,20 @@ else
2247
2310
  echo "PHASE TRAIL: verify-phase-trail.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install) — refusing to merge" >&2
2248
2311
  TRAIL="PHASE_TRAIL: ERROR (verifier not resolvable)"; TRAIL_RC=127
2249
2312
  else
2250
- TRAIL=$(bash "$TRAIL_SCRIPT" "$MERGE_ISSUE" -R {GH_REPO} $DOCS_ONLY_FLAG $CODE_DIFF_FLAG); TRAIL_RC=$?
2313
+ # 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).
2314
+ PR_HEAD_SHA=$(gh api "repos/{GH_REPO}/pulls/{PR_NUMBER}" --jq .head.sha 2>/dev/null) || PR_HEAD_SHA=""
2315
+ HEAD_SHA_ARGS=()
2316
+ if [[ "$PR_HEAD_SHA" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then HEAD_SHA_ARGS=(--head-sha "$PR_HEAD_SHA"); fi
2317
+ TRAIL=$(bash "$TRAIL_SCRIPT" "$MERGE_ISSUE" -R {GH_REPO} $DOCS_ONLY_FLAG $CODE_DIFF_FLAG ${HEAD_SHA_ARGS[@]+"${HEAD_SHA_ARGS[@]}"}); TRAIL_RC=$?
2318
+ if [ "$TRAIL_RC" -eq 0 ]; then
2319
+ # An `OVERRIDE:` line means the verifier accepted a human override. Record it on the PR from the verifier's output (never agent prose).
2320
+ OVR=$(printf '%s\n' "$TRAIL" | sed -n 's/^OVERRIDE: //p' | sed -n '1p')
2321
+ OVERRIDE_BOUND_HEAD=""
2322
+ if [ -n "$OVR" ]; then
2323
+ OVERRIDE_BOUND_HEAD="$PR_HEAD_SHA" # forge#3273: the merge below must land exactly this commit
2324
+ 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 -->
2325
+ fi
2326
+ fi
2251
2327
  fi
2252
2328
  if [ "$TRAIL_RC" -ge 2 ]; then
2253
2329
  # rc 2 (trail unreadable) / 127 (verifier not resolvable) is an infrastructure failure, NOT missing phases:
@@ -2407,6 +2483,12 @@ else
2407
2483
  fi
2408
2484
  fi
2409
2485
  fi
2486
+ # WIRE:PROVEN — spec prose; OVERRIDE_BOUND_HEAD is set in the verifier block above and consumed here (reviewed by trace)
2487
+ # forge#3273: an accepted break-glass override is bound to one exact head. If the commit being merged
2488
+ # differs (a push after the verifier ran, even a docs-only one), the override does not cover it.
2489
+ if [ "$CI_GATE_RC" -eq 0 ] && [ -n "${OVERRIDE_BOUND_HEAD:-}" ] && [ "$GATED_HEAD" != "$OVERRIDE_BOUND_HEAD" ]; then
2490
+ 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}"
2491
+ fi
2410
2492
  if [ "$CI_GATE_RC" -eq 4 ]; then
2411
2493
  # Not a human problem: the caller re-runs /review-pr on the new head (work-on/review.md R4).
2412
2494
  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
 
@@ -149,7 +150,7 @@ gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human" # allowlist:check-com
149
150
 
150
151
  ## Phase V2: Format and Verify
151
152
 
152
- Run after quality gate passes. All tool commands are read from `forge.yaml → verification.commands`; each step logs `SKIPPED — not configured` when the corresponding key is absent rather than silently passing.
153
+ Run after quality gate passes. All tool commands are read from `forge.yaml → verification.commands`. `forge.yaml` is usually gitignored, so it is absent from the worktree: every block below resolves `FORGE_CFG` to the worktree copy if present, else the main checkout's `forge.yaml` (via `git rev-parse --git-common-dir`). Before this, every verification command, learned test command and the SOPS chain check were silently skipped in worktree builds; each step logs `SKIPPED — not configured` when the corresponding key is absent rather than silently passing.
153
154
 
154
155
  **Track skipped checks** — initialize before any check runs:
155
156
  ```bash
@@ -177,8 +178,9 @@ run_verification_command() {
177
178
  **Python**:
178
179
  ```bash
179
180
  cd {WORKTREE_PATH}
181
+ FORGE_CFG=forge.yaml; [ -f "$FORGE_CFG" ] || { _gcd=$(git rev-parse --git-common-dir 2>/dev/null) && _gcd=$(cd "$_gcd" 2>/dev/null && pwd) && [ -f "${_gcd%/.git}/forge.yaml" ] && FORGE_CFG="${_gcd%/.git}/forge.yaml"; } # gitignored forge.yaml exists only in the main checkout, not in worktrees
180
182
 
181
- PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
183
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' "$FORGE_CFG" 2>/dev/null || echo '')
182
184
  if [ -n "$PYTHON_FORMAT" ]; then
183
185
  run_verification_command "python.format" "$PYTHON_FORMAT"
184
186
  else
@@ -194,10 +196,11 @@ Failures in `py_compile` are BLOCKING — fix before continuing.
194
196
  **TypeScript**:
195
197
  ```bash
196
198
  cd {WORKTREE_PATH}
199
+ FORGE_CFG=forge.yaml; [ -f "$FORGE_CFG" ] || { _gcd=$(git rev-parse --git-common-dir 2>/dev/null) && _gcd=$(cd "$_gcd" 2>/dev/null && pwd) && [ -f "${_gcd%/.git}/forge.yaml" ] && FORGE_CFG="${_gcd%/.git}/forge.yaml"; } # gitignored forge.yaml exists only in the main checkout, not in worktrees
197
200
 
198
- TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' forge.yaml 2>/dev/null || echo '')
199
- TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
200
- TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
201
+ TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' "$FORGE_CFG" 2>/dev/null || echo '')
202
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' "$FORGE_CFG" 2>/dev/null || echo '')
203
+ TS_BUILD=$(yq '.verification.commands.typescript.build // ""' "$FORGE_CFG" 2>/dev/null || echo '')
201
204
 
202
205
  if [ -n "$TS_FORMAT" ]; then
203
206
  run_verification_command "typescript.format" "$TS_FORMAT"
@@ -233,8 +236,9 @@ Before running any test command below, check it against `verification.known_slow
233
236
 
234
237
  ```bash
235
238
  cd {WORKTREE_PATH}
239
+ FORGE_CFG=forge.yaml; [ -f "$FORGE_CFG" ] || { _gcd=$(git rev-parse --git-common-dir 2>/dev/null) && _gcd=$(cd "$_gcd" 2>/dev/null && pwd) && [ -f "${_gcd%/.git}/forge.yaml" ] && FORGE_CFG="${_gcd%/.git}/forge.yaml"; } # gitignored forge.yaml exists only in the main checkout, not in worktrees
236
240
  # Read directly from forge.yaml (static, operator-declared config).
237
- KNOWN_SLOW_TESTS=$(yq -o=json -I=0 '.verification.known_slow_tests // []' forge.yaml 2>/dev/null || echo '[]')
241
+ KNOWN_SLOW_TESTS=$(yq -o=json -I=0 '.verification.known_slow_tests // []' "$FORGE_CFG" 2>/dev/null || echo '[]')
238
242
 
239
243
  # apply_known_slow_filter <cmd> — echoes the command to actually run, or "" to
240
244
  # skip it entirely. Matching is substring match of `pattern` against the full
@@ -273,7 +277,8 @@ After all `verification.commands` steps complete, run any commands from `forge.y
273
277
 
274
278
  ```bash
275
279
  cd {WORKTREE_PATH}
276
- LEARNED_TEST_COMMANDS=$(yq -o=json -I=0 '.learned.test_commands // []' forge.yaml 2>/dev/null || echo '[]')
280
+ FORGE_CFG=forge.yaml; [ -f "$FORGE_CFG" ] || { _gcd=$(git rev-parse --git-common-dir 2>/dev/null) && _gcd=$(cd "$_gcd" 2>/dev/null && pwd) && [ -f "${_gcd%/.git}/forge.yaml" ] && FORGE_CFG="${_gcd%/.git}/forge.yaml"; } # gitignored forge.yaml exists only in the main checkout, not in worktrees
281
+ LEARNED_TEST_COMMANDS=$(yq -o=json -I=0 '.learned.test_commands // []' "$FORGE_CFG" 2>/dev/null || echo '[]')
277
282
  LEARNED_FAILED=0
278
283
  if [ -n "$LEARNED_TEST_COMMANDS" ] && [ "$LEARNED_TEST_COMMANDS" != "[]" ] && [ "$LEARNED_TEST_COMMANDS" != "null" ]; then
279
284
  echo "Running learned test commands..."
@@ -367,7 +372,8 @@ After static proxy checks, run a lightweight live browser check using Playwright
367
372
 
368
373
  ```bash
369
374
  cd {WORKTREE_PATH}
370
- APP_URL=$(yq '.services.app_url // ""' forge.yaml 2>/dev/null || echo '')
375
+ FORGE_CFG=forge.yaml; [ -f "$FORGE_CFG" ] || { _gcd=$(git rev-parse --git-common-dir 2>/dev/null) && _gcd=$(cd "$_gcd" 2>/dev/null && pwd) && [ -f "${_gcd%/.git}/forge.yaml" ] && FORGE_CFG="${_gcd%/.git}/forge.yaml"; } # gitignored forge.yaml exists only in the main checkout, not in worktrees
376
+ APP_URL=$(yq '.services.app_url // ""' "$FORGE_CFG" 2>/dev/null || echo '')
371
377
  if [ -z "$APP_URL" ]; then
372
378
  echo "SKIPPED — services.app_url not configured in forge.yaml (browser signal check requires a running app URL)"
373
379
  else
@@ -441,7 +447,8 @@ ADDED_LINES=$(git diff HEAD -- {CHANGED_FILES} | grep -E '^\+' | grep -v '^+++')
441
447
 
442
448
  ```bash
443
449
  cd {WORKTREE_PATH}
444
- SECRETS_BACKEND=$(yq '.deploy.secrets_backend // ""' forge.yaml 2>/dev/null || echo '')
450
+ FORGE_CFG=forge.yaml; [ -f "$FORGE_CFG" ] || { _gcd=$(git rev-parse --git-common-dir 2>/dev/null) && _gcd=$(cd "$_gcd" 2>/dev/null && pwd) && [ -f "${_gcd%/.git}/forge.yaml" ] && FORGE_CFG="${_gcd%/.git}/forge.yaml"; } # gitignored forge.yaml exists only in the main checkout, not in worktrees
451
+ SECRETS_BACKEND=$(yq '.deploy.secrets_backend // ""' "$FORGE_CFG" 2>/dev/null || echo '')
445
452
  if [ "$SECRETS_BACKEND" != "sops" ]; then
446
453
  echo 'SKIP: SOPS chain check — deploy.secrets_backend is not "sops". Configure deploy.secrets_backend in forge.yaml to enable.'
447
454
  fi