forgedock 1.12.1 → 1.13.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 (64) hide show
  1. package/bin/engine/phases.mjs +54 -12
  2. package/bin/engine/runlog.mjs +12 -0
  3. package/bin/engine-cli.mjs +3 -1
  4. package/bin/engine.mjs +64 -16
  5. package/bin/hooks/interactive-engine.mjs +135 -212
  6. package/bin/hooks/pre-tool-use.mjs +16 -4
  7. package/bin/labels.json +5 -0
  8. package/bin/opencode-adapter.mjs +1 -1
  9. package/bin/runner.mjs +234 -21
  10. package/commands/autopilot.md +6 -6
  11. package/commands/cleanup.md +7 -4
  12. package/commands/orchestrate/phase-1-resolve.md +1 -1
  13. package/commands/orchestrate/phase-3-dependency.md +14 -19
  14. package/commands/orchestrate/phase-4-execution.md +50 -38
  15. package/commands/orchestrate/phase-6-report.md +6 -0
  16. package/commands/orchestrate/safety.md +1 -1
  17. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  18. package/commands/pipeline-health/phase-5-report.md +1 -0
  19. package/commands/pipeline-resume.md +1 -1
  20. package/commands/pipeline-status.md +2 -2
  21. package/commands/review-pr-agents/api.md +1 -1
  22. package/commands/review-pr-agents/auth.md +1 -1
  23. package/commands/review-pr-agents/infra.md +9 -3
  24. package/commands/review-pr-agents/protocols.md +19 -5
  25. package/commands/review-pr-agents/scraper.md +4 -1
  26. package/commands/review-pr-agents/security.md +17 -5
  27. package/commands/review-pr-agents.md +1 -1
  28. package/commands/review-pr-staging.md +302 -8
  29. package/commands/review-pr.md +254 -34
  30. package/commands/work-on/build/architect.md +36 -0
  31. package/commands/work-on/build/validate.md +88 -4
  32. package/commands/work-on/build.md +95 -3
  33. package/commands/work-on/remediate.md +115 -13
  34. package/commands/work-on.md +4 -4
  35. package/docs/CONFIG.md +29 -0
  36. package/docs/RELEASE-NOTES.md +45 -0
  37. package/docs/spec/forge-protocol-v1.md +22 -1
  38. package/docs/spec/review-protocol.md +24 -4
  39. package/package.json +2 -1
  40. package/scripts/check-blast-radius.sh +164 -0
  41. package/scripts/check-blast-radius.test.sh +183 -0
  42. package/scripts/check-contract-scope.sh +137 -0
  43. package/scripts/check-contract-scope.test.sh +83 -0
  44. package/scripts/check-spec-bash.sh +60 -3
  45. package/scripts/check-spec-bash.test.sh +56 -0
  46. package/scripts/check-spec-markers.sh +29 -0
  47. package/scripts/check-test-wiring.sh +57 -0
  48. package/scripts/check-test-wiring.test.sh +70 -0
  49. package/scripts/classify-finding.sh +72 -8
  50. package/scripts/classify-finding.test.sh +77 -8
  51. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  52. package/scripts/diff-size.test.sh +3 -2
  53. package/scripts/doctor-pipeline-state.sh +35 -0
  54. package/scripts/forge-annotation.sh +3 -0
  55. package/scripts/forge-root.test.sh +70 -1
  56. package/scripts/forge-run.sh +2 -2
  57. package/scripts/recovery-claim-live.test.sh +10 -7
  58. package/scripts/remediate-basesync.test.sh +103 -0
  59. package/scripts/review-delta.sh +206 -0
  60. package/scripts/review-delta.test.sh +213 -0
  61. package/scripts/review-pr-gate2.test.sh +97 -0
  62. package/scripts/review-pr-staging-scope.test.sh +99 -0
  63. package/scripts/run-shell-tests.sh +115 -0
  64. package/scripts/select-fix-targets.sh +17 -5
@@ -495,6 +495,8 @@ grep -rn "$GATED_FUNCTION" "$SERVICE_DIR" --include="*.py" | grep -v "#"
495
495
 
496
496
  For each call site found: read the surrounding gate condition (±10 lines). If the condition includes fields that do NOT require the gated resource (e.g., `extraction_schema` gated behind an LLM key check when `extraction_schema` is processed without an LLM), add that caller file to the FORGE:ARCHITECT affected-paths table with a note explaining the incorrect gate condition.
497
497
 
498
+ When Phase A2.8 builds the blast-radius manifest, run the sweep over the whole repository (`git grep`), not only `$SERVICE_DIR` — siblings in other directories are the ones a directory-scoped sweep misses.
499
+
498
500
  **Do NOT** omit sibling callers from the affected-paths table simply because they were not listed in the issue spec. The architect's scope is determined by code correctness, not by the issue spec's file list. A gate-condition bug that exists identically in 3 router files must be fixed in all 3 — even if the issue only named 1.
499
501
 
500
502
  ---
@@ -575,6 +577,30 @@ The comment below passes the table through in a `### Pattern Sweep` section so t
575
577
 
576
578
  ---
577
579
 
580
+ ## Phase A2.8: Blast-Radius Manifest *(skip when no existing symbol, field, flag, schema or contract changes)* <!-- Added: forge#3446 -->
581
+
582
+ The caller and sibling sweep (A1, A2.2) must leave a record a machine can check. For every **existing** symbol, field, flag, schema or contract the change modifies, emit one `SYMBOL:` record and one `HIT:` record per file that mentions it. `scripts/check-blast-radius.sh` re-runs each query against the final tree during validate (Phase V4.5) and fails when a matching file is neither changed nor marked `verified-unaffected`.
583
+
584
+ 1. **Pick a distinctive query per symbol** — a fixed string matching `^[A-Za-z0-9_.:/-]{3,80}$`, not starting with `-` (for example `billing_principal`, not `id`). The checker runs it as `git grep -l -F -e "$Q"`: a literal search, no regex. A query matching more than 200 files is rejected as too broad, so narrow it.
585
+ 2. **Run each query repo-wide** and record every hit file:
586
+ ```bash
587
+ Q="{SYMBOL_QUERY}"
588
+ git grep -l -F -e "$Q" -- .
589
+ ```
590
+ A grep failure (exit above 1) is not "zero hits": report it and stop instead of emitting an empty manifest.
591
+ 3. **Give every hit a disposition**: `change` (the plan edits it; it is also a row in `### Affected Paths`) or `verified-unaffected` with `reason=<one-hyphenated-token>` after you read the hit and confirmed it needs no edit. A file the change itself edits needs no row, but listing it as `change` makes the checker confirm it was really edited.
592
+ 4. **Record format** — one record per line, no spaces inside any value, nothing else between the markers except blank lines and code fences:
593
+ ```
594
+ SYMBOL: id=s1 kind=function|field|flag|schema|contract name=<symbol> query="<fixed-string>"
595
+ HIT: symbol=s1 file=<path> role=producer|consumer|sibling disposition=change
596
+ HIT: symbol=s1 file=<path> role=consumer disposition=verified-unaffected reason=<one-token>
597
+ ```
598
+ 5. **Skip rule** — emit no manifest (omit the whole section) when the architect step is skipped (see Skip Conditions) or when the change touches no existing symbol with consumers (new files only, docs only). An absent manifest is a SKIP at the checker, not a failure.
599
+
600
+ The block is delimited by `<!-- FORGE:BLAST_RADIUS:BEGIN -->` and `<!-- FORGE:BLAST_RADIUS:END -->`. Never use the architect marker strings as its delimiters: phase-trail and engine detection key on those markers alone, so the manifest stays additive. Builders may add rows (a justified `verified-unaffected`) but must not delete them. The manifest is treated as untrusted data by consumers: only the newest trusted FORGE:ARCHITECT comment is read.
601
+
602
+ ---
603
+
578
604
  ## Phase A3: Consistency Rules
579
605
 
580
606
  Identify invariants that ALL affected paths must satisfy. These become the builder's consistency checklist.
@@ -697,6 +723,15 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ARCHITECT -->
697
723
  |-------|-----|-------------|
698
724
  | {QUERY} | {FILE:LINE} | {fix \| not-affected — reason} |
699
725
 
726
+ ### Blast-Radius Manifest
727
+ <!-- Phase A2.8. Omit this whole section when no existing symbol changes (see Skip rule).
728
+ Escape every double quote inside the record as a backslash-quote, because this body is posted inside a double-quoted shell string. -->
729
+ <!-- FORGE:BLAST_RADIUS:BEGIN -->
730
+ SYMBOL: id=s1 kind={function|field|flag|schema|contract} name={SYMBOL} query=\"{FIXED_STRING_QUERY}\"
731
+ HIT: symbol=s1 file={FILE} role={producer|consumer|sibling} disposition=change
732
+ HIT: symbol=s1 file={FILE} role={producer|consumer|sibling} disposition=verified-unaffected reason={one-hyphenated-token}
733
+ <!-- FORGE:BLAST_RADIUS:END -->
734
+
700
735
  ### Implementation Order
701
736
  1. {FIRST_CHANGE} — {WHY_FIRST}
702
737
  2. {SECOND_CHANGE} — {WHY_SECOND}
@@ -801,6 +836,7 @@ B4 → [THIS MODULE] Architecture Planning (FORGE:ARCHITECT comment)
801
836
  Phase A2: Trace the Data Flow (A2.1 Runtime UID x Volume Ownership, A2.2 Gate-Condition Caller Sweep)
802
837
  Phase A2.5: Pipeline Phase-Dependency Check
803
838
  Phase A2.7: Pattern Sweep Intake (review-finding issues only)
839
+ Phase A2.8: Blast-Radius Manifest (enforced by validate V4.5, passed to review-pr)
804
840
  Phase A3: Consistency Rules
805
841
  Phase A4: Sequence the Implementation
806
842
  Phase A5: Risk Assessment
@@ -43,7 +43,7 @@ Parse from $ARGUMENTS:
43
43
 
44
44
  ## Skip Conditions
45
45
 
46
- Skip Phases V0–V4 (set `GATE_PASSED=true`, iterations 0) and go straight to Phase V5 (which posts the skip-path quality-gate marker, commits, and marks the build complete) if:
46
+ Skip Phases V0–V4.5 (set `GATE_PASSED=true`, iterations 0) and go straight to Phase V5 (which posts the skip-path quality-gate marker, commits, and marks the build complete) if:
47
47
  - Only 1 file was changed AND the shared predicate classifies it as documentation: `printf '%s\n' "<that file>" | bash "$FORGE_ROOT/scripts/is-docs-only.sh"` exits 0 (resolve `FORGE_ROOT` with the canonical block; unresolvable → the gate runs). A single file under `commands/`, `.claude/`, `.agents/`, `hooks/`, … or any agent-instruction Markdown is **code** for this purpose (it contains executable shell and agent instructions) and is never skipped. <!-- field test: phase-4-execution.md was skipped as "docs" -->
48
48
 
49
49
  In all other cases, the gate MUST run.
@@ -508,6 +508,86 @@ docker exec {CONTAINER_NAME} env | grep {VAR_NAME}
508
508
 
509
509
  ---
510
510
 
511
+ ## Phase V4.5: Blast-Radius Check *(runs after V4, before V5 — skipped only on the docs-only skip path)* <!-- Added: forge#3446 -->
512
+
513
+ The architect records the caller and sibling sweep as a machine-readable manifest between `<!-- FORGE:BLAST_RADIUS:BEGIN -->` and `<!-- FORGE:BLAST_RADIUS:END -->` inside its FORGE:ARCHITECT comment. This phase runs `check-blast-radius.sh`, a script and not a prompt, which re-runs every manifest query against the final tree and fails on any matching file that is neither changed nor marked `verified-unaffected`.
514
+
515
+ - **Trust**: the manifest drives a gate, so it is read only from the newest comment that `trusted-comments.sh` accepts (the same trust predicate as the phase-trail verifier). An untrusted comment is never parsed.
516
+ - **Install root only**: both scripts resolve from `FORGE_ROOT` and never from the worktree, because the branch under audit must not supply its own gate. An unresolvable script is DEGRADED, never a pass.
517
+ - **Exit codes**: `0` covered or no manifest (SKIP); `1` an `UNLISTED:` or `NOT_DONE:` line was printed, so set `GATE_PASSED=false`, fix the code (or add a justified `verified-unaffected` row to the plan by editing the FORGE:ARCHITECT comment; never delete a row), and re-run V1 onward; anything else, a failed fetch, or an unresolvable script is DEGRADED: append `blast-radius` to `SKIPPED_CHECKS` so it appears in `verification_skipped`, and print the reason. DEGRADED never passes silently and a failed fetch is never treated as "no manifest".
518
+ - **Carry-forward**: each Bash call is a fresh shell, so V4.5's variables do not reach V5. The block prints its result as explicit lines: `BLAST_RADIUS_STATE: <PASS|SKIP|FAIL|DEGRADED>`, plus `GATE_PASSED: false` on FAIL and `SKIPPED_CHECKS+=blast-radius` on DEGRADED. Record those lines in your notes; V5 Step 1 sets `GATE_PASSED` and V5 Step 4 sets `SKIPPED_CHECKS` from them, alongside the V2 notes.
519
+
520
+ ```bash
521
+ cd {WORKTREE_PATH}
522
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
523
+ FORGE_ROOT=""
524
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
525
+ _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
526
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
527
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
528
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
529
+ # 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.
530
+ _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")")"
531
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
532
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
533
+ # 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)
534
+ _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)"
535
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
536
+ # '${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.
537
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
538
+ while IFS= read -r _c; do
539
+ 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
540
+ done <<< "$_k"
541
+ fi
542
+ BR_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-blast-radius.sh}"; [ -f "$BR_SCRIPT" ] || BR_SCRIPT=""
543
+ TRUST_SCRIPT="${FORGE_ROOT:+$FORGE_ROOT/scripts/trusted-comments.sh}"; [ -f "$TRUST_SCRIPT" ] || TRUST_SCRIPT=""
544
+ BR_STATE=""; BR_NOTE=""; BR_FILE=$(mktemp "${TMPDIR:-/tmp}/forge-blast-radius.XXXXXX")
545
+ if [ -z "$BR_SCRIPT" ] || [ -z "$TRUST_SCRIPT" ]; then
546
+ BR_STATE="DEGRADED"; BR_NOTE="check-blast-radius.sh or trusted-comments.sh not found under FORGE_ROOT"
547
+ else
548
+ COMMENTS_JSON=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments 2>/dev/null); FETCH_RC=$?
549
+ if [ "$FETCH_RC" -ne 0 ]; then
550
+ BR_STATE="DEGRADED"; BR_NOTE="could not fetch issue comments (rc=${FETCH_RC})"
551
+ else
552
+ BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUST_SCRIPT" bodies '^<!-- FORGE:ARCHITECT -->'); TRUST_RC=$?
553
+ if [ "$TRUST_RC" -ne 0 ]; then
554
+ BR_STATE="DEGRADED"; BR_NOTE="trusted-comments.sh failed (rc=${TRUST_RC})"
555
+ elif [ -z "$BODIES" ]; then
556
+ BR_STATE="SKIP"; BR_NOTE="no trusted FORGE:ARCHITECT comment"
557
+ else
558
+ # one JSON string per line, newest last
559
+ printf '%s\n' "$BODIES" | tail -n 1 | jq -r . > "$BR_FILE"; JQ_RC=$?
560
+ if [ "$JQ_RC" -ne 0 ]; then
561
+ BR_STATE="DEGRADED"; BR_NOTE="could not decode the FORGE:ARCHITECT comment (rc=${JQ_RC})"
562
+ else
563
+ git fetch origin {PR_BASE} >/dev/null 2>&1 || true
564
+ BR_OUT=$(bash "$BR_SCRIPT" --manifest "$BR_FILE" --base "origin/{PR_BASE}" --repo "{WORKTREE_PATH}" 2>&1); BR_RC=$?
565
+ printf '%s\n' "$BR_OUT"
566
+ case "$BR_RC" in
567
+ 0) BR_STATE="PASS" ;;
568
+ 1) BR_STATE="FAIL"; GATE_PASSED=false ;;
569
+ *) BR_STATE="DEGRADED"; BR_NOTE="check-blast-radius.sh exited ${BR_RC}" ;;
570
+ esac
571
+ fi
572
+ fi
573
+ fi
574
+ fi
575
+ rm -f "$BR_FILE"
576
+ echo "BLAST_RADIUS: ${BR_STATE}${BR_NOTE:+ — $BR_NOTE}"
577
+ # Carry-forward lines for V5 (fresh shell): read them back in V5 Step 1 and Step 4.
578
+ echo "BLAST_RADIUS_STATE: ${BR_STATE}"
579
+ if [ "$BR_STATE" = "FAIL" ]; then
580
+ echo "GATE_PASSED: false"
581
+ elif [ "$BR_STATE" = "DEGRADED" ]; then
582
+ SKIPPED_CHECKS="${SKIPPED_CHECKS:+$SKIPPED_CHECKS, }blast-radius"
583
+ echo "SKIPPED_CHECKS+=blast-radius"
584
+ fi
585
+ ```
586
+
587
+ On `FAIL`, the blocker line for `VALIDATE_RESULT` is `blast-radius: <UNLISTED/NOT_DONE lines>`; the repair loop (build B6.5) fixes the listed files, not the checker or the manifest.
588
+
589
+ ---
590
+
511
591
  ## Phase V5: Marker, Commit, Audit, Complete (always — after GATE_PASSED=true)
512
592
 
513
593
  Phase V5 runs in this exact order: **(1)** post the `FORGE:QUALITY_GATE` marker, **(2)** commit, **(3)** ancestry audit, **(4)** append `FORGE:BUILDER:COMPLETE` and the verification status. Do not reorder.
@@ -518,7 +598,10 @@ The quality gate must leave a checkable artifact. `scripts/verify-phase-trail.sh
518
598
 
519
599
  **Skip-path marker**: when the Skip Conditions above set `GATE_PASSED=true` early (single config/docs file), still post the marker with `**Result**: PASS (skipped — single config/docs file)` and `**Iterations**: 0`, so the verifier never has to guess. The verifier's `--docs-only` waiver additionally covers diffs accepted by `scripts/is-docs-only.sh` (allowlisted Markdown only: `docs/**` or root README/CHANGELOG/CONTRIBUTING/SECURITY/GOVERNANCE; nested `AGENTS.md`/`CLAUDE.md`/`SKILL.md`/`GEMINI.md` and the instruction directories `commands/`, `devdocs/`, `templates/`, `skills/`, `agents/`, `hooks/`, `.claude/`, `.claude-plugin/`, `.agents/`, `.codex/`, `.cursor/`, `.github/`, `.opencode/`, `.gemini/`, `.kiro/` excluded; callers feed both sides of renames).
520
600
 
601
+ Set `GATE_PASSED` from the V1 loop result, then apply V4.5's printed carry-forward line: if V4.5 printed `GATE_PASSED: false` (`BLAST_RADIUS_STATE: FAIL`), `GATE_PASSED` is `false` here even if the V1 loop passed.
602
+
521
603
  ```bash
604
+ # GATE_PASSED: set from the V1 loop result, and forced to false when V4.5 printed "GATE_PASSED: false"
522
605
  GATE_RESULT=$([ "$GATE_PASSED" = "true" ] && echo PASS || echo FAIL)
523
606
  # Bind the PASS to what was actually gated: the staged tree is exactly the tree the V5 commit will have.
524
607
  # scripts/verify-phase-trail.sh --head-tree (work-on/review.md R1.5) rejects a PASS recorded for a different tree. <!-- Added: forge#3149 -->
@@ -647,12 +730,12 @@ If the output contains `ANCESTRY_FAILED=1`: do NOT append `:COMPLETE` and do NOT
647
730
 
648
731
  After the ancestry audit passes (or is skipped), patch the existing FORGE:BUILDER comment: add the `**Verification Status**` line, the best-effort `cost_usd:` line, and the `<!-- FORGE:BUILDER:COMPLETE -->` marker. This is the **only** place the marker is written — it signals that a real commit exists on the branch and the build is safe to resume-skip. <!-- Added: forge#1305 -->
649
732
 
650
- `SKIPPED_CHECKS` comes from Phase V2. Shell state may not persist between Bash calls, so set it here from your V2 notes before running the block (comma-separated check names, empty when every configured check ran).
733
+ `SKIPPED_CHECKS` comes from Phase V2 and Phase V4.5. Shell state may not persist between Bash calls, so set it here from your V2 notes before running the block (comma-separated check names, empty when every configured check ran), and append `blast-radius` when V4.5 printed `SKIPPED_CHECKS+=blast-radius` (`BLAST_RADIUS_STATE: DEGRADED`).
651
734
 
652
735
  **Cost line reconciliation**: the machine-readable `cost_usd:` line (best-effort; only when `PHASE_COST_USD` is available, never blocking) is the single cost signal. Do not add a separate `**Cost (build phase)**` line.
653
736
 
654
737
  ```bash
655
- # SKIPPED_CHECKS: set from the V2 notes, e.g. SKIPPED_CHECKS="python.format, typescript.typecheck/build"
738
+ # SKIPPED_CHECKS: set from the V2 notes plus V4.5's carry-forward line, e.g. SKIPPED_CHECKS="python.format, blast-radius"
656
739
  SKIPPED_CHECKS="${SKIPPED_CHECKS:-}"
657
740
  if [ -z "$SKIPPED_CHECKS" ]; then
658
741
  VERIFICATION_STATUS="✅ All configured verification commands passed"
@@ -708,7 +791,7 @@ VALIDATE_RESULT:
708
791
  blocker: {description if gate_passed=false}
709
792
  verification_skipped: [] # empty when all configured checks ran; list of skipped check names otherwise
710
793
  # e.g. ["python.format", "typescript.typecheck/build"]
711
- # populated from SKIPPED_CHECKS in Phase V2
794
+ # populated from SKIPPED_CHECKS in Phase V2 and V4.5
712
795
  ```
713
796
 
714
797
  ---
@@ -722,6 +805,7 @@ implement (work-on:build:implement) — code written, staged (not committed), FO
722
805
  → [THIS MODULE] validate
723
806
  V0 self-check → V1 quality-gate loop (Skill quality-gate, forked) → V2 format/verify + known-slow/learned tests
724
807
  → V3 proxy wiring → V3.5 DB advisory → V3.6 browser signals → V4 deploy completeness
808
+ → V4.5 blast-radius manifest check (check-blast-radius.sh, install-root only)
725
809
  → V5 marker → commit → ancestry audit → BUILDER:COMPLETE
726
810
  review (work-on:review) — push, PR, merge
727
811
  ```
@@ -23,7 +23,7 @@ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --base {PR_BAS
23
23
  **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154. This file's mechanical bits (classification, label transitions) stay at this tier because they are interleaved with the reasoning-heavy child phases in the same run. <!-- Added: forge#1827 -->
24
24
  **NEVER use plan mode (EnterPlanMode).**
25
25
 
26
- **CRITICAL: You MUST execute ALL phases B0–B6.5 (including B5.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
+ **CRITICAL: You MUST execute ALL phases B0–B6.5 (including B5.5) in order (the one exception is the B0 size-gate resume branch, which enters at B5.5 with its state verified). 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.**
27
27
 
28
28
  **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.
29
29
 
@@ -173,6 +173,20 @@ EXISTING_FAST_PATH=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate
173
173
 
174
174
  **Resume check**:
175
175
  - If `<!-- FORGE:BUILDER:COMPLETE -->` is present in a BUILDER comment → build already complete. Derive BRANCH/WORKTREE_PATH as in B1A/B1C (without creating anything), then print `BUILD_RESULT: status: ALREADY_DONE`.
176
+ - **Size-gate resume** (checked BEFORE the partial-BUILDER rule below; first match wins) <!-- Added: forge#3463 -->: if a `<!-- FORGE:BUILDER -->` comment exists without `<!-- FORGE:BUILDER:COMPLETE -->` AND the latest trusted `FORGE:DIFF_SIZE` comment has `result: OVER`, the build stopped at a deliberate B5.5 exit (`NEEDS_DECOMPOSE` or the decompose-loop-guard Blocked exit), not in a crash. Keep the BUILDER comment, do not re-run B2-B5, and re-enter at **Phase B5.5**. Read the marker through the same anchored, trust-filtered lookup B5.5 uses (script resolution block verbatim):
177
+ ```bash
178
+ # <Script resolution block from above goes here, verbatim>
179
+ set -o pipefail
180
+ TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
181
+ [ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ] || { echo "DIFF_SIZE_LOOKUP=FAILED (trusted-comments.sh unresolved)"; exit 0; }
182
+ DS=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE') \
183
+ || { echo "DIFF_SIZE_LOOKUP=FAILED"; exit 0; }
184
+ LAST_RESULT=$(printf '%s\n' "$DS" | jq -r 'select(type == "string")' | sed -n 's/^result: *\([A-Z]*\).*/\1/p' | tail -n 1)
185
+ echo "DIFF_SIZE_RESULT=${LAST_RESULT:-<none>}"
186
+ ```
187
+ - `DIFF_SIZE_LOOKUP=FAILED` → Blocked exit with blocker `size-gate-unavailable: could not read FORGE:DIFF_SIZE` (a transient GitHub failure uses the `github-unavailable:` prefix). Never read a failed lookup as "no DIFF_SIZE".
188
+ - `DIFF_SIZE_RESULT=OVER` → verify, without creating anything, that the branch and worktree exist (derive BRANCH/WORKTREE_PATH as in B1A/B1C), that `git -C "$WORKTREE_PATH" branch --show-current` equals BRANCH, and that a staged diff is present (`! git -C "$WORKTREE_PATH" diff --cached --quiet`). Recover `COMPLEXITY_BAND` from `EXISTING_FAST_PATH` with the B0.5 resume extraction. When all hold, skip B0.5-B5 and continue at B5.5 (it re-measures, then applies the override / `NEEDS_DECOMPOSE` / `OK` logic).
189
+ - Anything else (no `FORGE:DIFF_SIZE`, `result` is not `OVER`, worktree or branch missing or mismatched, nothing staged, band unrecoverable) → never skip ahead on missing state; fall through to the next rule.
176
190
  - If `<!-- FORGE:BUILDER -->` exists BUT `<!-- FORGE:BUILDER:COMPLETE -->` is ABSENT → build was interrupted after the comment was posted but before the commit (validate V5). Delete the partial comment and restart from Phase B2 (contract): <!-- Added: forge#1305 -->
177
191
  ```bash
178
192
  PARTIAL_ID=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate \
@@ -347,7 +361,83 @@ esac
347
361
 
348
362
  **Skip if COMPLEXITY_BAND: INVESTIGATION** — go to B5. For every other band (TRIVIAL included) the contract is mandatory. If a `FORGE:CONTRACT` comment already exists on the issue (resume), do not post a second one — continue to B2.1.
349
363
 
350
- Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why:
364
+ Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why. Run B2.0 first: it files the follow-up issues for `deferred` out-of-scope items and validates the section, so `{OUT_OF_SCOPE_ITEMS}` below already holds real issue numbers.
365
+
366
+ ### B2.0: Out-of-scope dispositions — file deferred follow-ups, validate the section <!-- Added: forge#3447 -->
367
+
368
+ Every item in the contract's `### Out of Scope` section must carry exactly one disposition, or the section is the single line `None.`. Each bullet names at least one backticked path or symbol so review can match findings against it:
369
+
370
+ ```
371
+ - `path/or/symbol` — deferred → #N: <why it is deferred>
372
+ - `path/or/symbol` — not-affected: <evidence, e.g. the grep or call-graph check that shows no impact>
373
+ - `path/or/symbol` — accepted-risk: <reason the risk is acceptable>
374
+ ```
375
+
376
+ - **`deferred → #N`**: a follow-up issue `#N` already exists (filed in this step, before the contract is posted). Use it for a sibling path the work leaves alone but that still needs the change.
377
+ - **`not-affected: <evidence>`**: the path was checked and the change does not reach it. The evidence must be concrete. Review never demotes a finding against a `not-affected` item: a finding that contradicts the evidence means the contract was wrong.
378
+ - **`accepted-risk: <reason>`**: a known gap that is knowingly kept. Review demotes a non-security finding on this path to a note.
379
+ - **Known-flaw rule**: a contract that keeps a known-flawed design (a design the investigation or architect plan identified as flawed, a fail-open caller, an unsound threshold) must state the flaw and list it as a `deferred → #N` item with a linked follow-up. It may not hide behind `accepted-risk` or free text.
380
+ - Free-text exclusions without a disposition are invalid. `->` is accepted for `→`.
381
+
382
+ **File the `deferred` follow-ups** (skip the filing on resume when the contract already exists):
383
+
384
+ ```bash
385
+ # <Script resolution block from work-on/build.md, verbatim>
386
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
387
+ # For each deferred item (DEFERRED_PATH = the backticked path, DEFERRED_WHY = one line), repeat:
388
+ DEFERRED_MARKER="<!-- FORGE:DEFERRED_FROM: #{NUMBER} -->"
389
+ LOOKUP=$(gh issue list {GH_FLAG} --state open --search "\"FORGE:DEFERRED_FROM: #{NUMBER}\" in:body" \
390
+ --json number,body --limit 50 2>/dev/null) || LOOKUP="FAILED"
391
+ if [ "$LOOKUP" = "FAILED" ]; then
392
+ echo "DEFERRED_LOOKUP=FAILED" # a failed lookup is not "none found": Blocked exit, never re-file blind
393
+ else
394
+ DEFERRED_NUM=$(printf '%s' "$LOOKUP" | jq -r --arg p "$DEFERRED_PATH" \
395
+ '[.[] | select(.body | contains($p))] | first | .number // empty' 2>/dev/null)
396
+ if [ -z "$DEFERRED_NUM" ]; then
397
+ DEFERRED_URL=$(run gh issue create {GH_FLAG} --title "follow-up: ${DEFERRED_PATH} (deferred from #{NUMBER})" \
398
+ --body "## Problem
399
+
400
+ ${DEFERRED_WHY}
401
+
402
+ Deferred from #{NUMBER}: \`${DEFERRED_PATH}\` was declared out of scope in its contract and is tracked here instead of surfacing later as a review finding.
403
+
404
+ ## Affected Files
405
+
406
+ - \`${DEFERRED_PATH}\`
407
+
408
+ ## Acceptance Criteria
409
+
410
+ - [ ] The concern above is resolved for \`${DEFERRED_PATH}\`
411
+
412
+ ${DEFERRED_MARKER}")
413
+ DEFERRED_NUM=$(printf '%s' "$DEFERRED_URL" | grep -oE '[0-9]+$')
414
+ [ -n "${DRY_RUN:-}" ] && DEFERRED_NUM="0"
415
+ fi
416
+ echo "DEFERRED_ISSUE=${DEFERRED_NUM:-}"
417
+ fi
418
+ ```
419
+
420
+ - `DEFERRED_LOOKUP=FAILED`, or an empty `DEFERRED_NUM` after a real (non-`DRY_RUN`) create → Blocked exit with blocker `github-unavailable: could not file or look up deferred follow-up for <path>` (a transient failure follows the retry rule above; no `needs-human` for `github-unavailable:`).
421
+ - On resume the lookup reuses the open issue that carries the marker and the same path, so a retried build never files a duplicate.
422
+ - Prefer filing through the `/issue` create-hook's programmatic contract (`Skill(skill="{FORGE_SKILL_PREFIX}issue", ...)`) when it is available; the raw create above is the fallback and must keep the `FORGE:DEFERRED_FROM` marker in the body.
423
+
424
+ **Validate the composed section** with `scripts/check-contract-scope.sh validate` (the exact text that will replace `{OUT_OF_SCOPE_ITEMS}`):
425
+
426
+ ```bash
427
+ # <Script resolution block from work-on/build.md, verbatim>
428
+ SCOPE_FILE=$(mktemp "${TMPDIR:-/tmp}/contract-scope.XXXXXX")
429
+ printf '### Out of Scope\n\n%s\n' "{OUT_OF_SCOPE_ITEMS}" > "$SCOPE_FILE"
430
+ SCRIPT_REF=$(resolve_script 'check-contract-scope')
431
+ case "$SCRIPT_REF" in
432
+ prose:*) echo "SCOPE_CHECK=PROSE" ;; # apply the three-disposition rules above by hand
433
+ *) bash "${SCRIPT_REF#*:}" validate "$SCOPE_FILE"; echo "SCOPE_CHECK_RC=$?" ;;
434
+ esac
435
+ rm -f "$SCOPE_FILE"
436
+ ```
437
+
438
+ - `SCOPE_CHECK_RC=0` (or `SCOPE_CHECK=PROSE` after a manual check) → post the contract.
439
+ - `SCOPE_CHECK_RC=1` → the stderr lists each bad item. Fix the items (add the missing disposition, file the missing follow-up, name a backticked path), recompose, and re-run once. Still invalid → Blocked exit with blocker `contract-scope-invalid: <first bad item>`.
440
+ - `SCOPE_CHECK_RC=2` → a usage error in this step: fix the invocation and retry once, then Blocked exit.
351
441
 
352
442
  **Before posting, read the attribution config**:
353
443
  ```bash
@@ -387,6 +477,8 @@ run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CONTRACT -->
387
477
  ${ATTRIBUTION_LINE}"
388
478
  ```
389
479
 
480
+ `{OUT_OF_SCOPE_ITEMS}` is the validated B2.0 section body: `None.`, or typed bullets using the literal dispositions `deferred → #N`, `not-affected:` and `accepted-risk:`. Never post free text there.
481
+
390
482
  Contract must be grounded in the investigation report. Every deliverable file must appear in the affected files list from the investigator. Adversarially validate the proposed fix against adjacent system layers before posting.
391
483
 
392
484
  ### B2.1: Post FORGE:CLAIM on coordination issue (conditional — when running under orchestration batch) <!-- Added: forge#1736 -->
@@ -608,7 +700,7 @@ BUILD_RESULT:
608
700
  blocker: diff-size-over-threshold: {diff_lines} > {threshold}; split proposal posted in FORGE:DIFF_SIZE
609
701
  ```
610
702
 
611
- **Resume**: a re-invoked build whose latest trusted `FORGE:DIFF_SIZE` says `result: OVER` re-measures; if still over with no override it re-emits `NEEDS_DECOMPOSE` without a duplicate comment. When a valid `FORGE:SIZE_OVERRIDE` has appeared since, B5.5 re-measures and continues via `OVERRIDDEN`.
703
+ **Resume**: a re-invoked build re-enters here via the B0 size-gate resume branch (partial `FORGE:BUILDER` plus latest trusted `FORGE:DIFF_SIZE` `result: OVER` plus a staged diff in the kept worktree), so it does not re-run implement; this covers both exits that leave BUILDER incomplete, `NEEDS_DECOMPOSE` and the decompose-loop-guard Blocked exit. B5.5 re-measures: if still over with no override it re-emits `NEEDS_DECOMPOSE` without a duplicate comment (the loop-guard case re-emits the same Blocked exit); when a valid `FORGE:SIZE_OVERRIDE` has appeared since, it continues via `OVERRIDDEN`.
612
704
  # MUST CONTINUE to Phase B6 — the size gate result is intermediate, NOT terminal (validation still required), except the NEEDS_DECOMPOSE and Blocked exits above.
613
705
 
614
706
  ---
@@ -114,7 +114,7 @@ fi
114
114
  PARTIAL_PR_COMMENT_IDS=$(printf '%s' "$PR_COMMENTS" | jq -rs '[.[][] | select((.body // "") | startswith("<!-- FORGE:REMEDIATION -->")) | select((.body | contains("<!-- FORGE:REMEDIATION:COMPLETE -->")) | not) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | .id] | .[]' 2>/dev/null || true)
115
115
  ```
116
116
 
117
- **Per-kind scoping of the single-attempt guard** (forge#3496): the guard is scoped per remediation kind so a base-sync round is not swallowed by an earlier `ci-gate` / `inpr-fix` remediation on the same PR. A base-sync handoff is recognised by the router's `FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER}` bound marker on the issue (the router posts it immediately before invoking this phase); a completed base-sync round is recognised by a `FORGE:REMEDIATION:COMPLETE` trail carrying the `**Base sync**: ran` line that Phase M8 posts. The base-sync round is itself bounded to one, by that marker count in `work-on/review.md` R4 and by this check:
117
+ **Per-kind scoping of the single-attempt guard** (forge#3496): the guard is scoped per remediation kind so a base-sync round is not swallowed by an earlier `ci-gate` / `inpr-fix` remediation on the same PR. A base-sync handoff is recognised by the router's `FORGE:BASESYNC_REMEDIATION: pr={PR_NUMBER}` bound marker on the issue (the router posts it immediately before invoking this phase); a completed base-sync round is recognised by a `FORGE:REMEDIATION:COMPLETE` trail carrying the `**Base sync**: ran` line that Phase M8 posts. The router handoff is bounded to one per PR (the marker count in `work-on/review.md` R4, unchanged) and this check; inside remediation the sync is bounded per distinct base head and hard-capped at `BASESYNC_MAX_ROUNDS` pushed sync merges on the branch, counted from git rather than shell state (see Phase M3 and M6):
118
118
 
119
119
  ```bash
120
120
  if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
@@ -203,12 +203,14 @@ If the worktree/branch checkout fails for any reason (branch deleted, force-push
203
203
 
204
204
  ## Phase M3: Apply Fixes
205
205
 
206
- For each FIXABLE item from Phase M1: read the affected file(s) in `{WORKTREE_PATH}` before editing (never assume current state), apply the fix. Follow the same implementation discipline as `work-on/build/implement.md` I3 (cross-lane import guard, library-callback verification, deliverable-type consistency, no unrequested scope) — this file does not restate those rules, it inherits them.
206
+ For each FIXABLE item from Phase M1 (the base-sync item, when present, goes first; see below): read the affected file(s) in `{WORKTREE_PATH}` before editing (never assume current state), apply the fix. Follow the same implementation discipline as `work-on/build/implement.md` I3 (cross-lane import guard, library-callback verification, deliverable-type consistency, no unrequested scope) — this file does not restate those rules, it inherits them.
207
207
 
208
208
  **Never delete, skip, or weaken a failing check to go green.** Removing, skipping (`skip`, `xfail`, `continue-on-error`, `if: false`, commenting out) or weakening a failing test or CI step is never a valid fix. Fix the cause in the code under test, or classify the item UNFIXABLE, add/re-affirm `needs-human`, and post a comment that names the failing tests and failing assertions (test title plus assertion text from the CI log). The only exception is a deletion justified by removal of the tested code itself (see quality-gate 2U). Enforcement is the quality gate's coverage-reduction check (2U): a `COVERAGE-1` finding is fixed by restoring the test or step, never by suppressing the finding. <!-- Added: forge#3257 -->
209
209
 
210
210
  **Base-sync (merge-only)** <!-- Added: forge#3496 -->: runs when the block reason was a base conflict (`base-conflict`, or `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection). This step is authorized by the operator's batch dispatch and by the router's `FORGE:BASESYNC_REMEDIATION` bound; it applies to the PR's own branch in `{WORKTREE_PATH}` only. Never rebase, never force-push, never merge into or push to a shared branch.
211
211
 
212
+ **Ordering**: the base sync runs first in M3, right after M2's clean checkout and before any other FIXABLE edit. `git merge` needs a clean tree, and merging over uncommitted edits is refused (rc=1, no merge in progress) or leaves a merge that cannot be safely aborted. Apply the other FIXABLE items only after the sync has concluded (clean merge, or conflicts resolved and committed). Runs with no base-conflict block reason skip this step and are unchanged. <!-- Added: forge#3515 -->
213
+
212
214
  ```bash
213
215
  cd {WORKTREE_PATH}
214
216
  CUR_BRANCH=$(git rev-parse --abbrev-ref HEAD)
@@ -216,20 +218,48 @@ case "{HEAD_BRANCH}" in
216
218
  "{PR_BASE}"|main|master|staging|milestone/*) echo "refusing base sync: {HEAD_BRANCH} is a shared branch"; BASESYNC_REFUSED=true ;;
217
219
  esac
218
220
  [ "$CUR_BRANCH" = "{HEAD_BRANCH}" ] || BASESYNC_REFUSED=true
221
+ # Clean-tree precondition: never merge over pending edits.
222
+ if ! { git diff --quiet && git diff --cached --quiet; }; then
223
+ echo "refusing base sync: working tree has uncommitted changes"; BASESYNC_REFUSED=true
224
+ fi
225
+ git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
226
+ # Durable sync state (forge#3517): every block runs in a fresh shell, so never carry rounds or the base head in shell vars.
227
+ # Rounds = merge commits on the PUSHED branch's first-parent chain that are not on the base (a round counts only once pushed;
228
+ # a manual merge counts too, fail closed). Recorded base head = the newest such merge's second parent, when that is a base commit.
229
+ # An unreadable count is the cap.
230
+ BASESYNC_MAX_ROUNDS=2 # hard cap on sync rounds per PR (M3 plus M6 re-syncs)
231
+ BASESYNC_ROUNDS=$(git rev-list --first-parent --merges --count origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null) || BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS
232
+ _lm=$(git rev-list --first-parent --merges -1 origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null || true); _p2=""
233
+ [ -n "$_lm" ] && _p2=$(git rev-parse -q --verify "$_lm^2" 2>/dev/null) && git merge-base --is-ancestor "$_p2" origin/{PR_BASE} 2>/dev/null || _p2=""
234
+ BASESYNC_BASE_SHA="$_p2"
235
+ if [ "$BASESYNC_ROUNDS" -ge "$BASESYNC_MAX_ROUNDS" ]; then
236
+ echo "refusing base sync: ${BASESYNC_ROUNDS} sync round(s) already on {HEAD_BRANCH} (cap ${BASESYNC_MAX_ROUNDS})"; BASESYNC_REFUSED=true
237
+ fi
219
238
  if [ "${BASESYNC_REFUSED:-false}" != "true" ]; then
220
- git fetch origin {PR_BASE} # allowlist:check-command-side-effects
221
- git merge origin/{PR_BASE} --no-edit # allowlist:check-command-side-effects
222
- BASESYNC_RAN=true
239
+ BASESYNC_BASE_SHA=$(git rev-parse origin/{PR_BASE}) # the base head this round merges; persisted by M8 as `Base sync**: ran (base=<sha>)`
240
+ MERGE_OUT=$(git merge origin/{PR_BASE} --no-edit 2>&1); MERGE_RC=$? # allowlist:check-command-side-effects
241
+ echo "$MERGE_OUT"
242
+ # rc alone cannot tell a conflict from a refusal (both exit 1); MERGE_HEAD can.
243
+ if [ "$MERGE_RC" -eq 0 ]; then
244
+ BASESYNC_RAN=true
245
+ elif git rev-parse -q --verify MERGE_HEAD >/dev/null; then
246
+ BASESYNC_CONFLICTED=true # merge in progress with conflicts: resolve or park below
247
+ else
248
+ BASESYNC_MERGE_REFUSED=true # rc!=0 and no MERGE_HEAD: refused, nothing was merged
249
+ fi
223
250
  fi
224
251
  ```
225
252
 
226
- - Clean merge: nothing more to resolve; the merge commit is the sync.
227
- - Conflicts: read BOTH sides of every conflicted hunk and the surrounding code, then write the combined result by hand. No `-X ours`/`-X theirs`, no wholesale `git checkout --ours/--theirs`, no blanket take-one-side. After resolving, `git add` the files and conclude with `git commit -s --no-edit` (the merge commit).
228
- - **Unresolvable with confidence** (a hunk whose intent on either side you cannot reconcile, or the branch is refused above): capture the file list first, then abort, and park:
253
+ Exactly one outcome applies. `BASESYNC_RAN=true` is set only on a clean merge (or after a conflicted merge is concluded below); a refused merge is never treated as synced.
254
+
255
+ - Clean merge (`MERGE_RC=0`): nothing more to resolve; the merge commit is the sync.
256
+ - Conflicts (`MERGE_RC` nonzero, `MERGE_HEAD` present): read BOTH sides of every conflicted hunk and the surrounding code, then write the combined result by hand. No `-X ours`/`-X theirs`, no wholesale `git checkout --ours/--theirs`, no blanket take-one-side. After resolving, `git add` the files and conclude with `git commit -s --no-edit` (the merge commit), then set `BASESYNC_RAN=true`.
257
+ - **Unresolvable with confidence** (a hunk whose intent on either side you cannot reconcile, or the branch/tree is refused above): capture the file list first, then abort only if a merge is in progress, and park:
229
258
  ```bash
230
259
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
231
260
  CONFLICT_FILES=$(git diff --name-only --diff-filter=U)
232
- git merge --abort # allowlist:check-command-side-effects
261
+ [ -n "$CONFLICT_FILES" ] || CONFLICT_FILES="(no conflicted files reported)"
262
+ if git rev-parse -q --verify MERGE_HEAD >/dev/null; then git merge --abort; fi # allowlist:check-command-side-effects
233
263
  run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
234
264
  Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) could not be resolved with confidence. Conflicting files:
235
265
 
@@ -239,6 +269,19 @@ fi
239
269
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
240
270
  ```
241
271
  EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: unresolvable conflicts". Only that issue is parked.
272
+ - **Refused merge** (`MERGE_RC` nonzero and no `MERGE_HEAD`, i.e. `BASESYNC_MERGE_REFUSED=true`): git declined to start the merge (for example local changes would be overwritten). This is not a conflict and not a sync. There is nothing to abort. Park with the git output:
273
+ ```bash
274
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
275
+ run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "<!-- FORGE:BASESYNC_FAILED -->
276
+ Base sync of PR #{PR_NUMBER} (\`origin/{PR_BASE}\` into \`{HEAD_BRANCH}\`) was refused by git (rc=${MERGE_RC}); no merge was started. Git output:
277
+
278
+ \`\`\`
279
+ ${MERGE_OUT}
280
+ \`\`\`" # allowlist:check-command-side-effects
281
+ gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
282
+ ```
283
+ EXIT `REMEDIATE_RESULT: status: BLOCKED` with blocker "base-sync: merge refused (rc=N)" (N = `MERGE_RC`). Never fall through to M4 as synced.
284
+ - **Re-entry (per distinct base head)**: Phase M3's sync is re-enterable from Phase M6, but only when the base advanced past `BASESYNC_BASE_SHA` and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS`. A re-sync round runs this same block on the new head with the same guards (shared-branch refusal, clean tree, merge-only, no force-push, resolve-or-park, `FORGE:BASESYNC_FAILED`). The block derives `BASESYNC_ROUNDS` and `BASESYNC_BASE_SHA` from git on every entry (pushed sync merges on the branch), never from shell state, and refuses at the cap; an unreadable count is the cap. `BASESYNC_RAN=true` is still set only on a clean or concluded merge.
242
285
  - After a sync, the quality gate below re-runs on the merged tree, and Phase M6's re-review covers the new head (the reviewed-head guard forces a fresh review).
243
286
 
244
287
  **Quality Gate** (same loop as Phase 3G, max 3 iterations):
@@ -284,7 +327,7 @@ ${MERGE_COMMITS}
284
327
  fi
285
328
  ```
286
329
 
287
- Then commit and push. A base-sync merge commit already exists from Phase M3; commit only additional quality-gate or fix edits on top.
330
+ Then commit and push. When a base sync ran, it ran first in Phase M3 on a clean tree and its merge commit already exists; commit only the additional quality-gate or fix edits on top of it.
288
331
 
289
332
  ```bash
290
333
  cd {WORKTREE_PATH}
@@ -345,6 +388,41 @@ Note the marker is `<!-- FORGE:REMEDIATION -->` with **no** `:COMPLETE` suffix y
345
388
 
346
389
  If no dispatch tool resolves, a missing tool is not a human decision, so do NOT invoke `review-pr`, do NOT add `needs-human`, and leave `workflow:in-review` in place. Set `RE_GATE_OUTCOME="REREVIEW-REQUIRED"`, skip Phase M7, go to Phase M8 (which posts `FORGE:REMEDIATION:COMPLETE` with a `REREVIEW-REQUIRED` re-gate line), and return `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`. The caller re-runs `/review-pr {PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE}` from a session that has dispatch. The caller owns the terminal fallback if that re-review cannot run (`commands/work-on.md` Phase 0A.1); remediation itself stays single-attempt and adds no `needs-human` here. If a dispatch tool resolves, continue below.
347
390
 
391
+ **Pre-review freshness pass** (not in dry-run): the quality gate, push and trail took minutes, so re-fetch right before the re-review. The block derives its state from git, as M3 does (a fresh shell has no `BASESYNC_*` vars, forge#3612): it runs only when the pushed branch already carries a sync merge (`BASESYNC_BASE_SHA` non-empty), the base advanced past it, and the round count is below the cap.
392
+
393
+ ```bash
394
+ cd {WORKTREE_PATH}
395
+ git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
396
+ BASESYNC_MAX_ROUNDS=2
397
+ BASESYNC_ROUNDS=$(git rev-list --first-parent --merges --count origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null) || BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS
398
+ _lm=$(git rev-list --first-parent --merges -1 origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null || true); _p2=""
399
+ [ -n "$_lm" ] && _p2=$(git rev-parse -q --verify "$_lm^2" 2>/dev/null) && git merge-base --is-ancestor "$_p2" origin/{PR_BASE} 2>/dev/null || _p2=""
400
+ BASESYNC_BASE_SHA="$_p2"
401
+ NOW_BASE_SHA=$(git rev-parse origin/{PR_BASE})
402
+ FRESH_MERGED=false
403
+ if [ -n "$BASESYNC_BASE_SHA" ] && [ "$NOW_BASE_SHA" != "$BASESYNC_BASE_SHA" ] && [ "$BASESYNC_ROUNDS" -lt "$BASESYNC_MAX_ROUNDS" ] \
404
+ && [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/{HEAD_BRANCH})" ] && git diff --quiet && git diff --cached --quiet; then
405
+ echo "base advanced since the last sync (${BASESYNC_BASE_SHA} -> ${NOW_BASE_SHA}); re-merging once"
406
+ if git merge origin/{PR_BASE} --no-edit; then # allowlist:check-command-side-effects
407
+ FRESH_MERGED=true
408
+ elif git rev-parse -q --verify MERGE_HEAD >/dev/null; then git merge --abort; fi # allowlist:check-command-side-effects
409
+ fi
410
+ echo "FRESH_MERGED=$FRESH_MERGED"
411
+ ```
412
+
413
+ Clean merges only: a conflicted or refused freshness merge is aborted (MERGE_HEAD-guarded) and left to the `base-conflict` handling below. A clean freshness merge (`FRESH_MERGED=true`) is local and unpushed, and it goes through the same gates as any other push before it leaves the worktree:
414
+
415
+ 1. Re-run the Phase M3 quality-gate loop on the merged tree.
416
+ 2. Run the Phase M4 pre-push ancestry guard block verbatim.
417
+ 3. Push with a checked rc:
418
+
419
+ ```bash
420
+ cd {WORKTREE_PATH}
421
+ if git push origin HEAD:{HEAD_BRANCH}; then echo "FRESH_PUSHED=true"; else echo "FRESH_PUSHED=false"; fi # allowlist:check-command-side-effects
422
+ ```
423
+
424
+ If the gate does not pass, the ancestry guard fails, or the push fails, drop the unpushed merge (`git reset --hard origin/{HEAD_BRANCH}`; local only, the pushed head is untouched, never a force-push) and continue to the re-review on the pushed head. The `base-conflict` handling below then decides. The round counts against `BASESYNC_MAX_ROUNDS` only once pushed, because the count is read from `origin/{HEAD_BRANCH}`. An ancestry-guard failure still parks at `needs-human` as M4 specifies.
425
+
348
426
  ```
349
427
  if DRY_RUN=true:
350
428
  record "Would invoke review-pr --auto-merge for PR #{PR_NUMBER}; skipped (dry-run)."
@@ -374,7 +452,24 @@ If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate r
374
452
 
375
453
  If the retained `REVIEW_RESULT` is `BLOCKED` with a blocker mentioning the phase trail (e.g. "phase trail unreadable (rc=N)": the Phase 8 verifier exited ≥2 / 127, forge#3147): the gate could not run, so nothing is missing and nothing is re-run. Keep `needs-human` (review-pr Phase 8 already re-asserted it), do not merge, and exit `REMEDIATE_RESULT: status: BLOCKED` with the same blocker.
376
454
 
377
- If the retained `REVIEW_RESULT` is `BLOCKED` with blocker "base-conflict" (forge#3496): the base moved again, or the sync did not clear the conflict. Treat it as re-escalated: the `BASESYNC_REMEDIATION` bound is already used, so do NOT sync a second time. Add `needs-human`, post a comment naming the PR and the conflicting files (`git diff --name-only --diff-filter=U` against a trial merge, or the GitHub mergeability report), and exit `REMEDIATE_RESULT: status: BLOCKED` with blocker "base conflict persists after base-sync remediation".
455
+ If the retained `REVIEW_RESULT` is `BLOCKED` with blocker "base-conflict" (forge#3496): either the base moved again during the re-review, or the sync did not clear the conflict. Tell the two apart by base head, not by attempt count:
456
+
457
+ ```bash
458
+ cd {WORKTREE_PATH}
459
+ git fetch origin {PR_BASE} {HEAD_BRANCH} # allowlist:check-command-side-effects
460
+ CUR_BASE_SHA=$(git rev-parse origin/{PR_BASE})
461
+ # Recorded head and rounds come from the pushed branch's sync merges (same derivation as M3), never from shell vars or
462
+ # the M8 trail line, which is written after this phase (forge#3517). No recorded head is treated as advanced, but only below the cap.
463
+ BASESYNC_MAX_ROUNDS=2
464
+ BASESYNC_ROUNDS=$(git rev-list --first-parent --merges --count origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null) || BASESYNC_ROUNDS=$BASESYNC_MAX_ROUNDS
465
+ _lm=$(git rev-list --first-parent --merges -1 origin/{PR_BASE}..origin/{HEAD_BRANCH} 2>/dev/null || true); _p2=""
466
+ [ -n "$_lm" ] && _p2=$(git rev-parse -q --verify "$_lm^2" 2>/dev/null) && git merge-base --is-ancestor "$_p2" origin/{PR_BASE} 2>/dev/null || _p2=""
467
+ BASESYNC_BASE_SHA="$_p2"
468
+ echo "recorded=${BASESYNC_BASE_SHA:-none} current=${CUR_BASE_SHA} rounds=${BASESYNC_ROUNDS}/${BASESYNC_MAX_ROUNDS}"
469
+ ```
470
+
471
+ - **Base advanced** (`CUR_BASE_SHA` differs from the recorded `BASESYNC_BASE_SHA`) and `BASESYNC_ROUNDS` is below `BASESYNC_MAX_ROUNDS` (2 pushed sync merges on the branch): this is a new conflict a human did not cause. Re-run the Phase M3 base-sync on the new head (same resolve-or-park rules), then the Phase M4 quality gate, ancestry guard and push, the M5 trail, and this Phase M6 once more. The pushed merge raises the derived count and moves the recorded head, so nothing is carried in shell state. Do not post a second `FORGE:REMEDIATION:COMPLETE`; only M8 does, once.
472
+ - **Base unchanged** (the sync did not clear the conflict) or `BASESYNC_ROUNDS` has reached `BASESYNC_MAX_ROUNDS` (a base that never settles): park. Add `needs-human`, post a comment naming the PR, the base SHA(s) tried (recorded and current) and the conflicting files (`git diff --name-only --diff-filter=U` against a trial merge, or the GitHub mergeability report), and exit `REMEDIATE_RESULT: status: BLOCKED` with blocker "base conflict persists after base-sync remediation".
378
473
 
379
474
  This re-runs the full review (domain agents → verdict → Phase 8 auto-merge gate). The FIXABLE transition above left the issue at the non-terminal `workflow:in-review` state. `review-pr.md` recognizes the in-progress `FORGE:REMEDIATION` marker posted in Phase M5 as evidence of the prior escalation, so one of two things happens inside Phase 8:
380
475
 
@@ -543,7 +638,12 @@ esac
543
638
  REMEDIATED_HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || true)
544
639
 
545
640
  # forge#3496: mark a base-sync round so Phase M0 scopes the single-attempt guard per kind.
546
- BASESYNC_LINE=""; [ "${BASESYNC_RAN:-false}" = "true" ] && BASESYNC_LINE="**Base sync**: ran"
641
+ # Persist the merged base head so it survives across blocks: fall back to the merge's second parent when the shell var is unset.
642
+ BASESYNC_LINE=""
643
+ if [ "${BASESYNC_RAN:-false}" = "true" ]; then
644
+ BASESYNC_BASE_SHA="${BASESYNC_BASE_SHA:-$(git -C {WORKTREE_PATH} rev-parse -q --verify 'HEAD^2' 2>/dev/null || true)}"
645
+ BASESYNC_LINE="**Base sync**: ran (base=${BASESYNC_BASE_SHA:-unknown})"
646
+ fi
547
647
 
548
648
  REMEDIATION_BODY="<!-- FORGE:REMEDIATION -->
549
649
  ## Remediation Complete for PR #{PR_NUMBER}
@@ -568,6 +668,8 @@ Skill("{FORGE_SKILL_PREFIX}work-on:close", args="{ISSUE_NUMBER} --repo {GH_REPO}
568
668
 
569
669
  `work-on:close` handles project board update, final issue body, parent tracker, trajectory log, and worktree cleanup (including the remediation worktree at `{WORKTREE_PATH}`) — do not duplicate any of that here.
570
670
 
671
+ **Under the headless engine** (forge#3624): the engine does not trust that this close ran. After `AUTO-LANDED` it reads the issue's real state; if the issue is not CLOSED with `workflow:merged`, the engine runs `work-on/close` itself. Close is idempotent (it skips work already done), so the self-driven close above stays correct for the Agent-spawn path.
672
+
571
673
  **If the outcome was `REREVIEW-REQUIRED`** (forge#3240): do not invoke close and do not add `needs-human`. Leave the worktree and `workflow:in-review` in place and return `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`; the caller runs the re-review and, on `AUTO-LANDED`, drives close itself; if the re-review cannot run, the caller applies the terminal fallback (`commands/work-on.md` Phase 0A.1).
572
674
 
573
675
  **If the outcome was `HELD-AWAITING-MERGE`, `RE-ESCALATED`, or `UNFIXABLE`**: leave the worktree in place (a human may need it for manual inspection/merge) and return the structured result below without invoking close. Do not close the issue.
@@ -588,4 +690,4 @@ REMEDIATE_RESULT:
588
690
  blocker: {description if status=BLOCKED}
589
691
  ```
590
692
 
591
- **Caller behavior**: this Skill already drives its own close phase when `re_gate_outcome: AUTO-LANDED` (see Phase M8) — the caller does not need to invoke close itself. For `re_gate_outcome: REREVIEW-REQUIRED` (`status: REREVIEW_REQUIRED`, forge#3240) the fix is pushed but no review ran because this session has no sub-agent dispatch tool: the caller MUST run `review-pr {PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE}` from a session that has dispatch (never inline) and then drive close itself on a merge. For every other `re_gate_outcome`, this result is terminal for the current invocation: the issue is left at `needs-human` or `workflow:awaiting-merge`, both already recognized as terminal states in the Universal Phase Dispatcher (see `work-on.md`). Whether invoked standalone (`/work-on <pr> --remediate`) or via the orchestrator's item 6.4 dispatch, no further action is required from the caller.
693
+ **Caller behavior**: this Skill already drives its own close phase when `re_gate_outcome: AUTO-LANDED` (see Phase M8) — the caller does not need to invoke close itself (under the headless engine, the engine verifies the issue is CLOSED + `workflow:merged` and runs close if it is missing). For `re_gate_outcome: REREVIEW-REQUIRED` (`status: REREVIEW_REQUIRED`, forge#3240) the fix is pushed but no review ran because this session has no sub-agent dispatch tool: the caller MUST run `review-pr {PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE}` from a session that has dispatch (never inline) and then drive close itself on a merge. For every other `re_gate_outcome`, this result is terminal for the current invocation: the issue is left at `needs-human` or `workflow:awaiting-merge`, both already recognized as terminal states in the Universal Phase Dispatcher (see `work-on.md`). Whether invoked standalone (`/work-on <pr> --remediate`) or via the orchestrator's item 6.4 dispatch, no further action is required from the caller.
@@ -224,7 +224,7 @@ fi
224
224
  Worktree/branch-already-exists and stale-label conditions are surfaced later (by the build phase's worktree step and the `## Error Handling` section) with their own recovery guidance in `docs/site/troubleshooting.md`.
225
225
 
226
226
  ### 0A: Parse input
227
- Extract project prefix and issue number. If `next`/`pick`: list open issues sorted by priority, skip `needs-human`, `workflow:decomposed`, and `workflow:awaiting-merge`, pick highest priority.
227
+ Extract project prefix and issue number. If `next`/`pick`: list open issues sorted by priority, skip `needs-human`, `workflow:remediating`, `workflow:decomposed`, and `workflow:awaiting-merge`, pick highest priority.
228
228
 
229
229
  **Resolve `UNDER_ORCHESTRATION`**: `true` if the invocation args contain `--under-orchestration`, else `false`. This is a single parse done once, here — every later gated block (heartbeats) just checks this variable, no re-parsing.
230
230
 
@@ -300,9 +300,9 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="${REMEDIATE_PR_NUMBER
300
300
  1. **Labels first**: read the issue's state and labels (`gh issue view ${ISSUE} {GH_FLAG} --json labels,state`). If the issue is closed, already has `needs-human`, or lacks `workflow:in-review`, do NOT dispatch and do NOT apply the fallback (something else already advanced it); STOP.
301
301
  2. **Existing claim**: list the PR's comments and apply the trusted-author, leading-marker predicate. Bash blocks are independent executions and a standalone router session has none of the orchestrator's shell functions, so every claim, read-back, classification and release block MUST paste verbatim, at its top, the **required helper set** from `phase-4-execution.md`: `trail_cache_dir` and `resolve_orch_login` (defined earlier in that file; transitive deps of `rereview_trusted_rows`), then the Step 1 SHARED HELPERS `rereview_trusted_rows`, `REREVIEW_CLAIMS`, `REREVIEW_DONE_FILTER`, `REREVIEW_LEASE_SECS` / `REREVIEW_MAX_SECS`, `REREVIEW_LEASE_FILTER`, `REREVIEW_FIRST_LEASE_FILTER` and `REREVIEW_RELEASED_FILTER`. Never improvise the predicate: too loose lets an untrusted commenter suppress a re-review, too strict drops the router's own bot-authored claim. If a trusted claim (lowest comment id wins) already embeds the PR's current head SHA (`headRefOid`), classify it with the single definition in `phase-4-execution.md` Step 1 (forge#3428, forge#3486: an unexpired or renewed lease, or an expired lease with liveness such as a recent trusted `FORGE:REVIEW*` comment or running checks, means another actor is live (`CLAIMED`); a merged/closed PR or an issue advanced past in-review is done (`NONE`)): for live or done do NOT dispatch; STOP without the fallback. A `FORGE:REREVIEW_RELEASED` marker (`Outcome: SETTLED`) is not a stand-down: step 1 guarantees the issue is still at `workflow:in-review`, so a SETTLED release reads `STALE`, as does an expired lease with no liveness and an open PR (including an unparseable `Lease:`); `STALE` goes to the terminal fallback with reason `stale re-review claim`; a failed liveness lookup fails closed to STOP (never `STALE`). Do not duplicate the predicate here (reference and paste, never re-write).
302
302
  2b. **Unresolved login (fail closed, before the claim)**: with the helpers pasted, run `resolve_orch_login`. If it prints an empty login (and `FORGE_BOT_LOGIN` is unset), the router's own claim could never be verified: do NOT post the claim and do NOT dispatch. Treat it as `UNREADABLE` (writes nothing, no terminal fallback write, never `STALE`), report the unresolved login in the operator-visible report line, and STOP; the orchestrator re-checks next cycle (mirrors `phase-4-execution.md` Step 1 SEC-8).
303
- 3. **Claim**: unless `DRY_RUN=true` (then record "would claim and re-review" and STOP), post the marker with `Actor: router`, the head SHA, a unique `Claim:` id and a UTC `Lease:` timestamp, in the exact format defined in `phase-4-execution.md` Step 1 (`<!-- FORGE:REREVIEW_DISPATCHED -->` as the first line, then `Head: <sha>`, `Actor: router`, `Claim: router-<epoch>-<pid>`, `Lease: <UTC ISO-8601>`), with the `gh pr comment` call checked (a failed post is a failure, never ignored).
304
- 4. **Read back** the PR comments (trusted rows only, projecting the comment `id`, after a short settle delay): the lowest-comment-id trusted claim for this head (`.[0]` of the id-sorted `REREVIEW_CLAIMS`) must carry this router's `Claim:` id; re-read once after verifying and treat a late lower-id claim as `LOST`. A missing row, an API failure, or a different earliest claim (`LOST`) means do NOT dispatch, and none of them stands down blindly: re-run the shared Step 1 classification from `phase-4-execution.md` (forge#3431; the same required helper set from step 2, including `trail_cache_dir` and `resolve_orch_login`, pasted at the top of the block, no router-local lease helper or constant) for this head and map the printed `REREVIEW_STATE`: `STALE` (an expired lease with no liveness and an open PR, a lease past `REREVIEW_MAX_SECS`, or an unparseable `Lease:` on the winning claim; a crashed earlier claimant) goes to the terminal fallback with reason `stale re-review claim`; `CLAIMED` (another actor's live claim) and `NONE` (merged, closed, or advanced past in-review) stand down without the fallback; `UNREADABLE` writes nothing (fail closed, never `STALE`); `PENDING` goes to the fallback only for the missing-row / API-failure case. For the missing-row / API-failure case only, the printed `FIRST_CLAIM_ID` (the winning `.[0]` claim) equal to this router's own `Claim:` id goes to the fallback (our own unverified claim must not stand the router down on itself), while another actor's id is classified like `LOST`. On `LOST` the winner is by definition another actor's claim, so a `STALE` winner is never excused as "owned by another actor".
305
- Only after the read-back verifies does the router invoke `review-pr`. After `review-pr` returns (any result) and the outcome is settled (a labelled terminal state, or the terminal fallback's label write verified; never before, forge#3486), the router posts a checked, DRY_RUN-guarded `<!-- FORGE:REREVIEW_RELEASED -->` marker (`Head: <sha>`, `Actor: router`, `Claim: <its Claim id>`, `Outcome: SETTLED`) in the format defined in `phase-4-execution.md` Step 2 (Renew and release; the release block also pastes the same required helper set). A release found while the issue is still at `workflow:in-review` is not terminal and reads `STALE`, so the fallback re-applies; the router may also renew the lease between phases by re-posting the claim with the same `Claim:` id and a fresh `Lease:`.
303
+ 3. **Claim**: unless `DRY_RUN=true` (then record "would claim and re-review" and STOP), post the marker with `Actor: router`, the head SHA, a unique `Claim:` id and a UTC `Lease:` timestamp, in the exact format defined in `phase-4-execution.md` Step 1 (`<!-- FORGE:REREVIEW_DISPATCHED -->` as the first line, then `Head: <sha>`, `Actor: router`, `Claim: router-<epoch>-<pid>`, `Lease: <UTC ISO-8601>`), with the `gh pr comment` call checked (a failed post is a failure, never ignored). The claim block MUST declare `DRY_RUN` explicitly (a fresh shell has none) and MUST print its claim id as a labelled final line, `CLAIM_ID=router-<epoch>-<pid>` (forge#3472), because step 4 and the release run in later independent blocks that can only use that printed value.
304
+ 4. **Read back** the PR comments (trusted rows only, projecting the comment `id`, after a short settle delay): the lowest-comment-id trusted claim for this head (`.[0]` of the id-sorted `REREVIEW_CLAIMS`) must carry this router's `Claim:` id; re-read once after verifying and treat a late lower-id claim as `LOST`. A missing row, an API failure, or a different earliest claim (`LOST`) means do NOT dispatch, and none of them stands down blindly: re-run the shared Step 1 classification from `phase-4-execution.md` (forge#3431; the same required helper set from step 2, including `trail_cache_dir` and `resolve_orch_login`, pasted at the top of the block, no router-local lease helper or constant) for this head and map the printed `REREVIEW_STATE`: `STALE` (an expired lease with no liveness and an open PR, a lease past `REREVIEW_MAX_SECS`, or an unparseable `Lease:` on the winning claim; a crashed earlier claimant) goes to the terminal fallback with reason `stale re-review claim`; `CLAIMED` (another actor's live claim) and `NONE` (merged, closed, or advanced past in-review) stand down without the fallback; `UNREADABLE` writes nothing (fail closed, never `STALE`); `PENDING` goes to the fallback only for the missing-row / API-failure case. For the missing-row / API-failure case only, the printed `FIRST_CLAIM_ID` (the winning `.[0]` claim) equal to this router's own `CLAIM_ID` printed by step 3 goes to the fallback (our own unverified claim must not stand the router down on itself), while another actor's id is classified like `LOST`. On `LOST` the winner is by definition another actor's claim, so a `STALE` winner is never excused as "owned by another actor".
305
+ Only after the read-back verifies does the router invoke `review-pr`. After `review-pr` returns (any result) and the outcome is settled (a labelled terminal state, or the terminal fallback's label write verified; never before, forge#3486), the router posts a checked, DRY_RUN-guarded `<!-- FORGE:REREVIEW_RELEASED -->` marker (`Head: <sha>`, `Actor: router`, `Claim: <the CLAIM_ID printed by step 3>`, `Outcome: SETTLED`) in the format defined in `phase-4-execution.md` Step 2 (Renew and release; the release block also pastes the same required helper set). A release found while the issue is still at `workflow:in-review` is not terminal and reads `STALE`, so the fallback re-applies; the router may also renew the lease between phases by re-posting the claim with the same `Claim:` id and a fresh `Lease:`.
306
306
 
307
307
  **Terminal fallback** (canonical definition; Phase 4R and `commands/orchestrate/phase-4-execution.md` item 6.4 reference it, so the review and remediate paths share one): a re-review that cannot run must never leave the issue at `workflow:in-review` with nothing to advance it. First re-read the issue's labels: if `needs-human` is already set and `workflow:in-review` is absent, the fallback has already been applied (or something else escalated it), so skip the edit and report that state (idempotent; forge#3413). Otherwise post a comment naming the PR and the reason (e.g. `review-pr could not be dispatched: <reason>`), then in ONE `gh issue edit` add `needs-human` and remove `workflow:in-review` (never one without the other), and re-read the labels to verify the write. If the label write failed, report that failure as the outcome instead of treating the fallback as done. Before commenting, re-check the PR state and liveness (forge#3428): a merged or closed PR, or another actor's claimant with recent activity per `phase-4-execution.md` Step 1 (this actor's own `Claim:` id never counts as that claimant, or an own-claim fallback would cancel itself), means skip the escalation and report that state. Never review inline, and never re-nest remediation to retry.
308
308