forgedock 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/bin/engine/admission.mjs +242 -7
  3. package/bin/engine/admission.test.mjs +183 -2
  4. package/bin/engine/phases.mjs +31 -22
  5. package/bin/engine/resolve.mjs +8 -1
  6. package/bin/engine/resolve.test.mjs +5 -0
  7. package/bin/engine-cli.mjs +2 -0
  8. package/bin/engine.mjs +26 -17
  9. package/bin/forgedock.mjs +124 -48
  10. package/bin/hooks/pre-tool-use.mjs +573 -2
  11. package/bin/journey.mjs +11 -7
  12. package/bin/labels.json +5 -0
  13. package/bin/opencode-adapter.mjs +652 -92
  14. package/bin/orchestrate-preflight.mjs +434 -0
  15. package/bin/runner.mjs +77 -9
  16. package/commands/audit-agents.md +75 -26
  17. package/commands/autopilot.md +4 -4
  18. package/commands/cleanup.md +10 -5
  19. package/commands/issue.md +95 -35
  20. package/commands/orchestrate/phase-1-resolve.md +70 -32
  21. package/commands/orchestrate/phase-3-dependency.md +167 -22
  22. package/commands/orchestrate/phase-4-execution.md +691 -62
  23. package/commands/orchestrate/phase-5-cleanup.md +5 -11
  24. package/commands/orchestrate/phase-6-report.md +27 -1
  25. package/commands/orchestrate/safety.md +1 -1
  26. package/commands/orchestrate.md +39 -1
  27. package/commands/quality-gate.md +30 -10
  28. package/commands/review-pr-agents/protocols.md +13 -0
  29. package/commands/review-pr-agents.md +8 -0
  30. package/commands/review-pr-staging.md +117 -17
  31. package/commands/review-pr.md +95 -23
  32. package/commands/work-on/build/validate.md +25 -4
  33. package/commands/work-on/close.md +40 -10
  34. package/commands/work-on/decompose.md +33 -1
  35. package/commands/work-on/investigate.md +33 -0
  36. package/commands/work-on/remediate.md +40 -5
  37. package/commands/work-on/review.md +27 -0
  38. package/commands/work-on.md +48 -5
  39. package/docs/CONFIG.md +51 -15
  40. package/docs/OPENCODE.md +128 -25
  41. package/docs/spec/review-protocol.md +7 -0
  42. package/package.json +1 -1
  43. package/scripts/check-admission-parity.mjs +1 -0
  44. package/scripts/check-command-side-effects.sh +14 -13
  45. package/scripts/check-shell-portability.sh +36 -0
  46. package/scripts/check-spec-markers.sh +21 -11
  47. package/scripts/checkbox-sections.test.sh +67 -0
  48. package/scripts/extract-affected-files.sh +85 -5
  49. 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
 
@@ -19,6 +19,14 @@ the `prompt` argument to whichever tool the orchestrator resolved (`Task(...)` o
19
19
  same per-agent isolation and the same requirement that each agent posts structured findings to the PR
20
20
  directly (`gh pr comment`) rather than relaying them through the orchestrator's own context.
21
21
 
22
+ **OpenCode override**: When `FORGE_RUNTIME=opencode` or an equivalent OpenCode marker is present, the
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: false }`:
25
+ use `general` for implementation/review and `explore` for read-only discovery. The `Neither tool is
26
+ available` hard stop applies only when lowercase native `task` is genuinely absent; it must not be
27
+ triggered by the absence of `Task` and `Agent` names in an OpenCode session. If native `task` is absent,
28
+ post `FORGE:REVIEW_BLOCKED` and never replace the isolated review with inline work.
29
+
22
30
  <!-- FORGE:PROTOCOL_SOURCE — canonical definition lives in docs/spec/review-protocol.md -->
23
31
 
24
32
  ---
@@ -23,9 +23,28 @@ Performs comprehensive review of `staging` before merging to `main`. Handles lar
23
23
 
24
24
  This spec dispatches Bug Hunter and domain review agents via a sub-agent-spawning tool. Different runtimes expose different tools for this — resolve deterministically, once per invocation, and use the same tool for every dispatch call in this run:
25
25
 
26
+ ### OpenCode Runtime Override
27
+
28
+ When the workflow is running under OpenCode, resolve the native tool before applying the Claude-specific availability order above:
29
+
30
+ ```bash
31
+ IS_OPENCODE_RUNTIME=false
32
+ if [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
33
+ [ -n "${OPENCODE_SESSION_ID:-}" ] ||
34
+ [ -n "${OPENCODE_PID:-}" ] ||
35
+ [ -n "${OPENCODE:-}" ]; then
36
+ IS_OPENCODE_RUNTIME=true
37
+ DISPATCH_TOOL=task
38
+ DISPATCH_SUBAGENT_TYPE=general
39
+ fi
40
+ ```
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). 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
+
26
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.)
27
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.
28
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.
29
48
 
30
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.
31
50
 
@@ -180,9 +199,17 @@ ALL_PR_NUMBERS=$(echo "$BUNDLE_PRS $MERGE_PRS" | tr ' ' '\n' | sort -u | grep -E
180
199
 
181
200
  echo "PRs in staging→main bundle: $(echo $ALL_PR_NUMBERS | tr '\n' ' ')"
182
201
 
183
- # 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
184
203
  BLOCKING_FINDINGS=""
204
+ DEGRADED_REVIEWS=""
185
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
+
186
213
  # Search for open review-finding issues that reference this PR
187
214
  OPEN_FINDINGS=$(gh issue list -R {GH_REPO} \
188
215
  --label "review-finding" \
@@ -199,6 +226,27 @@ ${OPEN_FINDINGS}"
199
226
  fi
200
227
  done
201
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
+
202
250
  # Step 3: Block deploy if open findings exist (unless human override present)
203
251
  if [ -n "$BLOCKING_FINDINGS" ]; then
204
252
  # Check for human override comment on the staging→main PR
@@ -385,7 +433,7 @@ For fixable failures: checkout staging, apply fix, verify locally, commit as `fi
385
433
 
386
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.
387
435
 
388
- **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:
389
437
  ```bash
390
438
  gh pr comment ${PR_NUMBER} -R ${GH_REPO} --body "<!-- FORGE:REVIEW-AGENT:material-change -->
391
439
  ## Material Change Analysis
@@ -410,7 +458,7 @@ Launch Bug Hunter agents for each service with changes:
410
458
 
411
459
  **Web Bug Hunter** (web/src/): React issues (keys, closures, hydration), data fetching, security (XSS), UX, build-breaking patterns, type issues. Prefix: FE.
412
460
 
413
- 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.
414
462
 
415
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):
416
464
  ```bash
@@ -431,7 +479,7 @@ Where `{service}` is `api`, `worker`, or `web`. Post one comment per service age
431
479
 
432
480
  Agent hunts for: dead code, duplicate logic, complexity (>50 line functions), naming issues, missing abstractions, logging quality, magic numbers. Prefix: QA.
433
481
 
434
- **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:
435
483
  ```bash
436
484
  gh pr comment ${PR_NUMBER} -R ${GH_REPO} --body "<!-- FORGE:REVIEW-AGENT:code-quality -->
437
485
  ## Code Quality Review
@@ -488,7 +536,7 @@ fi
488
536
 
489
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.
490
538
 
491
- **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:
492
540
  ```bash
493
541
  gh pr comment ${PR_NUMBER} -R ${GH_REPO} --body "<!-- FORGE:REVIEW-AGENT:{domain} -->
494
542
  ## {Domain} Review
@@ -507,7 +555,7 @@ Where `{domain}` is `security`, `auth`, `billing`, `concurrency`, `scraper`, `ap
507
555
 
508
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.
509
557
 
510
- **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:
511
559
  ```bash
512
560
  gh pr comment ${PR_NUMBER} -R ${GH_REPO} --body "<!-- FORGE:REVIEW-AGENT:regression-risk -->
513
561
  ## Regression Risk Assessment
@@ -664,8 +712,51 @@ If the gate exits with `RESULT: BLOCK DEPLOY` → **STOP**. A `<!-- FORGE:GATE_F
664
712
 
665
713
  ---
666
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
+
667
734
  ## Phase 7: Finding Triage & Issue Creation
668
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
+
669
760
  ### 7A: Extract Findings
670
761
  From PR comments, extract structured findings (`<!-- FINDING:... -->`). If none found, scan for unstructured findings. If still 0 → skip to Phase 8.
671
762
 
@@ -716,7 +807,11 @@ STAGING_FINDING_TITLE="chore: [summary] (staging review — PR #${PR_NUMBER})"
716
807
  # this is no longer required for safety — but strip it anyway so the raw title
717
808
  # stays readable if it round-trips through any other eval-based consumer.
718
809
  STAGING_FINDING_TITLE=$(printf '%s' "$STAGING_FINDING_TITLE" | tr '`' "'" | sed 's/\$(/$ (/g')
719
- 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")
720
815
  cat <<'ISSUE_EOF' > "$STAGING_FINDING_BODY_FILE"
721
816
  ## Problem
722
817
 
@@ -750,6 +845,7 @@ Files that need changes:
750
845
  - [ ] Finding validated: VALIDATED / FALSE_POSITIVE / INCONCLUSIVE
751
846
  - [ ] If VALIDATED: fix implemented and tested on correct branch
752
847
  ISSUE_EOF
848
+ printf '\n<!-- %s -->\n' "$STAGING_FINDING_BODY_MARKER" >> "$STAGING_FINDING_BODY_FILE"
753
849
 
754
850
  # STAGING_FINDING_SEVERITY is extracted from the finding's own **Severity**
755
851
  # body field (set above in the heredoc) — example assignment shown here for
@@ -776,18 +872,22 @@ else
776
872
  # --label is repeatable (not comma-joined) per the /issue programmatic contract.
777
873
  # ${MILESTONE_FLAG} carries the Phase 7D derivation through — empty string is
778
874
  # a no-op arg when the reviewed branch has no milestone (plain staging→main).
779
- 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.
780
877
  rm -f "$STAGING_FINDING_BODY_FILE"
781
878
 
782
- # /issue has no machine-readable return contract — resolve the created issue's number by
783
- # exact-title search immediately after the call (title embeds ${PR_NUMBER} + summary, unique
784
- # enough for a reliable single-match lookup). Retry to absorb GitHub Search API indexing lag.
785
- ISSUE_NUM=""
786
- for _resolve_attempt in 1 2 3; do
787
- ISSUE_NUM=$(gh issue list -R {GH_REPO} --search "in:title \"${STAGING_FINDING_TITLE}\"" --state open --limit 1 --json number --jq '.[0].number // empty')
788
- [ -n "$ISSUE_NUM" ] && break
789
- sleep 2
790
- 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
791
891
  fi
792
892
  ```
793
893