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
@@ -0,0 +1,258 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ /**
5
+ * Pure, independently-testable helpers for `forgedock watch` (bin/forgedock.mjs).
6
+ *
7
+ * Extracted out of watch()/render() (forge#2235) so the label-derivation,
8
+ * findings-lane classification, priority normalization, satellite-repo
9
+ * parsing, and heartbeat-batching logic can be unit tested without spawning
10
+ * `gh` subprocesses. None of these functions perform I/O themselves — the
11
+ * caller in forgedock.mjs reads files / shells out and passes the results in.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "fs";
15
+
16
+ /**
17
+ * Conservative built-in fallback for the watched label set, used only when
18
+ * bin/labels.json is missing or unreadable. Historically this was the ONLY
19
+ * label set watch() ever used (forge#2235) — kept here so a broken/missing
20
+ * manifest degrades to the old (narrow but working) behavior instead of
21
+ * watch() going fully blind.
22
+ */
23
+ export const FALLBACK_WORKFLOW_LABELS = [
24
+ "workflow:investigating",
25
+ "workflow:ready-to-build",
26
+ "workflow:building",
27
+ "workflow:in-review",
28
+ "needs-human",
29
+ ];
30
+
31
+ /**
32
+ * The label that marks a review-finding issue, watched in its own lane
33
+ * (forge#2235) independent of workflow:* state — a finding is born without
34
+ * any workflow:* label and may never acquire one (deferred/PERMANENT_DEFERRED
35
+ * findings), so it must not depend on the in-flight label set to be visible.
36
+ */
37
+ export const FINDINGS_LANE_LABEL = "review-finding";
38
+
39
+ /**
40
+ * Derive the watched "in-flight" label set from the canonical bin/labels.json
41
+ * manifest instead of a hardcoded literal (forge#2235). Returns every label
42
+ * whose name starts with "workflow:", plus "needs-human" (the terminal-stall
43
+ * pipeline label, not itself prefixed workflow: but part of the same family).
44
+ *
45
+ * Falls back to FALLBACK_WORKFLOW_LABELS if the manifest is absent, unreadable,
46
+ * or malformed, so a broken manifest never makes watch() go silent.
47
+ *
48
+ * @param {string} labelsJsonPath - absolute path to bin/labels.json
49
+ * @returns {string[]}
50
+ */
51
+ export function deriveWorkflowLabels(labelsJsonPath) {
52
+ try {
53
+ if (!existsSync(labelsJsonPath)) return FALLBACK_WORKFLOW_LABELS;
54
+ const raw = readFileSync(labelsJsonPath, "utf-8");
55
+ const all = JSON.parse(raw);
56
+ if (!Array.isArray(all)) return FALLBACK_WORKFLOW_LABELS;
57
+ const derived = all
58
+ .map((l) => l && l.name)
59
+ .filter((name) => typeof name === "string" && (name.startsWith("workflow:") || name === "needs-human"));
60
+ return derived.length > 0 ? derived : FALLBACK_WORKFLOW_LABELS;
61
+ } catch {
62
+ return FALLBACK_WORKFLOW_LABELS;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Normalize a mixed label array (strings or {name} objects, as returned by
68
+ * `gh issue list --json labels`) into a plain array of name strings.
69
+ * @param {Array<string|{name: string}>} labels
70
+ * @returns {string[]}
71
+ */
72
+ function labelNames(labels) {
73
+ if (!Array.isArray(labels)) return [];
74
+ return labels.map((l) => (typeof l === "string" ? l : l && l.name)).filter(Boolean);
75
+ }
76
+
77
+ /**
78
+ * Classify a review-finding issue's render status in the findings lane from
79
+ * its full label set (forge#2235). Callers should only invoke this for
80
+ * issues that do NOT carry a workflow:* label — those are in-flight and
81
+ * belong in the main table, not the findings lane, to avoid double-rendering
82
+ * the same issue in two places.
83
+ *
84
+ * - "validated" — confirmed as a real issue (label: validated)
85
+ * - "false-positive" — dismissed (label: false-positive)
86
+ * - "queued" — awaiting human validation (label: needs-validation)
87
+ * - "deferred" — bare review-finding with none of the above; this is
88
+ * the previously-invisible "PERMANENT_DEFERRED" bucket
89
+ *
90
+ * @param {Array<string|{name: string}>} labels
91
+ * @returns {"validated"|"false-positive"|"queued"|"deferred"}
92
+ */
93
+ export function classifyFindingStatus(labels) {
94
+ const names = labelNames(labels);
95
+ if (names.includes("validated")) return "validated";
96
+ if (names.includes("false-positive")) return "false-positive";
97
+ if (names.includes("needs-validation")) return "queued";
98
+ return "deferred";
99
+ }
100
+
101
+ /**
102
+ * True if the given label set carries any workflow:*-family label (i.e. the
103
+ * issue is "in-flight" per deriveWorkflowLabels()'s family, not just the
104
+ * literal "workflow:" prefix check — needs-human counts too).
105
+ *
106
+ * @param {Array<string|{name: string}>} labels
107
+ * @param {string[]} workflowLabels - the label set returned by deriveWorkflowLabels()
108
+ * @returns {boolean}
109
+ */
110
+ export function hasWorkflowLabel(labels, workflowLabels) {
111
+ const names = new Set(labelNames(labels));
112
+ return workflowLabels.some((wl) => names.has(wl));
113
+ }
114
+
115
+ /**
116
+ * Normalize a priority label to a short display form ("P0".."P3"), accepting
117
+ * both the canonical `priority:P<n>` form and the bare `P<n>` form used by
118
+ * some consumer repos (forge#2232 established this same dual-form handling
119
+ * for priority extraction elsewhere in the pipeline; forge#2235 applies it to
120
+ * watch's render rows).
121
+ *
122
+ * Canonical `priority:P<n>` wins if both happen to be present.
123
+ *
124
+ * @param {Array<string|{name: string}>} labels
125
+ * @returns {string|null} e.g. "P1", or null if no priority label found
126
+ */
127
+ export function normalizePriority(labels) {
128
+ const names = labelNames(labels);
129
+ const canonical = names.find((n) => /^priority:P[0-3]$/.test(n));
130
+ if (canonical) return canonical.replace("priority:", "");
131
+ const bare = names.find((n) => /^P[0-3]$/.test(n));
132
+ return bare || null;
133
+ }
134
+
135
+ /**
136
+ * Parse `repos.satellites[].repo` entries out of a forge.yaml file's raw
137
+ * text, without a YAML parser dependency — consistent with the rest of
138
+ * forgedock.mjs's regex-based forge.yaml reads (resolveLabelsRepo,
139
+ * stall_timeout_minutes, etc.). Returns [] when no `satellites:` section is
140
+ * present, so callers degrade to exactly the previous single-repo behavior.
141
+ *
142
+ * @param {string} forgeYamlText
143
+ * @returns {string[]} "owner/repo" strings
144
+ */
145
+ export function parseSatelliteRepos(forgeYamlText) {
146
+ if (typeof forgeYamlText !== "string") return [];
147
+ const idx = forgeYamlText.indexOf("satellites:");
148
+ if (idx === -1) return [];
149
+ const tail = forgeYamlText.slice(idx + "satellites:".length);
150
+ // Stop at the next non-indented (top-level) key so an unrelated later
151
+ // section's `repo:` field is never picked up as a satellite.
152
+ const stopMatch = tail.match(/\n[^\s#][^\n]*/);
153
+ const section = stopMatch ? tail.slice(0, stopMatch.index) : tail;
154
+ const repos = [];
155
+ // Anchor to a YAML key boundary (line start, optional indentation, optional
156
+ // `-` list-item marker) so a future suffixed key like `staging_repo:` can
157
+ // never be matched as a trailing substring of an unrelated key (forge#2457).
158
+ const re = /^[ \t]*(?:-[ \t]*)?repo:\s*["']?([^\s"'#]+)["']?/gm;
159
+ let m;
160
+ while ((m = re.exec(section)) !== null) {
161
+ repos.push(m[1]);
162
+ }
163
+ return repos;
164
+ }
165
+
166
+ /**
167
+ * Escape a value for safe interpolation inside a double-quoted GraphQL
168
+ * string literal (forge#2307). Backslash MUST be escaped first — escaping
169
+ * `"` before `\` would double-escape the backslashes just introduced by the
170
+ * quote-escaping step. A value with no special characters round-trips
171
+ * unchanged, so this is a no-op for the overwhelming common case (plain
172
+ * alphanumeric owner/repo names).
173
+ *
174
+ * @param {string} value
175
+ * @returns {string}
176
+ */
177
+ function escapeGraphQLString(value) {
178
+ return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
179
+ }
180
+
181
+ /**
182
+ * Build a single GraphQL query batching FORGE:HEARTBEAT comment lookups for
183
+ * many issues into one `gh api graphql` round-trip instead of one REST call
184
+ * per issue (forge#2235 — de-N+1). The caller is responsible for chunking
185
+ * issueNumbers to stay under GraphQL query complexity limits (a chunk size
186
+ * of ~25 is safe).
187
+ *
188
+ * Fetches only the last 20 comments per issue (heartbeats are appended, so
189
+ * the latest one is always near the end) rather than the full comment
190
+ * history, to keep the response small.
191
+ *
192
+ * `owner`/`repo` are escaped (forge#2307) before interpolation into the
193
+ * double-quoted GraphQL string literal so a value containing `"` or `\`
194
+ * cannot break out of the literal and alter the query structure. In
195
+ * practice these values come from trusted local input (forge.yaml or a
196
+ * `--repo` CLI flag — see bin/forgedock.mjs resolveLabelsRepo/watch()), not
197
+ * issue/PR content, but the query builder itself should not depend on that
198
+ * being true forever.
199
+ *
200
+ * @param {string} owner
201
+ * @param {string} repo
202
+ * @param {number[]} issueNumbers
203
+ * @returns {string} GraphQL query document
204
+ */
205
+ export function buildHeartbeatBatchQuery(owner, repo, issueNumbers) {
206
+ const aliases = issueNumbers
207
+ .map(
208
+ (n, i) =>
209
+ `i${i}: issue(number: ${Number(n)}) { number comments(last: 20) { nodes { body } } }`,
210
+ )
211
+ .join("\n ");
212
+ return `query {
213
+ repository(owner: "${escapeGraphQLString(owner)}", name: "${escapeGraphQLString(repo)}") {
214
+ ${aliases}
215
+ }
216
+ }`;
217
+ }
218
+
219
+ /**
220
+ * Extract the latest FORGE:HEARTBEAT comment body per issue number from a
221
+ * parsed `gh api graphql` response built against buildHeartbeatBatchQuery().
222
+ *
223
+ * @param {object} graphqlJson - parsed JSON response from `gh api graphql`
224
+ * @returns {Map<number, string|null>} issue number -> latest heartbeat body, or null if none found
225
+ */
226
+ export function parseHeartbeatBatchResponse(graphqlJson) {
227
+ const result = new Map();
228
+ const repoData = graphqlJson && graphqlJson.data && graphqlJson.data.repository;
229
+ if (!repoData) return result;
230
+ for (const key of Object.keys(repoData)) {
231
+ const issue = repoData[key];
232
+ if (!issue || typeof issue.number !== "number") continue;
233
+ const nodes = (issue.comments && issue.comments.nodes) || [];
234
+ let heartbeat = null;
235
+ for (let i = nodes.length - 1; i >= 0; i--) {
236
+ if (nodes[i] && typeof nodes[i].body === "string" && nodes[i].body.includes("FORGE:HEARTBEAT")) {
237
+ heartbeat = nodes[i].body;
238
+ break;
239
+ }
240
+ }
241
+ result.set(issue.number, heartbeat);
242
+ }
243
+ return result;
244
+ }
245
+
246
+ /**
247
+ * Chunk an array into fixed-size slices (helper for GraphQL batch sizing).
248
+ * @param {Array<*>} arr
249
+ * @param {number} size
250
+ * @returns {Array<Array<*>>}
251
+ */
252
+ export function chunkArray(arr, size) {
253
+ const out = [];
254
+ for (let i = 0; i < arr.length; i += size) {
255
+ out.push(arr.slice(i, i + size));
256
+ }
257
+ return out;
258
+ }
@@ -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,14 +389,43 @@ 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 -->
395
+ # Safety-exclusion alternation is word-boundary anchored (not bare substrings)
396
+ # and the body scan strips the review-finding template's attribution
397
+ # boilerplate (**Confidence**/**Severity**/**Review comment** — see forge#2477
398
+ # note below for why **Source**/**Agent** are deliberately excluded from this
399
+ # list) before matching — identical fix mirrored across all three sweep
400
+ # sites so `authority_source` and a Security-agent-attributed finding no
401
+ # longer false-positive, while a genuine auth/billing/security/anti-bot
402
+ # finding still excludes. <!-- forge#2423 -->
403
+ # Each stripped alternative is anchored to the field's real generator-output
404
+ # shape (enum for Confidence/Severity, URL for Review comment) rather than a
405
+ # bare label-prefix + `.*$` — matching on label shape alone lets
406
+ # attacker-controlled body text on one of these lines get stripped along with
407
+ # the label, smuggling banned keywords past the scan below. Source/Agent are
408
+ # deliberately NOT stripped: both hold genuinely free-text generator output (a
409
+ # PR title; an agent's self-description) with no fixed vocabulary, so no shape
410
+ # bound can distinguish legitimate attribution from attacker-authored payload
411
+ # placed in the same position — a length-bounded free-text alternative for
412
+ # either field re-opens the exact smuggling gap this fix closes (an attacker
413
+ # need only prefix their payload with a fake "PR #N — " or agent name to
414
+ # satisfy the bound). Leaving them unstripped trades a narrow, already-known
415
+ # false-positive (forge#2423's Agent-line case; a P3 finding whose Source/Agent
416
+ # text happens to mention a domain keyword is not auto-batched) for closing a
417
+ # real bypass — the safe direction for a security-relevant exclusion.
418
+ # <!-- forge#2477 -->
392
419
  UNBATCHED_P3=$(gh issue list {GH_FLAG} \
393
420
  --state open \
394
- --label "review-finding,priority:P3" \
421
+ --label "review-finding" \
395
422
  --limit 500 \
396
423
  --json number,title,body,labels,createdAt \
397
- --jq '.[] | select(([.labels[].name] | any(. == "batch")) | not)
398
- | select((.title | test("security|billing|anti-bot|auth"; "i")) | not)
399
- | select(.body | test("## Problem[\\s\\S]{0,500}(security|billing|anti-bot|auth)"; "i") | not)
424
+ --jq '.[] | select([.labels[].name] | any(test("^(priority:)?P3$")))
425
+ | select(([.labels[].name] | any(. == "batch")) | not)
426
+ | select((.title | test("\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i")) | not)
427
+ | (.body | gsub("(?m)^\\*\\*(?:Confidence\\*\\*: (?:CONFIRMED|LIKELY|POSSIBLE)|Severity\\*\\*: (?:CRITICAL|HIGH|MEDIUM|LOW|INFO)|Review comment\\*\\*: https?://\\S+)$"; "")) as $stripped_body
428
+ | select($stripped_body | test("## Problem[\\s\\S]{0,500}\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i") | not)
400
429
  | select(([.labels[].name] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth")) | not)')
401
430
 
402
431
  echo "Unbatched batchable P3 findings: $(echo "$UNBATCHED_P3" | jq -s 'length')"
package/commands/issue.md CHANGED
@@ -31,6 +31,7 @@ You create GitHub issues with the exact structure the `/work-on` pipeline expect
31
31
  | `--body-file <path>` | Supplies the final issue body by reading it from a file (programmatic mode only). Mutually exclusive with `--body`. |
32
32
  | `--label "..."` | Adds one label. Repeatable (`--label bug --label P2`) — labels accumulate. Programmatic mode only. |
33
33
  | `--milestone "..."` | Supplies the milestone title directly, skipping the Phase 2E milestone lookup. Programmatic mode only, optional. |
34
+ | `--exclude "N,N,..."` | Comma-separated issue numbers to exclude from the Phase 2D dedup candidate set. Programmatic mode only, optional. Forwarded verbatim to `scripts/issue-dedup.sh --exclude`. NOT a `--force` equivalent — narrows the candidate set, does not disable the gate. See Phase 2D. <!-- Added: forge#2432 --> |
34
35
 
35
36
  ```bash
36
37
  DRY_RUN=false
@@ -39,6 +40,7 @@ PROGRAMMATIC_BODY=""
39
40
  PROGRAMMATIC_BODY_FILE=""
40
41
  PROGRAMMATIC_LABELS=()
41
42
  PROGRAMMATIC_MILESTONE=""
43
+ PROGRAMMATIC_EXCLUDE=""
42
44
 
43
45
  # Positional/flag parsing. $ARGUMENTS is a single opaque string (the invoking
44
46
  # agent's raw args, e.g. from `Skill(skill="issue", args="--title \"fix: ...\" ...")`)
@@ -107,7 +109,7 @@ rm -f "$ARGS_TMP"
107
109
  LOOKS_LIKE_FLAG_MODE=false
108
110
  for arg in "${ARGS[@]}"; do
109
111
  case "$arg" in
110
- --dry-run|--title|--body|--body-file|--label|--milestone)
112
+ --dry-run|--title|--body|--body-file|--label|--milestone|--exclude)
111
113
  LOOKS_LIKE_FLAG_MODE=true
112
114
  break
113
115
  ;;
@@ -119,7 +121,7 @@ while [ "$LOOKS_LIKE_FLAG_MODE" = "true" ] && [ $i -lt ${#ARGS[@]} ]; do
119
121
  arg="${ARGS[$i]}"
120
122
  case "$arg" in
121
123
  --dry-run) DRY_RUN=true ;;
122
- --title|--body|--body-file|--label|--milestone)
124
+ --title|--body|--body-file|--label|--milestone|--exclude)
123
125
  # Each of these flags requires a value token immediately after it. A flag
124
126
  # with no following token (e.g. --title as the last argument) is a usage
125
127
  # error, not an empty value — fail loudly instead of silently proceeding
@@ -136,6 +138,7 @@ while [ "$LOOKS_LIKE_FLAG_MODE" = "true" ] && [ $i -lt ${#ARGS[@]} ]; do
136
138
  --body-file) PROGRAMMATIC_BODY_FILE="${ARGS[$i]}" ;;
137
139
  --label) PROGRAMMATIC_LABELS+=("${ARGS[$i]}") ;;
138
140
  --milestone) PROGRAMMATIC_MILESTONE="${ARGS[$i]}" ;;
141
+ --exclude) PROGRAMMATIC_EXCLUDE="${ARGS[$i]}" ;;
139
142
  esac
140
143
  ;;
141
144
  --*)
@@ -177,7 +180,7 @@ fi
177
180
  For callers that have already composed a title, body, labels, and (optionally) a milestone — audit tools, `/work-on` decomposition, pipeline-health proposals, review-finding creation, and similar automation — pass `--title` to activate programmatic mode. This bypasses Phase 1 (free-text parsing) and Phase 3 (LLM drafting) entirely; Phase 2D (dedup) and body validation still run.
178
181
 
179
182
  **Required**: `--title "TEXT"` and exactly one of `--body "TEXT"` / `--body-file <path>`.
180
- **Optional**: `--label "NAME"` (repeatable, zero or more), `--milestone "TITLE"`, `--dry-run`.
183
+ **Optional**: `--label "NAME"` (repeatable, zero or more), `--milestone "TITLE"`, `--dry-run`, `--exclude "N,N,..."` (see Phase 2D — narrows the dedup candidate set, not a `--force` equivalent).
181
184
 
182
185
  If `--title` is present but neither `--body` nor `--body-file` is supplied, or both are supplied, this is a usage error: print `ERROR: programmatic mode requires exactly one of --body or --body-file` and STOP — do not fall through to Phase 2D or Phase 4B.
183
186
 
@@ -340,7 +343,12 @@ Run the deterministic dedup script first (authoritative check), then fall back t
340
343
 
341
344
  ```bash
342
345
  # Authoritative deterministic check — uses token overlap algorithm (see scripts/issue-dedup.sh)
343
- DEDUP_RESULT=$(scripts/issue-dedup.sh "{PROPOSED_TITLE}" {GH_FLAG} 2>&1)
346
+ # In programmatic mode, forward $PROGRAMMATIC_EXCLUDE (if set) as --exclude — see the
347
+ # "Exclusion mechanism" note below. In interactive mode $PROGRAMMATIC_EXCLUDE is always
348
+ # empty, so this is a no-op for that path.
349
+ DEDUP_EXCLUDE_ARGS=()
350
+ [ -n "$PROGRAMMATIC_EXCLUDE" ] && DEDUP_EXCLUDE_ARGS=(--exclude "$PROGRAMMATIC_EXCLUDE")
351
+ DEDUP_RESULT=$(scripts/issue-dedup.sh "{PROPOSED_TITLE}" {GH_FLAG} "${DEDUP_EXCLUDE_ARGS[@]}" 2>&1)
344
352
  DEDUP_EXIT=$?
345
353
 
346
354
  if [ "$DEDUP_EXIT" -eq 1 ]; then
@@ -369,6 +377,8 @@ If a duplicate exists:
369
377
  - **Closed duplicate found (regression)**: Create the issue but reference the prior issue in the body: "Regression of #{N}."
370
378
  - **User wants to override**: Pass `--force` to the dedup script — this is an explicit human override path. Agents MUST NOT pass `--force` without user authorization. <!-- Added: forge#1335 -->
371
379
 
380
+ **Exclusion mechanism (`--exclude`, programmatic mode only)**: A caller that already knows a set of open issue numbers this new issue is a deliberate supersede of — not an accidental duplicate — can pass `--exclude "N,N,..."` to `/issue` (forwarded to `scripts/issue-dedup.sh --exclude`). The canonical example is `phase-1-resolve.md`'s P3 review-finding batching: a batch issue restates its own member findings' subject matter by construction, so without exclusion it always collides with them under the token-overlap algorithm. `--exclude` removes those specific issue numbers from Phase 2D's candidate set BEFORE matching runs — it is **NOT a `--force` equivalent**: it narrows the candidate set, it does not disable the check, and it requires no human authorization (unlike `--force`, which agents MUST NOT pass without one). Any OTHER open issue — one not in the exclusion list — is still matched normally, so a title that happens to duplicate an unrelated existing issue still trips the STOP. <!-- Added: forge#2432 -->
381
+
372
382
  ### 2E: Check for milestone context
373
383
 
374
384
  ```bash
@@ -54,9 +54,51 @@ 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" for generation *depth*, while `--max-generation`/`--policy` express finer-grained depth policy (e.g. "admit gen-2, stop at gen-3").
100
+
101
+ **Time scope is a separate axis from depth** (forge#2628): by default, `cascade`/`review-findings`/`findings` only resolves `review-finding` issues created at/after this batch's start (T0) — see `phase-1-resolve.md` "Cascade / Review-Finding Resolution" for the run-spawned-vs-backlog distinction. None of the depth flags/config above widen that time window. `--include-backlog` is the only flag that does — it opts into the whole open-backlog sweep regardless of when issues were created, and must be requested explicitly; it is never implied by adding `cascade` to an already-scoped batch.
60
102
 
61
103
  ---
62
104