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/engine/runlog.mjs +11 -1
- package/bin/engine-cli.mjs +6 -0
- package/bin/engine.mjs +194 -59
- package/bin/forgedock.mjs +65 -4
- package/bin/hooks/pre-tool-use.mjs +29 -7
- package/bin/journey.mjs +578 -42
- package/bin/runner.mjs +159 -2
- package/bin/watch-utils.mjs +4 -1
- package/commands/cleanup.md +27 -2
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +3 -1
- package/commands/orchestrate/phase-1-resolve.md +125 -14
- package/commands/orchestrate/phase-3-dependency.md +254 -40
- package/commands/orchestrate/phase-4-execution.md +402 -39
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +97 -32
- package/commands/work-on/remediate.md +53 -4
- package/package.json +1 -1
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-phase-registry-drift.mjs +23 -1
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +4 -0
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
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)
|
|
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
|
-
|
|
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(
|
package/bin/watch-utils.mjs
CHANGED
|
@@ -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
|
-
|
|
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]);
|
package/commands/cleanup.md
CHANGED
|
@@ -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
|
-
|
|
|
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
|
-
|
|
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
|
|
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`) |
|
|
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
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
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
|
|
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-
|
|
114
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|