forgedock 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/runner.mjs CHANGED
@@ -634,6 +634,112 @@ export function extractSessionLimitResetTime(output) {
634
634
  return sanitizeArgvForLog([resetAt]);
635
635
  }
636
636
 
637
+ /**
638
+ * Computes the UTC offset (in ms) `timeZone` observes at `instantMs`, defined
639
+ * such that `wallClockAsUTC = instantMs + offset` — i.e. a positive offset
640
+ * means the zone is ahead of UTC at that instant. Used by
641
+ * `wallTimeInZoneToEpochMs()` below to convert a local wall-clock time back
642
+ * into a real UTC epoch, correctly handling DST for zones that observe it
643
+ * (though the only zone shape actually seen in production CLI output today —
644
+ * `Asia/Calcutta`, a fixed +5:30 IANA link name with no DST — never needs the
645
+ * iteration this enables; the general form is kept so any zone name the CLI
646
+ * reports is handled correctly, not just the one observed so far).
647
+ *
648
+ * @param {number} instantMs - a real UTC epoch instant
649
+ * @param {string} timeZone - IANA zone name (e.g. "Asia/Calcutta")
650
+ * @returns {number} offset in ms
651
+ */
652
+ function tzOffsetMsAtInstant(instantMs, timeZone) {
653
+ const dtf = new Intl.DateTimeFormat("en-US", {
654
+ timeZone,
655
+ hourCycle: "h23",
656
+ year: "numeric", month: "2-digit", day: "2-digit",
657
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
658
+ });
659
+ const parts = Object.fromEntries(dtf.formatToParts(new Date(instantMs)).map((p) => [p.type, p.value]));
660
+ const localAsUTC = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second);
661
+ return localAsUTC - instantMs;
662
+ }
663
+
664
+ /**
665
+ * Converts a local wall-clock time `(y, mo, d, h, mi, s)` *in* `timeZone*
666
+ * into the real UTC epoch ms instant it represents. Two-iteration
667
+ * fixed-point refinement using `tzOffsetMsAtInstant()` above — sufficient
668
+ * for every real-world zone (including DST transitions, which shift the
669
+ * offset by at most a couple of hours, well within one refinement step).
670
+ *
671
+ * @returns {number} UTC epoch ms
672
+ */
673
+ function wallTimeInZoneToEpochMs(y, mo, d, h, mi, s, timeZone) {
674
+ const naiveUtc = Date.UTC(y, mo - 1, d, h, mi, s);
675
+ let guess = naiveUtc;
676
+ for (let i = 0; i < 2; i++) {
677
+ guess = naiveUtc - tzOffsetMsAtInstant(guess, timeZone);
678
+ }
679
+ return guess;
680
+ }
681
+
682
+ /**
683
+ * Parses the CLI's session-limit reset-time text (forge#2241's
684
+ * `extractSessionLimitResetTime()` output — e.g. `"12:50am (Asia/Calcutta)"`)
685
+ * into a machine-usable UTC epoch ms timestamp (forge#2524 AC5). The CLI only
686
+ * ever reports a bare time-of-day plus an IANA zone name, never a date — the
687
+ * reset is always the *next* occurrence of that wall-clock time from `nowMs`,
688
+ * so this resolves "today" in the target zone and rolls forward one day if
689
+ * that candidate has already passed.
690
+ *
691
+ * Deliberately narrow, mirroring `extractSessionLimitResetTime()`'s own
692
+ * "never fabricate a value" contract: returns `undefined` on any input that
693
+ * doesn't match the exact expected shape, or names a timezone
694
+ * `Intl.DateTimeFormat` doesn't recognize — callers must treat `undefined` as
695
+ * "could not compute a wait duration", never substitute a guessed default.
696
+ *
697
+ * @param {string|undefined} resetAtText - the (already-sanitized) display
698
+ * string from `extractSessionLimitResetTime()`
699
+ * @param {number} [nowMs] - current time; defaults to `Date.now()`, overridable for tests
700
+ * @returns {number|undefined} UTC epoch ms of the next occurrence of that
701
+ * wall-clock time in that zone, or `undefined` if unparseable
702
+ */
703
+ export function parseSessionLimitResetEpochMs(resetAtText, nowMs = Date.now()) {
704
+ if (!resetAtText) return undefined;
705
+ const match = /^(\d{1,2}):(\d{2})\s*([ap]m)\s*\(([^)]+)\)\s*$/i.exec(resetAtText.trim());
706
+ if (!match) return undefined;
707
+ const [, hourStr, minuteStr, ampm, timeZone] = match;
708
+ const rawHour = parseInt(hourStr, 10);
709
+ // Reject out-of-range hours BEFORE the `% 12` conversion below — otherwise
710
+ // malformed input like "13:30pm" or "99:30pm" silently aliases to a
711
+ // plausible-but-wrong hour (13 % 12 = 1, 99 % 12 = 3) instead of being
712
+ // rejected, violating this function's own "never fabricate a value"
713
+ // contract. Mirrors the `minute > 59` guard further down.
714
+ if (rawHour < 1 || rawHour > 12) return undefined;
715
+ let hour = rawHour % 12;
716
+ if (/pm/i.test(ampm)) hour += 12;
717
+ const minute = parseInt(minuteStr, 10);
718
+ if (!Number.isFinite(hour) || !Number.isFinite(minute) || minute > 59) return undefined;
719
+
720
+ let todayParts;
721
+ try {
722
+ const dtf = new Intl.DateTimeFormat("en-US", {
723
+ timeZone, year: "numeric", month: "2-digit", day: "2-digit",
724
+ });
725
+ todayParts = Object.fromEntries(dtf.formatToParts(new Date(nowMs)).map((p) => [p.type, p.value]));
726
+ } catch {
727
+ // Unrecognized IANA zone name — Intl throws a RangeError. Do not fabricate
728
+ // a value; the caller must treat this identically to any other parse failure.
729
+ return undefined;
730
+ }
731
+
732
+ const y = +todayParts.year, mo = +todayParts.month, d = +todayParts.day;
733
+ let epoch = wallTimeInZoneToEpochMs(y, mo, d, hour, minute, 0, timeZone);
734
+ // The CLI reports a reset time that is, by definition, in the future
735
+ // relative to when it printed the message — if today's candidate has
736
+ // already passed `nowMs`, the real reset is tomorrow at the same wall-clock time.
737
+ if (epoch <= nowMs) {
738
+ epoch = wallTimeInZoneToEpochMs(y, mo, d + 1, hour, minute, 0, timeZone);
739
+ }
740
+ return epoch;
741
+ }
742
+
637
743
  /**
638
744
  * Coerce a single CLI-reported `usage.*` field to a finite number, or `0`
639
745
  * when it isn't one (forge#2424). `?? 0` only replaces `null`/`undefined` —
@@ -815,7 +921,18 @@ export function runCliBackend({
815
921
  // quota-exhaustion failure will clear without reading raw logs. Only
816
922
  // ever set when the pattern actually matches — never fabricated.
817
923
  const resetAt = extractSessionLimitResetTime(output);
818
- if (resetAt) err.resetAt = resetAt;
924
+ if (resetAt) {
925
+ err.resetAt = resetAt;
926
+ // forge#2524: also attach a machine-usable epoch-ms timestamp so
927
+ // bin/engine.mjs can compute an actual wait duration and pause
928
+ // in-process instead of merely displaying the reset time. Only ever
929
+ // set when parsing succeeds — never fabricated (mirrors resetAt's
930
+ // own contract). A future/unparseable reset time simply leaves this
931
+ // field absent, and the caller's fail-fast/terminate behavior is
932
+ // unaffected (see bin/engine.mjs's runIssue() pause-loop guard).
933
+ const resetAtEpochMs = parseSessionLimitResetEpochMs(resetAt, Date.now());
934
+ if (resetAtEpochMs !== undefined) err.resetAtEpochMs = resetAtEpochMs;
935
+ }
819
936
  throw err;
820
937
  }
821
938
 
@@ -873,7 +990,47 @@ export function runCliBackend({
873
990
  // Prefer the parsed envelope's human-readable `.result` string so
874
991
  // console output stays prose, not a raw JSON blob; fall back to the raw
875
992
  // captured output when parsing failed or `.result` was absent.
876
- const humanOutput = parsedResult ?? output;
993
+ //
994
+ // forge#2456: `.result` alone would otherwise silently drop any non-empty
995
+ // `stderr` on an exit-0 run (deprecation notice, Node runtime banner,
996
+ // etc.) — a real behavior change from the pre-#2398 baseline, where the
997
+ // combined stdout+stderr stream was always logged. Append trimmed
998
+ // `stderr` after the parsed result when present, so operators still see
999
+ // it; when `stderr` is empty (the common case) the logged string is
1000
+ // unchanged. This mirrors the same "combine streams instead of dropping
1001
+ // one" fix already applied to the `run_bash` tool handler's success path
1002
+ // in #1229. `JSON.parse` itself still targets `stdout` alone (forge#2422)
1003
+ // — only the logged/displayed string composition changes here.
1004
+ //
1005
+ // forge#2483/forge#2484 (batch #2522): two follow-up fixes to the
1006
+ // forge#2456 composition above, applied together since they share the
1007
+ // exact same lines:
1008
+ // - forge#2483: `stderrTrimmed` is untrusted subprocess output (the CLI
1009
+ // echoes untrusted issue/PR body content — the same threat class
1010
+ // `sanitizeArgvForLog`/`sanitizeOutputExcerptForLog` already harden
1011
+ // for the timeout/non-zero-exit diagnostic paths above). It is now
1012
+ // routed through the same `sanitizeOutputExcerptForLog()` helper
1013
+ // before being concatenated into `humanOutput`, instead of reaching
1014
+ // `logger.log()` raw.
1015
+ // - forge#2484: `parsedResult !== null` is true even when
1016
+ // `parsedResult === ""` (the earlier `typeof parsed.result ===
1017
+ // "string"` check accepts `""`). Previously that produced
1018
+ // `"" + "\n" + stderrTrimmed` — a leading-newline-only artifact.
1019
+ // `parsedResult` (truthy check, not `!== null`) now gates the
1020
+ // "prefix with parsedResult" behavior, so an empty `.result` falls
1021
+ // through to the sanitized stderr alone, with no separator.
1022
+ // The `parsedResult === null` fallback branch (raw `output`) is
1023
+ // untouched by either fix — out of scope for both findings.
1024
+ const stderrTrimmed = stderr.trim();
1025
+ const sanitizedStderr = stderrTrimmed ? sanitizeOutputExcerptForLog(stderrTrimmed) : "";
1026
+ const humanOutput =
1027
+ parsedResult !== null
1028
+ ? parsedResult
1029
+ ? sanitizedStderr
1030
+ ? `${parsedResult}\n${sanitizedStderr}`
1031
+ : parsedResult
1032
+ : sanitizedStderr
1033
+ : output;
877
1034
  if (humanOutput) logger.log(humanOutput);
878
1035
 
879
1036
  logger.log(
@@ -152,7 +152,10 @@ export function parseSatelliteRepos(forgeYamlText) {
152
152
  const stopMatch = tail.match(/\n[^\s#][^\n]*/);
153
153
  const section = stopMatch ? tail.slice(0, stopMatch.index) : tail;
154
154
  const repos = [];
155
- const re = /repo:\s*["']?([^\s"'#]+)["']?/g;
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;
156
159
  let m;
157
160
  while ((m = re.exec(section)) !== null) {
158
161
  repos.push(m[1]);
@@ -392,6 +392,30 @@ Eligibility is **default-batchable**: any open `review-finding` + a P3 priority
392
392
  # NOTE: "priority:P3" is intentionally NOT in the --label filter — GH label filters are
393
393
  # exact-match and can't OR it with bare "P3", so the P3 test moves into the jq predicate
394
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 -->
395
419
  UNBATCHED_P3=$(gh issue list {GH_FLAG} \
396
420
  --state open \
397
421
  --label "review-finding" \
@@ -399,8 +423,9 @@ UNBATCHED_P3=$(gh issue list {GH_FLAG} \
399
423
  --json number,title,body,labels,createdAt \
400
424
  --jq '.[] | select([.labels[].name] | any(test("^(priority:)?P3$")))
401
425
  | select(([.labels[].name] | any(. == "batch")) | not)
402
- | select((.title | test("security|billing|anti-bot|auth"; "i")) | not)
403
- | select(.body | test("## Problem[\\s\\S]{0,500}(security|billing|anti-bot|auth)"; "i") | 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)
404
429
  | select(([.labels[].name] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth")) | not)')
405
430
 
406
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
@@ -96,7 +96,9 @@ All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{PROJECT_NAME}`, `{STAGING_BRANCH}
96
96
  | `--max-generation <N\|unlimited>` | `orchestration.cascade.max_generation` | `/orchestrate cascade --max-generation 3` |
97
97
  | `--token-budget <N\|unlimited>` | `orchestration.cascade.token_budget` | `/orchestrate cascade --token-budget unlimited` |
98
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").
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.
100
102
 
101
103
  ---
102
104
 
@@ -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
@@ -21,19 +42,57 @@ Parse `$ARGUMENTS` to determine which issues to work on:
21
42
  | `fast-lane` or `fast` | All open fast-lane issues (no milestone, bugs/fixes) |
22
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
- | `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 -->|
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 -->|
25
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 -->|
26
47
 
27
48
  ### Cascade / Review-Finding Resolution
28
49
 
29
- <!-- Added: forge#2231 -->
50
+ <!-- Added: forge#2231, T0-scoping added: forge#2628 -->
30
51
 
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:
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.
32
73
 
33
74
  ```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 \
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 \
37
96
  --json number,title,labels,milestone,body)
38
97
 
39
98
  # --- Cascade admission policy resolution (forge#2234) ---
@@ -106,14 +165,17 @@ echo "$CASCADE_CANDIDATES" | jq -c '.[]' | while IFS= read -r FINDING; do
106
165
  done
107
166
  ```
108
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
+
109
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.
110
171
 
111
- This resolve step is a human-requested entry point (the operator typed `cascade`/`review-findings`/`findings` directly), so it honors an explicit override:
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:
112
173
 
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.
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.
115
177
 
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.
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.
117
179
 
118
180
  ### Near-Miss Suggestion
119
181
 
@@ -149,6 +211,8 @@ This mirrors `bin/engine/resolve.mjs`'s `classifyInputPattern` — a typed, unit
149
211
 
150
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.
151
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.
215
+
152
216
  ### Fetch the issues
153
217
 
154
218
  For each resolved repo, use the appropriate `-R` flag:
@@ -308,14 +372,45 @@ These exclusions apply regardless of priority: P1/P2 findings are already never
308
372
  # NOTE: `--label` is an exact-match GH filter and cannot OR "priority:P3" with bare "P3" in
309
373
  # one query, so the P3 test moves into the jq predicate below (schema-tolerant, forge#2232).
310
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.
311
381
  BATCHABLE_P3=$(gh issue list {GH_FLAG} \
312
382
  --state open \
313
383
  --label "review-finding" \
314
384
  --limit 500 \
315
385
  --json number,title,body,labels \
316
386
  --jq '.[] | select([.labels[].name] | any(test("^(priority:)?P3$")))
317
- | select((.title | test("security|billing|anti-bot|auth"; "i")) | not)
318
- | select(.body | test("## Problem[\\s\\S]{0,500}(security|billing|anti-bot|auth)"; "i") | not)
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)
319
414
  | select(([.labels[].name] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth")) | not)')
320
415
 
321
416
  # Surface area = the exact affected file path listed first under "## Affected Files" (primary grouping key).
@@ -324,6 +419,13 @@ BATCHABLE_P3=$(gh issue list {GH_FLAG} \
324
419
  # "web/src/app/billing/page.tsx" → EXCLUDED (billing path)
325
420
  # "commands/orchestrate/phase-1-resolve.md" → file "commands/orchestrate/phase-1-resolve.md", leaf-dir "commands/orchestrate"
326
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)
327
429
  ```
328
430
 
329
431
  **Batch creation rule (two-tier threshold):** <!-- Changed: forge#1818 — added lower same-file tier -->
@@ -335,8 +437,11 @@ BATCHABLE_P3=$(gh issue list {GH_FLAG} \
335
437
 
336
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 -->
337
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
+
338
442
  ```bash
339
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")
340
445
  BATCH_BODY_FILE="$(mktemp)"
341
446
  cat > "$BATCH_BODY_FILE" <<'BATCH_EOF'
342
447
  ## Problem
@@ -365,7 +470,7 @@ BATCH_EOF
365
470
  ```
366
471
 
367
472
  ```
368
- 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}\""))
369
474
  ```
370
475
 
371
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}`):
@@ -375,7 +480,13 @@ BATCH_ISSUE_NUM=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE 'issues/[0-9]+' | head -
375
480
  [ -z "$BATCH_ISSUE_NUM" ] && BATCH_ISSUE_NUM=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE '\*\*#[0-9]+\*\*' | head -1 | grep -oE '[0-9]+')
376
481
 
377
482
  if [ -z "$BATCH_ISSUE_NUM" ]; then
378
- 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."
379
490
  fi
380
491
  ```
381
492