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
@@ -8,6 +8,27 @@ install: core
8
8
 
9
9
  ## Phase 1: Resolve the Issue Set
10
10
 
11
+ ### Batch-Start Timestamp (T0) — capture FIRST, before any resolution
12
+
13
+ <!-- Added: forge#2628 -->
14
+
15
+ Before parsing `$ARGUMENTS` or resolving anything, capture the batch's start timestamp once:
16
+
17
+ ```bash
18
+ BATCH_T0=$(date -u +%Y-%m-%dT%H:%M:%SZ)
19
+ echo "Batch start T0: ${BATCH_T0}"
20
+ ```
21
+
22
+ `BATCH_T0` is the anchor for the run-spawned-vs-backlog cascade distinction used throughout this
23
+ file's "Cascade / Review-Finding Resolution" section below and by `phase-4-execution.md` Step 4C.
24
+ Persist it alongside `ORIGINATING_QUERY_KIND`/`ORIGINATING_QUERY_PATTERN`/`ORIGINATING_QUERY_ARGS`
25
+ (see "Predicate Persistence" below) in the batch's in-memory/report state so Phase 4 reads the
26
+ same value this step captured — it must never independently re-derive its own T0 from "now" at
27
+ whatever later moment Step 4A.pre happens to run, which would silently widen the window past what
28
+ this run actually spawned. If a session resumes mid-run without `BATCH_T0` in context (e.g. after
29
+ compaction), Phase 4's Step 4A.pre falls back to capturing its own timestamp — see that section's
30
+ note — but this is a degraded fallback, not the normal path.
31
+
11
32
  Parse `$ARGUMENTS` to determine which issues to work on:
12
33
 
13
34
  ### Input Patterns
@@ -16,12 +37,181 @@ Parse `$ARGUMENTS` to determine which issues to work on:
16
37
  |-------|------------|
17
38
  | `milestone <slug>` | All open issues assigned to that GitHub milestone (default repo) |
18
39
  | `#1 #2 #3` or `1 2 3` | Specific issue numbers, optionally repo-prefixed (e.g., `#123 mcp:5 n8n:12`) |
19
- | `next <N>` | Top N priority open issues (priority:P0 first, then priority:P1, etc.) |
40
+ | `next <N>` | Top N priority open issues (P0 first, then P1, etc. — accepts both `priority:P<n>` and bare `P<n>` labels, see "Priority label schema" note under P3 batching below) |
20
41
  | `next <N> all-repos` | Top N across ALL ecosystem repos |
21
42
  | `fast-lane` or `fast` | All open fast-lane issues (no milestone, bugs/fixes) |
22
- | `priority:P0` or `priority:P1` | All open issues with that priority label |
43
+ | `priority:P0` or `priority:P1` | All open issues with that priority label (matches both `priority:P<n>` and bare `P<n>` on the target repo — see "Priority label schema" note below) |
23
44
  | `mcp:fast` or `n8n:next 3` | Repo-scoped queries |
24
- | `<slug>` (no keyword) | Try milestone first, then fall back to label search |
45
+ | `cascade`, `review-findings`, or `findings` (optionally `--include-deferred` / `--allow-gen2` / `--include-backlog`) | `review-finding` issues created at/after this batch's T0 (default repo, or repo-scoped e.g. `mcp:cascade`) — i.e. empty unless combined with a run that has already spawned findings, or `--include-backlog` is passed. See "Cascade / Review-Finding Resolution" below for the run-spawned-vs-backlog distinction and the generation-depth admission on top of it (`orchestration.cascade.max_generation`, default 1). <!-- Added: forge#2231, forge#2234, forge#2628 -->|
46
+ | `<slug>` (no keyword) | Try milestone first, then fall back to label search. If both resolve to zero issues, report near-miss label candidates instead of silently resolving to nothing — see "Near-Miss Suggestion" below. <!-- Added: forge#2231 -->|
47
+
48
+ ### Cascade / Review-Finding Resolution
49
+
50
+ <!-- Added: forge#2231, T0-scoping added: forge#2628 -->
51
+
52
+ When the input matches `cascade`, `review-findings`, or `findings` (case-insensitive, optionally repo-prefixed), resolve to open `review-finding`-labeled issues instead of a milestone or plain label search, then apply a generation depth check so the default set here matches what Step 4C would actually admit.
53
+
54
+ **Two modes — run-spawned cascade (default) vs. whole-backlog sweep (explicit opt-in only):**
55
+
56
+ There are two different things an operator can mean by "cascade," and this resolve step must not
57
+ silently conflate them:
58
+
59
+ - **(a) Run-spawned cascade (default)** — recursively work the `review-finding` issues *this batch*
60
+ produces. Bounded to findings created at/after `BATCH_T0` (captured at the very top of this file,
61
+ before any resolution runs). This is the safe default: combined with an ordinary scoped batch
62
+ (e.g. `<no:milestone set>` + `cascade`), it expresses "follow through on what this run's own
63
+ `/review-pr` agents find" — not "also adopt every other open finding already sitting in the repo."
64
+ Because `BATCH_T0` is captured at batch start, a bare `cascade` invocation with nothing yet spawned
65
+ legitimately resolves to an **empty set** at Phase 1 — new findings admitted by this mode are picked
66
+ up as they're created via `phase-4-execution.md` Step 4C's mid-run sweep (which uses the same
67
+ `BATCH_T0` anchor), not via this one-shot Phase 1 fetch.
68
+ - **(b) Whole-backlog sweep (explicit opt-in only)** — work every open `review-finding` in the repo,
69
+ regardless of when it was created. This is what the pre-#2628 default silently did on every
70
+ `cascade`/`review-findings`/`findings` invocation. It is still available, but now requires the
71
+ explicit `--include-backlog` flag (e.g. `cascade --include-backlog`, `review-findings
72
+ --include-backlog`) — adding `cascade` to an already-scoped batch never implies it.
73
+
74
+ ```bash
75
+ # BATCH_T0 was captured at the top of this file (Phase 1, "Batch-Start Timestamp (T0)"),
76
+ # before any resolution ran — reuse it here, do not recapture.
77
+
78
+ # --include-backlog opts into mode (b): the full open review-finding backlog, no created-date
79
+ # filter. Without it (the default, mode (a)), only findings created at/after BATCH_T0 are fetched
80
+ # — i.e. issues this batch itself could plausibly have spawned so far.
81
+ if echo "{ARGUMENTS}" | grep -qE -- '--include-backlog'; then
82
+ CASCADE_MODE="backlog-sweep"
83
+ CASCADE_SEARCH="label:review-finding"
84
+ else
85
+ CASCADE_MODE="run-spawned"
86
+ CASCADE_SEARCH="label:review-finding created:>=${BATCH_T0}"
87
+ fi
88
+ echo "Cascade mode: ${CASCADE_MODE} (search: \"${CASCADE_SEARCH}\")"
89
+
90
+ # Fetch matching open review-finding issues, including body (needed for the generation check
91
+ # below — unlike a plain label search, we cannot skip straight to {number,title,labels,milestone}
92
+ # here). --search (not --label) is required to combine the label filter with the created-date
93
+ # filter — verified working: `gh issue list --state open --search "label:review-finding
94
+ # created:>=$T0"`.
95
+ CASCADE_CANDIDATES=$(gh issue list {GH_FLAG} --state open --search "${CASCADE_SEARCH}" --limit 500 \
96
+ --json number,title,labels,milestone,body)
97
+
98
+ # --- Cascade admission policy resolution (forge#2234) ---
99
+ # orchestration.cascade.max_generation is the config-driven, granular successor to the
100
+ # pre-#2234 --include-deferred/--allow-gen2 flags: instead of an all-or-nothing "admit
101
+ # every deferred generation," an operator can say "admit up to generation 3" and no
102
+ # further. Preset table mirrors phase-4-execution.md's Step 4A.pre resolution exactly
103
+ # (see bin/engine/admission.mjs for the typed, unit-tested reference implementation).
104
+ CASCADE_POLICY_NAME=$(yq '.orchestration.cascade.policy // "balanced"' forge.yaml 2>/dev/null || echo "balanced")
105
+ [ "$CASCADE_POLICY_NAME" = "null" ] && CASCADE_POLICY_NAME="balanced"
106
+ case "$CASCADE_POLICY_NAME" in
107
+ all) PRESET_MAX_GEN="unlimited" ;;
108
+ conservative|balanced) PRESET_MAX_GEN=1 ;;
109
+ *) echo "WARNING: forge.yaml → orchestration.cascade.policy \"${CASCADE_POLICY_NAME}\" is not one of: all, balanced, conservative — falling back to \"balanced\""
110
+ CASCADE_POLICY_NAME="balanced"; PRESET_MAX_GEN=1 ;;
111
+ esac
112
+ MAX_GENERATION=$(yq ".orchestration.cascade.max_generation // \"${PRESET_MAX_GEN}\"" forge.yaml 2>/dev/null || echo "$PRESET_MAX_GEN")
113
+ [ "$MAX_GENERATION" = "null" ] && MAX_GENERATION="$PRESET_MAX_GEN"
114
+ if [ "$MAX_GENERATION" != "unlimited" ] && ! echo "$MAX_GENERATION" | grep -qE '^[1-9][0-9]*$'; then
115
+ echo "WARNING: forge.yaml → orchestration.cascade.max_generation is not a positive integer or \"unlimited\" (\"${MAX_GENERATION}\") — falling back to default ${PRESET_MAX_GEN}"
116
+ MAX_GENERATION="$PRESET_MAX_GEN"
117
+ fi
118
+
119
+ # Pre-#2234 CLI override — still honored for this resolve step specifically. Either flag
120
+ # forces MAX_GENERATION to "unlimited" for this run only, taking precedence over both the
121
+ # preset and any explicit orchestration.cascade.max_generation value (a human typing
122
+ # --allow-gen2 at the CLI is asking for everything, right now, overriding config).
123
+ echo "{ARGUMENTS}" | grep -qE -- '--include-deferred|--allow-gen2' && MAX_GENERATION="unlimited"
124
+
125
+ echo "Cascade resolve: policy=${CASCADE_POLICY_NAME} max_generation=${MAX_GENERATION} (forge.yaml → orchestration.cascade.max_generation; --include-deferred/--allow-gen2 forces unlimited)"
126
+ # --- End cascade admission policy resolution ---
127
+
128
+ # Generation depth — walks the "spawned from issue #N" / "source issue #N" chain up to
129
+ # MAX_HOPS times, counting how many review-finding-labeled ancestors precede this finding.
130
+ # Generation 1 = not spawned from a review-finding (an original issue). Generation 2 = spawned
131
+ # from a review-finding. Generation 3 = spawned from a finding that was itself spawned from a
132
+ # review-finding. This numeric walk (rather than the pre-#2234 single-hop boolean) is what
133
+ # makes "admit gen-2, stop at gen-3" (max_generation: 3) expressible — a binary flag cannot
134
+ # say that; it can only say "admit everything deferred" or "admit nothing deferred." Bounded
135
+ # at MAX_HOPS to guard against a malformed/cyclic reference chain.
136
+ MAX_HOPS=10
137
+ compute_generation() {
138
+ local body="$1"
139
+ local generation=1
140
+ local hops=0
141
+ while [ "$hops" -lt "$MAX_HOPS" ]; do
142
+ # Portable two-step extraction (grep -E, not -P/PCRE — macOS BSD grep lacks \K support):
143
+ # first match the whole "spawned from issue #N" / "source issue: #N" phrase, then pull
144
+ # the trailing digits off that match.
145
+ local source_num
146
+ source_num=$(echo "$body" | grep -ioE 'spawned from issue #[0-9]+|source issue[: #]+[0-9]+' | head -1 | grep -oE '[0-9]+$')
147
+ [ -z "$source_num" ] && break
148
+ local source_data
149
+ source_data=$(gh issue view "$source_num" {GH_FLAG} --json labels,body 2>/dev/null) || break
150
+ echo "$source_data" | jq -e '[.labels[].name] | index("review-finding")' >/dev/null 2>&1 || break
151
+ generation=$((generation + 1))
152
+ body=$(echo "$source_data" | jq -r '.body')
153
+ hops=$((hops + 1))
154
+ done
155
+ echo "$generation"
156
+ }
157
+
158
+ CASCADE_RESOLVED="[]"
159
+ echo "$CASCADE_CANDIDATES" | jq -c '.[]' | while IFS= read -r FINDING; do
160
+ FINDING_BODY=$(echo "$FINDING" | jq -r '.body')
161
+ GENERATION=$(compute_generation "$FINDING_BODY")
162
+ if [ "$MAX_GENERATION" = "unlimited" ] || [ "$GENERATION" -le "$MAX_GENERATION" ]; then
163
+ echo "$FINDING" | jq --argjson gen "$GENERATION" '{number, title, labels: [.labels[].name], milestone: .milestone.title, generation: $gen}'
164
+ fi
165
+ done
166
+ ```
167
+
168
+ **Two independent filters apply, in sequence**: first the T0-vs-backlog *time* filter (`CASCADE_SEARCH` above — which issues are even fetched), then the generation *depth* filter below (which of the fetched issues are admitted). They answer different questions — "how far back in time" vs. "how many cascade hops deep" — and either can be widened independently of the other.
169
+
170
+ **Generation > `max_generation` findings are excluded by default** by the loop above — a `review-finding` issue whose *source* chain is deeper than `orchestration.cascade.max_generation` (default: 1, i.e. generation ≥ 2 excluded — the pre-#2234 behavior, unchanged when the section is absent) is normally deferred permanently (`PERMANENT_DEFERRED`) by Step 4C's own absolute check. That cap is an **autonomy guard**, not a human-request guard: it exists to stop an unattended run from cascading forever, not to block an operator who explicitly asked for this exact bucket of work. See `phase-4-execution.md` Step 4C rule 1 and the reworded anti-pattern note for the full rationale.
171
+
172
+ This resolve step is a human-requested entry point (the operator typed `cascade`/`review-findings`/`findings` directly), so it honors explicit overrides for both filters:
173
+
174
+ - `--include-backlog` (forge#2628): widens the **time** filter — mode (b) above, whole open backlog regardless of `BATCH_T0`. Independent of the generation-depth flags below; combine both to get "everything, at every depth" (e.g. `cascade --include-backlog --allow-gen2`).
175
+ - `--include-deferred` or `--allow-gen2` appended to the input (e.g. `cascade --allow-gen2`, `review-findings --include-deferred`): widens the **depth** filter — forces `MAX_GENERATION="unlimited"` above, admitting every generation into the resolved set for this run. Without either flag, `orchestration.cascade.max_generation` (default 1) governs how deep the resolved set reaches — the flags/config only change what this **explicit** request is allowed to touch; they do not relax Step 4C's autonomous behavior for anything spawned *during* this run (see "Recursion safety" below).
176
+ - **`orchestration.cascade.max_generation`** (forge#2234) is the config-driven successor to the CLI depth flags above and supports a granularity the flags cannot: `max_generation: 3` admits generations 1-3 and stops at 4, expressing "admit gen-2, stop at gen-3" directly — something an all-or-nothing `--allow-gen2` flag could never say. The flags remain available and, when passed, override config with `unlimited` for that one invocation. There is no equivalent config-driven lever for the time filter — `--include-backlog` is the only way to widen it, deliberately, since a config default that silently re-adopts the whole backlog would reintroduce exactly the bug this section fixes.
177
+
178
+ **Recursion safety (unchanged)**: findings spawned *during* this run by its own sweep agents are still never re-swept, regardless of whether `--include-backlog`/`--include-deferred`/`--allow-gen2`/`orchestration.cascade.max_generation` was in effect at resolve time — see `phase-4-execution.md` Step 4C rules and the recursion-safety note near the anti-patterns list. The overrides above only widen what this one resolve step admits from the *pre-existing* open issue set; they have no effect on Step 4C's in-run admission logic.
179
+
180
+ ### Near-Miss Suggestion
181
+
182
+ <!-- Added: forge#2231 -->
183
+
184
+ When a bare `<slug>` (no keyword) resolves to **zero** issues via both the milestone lookup and the label-search fallback, do not silently return an empty set. Fetch the repo's label list and suggest the closest matching label(s) instead:
185
+
186
+ ```bash
187
+ ALL_LABELS=$(gh label list {GH_FLAG} --json name --jq '.[].name')
188
+ # Simple substring/prefix match against the attempted slug — no fuzzy-matching library required.
189
+ NEAR_MISS=$(echo "$ALL_LABELS" | grep -iE "${SLUG:0:4}" || true)
190
+ ```
191
+
192
+ Report back to the caller: `No milestone or label matched "{SLUG}". Did you mean: {NEAR_MISS list, or "review-finding"/"batch" if SLUG resembles "cascade"}?` This directly fixes the case reported in #2231 — `cascade` is not itself a label in most repos (the only cascade-adjacent labels are `review-finding` and `batch`), so a bare `cascade` slug previously resolved to nothing with no feedback. Note that `cascade`/`review-findings`/`findings` are now handled by the dedicated resolution rule above and never reach this bare-slug fallback path at all — this near-miss path remains for any other unmatched slug.
193
+
194
+ ### Predicate Persistence (literal set vs. standing query)
195
+
196
+ <!-- Added: forge#2236 -->
197
+
198
+ Phase 1 resolves `$ARGUMENTS` to a concrete issue-number list exactly once, at T0, and that list is currently frozen for the rest of the run (`phase-4-execution.md`: "Phase 1 only runs once, at the start."). Of the input patterns above, only `#1 #2 #3` / `1 2 3` (optionally repo-prefixed) is genuinely a one-time literal set — every other row (`milestone <slug>`, `next <N>`, `next <N> all-repos`, `fast-lane`, `priority:P0`/`priority:P1`, `mcp:fast`/`n8n:next 3`, a bare `<slug>`) is a **standing query**: the predicate ("all open P0s", "this milestone") is the caller's actual intent, not the specific numbers it happened to resolve to at T0.
199
+
200
+ Classify `$ARGUMENTS` and persist that classification alongside the resolved issue-number list, so Phase 4 (`phase-4-execution.md` Step 4B) can decide whether to re-run the query later in the batch:
201
+
202
+ ```bash
203
+ node -e '
204
+ import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ classifyInputPattern }) => {
205
+ console.log(JSON.stringify(classifyInputPattern(process.argv[1])));
206
+ }, () => process.exit(0));
207
+ ' "$ARGUMENTS"
208
+ ```
209
+
210
+ This mirrors `bin/engine/resolve.mjs`'s `classifyInputPattern` — a typed, unit-tested reference implementation of the same literal-vs-query rule table shown here (see that file's docstring). As with `admission.mjs` for cascade policy, the two must stay in sync by hand: any change to which patterns count as `literal` vs `query` in this table must be mirrored in `resolve.mjs`, and vice versa.
211
+
212
+ Persist the result (`kind`, `pattern`, `args`) next to the resolved issue-number list in the batch's in-memory/report state — e.g. `ORIGINATING_QUERY_KIND`, `ORIGINATING_QUERY_PATTERN`, `ORIGINATING_QUERY_ARGS`. Phase 4's re-resolution step (see `phase-4-execution.md` Step 4B) reads these three values; it never re-derives them from `$ARGUMENTS` a second time. If `kind` is `literal`, Phase 4 MUST NOT attempt to re-resolve — the list of numbers already resolved here IS the complete intent.
213
+
214
+ **`BATCH_T0` is persisted the same way** (forge#2628) — alongside these three values, not as a separate mechanism. Phase 4's Step 4A.pre reads the persisted `BATCH_T0` for Step 4C's run-spawned-cascade time filter (see that section); it must not capture a fresh "now" of its own, which would silently widen the admitted window past what this run actually spawned between Phase 1 and Phase 4's start.
25
215
 
26
216
  ### Fetch the issues
27
217
 
@@ -42,7 +232,11 @@ gh issue view {NUMBER} -R {SATELLITE_REPO} --json number,title,labels,state,mile
42
232
  gh issue list {GH_FLAG} --state open --limit {N} --json number,title,labels,milestone
43
233
  # For each satellite in forge.yaml → repos.satellites:
44
234
  gh issue list -R {SATELLITE_REPO} --state open --limit 500 --json number,title,labels,milestone
45
- # Combine, sort by priority, take top N
235
+ # Combine, sort by priority, take top N.
236
+ # Priority rank when sorting: read each issue's labels and normalize to bare P<n> form —
237
+ # `priority:P<n>` and bare `P<n>` both resolve to the same rank (see "Priority label schema"
238
+ # note under P3 batching below); an issue with neither label form sorts last, not first, and
239
+ # should be logged as untriaged rather than silently treated as top priority. <!-- Added: forge#2232 -->
46
240
  ```
47
241
 
48
242
  **Count validation**: After fetching issues, log the count returned. If a milestone query returns exactly 30 issues, warn the user: "WARNING: Exactly 30 issues returned — gh CLI default may have truncated results. Re-running with --limit 500 is recommended." (The --limit 500 above prevents this for standard runs, but verify if the count seems suspiciously low relative to milestone expectations.)
@@ -61,15 +255,107 @@ Remove issues that should NOT be worked on:
61
255
 
62
256
  If a `workflow:decomposed` issue is found, automatically expand it to its open sub-issues instead.
63
257
 
258
+ ### Source-PR Triage Hint (pre-flight, non-binding)
259
+
260
+ <!-- Added: forge#2351 -->
261
+
262
+ **This is a triage hint for the operator and the dispatched investigation agent — it is NEVER an automated verdict.** It must never cause an issue to be closed, skipped, or excluded from dispatch. See "CRITICAL: No duplicate detection at orchestrator level" below — that rule's spirit ("only a `/work-on` investigation agent, after reading the actual code, can determine validity") applies identically here: a closed-unmerged source PR is a reason to look closer, not a reason to skip looking at all.
263
+
264
+ **Why this exists**: `staging-review`/`review-finding` issues carry a `**Source**: PR #{N}` citation in their body (e.g. `**Source**: PR #2337 — Deploy: staging → main`, or `**Source**: PR #2337 | Agent: material-change | Confidence: ... | Severity: ...` — see `review-pr-staging.md` and `review-pr.md` for the citation format). When that source PR closed without merging, the finding's premise ("this change lands on staging/main") may or may not still hold — resolving the PR's state ONCE per distinct PR number, before dispatch, costs one `gh pr view` call and gives the operator and the investigation agent a head start, without ever deciding the outcome.
265
+
266
+ **Two counterexamples from the same 2026-07-17 run prove why this must stay a hint, never a skip:**
267
+ - **#2339 / #2342** — source PR #2337 closed unmerged; the flagged change genuinely never landed by any route (`git log -S` found zero commits for #2339's flagged line; the widening commit in #2342 is not an ancestor of `origin/main`). The hint would have been correct here.
268
+ - **#2346 / #2261 (the load-bearing counterexample)** — source PR #2337 also closed unmerged for #2346, but the flagged code reached `staging` anyway via a **different, independently-merged PR (#2261, commit `90376f5`)** — verified via `git merge-base --is-ancestor 90376f5 origin/staging`. #2346 correctly ended at `needs-human`, not `invalid`. A hint that had been treated as a verdict ("source PR never merged → close") would have been **factually wrong** here — sibling PRs can and do reintroduce or independently land the same code by a different route. **The rule to encode: "the source PR never merged" does NOT imply "the finding is moot."**
269
+
270
+ **Resolve once per distinct PR number, not once per issue** (many findings from one staging review cite the same source PR):
271
+
272
+ ```bash
273
+ # Fetch staging-review / review-finding issues from the already-resolved issue set.
274
+ # Follows this file's per-issue `gh issue view` convention (see Step 3A/3B in
275
+ # phase-3-dependency.md) rather than assuming a pre-materialized bulk JSON variable.
276
+ declare -A SOURCE_PR_STATE_CACHE # PR number -> "state|mergedAt" (cached, resolved once per PR)
277
+ declare -A ISSUE_SOURCE_PR # issue number -> cited PR number (or empty if no citation found)
278
+ declare -A ISSUE_SOURCE_PR_STATE # issue number -> "OPEN" | "MERGED" | "CLOSED_UNMERGED" | "unknown"
279
+ declare -A ISSUE_LIKELY_MOOT # issue number -> "yes" | "unknown"
280
+
281
+ for NUM in {issue_numbers}; do
282
+ ISSUE_JSON_NUM=$(gh issue view "$NUM" {GH_FLAG} --json labels,body 2>/dev/null)
283
+ LABELS_NUM=$(echo "$ISSUE_JSON_NUM" | jq -r '[.labels[].name] | join(",")' 2>/dev/null || echo "")
284
+ case "$LABELS_NUM" in
285
+ *staging-review*|*review-finding*) : ;;
286
+ *) ISSUE_LIKELY_MOOT[$NUM]="unknown"; continue ;;
287
+ esac
288
+
289
+ ISSUE_BODY_NUM=$(echo "$ISSUE_JSON_NUM" | jq -r '.body // ""' 2>/dev/null || echo "")
290
+
291
+ # Extract the cited PR number. Anchored on the literal "**Source**: PR #" prefix so it
292
+ # matches both observed formats ("PR #2337 — Deploy: staging → main" and
293
+ # "PR #2337 | Agent: material-change | ..."); capture stops at the first non-digit.
294
+ PRNUM=$(echo "$ISSUE_BODY_NUM" | grep -oE '\*\*Source\*\*: PR #[0-9]+' | head -1 | grep -oE '[0-9]+' | head -1)
295
+ # Digits-only guard (defense in depth — the regex above already restricts to [0-9]+,
296
+ # but re-validate before interpolating into any gh command; see #1833 — an unsanitized
297
+ # issue-body-derived value was templated verbatim into a gh command elsewhere in this file).
298
+ if ! echo "$PRNUM" | grep -qE '^[0-9]+$'; then
299
+ ISSUE_SOURCE_PR[$NUM]=""
300
+ ISSUE_SOURCE_PR_STATE[$NUM]="unknown"
301
+ ISSUE_LIKELY_MOOT[$NUM]="unknown"
302
+ continue
303
+ fi
304
+ ISSUE_SOURCE_PR[$NUM]="$PRNUM"
305
+
306
+ # Resolve this PR's state ONCE — cache hit skips the gh call entirely for every
307
+ # subsequent issue citing the same PR number.
308
+ if [ -z "${SOURCE_PR_STATE_CACHE[$PRNUM]+x}" ]; then
309
+ PR_JSON=$(gh pr view "$PRNUM" {GH_FLAG} --json state,mergedAt 2>/dev/null)
310
+ if [ -n "$PR_JSON" ]; then
311
+ PR_STATE=$(echo "$PR_JSON" | jq -r '.state')
312
+ PR_MERGED_AT=$(echo "$PR_JSON" | jq -r '.mergedAt // "null"')
313
+ SOURCE_PR_STATE_CACHE[$PRNUM]="${PR_STATE}|${PR_MERGED_AT}"
314
+ else
315
+ # gh pr view failed (rate limit, deleted PR, network) — non-fatal, mark unknown and continue.
316
+ SOURCE_PR_STATE_CACHE[$PRNUM]="unknown|unknown"
317
+ fi
318
+ fi
319
+
320
+ CACHED="${SOURCE_PR_STATE_CACHE[$PRNUM]}"
321
+ C_STATE="${CACHED%%|*}"
322
+ C_MERGED="${CACHED##*|}"
323
+
324
+ if [ "$C_STATE" = "unknown" ]; then
325
+ ISSUE_SOURCE_PR_STATE[$NUM]="unknown"
326
+ ISSUE_LIKELY_MOOT[$NUM]="unknown"
327
+ elif [ "$C_STATE" = "CLOSED" ] && [ "$C_MERGED" = "null" ]; then
328
+ ISSUE_SOURCE_PR_STATE[$NUM]="CLOSED_UNMERGED"
329
+ ISSUE_LIKELY_MOOT[$NUM]="yes"
330
+ elif [ "$C_STATE" = "MERGED" ] || { [ "$C_STATE" = "CLOSED" ] && [ "$C_MERGED" != "null" ]; }; then
331
+ ISSUE_SOURCE_PR_STATE[$NUM]="MERGED"
332
+ ISSUE_LIKELY_MOOT[$NUM]="unknown"
333
+ else
334
+ ISSUE_SOURCE_PR_STATE[$NUM]="OPEN"
335
+ ISSUE_LIKELY_MOOT[$NUM]="unknown"
336
+ fi
337
+ done
338
+ ```
339
+
340
+ **Output arrays** (consumed downstream — never re-derived): `ISSUE_LIKELY_MOOT[$NUM]` (`yes`/`unknown`), `ISSUE_SOURCE_PR[$NUM]` (cited PR number or empty), `ISSUE_SOURCE_PR_STATE[$NUM]` (`OPEN`/`MERGED`/`CLOSED_UNMERGED`/`unknown`).
341
+
342
+ - `commands/orchestrate/phase-3-dependency.md` Step 3E's Dependency Graph plan table dereferences `ISSUE_LIKELY_MOOT[$NUM]` as a `Source-PR Hint` column — informational only, never affecting a row's `Status`.
343
+ - `commands/orchestrate/phase-4-execution.md`'s Agent-spawn template threads `ISSUE_LIKELY_MOOT[$NUM]` / `ISSUE_SOURCE_PR[$NUM]` / `ISSUE_SOURCE_PR_STATE[$NUM]` into the dispatched agent's initial context as `{SOURCE_PR_HINT_CONTEXT}`, explicitly framed as a starting point to check first — never as a reason to skip investigating.
344
+
345
+ **MUST NOT**: This hint MUST NOT be used, anywhere in this pipeline, to auto-close an issue, auto-skip it from dispatch, exclude it from the resolved issue set, or substitute for the investigation agent's own evidence-based verdict. Every `likely-moot: yes` issue is still dispatched into a full `/work-on` pipeline exactly like every other issue in the resolved set — the "CRITICAL: No duplicate detection at orchestrator level" rule below governs this identically.
346
+
64
347
  ### P3 Review-Finding Batching (deterministic grouping rule)
65
348
 
66
349
  <!-- Added: forge#1333 -->
67
350
  <!-- Extended: forge#1818 — default-batchable eligibility, surface-area grouping, lowered same-file threshold -->
351
+ <!-- Extended: forge#2232 — priority-label schema tolerance (bare P<n> vs priority:P<n>) -->
68
352
 
69
353
  Before finalizing the issue set, apply the P3 batching rule to reduce full-pipeline overhead on low-severity review findings.
70
354
 
355
+ **Priority label schema**: ForgeDock's own issue creator (`review-pr.md`) writes only the canonical `priority:P<n>` label form. Some repos this pipeline operates against (issues opened externally, imported, or predating ForgeDock adoption) instead carry a bare `P<n>` label with no `priority:` prefix. Every priority-read check in this section — and its mirrors in `phase-4-execution.md` and `cleanup.md` — MUST accept both forms. `priority:P<n>` wins when (unusually) both are present on the same issue. Issue-*creation* call sites (the batch-issue creation below) are unaffected and keep writing canonical `priority:P<n>` only — there is no case where this pipeline needs to *write* the bare form. <!-- Added: forge#2232 -->
356
+
71
357
  **Trigger conditions** (default-batchable — no opt-in marker required):
72
- 1. The issue has labels `review-finding` + `priority:P3`
358
+ 1. The issue has label `review-finding` + a P3 priority label (`priority:P3` or bare `P3`)
73
359
  2. The issue does NOT match any Safety exclusion below
74
360
 
75
361
  The `<!-- FORGE:BATCHABLE -->` marker (still appended by `review-pr.md` at finding-creation time) is honored when present but is no longer REQUIRED for eligibility — a `review-finding`+`priority:P3` issue is batchable by default unless explicitly excluded. This closes the gap where cascade-spawned findings that never carried the marker sat un-batched indefinitely. <!-- Added: forge#1818 -->
@@ -82,14 +368,49 @@ These exclusions apply regardless of priority: P1/P2 findings are already never
82
368
 
83
369
  **Grouping algorithm (surface area — same file first, leaf directory as broader fallback):** <!-- Changed: forge#1818 — was domain-only -->
84
370
  ```bash
85
- # Fetch all open batchable P3 issues (default-batchable; marker no longer required)
371
+ # Fetch all open batchable P3 issues (default-batchable; marker no longer required).
372
+ # NOTE: `--label` is an exact-match GH filter and cannot OR "priority:P3" with bare "P3" in
373
+ # one query, so the P3 test moves into the jq predicate below (schema-tolerant, forge#2232).
374
+ # Only "review-finding" stays in the --label filter.
375
+ # Safety-exclusion keyword alternation, shared verbatim across all three
376
+ # mirrored sites (this file, phase-4-execution.md, cleanup.md) — see forge#2423.
377
+ # Word-boundary anchored so it matches whole terms only, not substrings:
378
+ # `authority_source`/`authoritative`/`author`/`authored` no longer trip `auth`.
379
+ # `authentication|authorization|authn|authz` are listed explicitly so real
380
+ # auth-domain findings that never use the bare word "auth" still exclude.
86
381
  BATCHABLE_P3=$(gh issue list {GH_FLAG} \
87
382
  --state open \
88
- --label "review-finding,priority:P3" \
383
+ --label "review-finding" \
89
384
  --limit 500 \
90
385
  --json number,title,body,labels \
91
- --jq '.[] | select((.title | test("security|billing|anti-bot|auth"; "i")) | not)
92
- | select(.body | test("## Problem[\\s\\S]{0,500}(security|billing|anti-bot|auth)"; "i") | not)
386
+ --jq '.[] | select([.labels[].name] | any(test("^(priority:)?P3$")))
387
+ | select((.title | test("\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i")) | not)
388
+ # Strip the review-finding template's attribution boilerplate
389
+ # (**Confidence**/**Severity**/**Review comment** — see forge#2477
390
+ # note below for why **Source**/**Agent** are deliberately excluded
391
+ # from this list; see review-pr.md L1691-1719 for the full template)
392
+ # before scanning the body — otherwise a finding is excluded because
393
+ # of who reviewed it (e.g. "**Agent**: Security (...)") rather than
394
+ # what it is actually about. <!-- forge#2423 -->
395
+ # Each alternative is anchored to the field's real generator-output shape
396
+ # (enum for Confidence/Severity, URL for Review comment) rather than a
397
+ # bare label-prefix + `.*$` — matching on label shape alone lets
398
+ # attacker-controlled body text on one of these lines get stripped
399
+ # along with the label, smuggling banned keywords past the scan below.
400
+ # Source/Agent are deliberately NOT stripped: both hold genuinely
401
+ # free-text generator output (a PR title; an agent's self-description)
402
+ # with no fixed vocabulary, so no shape bound can distinguish
403
+ # legitimate attribution from attacker-authored payload placed in the
404
+ # same position — a length-bounded free-text alternative for either
405
+ # field re-opens the exact smuggling gap this fix closes (an attacker
406
+ # need only prefix their payload with a fake "PR #N — " or agent name
407
+ # to satisfy the bound). Leaving them unstripped trades a narrow,
408
+ # already-known false-positive (forge#2423's Agent-line case; a P3
409
+ # finding whose Source/Agent text happens to mention a domain keyword
410
+ # is not auto-batched) for closing a real bypass — the safe direction
411
+ # for a security-relevant exclusion. <!-- forge#2477 -->
412
+ | (.body | gsub("(?m)^\\*\\*(?:Confidence\\*\\*: (?:CONFIRMED|LIKELY|POSSIBLE)|Severity\\*\\*: (?:CRITICAL|HIGH|MEDIUM|LOW|INFO)|Review comment\\*\\*: https?://\\S+)$"; "")) as $stripped_body
413
+ | select($stripped_body | test("## Problem[\\s\\S]{0,500}\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i") | not)
93
414
  | select(([.labels[].name] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth")) | not)')
94
415
 
95
416
  # Surface area = the exact affected file path listed first under "## Affected Files" (primary grouping key).
@@ -98,6 +419,13 @@ BATCHABLE_P3=$(gh issue list {GH_FLAG} \
98
419
  # "web/src/app/billing/page.tsx" → EXCLUDED (billing path)
99
420
  # "commands/orchestrate/phase-1-resolve.md" → file "commands/orchestrate/phase-1-resolve.md", leaf-dir "commands/orchestrate"
100
421
  # "commands/review-pr.md" → file "commands/review-pr.md", leaf-dir "commands"
422
+
423
+ # Safety-exclusion worked examples (forge#2423 — both false-positive classes
424
+ # fixed, true-positive case still excluded):
425
+ # Title "authority_source docstring fix" → NOT excluded (was: excluded — bare-substring "auth" match)
426
+ # Body: "...**Agent**: Security (General Security & Quality Scan)..." with a
427
+ # ## Problem about a stale docstring count → NOT excluded (was: excluded — reviewer's own name matched "security")
428
+ # Title "fix auth bypass in login flow" → still excluded (genuine auth finding — true positive preserved)
101
429
  ```
102
430
 
103
431
  **Batch creation rule (two-tier threshold):** <!-- Changed: forge#1818 — added lower same-file tier -->
@@ -109,8 +437,11 @@ BATCHABLE_P3=$(gh issue list {GH_FLAG} \
109
437
 
110
438
  **Route batch issue creation through `/issue`'s programmatic invocation contract** (`commands/issue.md`, added #2085) instead of calling `gh issue create` directly — this gets dedup (Phase 2D) and mandatory-section body validation (Phase 3F) for free. <!-- Changed: forge#2086 — route through /issue create-hook -->
111
439
 
440
+ **Exclude the cluster's own member issues from the dedup candidate set (MANDATORY):** A batch title necessarily restates its member findings' subject matter by construction — it is a deliberate supersede, not an accidental duplicate — so without exclusion it always collides with its own members under Phase 2D's token-overlap algorithm, making the batching rule unfollowable. Pass the cluster's member issue numbers via `/issue`'s `--exclude` flag (`scripts/issue-dedup.sh --exclude`, forge#2432) so Phase 2D only fires on a **genuine non-member duplicate** — some other open issue this batch was never meant to cover. This is NOT a `--force` equivalent: it narrows the candidate set, it does not disable the gate. <!-- Added: forge#2432 -->
441
+
112
442
  ```bash
113
443
  SAFE_SURFACE_AREA=$(printf '%s' "{SURFACE_AREA}" | tr -cd 'A-Za-z0-9._/-')
444
+ MEMBER_LIST="{N1},{N2},{N3},..." # comma-joined member issue numbers for this cluster (e.g. "2422,2424")
114
445
  BATCH_BODY_FILE="$(mktemp)"
115
446
  cat > "$BATCH_BODY_FILE" <<'BATCH_EOF'
116
447
  ## Problem
@@ -139,7 +470,7 @@ BATCH_EOF
139
470
  ```
140
471
 
141
472
  ```
142
- ISSUE_SKILL_OUTPUT=$(Skill(skill="issue", args="--title \"fix(batch): P3 review findings — ${SAFE_SURFACE_AREA} (batch #{BATCH_N})\" --body-file \"${BATCH_BODY_FILE}\" --label \"review-finding\" --label \"priority:P3\" --label \"batch\""))
473
+ ISSUE_SKILL_OUTPUT=$(Skill(skill="issue", args="--title \"fix(batch): P3 review findings — ${SAFE_SURFACE_AREA} (batch #{BATCH_N})\" --body-file \"${BATCH_BODY_FILE}\" --label \"review-finding\" --label \"priority:P3\" --label \"batch\" --exclude \"${MEMBER_LIST}\""))
143
474
  ```
144
475
 
145
476
  **Extract the created batch issue number from the Skill output** (see `commands/issue.md` Phase 4C/4E — it echoes `Created: {url}` and reports `**#{NUMBER}**: {title}`):
@@ -149,7 +480,13 @@ BATCH_ISSUE_NUM=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE 'issues/[0-9]+' | head -
149
480
  [ -z "$BATCH_ISSUE_NUM" ] && BATCH_ISSUE_NUM=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE '\*\*#[0-9]+\*\*' | head -1 | grep -oE '[0-9]+')
150
481
 
151
482
  if [ -z "$BATCH_ISSUE_NUM" ]; then
152
- echo "WARNING: /issue did not report a created batch issue number — likely a Phase 2D dedup STOP (near-duplicate found) or a usage error. Do not replace member issues with a batch issue for this cluster; leave the members on the standard individual pipeline instead."
483
+ # With --exclude "${MEMBER_LIST}" passed above, Phase 2D no longer fires on the
484
+ # cluster's own declared members — a STOP reaching this point means a GENUINE
485
+ # non-member duplicate was found (some other open issue already covers this
486
+ # exact surface area), or a usage error. Do NOT replace member issues with a
487
+ # batch issue for this cluster; leave the members on the standard individual
488
+ # pipeline instead. <!-- Reworded: forge#2432 -->
489
+ echo "WARNING: /issue did not report a created batch issue number — likely a Phase 2D dedup STOP against a non-member issue (a real duplicate — member exclusion via --exclude \"${MEMBER_LIST}\" is already applied above, so this is not a false positive against the cluster's own members) or a usage error. Do not replace member issues with a batch issue for this cluster; leave the members on the standard individual pipeline instead."
153
490
  fi
154
491
  ```
155
492