forgedock 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -681,14 +681,32 @@ gh label create "needs-validation" --color "FBCA04" --description "Review findin
681
681
  gh label create "staging-review" --color "1D76DB" --description "Finding from a staging branch review before deploy to main. Managed by ForgeDock." --force -R {GH_REPO} 2>/dev/null
682
682
  ```
683
683
 
684
- ### 7D: Milestone Detection
685
- Only assign milestone if reviewing a milestone/* branch. Plain staging reviews get no milestone.
684
+ ### 7D: Milestone & Code Branch Detection
685
+
686
+ Derive both `CODE_BRANCH` and `MILESTONE_FLAG` from the reviewed PR before creating any findings. Unlike `commands/review-pr.md` (feature-lane PRs with short-lived HEAD branches that get deleted post-merge — see the rationale comment on that spec's finding template), `review-pr-staging.md` reviews long-lived branches (`staging`, `milestone/*`) that persist after merge, so `CODE_BRANCH` uses `headRefName` (the PR's HEAD — the branch actually being reviewed), consistent with forge#1391's original fix. This replaces the previous hardcoded `staging` literal, which was silently wrong whenever this spec reviewed a `milestone/X → staging` PR instead of a `staging → main` PR — `CODE_BRANCH` used to always say `staging` even when the reviewed branch was `milestone/X`.
687
+
688
+ ```bash
689
+ CODE_BRANCH=$(gh pr view ${PR_NUMBER} --json headRefName --jq '.headRefName')
690
+ MILESTONE_FLAG=""
691
+
692
+ # scripts/derive-finding-milestone.sh is the single source of truth for
693
+ # milestone derivation — commands/review-pr.md and commands/review-pr-staging.md
694
+ # both call it identically so the two specs cannot independently drift.
695
+ # <!-- forge#2443 -->
696
+ MILESTONE_TITLE=$(bash scripts/derive-finding-milestone.sh "${PR_NUMBER}" -R {GH_REPO})
697
+ # Quoted so multi-word milestone titles survive as a single value wherever
698
+ # MILESTONE_FLAG is later interpolated — matches the quoting convention already
699
+ # used for --title/--body-file/--label below.
700
+ [ -n "$MILESTONE_TITLE" ] && MILESTONE_FLAG="--milestone \"$MILESTONE_TITLE\""
701
+ ```
702
+
703
+ Plain staging→main reviews resolve `CODE_BRANCH` to `staging` dynamically (same value as before, now derived rather than hardcoded) and typically resolve no milestone. A milestone→staging review resolves `CODE_BRANCH` to `milestone/X` and (via the shared script) the matching milestone.
686
704
 
687
705
  ### 7E: Deduplicate Against Existing Issues
688
706
  Check for open review-finding issues at same file:line → skip. Closed issues at same location → potential regression (elevate priority).
689
707
 
690
708
  ### 7F: Create Issues
691
- Sequential creation. Title: `Staging Review: {summary} (staging → main)`. Labels: review-finding, needs-validation, staging-review, priority:P1/priority:P2/priority:P3. Body includes: source branch context (`staging`), code context, evidence, validation checklist.
709
+ Sequential creation. Title: `Staging Review: {summary} (staging → main)`. Labels: review-finding, needs-validation, staging-review, priority:P0-P3 (derived from Severity — see below). Body includes: source branch context (`${CODE_BRANCH}`, derived in Phase 7D — not a hardcoded `staging` literal), code context, evidence, validation checklist.
692
710
 
693
711
  **For each finding** (that passes dedup), create issue through the `/issue` create-hook's programmatic invocation contract (see `commands/issue.md` § "Programmatic Invocation Contract") instead of calling the raw issue-creation command directly:
694
712
  ```bash
@@ -716,10 +734,10 @@ Files that need changes:
716
734
 
717
735
  ## Source Branch Context
718
736
 
719
- **Code branch**: `staging`
720
- **Worktree base**: `origin/staging`
737
+ **Code branch**: `[CODE_BRANCH]`
738
+ **Worktree base**: `origin/[CODE_BRANCH]`
721
739
 
722
- > When fixing: `git worktree add ../fix-{slug} -b fix/{slug} origin/staging`
740
+ > When fixing: `git worktree add ../fix-{slug} -b fix/{slug} origin/[CODE_BRANCH]`
723
741
 
724
742
  ## Code Context
725
743
  [10 lines around finding]
@@ -733,8 +751,32 @@ Files that need changes:
733
751
  - [ ] If VALIDATED: fix implemented and tested on correct branch
734
752
  ISSUE_EOF
735
753
 
754
+ # STAGING_FINDING_SEVERITY is extracted from the finding's own **Severity**
755
+ # body field (set above in the heredoc) — example assignment shown here for
756
+ # clarity, same convention as STAGING_FINDING_TITLE etc.
757
+ STAGING_FINDING_SEVERITY="LOW"
758
+
759
+ # priority:* label is a deterministic function of the finding's **Severity**
760
+ # (CRITICAL/HIGH/MEDIUM/LOW) — NEVER of its Confidence
761
+ # (CONFIRMED/LIKELY/POSSIBLE). scripts/severity-to-priority.sh is the single
762
+ # source of truth for this mapping; commands/review-pr.md calls the identical
763
+ # script so the two specs cannot independently drift. <!-- forge#2447 -->
764
+ STAGING_FINDING_PRIORITY=$(bash scripts/severity-to-priority.sh "$STAGING_FINDING_SEVERITY")
765
+ STAGING_FINDING_PRIORITY_EXIT=$?
766
+
767
+ # Exit code MUST be checked before use — severity-to-priority.sh exits 1 (empty stdout)
768
+ # on a missing/unrecognized severity. Proceeding with an empty $STAGING_FINDING_PRIORITY
769
+ # would call Skill(issue, --label "") instead of aborting issue creation for this finding.
770
+ # Mirrors the identical check in commands/review-pr.md. <!-- forge#2479 -->
771
+ if [ "$STAGING_FINDING_PRIORITY_EXIT" -ne 0 ]; then
772
+ echo "PRIORITY: severity-to-priority.sh failed (exit $STAGING_FINDING_PRIORITY_EXIT) for severity '$STAGING_FINDING_SEVERITY' — skipping finding issue creation"
773
+ # Skip this finding — do NOT fall through to issue creation with an empty label
774
+ else
775
+
736
776
  # --label is repeatable (not comma-joined) per the /issue programmatic contract.
737
- Skill(skill="issue", args="--title \"$STAGING_FINDING_TITLE\" --body-file \"$STAGING_FINDING_BODY_FILE\" --label review-finding --label needs-validation --label staging-review --label \"{priority}\"")
777
+ # ${MILESTONE_FLAG} carries the Phase 7D derivation through — empty string is
778
+ # a no-op arg when the reviewed branch has no milestone (plain staging→main).
779
+ Skill(skill="issue", args="--title \"$STAGING_FINDING_TITLE\" --body-file \"$STAGING_FINDING_BODY_FILE\" --label review-finding --label needs-validation --label staging-review --label \"$STAGING_FINDING_PRIORITY\" ${MILESTONE_FLAG}")
738
780
  rm -f "$STAGING_FINDING_BODY_FILE"
739
781
 
740
782
  # /issue has no machine-readable return contract — resolve the created issue's number by
@@ -746,9 +788,10 @@ for _resolve_attempt in 1 2 3; do
746
788
  [ -n "$ISSUE_NUM" ] && break
747
789
  sleep 2
748
790
  done
791
+ fi
749
792
  ```
750
793
 
751
- Labels: `review-finding` + `needs-validation` + `staging-review` + priority (`priority:P1` CONFIRMED, `priority:P2` LIKELY, `priority:P3` POSSIBLE).
794
+ Labels: `review-finding` + `needs-validation` + `staging-review` + priority. `priority:*` is derived from the finding's `**Severity**` field via `scripts/severity-to-priority.sh` (identical script used by `commands/review-pr.md` — single documented mapping, see that script's header comment): `CRITICAL` → `priority:P0`, `HIGH` → `priority:P1`, `MEDIUM` → `priority:P2`, `LOW` → `priority:P3`. **Never derive `priority:*` from Confidence** (CONFIRMED/LIKELY/POSSIBLE) — conflating the two axes previously mislabeled LOW-severity CONFIRMED findings as `priority:P1`, defeating orchestrate's P3 batching rule. <!-- forge#2447 -->
752
795
 
753
796
  **No pre-filtering**: Every finding becomes an issue. Validation agents sort out false positives downstream.
754
797
 
@@ -1626,26 +1626,28 @@ gh label create "false-positive" --color "CCCCCC" --description "Review finding
1626
1626
 
1627
1627
  **Milestone detection:**
1628
1628
  ```bash
1629
- # Check BOTH head and base branches — feature PRs targeting milestone/* branches
1630
- # should inherit the milestone for their review findings
1629
+ # BASE_BRANCH is what the finding template stamps as **Code branch** below
1630
+ # (see the rationale comment at that template block) — HEAD_BRANCH is no
1631
+ # longer needed here; derive-finding-milestone.sh resolves its own
1632
+ # base/head branch lookup internally for its Tier 3 slug match.
1631
1633
  BASE_BRANCH=$(gh pr view ${PR_NUMBER} --json baseRefName --jq '.baseRefName')
1632
- HEAD_BRANCH=$(gh pr view ${PR_NUMBER} --json headRefName --jq '.headRefName')
1633
1634
  MILESTONE_FLAG=""
1634
1635
 
1635
- # First check: PR's base branch is a milestone branch (most common for feature-lane PRs)
1636
- MILESTONE_BRANCH=""
1637
- if echo "$BASE_BRANCH" | grep -qE "^milestone/"; then
1638
- MILESTONE_BRANCH="$BASE_BRANCH"
1639
- elif echo "$HEAD_BRANCH" | grep -qE "^milestone/"; then
1640
- MILESTONE_BRANCH="$HEAD_BRANCH"
1641
- fi
1642
-
1643
- if [ -n "$MILESTONE_BRANCH" ]; then
1644
- BRANCH_SLUG=$(echo "$MILESTONE_BRANCH" | sed 's|^milestone/||')
1645
- MILESTONE_NUMBER=$(gh api repos/${REPO}/milestones 2>/dev/null | jq --arg slug "$BRANCH_SLUG" '.[] | select((.title | ascii_downcase | gsub("[^a-z0-9]+"; "-")) == $slug) | .number' 2>/dev/null | head -1)
1646
- [ -z "$MILESTONE_NUMBER" ] && MILESTONE_NUMBER=$(gh api repos/${REPO}/milestones 2>/dev/null | jq --arg slug "$BRANCH_SLUG" '.[] | select((.title | ascii_downcase | gsub("[^a-z0-9]+"; "-")) | test($slug)) | .number' 2>/dev/null | head -1)
1647
- [ -n "$MILESTONE_NUMBER" ] && MILESTONE_FLAG="--milestone $(gh api repos/${REPO}/milestones/${MILESTONE_NUMBER} --jq '.title')"
1648
- fi
1636
+ # scripts/derive-finding-milestone.sh is the single source of truth for
1637
+ # milestone derivation — commands/review-pr.md and commands/review-pr-staging.md
1638
+ # both call it identically so the two specs cannot independently drift the
1639
+ # way they did before this fix (forge#2443 Instance B/C: findings from a
1640
+ # milestone-lane PR sometimes inherited the milestone and sometimes didn't,
1641
+ # depending on incidental inheritance rather than an explicit step). The
1642
+ # script's 3-tier resolution (PR's own milestone -> originating issue's
1643
+ # milestone via Closes/Fixes/Resolves #N -> branch-slug match against
1644
+ # milestone/* branches) supersedes the old base/head-branch-only slug match
1645
+ # that used to be hand-rolled here.
1646
+ MILESTONE_TITLE=$(bash scripts/derive-finding-milestone.sh "${PR_NUMBER}" -R "${REPO}")
1647
+ # Quoted so multi-word milestone titles (e.g. "Watch & Fleet Observability") survive
1648
+ # as a single value wherever MILESTONE_FLAG is later interpolated — matches the
1649
+ # quoting convention already used for --title/--body-file/--label below.
1650
+ [ -n "$MILESTONE_TITLE" ] && MILESTONE_FLAG="--milestone \"$MILESTONE_TITLE\""
1649
1651
  ```
1650
1652
 
1651
1653
  **Dedup against existing issues (MANDATORY before creating):**
@@ -1763,10 +1765,26 @@ Files that need changes:
1763
1765
 
1764
1766
  ## Source Branch Context
1765
1767
 
1766
- **Code branch**: `[HEAD_BRANCH]`
1767
- **Worktree base**: `origin/[HEAD_BRANCH]`
1768
-
1769
- > When fixing: `git worktree add ../fix-{slug} -b fix/{slug} origin/[HEAD_BRANCH]`
1768
+ <!--
1769
+ BASE_BRANCH, not HEAD_BRANCH, is correct here (forge#2443). This looks like
1770
+ a revert of forge#1391 ("review-finding template stamps BASE_BRANCH instead
1771
+ of HEAD_BRANCH — fixes target wrong code state") but is NOT: forge#1391's
1772
+ scenario was a staging->main review where HEAD (staging) is a long-lived
1773
+ branch that already had the bug's code, while BASE (main) had not yet
1774
+ received it. review-pr.md's scenario is different — HEAD here is a
1775
+ short-lived feat/*|fix/* branch that Phase 6E deletes once the auto-merge
1776
+ step completes, which happens SYNCHRONOUSLY within this same /review-pr run,
1777
+ before any downstream consumer (a human, or /work-on) ever reads this
1778
+ finding. By the time the finding is read, HEAD no longer exists but BASE
1779
+ does, and BASE now contains the merged code the finding is about.
1780
+ commands/review-pr-staging.md keeps HEAD_BRANCH semantics (as a dynamic
1781
+ ${CODE_BRANCH}, not a hardcoded literal) because ITS reviewed branches
1782
+ (staging, milestone/*) are long-lived — consistent with forge#1391.
1783
+ -->
1784
+ **Code branch**: `[BASE_BRANCH]`
1785
+ **Worktree base**: `origin/[BASE_BRANCH]`
1786
+
1787
+ > When fixing: `git worktree add ../fix-{slug} -b fix/{slug} origin/[BASE_BRANCH]`
1770
1788
 
1771
1789
  ## Code Context
1772
1790
  [10 lines around finding]
@@ -1785,8 +1803,30 @@ Files that need changes:
1785
1803
  [BATCHABLE_ANNOTATION]
1786
1804
  ISSUE_EOF
1787
1805
 
1806
+ # FINDING_SEVERITY is extracted from the finding's own **Severity** body field
1807
+ # (set above in the heredoc) — example assignment shown here for clarity, same
1808
+ # convention as FINDING_ISSUE_TITLE etc.
1809
+ FINDING_SEVERITY="LOW"
1810
+
1811
+ # priority:* label is a deterministic function of the finding's **Severity**
1812
+ # (CRITICAL/HIGH/MEDIUM/LOW) — NEVER of its Confidence
1813
+ # (CONFIRMED/LIKELY/POSSIBLE). scripts/severity-to-priority.sh is the single
1814
+ # source of truth for this mapping; commands/review-pr-staging.md calls the
1815
+ # identical script so the two specs cannot independently drift. <!-- forge#2447 -->
1816
+ FINDING_PRIORITY=$(bash scripts/severity-to-priority.sh "$FINDING_SEVERITY")
1817
+ FINDING_PRIORITY_EXIT=$?
1818
+
1819
+ # Exit code MUST be checked before use — severity-to-priority.sh exits 1 (empty stdout)
1820
+ # on a missing/unrecognized severity, mirroring the DEDUP_EXIT idiom above. Proceeding
1821
+ # with an empty $FINDING_PRIORITY would call Skill(issue, --label "") instead of aborting
1822
+ # issue creation for this finding. <!-- forge#2479 -->
1823
+ if [ "$FINDING_PRIORITY_EXIT" -ne 0 ]; then
1824
+ echo "PRIORITY: severity-to-priority.sh failed (exit $FINDING_PRIORITY_EXIT) for severity '$FINDING_SEVERITY' — skipping finding issue creation"
1825
+ # Skip this finding — do NOT fall through to issue creation with an empty label
1826
+ else
1827
+
1788
1828
  # --label is repeatable (not comma-joined) per the /issue programmatic contract.
1789
- Skill(skill="issue", args="--title \"$FINDING_ISSUE_TITLE\" --body-file \"$FINDING_ISSUE_BODY_FILE\" --label review-finding --label needs-validation --label \"{priority}\" ${MILESTONE_FLAG}")
1829
+ Skill(skill="issue", args="--title \"$FINDING_ISSUE_TITLE\" --body-file \"$FINDING_ISSUE_BODY_FILE\" --label review-finding --label needs-validation --label \"$FINDING_PRIORITY\" ${MILESTONE_FLAG}")
1790
1830
  rm -f "$FINDING_ISSUE_BODY_FILE"
1791
1831
 
1792
1832
  # /issue has no machine-readable return contract (it's a user-facing command, not a work-on
@@ -1799,15 +1839,16 @@ for _resolve_attempt in 1 2 3; do
1799
1839
  [ -n "$ISSUE_NUM" ] && break
1800
1840
  sleep 2
1801
1841
  done
1842
+ fi
1802
1843
  ```
1803
1844
 
1804
- Labels: `review-finding` + `needs-validation` + priority (`priority:P1` CONFIRMED, `priority:P2` LIKELY, `priority:P3` POSSIBLE).
1845
+ Labels: `review-finding` + `needs-validation` + priority. `priority:*` is derived from the finding's `**Severity**` field via `scripts/severity-to-priority.sh` (single documented mapping — see that script's header comment): `CRITICAL` → `priority:P0`, `HIGH` → `priority:P1`, `MEDIUM` → `priority:P2`, `LOW` → `priority:P3`. **Never derive `priority:*` from Confidence** (CONFIRMED/LIKELY/POSSIBLE) — Confidence and Severity are independent axes; conflating them previously mislabeled LOW-severity CONFIRMED findings as `priority:P1`, defeating the P3 batching rule below. <!-- forge#2447 -->
1805
1846
 
1806
- **P3 batchable annotation** — <!-- Added: forge#1333 --> When priority resolves to `priority:P3` (POSSIBLE confidence), check whether the finding qualifies for batching. If the affected file does NOT touch a security or billing path, substitute `[BATCHABLE_ANNOTATION]` with the `<!-- FORGE:BATCHABLE -->` marker. This signals the orchestrator's Phase 1 batching rule that this finding can be grouped with other P3s in the same domain:
1847
+ **P3 batchable annotation** — <!-- Added: forge#1333 --> When priority resolves to `priority:P3` (i.e. `**Severity**: LOW`), check whether the finding qualifies for batching. If the affected file does NOT touch a security or billing path, substitute `[BATCHABLE_ANNOTATION]` with the `<!-- FORGE:BATCHABLE -->` marker. This signals the orchestrator's Phase 1 batching rule that this finding can be grouped with other P3s in the same domain:
1807
1848
 
1808
1849
  ```bash
1809
1850
  # Determine batchable eligibility for this finding
1810
- FINDING_PRIORITY="{priority}" # priority:P1 / priority:P2 / priority:P3
1851
+ # $FINDING_PRIORITY was already computed above (severity-to-priority.sh) — reuse it, do not re-derive.
1811
1852
  BATCHABLE_ANNOTATION=""
1812
1853
 
1813
1854
  if [ "$FINDING_PRIORITY" = "priority:P3" ]; then
@@ -2089,19 +2130,41 @@ if [ "$PRE_MERGE_HEALTH" = "CONFLICTING" ] || [ "$PRE_MERGE_HEALTH_STATE" = "DIR
2089
2130
  # STOP — do not attempt gh pr merge on a CONFLICTING/DIRTY PR
2090
2131
  else
2091
2132
 
2092
- # Previously-escalated re-review guard <!-- Added: forge#1810 -->
2133
+ # Previously-escalated re-review guard <!-- Added: forge#1810; base-scoped: forge#2570 -->
2093
2134
  # If the linked issue currently carries needs-human, this PR was escalated at some
2094
2135
  # earlier point (VERDICT/purpose-regression/calibration/trust/mergeability) and has
2095
2136
  # now been remediated + re-reviewed back to a clean, mergeable APPROVED state above.
2096
- # The "auto-land bar" (≥2 independent adversarial approvals + domain gates — see
2097
- # #1809 Q1) is not yet implemented (future #1809 sub-issues B/C/D), so the safe
2098
- # default here is to land on workflow:awaiting-merge (clearing needs-human) rather
2099
- # than silently auto-merging a PR that was once flagged human-risk.
2137
+ #
2138
+ # forge#2570: the hold is now scoped by the PR's target branch. This PR still transitions
2139
+ # to workflow:awaiting-merge (clearing needs-human) in BOTH cases below — the transition is
2140
+ # unchanged; only who acts next differs:
2141
+ # - `main` / deploy gate (or an unresolved base — fail closed): held for a HUMAN merge
2142
+ # decision, because `staging → main` is the genuine human gate no agent performs.
2143
+ # - any other base (staging, milestone/* — reversible integration branches): NOT held for
2144
+ # a human. It is parked for remediate.md Phase M7's base-scoped auto-land bar (bot APPROVED
2145
+ # + quality-gate pass), which merges it on the SAME condition the normal /work-on fast lane
2146
+ # uses for the same target branch — no manual click. This reconciles the asymmetry where a
2147
+ # bot-only re-review (authorAssociation=NONE) could never satisfy the strict ≥2 verified-
2148
+ # human bar and so stranded staging PRs the fast lane would auto-merge.
2100
2149
  PREVIOUSLY_ESCALATED=$(gh issue view {MERGE_ISSUE} {MERGE_GH_FLAG} --json labels \
2101
2150
  --jq '[.labels[].name] | any(. == "needs-human")' 2>/dev/null || echo "false")
2151
+ GUARD_BASE=$(gh pr view {PR_NUMBER} {MERGE_GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
2102
2152
 
2103
2153
  if [ "$PREVIOUSLY_ESCALATED" = "true" ]; then
2104
- gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "🟠 PR #{PR_NUMBER} was previously escalated (\`needs-human\`) and has now been re-reviewed to \`${VERDICT:-APPROVED}\` with a clean mergeability check. The automated auto-land bar for previously-escalated PRs is not yet implemented, so this PR is held at \`workflow:awaiting-merge\` for a human merge decision instead of auto-merging. Merge manually once reviewed: \`gh pr merge {PR_NUMBER} {MERGE_GH_FLAG} --merge\`."
2154
+ # forge#2625: `jq -r '.baseRefName'` stringifies a JSON null to the literal text "null" (not
2155
+ # an empty string), so the `-n` check alone does not catch it — add an explicit `!= "null"`
2156
+ # check so a literal-string "null" is treated the same as an empty/unresolved base (strict).
2157
+ if [ -n "$GUARD_BASE" ] && [ "$GUARD_BASE" != "null" ] && [ "$GUARD_BASE" != "main" ]; then
2158
+ # forge#2570: non-`main` base — defer to remediate.md M7's auto-land, not a human.
2159
+ # forge#2626: standalone `/review-pr` (no remediate.md M6→M7 chain) would otherwise
2160
+ # strand this PR at `workflow:awaiting-merge` with no actionable guidance — add a
2161
+ # manual-merge fallback so the comment is correct in both the chained and standalone cases.
2162
+ # GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
2163
+ gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "🟠 PR #{PR_NUMBER} was previously escalated (\`needs-human\`) and has now been re-reviewed to \`${VERDICT:-APPROVED}\` with a clean mergeability check on non-\`main\` base \`${GUARD_BASE}\`. It is parked at \`workflow:awaiting-merge\` for the remediation auto-land bar (\`remediate.md\` Phase M7, forge#2570) — which auto-merges it on the same fast-lane condition the normal \`/work-on\` path uses for this target branch — NOT for a human merge decision. If no remediation \`M7\` follows (e.g. this review was invoked standalone), merge manually once reviewed: \`gh pr merge {PR_NUMBER} {MERGE_GH_FLAG} --merge\`."
2164
+ else
2165
+ # forge#2570: `main` / deploy gate (or unresolved base — fail closed to the human gate).
2166
+ gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "🟠 PR #{PR_NUMBER} was previously escalated (\`needs-human\`) and has now been re-reviewed to \`${VERDICT:-APPROVED}\` with a clean mergeability check. This PR targets the deploy gate (\`${GUARD_BASE:-unresolved base}\`), so it is held at \`workflow:awaiting-merge\` for a human merge decision — \`staging → main\` is the genuine human gate. Merge manually once reviewed: \`gh pr merge {PR_NUMBER} {MERGE_GH_FLAG} --merge\`."
2167
+ fi
2105
2168
  RESOLUTION=$(resolve_script 'transition-label')
2106
2169
  TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
2107
2170
  case "$TIER" in
@@ -2111,7 +2174,9 @@ if [ "$PREVIOUSLY_ESCALATED" = "true" ]; then
2111
2174
  --remove-label "needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
2112
2175
  ;;
2113
2176
  esac
2114
- # STOP — do not attempt gh pr merge; a human decides the merge from here
2177
+ # STOP — do not attempt gh pr merge here. The merge decision belongs to a human for a
2178
+ # `main`/deploy-gate base, or to remediate.md Phase M7's base-scoped auto-land bar for a
2179
+ # non-`main` base (forge#2570). Either way this guard only parks at workflow:awaiting-merge.
2115
2180
  else
2116
2181
  # Checkpoint comment on issue
2117
2182
  gh issue comment {MERGE_ISSUE} {MERGE_GH_FLAG} --body "Review complete for PR #{PR_NUMBER}. Verdict: ${VERDICT:-APPROVED}. Proceeding to merge."
@@ -255,11 +255,51 @@ REVIEW_BODIES=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
255
255
  APPROVED_COUNT=$(echo "$REVIEW_BODIES" | grep -cE 'APPROVED:' 2>/dev/null || true); APPROVED_COUNT=${APPROVED_COUNT:-0}
256
256
  ```
257
257
 
258
- **Auto-land bar** (per #1809 Q1) — BOTH conditions required:
258
+ **Auto-land bar — base-branch scoped** (forge#2570): the condition the PR must clear to auto-land depends on its target branch. This reconciles the remediation bar with the normal `/work-on` fast-lane merge bar for the *same target branch*, while keeping the strict human-verified bar only where a human is genuinely in the loop (the `staging → main` deploy gate).
259
+
260
+ **Re-derive the base fresh — do NOT reuse `$PR_BASE` for this decision** (forge#2624): `$PR_BASE` was resolved in Phase M0 as `PR_BASE="${PR_BASE:-$(... .baseRefName)}"` — a caller-supplied `--base` wins over the PR's actual live base whenever non-empty. That is fine for M0's own purposes (worktree base, display text), but this is a security-relevant decision point: a wrong/stale `--base staging` on a PR that actually targets `main` must never be allowed to relax the strict deploy-gate bar. Mirror `review-pr.md`'s sibling guard (`GUARD_BASE`, which always re-fetches `baseRefName` fresh at its own decision point) by re-querying the PR's live base here, independent of whatever `$PR_BASE` currently holds:
261
+
262
+ ```bash
263
+ # forge#2624: re-fetch baseRefName fresh from the PR itself — never trust the
264
+ # M0-resolved $PR_BASE for this decision, since M0 prefers a caller-supplied
265
+ # --base over the live value. This is the one line in this phase that makes
266
+ # a trust/security decision, so it must be immune to a caller-overridable input.
267
+ LIVE_BASE_REF=$(gh pr view {PR_NUMBER} {GH_FLAG} --json baseRefName --jq '.baseRefName' 2>/dev/null || echo "")
268
+
269
+ # forge#2570: `main` (and any deploy-gate base) keeps the strict #1809 Q1 verified-human bar;
270
+ # every other base (staging, milestone/* — the reversible integration branches) reconciles to
271
+ # the fast-lane bar. Key on "is the deploy gate", NOT the literal string "staging", so milestone
272
+ # branches reconcile too. Fail closed: only a KNOWN, non-empty, non-`main` LIVE base reconciles
273
+ # to the fast lane; an empty/unresolved fetch is treated as the deploy gate (strict) so a base-
274
+ # resolution failure (or a caller passing a stale/incorrect --base) can never accidentally
275
+ # relax the bar.
276
+ # forge#2625: `jq -r '.baseRefName'` stringifies a JSON null to the literal text "null" (not an
277
+ # empty string), so the `-n` check alone does not catch it — add an explicit `!= "null"` check
278
+ # so a literal-string "null" is treated the same as an empty/unresolved base (strict).
279
+ if [ -n "$LIVE_BASE_REF" ] && [ "$LIVE_BASE_REF" != "null" ] && [ "$LIVE_BASE_REF" != "main" ]; then IS_DEPLOY_GATE=false; else IS_DEPLOY_GATE=true; fi
280
+ ```
281
+
282
+ **Non-`main` base (`IS_DEPLOY_GATE=false` — staging / milestone)** — reconcile to the fast-lane bar. `review-pr.md`'s Phase 8 guard only parks a PR at `workflow:awaiting-merge` after a clean, mergeable `APPROVED` re-review (the same bot-`APPROVED` signal the normal fast lane auto-merges on), so the only additional condition is this remediation's own quality gate:
283
+ 1. `GATE_PASSED = true` from this remediation's Phase M3 quality-gate loop.
284
+
285
+ The strict `APPROVED_COUNT >= 2` verified-human requirement does NOT apply here: it is structurally unsatisfiable for bot-only pipeline review (bot reviews are `authorAssociation=NONE`), and `staging` is reversible — the real human gate is `staging → main`, which no agent performs. This makes the remediation bar identical to the normal fast-lane bar for the same target branch (the issue's core ask). The `authorAssociation` trust filter above is **unchanged** — the relaxation is scoped by *target branch only*, never by *who* may approve (forge#1976/#2519).
286
+
287
+ **`main` base (`IS_DEPLOY_GATE=true` — deploy gate)** — keep the strict #1809 Q1 bar, BOTH conditions required:
259
288
  1. `APPROVED_COUNT >= 2` — at least two distinct adversarial `APPROVED:` review comments from repo collaborators (`OWNER`/`MEMBER`/`COLLABORATOR` authorAssociation only — see trust filter above; same counting convention as `work-on.md` Phase 7A).
260
289
  2. `GATE_PASSED = true` from this remediation's own Phase M3 quality-gate loop.
261
290
 
262
- **If the bar is met**:
291
+ **Evaluate the base-scoped bar**:
292
+ ```bash
293
+ if [ "$IS_DEPLOY_GATE" = "true" ]; then
294
+ # main / deploy gate — strict verified-human bar
295
+ { [ "${APPROVED_COUNT:-0}" -ge 2 ] && [ "${GATE_PASSED:-false}" = "true" ]; } && BAR_MET=true || BAR_MET=false
296
+ else
297
+ # staging / milestone — fast-lane bar (bot APPROVED already implied by workflow:awaiting-merge)
298
+ [ "${GATE_PASSED:-false}" = "true" ] && BAR_MET=true || BAR_MET=false
299
+ fi
300
+ ```
301
+
302
+ **If the bar is met** (`BAR_MET=true`):
263
303
  ```bash
264
304
  gh pr merge {PR_NUMBER} {GH_FLAG} --merge
265
305
  MERGE_STATE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state --jq '.state')
@@ -281,7 +321,7 @@ else
281
321
  fi
282
322
  ```
283
323
 
284
- **If the bar is NOT met**: leave the issue at `workflow:awaiting-merge` exactly as `review-pr.md`'s guard set it — do NOT attempt a merge. `RE_GATE_OUTCOME="HELD-AWAITING-MERGE"`. Fail-safe direction: any doubt about the bar defaults to holding, matching `review-pr.md`'s own existing default for every other caller.
324
+ **If the bar is NOT met** (`BAR_MET=false`): leave the issue at `workflow:awaiting-merge` exactly as `review-pr.md`'s guard set it — do NOT attempt a merge. `RE_GATE_OUTCOME="HELD-AWAITING-MERGE"`. Fail-safe direction: any doubt about the bar (including an unresolved or unexpected `LIVE_BASE_REF`, which leaves `IS_DEPLOY_GATE=true`-equivalent strict handling) defaults to holding, matching `review-pr.md`'s own existing default for every other caller.
285
325
 
286
326
  ---
287
327
 
@@ -291,7 +331,16 @@ Post the completion body to **both** `{PR_NUMBER}` and `{ISSUE_NUMBER}` — this
291
331
 
292
332
  ```bash
293
333
  case "$RE_GATE_OUTCOME" in
294
- AUTO-LANDED) AUTO_LAND_BAR_TEXT="MET (${APPROVED_COUNT:-0} APPROVED: reviews + quality gate pass)"; OUTCOME_DETAIL="to {PR_BASE}" ;;
334
+ AUTO-LANDED)
335
+ # forge#2570: the bar that was met differs by target branch. Non-`main` (staging/milestone)
336
+ # lands on the fast-lane bar (clean re-review APPROVED + quality-gate pass), identical to the
337
+ # normal /work-on path; `main`/deploy-gate lands only on the strict ≥2 verified-human bar.
338
+ if [ "${IS_DEPLOY_GATE:-true}" = "false" ]; then
339
+ AUTO_LAND_BAR_TEXT="MET — fast-lane bar for non-\`main\` base (clean re-review APPROVED + quality gate pass), matching the normal /work-on merge bar for the same target branch"
340
+ else
341
+ AUTO_LAND_BAR_TEXT="MET (${APPROVED_COUNT:-0} APPROVED: reviews + quality gate pass)"
342
+ fi
343
+ OUTCOME_DETAIL="to {PR_BASE}" ;;
295
344
  HELD-AWAITING-MERGE) AUTO_LAND_BAR_TEXT="NOT MET (${APPROVED_COUNT:-0} APPROVED: reviews)"; OUTCOME_DETAIL="at workflow:awaiting-merge" ;;
296
345
  RE-ESCALATED) AUTO_LAND_BAR_TEXT="N/A — re-escalated before the bar was evaluated"; OUTCOME_DETAIL="at needs-human" ;;
297
346
  UNFIXABLE) AUTO_LAND_BAR_TEXT="N/A — unfixable (see Phase M1 classification)"; OUTCOME_DETAIL="at needs-human" ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Autonomous dev pipeline for Claude Code. GitHub as institutional memory — every issue, PR, and annotation survives context resets and makes the next agent smarter.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -6,7 +6,7 @@
6
6
  * Checks:
7
7
  * - Completion sentinel presence (§3.2) — for types that define one
8
8
  * - Required field presence (§4.1 per-type field tables)
9
- * - Required field enum values (Verdict, Confidence, Severity, Task Type)
9
+ * - Required field enum values (Verdict, Confidence, Severity, Task Type, Re-gate outcome)
10
10
  * - Inline-value non-empty (§3.4)
11
11
  * - CARD inline-value decodability + sha8 integrity (§4.2 — see card.js)
12
12
  * - Partial sentinel marking (§3.3) — generates a warning, not an error
@@ -129,5 +129,28 @@ export function validate(annotation) {
129
129
  }
130
130
  }
131
131
 
132
+ // REMEDIATION-specific enum validation (forge#2450): the Re-gate outcome field is
133
+ // optional at the required-field-presence layer (it is absent until
134
+ // commands/work-on/remediate.md's Phase M8 posts it), but when present its value
135
+ // must come from the single-sourced registry list — not a second hardcoded copy.
136
+ //
137
+ // Phase M8 posts this field as "**Re-gate outcome**: ${RE_GATE_OUTCOME} ${OUTCOME_DETAIL}"
138
+ // (see commands/work-on/remediate.md), so the parsed field value carries trailing free-text
139
+ // detail (e.g. "AUTO-LANDED to staging"), not just the bare outcome token. Only the leading
140
+ // token is the actual outcome — extract it the same way bin/engine/phases.mjs's own
141
+ // `/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/` regex does, so real production annotations with
142
+ // trailing detail text are not falsely rejected.
143
+ if (type === 'REMEDIATION') {
144
+ const reGateOutcome = fields['Re-gate outcome'];
145
+ if (reGateOutcome) {
146
+ const outcomeToken = reGateOutcome.match(/^([A-Z-]+)/)?.[1] ?? reGateOutcome;
147
+ if (!typeDef.reGateOutcomeValues.includes(outcomeToken)) {
148
+ errors.push(
149
+ `Invalid Re-gate outcome "${outcomeToken}" — must be one of: ${typeDef.reGateOutcomeValues.join(', ')}`,
150
+ );
151
+ }
152
+ }
153
+ }
154
+
132
155
  return { valid: errors.length === 0, errors, warnings };
133
156
  }
@@ -69,6 +69,28 @@ async function main() {
69
69
  process.exit(1);
70
70
  }
71
71
 
72
+ // Verify every id in PHASE_IDS has a corresponding key in PHASE_MARKERS.
73
+ // The empty-marker check below iterates Object.entries(PHASE_MARKERS), which
74
+ // only visits keys that actually exist on the object — a phase id that is
75
+ // entirely absent from PHASE_MARKERS (as opposed to present-but-empty) never
76
+ // appears in that iteration, so it would pass silently here and only surface
77
+ // later as a runtime TypeError in bin/engine/phases.mjs (e.g.
78
+ // `PHASE_MARKERS.someId.completionMarker` on an undefined `someId` entry).
79
+ // Catch that gap explicitly before the empty-marker check below.
80
+ const phaseMarkers = registryModule.PHASE_MARKERS || {};
81
+ const missingKeys = registryIds.filter(
82
+ (id) => !Object.prototype.hasOwnProperty.call(phaseMarkers, id)
83
+ );
84
+ if (missingKeys.length > 0) {
85
+ console.error(
86
+ `ERROR: PHASE_MARKERS is missing an entry entirely for: ${missingKeys.join(", ")}`
87
+ );
88
+ console.error(
89
+ " Add a corresponding key to packages/protocol/src/phases.js's PHASE_MARKERS for each id above."
90
+ );
91
+ process.exit(1);
92
+ }
93
+
72
94
  // Also verify every registry marker entry that has a `completionMarker` or
73
95
  // `completionLabel` field is a non-empty string — catches an accidental
74
96
  // undefined/null slipping in (e.g. a typo'd RESERVED_TYPES key whose
@@ -76,7 +98,7 @@ async function main() {
76
98
  // has(blob, undefined) → substring "undefined" never matches, masking a
77
99
  // real bug as "phase never completes".
78
100
  const emptyMarkers = [];
79
- for (const [phaseId, entry] of Object.entries(registryModule.PHASE_MARKERS || {})) {
101
+ for (const [phaseId, entry] of Object.entries(phaseMarkers)) {
80
102
  const marker = entry.completionMarker ?? entry.completionLabel;
81
103
  if (typeof marker !== "string" || marker.length === 0) {
82
104
  emptyMarkers.push(phaseId);
@@ -117,6 +117,8 @@ MARKER_REGISTRY="
117
117
  KNOWLEDGE_GIST
118
118
  LEARNED
119
119
  LEARNED_RULES
120
+ LEASE
121
+ LEASE_RELEASED
120
122
  LEDGER_INDEXED
121
123
  MEMORY_INDEX
122
124
  MEMORY_INDEXED
@@ -70,10 +70,15 @@ if ! [[ "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then
70
70
  exit 1
71
71
  fi
72
72
 
73
- # Fetch milestone title from GitHub
74
- # gh issue view exits non-zero if the issue does not exist or auth fails
73
+ # Fetch milestone title (and labels, for the review-finding staging-default
74
+ # note below) from GitHub in a single call — gh issue view exits non-zero if
75
+ # the issue does not exist or auth fails. Labels are fetched here rather
76
+ # than via a second `gh issue view` call so this stays one round trip, same
77
+ # failure surface as before this field was added. <!-- Added: forge#2443 -->
75
78
  GH_STDERR_TMP=$(mktemp)
76
- MILESTONE_TITLE=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" --json milestone --jq '.milestone.title // empty' 2>"$GH_STDERR_TMP") || {
79
+ chmod 600 "$GH_STDERR_TMP"
80
+ trap 'rm -f "$GH_STDERR_TMP"' EXIT
81
+ ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" --json milestone,labels 2>"$GH_STDERR_TMP") || {
77
82
  echo "ERROR: failed to fetch issue #$ISSUE_NUMBER — check issue number and repo flag" >&2
78
83
  cat "$GH_STDERR_TMP" >&2
79
84
  rm -f "$GH_STDERR_TMP"
@@ -81,6 +86,9 @@ MILESTONE_TITLE=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" --json mile
81
86
  }
82
87
  rm -f "$GH_STDERR_TMP"
83
88
 
89
+ MILESTONE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.milestone.title // empty')
90
+ HAS_REVIEW_FINDING_LABEL=$(echo "$ISSUE_JSON" | jq -r '[.labels[]?.name] | any(. == "review-finding")')
91
+
84
92
  # Resolve staging branch name from forge.yaml (branches.staging), defaulting to "staging".
85
93
  # Uses yq if available; falls back gracefully when forge.yaml is absent or yq is not installed.
86
94
  STAGING_BRANCH=$(yq '.branches.staging // "staging"' forge.yaml 2>/dev/null || echo 'staging')
@@ -104,6 +112,24 @@ if [ -z "$MILESTONE_TITLE" ]; then
104
112
  echo " Run: git push origin HEAD:$STAGING_BRANCH (from the default branch)" >&2
105
113
  exit 1
106
114
  fi
115
+ # Defense-in-depth visibility (forge#2443): a review-finding issue with no
116
+ # milestone defaults to the staging fast lane here — which is correct ONLY
117
+ # when the finding's subject code actually lives on staging. For a finding
118
+ # spawned from a milestone-lane PR whose body is missing **Code branch**
119
+ # and whose milestone wasn't propagated, this default silently routes the
120
+ # fix to the one branch where the code is guaranteed absent, causing
121
+ # /work-on's investigation phase to close it as invalid. This script has
122
+ # no way to know whether that mismatch applies to THIS issue (it only
123
+ # knows the issue has no milestone) — the note below is diagnostic only,
124
+ # non-fatal, so callers relying on stdout are unaffected.
125
+ # WIRE:PROVEN — manual: ran classify-lane.sh against a fake `gh issue view`
126
+ # returning labels:[{"name":"review-finding"}] and milestone:null; confirmed
127
+ # the NOTE lines below print to stderr while stdout stays unaffected.
128
+ if [ "$HAS_REVIEW_FINDING_LABEL" = "true" ]; then
129
+ echo "NOTE: issue #$ISSUE_NUMBER is a review-finding with no milestone — defaulting to staging fast lane ('$STAGING_BRANCH')." >&2
130
+ echo " If this finding's subject code only exists on a milestone branch, this default is WRONG." >&2
131
+ echo " See commands/orchestrate/phase-4-execution.md Step 4C for the repair/loud-failure guard." >&2
132
+ fi
107
133
  if [ "$JSON_OUTPUT" = "true" ]; then
108
134
  printf '{"lane":"%s","branch":"%s","source":"fast-lane","milestone":null}\n' \
109
135
  "$STAGING_BRANCH" "$STAGING_BRANCH"