forgedock 1.10.3 → 1.11.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/commands/ci-audit.md +3 -3
- package/commands/compat-audit.md +1 -1
- package/commands/deploy-pr.md +6 -6
- package/commands/explain.md +2 -2
- package/commands/fix-ci.md +2 -2
- package/commands/forge-stats.md +1 -1
- package/commands/forgedock-init.md +3 -3
- package/commands/optimize.md +5 -5
- package/commands/orchestrate/phase-1-resolve.md +2 -2
- package/commands/orchestrate/phase-4-execution.md +182 -41
- package/commands/quality-gate.md +1 -1
- package/commands/recover-orphans.md +6 -6
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-staging.md +28 -6
- package/commands/review-pr.md +434 -120
- package/commands/security-audit.md +1 -1
- package/commands/spec-doctor.md +2 -2
- package/commands/test-gate.md +7 -7
- package/commands/work-on/build/architect.md +21 -0
- package/commands/work-on/build/context.md +1 -1
- package/commands/work-on/build/implement.md +17 -0
- package/commands/work-on/build/validate.md +2 -2
- package/commands/work-on/build.md +98 -9
- package/commands/work-on/close.md +33 -11
- package/commands/work-on/decompose.md +19 -5
- package/commands/work-on/investigate.md +34 -4
- package/commands/work-on/remediate.md +30 -6
- package/commands/work-on/review.md +10 -8
- package/commands/work-on.md +20 -11
- package/docs/CONFIG.md +30 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/docs/WORK-ON-RUNTIME.md +22 -2
- package/docs/site/architecture.md +1 -1
- package/docs/spec/forge-protocol-v1.md +10 -0
- package/docs/spec/review-protocol.md +1 -0
- package/package.json +1 -1
- package/packages/protocol/src/types.js +17 -0
- package/scripts/check-spec-bash.sh +45 -2
- package/scripts/check-spec-bash.test.sh +24 -1
- package/scripts/check-spec-markers.sh +53 -0
- package/scripts/diff-size.sh +84 -0
- package/scripts/diff-size.test.sh +117 -0
- package/scripts/forge-root.test.sh +15 -0
- package/scripts/remediation-marker-match.test.sh +51 -0
- package/scripts/review-pr-arguments.test.sh +180 -0
- package/scripts/trusted-comments.test.sh +23 -0
package/commands/review-pr.md
CHANGED
|
@@ -58,6 +58,8 @@ When `IS_OPENCODE_RUNTIME=true`, lowercase native `task` is the preferred isolat
|
|
|
58
58
|
3. **Neither tool is available**: this is a genuine setup defect, not a routing decision — HARD STOP, post a PR/issue comment explaining that no sub-agent dispatch tool is available, add `needs-human`, and exit without posting a verdict. Do NOT fall back to reviewing inline in the orchestrator's own context — inline self-review is strictly weaker than an isolated fresh-context reviewer and is never a substitute for a missing dispatch tool.
|
|
59
59
|
4. **Dispatch pool exhausted or dispatch call fails**: this is distinct from tool absence. If any selected reviewer cannot be launched because the runtime reports a sub-agent/session/pool limit (or any dispatch call fails), HARD STOP immediately. Do not review that domain inline, do not silently reduce the panel, and do not merge. Mark the PR `review-degraded`, add `needs-human` to the linked issue, post `<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->` with the selected and completed reviewer counts, then exit without a `FORGE:REVIEW` verdict. A later fresh session must re-run the full selected panel.
|
|
60
60
|
|
|
61
|
+
**Reviewer completion model (spawn-site ownership)**: `Task` and OpenCode `task` calls are joined, so their return is the completion signal. `Agent` is async-only (no wait parameter): the call returns `Async agent launched`, and when this skill runs inside a forked phase (`work-on:review` / `work-on:remediate`) the reviewers' completion notifications are attributed to the root session, not to this phase (see `docs/WORK-ON-RUNTIME.md` R6). With `{DISPATCH_TOOL}=Agent`, never end the turn waiting for notifications and never treat a missing notification as a missing review: the completion signal is each reviewer's `FORGE:REVIEW-AGENT:{domain}` PR comment for the reviewed head SHA, read by the bounded wait in Phase 4.
|
|
62
|
+
|
|
61
63
|
**Do not halt to ask the operator which tool to use.** Steps 1–2 are deterministic and fully resolve the common case; only step 3 (both absent) requires a stop, and even then the action is HARD STOP + `needs-human`, not a question back to the operator.
|
|
62
64
|
|
|
63
65
|
Everywhere this file (and `review-pr-agents.md` / the `review-pr-agents/*.md` persona files) says `Task(...)`, read it as `{DISPATCH_TOOL}(...)` using the value resolved here.
|
|
@@ -106,7 +108,100 @@ If `$ARGUMENTS` contains `--auto-merge`, this review was invoked from `/work-on`
|
|
|
106
108
|
Example: "3126 --auto-merge --issue 3124 --base staging --gh-flag -R $GH_REPO --worktree /path/to/worktree"
|
|
107
109
|
```
|
|
108
110
|
|
|
109
|
-
Extract: `PR_NUMBER`, `AUTO_MERGE=true`, `MERGE_ISSUE`, `MERGE_BASE`, `MERGE_GH_FLAG`, `MERGE_WORKTREE` (optional — the absolute path to the git worktree to clean up after merge)
|
|
111
|
+
Extract: `PR_NUMBER`, `AUTO_MERGE=true`, `MERGE_ISSUE`, `MERGE_BASE`, `MERGE_GH_FLAG`, `MERGE_WORKTREE` (optional — the absolute path to the git worktree to clean up after merge) using the **Argument Parse** block below.
|
|
112
|
+
|
|
113
|
+
**`$ARGUMENTS` is the whole argument string, never a PR number.** Every `gh` call in this spec uses `"$PR_NUMBER"` (and `-R "$REPO"`), bound once by the block below. Shell state does not persist between Bash calls, so any later Bash block that reads `PR_NUMBER`, `REPO`, `REPO_FLAG` or `MERGE_*` must re-run this block first. <!-- Added: forge#3404 -->
|
|
114
|
+
|
|
115
|
+
#### Argument Parse
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# BEGIN review-pr-arg-parse
|
|
119
|
+
# Tokenize the argument string without eval. First token is the PR ref (number or .../pull/N URL)
|
|
120
|
+
# or a mode keyword; the rest are flags.
|
|
121
|
+
# The loader substitutes the argument text into this block BEFORE bash parses it, so it is read from a
|
|
122
|
+
# quoted heredoc (inert for quotes, $(...), backticks and backslashes) and never from a quoted assignment.
|
|
123
|
+
# NONCE RULE: before running, replace the word NONCE in BOTH delimiter lines below with a fresh random hex string
|
|
124
|
+
# (e.g. `openssl rand -hex 16`) that does not occur in the argument text. Never run the literal NONCE delimiter. <!-- Added: forge#3466 -->
|
|
125
|
+
IFS= read -r -d '' ARGS_RAW <<'FORGE_ARGS_EOF_NONCE'
|
|
126
|
+
$ARGUMENTS
|
|
127
|
+
FORGE_ARGS_EOF_NONCE
|
|
128
|
+
ARGS_RAW="${ARGS_RAW%$'\n'}"
|
|
129
|
+
# Fail closed: a string holding a quote, backtick, dollar sign, backslash, newline or tab is rejected whole.
|
|
130
|
+
# Nothing is parsed from it, so no PR number, repo, merge value or auto-merge flag survives.
|
|
131
|
+
case "$ARGS_RAW" in
|
|
132
|
+
*'"'*|*'`'*|*'$'*|*'\'*|*$'\n'*|*$'\t'*)
|
|
133
|
+
echo "review-pr: rejected argument string (contains a quote, backtick, dollar sign, backslash, newline or tab) - nothing parsed, auto-merge not honoured" >&2
|
|
134
|
+
ARGS_RAW="" ;;
|
|
135
|
+
esac
|
|
136
|
+
read -r -a ARG_TOKENS <<< "$ARGS_RAW"
|
|
137
|
+
PR_REF="${ARG_TOKENS[0]:-}"
|
|
138
|
+
PR_NUMBER=""
|
|
139
|
+
case "$PR_REF" in
|
|
140
|
+
*[!0-9]*) PR_NUMBER=$(printf '%s' "$PR_REF" | sed -nE 's#^https?://[^ ]*/pull/([0-9]+).*$#\1#p') ;;
|
|
141
|
+
"") ;;
|
|
142
|
+
*) PR_NUMBER="$PR_REF" ;;
|
|
143
|
+
esac
|
|
144
|
+
AUTO_MERGE=false; THOROUGH=false
|
|
145
|
+
MERGE_ISSUE=""; MERGE_BASE=""; MERGE_WORKTREE=""; MERGE_GH_FLAG=""; REPO_FLAG=""; REPO=""
|
|
146
|
+
arg_i=1
|
|
147
|
+
case "$PR_REF" in -*) PR_NUMBER=""; arg_i=0 ;; esac # leading flag, no PR ref
|
|
148
|
+
while [ "$arg_i" -lt "${#ARG_TOKENS[@]}" ]; do
|
|
149
|
+
arg_t="${ARG_TOKENS[$arg_i]}"
|
|
150
|
+
case "$arg_t" in
|
|
151
|
+
--auto-merge) AUTO_MERGE=true ;; # flag parse only, no side effect <!-- allowlist:check-command-side-effects -->
|
|
152
|
+
--thorough) THOROUGH=true ;;
|
|
153
|
+
--issue|--base|--worktree)
|
|
154
|
+
# A value that is empty or starts with "-" is another flag: treat the value as missing, do not consume it
|
|
155
|
+
arg_v="${ARG_TOKENS[$((arg_i + 1))]:-}"
|
|
156
|
+
case "$arg_v" in
|
|
157
|
+
""|-*) echo "review-pr: $arg_t needs a value, got '${arg_v}' - ignoring" >&2 ;;
|
|
158
|
+
*) arg_i=$((arg_i + 1))
|
|
159
|
+
case "$arg_t" in
|
|
160
|
+
--issue) MERGE_ISSUE="$arg_v" ;;
|
|
161
|
+
--base) MERGE_BASE="$arg_v" ;;
|
|
162
|
+
--worktree) MERGE_WORKTREE="$arg_v" ;;
|
|
163
|
+
esac ;;
|
|
164
|
+
esac ;;
|
|
165
|
+
--gh-flag)
|
|
166
|
+
# Value is -R owner/repo, unquoted (a double quote rejects the whole string above; single quotes are
|
|
167
|
+
# stripped for older callers): consume flag + repo tokens
|
|
168
|
+
arg_f=$(printf '%s' "${ARG_TOKENS[$((arg_i + 1))]:-}" | tr -d "\"'")
|
|
169
|
+
if [ "$arg_f" = "-R" ] || [ "$arg_f" = "--repo" ]; then
|
|
170
|
+
arg_i=$((arg_i + 1))
|
|
171
|
+
arg_v=$(printf '%s' "${ARG_TOKENS[$((arg_i + 1))]:-}" | tr -d "\"'")
|
|
172
|
+
case "$arg_v" in
|
|
173
|
+
""|-*) echo "review-pr: --gh-flag needs owner/repo, got '${arg_v}' - ignoring" >&2 ;;
|
|
174
|
+
*) arg_i=$((arg_i + 1)); REPO="$arg_v" ;;
|
|
175
|
+
esac
|
|
176
|
+
else
|
|
177
|
+
echo "review-pr: --gh-flag expects -R owner/repo - ignoring" >&2
|
|
178
|
+
fi ;;
|
|
179
|
+
esac
|
|
180
|
+
arg_i=$((arg_i + 1))
|
|
181
|
+
done
|
|
182
|
+
# Shape-check parsed values before anything substitutes them into shell or jq; invalid values are
|
|
183
|
+
# cleared (fail closed: a merge request without a valid issue number returns BLOCKED in Phase 8).
|
|
184
|
+
if [ -n "$MERGE_ISSUE" ] && ! printf '%s' "$MERGE_ISSUE" | grep -qE '^[0-9]+$'; then
|
|
185
|
+
echo "review-pr: --issue must be numeric - ignoring '$MERGE_ISSUE'" >&2; MERGE_ISSUE=""
|
|
186
|
+
fi
|
|
187
|
+
if [ -n "$REPO" ] && ! printf '%s' "$REPO" | grep -qE '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'; then
|
|
188
|
+
echo "review-pr: --gh-flag repo must be owner/repo - ignoring '$REPO'" >&2; REPO=""
|
|
189
|
+
fi
|
|
190
|
+
if [ -n "$MERGE_BASE" ] && ! printf '%s' "$MERGE_BASE" | grep -qE '^[A-Za-z0-9._/-]+$'; then
|
|
191
|
+
echo "review-pr: --base must be a branch name - ignoring '$MERGE_BASE'" >&2; MERGE_BASE=""
|
|
192
|
+
fi
|
|
193
|
+
if [ -n "$MERGE_WORKTREE" ] && ! printf '%s' "$MERGE_WORKTREE" | grep -qE '^/[A-Za-z0-9._/@+=,:-]+$'; then
|
|
194
|
+
echo "review-pr: --worktree must be an absolute path without shell metacharacters - ignoring '$MERGE_WORKTREE'" >&2; MERGE_WORKTREE=""
|
|
195
|
+
fi
|
|
196
|
+
if [ -n "$REPO" ]; then MERGE_GH_FLAG="-R $REPO"; REPO_FLAG="-R $REPO"; fi
|
|
197
|
+
if [ -n "$PR_NUMBER" ] && [ -z "$REPO" ]; then
|
|
198
|
+
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || echo "")
|
|
199
|
+
[ -n "$REPO" ] && REPO_FLAG="-R $REPO"
|
|
200
|
+
fi
|
|
201
|
+
# END review-pr-arg-parse
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Argument injection hardening** <!-- Added: forge#3466 -->: the loader substitutes `$ARGUMENTS` textually, so a quoted assignment (`ARGS_RAW="..."`) can be broken out of by any caller that forwards text it does not control. The block therefore reads the string from a quoted heredoc and rejects any string containing a quote, backtick, dollar sign, backslash, newline or tab: it prints `review-pr: rejected argument string`, parses nothing, and binds `PR_NUMBER`, `REPO` and `MERGE_*` empty with `AUTO_MERGE=false`, so `--auto-merge` is never honoured from a rejected string (a run with no PR number stops in Phase -1 before any `gh pr view`). The heredoc delimiter is not a constant: the spec carries the placeholder `FORGE_ARGS_EOF_NONCE` and the executor MUST replace `NONCE` with a fresh random hex string before running, so a caller cannot pre-compute a delimiter line (a newline followed by the literal placeholder or any earlier public token stays inert text and is then rejected by the newline check). Residual risk: this depends on the executor following the NONCE rule; the complete fix is for the loader to pass arguments out of band. Callers must pass `--gh-flag -R owner/repo` unquoted.
|
|
110
205
|
|
|
111
206
|
**`--auto-merge` requires `--issue`** <!-- Added: forge#3102, forge#3124 -->: the phase-trail gate in Phase 8 verifies the trail of the linked issue, so `--issue` is mandatory whenever `AUTO_MERGE=true`. Do NOT infer `MERGE_ISSUE` from the PR: the PR body's closing reference and the head-branch suffix are both author-controlled (and the closing-issue reference list is empty for PRs based on a non-default branch such as `staging`), so neither is a trustworthy binding. If `AUTO_MERGE=true` and `MERGE_ISSUE` is empty, Phase 8 does not merge and returns the documented `REVIEW_RESULT: status: BLOCKED`, blocker: "auto-merge requires --issue". The PR stays open for a human or a re-run with `--issue`.
|
|
112
207
|
|
|
@@ -114,11 +209,11 @@ If `--auto-merge` is NOT present, `AUTO_MERGE=false` — Phase 8 (Auto-Merge) wi
|
|
|
114
209
|
|
|
115
210
|
### Thoroughness Flag
|
|
116
211
|
|
|
117
|
-
If
|
|
212
|
+
If the arguments contain `--thorough` (parsed above), set `THOROUGH=true`. This restores full union-dispatch (all matched domain agents run) for release-critical PRs. Default is `THOROUGH=false` — risk-scaled dispatch (2-3 agents).
|
|
118
213
|
|
|
119
214
|
```bash
|
|
120
|
-
THOROUGH
|
|
121
|
-
echo "
|
|
215
|
+
# THOROUGH is bound by the Argument Parse block (default false)
|
|
216
|
+
echo "THOROUGH=$THOROUGH"
|
|
122
217
|
```
|
|
123
218
|
|
|
124
219
|
---
|
|
@@ -130,18 +225,24 @@ echo "$ARGUMENTS" | grep -q "\-\-thorough" && THOROUGH=true
|
|
|
130
225
|
Resolve the PR number and post a routing marker immediately. This creates an audit trail — if a PR has no `FORGE:REVIEW_ROUTE` comment after a review command was run, the review was bypassed or never started.
|
|
131
226
|
|
|
132
227
|
```bash
|
|
133
|
-
# Determine REVIEW_MODE from
|
|
134
|
-
REVIEW_MODE_RAW="$
|
|
135
|
-
if echo "$
|
|
228
|
+
# Determine REVIEW_MODE from the first token (PR_REF, bound by Argument Parse) before any routing decision
|
|
229
|
+
REVIEW_MODE_RAW="$ARGS_RAW"
|
|
230
|
+
if echo "$PR_REF" | grep -qE '^(staging|feature|staging:feature)$'; then
|
|
136
231
|
REVIEW_MODE="staging-keyword"
|
|
137
232
|
ROUTE_PR_NUMBER="(resolved by staging sub-command)"
|
|
138
|
-
elif echo "$
|
|
233
|
+
elif echo "$PR_REF" | grep -qE '^(open|all)$'; then
|
|
139
234
|
REVIEW_MODE="multi-pr"
|
|
140
235
|
ROUTE_PR_NUMBER="(list mode)"
|
|
141
236
|
else
|
|
142
|
-
# Single PR number or URL
|
|
143
|
-
|
|
237
|
+
# Single PR number or URL. An empty PR_NUMBER (rejected or unparseable argument string) must stop here:
|
|
238
|
+
# `gh pr view ""` falls back to the current branch's PR and would review the wrong PR. <!-- Added: forge#3466 -->
|
|
239
|
+
if [ -z "$PR_NUMBER" ]; then
|
|
240
|
+
echo "review-pr: no PR number resolved from the argument string - stopping (nothing reviewed, nothing posted)" >&2
|
|
241
|
+
exit 1
|
|
242
|
+
fi
|
|
243
|
+
PR_ROUTE_INFO=$(gh pr view "$PR_NUMBER" ${REPO_FLAG} --json number,baseRefName,headRefName --jq '{number:.number,base:.baseRefName,head:.headRefName}')
|
|
144
244
|
ROUTE_PR_NUMBER=$(echo "$PR_ROUTE_INFO" | jq -r '.number')
|
|
245
|
+
PR_NUMBER="$ROUTE_PR_NUMBER"
|
|
145
246
|
ROUTE_HEAD=$(echo "$PR_ROUTE_INFO" | jq -r '.head')
|
|
146
247
|
ROUTE_BASE=$(echo "$PR_ROUTE_INFO" | jq -r '.base')
|
|
147
248
|
if [ "$ROUTE_HEAD" = "staging" ] && [ "$ROUTE_BASE" = "main" ] || [ "$ROUTE_HEAD" = "feature" ] && [ "$ROUTE_BASE" = "main" ]; then
|
|
@@ -151,7 +252,7 @@ else
|
|
|
151
252
|
fi
|
|
152
253
|
fi
|
|
153
254
|
|
|
154
|
-
REVIEW_SHA_ROUTE=$(gh pr view ${ROUTE_PR_NUMBER:-$
|
|
255
|
+
REVIEW_SHA_ROUTE=$(gh pr view "${ROUTE_PR_NUMBER:-$PR_NUMBER}" ${REPO_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null | cut -c1-7 || echo "n/a")
|
|
155
256
|
|
|
156
257
|
# spec-evolution guard — MUST run before posting FORGE:REVIEW_ROUTE <!-- Added: forge#1742 -->
|
|
157
258
|
# spec-evolution PRs are produced by /spec-doctor and require eval-gate + human review.
|
|
@@ -164,10 +265,10 @@ if [ "$REVIEW_MODE" = "single-pr" ] && [ -n "$ROUTE_PR_NUMBER" ] && [ "$ROUTE_PR
|
|
|
164
265
|
if [ "$SPEC_EVOL_CHECK" = "true" ]; then
|
|
165
266
|
IS_SPEC_EVOLUTION=true
|
|
166
267
|
AUTO_MERGE=false
|
|
167
|
-
# Add needs-human label to the associated issue (if --issue was passed
|
|
168
|
-
SPEC_EVOL_ISSUE
|
|
268
|
+
# Add needs-human label to the associated issue (if --issue was passed)
|
|
269
|
+
SPEC_EVOL_ISSUE="$MERGE_ISSUE"
|
|
169
270
|
if [ -n "$SPEC_EVOL_ISSUE" ]; then
|
|
170
|
-
GH_FLAG_SPEC
|
|
271
|
+
GH_FLAG_SPEC="$MERGE_GH_FLAG"
|
|
171
272
|
gh issue edit "$SPEC_EVOL_ISSUE" $GH_FLAG_SPEC --add-label "needs-human" 2>/dev/null || true
|
|
172
273
|
fi
|
|
173
274
|
echo "SPEC-EVOLUTION GUARD: PR #${ROUTE_PR_NUMBER} carries 'spec-evolution' label."
|
|
@@ -205,10 +306,10 @@ Check input to determine which mode:
|
|
|
205
306
|
|
|
206
307
|
### MODE 1: Staging Review
|
|
207
308
|
|
|
208
|
-
If `$ARGUMENTS` is "staging", "feature", or "staging:feature":
|
|
309
|
+
If `$ARGUMENTS` is "staging", "feature", or "staging:feature" (forward the validated `ARGS_RAW` from the Argument Parse block, never a rejected string):
|
|
209
310
|
|
|
210
311
|
```
|
|
211
|
-
>>> INVOKE: Skill("{FORGE_SKILL_PREFIX}review-pr-staging", "
|
|
312
|
+
>>> INVOKE: Skill("{FORGE_SKILL_PREFIX}review-pr-staging", "{ARGS_RAW}")
|
|
212
313
|
>>> THEN STOP — the staging command handles the full flow.
|
|
213
314
|
```
|
|
214
315
|
|
|
@@ -224,14 +325,15 @@ Show list, ask user which to review, then loop through each with full review.
|
|
|
224
325
|
|
|
225
326
|
**Auto-detect staging mode:**
|
|
226
327
|
```bash
|
|
227
|
-
|
|
328
|
+
[ -n "$PR_NUMBER" ] || { echo "review-pr: no PR number - stopping" >&2; exit 1; } # never let gh pick the current-branch PR <!-- Added: forge#3466 -->
|
|
329
|
+
PR_INFO=$(gh pr view "$PR_NUMBER" ${REPO_FLAG} --json baseRefName,headRefName,additions,deletions,title)
|
|
228
330
|
HEAD=$(echo $PR_INFO | jq -r '.headRefName')
|
|
229
331
|
BASE=$(echo $PR_INFO | jq -r '.baseRefName')
|
|
230
332
|
```
|
|
231
333
|
|
|
232
334
|
If `HEAD = "staging" AND BASE = "main"` OR `HEAD = "feature" AND BASE = "main"`:
|
|
233
335
|
```
|
|
234
|
-
>>> INVOKE: Skill("{FORGE_SKILL_PREFIX}review-pr-staging", "
|
|
336
|
+
>>> INVOKE: Skill("{FORGE_SKILL_PREFIX}review-pr-staging", "{ARGS_RAW}")
|
|
235
337
|
>>> THEN STOP.
|
|
236
338
|
```
|
|
237
339
|
|
|
@@ -251,22 +353,22 @@ Otherwise → proceed to Phase 1.
|
|
|
251
353
|
|
|
252
354
|
### 1A: Fetch PR Data
|
|
253
355
|
```bash
|
|
254
|
-
gh pr view $
|
|
255
|
-
REVIEW_SHA=$(gh pr view $
|
|
356
|
+
gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,body,author,baseRefName,headRefName,files,additions,deletions
|
|
357
|
+
REVIEW_SHA=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq '.headRefOid')
|
|
256
358
|
REVIEW_SHA_SHORT=$(echo "$REVIEW_SHA" | cut -c1-7)
|
|
257
|
-
gh pr diff $
|
|
258
|
-
gh pr diff $
|
|
359
|
+
gh pr diff "$PR_NUMBER" -R "$REPO" --name-only
|
|
360
|
+
gh pr diff "$PR_NUMBER" -R "$REPO"
|
|
259
361
|
|
|
260
362
|
# Mergeability check — GitHub computes this asynchronously; retry up to 3× on UNKNOWN
|
|
261
363
|
# <!-- Added: forge#194 -->
|
|
262
|
-
MERGE_HEALTH_RESULT=$(gh pr view $
|
|
364
|
+
MERGE_HEALTH_RESULT=$(gh pr view "$PR_NUMBER" -R "$REPO" --json mergeable,mergeStateStatus --jq '"\\(.mergeable)|\\(.mergeStateStatus)"')
|
|
263
365
|
MERGE_HEALTH=${MERGE_HEALTH_RESULT%%|*}
|
|
264
366
|
MERGE_HEALTH_STATE=${MERGE_HEALTH_RESULT##*|}
|
|
265
367
|
MERGE_RETRY=0
|
|
266
368
|
while [ "$MERGE_HEALTH" = "UNKNOWN" ] && [ "$MERGE_RETRY" -lt 3 ]; do
|
|
267
369
|
MERGE_RETRY=$((MERGE_RETRY + 1))
|
|
268
370
|
sleep 5
|
|
269
|
-
MERGE_HEALTH_RESULT=$(gh pr view $
|
|
371
|
+
MERGE_HEALTH_RESULT=$(gh pr view "$PR_NUMBER" -R "$REPO" --json mergeable,mergeStateStatus --jq '"\\(.mergeable)|\\(.mergeStateStatus)"')
|
|
270
372
|
MERGE_HEALTH=${MERGE_HEALTH_RESULT%%|*}
|
|
271
373
|
MERGE_HEALTH_STATE=${MERGE_HEALTH_RESULT##*|}
|
|
272
374
|
done
|
|
@@ -274,12 +376,12 @@ done
|
|
|
274
376
|
# MERGE_HEALTH_STATE: CLEAN | DIRTY | BLOCKED | UNSTABLE | UNKNOWN
|
|
275
377
|
|
|
276
378
|
# Resolve repo name early — used in Phases 5, 6, 8B, 9A (clean-review skip path bypasses Phase 6A) <!-- Added: forge#820 -->
|
|
277
|
-
REPO
|
|
379
|
+
REPO="${REPO:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}"
|
|
278
380
|
|
|
279
381
|
# First-time contributor detection <!-- Added: forge#960 -->
|
|
280
382
|
# Requires REPO to be set (line above). Checks whether the PR author has any prior merged PRs in this repo.
|
|
281
383
|
# IS_FIRST_TIME_CONTRIBUTOR is consumed in Phase 9 to conditionally post an onboarding welcome comment.
|
|
282
|
-
PR_AUTHOR=$(gh pr view "$
|
|
384
|
+
PR_AUTHOR=$(gh pr view "$PR_NUMBER" -R "$REPO" --json author --jq '.author.login')
|
|
283
385
|
PRIOR_MERGED_COUNT=$(gh pr list -R "$REPO" --author "$PR_AUTHOR" --state merged --limit 1 --json number --jq 'length' 2>/dev/null || echo "1")
|
|
284
386
|
IS_FIRST_TIME_CONTRIBUTOR="false"
|
|
285
387
|
[ "$PRIOR_MERGED_COUNT" -eq 0 ] && IS_FIRST_TIME_CONTRIBUTOR="true"
|
|
@@ -287,8 +389,8 @@ IS_FIRST_TIME_CONTRIBUTOR="false"
|
|
|
287
389
|
|
|
288
390
|
### 1B: Classify
|
|
289
391
|
```bash
|
|
290
|
-
FILES=$(gh pr diff $
|
|
291
|
-
DIFF=$(gh pr diff $
|
|
392
|
+
FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
|
|
393
|
+
DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO")
|
|
292
394
|
|
|
293
395
|
echo "=== SERVICES ==="
|
|
294
396
|
# Service detection is path-agnostic — matches project-specific and generic structures
|
|
@@ -348,7 +450,7 @@ else
|
|
|
348
450
|
fi
|
|
349
451
|
|
|
350
452
|
# Always run compile check on changed Python files (fast, language-universal)
|
|
351
|
-
gh pr diff $
|
|
453
|
+
gh pr diff "$PR_NUMBER" -R "$REPO" --name-only | grep '\.py$' | while IFS= read -r f; do
|
|
352
454
|
python3 -m py_compile "$f" 2>&1
|
|
353
455
|
done
|
|
354
456
|
```
|
|
@@ -392,19 +494,19 @@ Covered by `PYTHON_TYPECHECK` command in 2A above. If `verification.commands.pyt
|
|
|
392
494
|
|
|
393
495
|
### 2D: Environment Variable Audit
|
|
394
496
|
```bash
|
|
395
|
-
gh pr diff $
|
|
497
|
+
gh pr diff "$PR_NUMBER" -R "$REPO" | grep -E "os\.getenv|os\.environ|process\.env" | head -30
|
|
396
498
|
```
|
|
397
499
|
Flag if new env vars not in `.env.example`.
|
|
398
500
|
|
|
399
501
|
### 2E: Secrets Detection (CRITICAL — BLOCKING if found)
|
|
400
502
|
```bash
|
|
401
|
-
gh pr diff $
|
|
402
|
-
gh pr diff $
|
|
503
|
+
gh pr diff "$PR_NUMBER" -R "$REPO" | grep -iE "(api[_-]?key|secret[_-]?key|password|token|credential|private[_-]?key)" | grep -vE "(#|//|\.example|placeholder|PLACEHOLDER|YOUR_|<|>)" | head -20
|
|
504
|
+
gh pr diff "$PR_NUMBER" -R "$REPO" | grep -oE "['\"][A-Za-z0-9+/=]{40,}['\"]" | head -10
|
|
403
505
|
```
|
|
404
506
|
|
|
405
507
|
### 2F: SQL Migration Validation (if *.sql changed)
|
|
406
508
|
```bash
|
|
407
|
-
gh pr diff $
|
|
509
|
+
gh pr diff "$PR_NUMBER" -R "$REPO" --name-only | grep "\.sql$" | while IFS= read -r sql_file; do
|
|
408
510
|
grep -E "FOR UPDATE" "$sql_file" | grep -qE "(SUM|COUNT|AVG|MIN|MAX)\s*\(" && echo "ERROR: FOR UPDATE with aggregate"
|
|
409
511
|
grep -qE "DROP (TABLE|COLUMN|INDEX)" "$sql_file" && ! grep -qE "IF EXISTS" "$sql_file" && echo "WARNING: DROP without IF EXISTS"
|
|
410
512
|
grep -qE "ALTER TABLE.*ADD COLUMN.*NOT NULL" "$sql_file" && ! grep -qE "DEFAULT" "$sql_file" && echo "WARNING: NOT NULL without DEFAULT"
|
|
@@ -456,7 +558,7 @@ for lang in python typescript go rust; do
|
|
|
456
558
|
--base "$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null | sed 's|origin/||' || echo main)" \
|
|
457
559
|
--retries 3 2>&1)
|
|
458
560
|
echo "$CL_RESULT"
|
|
459
|
-
CL=$(echo "$CL_RESULT" | grep '^CLASSIFICATION:' | awk '{print $2}')
|
|
561
|
+
CL=$(echo "$CL_RESULT" | grep '^CLASSIFICATION:' | awk '{print $(2)}')
|
|
460
562
|
if [ "$CL" = "PRE_BROKEN" ] || [ "$CL" = "FLAKY" ]; then
|
|
461
563
|
echo "ADVISORY (not blocking): ${lang} tests classified ${CL} — quarantined, does not block this review"
|
|
462
564
|
else
|
|
@@ -481,7 +583,7 @@ fi
|
|
|
481
583
|
### 2I: Build Verification (MANDATORY for staging→main AND milestone→staging)
|
|
482
584
|
|
|
483
585
|
```bash
|
|
484
|
-
CHANGED_FILES=$(gh pr diff $
|
|
586
|
+
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
|
|
485
587
|
HAS_TS=$(echo "$CHANGED_FILES" | grep -E '\.(tsx?|jsx?)$' | head -1)
|
|
486
588
|
HAS_PY=$(echo "$CHANGED_FILES" | grep -E '\.py$' | head -1)
|
|
487
589
|
# Use POSIX-portable if/else (avoid bash-only [[ ]])
|
|
@@ -498,7 +600,7 @@ if [ "$IS_STAGING_TO_MAIN" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]
|
|
|
498
600
|
Read `forge.yaml → verification.commands.typescript.typecheck` and `.build`:
|
|
499
601
|
|
|
500
602
|
```bash
|
|
501
|
-
gh pr checkout $
|
|
603
|
+
gh pr checkout "$PR_NUMBER" -R "$REPO" --detach 2>/dev/null
|
|
502
604
|
|
|
503
605
|
TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
|
|
504
606
|
TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
|
|
@@ -531,7 +633,7 @@ If `BUILD_EXIT != 0`: **CONFIRMED blocking** — build/prerender failure.
|
|
|
531
633
|
Read `forge.yaml → verification.commands.python.format` and `.build`:
|
|
532
634
|
|
|
533
635
|
```bash
|
|
534
|
-
gh pr checkout $
|
|
636
|
+
gh pr checkout "$PR_NUMBER" -R "$REPO" --detach 2>/dev/null
|
|
535
637
|
|
|
536
638
|
# Compile-check all changed Python files (language-universal — no config needed)
|
|
537
639
|
echo "$CHANGED_FILES" | grep '\.py$' | while IFS= read -r f; do python3 -m py_compile "$f" 2>&1; done
|
|
@@ -556,10 +658,10 @@ Check whether the PR's actual changes match what the builder committed to in its
|
|
|
556
658
|
|
|
557
659
|
```bash
|
|
558
660
|
# Find the contract comment on the linked issue
|
|
559
|
-
ISSUE_NUM=$(gh pr view $
|
|
661
|
+
ISSUE_NUM=$(gh pr view "$PR_NUMBER" -R "$REPO" --json body --jq '.body | gsub("(?s).*?(?:Closes #|#)(?<n>[0-9]+).*"; "\(.n)") // empty' 2>/dev/null | head -1)
|
|
560
662
|
if [ -n "$ISSUE_NUM" ]; then
|
|
561
663
|
CONTRACT_FILES=$(gh api repos/${REPO}/issues/${ISSUE_NUM}/comments --jq '[.[] | select(.body | contains("FORGE:CONTRACT"))] | .[0].body' 2>/dev/null | grep -E '`[^`]+\.(py|tsx?|sql|sh|yml|yaml|json)`' | grep -oE '`[^`]+\.(py|tsx?|sql|sh|yml|yaml|json)`' | tr -d '`' | sort -u)
|
|
562
|
-
PR_FILES=$(gh pr diff $
|
|
664
|
+
PR_FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only | sort -u)
|
|
563
665
|
|
|
564
666
|
# Files in PR but NOT in contract
|
|
565
667
|
SCOPE_CREEP=$(comm -23 <(echo "$PR_FILES") <(echo "$CONTRACT_FILES") 2>/dev/null | grep -vE '\.(md|txt|example)$')
|
|
@@ -629,7 +731,7 @@ For each changed file, execute the relevant checks using the standalone verifica
|
|
|
629
731
|
**Platform note**: The verify-*.sh scripts require bash and standard POSIX tools. On Windows without bash (Git Bash / WSL / MSYS2), these checks are skipped with an explicit message — the review continues without them.
|
|
630
732
|
|
|
631
733
|
```bash
|
|
632
|
-
CHANGED_FILES=$(gh pr diff $
|
|
734
|
+
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
|
|
633
735
|
REPO_ROOT="." # Assumes cwd is the repo root
|
|
634
736
|
|
|
635
737
|
# Resolve the verify-*.sh scripts source directory with the same deterministic
|
|
@@ -663,7 +765,7 @@ if [ "$BASH_AVAILABLE" = "true" ]; then
|
|
|
663
765
|
CHANGED_FILES_TMP="/tmp/forge-review-changed-$$.tmp"
|
|
664
766
|
DIFF_TMP="/tmp/forge-review-diff-$$.tmp"
|
|
665
767
|
echo "$CHANGED_FILES" > "$CHANGED_FILES_TMP"
|
|
666
|
-
gh pr diff $
|
|
768
|
+
gh pr diff "$PR_NUMBER" -R "$REPO" > "$DIFF_TMP"
|
|
667
769
|
|
|
668
770
|
# --- Script-based checks (reusable, testable, deterministic) ---
|
|
669
771
|
# Each script exits 0 (pass), 1 (blocking findings), or 2 (warnings only).
|
|
@@ -702,7 +804,7 @@ if [ "$BASH_AVAILABLE" = "true" ]; then
|
|
|
702
804
|
| grep -E '^\s*-\s+' \
|
|
703
805
|
| sed 's/^\s*-\s*//' \
|
|
704
806
|
| tr -d '"'"'" \
|
|
705
|
-
| awk 'NR>1{printf "|"}{printf $0}END{print ""}')
|
|
807
|
+
| awk 'NR>1{printf "|"}{printf "%s", $(0)}END{print ""}')
|
|
706
808
|
fi
|
|
707
809
|
export FORGE_INTERNAL_PATTERNS
|
|
708
810
|
echo "=== Running: verify-host-headers.sh ==="
|
|
@@ -731,7 +833,7 @@ echo "$CHANGED_FILES" | grep -E '\.py$' | while IFS= read -r f; do
|
|
|
731
833
|
# Find function-scoped imports (indented import statements)
|
|
732
834
|
grep -nE "^\s+import [a-z]" "$f" 2>/dev/null | while read line; do
|
|
733
835
|
LINENO=$(echo "$line" | cut -d: -f1)
|
|
734
|
-
MODULE=$(echo "$line" | grep -oE "import [a-z_]+" | awk '{print $2}')
|
|
836
|
+
MODULE=$(echo "$line" | grep -oE "import [a-z_]+" | awk '{print $(2)}')
|
|
735
837
|
# Check if the same module is used BEFORE this line in the same function
|
|
736
838
|
# (simplified check — agents should do full scope analysis)
|
|
737
839
|
[ -n "$MODULE" ] && head -n $((LINENO-1)) "$f" 2>/dev/null | grep -qE "^\s+.*\b${MODULE}\." && \
|
|
@@ -784,8 +886,8 @@ if [ -n "$WORKFLOW_FILES" ] || [ "$IS_STAGING_PR" = "true" ]; then
|
|
|
784
886
|
# patterns simultaneously. The flag form sets p=1 on the header line,
|
|
785
887
|
# prints body lines while p=1, and clears p when the next sibling job
|
|
786
888
|
# header (same indentation, lowercase start) is seen. <!-- Added: forge#310 -->
|
|
787
|
-
CI_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
|
|
788
|
-
DEPLOY_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
|
|
889
|
+
CI_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
|
|
890
|
+
DEPLOY_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
|
|
789
891
|
|
|
790
892
|
# Check for PYTHONPATH specifically — the exact var that caused the #11356 failure
|
|
791
893
|
CI_PYPATH=$(echo "$CI_ENVS" | grep "PYTHONPATH" || echo "(not set)")
|
|
@@ -798,16 +900,16 @@ if [ -n "$WORKFLOW_FILES" ] || [ "$IS_STAGING_PR" = "true" ]; then
|
|
|
798
900
|
fi
|
|
799
901
|
|
|
800
902
|
# Check for dependency installation steps that exist in one but not the other
|
|
801
|
-
CI_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
|
|
802
|
-
DEPLOY_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
|
|
903
|
+
CI_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
|
|
904
|
+
DEPLOY_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
|
|
803
905
|
if [ "$CI_INSTALLS" != "$DEPLOY_INSTALLS" ]; then
|
|
804
906
|
echo " WARNING: Different number of dependency install steps in '$JOB' — ci.yml has $CI_INSTALLS, deploy has $DEPLOY_INSTALLS"
|
|
805
907
|
echo " ACTION: Read both files and verify all dependencies needed by tests are installed in both workflows."
|
|
806
908
|
fi
|
|
807
909
|
|
|
808
910
|
# Check step names — if CI has a step that deploy doesn't, flag it
|
|
809
|
-
CI_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
|
|
810
|
-
DEPLOY_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
|
|
911
|
+
CI_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
|
|
912
|
+
DEPLOY_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $(0)~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
|
|
811
913
|
MISSING_IN_DEPLOY=$(comm -23 <(echo "$CI_STEPS") <(echo "$DEPLOY_STEPS") 2>/dev/null || true)
|
|
812
914
|
if [ -n "$MISSING_IN_DEPLOY" ]; then
|
|
813
915
|
echo " WARNING: Steps in ci.yml '$JOB' missing from deploy-production.yml:"
|
|
@@ -835,8 +937,8 @@ Format: `INTEG-N|CONFIRMED|HIGH|file:line|Changed code may be unreachable: {reas
|
|
|
835
937
|
**NEVER scale review depth by line count.** A 5-line shell script processing LLM output is more dangerous than a 500-line React component.
|
|
836
938
|
|
|
837
939
|
```bash
|
|
838
|
-
DIFF=$(gh pr diff $
|
|
839
|
-
FILES=$(gh pr diff $
|
|
940
|
+
DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO")
|
|
941
|
+
FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
|
|
840
942
|
echo "=== RISK SIGNALS ==="
|
|
841
943
|
echo "$DIFF" | grep -cE "subprocess|exec|eval|system\(|popen|heredoc" && echo " UNTRUSTED_INPUT_PROCESSING" || true
|
|
842
944
|
echo "$DIFF" | grep -cE "\.sh$|bash|shell|cron" && echo " SHELL_SCRIPT" || true
|
|
@@ -985,7 +1087,7 @@ echo "AUTH=$SCORE_AUTH BILLING=$SCORE_BILLING CONCURRENCY=$SCORE_CONCURRENCY DAT
|
|
|
985
1087
|
|
|
986
1088
|
```bash
|
|
987
1089
|
CONTRACT_RISK_FLAGS=""
|
|
988
|
-
ISSUE_NUM=$(gh pr view $
|
|
1090
|
+
ISSUE_NUM=$(gh pr view "$PR_NUMBER" -R "$REPO" --json body --jq '.body | gsub("(?s).*?(?:Closes #|#)(?<n>[0-9]+).*"; "\(.n)") // empty' 2>/dev/null | head -1)
|
|
989
1091
|
if [ -n "$ISSUE_NUM" ]; then
|
|
990
1092
|
CONTRACT_BODY=$(gh api repos/${REPO}/issues/${ISSUE_NUM}/comments \
|
|
991
1093
|
--jq '[.[] | select(.body | contains("FORGE:CONTRACT"))] | .[0].body' 2>/dev/null || echo "")
|
|
@@ -1026,7 +1128,7 @@ Pick the top 1-2 domain agents by score:
|
|
|
1026
1128
|
DOMAIN_SCORES="$SCORE_AUTH:AUTH $SCORE_BILLING:BILLING $SCORE_CONCURRENCY:CONCURRENCY $SCORE_DATABASE:DATABASE $SCORE_INFRA:INFRA $SCORE_SCRAPING:SCRAPING $SCORE_FRONTEND:FRONTEND $SCORE_API:API"
|
|
1027
1129
|
|
|
1028
1130
|
# Sort descending by score, pick top 2 with score > 0
|
|
1029
|
-
TOP_DOMAINS=$(echo "$DOMAIN_SCORES" | tr ' ' '\n' | sort -t: -k1 -rn | head -2 | awk -F: '$1 > 0 {print $2}' | tr '\n' ' ')
|
|
1131
|
+
TOP_DOMAINS=$(echo "$DOMAIN_SCORES" | tr ' ' '\n' | sort -t: -k1 -rn | head -2 | awk -F: '$(1) > 0 {print $(2)}' | tr '\n' ' ')
|
|
1030
1132
|
for DOMAIN in $TOP_DOMAINS; do
|
|
1031
1133
|
SELECTED_AGENTS="$SELECTED_AGENTS $DOMAIN"
|
|
1032
1134
|
done
|
|
@@ -1053,7 +1155,7 @@ echo "=== BASELINE ROSTER (top domains): $SELECTED_AGENTS ==="
|
|
|
1053
1155
|
```bash
|
|
1054
1156
|
# Apply escalation triggers
|
|
1055
1157
|
add_agent() {
|
|
1056
|
-
local AGENT="$1"
|
|
1158
|
+
local AGENT="${1}"
|
|
1057
1159
|
echo "$SELECTED_AGENTS" | grep -qw "$AGENT" || SELECTED_AGENTS="$SELECTED_AGENTS $AGENT"
|
|
1058
1160
|
}
|
|
1059
1161
|
|
|
@@ -1065,7 +1167,7 @@ add_agent() {
|
|
|
1065
1167
|
[ "$SCORE_SCRAPING" -ge 3 ] && [ -n "$DOMAIN_CONTEXT_SCRAPING" ] && add_agent "Scraping"
|
|
1066
1168
|
[ "$CHURN_ESCALATION" = "true" ] && {
|
|
1067
1169
|
# Add the top-scoring domain for deeper churn scrutiny if not already selected
|
|
1068
|
-
TOP_CHURN_DOMAIN=$(echo "$DOMAIN_SCORES" | tr ' ' '\n' | sort -t: -k1 -rn | head -1 | awk -F: '{print $2}')
|
|
1170
|
+
TOP_CHURN_DOMAIN=$(echo "$DOMAIN_SCORES" | tr ' ' '\n' | sort -t: -k1 -rn | head -1 | awk -F: '{print $(2)}')
|
|
1069
1171
|
[ -n "$TOP_CHURN_DOMAIN" ] && add_agent "$TOP_CHURN_DOMAIN"
|
|
1070
1172
|
}
|
|
1071
1173
|
echo "$CONTRACT_RISK_FLAGS" | grep -q "HIGH_RISK" && {
|
|
@@ -1151,7 +1253,7 @@ if [ "$PROVENANCE_TABLE_LOADED" = "true" ]; then
|
|
|
1151
1253
|
done | sort -u | tr '\n' '|' | sed 's/|$//')
|
|
1152
1254
|
|
|
1153
1255
|
# Determine task type from FORGE:INVESTIGATOR annotation on the linked issue (if available)
|
|
1154
|
-
ISSUE_NUM_FOR_TRUST=$(gh pr view "$
|
|
1256
|
+
ISSUE_NUM_FOR_TRUST=$(gh pr view "$PR_NUMBER" -R "$REPO" --json body \
|
|
1155
1257
|
--jq '.body | gsub("(?s).*?(?:Closes #|#)(?<n>[0-9]+).*"; "\(.n)") // ""' 2>/dev/null | head -1)
|
|
1156
1258
|
TASK_TYPE_FOR_TRUST=""
|
|
1157
1259
|
if [ -n "$ISSUE_NUM_FOR_TRUST" ]; then
|
|
@@ -1161,7 +1263,7 @@ if [ "$PROVENANCE_TABLE_LOADED" = "true" ]; then
|
|
|
1161
1263
|
fi
|
|
1162
1264
|
# Fall back to title-based inference if INVESTIGATOR comment absent
|
|
1163
1265
|
if [ -z "$TASK_TYPE_FOR_TRUST" ]; then
|
|
1164
|
-
PR_TITLE=$(gh pr view "$
|
|
1266
|
+
PR_TITLE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json title --jq '.title' 2>/dev/null || echo "")
|
|
1165
1267
|
case "$PR_TITLE" in
|
|
1166
1268
|
fix*|Fix*) TASK_TYPE_FOR_TRUST="Bug Fix" ;;
|
|
1167
1269
|
feat*|Feat*) TASK_TYPE_FOR_TRUST="Feature" ;;
|
|
@@ -1364,7 +1466,7 @@ if [[ "$TEMPLATE_SOURCE" == "none" ]]; then
|
|
|
1364
1466
|
fi
|
|
1365
1467
|
```
|
|
1366
1468
|
|
|
1367
|
-
**If `TEMPLATE_SOURCE` is `none`**: HARD STOP. Post a PR comment explaining the setup is broken (`gh pr comment $
|
|
1469
|
+
**If `TEMPLATE_SOURCE` is `none`**: HARD STOP. Post a PR comment explaining the setup is broken (`gh pr comment "$PR_NUMBER" -R "$REPO" --body "..."`) instructing the user to run `npx forgedock update` to repair the install, add `needs-human`, and exit Phase 3 without posting any findings or a `FORGE:REVIEW` verdict. **NEVER perform the review inline in the main agent context as a substitute.** A degraded solo review that presents itself as complete is worse than no review — missing templates must fail loudly, not silently.
|
|
1368
1470
|
|
|
1369
1471
|
**If `TEMPLATE_SOURCE` is `plugin_root`, `forge_home` or `repo_path`** (the normal cases — behavior unchanged from before this guard existed):
|
|
1370
1472
|
```
|
|
@@ -1420,10 +1522,10 @@ fi
|
|
|
1420
1522
|
# Build domain-scoped index slices for each selected agent's domain
|
|
1421
1523
|
# Replace {DOMAIN} with the agent's domain label (auth, billing, database, api, frontend, etc.)
|
|
1422
1524
|
build_index_slice() {
|
|
1423
|
-
local domain="$1"
|
|
1525
|
+
local domain="${1}"
|
|
1424
1526
|
if [[ -x "$CODE_INDEX_SCRIPT" ]]; then
|
|
1425
1527
|
local files
|
|
1426
|
-
files=$(bash "$CODE_INDEX_SCRIPT" query --domain "$domain" --repo-path "$REPO_PATH" 2>/dev/null | awk -F'\t' '{print $1}' | head -30 | tr '\n' ' ')
|
|
1528
|
+
files=$(bash "$CODE_INDEX_SCRIPT" query --domain "$domain" --repo-path "$REPO_PATH" 2>/dev/null | awk -F'\t' '{print $(1)}' | head -30 | tr '\n' ' ')
|
|
1427
1529
|
echo "Domain files (${domain}): ${files:-none}"
|
|
1428
1530
|
else
|
|
1429
1531
|
echo "Code index not available — agent will use grep exploration"
|
|
@@ -1447,8 +1549,8 @@ Each domain agent receives only the diff slice relevant to its domain, not the f
|
|
|
1447
1549
|
|
|
1448
1550
|
```bash
|
|
1449
1551
|
# Full diff fetched once — agents do NOT re-fetch it
|
|
1450
|
-
FULL_DIFF=$(gh pr diff $
|
|
1451
|
-
FULL_FILES=$(gh pr diff $
|
|
1552
|
+
FULL_DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO")
|
|
1553
|
+
FULL_FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only)
|
|
1452
1554
|
|
|
1453
1555
|
# --- Security agent: always receives the full diff (cross-cutting domain) ---
|
|
1454
1556
|
DIFF_SLICE_SECURITY="$FULL_DIFF"
|
|
@@ -1523,7 +1625,7 @@ DIFF_SLICE_SCRAPER=$(echo "$FULL_DIFF" | awk '
|
|
|
1523
1625
|
```bash
|
|
1524
1626
|
# Truncate any slice exceeding 100K chars before passing to agent
|
|
1525
1627
|
truncate_slice() {
|
|
1526
|
-
local slice="$1"
|
|
1628
|
+
local slice="${1}"
|
|
1527
1629
|
local limit=102400
|
|
1528
1630
|
if [ "${#slice}" -gt "$limit" ]; then
|
|
1529
1631
|
echo "${slice:0:$limit}"
|
|
@@ -1554,30 +1656,64 @@ The `protocols.md` file contains the Evidence-Based Review Protocol, Structured
|
|
|
1554
1656
|
8. If Phase 2.5 found broken assumptions, append them to the agent's prompt as "Pre-found integration issues to verify"
|
|
1555
1657
|
9. Launch via the resolved `{DISPATCH_TOOL}` (see Sub-Agent Dispatch Tool Resolution above) with `model: "{SUBAGENT_MODEL}"` (forge.yaml `agents.subagent_model`, else `agents.default_model`, else `"sonnet"`; fallback `"opus"` if rate-limited). Under OpenCode, emit a top-level `subagent_type: "general"` or `"explore"` in the native `task` argument object and use `background: false` for each reviewer.
|
|
1556
1658
|
|
|
1557
|
-
**
|
|
1659
|
+
**Reviewer-comment trust and shape (applies to every check below and in Phase 4)**: a reviewer comment counts only when it is from a trusted author (`scripts/trusted-comments.sh`, the same predicate as the in-PR fix gate and `verify-phase-trail.sh`: trusted association, Bot account, or `FORGE_TRAIL_TRUSTED_LOGINS`), its body **starts with** `<!-- FORGE:REVIEW-AGENT:{domain} -->`, and it carries a line `Reviewed-SHA: ${REVIEW_SHA}` for the current head. The head SHA is public, so the SHA line alone proves nothing: an untrusted commenter must never be able to suppress dispatch or satisfy the panel guard. If the trust script cannot be resolved, or its count is not an integer, the reviewer is treated as **not posted** (fail closed: re-dispatch in 3C, missing in Phase 4).
|
|
1558
1660
|
|
|
1559
|
-
**
|
|
1661
|
+
**Idempotent re-entry (before launching)**: a re-invoked review must reuse reviewers that already posted for the current head instead of re-dispatching the full panel. For each domain in `$SELECTED_AGENTS`, check for an existing trusted comment of the shape above; launch only the domains without one (possibly none), then continue to Phase 4. Comments for a different SHA never count. The selected roster for the panel guard stays the full `$SELECTED_AGENTS` set.
|
|
1560
1662
|
|
|
1561
1663
|
```bash
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1664
|
+
# Each Bash call is a fresh shell: re-declare the state this block needs with the orchestrator's values.
|
|
1665
|
+
REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; REVIEW_SHA="{REVIEW_SHA}"; SELECTED_AGENTS="{SELECTED_AGENTS}"
|
|
1666
|
+
case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
|
|
1667
|
+
if [ "$REVIEW_SHA_OK" != "true" ]; then
|
|
1668
|
+
# An empty or malformed SHA would make the Reviewed-SHA match accept any head (fail open): refuse instead.
|
|
1669
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA '${REVIEW_SHA}' is not a 40/64-hex commit id; re-run /review-pr"; exit 0
|
|
1670
|
+
fi
|
|
1671
|
+
WAIT_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/forge-review-wait-$(id -u)" # absolute and identical in every Bash call (cwd is not)
|
|
1672
|
+
rm -f "${WAIT_DIR}/${PR_NUMBER}_${REVIEW_SHA}_review-wait-deadline" # a (re)dispatch always starts a fresh wait window
|
|
1673
|
+
_l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
|
|
1674
|
+
TRUSTED_SCRIPT=""
|
|
1675
|
+
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
|
|
1676
|
+
case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
|
|
1677
|
+
done
|
|
1678
|
+
PENDING_AGENTS=""
|
|
1679
|
+
for AGENT in $SELECTED_AGENTS; do
|
|
1680
|
+
AGENT_DOMAIN=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
|
|
1681
|
+
HAVE=""
|
|
1682
|
+
if [ -n "$TRUSTED_SCRIPT" ]; then
|
|
1683
|
+
HAVE=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
|
|
1684
|
+
| bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
|
|
1685
|
+
fi
|
|
1686
|
+
case "$HAVE" in ''|*[!0-9]*) HAVE=0 ;; esac # fail closed: unreadable or untrusted counts as not posted
|
|
1687
|
+
[ "$HAVE" -lt 1 ] && PENDING_AGENTS="${PENDING_AGENTS} ${AGENT}"
|
|
1688
|
+
done
|
|
1689
|
+
echo "PENDING_AGENTS:${PENDING_AGENTS:- (none)}"
|
|
1690
|
+
```
|
|
1691
|
+
|
|
1692
|
+
**CRITICAL**: Launch ALL agents in `PENDING_AGENTS` in a SINGLE message using multiple `{DISPATCH_TOOL}` calls. If `PENDING_AGENTS` is empty, every selected reviewer already posted for this head: launch nothing and go straight to Phase 4. Each agent must persist its finalized body before posting it with `gh pr comment --body-file`, start the body with `<!-- FORGE:REVIEW-AGENT:{lowercase-domain} -->` followed by a `Reviewed-SHA: ${REVIEW_SHA}` line (the full head SHA, outside the marker), and return its verdict and findings to the orchestrator independently of GitHub delivery.
|
|
1693
|
+
|
|
1694
|
+
**Dispatch failure and partial-panel guard (MANDATORY):** Count the selected roster before dispatch. If any launch fails, including from pool exhaustion, do not continue with the agents that did launch as a sufficient panel. Immediately run the **Panel hard-stop block** below with `ACTUAL_AGENT_COUNT=0` and exit without a verdict. After reviewers complete (joined `Task`/`task` return) or, under async `Agent`, after the Phase 4 bounded wait ends, independently compare their posted trusted current-SHA `FORGE:REVIEW-AGENT` markers with the selected count; a smaller count is the same hard stop. This catches a reviewer that accepted dispatch but failed before posting. Under async `Agent` the comparison runs only after the wait, so a reviewer that is merely late is not a false degraded stop.
|
|
1695
|
+
|
|
1696
|
+
**Panel hard-stop block** (run as its own Bash call from 3C on a launch failure and from Phase 4 on a timeout or shortfall; it is self-contained because each Bash call is a fresh shell and nothing defined in another block survives):
|
|
1565
1697
|
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1698
|
+
```bash
|
|
1699
|
+
REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; MERGE_ISSUE="{MERGE_ISSUE}" # MERGE_ISSUE empty when no --issue
|
|
1700
|
+
SELECTED_AGENT_COUNT=$(printf '%s' "{SELECTED_AGENTS}" | tr ' ' '\n' | grep -c '.' || true)
|
|
1701
|
+
ACTUAL_AGENT_COUNT="{ACTUAL_AGENT_COUNT}" # 0 on a launch failure; the Phase 4 count otherwise
|
|
1702
|
+
gh label create "review-degraded" --color "E4E669" --description "PR review panel was incomplete; re-review required before deployment. Managed by ForgeDock." --force -R "$REPO" 2>/dev/null || true
|
|
1703
|
+
gh pr edit "$PR_NUMBER" -R "$REPO" --add-label "review-degraded" --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
1704
|
+
gh pr comment "$PR_NUMBER" -R "$REPO" --body "<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->
|
|
1570
1705
|
## Review Blocked: Incomplete Isolated Review Panel
|
|
1571
1706
|
|
|
1572
1707
|
**Selected isolated reviewers**: ${SELECTED_AGENT_COUNT}
|
|
1573
|
-
**Completed isolated reviewers**: ${ACTUAL_AGENT_COUNT}
|
|
1708
|
+
**Completed isolated reviewers**: ${ACTUAL_AGENT_COUNT:-0}
|
|
1574
1709
|
|
|
1575
|
-
At least one reviewer could not be dispatched or complete, commonly because the per-session sub-agent pool was exhausted. No inline substitution was performed. Re-run the full panel in a fresh session before merging."
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
fi
|
|
1710
|
+
At least one reviewer could not be dispatched or complete, commonly because the per-session sub-agent pool was exhausted or the bounded wait for current-SHA reviewer comments expired. No inline substitution was performed. Re-run the full panel in a fresh session before merging."
|
|
1711
|
+
if [ -n "$MERGE_ISSUE" ]; then gh issue edit "$MERGE_ISSUE" {MERGE_GH_FLAG} --add-label "needs-human" 2>/dev/null || true; fi # allowlist:check-command-side-effects
|
|
1712
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: incomplete review panel (${ACTUAL_AGENT_COUNT:-0}/${SELECTED_AGENT_COUNT})"
|
|
1579
1713
|
```
|
|
1580
1714
|
|
|
1715
|
+
After this block prints its `REVIEW_RESULT` line, stop: return that result with no verdict.
|
|
1716
|
+
|
|
1581
1717
|
#### Domain Diff Slicing
|
|
1582
1718
|
|
|
1583
1719
|
Each agent's prompt receives `[FILE_LIST]` scoped to its domain-relevant files (computed in Phase 3B as `DOMAIN_FILES_*`). This reduces per-agent token cost — each agent reads only the diff slice relevant to its domain, not the full changeset.
|
|
@@ -1602,32 +1738,90 @@ When substituting `[FILE_LIST]` in each agent's template:
|
|
|
1602
1738
|
|
|
1603
1739
|
## Phase 4: Wait for Agents
|
|
1604
1740
|
|
|
1741
|
+
Reviewer completion is read from GitHub, never from notifications (see the "Reviewer completion model" under Sub-Agent Dispatch Tool Resolution). A reviewer counts as complete only when a **trusted** comment (see the trust rule in Phase 3C) starting with `<!-- FORGE:REVIEW-AGENT:{domain} -->` also carries `Reviewed-SHA: <current head>`.
|
|
1742
|
+
|
|
1743
|
+
- **Joined tools** (`Task`, OpenCode `task`): the reviewers already returned, so one pass decides: anything missing is a delivery failure.
|
|
1744
|
+
- **Async `Agent`** (and any tool not known to be joined): bounded wait, **one poll pass per Bash call**. A Bash call is capped at 120s by default (600s max), so a single 900s sleep loop would be killed mid-wait and the hard stop would never run. The first pass records an epoch deadline in a private, absolute state file keyed by PR and head SHA (`$XDG_RUNTIME_DIR`/`$TMPDIR`, never the cwd, which changes between calls); each later pass re-reads it, so the deadline survives across calls. Every exit path removes it, and Phase 3C removes it before (re)dispatch, so a re-review never inherits an expired window. Re-run the block while it prints `PANEL_STATUS: WAITING` (it sleeps `REVIEWER_POLL_SECS`, at most 60s, before exiting).
|
|
1745
|
+
- Each pass re-reads the PR head. If it moved off `REVIEW_SHA`, stop: the panel reviewed a stale head, so the block returns the documented `REVIEW_RESULT` BLOCKED (stale review). If the head probe itself fails, that is a BLOCKED result too (fail closed), never a pass.
|
|
1746
|
+
- `PANEL_STATUS: TIMEOUT` or `PANEL_STATUS: SHORTFALL`, or a poll call that was killed or timed out, is the hard stop: run the **Panel hard-stop block** from Phase 3C with `ACTUAL_AGENT_COUNT` set to the count the block printed (0 if the call was killed). Never review the missing domain inline.
|
|
1747
|
+
|
|
1605
1748
|
```bash
|
|
1606
|
-
|
|
1607
|
-
|
|
1749
|
+
# Each Bash call is a fresh shell: re-declare state with the orchestrator's values (never rely on Phase 3C variables).
|
|
1750
|
+
REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; REVIEW_SHA="{REVIEW_SHA}"; SELECTED_AGENTS="{SELECTED_AGENTS}"
|
|
1751
|
+
DISPATCH_TOOL="{DISPATCH_TOOL}" # Task | Agent | task (OpenCode)
|
|
1752
|
+
case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
|
|
1753
|
+
if [ "$REVIEW_SHA_OK" != "true" ]; then
|
|
1754
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA '${REVIEW_SHA}' is not a 40/64-hex commit id; re-run /review-pr"
|
|
1755
|
+
echo "PANEL_STATUS: STOP"; exit 0
|
|
1756
|
+
fi
|
|
1757
|
+
# Deadline state is keyed by PR AND head SHA, so a re-review of a new head never inherits an expired deadline.
|
|
1758
|
+
WAIT_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/forge-review-wait-$(id -u)" # absolute and identical in every Bash call (cwd is not)
|
|
1759
|
+
mkdir -m 700 -p "$WAIT_DIR" 2>/dev/null
|
|
1760
|
+
if [ ! -d "$WAIT_DIR" ] || [ ! -O "$WAIT_DIR" ] || [ -L "$WAIT_DIR" ]; then
|
|
1761
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: reviewer-wait state dir ${WAIT_DIR} is not a private directory owned by this user"
|
|
1762
|
+
echo "PANEL_STATUS: STOP"; exit 0
|
|
1763
|
+
fi
|
|
1764
|
+
DEADLINE_FILE="${WAIT_DIR}/${PR_NUMBER}_${REVIEW_SHA}_review-wait-deadline"
|
|
1765
|
+
|
|
1766
|
+
REVIEWER_WAIT_SECS="${REVIEWER_WAIT_SECS:-900}"
|
|
1767
|
+
case "$REVIEWER_WAIT_SECS" in ''|*[!0-9]*) REVIEWER_WAIT_SECS=900 ;; esac
|
|
1768
|
+
[ "$REVIEWER_WAIT_SECS" -gt 3600 ] && REVIEWER_WAIT_SECS=3600
|
|
1769
|
+
REVIEWER_POLL_SECS=30
|
|
1770
|
+
SELECTED_AGENT_COUNT=$(printf '%s' "$SELECTED_AGENTS" | tr ' ' '\n' | grep -c '.' || true)
|
|
1771
|
+
|
|
1772
|
+
# Joined tools return only when the reviewer is done: no waiting. Everything else polls to the deadline.
|
|
1773
|
+
case "$DISPATCH_TOOL" in Task|task) JOINED=true ;; *) JOINED=false ;; esac
|
|
1774
|
+
NOW=$(date +%s)
|
|
1775
|
+
[ -s "$DEADLINE_FILE" ] || echo $(( NOW + REVIEWER_WAIT_SECS )) > "$DEADLINE_FILE"
|
|
1776
|
+
DEADLINE=$(cat "$DEADLINE_FILE" 2>/dev/null)
|
|
1777
|
+
case "$DEADLINE" in ''|*[!0-9]*) DEADLINE=$NOW ;; esac
|
|
1778
|
+
|
|
1779
|
+
NOW_SHA=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
|
|
1780
|
+
if [ -z "$NOW_SHA" ]; then
|
|
1781
|
+
rm -f "$DEADLINE_FILE"
|
|
1782
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: head probe failed during reviewer wait; re-run /review-pr"
|
|
1783
|
+
echo "PANEL_STATUS: STOP"; exit 0
|
|
1784
|
+
fi
|
|
1785
|
+
if [ "$NOW_SHA" != "$REVIEW_SHA" ]; then
|
|
1786
|
+
rm -f "$DEADLINE_FILE"
|
|
1787
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: stale review (head moved from ${REVIEW_SHA} to ${NOW_SHA} during the reviewer wait); re-run /review-pr on the new head"
|
|
1788
|
+
echo "PANEL_STATUS: STOP"; exit 0
|
|
1789
|
+
fi
|
|
1608
1790
|
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1791
|
+
_l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
|
|
1792
|
+
TRUSTED_SCRIPT=""
|
|
1793
|
+
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
|
|
1794
|
+
case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
|
|
1795
|
+
done
|
|
1796
|
+
COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
|
|
1612
1797
|
MISSING_AGENT_COMMENTS=""
|
|
1798
|
+
ACTUAL_AGENT_COUNT=0
|
|
1613
1799
|
for AGENT in $SELECTED_AGENTS; do
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1800
|
+
AGENT_DOMAIN=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
|
|
1801
|
+
N=""
|
|
1802
|
+
if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$COMMENTS_JSON" ]; then
|
|
1803
|
+
N=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
|
|
1804
|
+
fi
|
|
1805
|
+
case "$N" in ''|*[!0-9]*) N=0 ;; esac # fail closed
|
|
1806
|
+
if [ "$N" -lt 1 ]; then MISSING_AGENT_COMMENTS="${MISSING_AGENT_COMMENTS} ${AGENT_DOMAIN}"; else ACTUAL_AGENT_COUNT=$(( ACTUAL_AGENT_COUNT + 1 )); fi
|
|
1621
1807
|
done
|
|
1622
1808
|
|
|
1623
|
-
if [ -
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1809
|
+
if [ -z "$MISSING_AGENT_COMMENTS" ] && [ "$ACTUAL_AGENT_COUNT" -ge "$SELECTED_AGENT_COUNT" ]; then
|
|
1810
|
+
rm -f "$DEADLINE_FILE"; echo "PANEL_STATUS: COMPLETE (${ACTUAL_AGENT_COUNT}/${SELECTED_AGENT_COUNT})"
|
|
1811
|
+
elif [ "$JOINED" = "true" ] || [ "$(date +%s)" -ge "$DEADLINE" ]; then
|
|
1812
|
+
rm -f "$DEADLINE_FILE"
|
|
1813
|
+
echo "REVIEW DELIVERY FAILURE: missing trusted current-SHA findings comment(s) for:${MISSING_AGENT_COMMENTS}"
|
|
1814
|
+
echo "Use each agent's returned verdict, findings, and durable body path to recover the review."
|
|
1815
|
+
[ "$JOINED" = "true" ] && echo "PANEL_STATUS: SHORTFALL (ACTUAL_AGENT_COUNT=${ACTUAL_AGENT_COUNT})" || echo "PANEL_STATUS: TIMEOUT (ACTUAL_AGENT_COUNT=${ACTUAL_AGENT_COUNT})"
|
|
1816
|
+
else
|
|
1817
|
+
echo "PANEL_STATUS: WAITING (${ACTUAL_AGENT_COUNT}/${SELECTED_AGENT_COUNT}; missing:${MISSING_AGENT_COMMENTS})"
|
|
1818
|
+
sleep "$REVIEWER_POLL_SECS"
|
|
1627
1819
|
fi
|
|
1628
1820
|
```
|
|
1629
1821
|
|
|
1630
|
-
|
|
1822
|
+
On `PANEL_STATUS: STOP`, return the printed `REVIEW_RESULT` and do nothing else (no hard stop, no verdict, no label change: the panel is intact, only the head moved).
|
|
1823
|
+
|
|
1824
|
+
**Do NOT proceed until ALL selected agents' current-SHA comments are visible on the PR.** Under OpenCode, each foreground `task(..., background: false)` returns only when that reviewer is complete; verify its corresponding structured PR comment before continuing. A missing comment after the wait is an explicit delivery failure: do not synthesize a verdict or treat it as a clean result. Do not infer a clean review from a missing comment: a reviewer's return text may contain findings that could not be posted because GitHub writes were throttled.
|
|
1631
1825
|
|
|
1632
1826
|
---
|
|
1633
1827
|
|
|
@@ -1636,11 +1830,32 @@ fi
|
|
|
1636
1830
|
**Skip if**: Only 1 agent OR total findings ≤ 3.
|
|
1637
1831
|
|
|
1638
1832
|
```bash
|
|
1833
|
+
# Each Bash call is a fresh shell: re-declare state with the orchestrator's values.
|
|
1834
|
+
REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; REVIEW_SHA="{REVIEW_SHA}"
|
|
1835
|
+
case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
|
|
1836
|
+
[ "$REVIEW_SHA_OK" = "true" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA is not a commit id (an empty SHA would count every head's findings)"; exit 0; }
|
|
1639
1837
|
# Extract structured finding IDs from FINDING HTML comments.
|
|
1640
|
-
#
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1838
|
+
# Only TRUSTED reviewer bodies count (scripts/trusted-comments.sh: trusted association, Bot account, or
|
|
1839
|
+
# FORGE_TRAIL_TRUSTED_LOGINS), each must START with the FORGE:REVIEW-AGENT marker and carry a Reviewed-SHA line for the
|
|
1840
|
+
# CURRENT head. The head SHA is public, so the SHA line alone proves nothing: a bare contains() selection would let any
|
|
1841
|
+
# commenter forge FINDING markers that get filed as issues or inflate the agent count. Earlier-head comments are excluded
|
|
1842
|
+
# so a re-entry or head move does not re-file already-fixed findings. Uses jq scan() (POSIX-portable, no PCRE grep).
|
|
1843
|
+
_l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
|
|
1844
|
+
TRUSTED_SCRIPT=""
|
|
1845
|
+
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
|
|
1846
|
+
case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
|
|
1847
|
+
done
|
|
1848
|
+
COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
|
|
1849
|
+
AGENT_RE="^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)"
|
|
1850
|
+
if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then
|
|
1851
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read reviewer findings (fail closed)"; exit 0
|
|
1852
|
+
fi
|
|
1853
|
+
AGENT_BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$AGENT_RE" 2>/dev/null) \
|
|
1854
|
+
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: trusted reviewer comments unreadable (fail closed)"; exit 0; }
|
|
1855
|
+
ALL_FINDINGS=$(printf '%s\n' "$AGENT_BODIES" | jq -r 'scan("<!-- FINDING:([^>]+) -->") | .[0]' 2>/dev/null)
|
|
1856
|
+
# Agent bodies start with the REVIEW-AGENT marker and carry REVIEW-FINDINGS-START inside, so count it with a lookahead.
|
|
1857
|
+
AGENT_COUNT=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
|
|
1858
|
+
case "$AGENT_COUNT" in ''|*[!0-9]*) AGENT_COUNT=0 ;; esac # fail closed
|
|
1644
1859
|
FINDING_COUNT=$(echo "$ALL_FINDINGS" | grep -c '.' || echo 0)
|
|
1645
1860
|
```
|
|
1646
1861
|
|
|
@@ -1648,7 +1863,7 @@ If synthesis needed, launch a `general-purpose` Task (model: `"{SUBAGENT_MODEL}"
|
|
|
1648
1863
|
- Deduplicate findings by file + line range ±5 (keep higher confidence)
|
|
1649
1864
|
- Resolve contradictions by reading disputed code
|
|
1650
1865
|
- Dismiss false positives with evidence
|
|
1651
|
-
- Post synthesis comment with `<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->`
|
|
1866
|
+
- Post synthesis comment whose body **starts with** `<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->` (first line, nothing before it), followed by a `Reviewed-SHA: ${REVIEW_SHA}` line on its own line (Phase 6A only reads trusted synthesized blocks that open with the marker and match the current head; a body with a preamble is ignored and Phase 6A falls back to the raw agent bodies)
|
|
1652
1867
|
- Do NOT add new findings — only triage existing ones
|
|
1653
1868
|
|
|
1654
1869
|
**IMPORTANT**: When synthesized block exists, Phase 6 MUST use it instead of raw findings.
|
|
@@ -1662,17 +1877,39 @@ If synthesis needed, launch a `general-purpose` Task (model: `"{SUBAGENT_MODEL}"
|
|
|
1662
1877
|
### 6A: Extract Findings
|
|
1663
1878
|
|
|
1664
1879
|
```bash
|
|
1665
|
-
|
|
1880
|
+
# Each Bash call is a fresh shell: re-declare state with the orchestrator's values.
|
|
1881
|
+
REPO="{GH_REPO}"; PR_NUMBER="{PR_NUMBER}"; REVIEW_SHA="{REVIEW_SHA}"
|
|
1882
|
+
case "$REVIEW_SHA" in *[!0-9a-f]*|"") REVIEW_SHA_OK=false ;; *) [ "${#REVIEW_SHA}" -eq 40 ] || [ "${#REVIEW_SHA}" -eq 64 ] && REVIEW_SHA_OK=true || REVIEW_SHA_OK=false ;; esac
|
|
1883
|
+
[ "$REVIEW_SHA_OK" = "true" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA is not a commit id (an empty SHA would count every head's findings)"; exit 0; }
|
|
1884
|
+
# Only TRUSTED bodies for the CURRENT head count (scripts/trusted-comments.sh + anchored marker + Reviewed-SHA line):
|
|
1885
|
+
# findings from earlier heads are already fixed or re-reviewed, and an untrusted commenter must never be able to inject
|
|
1886
|
+
# findings or post a forged synthesis block that makes the review read clean (the head SHA is public).
|
|
1887
|
+
_l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
|
|
1888
|
+
TRUSTED_SCRIPT=""
|
|
1889
|
+
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
|
|
1890
|
+
case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
|
|
1891
|
+
done
|
|
1892
|
+
COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
|
|
1893
|
+
if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then
|
|
1894
|
+
echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read clean (fail closed)"; exit 0
|
|
1895
|
+
fi
|
|
1896
|
+
SYNTH_RE="^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)"
|
|
1897
|
+
AGENT_RE="^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)"
|
|
1898
|
+
HAS_SYNTHESIS=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "$SYNTH_RE" 2>/dev/null || echo "")
|
|
1899
|
+
case "$HAS_SYNTHESIS" in ''|*[!0-9]*) echo "REVIEW_RESULT: status: BLOCKED, blocker: synthesis count unreadable (fail closed)"; exit 0 ;; esac
|
|
1666
1900
|
|
|
1667
1901
|
if [ "$HAS_SYNTHESIS" -gt 0 ]; then
|
|
1668
|
-
# Extract finding IDs from synthesized block using jq scan()
|
|
1669
|
-
|
|
1670
|
-
|
|
1902
|
+
# Extract finding IDs from the trusted current-head synthesized block using jq scan() (no PCRE grep needed)
|
|
1903
|
+
SOURCE_BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$SYNTH_RE" 2>/dev/null) \
|
|
1904
|
+
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: trusted synthesis unreadable (fail closed)"; exit 0; }
|
|
1671
1905
|
else
|
|
1672
|
-
# Extract finding IDs from
|
|
1673
|
-
|
|
1674
|
-
|
|
1906
|
+
# Extract finding IDs from the trusted current-head agent comments using jq scan() (portable, no PCRE grep)
|
|
1907
|
+
SOURCE_BODIES=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies "$AGENT_RE" 2>/dev/null) \
|
|
1908
|
+
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: trusted reviewer comments unreadable (fail closed)"; exit 0; }
|
|
1909
|
+
# The panel guard guarantees at least one trusted reviewer body for this head; none means a misread, never "clean".
|
|
1910
|
+
[ -n "$SOURCE_BODIES" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: no trusted reviewer comments for head ${REVIEW_SHA} (fail closed)"; exit 0; }
|
|
1675
1911
|
fi
|
|
1912
|
+
FINDINGS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r 'scan("<!-- FINDING:([^>]+) -->") | .[0]' 2>/dev/null)
|
|
1676
1913
|
```
|
|
1677
1914
|
|
|
1678
1915
|
Also include any `INTEG-N` findings from Phase 2.5 that weren't already covered by agents.
|
|
@@ -1706,7 +1943,7 @@ _cands="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_H
|
|
|
1706
1943
|
for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
|
|
1707
1944
|
[ -n "$_cfg" ] || continue
|
|
1708
1945
|
_cands="$_cands
|
|
1709
|
-
$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$0}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
|
|
1946
|
+
$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
|
|
1710
1947
|
done
|
|
1711
1948
|
_cands="$_cands
|
|
1712
1949
|
$PWD"
|
|
@@ -1723,7 +1960,7 @@ INPR_DIFF_FILE=""
|
|
|
1723
1960
|
if [ "${AUTO_MERGE:-false}" = "true" ] && [ -n "${MERGE_ISSUE:-}" ]; then
|
|
1724
1961
|
_l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
|
|
1725
1962
|
TRUSTED_SCRIPT=""
|
|
1726
|
-
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$
|
|
1963
|
+
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
|
|
1727
1964
|
case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
|
|
1728
1965
|
done
|
|
1729
1966
|
if [ -n "$TRUSTED_SCRIPT" ]; then INPR_MARKERS=$(gh api --paginate "repos/{GH_REPO}/issues/{PR_NUMBER}/comments" 2>/dev/null \
|
|
@@ -1897,6 +2134,73 @@ fi
|
|
|
1897
2134
|
- Open `review-finding` issue at same file with similar title (3+ shared keywords) → **skip** (likely same finding despite line drift)
|
|
1898
2135
|
- Closed `review-finding` at same file within ±5 lines → create with regression warning, elevate to `priority:P1`
|
|
1899
2136
|
|
|
2137
|
+
**Pattern recurrence check (MANDATORY before creating, after the dedup above):** <!-- Added: forge#3449 -->
|
|
2138
|
+
|
|
2139
|
+
Title and line-range dedup is instance-keyed, so the same defect class re-filed in a sibling file slips through and fix chains grow. Key recurrence on the `FORGE:PATTERN` slug plus subsystem. **Subsystem** = the directory of the finding's primary file, truncated to its first two segments (`commands/work-on/build/x.md` -> `commands/work-on`; `commands/review-pr.md` -> `commands`). A file at the repo root has no subsystem and skips the check. This step runs only for findings that survived the note-disposition classifier above, so it never re-admits a finding the cascade damper already suppressed.
|
|
2140
|
+
|
|
2141
|
+
**Exemptions are decided FIRST, before any lookup or consolidation.** A finding is never consolidated away, and skips this check entirely (file it as a normal instance issue), when ANY of these hold: category `Security`, a REGRESSION (closed-issue match above), severity `HIGH` or `CRITICAL`, or priority `P0`/`P1`. Consolidation exists to damp repeated low-severity noise; it must never lower the `review-finding` counts the Phase 6 cascade damper and breaker read for severe findings.
|
|
2142
|
+
|
|
2143
|
+
```bash
|
|
2144
|
+
REPO="{GH_REPO}" # placeholder, not an env var: review-pr never exports GH_REPO
|
|
2145
|
+
PATTERN_SLUG="pattern-slug" # the finding's FORGE:PATTERN value
|
|
2146
|
+
PRIMARY_FILE="path/to/file.py" # the finding's primary file
|
|
2147
|
+
FINDING_EXEMPT="false" # set true for Security / REGRESSION / HIGH / CRITICAL / P0 / P1
|
|
2148
|
+
CONSOLIDATE="false"; PRIOR_NUMS=""; SUBSYSTEM=""
|
|
2149
|
+
# Slug and path are model-written text: validate before any shell or jq use.
|
|
2150
|
+
# A bash pattern test (not per-line grep) so a multi-line value cannot pass on one valid line.
|
|
2151
|
+
if [ "$FINDING_EXEMPT" = "true" ]; then
|
|
2152
|
+
echo "RECURRENCE: exempt (severity/category/regression) - filing normally"
|
|
2153
|
+
elif [ -z "$REPO" ]; then
|
|
2154
|
+
echo "RECURRENCE: skipped - repo not resolved, filing normally"
|
|
2155
|
+
elif ! [[ "$PATTERN_SLUG" =~ ^[a-z0-9-]+$ ]]; then
|
|
2156
|
+
echo "RECURRENCE: skipped - invalid slug, filing normally"
|
|
2157
|
+
else
|
|
2158
|
+
SUBSYSTEM=$(dirname -- "$PRIMARY_FILE" | cut -d/ -f1-2)
|
|
2159
|
+
if [ "$SUBSYSTEM" = "." ] || ! [[ "$SUBSYSTEM" =~ ^[A-Za-z0-9._/-]+$ ]]; then
|
|
2160
|
+
echo "RECURRENCE: skipped - no valid subsystem, filing normally"
|
|
2161
|
+
else
|
|
2162
|
+
SELF="${MERGE_ISSUE:-0}"; [[ "$SELF" =~ ^[0-9]+$ ]] || SELF=0
|
|
2163
|
+
# Narrow server-side by the exact slug tag (the slug is validated, so safe in --search), then
|
|
2164
|
+
# filter in jq. Excludes class issues, false-positive findings and issues closed as not planned.
|
|
2165
|
+
PRIOR=$(gh issue list -R "$REPO" --state all --label review-finding --limit 200 \
|
|
2166
|
+
--search "FORGE:PATTERN: ${PATTERN_SLUG} in:body" \
|
|
2167
|
+
--json number,body,labels,stateReason 2>/dev/null) || PRIOR="__LOOKUP_FAILED__"
|
|
2168
|
+
if [ "$PRIOR" = "__LOOKUP_FAILED__" ]; then
|
|
2169
|
+
# Fail closed on lookup errors: file the finding normally, never drop it.
|
|
2170
|
+
echo "RECURRENCE: lookup failed - filing as a normal instance issue"
|
|
2171
|
+
else
|
|
2172
|
+
[ "$(printf '%s' "$PRIOR" | jq 'length')" -ge 200 ] && echo "RECURRENCE: WARNING lookup hit the 200-result cap, older priors may be missed"
|
|
2173
|
+
PRIOR_NUMS=$(printf '%s' "$PRIOR" | jq -r --arg slug "$PATTERN_SLUG" --arg sub "$SUBSYSTEM" --argjson self "$SELF" '
|
|
2174
|
+
($sub | gsub("\\."; "\\.")) as $esc
|
|
2175
|
+
| [.[] | select(.number != $self)
|
|
2176
|
+
| select(.stateReason != "NOT_PLANNED")
|
|
2177
|
+
| select((.labels | map(.name)) as $l | (($l | index("pattern-class")) or ($l | index("false-positive"))) | not)
|
|
2178
|
+
| select(.body | contains("<!-- FORGE:PATTERN: " + $slug + " -->"))
|
|
2179
|
+
| select(.body | test("(^|[^A-Za-z0-9._-])" + $esc + "/"))
|
|
2180
|
+
| .number] | join(" ")')
|
|
2181
|
+
if [ -n "$PRIOR_NUMS" ]; then
|
|
2182
|
+
CONSOLIDATE="true"
|
|
2183
|
+
echo "RECURRENCE: $PATTERN_SLUG seen before in $SUBSYSTEM (#${PRIOR_NUMS// /, #}) - consolidating"
|
|
2184
|
+
fi
|
|
2185
|
+
fi
|
|
2186
|
+
fi
|
|
2187
|
+
fi
|
|
2188
|
+
```
|
|
2189
|
+
|
|
2190
|
+
Each bash call is a fresh shell. Run the consolidation commands below in the SAME call as the block above, or re-declare `REPO`, `PATTERN_SLUG`, `SUBSYSTEM`, `PRIOR_NUMS` and `CONSOLIDATE` first. When `CONSOLIDATE=true` (this is the 2nd or later occurrence), consolidate instead of filing another instance issue. `CLASS_DONE` stays `false` unless every step below succeeds:
|
|
2191
|
+
|
|
2192
|
+
```bash
|
|
2193
|
+
CLASS_DONE="false"; CLASS_MARKER="<!-- FORGE:PATTERN-CLASS: ${PATTERN_SLUG} ${SUBSYSTEM} -->"
|
|
2194
|
+
find_class() { gh issue list -R "$REPO" --state open --label pattern-class --limit 100 --json number,body 2>/dev/null \
|
|
2195
|
+
| jq -r --arg m "$CLASS_MARKER" '[.[] | select(.body | contains($m)) | .number] | sort | .[0] // empty'; }
|
|
2196
|
+
CLASS_NUM=$(find_class) || CLASS_NUM="__LOOKUP_FAILED__" # lookup error: stop consolidating, file normally
|
|
2197
|
+
```
|
|
2198
|
+
|
|
2199
|
+
1. If the lookup failed, stop consolidating and file normally (fail closed). Otherwise, if `CLASS_NUM` is set, add one comment to that issue listing this finding (file, line, PR), unless a comment already contains the marker `<!-- FORGE:PATTERN-CLASS-ITEM: PR #${PR_NUMBER} ${PRIMARY_FILE}:${LINE} -->` (check it with `jq --arg` and an exact `contains`, so `PR #12` never matches `PR #123`). Set `CLASS_DONE=true` on success.
|
|
2200
|
+
2. If `CLASS_NUM` is empty, ensure the label exists (`gh label create pattern-class -R "$REPO" --color ededed --description "Class-level consolidation of a recurring finding pattern" --force`), re-run `find_class` immediately before creating (tie-break for parallel reviewers: if more than one class issue exists, the lowest number wins and a duplicate you created is closed as a duplicate of it), then create ONE class-level issue through the `/issue` programmatic invocation contract (same as the per-finding path below), titled `fix: class-wide ${PATTERN_SLUG} in ${SUBSYSTEM}`. Its body MUST carry BOTH `${CLASS_MARKER}` and `<!-- FORGE:PATTERN: ${PATTERN_SLUG} -->`, the Prevention text, and links to every prior instance and this finding. Labels: `review-finding`, `pattern-class`, plus the highest priority among the linked issues. The `FORGE:PATTERN` tag plus `review-finding` label are what make investigate step 7.6 sweep it, and they keep it counted by pipeline-health Phase 4A (two instances plus the class issue reach its 3-distinct-issue threshold). The fix for it must follow the investigate Pattern Sweep. Set `CLASS_DONE=true` only if the create returned an issue number.
|
|
2201
|
+
3. Only when `CLASS_DONE=true`: comment on this PR with the class-level issue link (marker `<!-- FORGE:PATTERN-CLASS-LINK: ${PATTERN_SLUG} ${SUBSYSTEM} PR #${PR_NUMBER} -->`; skip if a PR comment already has it) and skip creating the instance issue. **If class create or comment failed for any reason, do NOT skip the instance issue: file it normally** so the finding is never lost.
|
|
2202
|
+
4. Consolidation links findings; it does not drop them. A finding that is exempt (see above) is filed as normal and may add the class-level link to its body.
|
|
2203
|
+
|
|
1900
2204
|
**For each finding** (that passes dedup), create issue through the `/issue` create-hook's programmatic invocation contract (see `commands/issue.md` § "Programmatic Invocation Contract") — this preserves the bespoke line-range/title dedup above as a precise pre-check, while `/issue`'s own Phase 2D dedup runs as a coarser second pass:
|
|
1901
2205
|
```bash
|
|
1902
2206
|
FINDING_ISSUE_TITLE="fix: [summary] (review finding — PR #${PR_NUMBER})"
|
|
@@ -2161,7 +2465,7 @@ if [ "$MERGE_HEALTH" = "CONFLICTING" ] || [ "$MERGE_HEALTH_STATE" = "DIRTY" ] ||
|
|
|
2161
2465
|
fi
|
|
2162
2466
|
|
|
2163
2467
|
# Resolve attribution footer (forge.yaml → attribution.pr_footer)
|
|
2164
|
-
ATTRIBUTION_PR_FOOTER=$(grep -A5 "^attribution:" forge.yaml 2>/dev/null | grep "pr_footer:" | awk '{print $2}' | tr -d '"' || echo "false")
|
|
2468
|
+
ATTRIBUTION_PR_FOOTER=$(grep -A5 "^attribution:" forge.yaml 2>/dev/null | grep "pr_footer:" | awk '{print $(2)}' | tr -d '"' || echo "false")
|
|
2165
2469
|
ATTRIBUTION_FOOTER_LINE=""
|
|
2166
2470
|
if [ "$ATTRIBUTION_PR_FOOTER" = "true" ]; then
|
|
2167
2471
|
ATTRIBUTION_FOOTER_LINE="
|
|
@@ -2174,16 +2478,16 @@ fi
|
|
|
2174
2478
|
TRUST_ANNOTATION="**Intensity tier**: ${INTENSITY_TIER:-SHADOW} | **Cell**: \`${TABLE_CELL_KEY:-(unavailable)}\` | **Shadow mode**: ${TRUST_SHADOW_MODE:-true}"
|
|
2175
2479
|
|
|
2176
2480
|
# Stale review:
|
|
2177
|
-
gh pr review $
|
|
2481
|
+
gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "Review of commit $REVIEW_SHA_SHORT is stale — PR HEAD changed. Re-run /review-pr."
|
|
2178
2482
|
|
|
2179
2483
|
# Clean (no blocking issues):
|
|
2180
|
-
gh pr review $
|
|
2484
|
+
gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "APPROVED: commit $REVIEW_SHA_SHORT after context-aware review ([N] agents: [names]). [M] findings created as issues. Safe to merge.
|
|
2181
2485
|
${TRUST_ANNOTATION}
|
|
2182
2486
|
$([ "$MERGE_HEALTH" = "UNKNOWN" ] && echo "
|
|
2183
2487
|
⚠ Mergeability: GitHub is still computing merge state (UNKNOWN after retries). Verify manually before merging.")${ATTRIBUTION_FOOTER_LINE}"
|
|
2184
2488
|
|
|
2185
2489
|
# Blocking issues (including merge conflicts and purpose regressions):
|
|
2186
|
-
gh pr review $
|
|
2490
|
+
gh pr review "$PR_NUMBER" -R "$REPO" --comment --body "CHANGES REQUESTED: commit $REVIEW_SHA_SHORT — [N] blocking issues found. See GitHub issues.
|
|
2187
2491
|
${TRUST_ANNOTATION}
|
|
2188
2492
|
$([ "$HAS_MERGE_CONFLICT" = "true" ] && echo "
|
|
2189
2493
|
🔴 Merge Conflict: ${MERGE_CONFLICT_MSG}")
|
|
@@ -2297,7 +2601,7 @@ FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test(
|
|
|
2297
2601
|
# FORGE_TRAIL_TRUSTED_LOGINS. A GitHub App bot always has author_association NONE, so an association-only filter drops the pipeline's own markers.
|
|
2298
2602
|
_l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
|
|
2299
2603
|
TRUSTED_SCRIPT=""
|
|
2300
|
-
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$
|
|
2604
|
+
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
|
|
2301
2605
|
case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
|
|
2302
2606
|
done
|
|
2303
2607
|
if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$FINDING_COUNT" ]; then
|
|
@@ -2358,7 +2662,7 @@ else
|
|
|
2358
2662
|
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
2359
2663
|
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
2360
2664
|
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
2361
|
-
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
2665
|
+
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
2362
2666
|
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
2363
2667
|
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
2364
2668
|
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
@@ -2646,7 +2950,7 @@ fi
|
|
|
2646
2950
|
|
|
2647
2951
|
```bash
|
|
2648
2952
|
if [ "${IS_MILESTONE_TO_STAGING:-false}" = "true" ]; then
|
|
2649
|
-
PR_MERGE_STATE=$(gh pr view $
|
|
2953
|
+
PR_MERGE_STATE=$(gh pr view "$PR_NUMBER" -R "$REPO" --json state --jq '.state' 2>/dev/null || echo "")
|
|
2650
2954
|
if [ "$PR_MERGE_STATE" = "MERGED" ]; then
|
|
2651
2955
|
echo "Phase 9A: Checking for open review-finding issues to demilestone (fallback — manual merge path)..."
|
|
2652
2956
|
|
|
@@ -2677,7 +2981,7 @@ fi
|
|
|
2677
2981
|
<!-- Added: forge#815 -->
|
|
2678
2982
|
|
|
2679
2983
|
```bash
|
|
2680
|
-
CURRENT_SHA=$(gh pr view $
|
|
2984
|
+
CURRENT_SHA=$(gh pr view "$PR_NUMBER" -R "$REPO" --json headRefOid --jq '.headRefOid')
|
|
2681
2985
|
REVIEW_IS_STALE="false"
|
|
2682
2986
|
if [ "$CURRENT_SHA" != "$REVIEW_SHA" ]; then REVIEW_IS_STALE="true"; fi
|
|
2683
2987
|
```
|
|
@@ -2715,7 +3019,7 @@ if [ "$IS_FIRST_TIME_CONTRIBUTOR" = "true" ] && [ "$WELCOME_ENABLED" != "false"
|
|
|
2715
3019
|
# ${VAR} interpolation, which a quoted heredoc would disable. Literal backticks in
|
|
2716
3020
|
# the static text are still escaped (\`) since they remain live inside an unquoted
|
|
2717
3021
|
# heredoc, same as inside a double-quoted string. See forge#2223.
|
|
2718
|
-
gh pr comment "$
|
|
3022
|
+
gh pr comment "$PR_NUMBER" -R "$REPO" --body "$(cat <<EOF
|
|
2719
3023
|
## Welcome to ${PROJECT_NAME_WELCOME}! 🎉
|
|
2720
3024
|
|
|
2721
3025
|
Hi @${PR_AUTHOR} — this looks like your first merged contribution here. Thanks for opening a PR!
|
|
@@ -2744,8 +3048,18 @@ fi
|
|
|
2744
3048
|
A degraded run that skipped Task-based agent dispatch must be visible from this summary alone, without interrogating the agent afterward. Compute the actual launched-agent count from the `<!-- FORGE:REVIEW-AGENT:{domain} -->` comments each agent is required to post (Phase 3C), rather than trusting a free-text tally:
|
|
2745
3049
|
|
|
2746
3050
|
```bash
|
|
2747
|
-
|
|
2748
|
-
|
|
3051
|
+
# Same trust rule as Phase 3C/4: only trusted comments whose body starts with the marker count.
|
|
3052
|
+
_l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
|
|
3053
|
+
TRUSTED_SCRIPT=""
|
|
3054
|
+
for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock"; do
|
|
3055
|
+
case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
|
|
3056
|
+
done
|
|
3057
|
+
ACTUAL_AGENT_DOMAINS=""
|
|
3058
|
+
if [ -n "$TRUSTED_SCRIPT" ]; then
|
|
3059
|
+
ACTUAL_AGENT_DOMAINS=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
|
|
3060
|
+
| bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
|
|
3061
|
+
| jq -rs '[.[] | scan("^<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | join(", ")' 2>/dev/null || echo "") # unresolvable => empty => count 0 (degraded, fail closed)
|
|
3062
|
+
fi
|
|
2749
3063
|
# NOTE: no `|| echo 0` fallback here — grep -c always prints a count (including 0 on
|
|
2750
3064
|
# no match) even though it exits 1 in that case. Appending `|| echo 0` after a `grep -c`
|
|
2751
3065
|
# pipeline double-counts: the pipeline already printed "0", then the fallback prints a
|
|
@@ -2757,7 +3071,7 @@ ACTUAL_AGENT_COUNT=$(echo "$ACTUAL_AGENT_DOMAINS" | tr ',' '\n' | grep -c '.' 2>
|
|
|
2757
3071
|
Substitute `ACTUAL_AGENT_COUNT`/`ACTUAL_AGENT_DOMAINS` into the summary's `**Agents**: [N] ([names])` field below — do NOT substitute a manually-counted or remembered figure. Compare it to `SELECTED_AGENT_COUNT` from Phase 3C. If it is smaller, the panel is degraded: the Phase 3C hard-stop path must already have labelled the PR `review-degraded`, added `needs-human`, and exited without a verdict. Never summarize, approve, or merge a partial panel. `ACTUAL_AGENT_COUNT=0` is the same hard stop, not a solo/inline review mode.
|
|
2758
3072
|
|
|
2759
3073
|
```bash
|
|
2760
|
-
gh pr comment $
|
|
3074
|
+
gh pr comment "$PR_NUMBER" -R "$REPO" --body "$(cat <<'EOF'
|
|
2761
3075
|
# PR Review Summary: #[NUMBER] - [TITLE]
|
|
2762
3076
|
|
|
2763
3077
|
## Review Integrity
|