forgedock 1.7.1 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/bin/engine/admission.mjs +242 -7
  2. package/bin/engine/admission.test.mjs +183 -2
  3. package/bin/engine/phases.mjs +31 -22
  4. package/bin/engine/resolve.mjs +8 -1
  5. package/bin/engine/resolve.test.mjs +5 -0
  6. package/bin/engine.mjs +26 -17
  7. package/bin/forgedock.mjs +15 -2
  8. package/bin/hooks/pre-tool-use.mjs +573 -2
  9. package/bin/journey.mjs +11 -7
  10. package/bin/labels.json +5 -0
  11. package/bin/opencode-adapter.mjs +47 -13
  12. package/bin/orchestrate-preflight.mjs +434 -0
  13. package/bin/runner.mjs +30 -4
  14. package/commands/audit-agents.md +75 -26
  15. package/commands/autopilot.md +4 -4
  16. package/commands/cleanup.md +10 -5
  17. package/commands/issue.md +95 -35
  18. package/commands/orchestrate/phase-1-resolve.md +70 -32
  19. package/commands/orchestrate/phase-3-dependency.md +141 -13
  20. package/commands/orchestrate/phase-4-execution.md +660 -70
  21. package/commands/orchestrate/phase-5-cleanup.md +5 -11
  22. package/commands/orchestrate/phase-6-report.md +27 -1
  23. package/commands/orchestrate/safety.md +1 -1
  24. package/commands/orchestrate.md +39 -1
  25. package/commands/quality-gate.md +30 -10
  26. package/commands/review-pr-agents/protocols.md +13 -0
  27. package/commands/review-pr-agents.md +1 -1
  28. package/commands/review-pr-staging.md +100 -18
  29. package/commands/review-pr.md +78 -24
  30. package/commands/upgrade-deps.md +1 -1
  31. package/commands/work-on/build/validate.md +25 -4
  32. package/commands/work-on/close.md +40 -10
  33. package/commands/work-on/decompose.md +33 -1
  34. package/commands/work-on/investigate.md +33 -0
  35. package/commands/work-on/remediate.md +40 -5
  36. package/commands/work-on/review.md +27 -0
  37. package/commands/work-on.md +48 -5
  38. package/docs/CONFIG.md +45 -10
  39. package/docs/OPENCODE.md +21 -6
  40. package/docs/spec/review-protocol.md +7 -0
  41. package/package.json +1 -1
  42. package/scripts/check-admission-parity.mjs +1 -0
  43. package/scripts/check-command-side-effects.sh +14 -13
  44. package/scripts/check-shell-portability.sh +36 -0
  45. package/scripts/check-spec-markers.sh +21 -11
  46. package/scripts/checkbox-sections.test.sh +67 -0
  47. package/scripts/extract-affected-files.sh +85 -5
  48. package/scripts/extract-affected-files.test.sh +154 -2
@@ -49,16 +49,11 @@ Include the audit summary in the final report (Phase 6). Key metrics to surface:
49
49
 
50
50
  ```bash
51
51
  if [ -n "${COORD_ISSUE_NUMBER:-}" ]; then
52
- # Detect any still-active (unreleased) claims — a claim posted with no matching
53
- # CLAIM_RELEASED comment on the same coordination issue. Adapted from the query
54
- # phase-4-execution.md's claims-board relaxation sweep uses (Step 4B item 4),
55
- # simplified to holder-only (a flat string array) since Step 5C only needs
56
- # holder names for the closure note — unlike phase-4-execution.md's version,
57
- # which also captures each claim's `files` into an object alongside `holder`.
58
- ACTIVE_CLAIMS=$(gh api repos/{GH_REPO}/issues/${COORD_ISSUE_NUMBER}/comments \
59
- --jq '[.[] | select(.body | contains("<!-- FORGE:CLAIM -->")) |
60
- select(.body | contains("<!-- FORGE:CLAIM_RELEASED -->") | not)] |
61
- map(.body | capture("\\*\\*Holder\\*\\*: (?P<h>[^\\n]+)").h // "unknown")' 2>/dev/null || echo '[]')
52
+ # Use Step 4A's read_active_claims() helper (re-declare it verbatim here when this
53
+ # phase runs in a fresh context). Releases are separate later comments, so checking
54
+ # for CLAIM_RELEASED inside the claim body would retain every released claim.
55
+ ACTIVE_CLAIMS=$(read_active_claims "$COORD_ISSUE_NUMBER" 2>/dev/null \
56
+ | jq '[.[].holder // "unknown"]' 2>/dev/null || echo '[]')
62
57
  ACTIVE_CLAIMS_COUNT=$(echo "$ACTIVE_CLAIMS" | jq 'length' 2>/dev/null || echo 0)
63
58
 
64
59
  if [ "$ACTIVE_CLAIMS_COUNT" -gt 0 ] 2>/dev/null; then
@@ -87,4 +82,3 @@ fi
87
82
  Include the cleanup summary in the final report (Phase 6), including whether the coordination issue was closed this run (see Step 5C). If cleanup found problems, call them out — they indicate agent pipeline failures that may need investigation.
88
83
 
89
84
  ---
90
-
@@ -1,4 +1,5 @@
1
1
  ---
2
+
2
3
  install: core
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -208,6 +209,7 @@ Sweep (Step 4F.2.5) once the batch is no longer fully gated.
208
209
  | #{F} | {title} | original | ⏸ Awaiting Merge | #{PR} | staging |
209
210
  | #{E} | {title} | original | ⏭ Skipped (dep) | — | — |
210
211
  | #{G} | {title} | original | 🔗 Blocked-on-Merge (gated by #{PRED}) | — | — |
212
+ | #{H} | {title} | original | ⚠ Review Panel Degraded | #{PR} | {target} |
211
213
 
212
214
  `⏸ Awaiting Merge` (`workflow:awaiting-merge`) is structurally distinct from `⚠ Blocked`
213
215
  (`needs-human`): a Blocked row means the pipeline hit something it cannot resolve on its own
@@ -223,6 +225,17 @@ was abandoned. A Blocked-on-Merge row means the work is queued and will auto-dis
223
225
  the gating predecessor's PR merges — no manual `/orchestrate` re-run required. Never render a
224
226
  blocked-on-human-merge dependent as `⏭ Skipped (dep)`.
225
227
 
228
+ ### Degraded Review Panels
229
+
230
+ {IF any PR reviewed during this batch has the `review-degraded` label:}
231
+ | Issue | PR | Target | Required action |
232
+ |-------|----|--------|-----------------|
233
+ | #{NUM} | #{PR} | {target} | Re-run the complete isolated review panel in a fresh session |
234
+
235
+ These PRs are not eligible to merge or deploy. A dispatch-pool failure produced an incomplete panel, so no inline or partial-panel verdict is accepted.
236
+
237
+ {ELSE: omit this section entirely.}
238
+
226
239
  {IF `MERGE_READY_PRS` (Step 6A.5) is non-empty:}
227
240
 
228
241
  ### Merge-Ready — {N} PR(s) awaiting only a human merge
@@ -286,12 +299,14 @@ current session's live wake pick them up automatically if it is still running).
286
299
  {list of cosmetic deferred issue numbers and titles}
287
300
 
288
301
  ### Summary
302
+ - **Knowledge Gists**: {IF FORGE_GIST_CAPABLE == "false": "Skipped - unavailable for the batch authentication (informational)." ELSE: "Available."}
289
303
  - **Investigations**: {N} completed, spawned {M} new issues
290
304
  - **Review findings**: {N} spawned, {M} resolved in-batch, {K} swept, {J} deferred (cosmetic), {L} deferred (gen2), {IDLE_POLICY_DEFERRED_COUNT} deferred (idle policy, forge#1814)
291
305
  - **Succeeded**: {N} issues resolved (implementation + sweep)
292
306
  - **Failed**: {N} issues need attention
293
307
  - **Merge-ready**: {#MERGE_READY_PRS[@]:-0} PRs awaiting only a human merge (see "Merge-Ready" section above)
294
308
  - **Blocked-on-merge**: {#BLOCKED_ON_MERGE[@]:-0} issues queued behind a human-gated predecessor, will auto-dispatch on merge (see "Blocked-on-Merge" section above)
309
+ - **Degraded review panels**: {N} PRs require a fresh full-panel re-review before merge/deploy
295
310
  - **Skipped**: {N} issues (dependency failures)
296
311
 
297
312
  ### Post-Batch Cleanup
@@ -325,6 +340,11 @@ current session's live wake pick them up automatically if it is still running).
325
340
  | Findings deferred — idle policy (forge#1814) | {IDLE_POLICY_DEFERRED_COUNT} (batch fully human-gated at defer time — see Step 4B item 6.7) |
326
341
  | P3 findings clubbed (surface-area batching, forge#1818) | {SURFACE_BATCH_COUNT:-0} batch(es) covering {#SURFACE_BATCHED_FINDINGS[@]:-0} finding(s) — see Step 4C |
327
342
  | Findings deferred — token budget (forge#1858) | {#TOKEN_DEFERRED[@]:-0} (batch spend {BATCH_TOKEN_SPEND:-0}/{TOKEN_BUDGET:-uncapped} tokens — see Step 4C) |
343
+ | Full-repo intake (`policy: all`) | {FULL_REPO_SWEEP_COUNT:-0} sweep completion mark; {#FULL_REPO_INTAKE[@]:-0} newly-seen actionable issues admitted |
344
+ | Automated alerts deduplicated | {AUTOMATED_DUPLICATES_CLOSED:-0} closed; canonical issue retained and linked for each |
345
+ | **Cascade amplification** | **{FINDINGS_SPAWNED:-0}/{MERGED_UNITS:-0} = {AMPLIFICATION_RATIO:-0} findings per merged unit** |
346
+ | Amplification composition | {#REFINEMENT_FINDINGS[@]:-0} same-lineage refinements; {#NEW_SURFACE_FINDINGS[@]:-0} new-surface findings |
347
+ | Amplification bound | {CASCADE_MAX_AMPLIFICATION:-off}; {#AMPLIFICATION_DEFERRED[@]:-0} same-lineage refinements deferred to completion sweep |
328
348
  | Competing recommendations reconciled (Phase 2.5) | {RECONCILED_COUNT} (investigation plans arbitrated in place + serialized) |
329
349
  | Findings validated | {N} |
330
350
  | False positives | {N} ({%}) |
@@ -334,12 +354,19 @@ current session's live wake pick them up automatically if it is still running).
334
354
  | Actual spend (best-effort telemetry) | ${ACTUAL_SPEND:-—} |
335
355
  | Issues deferred (budget ceiling) | {#DEFERRED_BUDGET_ISSUES[@]:-0} |
336
356
  | ε-reserve issued (no-prior dispatch) | {EPSILON_DISPATCHED:-no} |
357
+ | Top-level workers dispatched | ${TOP_LEVEL_DISPATCH_TOTAL:-0} |
358
+ | Subagent spawns planned (8 per worker) | ${PLANNED_SUBAGENT_SPAWNS:-0} |
359
+ | Subagent spawns observed | ${OBSERVED_SUBAGENT_SPAWNS:-unavailable} |
337
360
  | **Value-weighted throughput** | **{VALUE_THROUGHPUT:-—} value-pts/USD** |
338
361
 
339
362
  `value-weighted throughput` = Σ(priority_weight × danger_zone_weight) for **merged** issues ÷ actual spend (USD). A higher value means more high-priority issues were resolved per dollar. Trend this across runs via `/pipeline-health` to measure the economic scheduling ROI. <!-- Added: forge#1743 -->
340
363
 
341
364
  `P3 findings clubbed` and `Findings deferred — token budget` are a DIFFERENT mechanism from the `$`-denominated `Budget limit`/`Projected spend`/`Actual spend`/`Issues deferred (budget ceiling)` rows above (forge#1743, opt-in `--budget N` flag, gates the *original* issue dispatch order). The token-budget row reads the always-on, token-denominated ceiling that scopes ONLY to Step 4C's review-finding cascade dispatch (forge#1858); `SURFACE_BATCH_COUNT` and `SURFACE_BATCHED_FINDINGS` are populated by the same Step 4C surface-area-batching block (forge#1818) that clubs P3 findings sharing a file/leaf-directory into one dispatched pipeline. Both degrade gracefully to `0`/`uncapped` when the batch had no review findings or ran with defaults. <!-- Added: forge#1858 -->
342
365
 
366
+ **Cascade amplification detail**: render one row per `FINDINGS_BY_SOURCE_PR` entry: `| PR #{source} | {finding count} | {refinement count} | {new-surface count} |`. A ratio at or above 1.0 means the batch is not reducing its open-work count through merges alone; it does **not** mean the findings are low-value. Include any convergence warning and state that the default `max_amplification: off` never defers findings. When an opt-in bound deferred a refinement, list it under Completion Sweep as re-evaluable rather than silently omitting it.
367
+
368
+ For `policy: all`, include the full-repo intake counts and list each automated duplicate with its canonical issue. A sweep measures all new open issues, including CI alerts without `review-finding`; it must not claim that the cascade ratio alone measures total queue growth.
369
+
343
370
  **Compute value-weighted throughput** (populate before rendering the table):
344
371
 
345
372
  ```bash
@@ -433,4 +460,3 @@ the Summary section above. `BATCH_ELAPSED` is the wall-clock duration of the orc
433
460
  (Step 6B `Duration`), not the sum of per-issue elapsed times.
434
461
 
435
462
  ---
436
-
@@ -11,7 +11,7 @@ install: core
11
11
  1. **Every agent MUST invoke `/work-on` via the Skill tool.** No custom prompts. No manual implementation. Copy the Phase 4A template, fill in variables, done. (See HARD RULES at top of file.)
12
12
  2. **NEVER merge anything to `main`** — agents merge to `staging` or `milestone/{slug}` only
13
13
  3. **Concurrency is capped by default (12 in-flight agents)** <!-- Updated: forge#1912 --> — file overlap and dependencies still determine which issues are *eligible* to run in parallel, but Phase 4 never dispatches more than `MAX_CONCURRENT` at once (default 12); the rest queue and dispatch as running agents complete. Set `forge.yaml → orchestration.max_concurrent` to raise or lower the cap (see Engine mode § Concurrency model, and `phase-4-execution.md` Step 4A-pre.0.2).
14
- 4. **Always confirm with user before launching** — Step 3E is the mandatory checkpoint
14
+ 4. **Always confirm with user before launching** — Step 3E is the mandatory checkpoint. Headless callers must pass the explicit `--auto` or `--confirm` authorization; OpenCode must not infer confirmation from a completed preflight.
15
15
  5. **No retries** — if an agent fails, report it and move on
16
16
  6. **Respect existing work** — skip issues already being worked on (`workflow:building`, `workflow:in-review`)
17
17
  7. **Dependency failures cascade** — if A fails and B depends on A, all transitive dependents of A are skipped (not attempted)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Orchestrate parallel work on multiple issues or an entire milestone — spawns sub-agents that each run the full /work-on pipeline
3
- argument-hint: "[milestone <slug> | #1 #2 #3 | next <N> | fast-lane | priority:P0]"
3
+ argument-hint: "[milestone <slug> | #1 #2 #3 | next <N> | fast-lane | priority:P0] [--auto|--confirm]"
4
4
  ---
5
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
6
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -9,8 +9,46 @@ argument-hint: "[milestone <slug> | #1 #2 #3 | next <N> | fast-lane | priority:P
9
9
 
10
10
  **Input**: $ARGUMENTS
11
11
 
12
+ `--auto` and `--confirm` authorize the dispatch checkpoint; they are control
13
+ flags, not part of the issue-set query. `--deep-plan` requests the full analysis
14
+ path, and `--max-concurrent N` only tunes the dispatch cap.
15
+
12
16
  This file is the slim dispatcher. Detailed phase content lives in `commands/orchestrate/`.
13
17
 
18
+ ## OpenCode Preflight
19
+
20
+ When `FORGE_RUNTIME=opencode` (or an OpenCode runtime marker is present), run the
21
+ deterministic preflight at `$FORGE_HOME/bin/orchestrate-preflight.mjs` immediately,
22
+ before reading `orchestrate/config.md`, this workflow, or any phase spec. The helper
23
+ resolves the repository from `forge.yaml`, including a parent config when the target
24
+ is a nested Git worktree:
25
+
26
+ ```bash
27
+ node "$FORGE_HOME/bin/orchestrate-preflight.mjs" \
28
+ --repo "$GH_REPO" \
29
+ --args "$ARGUMENTS"
30
+ ```
31
+
32
+ The preflight is a compact mechanical adapter for issue resolution, eligibility,
33
+ explicit dependencies, scoped issue-body file overlap, database serialization, and
34
+ the initial ready queue. If it returns a supported plan with `requiresDeepPlan: false`
35
+ and `confirmed: true`, launch `dispatchNow` with native background `task` calls
36
+ immediately. Without an explicit `--auto` or `--confirm` argument, present the
37
+ compact plan and ask for one confirmation; after the user confirms, launch the
38
+ plan's ready queue without re-reading the large phase files. Do not load the full
39
+ Phase 3 or Phase 4 prose just to ask that question.
40
+
41
+ Continue through the phase files when the plan says `requiresDeepPlan`, the input is
42
+ unsupported, preflight fails, or a task-result event requires recovery. This adapter
43
+ never closes, deduplicates, or edits issues; the full shared workflow remains the
44
+ authority for investigations, review-finding cascade handling, recovery, cleanup,
45
+ and reporting.
46
+
47
+ For a supported compact OpenCode plan, stop after the fast-path dispatch. The phase
48
+ execution order below is the fallback path for deep plans, unsupported inputs,
49
+ preflight failures, and task-result recovery; do not read it merely to confirm or
50
+ dispatch a compact plan.
51
+
14
52
  ## Execution Order
15
53
 
16
54
  Read and execute phases in sequence. Each phase file is self-contained.
@@ -246,6 +246,12 @@ echo "Applicable domains: $DOMAINS"
246
246
 
247
247
  Run ONLY the checks whose domain was identified in Step 1.5. Skip checks for domains not present in the `DOMAINS` list.
248
248
 
249
+ **Execution budget**: Set `FORGEDOCK_QUALITY_GATE_TIMEOUT_SECONDS` to configure the
250
+ per-check budget (default: 30 seconds). Set `FORGEDOCK_VERIFICATION_TIMEOUT_SECONDS`
251
+ to configure formatter, typecheck, and build commands (default: 120 seconds). Both
252
+ values must be positive integers. A timeout emits a structured HIGH finding and fails
253
+ the gate rather than leaving the workflow blocked.
254
+
249
255
  - **2A (Security)**: Run if `SECURITY` in DOMAINS *(always — included for all code files)*
250
256
  - **2B (Auth)**: Run if `AUTH` in DOMAINS
251
257
  - **2C (Deploy chain)**: Run if `DEPLOY` in DOMAINS
@@ -715,9 +721,9 @@ Do NOT suppress the finding by making the check less strict or adding fake no-op
715
721
  ```bash
716
722
  # 2G.9: Danger-zone recurrence check
717
723
  # Step 1: Read injected danger-zone cards from FORGE:CONTEXT comment on the issue
718
- CONTEXT_COMMENT=$(gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
724
+ CONTEXT_COMMENT=$(timeout 10 gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
719
725
  --jq '[.[] | select(.body | contains("<!-- FORGE:CONTEXT -->"))] | last | .body // ""' 2>/dev/null \
720
- | timeout 10 cat || echo '')
726
+ || echo '')
721
727
 
722
728
  # Extract the Danger-Zone Rule Cards section
723
729
  DZ_SECTION=$(echo "$CONTEXT_COMMENT" \
@@ -1302,6 +1308,11 @@ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
1302
1308
  ```bash
1303
1309
  REGISTRY_FILE="{WORKTREE_PATH}/scripts/check-registry/manifest.json"
1304
1310
  REGISTRY_FINDINGS=""
1311
+ QUALITY_GATE_TIMEOUT_SECONDS="${FORGEDOCK_QUALITY_GATE_TIMEOUT_SECONDS:-30}"
1312
+ if ! [[ "$QUALITY_GATE_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]]; then
1313
+ echo "QUALITY-GATE-CONFIG | HIGH | timeout | FORGEDOCK_QUALITY_GATE_TIMEOUT_SECONDS must be a positive integer (got '$QUALITY_GATE_TIMEOUT_SECONDS')"
1314
+ QUALITY_GATE_TIMEOUT_SECONDS=30
1315
+ fi
1305
1316
 
1306
1317
  if [ ! -f "$REGISTRY_FILE" ]; then
1307
1318
  echo "2R: No check registry found at $REGISTRY_FILE — skipping registry checks."
@@ -1332,11 +1343,15 @@ else
1332
1343
  continue
1333
1344
  fi
1334
1345
 
1335
- # Run the check; pass changed files list and worktree path
1336
- CHECK_OUTPUT=$("$SCRIPT_PATH" "{CHANGED_FILES}" "{WORKTREE_PATH}" 2>/dev/null)
1346
+ # Bound every third-party check. Exit 124 is timeout's documented code.
1347
+ CHECK_OUTPUT=$(timeout "$QUALITY_GATE_TIMEOUT_SECONDS" "$SCRIPT_PATH" "{CHANGED_FILES}" "{WORKTREE_PATH}" 2>/dev/null)
1337
1348
  CHECK_EXIT=$?
1338
1349
 
1339
- if [ "$CHECK_EXIT" -eq 1 ]; then
1350
+ if [ "$CHECK_EXIT" -eq 124 ]; then
1351
+ REGISTRY_FINDINGS="${REGISTRY_FINDINGS}
1352
+ REGISTRY-${SLUG}-timeout | HIGH | (check-registry) | Registry check '$SLUG' timed out after ${QUALITY_GATE_TIMEOUT_SECONDS}s — failing closed. Investigate and optimize the script before proceeding."
1353
+ echo "2R: TIMEOUT — $SLUG (${QUALITY_GATE_TIMEOUT_SECONDS}s) — fail-closed HIGH finding added"
1354
+ elif [ "$CHECK_EXIT" -eq 1 ]; then
1340
1355
  # Check fired — append to findings
1341
1356
  while IFS= read -r line; do
1342
1357
  [ -z "$line" ] && continue
@@ -1383,6 +1398,11 @@ if [[ "$ADAPTIVE_DIR_NORM" != "${WORKTREE_NORM}/"* ]]; then
1383
1398
  fi
1384
1399
 
1385
1400
  GATED_FINDINGS=""
1401
+ QUALITY_GATE_TIMEOUT_SECONDS="${FORGEDOCK_QUALITY_GATE_TIMEOUT_SECONDS:-30}"
1402
+ if ! [[ "$QUALITY_GATE_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]]; then
1403
+ echo "QUALITY-GATE-CONFIG | HIGH | timeout | FORGEDOCK_QUALITY_GATE_TIMEOUT_SECONDS must be a positive integer (got '$QUALITY_GATE_TIMEOUT_SECONDS')"
1404
+ QUALITY_GATE_TIMEOUT_SECONDS=30
1405
+ fi
1386
1406
 
1387
1407
  if [ "$ADAPTIVE_ENABLED" != "false" ]; then
1388
1408
  GATE_D_DIR="${ADAPTIVE_DIR}/gate.d"
@@ -1411,17 +1431,17 @@ if [ "$ADAPTIVE_ENABLED" != "false" ]; then
1411
1431
  continue
1412
1432
  fi
1413
1433
 
1414
- # Run with per-script 30s timeout; pass diff path and worktree root
1434
+ # Run with the configured per-script timeout; pass diff path and worktree root
1415
1435
  # timeout exit code 124 = timed out; treat as fail-closed (HIGH finding)
1416
- GATE_OUTPUT=$(timeout 30 bash "$script" "$DIFF_PATH" "{WORKTREE_PATH}" 2>/dev/null)
1436
+ GATE_OUTPUT=$(timeout "$QUALITY_GATE_TIMEOUT_SECONDS" bash "$script" "$DIFF_PATH" "{WORKTREE_PATH}" 2>/dev/null)
1417
1437
  GATE_EXIT=$?
1418
1438
 
1419
1439
  if [ "$GATE_EXIT" -eq 124 ]; then
1420
1440
  # Timeout — fail closed: block the gate with HIGH finding
1421
- TIMEOUT_FINDING="GATD-${SLUG}-timeout | HIGH | (gate.d) | gate.d script '$SLUG' timed out after 30s — failing closed. Investigate and optimize the script or increase timeout via gate.d contract."
1441
+ TIMEOUT_FINDING="GATD-${SLUG}-timeout | HIGH | (gate.d) | gate.d script '$SLUG' timed out after ${QUALITY_GATE_TIMEOUT_SECONDS}s — failing closed. Investigate and optimize the script or increase the configured timeout."
1422
1442
  GATED_FINDINGS="${GATED_FINDINGS}
1423
1443
  ${TIMEOUT_FINDING}"
1424
- echo "2R.5: TIMEOUT — $SLUG (30s) — fail-closed HIGH finding added"
1444
+ echo "2R.5: TIMEOUT — $SLUG (${QUALITY_GATE_TIMEOUT_SECONDS}s) — fail-closed HIGH finding added"
1425
1445
  elif [ "$GATE_EXIT" -eq 1 ]; then
1426
1446
  # Script fired — findings on stdout
1427
1447
  while IFS= read -r line; do
@@ -1453,7 +1473,7 @@ If any gate.d check produces findings, include them in the Step 3 findings list
1453
1473
  - `$2` = absolute path to the worktree root
1454
1474
  - stdout = findings lines, one per line: `SLUG | HIGH|MEDIUM|LOW | FILE | MESSAGE`
1455
1475
  - Exit 0 = no findings / passed; Exit 1 = findings emitted; Exit 2 = inapplicable (diff has no relevant content)
1456
- - 30-second wall-time budget enforced by quality-gate; timeout = fail-closed HIGH finding
1476
+ - `FORGEDOCK_QUALITY_GATE_TIMEOUT_SECONDS` wall-time budget (30 seconds by default) enforced by quality-gate; timeout = fail-closed HIGH finding
1457
1477
  - Do NOT write to disk, do NOT make network requests, do NOT modify any files
1458
1478
 
1459
1479
  ---
@@ -29,6 +29,12 @@ All tool results consumed by agents — including diff slices, file reads, and c
29
29
 
30
30
  Rationale: agents receiving oversized context perform worse, not better — attention dilutes across irrelevant content, and token limits risk truncating the structured findings block that the triage phase depends on.
31
31
 
32
+ ## File-Backed GitHub Write Integrity
33
+
34
+ When staging a body for `gh issue create|edit` or `gh pr create|comment` with `--body-file`, use the session scratchpad (or a repo-relative scratch directory) rather than a generic path under shared `/tmp`; native Windows `gh` may not resolve Git Bash `/tmp` reliably. The filename must contain the issue or PR number and an agent-unique token, and `mktemp` must add a random suffix. Add exactly one caller-chosen marker to the body: `<!-- FORGE:BODY-INTEGRITY:<entity>_<role>_<agent-token> -->`.
35
+
36
+ Immediately after the write, re-read the issue, PR, or posted comment and assert that it contains that exact marker. Treat a missing marker as a hard error and stop. A unique filename reduces collision risk, but only the read-back detects another agent's plausible-looking content being posted in its place; do not rely on a visual review. The `/issue` programmatic contract performs this assertion for its `--body-file` callers.
37
+
32
38
  ## File Resolution Discipline
33
39
 
34
40
  Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to locate protocol files, persona templates, or verification scripts under any circumstances. If a `Read` or `bash` invocation of an expected pipeline file fails (e.g. because `$FORGE_HOME` is unset and the path degraded to a root-anchored form), that is never a reason to search the filesystem — it means the deterministic fallback chain the orchestrator already computed (`$FORGE_HOME` → `$REPO_PATH` → documented last-resort) was exhausted. Stop and report the failure (or fall through to the orchestrator's documented FATAL/hard-stop behavior — see `commands/review-pr.md` Phase 3C `TEMPLATE_BASE` guard) instead of improvising a `find /`-style search. A filesystem-wide `find` on an unset variable is the exact failure mode that produced runaway orphaned processes in production (see forge#1984, forge#2035). <!-- Added: forge#2035 -->
@@ -145,6 +151,12 @@ Every finding must include:
145
151
 
146
152
  **All review agents MUST include a machine-readable findings block at the end of their PR comment.** This is NON-OPTIONAL. Without structured findings, the review system cannot create GitHub issues, and findings die as unread PR comments. Every finding that doesn't become a GitHub issue is a finding that will never be addressed.
147
153
 
154
+ ### Persist Before Post
155
+
156
+ GitHub is a delivery channel, not the sole record of a review. Before the first `gh pr comment` attempt, write the finalized complete review body, including its `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker and structured findings block, to a uniquely named durable file such as `${TMPDIR:-/tmp}/forge-review-${PR_NUMBER}-${DOMAIN}-$$.md`. Use `gh pr comment --body-file "$REVIEW_BODY_PATH"`; do not construct a retry loop around a failed write.
157
+
158
+ Every agent MUST return its verdict, finding count, and one line per finding to the orchestrator even when posting succeeds. If the post fails, return the durable body path and the same finding summary, then stop. A 403 or other write failure is a failed delivery, not a clean review and not a reason to retry in the background.
159
+
148
160
  ### Format
149
161
 
150
162
  Append this block at the very end of your comment (after the `---` footer line, still inside the EOF heredoc). It uses HTML comments so it's invisible in rendered markdown:
@@ -163,6 +175,7 @@ Append this block at the very end of your comment (after the `---` footer line,
163
175
  6. **Summary**: Concise one-line description (no pipe `|` characters in summary)
164
176
  7. **Empty block**: If no findings at all, include just the START/END markers
165
177
  8. **HTML comments**: The block is invisible in rendered markdown but parseable by the review system
178
+ 9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the lowercase dispatched domain.
166
179
 
167
180
  ### Domain Prefixes
168
181
 
@@ -21,7 +21,7 @@ directly (`gh pr comment`) rather than relaying them through the orchestrator's
21
21
 
22
22
  **OpenCode override**: When `FORGE_RUNTIME=opencode` or an equivalent OpenCode marker is present, the
23
23
  orchestrator must set `DISPATCH_TOOL=task` before checking literal Claude tool names. A native task
24
- argument is shaped as `{ description: "...", prompt: "...", subagent_type: "general"|"explore", background: true }`:
24
+ argument is shaped as `{ description: "...", prompt: "...", subagent_type: "general"|"explore", background: false }`:
25
25
  use `general` for implementation/review and `explore` for read-only discovery. The `Neither tool is
26
26
  available` hard stop applies only when lowercase native `task` is genuinely absent; it must not be
27
27
  triggered by the absence of `Task` and `Agent` names in an OpenCode session. If native `task` is absent,
@@ -39,11 +39,12 @@ if [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
39
39
  fi
40
40
  ```
41
41
 
42
- When `IS_OPENCODE_RUNTIME=true`, lowercase native `task` is the preferred isolated dispatch tool. Do not enter the `Neither tool is available` branch merely because Claude's literal `Task` and `Agent` names are absent. Every native task call must use a top-level `subagent_type`: `general` for implementation/review work and `explore` for read-only discovery (Claude `general-purpose` and `codebase-explorer` map to those values). Use `background: true` for independent reviewers so the parent can process each completion event without a wave barrier. If the native `task` capability itself is absent from the current tool registry, use the existing hard-stop path and post `FORGE:REVIEW_BLOCKED`; never substitute inline review.
42
+ When `IS_OPENCODE_RUNTIME=true`, lowercase native `task` is the preferred isolated dispatch tool. Do not enter the `Neither tool is available` branch merely because Claude's literal `Task` and `Agent` names are absent. Every native task call must use a top-level `subagent_type`: `general` for implementation/review work and `explore` for read-only discovery (Claude `general-purpose` and `codebase-explorer` map to those values). Review tasks are foreground: wait for their completed result before synthesis. If the native `task` capability itself is absent from the current tool registry, use the existing hard-stop path and post `FORGE:REVIEW_BLOCKED`; never substitute inline review.
43
43
 
44
44
  1. **If `Task` is available in the current environment**: set `{DISPATCH_TOOL} = Task`. This is the preferred tool — tightest `allowed-tools` scoping. (Identical resolution logic to `/review-pr` Phase 3C — do not diverge.)
45
45
  2. **Else if `Agent` is available**: set `{DISPATCH_TOOL} = Agent`. This is the documented fallback, not a degraded path — use it exactly as you would `Task`: one call per selected agent (Bug Hunters in Phase 3, Code Quality in Phase 4, domain agents in Phase 5), same prompt template, `subagent_type: "general-purpose"` (or the closest equivalent the environment offers), same requirement that each agent posts its own findings directly to the PR via `gh pr comment`. Isolation and fresh-context review are preserved either way.
46
46
  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.
47
+ 4. **Dispatch pool exhausted or dispatch call fails**: this is not tool absence. If any Bug Hunter, quality, domain, material-change, or regression reviewer cannot be launched because the sub-agent pool/session limit is exhausted (or any dispatch call fails), HARD STOP. Do not substitute inline review or silently continue with a partial panel. Mark the PR `review-degraded`, post `<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->` with the selected and completed counts, and exit without a deploy verdict. A fresh session must re-run the full panel.
47
48
 
48
49
  **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.
49
50
 
@@ -198,9 +199,17 @@ ALL_PR_NUMBERS=$(echo "$BUNDLE_PRS $MERGE_PRS" | tr ' ' '\n' | sort -u | grep -E
198
199
 
199
200
  echo "PRs in staging→main bundle: $(echo $ALL_PR_NUMBERS | tr '\n' ' ')"
200
201
 
201
- # Step 2: For each PR in the bundle, check for open review-finding issues
202
+ # Step 2: For each PR in the bundle, check for open review-finding issues and degraded panels
202
203
  BLOCKING_FINDINGS=""
204
+ DEGRADED_REVIEWS=""
203
205
  for pr_num in $ALL_PR_NUMBERS; do
206
+ IS_REVIEW_DEGRADED=$(gh pr view "$pr_num" -R {GH_REPO} --json labels \
207
+ --jq '[.labels[].name] | any(. == "review-degraded")' 2>/dev/null || echo "false")
208
+ if [ "$IS_REVIEW_DEGRADED" = "true" ]; then
209
+ DEGRADED_REVIEWS="${DEGRADED_REVIEWS}
210
+ **PR #${pr_num}** has a \`review-degraded\` label and requires a full fresh-context re-review."
211
+ fi
212
+
204
213
  # Search for open review-finding issues that reference this PR
205
214
  OPEN_FINDINGS=$(gh issue list -R {GH_REPO} \
206
215
  --label "review-finding" \
@@ -217,6 +226,27 @@ ${OPEN_FINDINGS}"
217
226
  fi
218
227
  done
219
228
 
229
+ # A degraded panel is a review-integrity failure, not a finding that an override may waive.
230
+ if [ -n "$DEGRADED_REVIEWS" ]; then
231
+ echo "⛔ DEPLOY BLOCKED — An included PR has an incomplete isolated review panel."
232
+ echo "$DEGRADED_REVIEWS"
233
+ if [ -n "$PR_NUMBER" ]; then
234
+ gh pr comment "$PR_NUMBER" -R {GH_REPO} --body "<!-- FORGE:GATE_FAILURE -->
235
+ ## Deploy Gate: BLOCKED
236
+
237
+ **Gate**: review-panel-integrity
238
+
239
+ ### Degraded Review Panels
240
+
241
+ ${DEGRADED_REVIEWS}
242
+
243
+ Re-run the complete review panel in a fresh session before deployment.
244
+
245
+ <!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->" 2>/dev/null || true
246
+ fi
247
+ exit 1
248
+ fi
249
+
220
250
  # Step 3: Block deploy if open findings exist (unless human override present)
221
251
  if [ -n "$BLOCKING_FINDINGS" ]; then
222
252
  # Check for human override comment on the staging→main PR
@@ -403,7 +433,7 @@ For fixable failures: checkout staging, apply fix, verify locally, commit as `fi
403
433
 
404
434
  Launch agent (model: {SUBAGENT_MODEL}) to analyze all commits since last deploy. Categorize as: NEW FEATURE, ENHANCEMENT, BUG FIX, REFACTOR, SECURITY, PERFORMANCE, INFRASTRUCTURE, DEPENDENCY. Separate user-facing vs internal. Document breaking changes and required pre-deploy actions.
405
435
 
406
- **MANDATORY — post findings as PR comment**: After completing analysis, the agent MUST post its full report directly to the PR:
436
+ **MANDATORY — post findings as PR comment**: Before its first post attempt, the agent MUST persist the final report to a uniquely named durable file, then post it with `--body-file`. It MUST also return verdict, finding count, and one line per finding to the orchestrator whether the post succeeds or fails; on failure, return the file path and stop without retrying. After completing analysis, the agent posts its full report directly to the PR:
407
437
  ```bash
408
438
  gh pr comment ${PR_NUMBER} -R ${GH_REPO} --body "<!-- FORGE:REVIEW-AGENT:material-change -->
409
439
  ## Material Change Analysis
@@ -428,7 +458,7 @@ Launch Bug Hunter agents for each service with changes:
428
458
 
429
459
  **Web Bug Hunter** (web/src/): React issues (keys, closures, hydration), data fetching, security (XSS), UX, build-breaking patterns, type issues. Prefix: FE.
430
460
 
431
- Each reads the service diff, hunts for bugs, traces context across imports, posts findings with structured block.
461
+ Each reads the service diff, hunts for bugs, traces context across imports, persists its finalized report before posting, and returns its verdict and findings independently of GitHub delivery.
432
462
 
433
463
  **MANDATORY — each Bug Hunter agent MUST post its findings directly to the PR immediately upon completion** (do not wait for the orchestrator to batch-post):
434
464
  ```bash
@@ -449,7 +479,7 @@ Where `{service}` is `api`, `worker`, or `web`. Post one comment per service age
449
479
 
450
480
  Agent hunts for: dead code, duplicate logic, complexity (>50 line functions), naming issues, missing abstractions, logging quality, magic numbers. Prefix: QA.
451
481
 
452
- **MANDATORY — post findings as PR comment**: After completing analysis, the agent MUST post its full report directly to the PR:
482
+ **MANDATORY — post findings as PR comment**: Before its first post attempt, the agent MUST persist the final report to a uniquely named durable file, then post it with `--body-file`. It MUST also return verdict, finding count, and one line per finding to the orchestrator whether the post succeeds or fails; on failure, return the file path and stop without retrying. After completing analysis, the agent posts its full report directly to the PR:
453
483
  ```bash
454
484
  gh pr comment ${PR_NUMBER} -R ${GH_REPO} --body "<!-- FORGE:REVIEW-AGENT:code-quality -->
455
485
  ## Code Quality Review
@@ -506,7 +536,7 @@ fi
506
536
 
507
537
  Launch domain-specific agents based on which domains have changes. Substitute PR diff commands with staging diff commands. Agents: General Security (always), Auth, Billing, Concurrency, Scraper, API Design, Database, Infrastructure.
508
538
 
509
- **MANDATORY — each domain agent MUST post its findings directly to the PR immediately upon completion** (not batched by the orchestrator):
539
+ **MANDATORY — each domain agent MUST persist its finalized body before posting its findings directly to the PR immediately upon completion** (not batched by the orchestrator). It MUST return verdict, finding count, and one line per finding to the orchestrator independently of delivery; if posting fails, return the durable file path and stop without retrying:
510
540
  ```bash
511
541
  gh pr comment ${PR_NUMBER} -R ${GH_REPO} --body "<!-- FORGE:REVIEW-AGENT:{domain} -->
512
542
  ## {Domain} Review
@@ -525,7 +555,7 @@ Where `{domain}` is `security`, `auth`, `billing`, `concurrency`, `scraper`, `ap
525
555
 
526
556
  Agent maps dependencies, assesses integration points (service boundaries, env vars, Docker changes, workflow sibling drift between ci.yml and deploy-production.yml), evaluates rollback difficulty (easy/hard/destructive/state-dependent), checks test coverage. Posts risk matrix with rollback plan. Prefix: REG.
527
557
 
528
- **MANDATORY — post findings as PR comment**: After completing analysis, the agent MUST post its full report directly to the PR:
558
+ **MANDATORY — post findings as PR comment**: Before its first post attempt, the agent MUST persist the final report to a uniquely named durable file, then post it with `--body-file`. It MUST also return verdict, finding count, and one line per finding to the orchestrator whether the post succeeds or fails; on failure, return the file path and stop without retrying. After completing analysis, the agent posts its full report directly to the PR:
529
559
  ```bash
530
560
  gh pr comment ${PR_NUMBER} -R ${GH_REPO} --body "<!-- FORGE:REVIEW-AGENT:regression-risk -->
531
561
  ## Regression Risk Assessment
@@ -682,8 +712,51 @@ If the gate exits with `RESULT: BLOCK DEPLOY` → **STOP**. A `<!-- FORGE:GATE_F
682
712
 
683
713
  ---
684
714
 
715
+ **Incomplete-panel guard (MANDATORY):** Increment `SELECTED_AGENT_COUNT` for every reviewer selected across Phases 2–6 before launching it. If any dispatch fails, set `DISPATCH_FAILED=true`. Before Phase 7, count unique `FORGE:REVIEW-AGENT` markers. If the completed count is lower than selected, follow the same hard-stop path as pool exhaustion: create `review-degraded` if needed, label the PR, post `FORGE:GATE_FAILURE`, and exit. Do not create a deploy verdict from partial reviewer output.
716
+
717
+ ```bash
718
+ ACTUAL_AGENT_COUNT=$(gh api "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
719
+ --jq '[.[].body | scan("<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | length' 2>/dev/null || echo 0)
720
+ if [ "$DISPATCH_FAILED" = "true" ] || [ "$ACTUAL_AGENT_COUNT" -lt "$SELECTED_AGENT_COUNT" ]; then
721
+ gh label create "review-degraded" --color "E4E669" --description "PR review panel was incomplete; re-review required before deployment. Managed by ForgeDock." --force -R "$GH_REPO" 2>/dev/null || true
722
+ gh pr edit "$PR_NUMBER" -R "$GH_REPO" --add-label "review-degraded" --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
723
+ gh pr comment "$PR_NUMBER" -R "$GH_REPO" --body "<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->
724
+ ## Deploy Review Blocked: Incomplete Isolated Review Panel
725
+
726
+ **Selected isolated reviewers**: ${SELECTED_AGENT_COUNT}
727
+ **Completed isolated reviewers**: ${ACTUAL_AGENT_COUNT}
728
+
729
+ Re-run the full staging review in a fresh session before deployment."
730
+ exit 1
731
+ fi
732
+ ```
733
+
685
734
  ## Phase 7: Finding Triage & Issue Creation
686
735
 
736
+ ### Phase 6.75: Verify Agent Delivery
737
+
738
+ Before launching every Phase 2-6 review agent, append its lowercase marker domain (for example, `security`, `bug-hunter-api`, or `regression-risk`) to `LAUNCHED_REVIEW_AGENTS`. A completed agent is accounted for only when its corresponding marker comment is present on the PR.
739
+
740
+ ```bash
741
+ MISSING_AGENT_COMMENTS=""
742
+ for AGENT_DOMAIN in $LAUNCHED_REVIEW_AGENTS; do
743
+ AGENT_COMMENT_COUNT=$(gh api "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
744
+ --jq "[.[] | select(.body | contains(\"<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->\"))] | length" \
745
+ 2>/dev/null || printf '0')
746
+ if [ "${AGENT_COMMENT_COUNT:-0}" -lt 1 ]; then
747
+ MISSING_AGENT_COMMENTS="${MISSING_AGENT_COMMENTS} ${AGENT_DOMAIN}"
748
+ fi
749
+ done
750
+
751
+ if [ -n "$MISSING_AGENT_COMMENTS" ]; then
752
+ echo "REVIEW DELIVERY FAILURE: missing findings comment(s) for:${MISSING_AGENT_COMMENTS}"
753
+ echo "Recover each agent's returned verdict, findings, and durable body path before rerunning review."
754
+ exit 1
755
+ fi
756
+ ```
757
+
758
+ Missing comments are an explicit failure, never evidence of no findings. Do not triage or synthesize a deploy verdict while any launched agent is unaccounted for.
759
+
687
760
  ### 7A: Extract Findings
688
761
  From PR comments, extract structured findings (`<!-- FINDING:... -->`). If none found, scan for unstructured findings. If still 0 → skip to Phase 8.
689
762
 
@@ -734,7 +807,11 @@ STAGING_FINDING_TITLE="chore: [summary] (staging review — PR #${PR_NUMBER})"
734
807
  # this is no longer required for safety — but strip it anyway so the raw title
735
808
  # stays readable if it round-trips through any other eval-based consumer.
736
809
  STAGING_FINDING_TITLE=$(printf '%s' "$STAGING_FINDING_TITLE" | tr '`' "'" | sed 's/\$(/$ (/g')
737
- STAGING_FINDING_BODY_FILE=$(mktemp)
810
+ SCRATCHPAD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"
811
+ REVIEW_AGENT_TOKEN="${AGENT_ID:-${HOSTNAME:-reviewer}-$$}"
812
+ mkdir -p "$SCRATCHPAD"
813
+ STAGING_FINDING_BODY_MARKER="FORGE:BODY-INTEGRITY:${PR_NUMBER}_staging-review_${REVIEW_AGENT_TOKEN}"
814
+ STAGING_FINDING_BODY_FILE=$(mktemp "$SCRATCHPAD/${PR_NUMBER}_staging-review_${REVIEW_AGENT_TOKEN}.XXXXXX.md")
738
815
  cat <<'ISSUE_EOF' > "$STAGING_FINDING_BODY_FILE"
739
816
  ## Problem
740
817
 
@@ -768,6 +845,7 @@ Files that need changes:
768
845
  - [ ] Finding validated: VALIDATED / FALSE_POSITIVE / INCONCLUSIVE
769
846
  - [ ] If VALIDATED: fix implemented and tested on correct branch
770
847
  ISSUE_EOF
848
+ printf '\n<!-- %s -->\n' "$STAGING_FINDING_BODY_MARKER" >> "$STAGING_FINDING_BODY_FILE"
771
849
 
772
850
  # STAGING_FINDING_SEVERITY is extracted from the finding's own **Severity**
773
851
  # body field (set above in the heredoc) — example assignment shown here for
@@ -794,18 +872,22 @@ else
794
872
  # --label is repeatable (not comma-joined) per the /issue programmatic contract.
795
873
  # ${MILESTONE_FLAG} carries the Phase 7D derivation through — empty string is
796
874
  # a no-op arg when the reviewed branch has no milestone (plain staging→main).
797
- Skill(skill="issue", args="--title \"$STAGING_FINDING_TITLE\" --body-file \"$STAGING_FINDING_BODY_FILE\" --label review-finding --label needs-validation --label staging-review --label \"$STAGING_FINDING_PRIORITY\" ${MILESTONE_FLAG}")
875
+ ISSUE_SKILL_OUTPUT=$(Skill(skill="issue", args="--title \"$STAGING_FINDING_TITLE\" --body-file \"$STAGING_FINDING_BODY_FILE\" --label review-finding --label needs-validation --label staging-review --label \"$STAGING_FINDING_PRIORITY\" ${MILESTONE_FLAG}"))
876
+ # /issue re-reads the created issue and hard-fails unless this exact marker is present.
798
877
  rm -f "$STAGING_FINDING_BODY_FILE"
799
878
 
800
- # /issue has no machine-readable return contract — resolve the created issue's number by
801
- # exact-title search immediately after the call (title embeds ${PR_NUMBER} + summary, unique
802
- # enough for a reliable single-match lookup). Retry to absorb GitHub Search API indexing lag.
803
- ISSUE_NUM=""
804
- for _resolve_attempt in 1 2 3; do
805
- ISSUE_NUM=$(gh issue list -R {GH_REPO} --search "in:title \"${STAGING_FINDING_TITLE}\"" --state open --limit 1 --json number --jq '.[0].number // empty')
806
- [ -n "$ISSUE_NUM" ] && break
807
- sleep 2
808
- done
879
+ # /issue succeeds only after its API create-token read-back (Phase 4B). Its
880
+ # explicit result marker distinguishes a verified create from an intentional
881
+ # dedup STOP; do not use title search to mask a swallowed 403.
882
+ ISSUE_NUM=$(printf '%s\n' "$ISSUE_SKILL_OUTPUT" | sed -n 's/.*ISSUE_CREATE_RESULT:CREATED number=\([0-9][0-9]*\).*/\1/p' | head -1)
883
+ DEDUP_NUMBER=$(printf '%s\n' "$ISSUE_SKILL_OUTPUT" | sed -n 's/.*ISSUE_CREATE_RESULT:DEDUP number=\([0-9][0-9]*\).*/\1/p' | head -1)
884
+ if [ -n "$DEDUP_NUMBER" ]; then
885
+ ISSUE_NUM="$DEDUP_NUMBER"
886
+ echo "Staging review finding deduped against existing issue #${ISSUE_NUM}."
887
+ elif [ -z "$ISSUE_NUM" ]; then
888
+ echo "ERROR: /issue did not report a verified staging review-finding number; stopping review instead of silently dropping the finding." >&2
889
+ exit 1
890
+ fi
809
891
  fi
810
892
  ```
811
893