forgedock 1.3.0 → 1.4.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 (45) 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/state.mjs +20 -28
  9. package/bin/engine-cli.mjs +80 -5
  10. package/bin/engine.mjs +390 -18
  11. package/bin/forgedock.mjs +336 -59
  12. package/bin/hooks/interactive-engine.mjs +50 -24
  13. package/bin/hooks/pre-tool-use.mjs +169 -13
  14. package/bin/journey.mjs +8 -2
  15. package/bin/labels.json +5 -0
  16. package/bin/runner.mjs +321 -10
  17. package/bin/watch-utils.mjs +255 -0
  18. package/commands/cleanup.md +7 -3
  19. package/commands/orchestrate/config.md +41 -1
  20. package/commands/orchestrate/phase-1-resolve.md +234 -8
  21. package/commands/orchestrate/phase-3-dependency.md +27 -18
  22. package/commands/orchestrate/phase-4-execution.md +221 -22
  23. package/commands/quality-gate.md +6 -6
  24. package/commands/review-pr.md +23 -7
  25. package/commands/upgrade-deps.md +7 -2
  26. package/commands/work-on/decompose.md +2 -0
  27. package/commands/work-on/investigate.md +22 -4
  28. package/commands/work-on/remediate.md +2 -0
  29. package/commands/work-on/review.md +40 -1
  30. package/commands/work-on.md +19 -4
  31. package/docs/CONFIG.md +120 -0
  32. package/docs/spec/eval-run-result.md +8 -4
  33. package/docs/spec/label-state-machine.md +9 -0
  34. package/package.json +2 -2
  35. package/packages/protocol/src/emit.js +5 -22
  36. package/packages/protocol/src/html-comment-escape.js +68 -0
  37. package/packages/protocol/src/index.js +1 -0
  38. package/packages/protocol/src/parse.js +5 -6
  39. package/packages/protocol/src/phases.js +105 -0
  40. package/packages/protocol/src/types.js +17 -0
  41. package/scripts/check-admission-parity.mjs +167 -0
  42. package/scripts/check-command-side-effects.sh +181 -66
  43. package/scripts/check-phase-registry-drift.mjs +96 -0
  44. package/scripts/check-spec-markers.sh +1 -0
  45. package/scripts/forge-run.sh +35 -4
@@ -381,7 +381,7 @@ Do NOT call `gh api ... -X PATCH -f state=closed` on any milestone. This phase i
381
381
 
382
382
  ### Step 4B.1: Fetch open batchable P3 findings
383
383
 
384
- Eligibility is **default-batchable**: any open `review-finding` + `priority:P3` issue qualifies unless excluded below. The `<!-- FORGE:BATCHABLE -->` marker (still appended by `review-pr.md` at finding-creation time) is honored when present but is no longer required — matching `phase-1-resolve.md`'s eligibility rule. <!-- Changed: forge#1828 — marker was previously mandatory -->
384
+ Eligibility is **default-batchable**: any open `review-finding` + a P3 priority label (`priority:P3` or bare `P3` — see `phase-1-resolve.md`'s "Priority label schema" note; some consumer repos label externally/legacy findings with the bare form) issue qualifies unless excluded below. The `<!-- FORGE:BATCHABLE -->` marker (still appended by `review-pr.md` at finding-creation time) is honored when present but is no longer required — matching `phase-1-resolve.md`'s eligibility rule. <!-- Changed: forge#1828 — marker was previously mandatory; forge#2232 — priority-schema tolerance -->
385
385
 
386
386
  ```bash
387
387
  # Find all open, unbatched P3 review findings. Excludes findings already
@@ -389,12 +389,16 @@ Eligibility is **default-batchable**: any open `review-finding` + `priority:P3`
389
389
  # exclusions (security, billing, anti-bot, auth) as the other two batching
390
390
  # sites, using the identical jq test() (Oniguruma) patterns so classification
391
391
  # cannot diverge between the three sweep locations. <!-- Changed: forge#1828 -->
392
+ # NOTE: "priority:P3" is intentionally NOT in the --label filter — GH label filters are
393
+ # exact-match and can't OR it with bare "P3", so the P3 test moves into the jq predicate
394
+ # below (schema-tolerant, mirrors phase-1-resolve.md exactly). <!-- Added: forge#2232 -->
392
395
  UNBATCHED_P3=$(gh issue list {GH_FLAG} \
393
396
  --state open \
394
- --label "review-finding,priority:P3" \
397
+ --label "review-finding" \
395
398
  --limit 500 \
396
399
  --json number,title,body,labels,createdAt \
397
- --jq '.[] | select(([.labels[].name] | any(. == "batch")) | not)
400
+ --jq '.[] | select([.labels[].name] | any(test("^(priority:)?P3$")))
401
+ | select(([.labels[].name] | any(. == "batch")) | not)
398
402
  | select((.title | test("security|billing|anti-bot|auth"; "i")) | not)
399
403
  | select(.body | test("## Problem[\\s\\S]{0,500}(security|billing|anti-bot|auth)"; "i") | not)
400
404
  | select(([.labels[].name] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth")) | not)')
@@ -54,9 +54,49 @@ DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
54
54
  # renders full tables once at the end regardless of this value.
55
55
  NARRATION_MODE=$(yq '.pipeline.narration // "terse"' "$CONFIG_FILE" 2>/dev/null || echo "terse")
56
56
  [ "$NARRATION_MODE" = "null" ] && NARRATION_MODE="terse"
57
+
58
+ # Cascade admission policy (forge#2234) — CLI flags take precedence over forge.yaml, which
59
+ # takes precedence over the "balanced" default. Mirrors the --model <name> precedence
60
+ # pattern below. See docs/CONFIG.md → `orchestration` for the full field reference and
61
+ # `phase-4-execution.md` / `phase-1-resolve.md` for where each resolved value is consumed.
62
+ # Extraction regex is an explicit character allowlist (`[A-Za-z0-9_-]+`), NOT a bare
63
+ # `\S+` — `\S+` would accept `"`, `` ` ``, `$`, `(`, `)`, which a downstream yq fallback
64
+ # embedding (`.orchestration.cascade.policy // "${CLI_CASCADE_POLICY:-balanced}"`) could
65
+ # not safely absorb: an embedded `"` breaks out of the yq string literal, letting the
66
+ # remainder be parsed as yq expression syntax (yq-expression injection — not shell/command
67
+ # injection, since `awk` here only extracts text, never executes it). The allowlist also
68
+ # matches the format every valid value actually takes: policy names are
69
+ # `all`/`balanced`/`conservative`; max-generation is a positive integer or `unlimited`;
70
+ # token-budget is a positive integer or `unlimited` — none of these ever contain anything
71
+ # outside `[A-Za-z0-9_-]`. (forge#2301)
72
+ echo "{ARGUMENTS}" | grep -qE -- '--policy[[:space:]]+[A-Za-z0-9_-]+' && \
73
+ CLI_CASCADE_POLICY=$(echo "{ARGUMENTS}" | grep -oE -- '--policy[[:space:]]+[A-Za-z0-9_-]+' | awk '{print $2}')
74
+ echo "{ARGUMENTS}" | grep -qE -- '--max-generation[[:space:]]+[A-Za-z0-9_-]+' && \
75
+ CLI_MAX_GENERATION=$(echo "{ARGUMENTS}" | grep -oE -- '--max-generation[[:space:]]+[A-Za-z0-9_-]+' | awk '{print $2}')
76
+ echo "{ARGUMENTS}" | grep -qE -- '--token-budget[[:space:]]+[A-Za-z0-9_-]+' && \
77
+ CLI_TOKEN_BUDGET=$(echo "{ARGUMENTS}" | grep -oE -- '--token-budget[[:space:]]+[A-Za-z0-9_-]+' | awk '{print $2}')
78
+ # CLI_CASCADE_POLICY / CLI_MAX_GENERATION / CLI_TOKEN_BUDGET, when set, override the
79
+ # corresponding forge.yaml → orchestration.cascade.* key for this invocation only — pass
80
+ # them through as the yq `//` fallback value at each Step 4A.pre / Phase 1 resolution site
81
+ # (`... // "${CLI_CASCADE_POLICY:-balanced}"`, etc.) instead of the bare literal default.
82
+ # Even with the allowlist above, the final resolved value is STILL validated against the
83
+ # same regex (`^[1-9][0-9]*$|^unlimited$` for the two numeric levers,
84
+ # `^(all|balanced|conservative)$` for policy) at its consuming site in
85
+ # phase-4-execution.md / phase-1-resolve.md before use — this allowlist is defense in
86
+ # depth, not a replacement for that validation.
57
87
  ```
58
88
 
59
- All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{PROJECT_NAME}`, `{STAGING_BRANCH}`, `{DEFAULT_BRANCH}`, and `{NARRATION_MODE}` references below are populated from `forge.yaml`.
89
+ All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{PROJECT_NAME}`, `{STAGING_BRANCH}`, `{DEFAULT_BRANCH}`, `{NARRATION_MODE}`, and `{CLI_CASCADE_POLICY}`/`{CLI_MAX_GENERATION}`/`{CLI_TOKEN_BUDGET}` references below are populated from `forge.yaml` and CLI flags.
90
+
91
+ ### Cascade Policy CLI Flags
92
+
93
+ | Flag | Overrides | Example |
94
+ |------|-----------|---------|
95
+ | `--policy <all\|balanced\|conservative>` | `orchestration.cascade.policy` | `/orchestrate cascade --policy all` |
96
+ | `--max-generation <N\|unlimited>` | `orchestration.cascade.max_generation` | `/orchestrate cascade --max-generation 3` |
97
+ | `--token-budget <N\|unlimited>` | `orchestration.cascade.token_budget` | `/orchestrate cascade --token-budget unlimited` |
98
+
99
+ Precedence: CLI flag > `forge.yaml → orchestration.cascade.*` > preset default (`balanced`). These flags are additive with, not a replacement for, the pre-existing `--include-deferred`/`--allow-gen2` flags documented in `phase-1-resolve.md` — those remain the fastest way to say "everything, right now," while `--max-generation`/`--policy` express finer-grained policy (e.g. "admit gen-2, stop at gen-3").
60
100
 
61
101
  ---
62
102
 
@@ -16,12 +16,138 @@ Parse `$ARGUMENTS` to determine which issues to work on:
16
16
  |-------|------------|
17
17
  | `milestone <slug>` | All open issues assigned to that GitHub milestone (default repo) |
18
18
  | `#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.) |
19
+ | `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
20
  | `next <N> all-repos` | Top N across ALL ecosystem repos |
21
21
  | `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 |
22
+ | `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
23
  | `mcp:fast` or `n8n:next 3` | Repo-scoped queries |
24
- | `<slug>` (no keyword) | Try milestone first, then fall back to label search |
24
+ | `cascade`, `review-findings`, or `findings` (optionally `--include-deferred` / `--allow-gen2`) | All open `review-finding` issues (default repo, or repo-scoped e.g. `mcp:cascade`). See "Cascade / Review-Finding Resolution" below — by default this admits up to `orchestration.cascade.max_generation` (default 1, i.e. generation ≥ 2 `PERMANENT_DEFERRED` findings excluded); the CLI flags force `unlimited` for this one run, overriding config. <!-- Added: forge#2231, forge#2234 -->|
25
+ | `<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 -->|
26
+
27
+ ### Cascade / Review-Finding Resolution
28
+
29
+ <!-- Added: forge#2231 -->
30
+
31
+ 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:
32
+
33
+ ```bash
34
+ # Fetch all open review-finding issues, including body (needed for the generation check below —
35
+ # unlike a plain label search, we cannot skip straight to {number,title,labels,milestone} here).
36
+ CASCADE_CANDIDATES=$(gh issue list {GH_FLAG} --label "review-finding" --state open --limit 500 \
37
+ --json number,title,labels,milestone,body)
38
+
39
+ # --- Cascade admission policy resolution (forge#2234) ---
40
+ # orchestration.cascade.max_generation is the config-driven, granular successor to the
41
+ # pre-#2234 --include-deferred/--allow-gen2 flags: instead of an all-or-nothing "admit
42
+ # every deferred generation," an operator can say "admit up to generation 3" and no
43
+ # further. Preset table mirrors phase-4-execution.md's Step 4A.pre resolution exactly
44
+ # (see bin/engine/admission.mjs for the typed, unit-tested reference implementation).
45
+ CASCADE_POLICY_NAME=$(yq '.orchestration.cascade.policy // "balanced"' forge.yaml 2>/dev/null || echo "balanced")
46
+ [ "$CASCADE_POLICY_NAME" = "null" ] && CASCADE_POLICY_NAME="balanced"
47
+ case "$CASCADE_POLICY_NAME" in
48
+ all) PRESET_MAX_GEN="unlimited" ;;
49
+ conservative|balanced) PRESET_MAX_GEN=1 ;;
50
+ *) echo "WARNING: forge.yaml → orchestration.cascade.policy \"${CASCADE_POLICY_NAME}\" is not one of: all, balanced, conservative — falling back to \"balanced\""
51
+ CASCADE_POLICY_NAME="balanced"; PRESET_MAX_GEN=1 ;;
52
+ esac
53
+ MAX_GENERATION=$(yq ".orchestration.cascade.max_generation // \"${PRESET_MAX_GEN}\"" forge.yaml 2>/dev/null || echo "$PRESET_MAX_GEN")
54
+ [ "$MAX_GENERATION" = "null" ] && MAX_GENERATION="$PRESET_MAX_GEN"
55
+ if [ "$MAX_GENERATION" != "unlimited" ] && ! echo "$MAX_GENERATION" | grep -qE '^[1-9][0-9]*$'; then
56
+ echo "WARNING: forge.yaml → orchestration.cascade.max_generation is not a positive integer or \"unlimited\" (\"${MAX_GENERATION}\") — falling back to default ${PRESET_MAX_GEN}"
57
+ MAX_GENERATION="$PRESET_MAX_GEN"
58
+ fi
59
+
60
+ # Pre-#2234 CLI override — still honored for this resolve step specifically. Either flag
61
+ # forces MAX_GENERATION to "unlimited" for this run only, taking precedence over both the
62
+ # preset and any explicit orchestration.cascade.max_generation value (a human typing
63
+ # --allow-gen2 at the CLI is asking for everything, right now, overriding config).
64
+ echo "{ARGUMENTS}" | grep -qE -- '--include-deferred|--allow-gen2' && MAX_GENERATION="unlimited"
65
+
66
+ echo "Cascade resolve: policy=${CASCADE_POLICY_NAME} max_generation=${MAX_GENERATION} (forge.yaml → orchestration.cascade.max_generation; --include-deferred/--allow-gen2 forces unlimited)"
67
+ # --- End cascade admission policy resolution ---
68
+
69
+ # Generation depth — walks the "spawned from issue #N" / "source issue #N" chain up to
70
+ # MAX_HOPS times, counting how many review-finding-labeled ancestors precede this finding.
71
+ # Generation 1 = not spawned from a review-finding (an original issue). Generation 2 = spawned
72
+ # from a review-finding. Generation 3 = spawned from a finding that was itself spawned from a
73
+ # review-finding. This numeric walk (rather than the pre-#2234 single-hop boolean) is what
74
+ # makes "admit gen-2, stop at gen-3" (max_generation: 3) expressible — a binary flag cannot
75
+ # say that; it can only say "admit everything deferred" or "admit nothing deferred." Bounded
76
+ # at MAX_HOPS to guard against a malformed/cyclic reference chain.
77
+ MAX_HOPS=10
78
+ compute_generation() {
79
+ local body="$1"
80
+ local generation=1
81
+ local hops=0
82
+ while [ "$hops" -lt "$MAX_HOPS" ]; do
83
+ # Portable two-step extraction (grep -E, not -P/PCRE — macOS BSD grep lacks \K support):
84
+ # first match the whole "spawned from issue #N" / "source issue: #N" phrase, then pull
85
+ # the trailing digits off that match.
86
+ local source_num
87
+ source_num=$(echo "$body" | grep -ioE 'spawned from issue #[0-9]+|source issue[: #]+[0-9]+' | head -1 | grep -oE '[0-9]+$')
88
+ [ -z "$source_num" ] && break
89
+ local source_data
90
+ source_data=$(gh issue view "$source_num" {GH_FLAG} --json labels,body 2>/dev/null) || break
91
+ echo "$source_data" | jq -e '[.labels[].name] | index("review-finding")' >/dev/null 2>&1 || break
92
+ generation=$((generation + 1))
93
+ body=$(echo "$source_data" | jq -r '.body')
94
+ hops=$((hops + 1))
95
+ done
96
+ echo "$generation"
97
+ }
98
+
99
+ CASCADE_RESOLVED="[]"
100
+ echo "$CASCADE_CANDIDATES" | jq -c '.[]' | while IFS= read -r FINDING; do
101
+ FINDING_BODY=$(echo "$FINDING" | jq -r '.body')
102
+ GENERATION=$(compute_generation "$FINDING_BODY")
103
+ if [ "$MAX_GENERATION" = "unlimited" ] || [ "$GENERATION" -le "$MAX_GENERATION" ]; then
104
+ echo "$FINDING" | jq --argjson gen "$GENERATION" '{number, title, labels: [.labels[].name], milestone: .milestone.title, generation: $gen}'
105
+ fi
106
+ done
107
+ ```
108
+
109
+ **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.
110
+
111
+ This resolve step is a human-requested entry point (the operator typed `cascade`/`review-findings`/`findings` directly), so it honors an explicit override:
112
+
113
+ - `--include-deferred` or `--allow-gen2` appended to the input (e.g. `cascade --allow-gen2`, `review-findings --include-deferred`): 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).
114
+ - **`orchestration.cascade.max_generation`** (forge#2234) is the config-driven successor to the CLI 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.
115
+
116
+ **Recursion safety (unchanged)**: findings spawned *during* this run by its own sweep agents are still never re-swept, regardless of whether `--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 override above only widens what this one resolve step admits from the *pre-existing* open issue set; it has no effect on Step 4C's in-run admission logic.
117
+
118
+ ### Near-Miss Suggestion
119
+
120
+ <!-- Added: forge#2231 -->
121
+
122
+ 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:
123
+
124
+ ```bash
125
+ ALL_LABELS=$(gh label list {GH_FLAG} --json name --jq '.[].name')
126
+ # Simple substring/prefix match against the attempted slug — no fuzzy-matching library required.
127
+ NEAR_MISS=$(echo "$ALL_LABELS" | grep -iE "${SLUG:0:4}" || true)
128
+ ```
129
+
130
+ 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.
131
+
132
+ ### Predicate Persistence (literal set vs. standing query)
133
+
134
+ <!-- Added: forge#2236 -->
135
+
136
+ 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.
137
+
138
+ 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:
139
+
140
+ ```bash
141
+ node -e '
142
+ import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ classifyInputPattern }) => {
143
+ console.log(JSON.stringify(classifyInputPattern(process.argv[1])));
144
+ }, () => process.exit(0));
145
+ ' "$ARGUMENTS"
146
+ ```
147
+
148
+ 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.
149
+
150
+ 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.
25
151
 
26
152
  ### Fetch the issues
27
153
 
@@ -42,7 +168,11 @@ gh issue view {NUMBER} -R {SATELLITE_REPO} --json number,title,labels,state,mile
42
168
  gh issue list {GH_FLAG} --state open --limit {N} --json number,title,labels,milestone
43
169
  # For each satellite in forge.yaml → repos.satellites:
44
170
  gh issue list -R {SATELLITE_REPO} --state open --limit 500 --json number,title,labels,milestone
45
- # Combine, sort by priority, take top N
171
+ # Combine, sort by priority, take top N.
172
+ # Priority rank when sorting: read each issue's labels and normalize to bare P<n> form —
173
+ # `priority:P<n>` and bare `P<n>` both resolve to the same rank (see "Priority label schema"
174
+ # note under P3 batching below); an issue with neither label form sorts last, not first, and
175
+ # should be logged as untriaged rather than silently treated as top priority. <!-- Added: forge#2232 -->
46
176
  ```
47
177
 
48
178
  **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 +191,107 @@ Remove issues that should NOT be worked on:
61
191
 
62
192
  If a `workflow:decomposed` issue is found, automatically expand it to its open sub-issues instead.
63
193
 
194
+ ### Source-PR Triage Hint (pre-flight, non-binding)
195
+
196
+ <!-- Added: forge#2351 -->
197
+
198
+ **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.
199
+
200
+ **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.
201
+
202
+ **Two counterexamples from the same 2026-07-17 run prove why this must stay a hint, never a skip:**
203
+ - **#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.
204
+ - **#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."**
205
+
206
+ **Resolve once per distinct PR number, not once per issue** (many findings from one staging review cite the same source PR):
207
+
208
+ ```bash
209
+ # Fetch staging-review / review-finding issues from the already-resolved issue set.
210
+ # Follows this file's per-issue `gh issue view` convention (see Step 3A/3B in
211
+ # phase-3-dependency.md) rather than assuming a pre-materialized bulk JSON variable.
212
+ declare -A SOURCE_PR_STATE_CACHE # PR number -> "state|mergedAt" (cached, resolved once per PR)
213
+ declare -A ISSUE_SOURCE_PR # issue number -> cited PR number (or empty if no citation found)
214
+ declare -A ISSUE_SOURCE_PR_STATE # issue number -> "OPEN" | "MERGED" | "CLOSED_UNMERGED" | "unknown"
215
+ declare -A ISSUE_LIKELY_MOOT # issue number -> "yes" | "unknown"
216
+
217
+ for NUM in {issue_numbers}; do
218
+ ISSUE_JSON_NUM=$(gh issue view "$NUM" {GH_FLAG} --json labels,body 2>/dev/null)
219
+ LABELS_NUM=$(echo "$ISSUE_JSON_NUM" | jq -r '[.labels[].name] | join(",")' 2>/dev/null || echo "")
220
+ case "$LABELS_NUM" in
221
+ *staging-review*|*review-finding*) : ;;
222
+ *) ISSUE_LIKELY_MOOT[$NUM]="unknown"; continue ;;
223
+ esac
224
+
225
+ ISSUE_BODY_NUM=$(echo "$ISSUE_JSON_NUM" | jq -r '.body // ""' 2>/dev/null || echo "")
226
+
227
+ # Extract the cited PR number. Anchored on the literal "**Source**: PR #" prefix so it
228
+ # matches both observed formats ("PR #2337 — Deploy: staging → main" and
229
+ # "PR #2337 | Agent: material-change | ..."); capture stops at the first non-digit.
230
+ PRNUM=$(echo "$ISSUE_BODY_NUM" | grep -oE '\*\*Source\*\*: PR #[0-9]+' | head -1 | grep -oE '[0-9]+' | head -1)
231
+ # Digits-only guard (defense in depth — the regex above already restricts to [0-9]+,
232
+ # but re-validate before interpolating into any gh command; see #1833 — an unsanitized
233
+ # issue-body-derived value was templated verbatim into a gh command elsewhere in this file).
234
+ if ! echo "$PRNUM" | grep -qE '^[0-9]+$'; then
235
+ ISSUE_SOURCE_PR[$NUM]=""
236
+ ISSUE_SOURCE_PR_STATE[$NUM]="unknown"
237
+ ISSUE_LIKELY_MOOT[$NUM]="unknown"
238
+ continue
239
+ fi
240
+ ISSUE_SOURCE_PR[$NUM]="$PRNUM"
241
+
242
+ # Resolve this PR's state ONCE — cache hit skips the gh call entirely for every
243
+ # subsequent issue citing the same PR number.
244
+ if [ -z "${SOURCE_PR_STATE_CACHE[$PRNUM]+x}" ]; then
245
+ PR_JSON=$(gh pr view "$PRNUM" {GH_FLAG} --json state,mergedAt 2>/dev/null)
246
+ if [ -n "$PR_JSON" ]; then
247
+ PR_STATE=$(echo "$PR_JSON" | jq -r '.state')
248
+ PR_MERGED_AT=$(echo "$PR_JSON" | jq -r '.mergedAt // "null"')
249
+ SOURCE_PR_STATE_CACHE[$PRNUM]="${PR_STATE}|${PR_MERGED_AT}"
250
+ else
251
+ # gh pr view failed (rate limit, deleted PR, network) — non-fatal, mark unknown and continue.
252
+ SOURCE_PR_STATE_CACHE[$PRNUM]="unknown|unknown"
253
+ fi
254
+ fi
255
+
256
+ CACHED="${SOURCE_PR_STATE_CACHE[$PRNUM]}"
257
+ C_STATE="${CACHED%%|*}"
258
+ C_MERGED="${CACHED##*|}"
259
+
260
+ if [ "$C_STATE" = "unknown" ]; then
261
+ ISSUE_SOURCE_PR_STATE[$NUM]="unknown"
262
+ ISSUE_LIKELY_MOOT[$NUM]="unknown"
263
+ elif [ "$C_STATE" = "CLOSED" ] && [ "$C_MERGED" = "null" ]; then
264
+ ISSUE_SOURCE_PR_STATE[$NUM]="CLOSED_UNMERGED"
265
+ ISSUE_LIKELY_MOOT[$NUM]="yes"
266
+ elif [ "$C_STATE" = "MERGED" ] || { [ "$C_STATE" = "CLOSED" ] && [ "$C_MERGED" != "null" ]; }; then
267
+ ISSUE_SOURCE_PR_STATE[$NUM]="MERGED"
268
+ ISSUE_LIKELY_MOOT[$NUM]="unknown"
269
+ else
270
+ ISSUE_SOURCE_PR_STATE[$NUM]="OPEN"
271
+ ISSUE_LIKELY_MOOT[$NUM]="unknown"
272
+ fi
273
+ done
274
+ ```
275
+
276
+ **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`).
277
+
278
+ - `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`.
279
+ - `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.
280
+
281
+ **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.
282
+
64
283
  ### P3 Review-Finding Batching (deterministic grouping rule)
65
284
 
66
285
  <!-- Added: forge#1333 -->
67
286
  <!-- Extended: forge#1818 — default-batchable eligibility, surface-area grouping, lowered same-file threshold -->
287
+ <!-- Extended: forge#2232 — priority-label schema tolerance (bare P<n> vs priority:P<n>) -->
68
288
 
69
289
  Before finalizing the issue set, apply the P3 batching rule to reduce full-pipeline overhead on low-severity review findings.
70
290
 
291
+ **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 -->
292
+
71
293
  **Trigger conditions** (default-batchable — no opt-in marker required):
72
- 1. The issue has labels `review-finding` + `priority:P3`
294
+ 1. The issue has label `review-finding` + a P3 priority label (`priority:P3` or bare `P3`)
73
295
  2. The issue does NOT match any Safety exclusion below
74
296
 
75
297
  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,13 +304,17 @@ These exclusions apply regardless of priority: P1/P2 findings are already never
82
304
 
83
305
  **Grouping algorithm (surface area — same file first, leaf directory as broader fallback):** <!-- Changed: forge#1818 — was domain-only -->
84
306
  ```bash
85
- # Fetch all open batchable P3 issues (default-batchable; marker no longer required)
307
+ # Fetch all open batchable P3 issues (default-batchable; marker no longer required).
308
+ # NOTE: `--label` is an exact-match GH filter and cannot OR "priority:P3" with bare "P3" in
309
+ # one query, so the P3 test moves into the jq predicate below (schema-tolerant, forge#2232).
310
+ # Only "review-finding" stays in the --label filter.
86
311
  BATCHABLE_P3=$(gh issue list {GH_FLAG} \
87
312
  --state open \
88
- --label "review-finding,priority:P3" \
313
+ --label "review-finding" \
89
314
  --limit 500 \
90
315
  --json number,title,body,labels \
91
- --jq '.[] | select((.title | test("security|billing|anti-bot|auth"; "i")) | not)
316
+ --jq '.[] | select([.labels[].name] | any(test("^(priority:)?P3$")))
317
+ | select((.title | test("security|billing|anti-bot|auth"; "i")) | not)
92
318
  | select(.body | test("## Problem[\\s\\S]{0,500}(security|billing|anti-bot|auth)"; "i") | not)
93
319
  | select(([.labels[].name] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth")) | not)')
94
320
 
@@ -605,15 +605,15 @@ fi
605
605
  value(issue) = priority_weight × danger_zone_weight
606
606
  ```
607
607
 
608
- **Priority weight** (from issue labels):
608
+ **Priority weight** (from issue labels — matches both the canonical `priority:P<n>` form and the bare `P<n>` form some consumer repos use for externally/legacy-labeled issues; see `phase-1-resolve.md`'s "Priority label schema" note. `priority:P<n>` wins if both are present on the same issue): <!-- Added: forge#2232 -->
609
609
 
610
610
  | Label | Weight |
611
611
  |-------|--------|
612
- | `priority:P0` | 4.0 |
613
- | `priority:P1` | 3.0 |
614
- | `priority:P2` | 2.0 |
615
- | `priority:P3` | 1.0 |
616
- | *(no priority label)* | 1.5 |
612
+ | `priority:P0` or `P0` | 4.0 |
613
+ | `priority:P1` or `P1` | 3.0 |
614
+ | `priority:P2` or `P2` | 2.0 |
615
+ | `priority:P3` or `P3` | 1.0 |
616
+ | *(neither form present)* | 1.5 |
617
617
 
618
618
  **Danger-zone weight** (from affected files via FORGE:INVESTIGATOR comment): Read the `### Affected Files` section and check each file path against the danger-zone list from `forge.yaml → review.danger_zones[]`. Each affected file that appears in a danger zone adds 0.5 to the weight (additive, capped at 2.0). Default (no matches): 1.0.
619
619
 
@@ -670,13 +670,18 @@ for NUM in "${ISSUES[@]}"; do
670
670
  LABELS=$(echo "$ISSUE_DATA" | jq -r '.labels[]' 2>/dev/null || echo '')
671
671
 
672
672
  # --- Value: priority weight ---
673
- if echo "$LABELS" | grep -q "priority:P0"; then
673
+ # Schema-tolerant (forge#2232): matches canonical "priority:P<n>" (whole-line, via -E
674
+ # anchors so "priority:P0" doesn't also satisfy a "priority:P0x"-style label) or bare
675
+ # "P<n>". $LABELS is already a flat newline-separated list of label names (not a JSON
676
+ # array), so this stays a grep -E check rather than jq test() — no engine-mismatch risk
677
+ # since there is no jq counterpart mirrored at this specific site.
678
+ if echo "$LABELS" | grep -qE "^priority:P0$|^P0$"; then
674
679
  PRIO_WEIGHT=4.0
675
- elif echo "$LABELS" | grep -q "priority:P1"; then
680
+ elif echo "$LABELS" | grep -qE "^priority:P1$|^P1$"; then
676
681
  PRIO_WEIGHT=3.0
677
- elif echo "$LABELS" | grep -q "priority:P2"; then
682
+ elif echo "$LABELS" | grep -qE "^priority:P2$|^P2$"; then
678
683
  PRIO_WEIGHT=2.0
679
- elif echo "$LABELS" | grep -q "priority:P3"; then
684
+ elif echo "$LABELS" | grep -qE "^priority:P3$|^P3$"; then
680
685
  PRIO_WEIGHT=1.0
681
686
  else
682
687
  PRIO_WEIGHT=1.5
@@ -814,6 +819,8 @@ done
814
819
 
815
820
  **Populate every row below by dereferencing `PREDECESSORS[$NUM]`, `ISSUE_DOMAIN[$NUM]`, `ISSUE_SCORE[$NUM]`, and `ISSUE_COST_ESTIMATE[$NUM]` computed in Steps 3B–3E.5 — do not fabricate values from memory or by re-reading issue titles at this point.** The Step 3D.6 gate having passed only guarantees the data structures exist; it does not substitute for actually reading them here. <!-- Added: forge#1913 -->
816
821
 
822
+ **Source-PR Hint column**: when `ISSUE_LIKELY_MOOT[$NUM]` (populated by `phase-1-resolve.md`'s "Source-PR Triage Hint" step — see that file) is present for an issue, dereference it into a `Source-PR Hint` column. This column is **informational only** — it never changes a row's `Status` (Ready/Blocked), never removes a row from the table, and never causes a row to be excluded from dispatch. It exists purely so the operator can see, before confirming the plan, which `staging-review`/`review-finding` issues cite a source PR that closed without merging — a reason to look closer during that issue's own investigation phase, not a reason to skip it (see `phase-1-resolve.md`'s counterexamples: #2339/#2342 vs. #2346/#2261 — the same signal was right in one case and would have been wrong as a verdict in the other). <!-- Added: forge#2351 -->
823
+
817
824
  ```
818
825
  ## Orchestration Plan
819
826
 
@@ -845,16 +852,18 @@ done
845
852
 
846
853
  ### Dependency Graph
847
854
 
848
- | Issue | Predecessors | Domain | Score | Est. Cost | Status |
849
- |-------|-------------|--------|-------|-----------|--------|
850
- | #{A} | — | FRONTEND | {score} | ${cost} | Ready (dispatches 1st by score) |
851
- | #{B} | — | BILLING | {score} | ${cost} | Ready (dispatches 2nd by score) |
852
- | #{C} | — | WORKER | {score} | ${cost} [ε] | Ready (dispatches 3rd — ε-reserve) |
853
- | #{D} | #{A} | FRONTEND | {score} | ${cost} | Blocked (waits for #{A} only) |
854
- | #{E} | — | DATABASE | {score} | ${cost} | Ready (dispatches 4th by score) |
855
- | #{F} | #{E} | DATABASE | {score} | ${cost} | Blocked (serialized — waits for #{E}) |
855
+ | Issue | Predecessors | Domain | Score | Est. Cost | Source-PR Hint | Status |
856
+ |-------|-------------|--------|-------|-----------|-----------------|--------|
857
+ | #{A} | — | FRONTEND | {score} | ${cost} | — | Ready (dispatches 1st by score) |
858
+ | #{B} | — | BILLING | {score} | ${cost} | — | Ready (dispatches 2nd by score) |
859
+ | #{C} | — | WORKER | {score} | ${cost} [ε] | — | Ready (dispatches 3rd — ε-reserve) |
860
+ | #{D} | #{A} | FRONTEND | {score} | ${cost} | — | Blocked (waits for #{A} only) |
861
+ | #{E} | — | DATABASE | {score} | ${cost} | — | Ready (dispatches 4th by score) |
862
+ | #{F} | #{E} | DATABASE | {score} | ${cost} | — | Blocked (serialized — waits for #{E}) |
863
+ | #{G} | — | INFRA | {score} | ${cost} | likely-moot (PR #{N} closed unmerged — verify first) | Ready (dispatches 5th by score) |
856
864
 
857
865
  **[ε]** = no cost prior; eligible for exploration reserve (10% of budget guaranteed for these)
866
+ **Source-PR Hint** = `${ISSUE_LIKELY_MOOT[$NUM]:-unknown}` rendered as `—` when `unknown`/absent, or `likely-moot (PR #{ISSUE_SOURCE_PR[$NUM]} closed unmerged — verify first)` when `yes`. Never affects `Status` or row inclusion — see `phase-1-resolve.md`'s "Source-PR Triage Hint" step for how this is computed and why it stays a hint.
858
867
 
859
868
  **Score** = value / estimated_cost (value = priority_weight × danger_zone_weight; higher = dispatches first within the ready-set)
860
869
  **Est. Cost** = cost-prior mean for (task_type × module), or label heuristic if no prior