forgedock 1.3.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.
Files changed (58) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/runlog.mjs +11 -1
  9. package/bin/engine/state.mjs +20 -28
  10. package/bin/engine-cli.mjs +86 -5
  11. package/bin/engine.mjs +543 -36
  12. package/bin/forgedock.mjs +397 -59
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +191 -13
  15. package/bin/journey.mjs +586 -44
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +478 -10
  18. package/bin/watch-utils.mjs +258 -0
  19. package/commands/cleanup.md +34 -5
  20. package/commands/issue.md +14 -4
  21. package/commands/orchestrate/config.md +43 -1
  22. package/commands/orchestrate/phase-1-resolve.md +348 -11
  23. package/commands/orchestrate/phase-3-dependency.md +272 -49
  24. package/commands/orchestrate/phase-4-execution.md +622 -60
  25. package/commands/orchestrate/phase-5-cleanup.md +2 -0
  26. package/commands/quality-gate.md +6 -6
  27. package/commands/review-pr-staging.md +51 -8
  28. package/commands/review-pr.md +120 -39
  29. package/commands/upgrade-deps.md +7 -2
  30. package/commands/work-on/decompose.md +2 -0
  31. package/commands/work-on/investigate.md +22 -4
  32. package/commands/work-on/remediate.md +55 -4
  33. package/commands/work-on/review.md +40 -1
  34. package/commands/work-on.md +19 -4
  35. package/docs/CONFIG.md +120 -0
  36. package/docs/spec/eval-run-result.md +8 -4
  37. package/docs/spec/label-state-machine.md +9 -0
  38. package/package.json +2 -2
  39. package/packages/protocol/src/emit.js +5 -22
  40. package/packages/protocol/src/html-comment-escape.js +68 -0
  41. package/packages/protocol/src/index.js +1 -0
  42. package/packages/protocol/src/parse.js +5 -6
  43. package/packages/protocol/src/phases.js +105 -0
  44. package/packages/protocol/src/types.js +17 -0
  45. package/packages/protocol/src/validate.js +24 -1
  46. package/scripts/check-admission-parity.mjs +167 -0
  47. package/scripts/check-command-side-effects.sh +181 -66
  48. package/scripts/check-phase-registry-drift.mjs +118 -0
  49. package/scripts/check-spec-markers.sh +3 -0
  50. package/scripts/classify-lane.sh +29 -3
  51. package/scripts/derive-finding-milestone.sh +184 -0
  52. package/scripts/doctor-pipeline-state.sh +1 -0
  53. package/scripts/extract-affected-files.sh +232 -0
  54. package/scripts/extract-affected-files.test.sh +331 -0
  55. package/scripts/forge-run.sh +39 -4
  56. package/scripts/issue-dedup.sh +69 -8
  57. package/scripts/issue-dedup.test.sh +142 -0
  58. package/scripts/severity-to-priority.sh +81 -0
@@ -45,6 +45,8 @@ Include the audit summary in the final report (Phase 6). Key metrics to surface:
45
45
 
46
46
  **Run after Step 5B, before Step 5D.** No-ops cleanly if the claims board was never created this run (`COORD_ISSUE_NUMBER` unset/empty — e.g. Step 3D.1's `gh issue create` failed, or this batch never reached DAG construction). Tolerates GitHub API failures without aborting the rest of Phase 5 — the same tolerant-failure convention (`2>/dev/null || echo ...`, `|| true`) used throughout `phase-3-dependency.md`/`phase-4-execution.md`.
47
47
 
48
+ **Orchestrator lease should already be released by this point** (forge#2627): `phase-4-execution.md`'s Termination condition (end of Step 4B) calls `release_orchestrator_lease()` before handing off to Phase 5 on a normal drain, and the "Stopping the orchestrator" procedure (Step 4A-pre.-0.5) does the same on an interrupted exit. Closing the coordination issue here does not depend on lease state either way — an unreleased lease on an issue about to be closed is harmless (a future batch creates its own new coordination issue per Step 3D.1), but if `ACTIVE_CLAIMS_NOTE` logic is ever extended to also surface lease state, prefer checking for a still-unreleased `FORGE:LEASE` the same way `ACTIVE_CLAIMS` is computed above.
49
+
48
50
  ```bash
49
51
  if [ -n "${COORD_ISSUE_NUMBER:-}" ]; then
50
52
  # Detect any still-active (unreleased) claims — a claim posted with no matching
@@ -1226,7 +1226,7 @@ fi
1226
1226
 
1227
1227
  **Triggered when**: BILLING domain is set (changed files are under `billing/`, `credits/`, `subscription`, `ledger`, or `payment` paths).
1228
1228
 
1229
- **Why this matters**: Billing code has three reliably grep-detectable defect classes that account for ~27% of review findings in the AlterLab health window: (1) `org_id` not propagated to debit/refund ledger write paths (silent data ownership loss — charges appear with wrong tenant); (2) `asyncio.sleep` / blocking sleep called inside an advisory-lock or open-transaction scope (holds DB connection/lock for the full sleep duration, causing connection exhaustion and deadlocks under load); (3) stdlib `logging.*()` calls passed structlog-style keyword arguments (`event=`, named context keys) that are not valid stdlib kwargs — crashes at runtime with `TypeError: unexpected keyword argument`. <!-- Added: forge#1329 --> <!-- allowlist: AlterLab -->
1229
+ **Why this matters**: Billing code has three reliably grep-detectable defect classes that recur often enough in production billing code to justify a deterministic check: (1) `org_id` not propagated to debit/refund ledger write paths (silent data ownership loss — charges appear with wrong tenant); (2) `asyncio.sleep` / blocking sleep called inside an advisory-lock or open-transaction scope (holds DB connection/lock for the full sleep duration, causing connection exhaustion and deadlocks under load); (3) stdlib `logging.*()` calls passed structlog-style keyword arguments (`event=`, named context keys) that are not valid stdlib kwargs — crashes at runtime with `TypeError: unexpected keyword argument`. <!-- Added: forge#1329 -->
1230
1230
 
1231
1231
  **2Q-1: org_id propagation in ledger write functions**
1232
1232
 
@@ -1241,7 +1241,7 @@ while IFS= read -r f; do
1241
1241
  # Read the full function signature (up to 3 lines to cover multi-line sigs)
1242
1242
  sig=$(sed -n "${lineno},$((lineno+2))p" "$f" 2>/dev/null)
1243
1243
  echo "$sig" | grep -qE '\borg_id\b' || \
1244
- echo "BILLING-1 | MEDIUM | $f:$lineno | Ledger write function '$(echo "$rest" | grep -oE 'def \w+' | head -1)' signature does not include org_id parameter — verify org_id is propagated to all debit/refund paths (AlterLab#25348 pattern)" <!-- allowlist: AlterLab -->
1244
+ echo "BILLING-1 | MEDIUM | $f:$lineno | Ledger write function '$(echo "$rest" | grep -oE 'def \w+' | head -1)' signature does not include org_id parameter — verify org_id is propagated to all debit/refund paths"
1245
1245
  done
1246
1246
  done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1247
1247
  ```
@@ -1252,14 +1252,14 @@ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1252
1252
  # Grep billing Python files for asyncio.sleep / time.sleep called inside an advisory-lock
1253
1253
  # or open-transaction context (within 20 lines after lock acquisition or BEGIN/session open).
1254
1254
  # Holding a lock or DB connection across a sleep starves the connection pool and causes
1255
- # deadlocks under concurrent load (AlterLab#25725 pattern). <!-- allowlist: AlterLab -->
1255
+ # deadlocks under concurrent load.
1256
1256
  while IFS= read -r f; do
1257
1257
  [ -z "$f" ] && continue
1258
1258
  grep -nE "\basyncio\.sleep\b|\btime\.sleep\b" "$f" 2>/dev/null | while IFS=: read -r lineno rest; do
1259
1259
  start=$((lineno > 20 ? lineno - 20 : 1))
1260
1260
  window=$(sed -n "${start},$((lineno-1))p" "$f" 2>/dev/null)
1261
1261
  echo "$window" | grep -qE 'advisory_lock|BEGIN\b|async with.*session|async with.*conn|async with.*transaction|with.*engine\b' && \
1262
- echo "BILLING-2 | HIGH | $f:$lineno | asyncio.sleep/time.sleep inside advisory-lock or open-transaction scope — holds DB connection/lock for full sleep duration, causing connection exhaustion and deadlocks under load (AlterLab#25725 pattern). Move sleep outside lock/transaction scope." <!-- allowlist: AlterLab -->
1262
+ echo "BILLING-2 | HIGH | $f:$lineno | asyncio.sleep/time.sleep inside advisory-lock or open-transaction scope — holds DB connection/lock for full sleep duration, causing connection exhaustion and deadlocks under load. Move sleep outside lock/transaction scope."
1263
1263
  done
1264
1264
  done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1265
1265
  ```
@@ -1271,7 +1271,7 @@ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1271
1271
  # that are NOT valid stdlib logging kwargs (exc_info, stack_info, stacklevel, extra).
1272
1272
  # Structlog-style kwargs (event=, extra_=, named context keys) are NOT accepted by stdlib
1273
1273
  # Logger methods and crash at runtime with TypeError: unexpected keyword argument.
1274
- # (AlterLab#25715, #25837, #25819 pattern) <!-- allowlist: AlterLab -->
1274
+ # This is a recurring, well-observed pattern in production billing code.
1275
1275
  while IFS= read -r f; do
1276
1276
  [ -z "$f" ] && continue
1277
1277
  # Match logging.info/warning/error/debug/critical and logger.info/etc. calls with
@@ -1280,7 +1280,7 @@ while IFS= read -r f; do
1280
1280
  grep -nE "\b(logging|logger)\.(info|warning|error|debug|critical|exception)\s*\(.*\b\w+\s*=" "$f" 2>/dev/null | \
1281
1281
  grep -vE "\b(exc_info|stack_info|stacklevel|extra)\s*=" | \
1282
1282
  grep -v "^\s*#" && \
1283
- echo "BILLING-3 | HIGH | $f | stdlib logging call with non-stdlib keyword argument detected — stdlib Logger.info/warning/error/etc. only accept exc_info, stack_info, stacklevel, extra as kwargs. Structlog-style kwargs (event=, named context keys) cause TypeError at runtime (AlterLab#25715 pattern). Use structlog.get_logger() instead of logging if structlog kwargs are needed." <!-- allowlist: AlterLab -->
1283
+ echo "BILLING-3 | HIGH | $f | stdlib logging call with non-stdlib keyword argument detected — stdlib Logger.info/warning/error/etc. only accept exc_info, stack_info, stacklevel, extra as kwargs. Structlog-style kwargs (event=, named context keys) cause TypeError at runtime. Use structlog.get_logger() instead of logging if structlog kwargs are needed."
1284
1284
  done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1285
1285
  ```
1286
1286
 
@@ -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."
@@ -2229,13 +2294,30 @@ Post a welcome comment when all three conditions are true:
2229
2294
  # Read config — default enabled when key is absent
2230
2295
  WELCOME_ENABLED=$(yq '.community.welcome_new_contributors // true' forge.yaml 2>/dev/null || echo 'true')
2231
2296
 
2232
- if [ "$IS_FIRST_TIME_CONTRIBUTOR" = "true" ] && [ "$WELCOME_ENABLED" != "false" ]; then
2297
+ if [ "$IS_FIRST_TIME_CONTRIBUTOR" = "true" ] && [ "$WELCOME_ENABLED" != "false" ] && [ "$REVIEW_MODE" = "single-pr" ]; then
2233
2298
  # Read optional custom values from forge.yaml community section
2234
2299
  CONTRIBUTING_URL=$(yq '.community.contributing_url // "CONTRIBUTING.md"' forge.yaml 2>/dev/null || echo 'CONTRIBUTING.md')
2235
2300
  CUSTOM_CONVENTIONS=$(yq '.community.conventions // ""' forge.yaml 2>/dev/null || echo '')
2236
2301
  PROJECT_NAME_WELCOME=$(yq '.project.name // "this project"' forge.yaml 2>/dev/null || echo 'this project')
2237
2302
 
2238
- gh pr comment "$ARGUMENTS" --body "## Welcome to ${PROJECT_NAME_WELCOME}! 🎉
2303
+ # Resolve the conventions text into a plain shell variable BEFORE interpolating it into
2304
+ # the --body string below. A nested $(...) command substitution containing an escaped
2305
+ # \$VAR here is never expanded — see forge#2214.
2306
+ if [ -n "$CUSTOM_CONVENTIONS" ]; then
2307
+ CONVENTIONS_TEXT="$CUSTOM_CONVENTIONS"
2308
+ else
2309
+ CONVENTIONS_TEXT="- **Conventional commits**: prefix your commit messages with \`fix(scope):\`, \`feat(scope):\`, \`refactor(scope):\`, etc.
2310
+ - **DCO sign-off**: all commits must be signed off with \`git commit -s\` (Developer Certificate of Origin — required for the dual-license model).
2311
+ Fix unsigned commits: \`git commit --amend -s --no-edit && git push --force-with-lease\` <!-- allowlist:check-command-side-effects -->
2312
+ - **Issues, not inline fixes**: when you spot a bug or improvement opportunity, open a GitHub issue rather than fixing it inline — so it flows through the pipeline with full traceability."
2313
+ fi
2314
+
2315
+ # Unquoted heredoc (not the corpus's quoted <<'EOF' form) — this body needs live
2316
+ # ${VAR} interpolation, which a quoted heredoc would disable. Literal backticks in
2317
+ # the static text are still escaped (\`) since they remain live inside an unquoted
2318
+ # heredoc, same as inside a double-quoted string. See forge#2223.
2319
+ gh pr comment "$ARGUMENTS" --body "$(cat <<EOF
2320
+ ## Welcome to ${PROJECT_NAME_WELCOME}! 🎉
2239
2321
 
2240
2322
  Hi @${PR_AUTHOR} — this looks like your first merged contribution here. Thanks for opening a PR!
2241
2323
 
@@ -2245,17 +2327,16 @@ The automated review pipeline (/review-pr) ran against your PR. It spawned domai
2245
2327
 
2246
2328
  ### Key conventions
2247
2329
 
2248
- $([ -n "\$CUSTOM_CONVENTIONS" ] && echo "\$CUSTOM_CONVENTIONS" || echo "- **Conventional commits**: prefix your commit messages with \`fix(scope):\`, \`feat(scope):\`, \`refactor(scope):\`, etc.
2249
- - **DCO sign-off**: all commits must be signed off with \`git commit -s\` (Developer Certificate of Origin — required for the dual-license model).
2250
- Fix unsigned commits: \`git commit --amend -s --no-edit && git push --force-with-lease\`
2251
- - **Issues, not inline fixes**: when you spot a bug or improvement opportunity, open a GitHub issue rather than fixing it inline — so it flows through the pipeline with full traceability.")
2330
+ ${CONVENTIONS_TEXT}
2252
2331
 
2253
2332
  ### Getting started
2254
2333
 
2255
2334
  - Contributing guide: [\`${CONTRIBUTING_URL}\`](${CONTRIBUTING_URL})
2256
2335
  - Run \`npx forgedock\` to install the pipeline commands locally if you want to run /review-pr or /work-on yourself.
2257
2336
 
2258
- Welcome aboard!"
2337
+ Welcome aboard!
2338
+ EOF
2339
+ )"
2259
2340
  fi
2260
2341
  ```
2261
2342
 
@@ -376,7 +376,12 @@ Store created issue numbers as `CREATED_ISSUES` (empty when `DRY_RUN=true`).
376
376
  For each issue in `CREATED_ISSUES`, invoke `/work-on` via the Skill tool:
377
377
 
378
378
  ```
379
- Skill(skill: "work-on", args: "{ISSUE_NUMBER}")
379
+ # DRY_RUN guard — must stay above the Skill() call it guards (forge#1609).
380
+ if [ "$DRY_RUN" = "true" ]; then
381
+ echo "[DRY RUN] Would invoke: Skill(skill: \"work-on\", args: \"{ISSUE_NUMBER}\")"
382
+ else
383
+ Skill(skill: "work-on", args: "{ISSUE_NUMBER}")
384
+ fi
380
385
  ```
381
386
 
382
387
  Run **sequentially** — each `/work-on` invocation is heavyweight. If one fails, continue to the next and record the outcome.
@@ -389,7 +394,7 @@ FIX_RESULTS = [
389
394
  ]
390
395
  ```
391
396
 
392
- **DRY_RUN check**: If `DRY_RUN=true`, print what would be invoked but skip `Skill()` calls.
397
+ **DRY_RUN check**: enforced inline above — the guard wraps the `Skill()` call rather than being stated after it, so a dry run cannot invoke `/work-on` even if only this section is read in isolation (forge#1609).
393
398
 
394
399
  ---
395
400
 
@@ -12,6 +12,8 @@ argument-hint: "[issue number] [--repo GH_REPO] [--gh-flag GH_FLAG]"
12
12
  **Invoked by**: `work-on.md` routing loop, when `INVESTIGATE_RESULT.decompose = YES`.
13
13
  **Output**: Create sub-issues, update parent tracker, post `<!-- FORGE:DECOMPOSED -->` comment, set labels. STOP — each sub-issue runs its own /work-on.
14
14
 
15
+ **Engine coverage** (forge#2379): this subcommand's `command` name (`work-on/decompose`) and completion marker (`FORGE:DECOMPOSED:COMPLETE`) are now registered as a real phase in the headless engine's phase table — `decompose` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and the matching `decompose` entry in `bin/engine/phases.mjs`'s `PHASES` array. The engine's `investigate` phase hands off to it on `DECOMPOSE:YES` instead of terminating in place, so a headless `runIssue()` walk now actually dispatches this subcommand rather than stopping short.
16
+
15
17
  **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 (label transitions, sub-issue creation) stay at this tier because they're interleaved with the reasoning-heavy sub-issue design steps in the same `Skill()` invocation — see `work-on.md` section "Model and Effort Tiering — What Actually Applies". <!-- Added: forge#1827 -->
16
18
  **NEVER use plan mode (EnterPlanMode).**
17
19
 
@@ -263,8 +263,8 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments --jq '.[] | {id: .id, body: .bod
263
263
  ```
264
264
 
265
265
  **Resume logic**:
266
- - If `<!-- FORGE:INVESTIGATOR -->` comment exists AND `<!-- INVESTIGATION:COMPLETE -->` is present in the SAME comment → investigation already complete, EXIT (return existing verdict to caller)
267
- - If `<!-- FORGE:INVESTIGATOR -->` comment exists BUT `<!-- INVESTIGATION:COMPLETE -->` is ABSENT → investigation was interrupted, delete the partial comment and restart:
266
+ - If `<!-- FORGE:INVESTIGATOR -->` comment exists AND (`<!-- INVESTIGATION:COMPLETE -->` OR `<!-- INVESTIGATION:INVALID -->`) is present in the SAME comment → investigation already complete, EXIT (return existing verdict to caller). `INVESTIGATION:INVALID` is the terminal sentinel Phase 1C emits for an INVALID verdict (see Phase 1C below) — it is just as much a completion marker as `INVESTIGATION:COMPLETE`, not an "interrupted" state.
267
+ - If `<!-- FORGE:INVESTIGATOR -->` comment exists BUT NEITHER `<!-- INVESTIGATION:COMPLETE -->` NOR `<!-- INVESTIGATION:INVALID -->` is present → investigation was interrupted, delete the partial comment and restart:
268
268
  ```bash
269
269
  gh api repos/{GH_REPO}/issues/comments/{COMMENT_ID} -X DELETE
270
270
  ```
@@ -355,10 +355,25 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
355
355
 
356
356
  ## Phase 1C: Post Investigation Comment
357
357
 
358
- The comment MUST include `<!-- INVESTIGATION:COMPLETE -->` at the very end, AFTER all required sections are present. This marker signals the investigation finished successfully.
358
+ The comment MUST include a terminal sentinel at the very end, AFTER all required sections are present. **The sentinel is conditional on the resolved Verdict — it is NOT always `<!-- INVESTIGATION:COMPLETE -->`:**
359
+
360
+ - **Verdict is INVALID** → close with `<!-- INVESTIGATION:INVALID -->`. This is a distinct, already-wired-up terminal marker: `bin/engine/phases.mjs`'s `detectOutcome` for the `investigate` phase checks for it explicitly (ahead of `INVESTIGATION:COMPLETE`) and routes to `terminalReason: "invalid"`; `bin/hooks/interactive-engine.mjs`'s `PHASE_MARKERS` table also already treats it as terminal. Emitting `INVESTIGATION:COMPLETE` for an INVALID verdict is what previously caused every completed investigation to read as `{verdict: "CONFIRMED"}` regardless of actual outcome — do NOT regress this (forge#2350).
361
+ - **Verdict is CONFIRMED or PARTIAL** → close with `<!-- INVESTIGATION:COMPLETE -->` as before (PARTIAL still routes to `ready-to-build` in Phase 1D — only INVALID gets the distinct terminal sentinel).
362
+
363
+ Compute the sentinel once, before building the comment body:
364
+
365
+ ```bash
366
+ if [ "{VERDICT}" = "INVALID" ]; then
367
+ INVESTIGATION_SENTINEL="<!-- INVESTIGATION:INVALID -->"
368
+ else
369
+ INVESTIGATION_SENTINEL="<!-- INVESTIGATION:COMPLETE -->"
370
+ fi
371
+ ```
359
372
 
360
373
  **CODEC PATH (forge#1727)**: Construct the annotation body via the protocol codec — do NOT hand-roll the `<!-- FORGE:INVESTIGATOR -->` header. Use `forge-annotation.sh write INVESTIGATOR --field ...` or `node packages/protocol/src/cli.js emit INVESTIGATOR --field ...` to produce the opening tag and completion sentinel. Fill in the Markdown body sections below. The full pattern:
361
374
 
375
+ **Caveat (forge#2368)**: `packages/protocol/src/types.js`'s `INVESTIGATOR.completionSentinel` is a single fixed value (`'INVESTIGATION:COMPLETE'`) and `packages/protocol/src/emit.js` appends it unconditionally — the codec does NOT currently support the verdict-conditional sentinel selection described above, and cannot emit `INVESTIGATION:INVALID`. Using the CODEC PATH for an INVALID-verdict investigation would silently regress the forge#2350 fix (every investigation would again read as `{verdict: "CONFIRMED"}`). Until the codec is extended to support a verdict-conditional sentinel, the hand-rolled block above/below (using the `${INVESTIGATION_SENTINEL}` variable computed above) is the authoritative path for Phase 1C — do not use the CODEC PATH for this annotation.
376
+
362
377
  ```bash
363
378
  # Build the annotation body via codec (escaping and sentinel handled by codec)
364
379
  ANNOTATION_BODY=$(node packages/protocol/src/cli.js emit INVESTIGATOR \
@@ -368,6 +383,7 @@ ANNOTATION_BODY=$(node packages/protocol/src/cli.js emit INVESTIGATOR \
368
383
  --field "Task Type={TASK_TYPE}" \
369
384
  --field "Decomposition Assessment={YES|NO} — {reason}")
370
385
  # ANNOTATION_BODY now has opening tag + required fields + INVESTIGATION:COMPLETE sentinel.
386
+ # NOTE: the codec's sentinel is fixed — do not use this path when Verdict=INVALID (see caveat above).
371
387
  # Append the Markdown body sections to it before posting.
372
388
  ```
373
389
 
@@ -450,9 +466,11 @@ ACCEPTANCE_CHECK: id=ac-4 type=command target="grep -qE '(>= ?2|2\+)' commands/o
450
466
 
451
467
  **Skipping**: if the issue has no verifiable acceptance criteria and none can be derived from the recommendation, emit a single sentinel: `ACCEPTANCE_CHECK: id=ac-skip type=skipped target="none" matcher="none" description=No machine-checkable criteria available — human review required`
452
468
  ${ANNOTATION_LINK_FOOTER}
453
- <!-- INVESTIGATION:COMPLETE -->"
469
+ ${INVESTIGATION_SENTINEL}"
454
470
  ```
455
471
 
472
+ **Do not hardcode `<!-- INVESTIGATION:COMPLETE -->` as the closing line.** The closing line MUST be the `${INVESTIGATION_SENTINEL}` variable computed above — it resolves to `<!-- INVESTIGATION:INVALID -->` for an INVALID verdict and `<!-- INVESTIGATION:COMPLETE -->` otherwise. `INVESTIGATION:COMPLETE` and `INVESTIGATION:INVALID` are mutually exclusive within a single posted comment — never emit both.
473
+
456
474
  ---
457
475
 
458
476
  ## Phase 1C.5: Create Knowledge Gist
@@ -20,6 +20,8 @@ argument-hint: "[PR number] [--issue N] [--repo GH_REPO] [--gh-flag GH_FLAG] [--
20
20
 
21
21
  **Scope note**: This mode owns exactly one gap — re-driving a `needs-human` PR's own remediation. It does NOT implement the `needs-human` sub-label taxonomy (#1815's scope) and it does NOT edit `review-pr.md`'s Phase 8 guard (forge#1810) — that guard's existing safe-default (`workflow:awaiting-merge` on any clean re-review of a previously-escalated PR) is reused as-is; this file only adds a bar-check *after* that guard has already fired.
22
22
 
23
+ **Engine coverage** (forge#2379): this subcommand's `command` name (`work-on/remediate`) and completion marker (`FORGE:REMEDIATION:COMPLETE`, including the `**Re-gate outcome**` field Phase M8 posts below) are now registered in the headless engine's phase table — `RESERVED_TYPES.REMEDIATION` in `packages/protocol/src/types.js`, `remediate` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and a matching `remediate` entry in `bin/engine/phases.mjs`'s `PHASES` array (`entryCondition`: review committed + `terminalReason: "needs-human"`; `detectOutcome` maps each re-gate outcome to the right terminal reason). **Known limitation, documented in that engine entry's own comment**: a single continuous `runIssue()` walk cannot reach this phase today — `review`'s `"blocked"` outcome and the needs-human divergence guard both terminate the run before `remediate`'s `entryCondition` is ever evaluated. This subcommand therefore continues to run exactly as documented above — as its own separate top-level entry point — until a follow-up issue live-wires the engine to reconstruct state from GitHub for a dedicated remediation run.
24
+
23
25
  ---
24
26
 
25
27
  ## Inputs
@@ -253,11 +255,51 @@ REVIEW_BODIES=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
253
255
  APPROVED_COUNT=$(echo "$REVIEW_BODIES" | grep -cE 'APPROVED:' 2>/dev/null || true); APPROVED_COUNT=${APPROVED_COUNT:-0}
254
256
  ```
255
257
 
256
- **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:
257
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).
258
289
  2. `GATE_PASSED = true` from this remediation's own Phase M3 quality-gate loop.
259
290
 
260
- **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`):
261
303
  ```bash
262
304
  gh pr merge {PR_NUMBER} {GH_FLAG} --merge
263
305
  MERGE_STATE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state --jq '.state')
@@ -279,7 +321,7 @@ else
279
321
  fi
280
322
  ```
281
323
 
282
- **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.
283
325
 
284
326
  ---
285
327
 
@@ -289,7 +331,16 @@ Post the completion body to **both** `{PR_NUMBER}` and `{ISSUE_NUMBER}` — this
289
331
 
290
332
  ```bash
291
333
  case "$RE_GATE_OUTCOME" in
292
- 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}" ;;
293
344
  HELD-AWAITING-MERGE) AUTO_LAND_BAR_TEXT="NOT MET (${APPROVED_COUNT:-0} APPROVED: reviews)"; OUTCOME_DETAIL="at workflow:awaiting-merge" ;;
294
345
  RE-ESCALATED) AUTO_LAND_BAR_TEXT="N/A — re-escalated before the bar was evaluated"; OUTCOME_DETAIL="at needs-human" ;;
295
346
  UNFIXABLE) AUTO_LAND_BAR_TEXT="N/A — unfixable (see Phase M1 classification)"; OUTCOME_DETAIL="at needs-human" ;;