forgedock 1.17.0 → 1.18.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 (60) hide show
  1. package/bin/engine/outcome-codes.mjs +24 -2
  2. package/bin/engine/phases.mjs +153 -102
  3. package/bin/engine/runlog.mjs +7 -0
  4. package/bin/engine-cli.mjs +5 -0
  5. package/bin/engine.mjs +84 -10
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/hooks/pre-tool-use.mjs +65 -32
  8. package/bin/runner.mjs +63 -4
  9. package/bin/stats/github-source.mjs +89 -18
  10. package/bin/stats/stats.test.mjs +89 -1
  11. package/commands/cleanup.md +10 -3
  12. package/commands/deploy-pr.md +64 -0
  13. package/commands/fix-ci.md +20 -6
  14. package/commands/milestone.md +34 -17
  15. package/commands/orchestrate/phase-4-execution.md +11 -4
  16. package/commands/review-pr-staging.md +142 -519
  17. package/commands/review-pr.md +490 -3128
  18. package/commands/test-gate.md +25 -10
  19. package/commands/work-on/build/architect.md +13 -1
  20. package/commands/work-on/build/implement.md +2 -1
  21. package/commands/work-on/build/validate.md +2 -0
  22. package/commands/work-on/build.md +107 -86
  23. package/commands/work-on/close.md +50 -15
  24. package/commands/work-on/decompose.md +72 -34
  25. package/commands/work-on/investigate.md +9 -12
  26. package/commands/work-on/remediate.md +27 -13
  27. package/commands/work-on/review.md +5 -3
  28. package/commands/work-on.md +1 -2
  29. package/docs/CONFIG.md +44 -25
  30. package/docs/OUTCOME-CODES.md +7 -2
  31. package/docs/RELEASE-NOTES.md +12 -0
  32. package/docs/STATS.md +2 -1
  33. package/docs/TELEMETRY.md +2 -2
  34. package/docs/WORK-ON-RUNTIME.md +6 -0
  35. package/docs/spec/forge-protocol-v1.md +2 -2
  36. package/package.json +1 -1
  37. package/packages/protocol/src/phases.js +5 -5
  38. package/scripts/cap-findings.sh +1 -1
  39. package/scripts/cap-findings.test.sh +1 -1
  40. package/scripts/check-spec-bash.sh +5 -1
  41. package/scripts/check-spec-bash.test.sh +20 -0
  42. package/scripts/close-parent-c3.test.sh +69 -0
  43. package/scripts/diff-size.sh +93 -4
  44. package/scripts/diff-size.test.sh +59 -2
  45. package/scripts/forge-root.test.sh +13 -11
  46. package/scripts/publish-staging-sync.test.sh +48 -0
  47. package/scripts/resolve-deploy-gate.sh +32 -6
  48. package/scripts/resolve-deploy-gate.test.sh +42 -1
  49. package/scripts/review-finalize.sh +1427 -0
  50. package/scripts/review-finalize.test.sh +833 -0
  51. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  52. package/scripts/review-pr-domain-map.test.sh +18 -13
  53. package/scripts/review-pr-gate2.test.sh +9 -65
  54. package/scripts/review-pr-staging-scope.test.sh +14 -98
  55. package/scripts/review-prepare.sh +48 -12
  56. package/scripts/review-prepare.test.sh +19 -1
  57. package/scripts/review-roster.sh +16 -2
  58. package/scripts/review-roster.test.sh +22 -0
  59. package/scripts/trusted-comments.test.sh +9 -2
  60. package/scripts/wait-ci-green.test.sh +3 -3
@@ -40,7 +40,7 @@ If `{NUMBER}`, `--repo` or `--gh-flag` is missing, print the result block with `
40
40
 
41
41
  ## Script resolution
42
42
 
43
- **Shell state does not persist between Bash tool calls** (each call is a fresh shell): paste the block below at the top of every bash command in this skill that calls `resolve_script` or uses `FORGE_ROOT`/`UNIVERSAL_DIR`.
43
+ **Shell state does not persist between Bash tool calls** (each call is a fresh shell): paste the block below at the top of every bash command in this skill that calls `resolve_script` or `forge_resolve` or uses `FORGE_ROOT`/`UNIVERSAL_DIR` (including the D1 resume check and Phase D6.5).
44
44
 
45
45
  ```bash
46
46
  # Canonical script resolution (keep byte-identical across specs; guarded by scripts/forge-root.test.sh).
@@ -147,7 +147,7 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
147
147
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR") | not) | {author: .user.login, body: .body}'
148
148
  ```
149
149
 
150
- Ignore machine-posted marker comments (`FORGE:DIFF_SIZE`, `FORGE:SIZE_OVERRIDE`) in this scan: a `FORGE:DIFF_SIZE` comment is a plan source (below), never an owner override direction. Scan non-agent comments for override signals — phrases like "do not", "do NOT", "instead", "revert", "remove this", "override", "actually", or explicit disagreement with the investigation's recommendation. If an override comment is found from a repo owner or admin (not a bot):
150
+ Ignore machine-posted marker comments (`FORGE:DIFF_SIZE`, `FORGE:SIZE_OVERRIDE`) in this scan: they are never an owner override direction. Scan non-agent comments for override signals — phrases like "do not", "do NOT", "instead", "revert", "remove this", "override", "actually", or explicit disagreement with the investigation's recommendation. If an override comment is found from a repo owner or admin (not a bot):
151
151
 
152
152
  1. **Document the override**: Note which direction the owner is steering (e.g., "remove the feature" vs. investigation's "keep with warnings")
153
153
  2. **Re-derive sub-issue scopes**: Derive sub-issue titles, bodies, and file scope from the override direction — NOT from the original investigation recommendation. The investigation's Decomposition Assessment may list sub-issues that are now stale or contradictory with the override.
@@ -161,22 +161,17 @@ Ignore machine-posted marker comments (`FORGE:DIFF_SIZE`, `FORGE:SIZE_OVERRIDE`)
161
161
  Extract from investigation report:
162
162
  - Decomposition Assessment section: list of proposed sub-issues with titles and dependencies
163
163
 
164
- **Size-gate plan source** <!-- Added: forge#3450 -->: when the build's diff-size gate routed here, the investigation said no decomposition, so its assessment lists no sub-issues. Read the latest TRUSTED `FORGE:DIFF_SIZE` comment (anchored, through `scripts/trusted-comments.sh`; the Script resolution block applies) and use it when it has `result: OVER`:
164
+ **Planning-time only** <!-- Added: forge#3862 -->: decompose runs only from the investigation's Decomposition Assessment (`**YES**`, which requires separable pieces: independent modules or services, no shared files, no ordering dependency, each independently testable). There is no post-build route into this skill: build never discards a finished diff and never hands one here, so the assessment is the only plan source and there are no persisted staged hunks to reuse.
165
+ - Milestone (from issue metadata)
166
+ - Priority label (P0/P1/P2) from issue labels
165
167
 
168
+ Extract the parent's batch members (empty for a non-batch parent, which makes Phase D2.7 a no-op), using the same block and `- [ ] #N` extraction as `commands/work-on.md` "Batch issue detection": <!-- Added: forge#3788 -->
166
169
  ```bash
167
- # <Script resolution block, verbatim>
168
- set -o pipefail
169
- TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
170
- if [ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ]; then
171
- SIZE_PLAN=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE' | jq -r 'select(type == "string")' ) \
172
- && echo "SIZE_PLAN_READ=ok" || echo "SIZE_PLAN_READ=FAILED"
173
- else echo "SIZE_PLAN_READ=FAILED"; fi
170
+ BATCH_MEMBERS=($(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body' \
171
+ | sed -n '/<!-- FORGE:BATCH_MEMBERS -->/,/<!-- \/FORGE:BATCH_MEMBERS -->/p' \
172
+ | sed -n 's/^.*- \[ \] #\([0-9][0-9]*\).*/\1/p' 2>/dev/null || true))
174
173
  ```
175
174
 
176
- A `FAILED` read or a comment without `result: OVER` means there is no size-gate plan (the assessment rules below then apply unchanged). Its `### Split Proposal` lines (`- **{title}** — {files}`) are the proposed sub-issues, in dependency order, for Phase D2. <!-- Added: forge#3573 --> A line may end with `— wip: {REF}@{SHA} (git checkout {SHA} -- {files})` (the durable staged hunks build B5.5 pushed to `forge/wip/{NUMBER}`), or `— patch: {PATH}` when the push failed and a patch file was written. Parse that per-seam reference as `WIP_REF_{N}` for Phase D2/D3. A line without one (or `wip: unavailable`) means no hunks were persisted; behaviour is unchanged.
177
- - Milestone (from issue metadata)
178
- - Priority label (P0/P1/P2) from issue labels
179
-
180
175
  Extract milestone title for sub-issue creation:
181
176
  ```bash
182
177
  MILESTONE_TITLE=$(gh issue view {NUMBER} {GH_FLAG} --json milestone --jq '.milestone.title // empty')
@@ -200,24 +195,30 @@ If `PARENT_REF` is non-empty → BLOCKED, blocker: "Issue #{NUMBER} is a sub-iss
200
195
 
201
196
  **Guard 2 — investigation report present**: If the FORGE:INVESTIGATOR comment is absent → BLOCKED, blocker: "No investigation report found — run investigate first".
202
197
 
203
- **Guard 3 — decomposition plan present**: If the investigation report has no Decomposition Assessment section, OR the assessment does not list any sub-issues, AND there is no trusted `FORGE:DIFF_SIZE` comment with `result: OVER` and a `### Split Proposal` (the size-gate plan source from D0) → BLOCKED, blocker: "Investigation report has no decomposition plan — re-run investigate with explicit decomposition scope".
198
+ **Guard 3 — decomposition plan present**: If the investigation report has no Decomposition Assessment section, OR the assessment does not list any sub-issues → BLOCKED, blocker: "Investigation report has no decomposition plan — re-run investigate with explicit decomposition scope".
204
199
 
205
- **Resume check**:
200
+ **Resume check** <!-- Added: forge#3881 -->: the marker comment drives parent edits and the D6.5 close, so only a trusted, marker-anchored comment counts (`scripts/trusted-comments.sh`, the same predicate as the review gates). Paste the Script resolution block first (fresh shell per Bash call). An unresolved helper or a failed read is treated as "no trusted comment" and the normal path continues (the D2.5 equivalent-set check still guards duplicates); an untrusted body never reaches `SUB_LIST`.
206
201
 
207
202
  ```bash
208
- gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
209
- --jq '[.[] | select(.body | contains("<!-- FORGE:DECOMPOSED -->"))] | last // empty | {url: .html_url, body: .body}'
203
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh) # unresolved => empty => fail closed
204
+ DECOMP_BODY=""; COMMENT_URL=""
205
+ if [ -n "$TRUSTED_SCRIPT" ] && COMMENTS_JSON=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments); then
206
+ DECOMP_BODY=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DECOMPOSED -->' | tail -n 1 | jq -r '.' 2>/dev/null || true)
207
+ [ -n "$DECOMP_BODY" ] && COMMENT_URL=$(printf '%s' "$COMMENTS_JSON" | jq -rs --arg b "$DECOMP_BODY" '[.[][] | select(.body == $b)] | last | .html_url // empty' 2>/dev/null || true)
208
+ else
209
+ echo "decompose resume lookup unavailable"
210
+ fi
210
211
  ```
211
212
 
212
- If a `<!-- FORGE:DECOMPOSED -->` comment exists → decomposition already complete. Derive `sub_issues` as a plain number list from that comment's `- #N:` lines and the comment URL from `.url`:
213
+ If a `<!-- FORGE:DECOMPOSED -->` comment exists → decomposition already complete. Derive `sub_issues` as a plain number list from that comment's `- #N:` lines and the comment URL from `$COMMENT_URL`:
213
214
 
214
215
  ```bash
215
- # $DECOMP_BODY is the .body of the comment found above
216
+ # $DECOMP_BODY is the trusted, anchored comment body found above
216
217
  SUB_LIST=$(printf '%s\n' "$DECOMP_BODY" | sed -nE 's/^- #([0-9]+).*/\1/p' | paste -sd, - | sed 's/,/, /g')
217
218
  echo "sub_issues: [${SUB_LIST}]"
218
219
  ```
219
220
 
220
- Run Phase D6 (idempotent label re-assert) and Phase D6.5 (close parent when `decomposition.close_parent` is true), then EXIT with `DECOMPOSE_RESULT: status: ALREADY_DONE`, `sub_issues: [${SUB_LIST}]`, `comment_url` from the comment.
221
+ Run Phase D6 (idempotent label re-assert) and Phase D6.5 (close parent when `decomposition.close_parent` is true), then EXIT with `DECOMPOSE_RESULT: status: ALREADY_DONE`, `sub_issues: [${SUB_LIST}]`, `comment_url` from the comment. When `DECOMP_BODY` is empty, continue to the normal path.
221
222
 
222
223
  ---
223
224
 
@@ -243,14 +244,14 @@ If `GIST_URLS` is non-empty, a `## Prior Investigation` section will be appended
243
244
 
244
245
  ## Phase D2: Design Sub-Issues
245
246
 
246
- From the Decomposition Assessment in the investigation report (adjusted for any owner override detected in Phase D0), or, when the assessment lists none, from the size-gate `### Split Proposal` in the trusted `FORGE:DIFF_SIZE` comment (Phase D0), extract:
247
- 1. Sub-issue titles (in dependency order — independent issues first). **Packing** <!-- Added: forge#3786 -->: do not create one sub-issue per seam. Bin-pack the seams into the fewest sub-issues whose estimate (code plus tests) is at or under 0.8 x threshold, i.e. `max(2, ceil(ESTIMATED_DIFF_LINES / (0.8 x threshold)))` sub-issues, keeping related seams together; each sub-issue body lists the seams it covers. Migration-ordering seams may stay separate. **Budget check** <!-- Added: forge#3790 -->: reject or repack any plan item whose estimate (code plus tests) is over 0.8 x threshold: split it along a real seam or merge-and-resplit until each item fits. A seam that can't fit (one cohesive module plus its tests) stays as one item, and its body states that explicitly with the estimate; build B5.5 then treats a cohesive overage as `COHESIVE` instead of stranding it. Never inflate the sub-issue count to force a fit. Record each item's estimate as `ESTIMATED_DIFF_LINES` for Phase D3. The parent is a terminal tracker and builds nothing, so every packed item, including the first, becomes a sub-issue (N packed items give N sub-issues)
247
+ From the Decomposition Assessment in the investigation report (adjusted for any owner override detected in Phase D0), extract:
248
+ 1. Sub-issue titles (in dependency order — independent issues first). **Separability**: one sub-issue per separable piece the assessment names (independent module or service, no file shared with another piece, no ordering dependency). Re-check each pair of pieces: if two pieces edit the same file or one needs the other's output first, they are NOT separable, so merge them into one sub-issue rather than creating dependent siblings on the same file. Do not split a cohesive change (one new module plus its tests) to hit a size, and never inflate the sub-issue count. Record each item's estimate as `ESTIMATED_DIFF_LINES` for Phase D3 (a scope baseline, not a limit). The parent is a terminal tracker and builds nothing, so every item, including the first, becomes a sub-issue (N items give N sub-issues)
248
249
  2. Dependencies between sub-issues (if issue B depends on issue A, A is created first)
249
250
  3. Brief description for each sub-issue body
250
251
 
251
252
  For each sub-issue, prepare:
252
253
  - **Title**: from investigation report's proposed sub-issue title (re-derived from the owner override when one was detected), including the final `{fix|feat|refactor}: ` prefix used in Phase D3
253
- - **Body**: brief description of scope + `**Parent**: #{NUMBER}` + an own-line `ESTIMATED_DIFF_LINES: {N}` (the packed item's estimate, code plus tests; build B5.5 compares it with the actual diff in `FORGE:DIFF_SIZE`) + dependency note if applicable + a `## Staged Hunks` section when the Split Proposal line carries a wip ref or patch (see D3)
254
+ - **Body**: brief description of scope + `**Parent**: #{NUMBER}` + an own-line `ESTIMATED_DIFF_LINES: {N}` (the item's estimate, code plus tests; build B5.5 uses it as the scope baseline) + dependency note if applicable
254
255
  - **Labels**: inherit priority label (P0/P1/P2) from parent; do NOT copy workflow labels
255
256
  - **Milestone**: same milestone title as parent (if parent has one)
256
257
 
@@ -297,12 +298,36 @@ If no exact title-set match exists, continue. A partial set is not equivalent: l
297
298
 
298
299
  ---
299
300
 
301
+ ## Phase D2.7: Batch-Member Reuse
302
+
303
+ <!-- Added: forge#3788 -->
304
+ A batch parent (Phase D0 `BATCH_MEMBERS` non-empty, from its `FORGE:BATCH_MEMBERS` block) lists members that admission closed as `batch-member`. Filing new sub-issues for work those members already describe duplicates them, so reuse a member when it is a clear match. This phase is conservative: any doubt falls through to the Phase D3 `/issue` creation. Skip the whole phase when `BATCH_MEMBERS` is empty or `DRY_RUN=true`. Guard 1, the `FORGE:DECOMPOSED` resume check and the Phase D2.5 equivalent-set check are unchanged.
305
+
306
+ Initialise `REUSED_NUMBERS=""` (comma-separated) and an empty per-sub-issue `REUSE_MAP`. For each planned sub-issue from Phase D2:
307
+
308
+ 1. **Match one to one**: a member matches only when exactly one member's title or affected-file set corresponds to this planned sub-issue and no other planned sub-issue maps to the same member. Zero or several matches mean no reuse. Member numbers are digits parsed from the parent body; treat them as untrusted input and never interpolate anything else into a command.
309
+ 2. **Re-read the member live** (never trust the parent's checklist):
310
+ ```bash
311
+ MEMBER_JSON=$(gh issue view "$MEMBER" {GH_FLAG} --json number,state,title,body,labels) || MEMBER_JSON=""
312
+ MERGED_PRS=$(gh pr list {GH_FLAG} --state merged --search "#${MEMBER} in:body" --json number,body 2>/dev/null \
313
+ | jq --arg m "#${MEMBER}" '[.[] | select(.body | test("(?i)(closes|fixes|resolves) " + $m + "\\b"))] | length') || MERGED_PRS=""
314
+ ```
315
+ 3. **Eligibility**: the read succeeded, the member is `CLOSED`, `MERGED_PRS` is `0`, and it carries none of `false-positive`, `needs-human`, `operator-only`. A failed lookup, an unparsable result, an open member (already live work, leave it alone), a merged PR or a disqualifying label all mean no reuse.
316
+ 4. **Reopen and re-parent** an eligible member: `gh issue reopen "$MEMBER" {GH_FLAG}`; then re-read its body and append, only when absent (idempotent on resume, never overwrite), the line `**Parent**: #{NUMBER}` and an own-line `ESTIMATED_DIFF_LINES: {N}` (the Phase D2 estimate); inherit the parent's priority label and remove any stale `workflow:*` labels (`gh issue edit "$MEMBER" {GH_FLAG} --remove-label ...` for each present). Add the parent's milestone when it has one and the member has none.
317
+ 5. Record `REUSE_MAP[<planned title>]=$MEMBER` and append it to `REUSED_NUMBERS`. A reopen failure means no reuse for this sub-issue.
318
+
319
+ Because a reused member carries `**Parent**: #{NUMBER}`, Guard 1 and the Phase D2.5 check treat it as a child.
320
+
321
+ ---
322
+
300
323
  ## Phase D3: Create Sub-Issues
301
324
 
302
325
  For each sub-issue (in dependency order), route creation through the `/issue` create-hook's programmatic invocation contract (`commands/issue.md` Programmatic Invocation Contract, added in #2085) instead of calling `gh issue create` directly. `/issue`'s Phase 2D runs the same `scripts/issue-dedup.sh` check this file used to run manually — dedup is enforced inside the create-hook on every call, with no bypass path. <!-- Changed: forge#2086 — route through /issue create-hook -->
303
326
 
304
327
  Initialise once before the loop: `CREATED_NUMBERS=""` (comma-separated, for the result block) and `SKIPPED_NOTES=""`.
305
328
 
329
+ **Reused member short-circuit** <!-- Added: forge#3788 -->: for a planned sub-issue that Phase D2.7 resolved to a reused member, do NOT compose a body or invoke `/issue`. Set `SUB_NUMBER` to the reused member, add it to `CREATED_NUMBERS`, and continue with the Prior Investigation append below (when `GIST_URLS` is non-empty) and the later phases as for any created sub-issue.
330
+
306
331
  **Sanitise the title and compose the sub-issue body to a temp file** (avoids quoting issues when passed as `--body-file`):
307
332
 
308
333
  ```bash
@@ -340,10 +365,6 @@ Files that need changes:
340
365
  {If depends on another sub-issue: "**Depends on**: #{SUB_ISSUE_N} — {reason}"}
341
366
  ESTIMATED_DIFF_LINES: {N}
342
367
 
343
- {Only when this seam's Split Proposal line carries a wip ref or patch (D0); otherwise omit this section entirely:}
344
- ## Staged Hunks
345
-
346
- The parent build's staged hunks for this seam are preserved outside the worktree: `{REF}@{SHA}`. Reuse them with `git fetch origin {REF} && git checkout {SHA} -- {files}` (wip ref), or `git apply {PATCH_PATH}` (patch file). They survive worktree cleanup.
347
368
  SUB_BODY_EOF
348
369
  ```
349
370
 
@@ -422,7 +443,7 @@ Investigation findings from the parent issue are available as Knowledge Gists:
422
443
  fi
423
444
  ```
424
445
 
425
- After the loop, if `CREATED_NUMBERS` is empty (no sub-issue was created) → BLOCKED, blocker: "No sub-issues were created (all skipped or deduplicated) — review the /issue output".
446
+ After the loop, if `CREATED_NUMBERS` is empty (no sub-issue was created or reused) → BLOCKED, blocker: "No sub-issues were created (all skipped or deduplicated) — review the /issue output".
426
447
 
427
448
  ---
428
449
 
@@ -440,7 +461,7 @@ TRACKER="
440
461
  ## Sub-Issue Tracker
441
462
 
442
463
  {if sub-issue B depends on A, note it inline}
443
- - [ ] #{SUB_ISSUE_1_NUMBER} — {SUB_ISSUE_1_TITLE}
464
+ - [ ] #{SUB_ISSUE_1_NUMBER} — {SUB_ISSUE_1_TITLE}{if reused in D2.7: _(reopened batch member)_}
444
465
  - [ ] #{SUB_ISSUE_2_NUMBER} — {SUB_ISSUE_2_TITLE} _(depends on #{SUB_ISSUE_1_NUMBER})_
445
466
  ..."
446
467
 
@@ -458,7 +479,7 @@ SHOW_ATTRIBUTION=$(yq '.branding.show_attribution // "true"' forge.yaml 2>/dev/n
458
479
  > Pipeline powered by [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock)"
459
480
  ```
460
481
 
461
- Compose the comment body. Include the `### Skipped Sub-Issues` section only when `SKIPPED_NOTES` is non-empty (each owner-override-obsolete sub-issue and each not-created sub-issue, with its reason), an `### Owner Override` line when Phase D0 detected one (the direction the owner steered), and a milestone-skipped note when `MILESTONE_SKIPPED=1`.
482
+ Compose the comment body. Include the `### Reused Batch Members` section only when `REUSED_NUMBERS` is non-empty. Include the `### Skipped Sub-Issues` section only when `SKIPPED_NOTES` is non-empty (each owner-override-obsolete sub-issue and each not-created sub-issue, with its reason), an `### Owner Override` line when Phase D0 detected one (the direction the owner steered), and a milestone-skipped note when `MILESTONE_SKIPPED=1`.
462
483
 
463
484
  ```bash
464
485
  [ "${DRY_RUN:-false}" = "true" ] && { echo "[DRY_RUN] would post the comment below"; exit 0; }
@@ -472,6 +493,11 @@ This issue has been broken into sub-issues. Each sub-issue runs through its own
472
493
  {for each sub-issue, in dependency order:}
473
494
  - #{SUB_ISSUE_NUMBER}: {TITLE}{if has dependency: _(depends on #{DEP_NUMBER})_}
474
495
 
496
+ {if REUSED_NUMBERS non-empty:}
497
+ ### Reused Batch Members
498
+
499
+ Reopened instead of filing new issues: ${REUSED_NUMBERS}
500
+
475
501
  {if SKIPPED_NOTES non-empty:}
476
502
  ### Skipped Sub-Issues
477
503
 
@@ -514,7 +540,7 @@ esac
514
540
  <!-- Added: forge#3736 -->
515
541
  A decomposed parent is only a tracker, but while it is open it inflates GitHub's open-issue count. When `decomposition.close_parent` is `true`, close the parent now that the sub-issues exist, the `FORGE:DECOMPOSED:COMPLETE` comment is posted (D5) and `workflow:decomposed` is set (D6). The label is kept. Close C3 keeps checking children off the closed parent and posts the completion comment when the last one closes.
516
542
 
517
- Run this phase on the normal path and on both ALREADY_DONE exits (D1 resume, D2.5). Set `SUB_NUMBERS` to the sub-issue numbers: `CREATED_NUMBERS` on the normal path, `SUB_LIST` (D1) or `CANONICAL_NUMBERS` (D2.5) on the resume paths, as a space or comma separated list. Config read is fail-safe: an unreadable `forge.yaml`, missing `yq` or any value other than `true` leaves the parent open.
543
+ Run this phase on the normal path and on both ALREADY_DONE exits (D1 resume, D2.5). Set `SUB_NUMBERS` to the sub-issue numbers: `CREATED_NUMBERS` on the normal path, `SUB_LIST` (D1) or `CANONICAL_NUMBERS` (D2.5) on the resume paths, as a space or comma separated list; an empty list (after dropping non-digits and the parent's own number) logs `SUB_NUMBERS empty` and skips the child guard and the close. Paste the Script resolution block first (the close gate calls `forge_resolve`). Config read is fail-safe: an unreadable `forge.yaml`, missing `yq` or any value other than `true` leaves the parent open.
518
544
 
519
545
  ```bash
520
546
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
@@ -522,6 +548,14 @@ CLOSE_PARENT=$(yq '.decomposition.close_parent // false' forge.yaml 2>/dev/null
522
548
  [ "$CLOSE_PARENT" = "true" ] || CLOSE_PARENT=false
523
549
  if [ "$CLOSE_PARENT" = "true" ]; then
524
550
  SUB_NUMBERS=$(printf '%s' "${SUB_NUMBERS:-}" | tr -c '0-9\n' ' ')
551
+ # Digits only, never the parent itself: an empty or unparsable list must not close the parent.
552
+ SUB_NUMBERS=$(for n in $SUB_NUMBERS; do [ "$n" = "{NUMBER}" ] || printf '%s ' "$n"; done)
553
+ fi
554
+ if [ "$CLOSE_PARENT" = "true" ] && [ -z "${SUB_NUMBERS// /}" ]; then
555
+ echo "D6.5: SUB_NUMBERS empty — parent left open"
556
+ CLOSE_PARENT=false
557
+ fi
558
+ if [ "$CLOSE_PARENT" = "true" ]; then
525
559
 
526
560
  # Child guard: every child body must carry the parent reference Close C3 matches on.
527
561
  for SUB_NUMBER in $SUB_NUMBERS; do
@@ -534,8 +568,12 @@ if [ "$CLOSE_PARENT" = "true" ]; then
534
568
 
535
569
  # Close only an OPEN parent, and only once the DECOMPOSED:COMPLETE marker is on it.
536
570
  PARENT_STATE=$(gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state' 2>/dev/null || true)
537
- HAS_COMPLETE=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
538
- --jq '[.[] | select(.body | contains("FORGE:DECOMPOSED:COMPLETE"))] | length' 2>/dev/null || echo 0)
571
+ # Trusted + anchored (marker on its own line); unresolved helper or failed read => 0 => parent stays open.
572
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
573
+ HAS_COMPLETE=0
574
+ [ -z "$TRUSTED_SCRIPT" ] || HAS_COMPLETE=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments 2>/dev/null \
575
+ | bash "$TRUSTED_SCRIPT" count '(^|\n)<!-- FORGE:DECOMPOSED:COMPLETE -->[ \t]*($|\n)' 2>/dev/null) || HAS_COMPLETE=0
576
+ case "$HAS_COMPLETE" in ''|*[!0-9]*) HAS_COMPLETE=0 ;; esac
539
577
  if [ "$PARENT_STATE" = "OPEN" ] && [ "${HAS_COMPLETE:-0}" -gt 0 ]; then
540
578
  REFS=$(for n in $SUB_NUMBERS; do printf '#%s ' "$n"; done)
541
579
  run gh issue close {NUMBER} {GH_FLAG} --reason completed \
@@ -581,16 +581,15 @@ bash {REPO_PATH}/scripts/code-index.sh query --domain {DOMAIN_LABEL} --repo-path
581
581
  - **Search the whole repo** (not just the affected directory), capturing the exit status so a failure is visible instead of masked by a pipe: `OUT=$(timeout 30 git grep -n -F -- "$QUERY"); RC=$?` (use `timeout` only if `command -v timeout` succeeds; stock macOS lacks it). `RC=0` means hits, `RC=1` means no match (0 hits is a valid result), any other `RC` (including 124, timeout) means the query failed. Quote the variable, always pass `-F` and `--`. Then cap: `printf '%s\n' "$OUT" | head -31`.
582
582
  - **Hit cap**: record at most 30 hits per query. If a query returns more, record the first 30 and mark the query `truncated at 30` — never silently drop hits.
583
583
  - **Disposition per hit** (MANDATORY, no unlabeled hits): `fix` (same defect class, must be changed in this PR) or `not-affected` with a one-line reason (e.g. already guarded, different semantics). The cited instance is always a `fix` row. Record each hit as `file:line` only (never the matched line text), one table row per hit.
584
- - **Scope cap**: if more than 25 `fix` rows result, do not grow one PR to fit them. Record the cited instance plus the rows in the same subsystem as `fix`, mark the rest `fix-deferred`, and recommend decomposition in the report (set `decompose: YES` if the remaining scope is too large for one PR).
584
+ - **Scope cap**: if more than 25 `fix` rows result, do not grow one PR to fit them. Record the cited instance plus the rows in the same subsystem as `fix`, mark the rest `fix-deferred`, and recommend decomposition in the report (set `decompose: YES` only if the remaining rows are separable pieces per step 7.7, never just because the list is long).
585
585
  - **Degrade, never block**: if any query fails (`RC` other than 0 or 1), write `Pattern sweep skipped: {reason}` in the `### Pattern Sweep` section, fall back to step 7.5, and continue. A failed query must never be recorded as 0 hits. The sweep is advisory evidence, not a gate.
586
586
  - Every `fix` row MUST appear in `### Affected Files`, and the acceptance spec MUST include a class-wide coverage check plus a check that the added test exercises more than the reviewer's single repro (see Phase 1C).
587
- 7.7. **Size estimate** *(always, after the affected-file list is settled)*: estimate the changed lines the build will produce BEFORE anything is built, so an oversized issue is decomposed up front instead of discarded at the post-build size gate (build B5.5). <!-- Added: forge#3573 -->
588
- - Per affected file: a new file counts about its full size; an edit counts about its hunks (added plus deleted lines). Add roughly 1:1 test lines for changed behavior. Skip lockfiles, generated files and fixtures (the gate excludes them).
589
- - `INDEPENDENT_SEAMS`: the number of independently buildable pieces. For a batch issue (several review findings or checklist members) this is the member count; otherwise count distinct directory/phase seams. Minimum 1.
590
- - Resolve config (defaults when absent): `THRESHOLD=$(yq '.build.diff_size.threshold // 1000' forge.yaml 2>/dev/null || echo 1000)`, `FACTOR=$(yq '.build.diff_size.estimate_factor // 1.2' forge.yaml 2>/dev/null || echo 1.2)`, `SEAM_MIN=$(yq '.build.diff_size.batch_seam_min // 3' forge.yaml 2>/dev/null || echo 3)`.
591
- - Rule: when `build.diff_size.threshold` is `0` the rule is skipped (emit `GATE_THRESHOLD: 0`). Otherwise the Decomposition Assessment MUST be `**YES**` when `ESTIMATED_DIFF_LINES` > threshold x `FACTOR` (`estimate_factor`, default 1.2), OR when `INDEPENDENT_SEAMS` >= `batch_seam_min` and `ESTIMATED_DIFF_LINES` > threshold / 2 (not well under the gate). The seam clause does NOT apply to an issue produced by review-finding batching (it has the `batch` label or a `FORGE:BATCH_MEMBERS` block): a batch was formed to be built as one unit, so it is `**YES**` only when `ESTIMATED_DIFF_LINES` > threshold x `FACTOR`, otherwise `**NO**`. <!-- Added: forge#3786 --> When YES, propose `max(2, ceil(ESTIMATED_DIFF_LINES / (0.8 x threshold)))` sub-issues, packing related seams together so each sub-issue's estimate (code plus tests) is at or under 0.8 x threshold; each sub-issue lists the seams it covers (migration-ordering seams may stay separate). Do not propose one sub-issue per seam. Every proposed sub-issue estimate MUST be at or under 0.8 x threshold (never at the gate size, e.g. a "~1000 lines" sub-issue against a 1000-line gate); a seam that can't fit under 0.8 x threshold (one cohesive module plus its tests with no natural split) MUST be called out explicitly in the assessment, with its estimate, so the later build gate can treat the overage as expected. <!-- Added: forge#3790 --> A YES that neither condition requires (estimate at or under threshold x `FACTOR` and not the seam clause) needs an own-line `DECOMPOSE_JUSTIFICATION: <reason>` line; the engine treats a YES without one as NO. A pure under-gate issue may stay `**NO**`.
592
- - **Exemption** <!-- Added: forge#3676 -->: emit `**NO**` (state the guard in the reason; still emit the numeric fields) instead of `**YES**` when the issue is already a sub-issue (its body has a `**Parent**: #N` line), a `FORGE:DECOMPOSED` comment exists, or a justified `FORGE:SIZE_OVERRIDE` exists. Decompose Guard 1 refuses these issues, so a YES would stall the run; the over-threshold YES rule applies only otherwise. The engine applies the same guard when it reads the verdict.
593
- - Emit the numbers in the comment as the own-line fields in Phase 1C. A pure estimate is a heuristic; state it in the reason, do not pad it.
587
+ 7.7. **Scope estimate and separability** *(always, after the affected-file list is settled)*: record the expected size as the scope baseline the build's post-build scope check (build B5.5) compares against, and decide whether the work is **separable**. Decomposition is a planning decision made here, never a post-build one: size alone never triggers it. <!-- Added: forge#3862 -->
588
+ - `ESTIMATED_DIFF_LINES`: per affected file, a new file counts about its full size and an edit about its hunks (added plus deleted lines); add roughly 1:1 test lines for changed behavior. Skip lockfiles, generated and vendored files. This number is a baseline for scope-creep detection, not a gate; state in the reason that it is a heuristic and do not pad it.
589
+ - `INDEPENDENT_SEAMS`: the number of pieces that are **separable**. A piece is separable only when ALL hold: it lives in an independent module or service, it shares no file with another piece, there is no ordering dependency between pieces (none needs another's output, schema or API first), and each piece is independently testable and shippable. A cohesive change stays ONE unit at any size: one new module plus its tests, a mechanical change across many files, or pieces that edit the same file are NOT separable. For a batch issue (several review findings or checklist members) count the members that meet the criteria. Minimum 1.
590
+ - Rule: the Decomposition Assessment is `**YES**` only when `INDEPENDENT_SEAMS` >= 2 (the pieces meet every criterion above), and the reason MUST name the pieces, the files each owns and why they share none and have no ordering. A single-seam issue is `**NO**` however large the estimate; never emit `**YES**` because of an estimate, a line count or a threshold. A YES needs an own-line `DECOMPOSE_JUSTIFICATION: <reason>` line stating the separability evidence; the engine treats a YES without one as NO. When YES, propose one sub-issue per separable piece with titles and dependencies (none, by definition), each listing its own files.
591
+ - **Exemption** <!-- Added: forge#3676 -->: emit `**NO**` (state the guard in the reason; still emit the numeric fields) instead of `**YES**` when the issue is already a sub-issue (its body has a `**Parent**: #N` line), a `FORGE:DECOMPOSED` comment exists, or a justified `FORGE:SIZE_OVERRIDE` exists. Decompose Guard 1 refuses these issues, so a YES would stall the run. The engine applies the same guard when it reads the verdict. An issue produced by review-finding batching (it has the `batch` label or a `FORGE:BATCH_MEMBERS` block) was formed to be built as one unit, so it is `**NO**` too. <!-- Added: forge#3786 -->
592
+ - Emit the numbers in the comment as the own-line fields in Phase 1C.
594
593
  8. **Fix-approach validation** — if the issue proposes a fix, don't adopt it as spec. Trace through the target system's middleware, auth, routing, config. Cross-domain: if fix in domain A interacts with domain B, read domain B's files too.
595
594
 
596
595
  ---
@@ -695,9 +694,7 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:INVESTIGATOR -->
695
694
  {if YES: proposed sub-issues with titles and dependencies}
696
695
  ESTIMATED_DIFF_LINES: {N}
697
696
  INDEPENDENT_SEAMS: {N}
698
- GATE_THRESHOLD: {N — build.diff_size.threshold; 0 when the gate is disabled}
699
- ESTIMATE_FACTOR: {FACTOR — resolved build.diff_size.estimate_factor, positive number, decimal allowed; 1.2 by default}
700
- {optional, only for a YES the numbers do not require: DECOMPOSE_JUSTIFICATION: {one-line reason}}
697
+ {only for a YES: DECOMPOSE_JUSTIFICATION: {one-line separability evidence}}
701
698
  <!-- DECOMPOSE:{YES|NO} -->
702
699
 
703
700
  ### Acceptance Spec <!-- Added: forge#1829 -->
@@ -743,7 +740,7 @@ ${INVESTIGATION_SENTINEL}"
743
740
 
744
741
  **Decomposition marker (MANDATORY, both output paths — template and codec)**: emit exactly one `<!-- DECOMPOSE:YES -->` or `<!-- DECOMPOSE:NO -->` line per investigator comment, matching the `**YES**`/`**NO**` verdict, on its own line (nothing else on that line) after the verdict line and any sub-issue list (the `**YES**`/`**NO**` line must stay directly under the heading — the resume parser reads it). The headless engine routes to `work-on/decompose` on this marker (or on the `**YES**` heading in older comments). Never quote or mention the opposite marker anywhere in the comment. Never emit it when Verdict=INVALID. <!-- Added: forge#3543 -->
745
742
 
746
- **Size-estimate fields (MANDATORY when Verdict is not INVALID)** <!-- Added: forge#3573 -->: `ESTIMATED_DIFF_LINES: N`, `INDEPENDENT_SEAMS: N` and `GATE_THRESHOLD: N` are each alone on their own line (whole numbers, no prose or backticks on the line), as is `ESTIMATE_FACTOR: F` (the resolved `FACTOR`, a positive number, decimal allowed), INSIDE `### Decomposition Assessment` after the verdict line and sub-issue list and before the `<!-- DECOMPOSE:.. -->` marker. The engine reads them with full-line anchors as a safety net: an estimate over `GATE_THRESHOLD` x `ESTIMATE_FACTOR` (1.2 when the field is absent or invalid) routes to decompose even when the verdict says NO, so the verdict and the numbers must agree. Conversely, the engine downgrades a `**YES**` whose numbers require neither the over-threshold rule nor the seam clause (seams >= 3 and estimate > threshold / 2) to NO unless the comment carries an own-line `DECOMPOSE_JUSTIFICATION: <reason>` line (same placement as the fields above); the line is optional otherwise. <!-- Added: forge#3786 --> Never quote these field lines elsewhere in the comment.
743
+ **Scope-estimate fields (MANDATORY when Verdict is not INVALID)** <!-- Added: forge#3573 -->: `ESTIMATED_DIFF_LINES: N` and `INDEPENDENT_SEAMS: N` are each alone on their own line (whole numbers, no prose or backticks on the line) INSIDE `### Decomposition Assessment` after the verdict line and sub-issue list and before the `<!-- DECOMPOSE:.. -->` marker. The estimate is the scope baseline for build B5.5; the engine never routes to decompose on it. The engine downgrades a `**YES**` to NO unless `INDEPENDENT_SEAMS` >= 2 and the comment carries an own-line `DECOMPOSE_JUSTIFICATION: <reason>` line (same placement as the fields above). <!-- Added: forge#3786, forge#3862 --> Never quote these field lines elsewhere in the comment.
747
744
 
748
745
  **Decomposition scopes the Acceptance Spec**: when the assessment is YES, the parent becomes a terminal tracker (decompose creates a sub-issue for every item, including the first; the parent builds nothing), so the Acceptance Spec MUST NOT claim item 1 as in scope on the parent: emit only tracker-level checks, or tag each check with the sub-item it belongs to (append `# item-N` to the description). Never emit checks for work the assessment assigns to a sub-issue: build's acceptance gate would otherwise fail on them and its repair loop would pull that work into this branch. <!-- Added: forge#3543 -->
749
746
 
@@ -124,6 +124,19 @@ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
124
124
  fi
125
125
  ```
126
126
 
127
+ **CI-only and INPR second attempts** <!-- Added: forge#3932 -->: the per-kind scoping also covers `ci-gate` and `inpr-fix`, so a CI-only failure after a remediation of another kind gets its own single attempt, not `ALREADY_DONE`. Trails carry no kind: each completed trail is attributed to the NEWEST trusted bound marker at or before it. When `CURRENT_KIND` is `CI_REMEDIATION` or `INPR_REMEDIATION` and no completed trail is attributed to it, `REMEDIATION_COMPLETE_N` is set to 0: one attempt per kind per PR, keeping the retry bound. An unreadable attribution keeps the kind-agnostic guard:
128
+
129
+ ```bash
130
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ] && [ -n "$ISSUE_COMMENTS" ] && { [ "$CURRENT_KIND" = "CI_REMEDIATION" ] || [ "$CURRENT_KIND" = "INPR_REMEDIATION" ]; }; then
131
+ KIND_DONE_N=$( { printf '%s\n' "$ISSUE_COMMENTS"; printf '%s\n' "$PR_COMMENTS"; } | jq -rs --arg pr "{PR_NUMBER}" --arg kind "$CURRENT_KIND" '
132
+ [.[][] | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | select((.body // "") | type == "string")] as $c
133
+ | [$c[] | select(.body | test("\\A<!-- FORGE:(CI|INPR|BASESYNC)_REMEDIATION: pr=" + $pr + " -->")) | {t: .created_at, k: (.body | capture("FORGE:(?<k>CI|INPR|BASESYNC)_REMEDIATION").k + "_REMEDIATION")}] as $m
134
+ | [$c[] | select(.body | test("\\A<!-- FORGE:REMEDIATION -->[\\s\\S]*<!-- FORGE:REMEDIATION:COMPLETE -->")) | .created_at] as $trails
135
+ | [$trails[] as $t | ([$m[] | select(.t <= $t)] | sort_by(.t) | last | .k // "") | select(. == $kind)] | length' 2>/dev/null || echo "")
136
+ if [ -n "$KIND_DONE_N" ] && [ "$KIND_DONE_N" -eq 0 ]; then REMEDIATION_COMPLETE_N=0; fi
137
+ fi
138
+ ```
139
+
127
140
  Completed trails of other kinds are left in place (never deleted); only the interrupted-partial cleanup below deletes anything.
128
141
 
129
142
  - If `REMEDIATION_COMPLETE_N` or `REMEDIATION_TRAIL_N` is empty (comments or `scripts/trusted-comments.sh` unreadable or unresolvable) → fail closed: EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "github-unavailable: remediation trail unreadable (comments or scripts/trusted-comments.sh unresolvable)". Never treat unreadable as "no trail" and never as `ALREADY_DONE`.
@@ -342,15 +355,15 @@ If still failing after 3 iterations: post a comment, re-affirm `needs-human`, EX
342
355
  **Pre-push ancestry guard** (same guard as `work-on/review.md` Phase R1; reuse it, do not write a parallel check): a base-sync merge of `origin/{PR_BASE}` passes it, a merge that brings in history from outside the base fails it.
343
356
 
344
357
  ```bash
345
- # <Script resolution block from work-on/review.md, verbatim>
346
358
  cd {WORKTREE_PATH}
347
359
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
348
360
  git fetch origin {PR_BASE} >/dev/null 2>&1 || true
349
- RESOLUTION=$(resolve_script 'check-branch-ancestry'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
350
- if [ "$TIER" = "prose" ]; then
361
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
362
+ ANCESTRY_SCRIPT=$(forge_resolve check-branch-ancestry.sh)
363
+ if [ -z "$ANCESTRY_SCRIPT" ]; then
351
364
  MERGE_COMMITS="check-branch-ancestry script not resolvable (fail closed)"; ANCESTRY_RC=2
352
365
  else
353
- MERGE_COMMITS=$(bash "$SCRIPT_PATH" {HEAD_BRANCH} origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
366
+ MERGE_COMMITS=$(bash "$ANCESTRY_SCRIPT" {HEAD_BRANCH} origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
354
367
  fi
355
368
  if [ "$ANCESTRY_RC" -ne 0 ]; then
356
369
  run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "## Pre-Push Ancestry Guard Failed
@@ -487,7 +500,7 @@ else:
487
500
 
488
501
  Wait for the completed child result and retain its `REVIEW_RESULT` in remediation state before continuing to Phase M7. A running/progress response is not a completion result. If the child errors or does not return a parseable `REVIEW_RESULT`, stop with `REMEDIATE_RESULT: status: BLOCKED`; do not report remediation in progress as a terminal parent result. The `Skill(...)` invocation above remains the non-OpenCode path.
489
502
 
490
- If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate refused to merge on missing phase markers, not on a code finding. Re-run each missing phase named on the `MISSING:` lines via its `Skill(...)`, then re-invoke this phase once. If it is `PHASE_TRAIL_FAILED` again, keep `needs-human`, post `<!-- FORGE:PHASE_TRAIL_FAILED -->` listing the still-missing markers, and exit `REMEDIATE_RESULT: status: BLOCKED`. Never treat it as `HELD-AWAITING-MERGE` or `AUTO-LANDED`. A still-failing trail can also be cleared by a human `<!-- FORGE:PHASE_TRAIL_OVERRIDE -->` comment (forge#3152, see `scripts/verify-phase-trail.sh -h`); the verifier decides, this phase never posts one.
503
+ If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate refused to merge on missing phase markers, not on a code finding. This phase does NOT re-dispatch the missing phases: it is a dispatching phase running in a fork (`docs/WORK-ON-RUNTIME.md` R1), and `work-on:review` Phase R4 is the single re-dispatch owner. Keep `needs-human`, post `<!-- FORGE:PHASE_TRAIL_FAILED -->` listing the missing markers from the `MISSING:` lines, and exit `REMEDIATE_RESULT: status: BLOCKED` with blocker "phase trail incomplete" so the router can hand the `MISSING:` lines back to the review owner. Never treat it as `HELD-AWAITING-MERGE` or `AUTO-LANDED`. A still-failing trail can also be cleared by a human `<!-- FORGE:PHASE_TRAIL_OVERRIDE -->` comment (forge#3152, see `scripts/verify-phase-trail.sh -h`); the verifier decides, this phase never posts one.
491
504
 
492
505
  If the retained `REVIEW_RESULT` is `BLOCKED` with a blocker mentioning the phase trail (e.g. "phase trail unreadable (rc=N)": the Phase 8 verifier exited ≥2 / 127, forge#3147): the gate could not run, so nothing is missing and nothing is re-run. Keep `needs-human` (review-pr Phase 8 already re-asserted it), do not merge, and exit `REMEDIATE_RESULT: status: BLOCKED` with the same blocker.
493
506
 
@@ -540,11 +553,12 @@ PR_LIVE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state,mergedAt,headRefOid 2>/d
540
553
  if [ "$(printf '%s' "$PR_LIVE" | jq -r '.state // ""' 2>/dev/null)" = "MERGED" ] || [ -n "$(printf '%s' "$PR_LIVE" | jq -r '.mergedAt // empty' 2>/dev/null)" ]; then
541
554
  RE_GATE_OUTCOME="AUTO-LANDED"
542
555
  # Idempotent post-merge bookkeeping (same case as the merge branch below; harmless when already applied).
543
- RESOLUTION=$(resolve_script 'transition-label')
544
- TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
556
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
557
+ TRANSITION_SCRIPT=$(forge_resolve transition-label.sh)
558
+ if [ -n "$TRANSITION_SCRIPT" ]; then TIER=universal; else TIER=prose; fi
545
559
  case "$TIER" in
546
- adaptive|universal) bash "$SCRIPT_PATH" {ISSUE_NUMBER} {GH_FLAG} merged ;;
547
- prose)
560
+ universal) bash "$TRANSITION_SCRIPT" {ISSUE_NUMBER} {GH_FLAG} merged ;;
561
+ prose|*)
548
562
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:merged" --remove-label "workflow:awaiting-merge,needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
549
563
  ;;
550
564
  esac
@@ -698,11 +712,11 @@ gh pr merge {PR_NUMBER} {GH_FLAG} --merge --match-head-commit "$GATED_HEAD" # al
698
712
  fi
699
713
  MERGE_STATE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state --jq '.state')
700
714
  if [ "$MERGE_STATE" = "MERGED" ]; then
701
- RESOLUTION=$(resolve_script 'transition-label')
702
- TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
715
+ TRANSITION_SCRIPT=$(forge_resolve transition-label.sh)
716
+ if [ -n "$TRANSITION_SCRIPT" ]; then TIER=universal; else TIER=prose; fi
703
717
  case "$TIER" in
704
- adaptive|universal) bash "$SCRIPT_PATH" {ISSUE_NUMBER} {GH_FLAG} merged ;;
705
- prose)
718
+ universal) bash "$TRANSITION_SCRIPT" {ISSUE_NUMBER} {GH_FLAG} merged ;;
719
+ prose|*)
706
720
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:merged" --remove-label "workflow:awaiting-merge,needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
707
721
  ;;
708
722
  esac
@@ -512,7 +512,7 @@ else:
512
512
 
513
513
  Wait for that task's completed result before Phase R4. Propagate its `REVIEW_RESULT` as this module's child state; do not return `REVIEW_RESULT`, report progress, release an orchestrator slot, or begin close work while the child is running. If the child errors or returns no parseable `REVIEW_RESULT`, return `REVIEW_RESULT: status: BLOCKED` with the child failure as the blocker. The normal `Skill(...)` invocation above remains the non-OpenCode path.
514
514
 
515
- /review-pr handles: the domain-agent review (it picks FULL, DELTA or BASE_SYNC_ONLY itself: a head that only merged the base is carried forward without reviewers) → post findings as separate issues (non-blocking) → merge the PR. It does NOT close the issue or clean up the worktree — `work-on:close` does both.
515
+ /review-pr handles, in this order: **prepare** (`scripts/review-prepare.sh`: PR data, mergeability, scans, roster, FULL, DELTA or BASE_SYNC_ONLY decision, templates and slices into one context file; exit 3 is `BLOCKED`, never skipped) → **LLM** (domain-agent dispatch, arbitration, verdict rationale; a head that only merged the base is carried forward without reviewers) → **finalize** (`scripts/review-finalize.sh`: extract, classify, file findings as separate non-blocking issues, verdict, merge gates, integrity). The same sequence runs on the Agent-spawn and engine paths because it lives in the spec, so no engine hook is needed. It does NOT close the issue or clean up the worktree — `work-on:close` does both.
516
516
 
517
517
  If `review-pr` is not found under `{FORGE_SKILL_PREFIX}review-pr` or `review-pr`, print `REVIEW_RESULT: status: BLOCKED`, blocker: "skill not found: review-pr" and STOP. Never review inline.
518
518
 
@@ -543,7 +543,7 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
543
543
  \`\`\`"
544
544
  ```
545
545
 
546
- - `REVIEW_RESULT: status: PHASE_TRAIL_FAILED` from /review-pr (forge#3102; internal to this skill — never printed as this skill's status) → the review gate refused to merge because phase markers are missing. Do NOT hand-post markers and do NOT add `needs-human` yet. For each `MISSING: <marker> -> <action>` line, run that phase via its `Skill(...)`, then re-invoke Phase R3 once (this is the single re-dispatch owner; the trail preflight at the top of R3 re-runs). If the second attempt returns `PHASE_TRAIL_FAILED` again, post a `<!-- FORGE:PHASE_TRAIL_FAILED -->` comment listing the still-missing markers, add `needs-human`, and return `REVIEW_RESULT: status: BLOCKED`, blocker: "phase trail incomplete after re-dispatch". The PR stays open and unmerged throughout.
546
+ - `REVIEW_RESULT: status: PHASE_TRAIL_FAILED` from /review-pr (forge#3102; internal to this skill — never printed as this skill's status) → the review gate refused to merge because phase markers are missing. Do NOT hand-post markers and do NOT add `needs-human` yet. For each `MISSING: <marker> -> <action>` line, run that phase via its `Skill(...)`, then re-invoke Phase R3 once (this is the single re-dispatch owner: `/review-pr` and `work-on:remediate` only report the status and never re-run missing phases; the trail preflight at the top of R3 re-runs). If the second attempt returns `PHASE_TRAIL_FAILED` again, post a `<!-- FORGE:PHASE_TRAIL_FAILED -->` comment listing the still-missing markers, add `needs-human`, and return `REVIEW_RESULT: status: BLOCKED`, blocker: "phase trail incomplete after re-dispatch". The PR stays open and unmerged throughout.
547
547
 
548
548
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker mentions the phase trail (any blocker containing "phase trail": "phase trail unreadable" when the Phase 8 verifier exited ≥2 / 127, "phase trail incomplete…", or "auto-merge requires --issue", forge#3147): the merge gate refused or could not run. Do NOT re-run phases (nothing is missing) and do NOT attempt the manual merge below, because that would bypass the gate. Add `needs-human` and return `REVIEW_RESULT: status: BLOCKED` with the same blocker. The PR stays open and unmerged.
549
549
 
@@ -560,7 +560,9 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
560
560
  gh pr edit {PR_NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
561
561
  ```
562
562
 
563
- - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed, were cancelled, or stayed pending past the gate retries): do NOT attempt the manual merge below — that would bypass the CI gate. Fixing red CI is pipeline work, not a human decision: hand remediation to the router **once**. Bound: count `<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->` comments on the issue first; if ≥ 1, do not remediate again — remove `workflow:remediating`, label the issue `needs-human` (the bound is exhausted, so a human is now genuinely needed) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise run the remediation-pending handoff block above (sets `workflow:remediating`, removes the `needs-human` that Phase 8 set) and return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: ci-gate` (see Output). Do not post the marker here: the router posts it immediately before it invokes remediation (Phase 4R), so a handoff interrupted before remediation starts is retried instead of being counted as used. **Never invoke `work-on:remediate` from this phase** (forge#3398): remediation re-reviews through `/review-pr`, which spawns domain-reviewer sub-agents, so it must run one level below the router, not nested inside this fork. Nested here, it lands at the sub-agent depth limit under `/orchestrate` with no `Agent` tool and strands the PR at `REREVIEW-REQUIRED` (forge#3391). The router runs `work-on:remediate`, which classifies a CI-gate refusal as FIXABLE, swaps `workflow:remediating` (or a legacy `needs-human`) for `workflow:in-review`, fixes the failing jobs on the PR branch, re-runs the quality gate and re-reviews the new head (scoped by /review-pr: full, delta or carried forward), and auto-lands through the same CI gate. <!-- forge#3191 -->
563
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "checks still pending" (Phase 8 merge-gate timed out while CI was still running; nothing failed, #3919): do NOT remediate and do NOT add `needs-human`. Return that BLOCKED result unchanged so the caller retries the review phase, whose merge gate re-runs once CI finishes.
564
+
565
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed or were cancelled): do NOT attempt the manual merge below — that would bypass the CI gate. Fixing red CI is pipeline work, not a human decision: hand remediation to the router **once**. Bound: count `<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->` comments on the issue first; if ≥ 1, do not remediate again — remove `workflow:remediating`, label the issue `needs-human` (the bound is exhausted, so a human is now genuinely needed) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise run the remediation-pending handoff block above (sets `workflow:remediating`, removes the `needs-human` that Phase 8 set) and return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: ci-gate` (see Output). Do not post the marker here: the router posts it immediately before it invokes remediation (Phase 4R), so a handoff interrupted before remediation starts is retried instead of being counted as used. **Never invoke `work-on:remediate` from this phase** (forge#3398): remediation re-reviews through `/review-pr`, which spawns domain-reviewer sub-agents, so it must run one level below the router, not nested inside this fork. Nested here, it lands at the sub-agent depth limit under `/orchestrate` with no `Agent` tool and strands the PR at `REREVIEW-REQUIRED` (forge#3391). The router runs `work-on:remediate`, which classifies a CI-gate refusal as FIXABLE, swaps `workflow:remediating` (or a legacy `needs-human`) for `workflow:in-review`, fixes the failing jobs on the PR branch, re-runs the quality gate and re-reviews the new head (scoped by /review-pr: full, delta or carried forward), and auto-lands through the same CI gate. <!-- forge#3191 -->
564
566
 
565
567
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "in-pr fix required" (review-pr §6B.6: CONFIRMED MEDIUM/HIGH/CRITICAL findings in files this PR changed, requested on the PR in a `FORGE:INPR_FIX` comment at the current head): do NOT merge and do NOT leave the issue at `needs-human` because of this gate. Run **one** fix round, bounded by `<!-- FORGE:INPR_REMEDIATION: pr={PR_NUMBER} -->` on the issue (`{BOUND}` = `INPR_REMEDIATION` below).
566
568
  - **Bound unused**: run the remediation-pending handoff block above (set `workflow:remediating`, no `needs-human`; remediation accepts that state and its Phase M1 clears it as FIXABLE). Then return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: inpr-fix` (the router posts the bound marker, as in the ci-gate case) — never invoke `work-on:remediate` from this phase (same reason as the ci-gate case). The router runs remediation, which reads the `FORGE:INPR_FIX` work order, fixes exactly those findings, re-reviews the new head and auto-lands. On the re-review, any finding still present is filed as an issue (the round is used). If remediation does not land, the router re-invokes this phase, which then finds the bound used and takes the branch below.
@@ -72,7 +72,7 @@ A sub-agent that receives no resolved value applies the same rule itself.
72
72
  |------|-------|----------------|-----------------|
73
73
  | 0 | Resolve & resume | — (router) | Always first |
74
74
  | 1 | Investigate | `work-on:investigate` | No `FORGE:INVESTIGATOR` comment containing `INVESTIGATION:COMPLETE` or `INVESTIGATION:INVALID` |
75
- | 2 | Decompose | `work-on:decompose` | `INVESTIGATE_RESULT.decompose: YES` (or resume: investigation says decompose YES and no `FORGE:DECOMPOSED`) or `BUILD_RESULT: NEEDS_DECOMPOSE` (`FORGE:DIFF_SIZE` over, no override) |
75
+ | 2 | Decompose | `work-on:decompose` | `INVESTIGATE_RESULT.decompose: YES` (or resume: investigation says decompose YES and no `FORGE:DECOMPOSED`). A finished build is never routed here: decomposition is a planning decision (forge#3862) |
76
76
  | 3 | Build | `work-on:build` | Investigation complete, decompose NO, no `FORGE:BUILDER:COMPLETE` |
77
77
  | 4 | Review (push, PR, review, merge) | `work-on:review` | `FORGE:BUILDER:COMPLETE` present and PR not merged |
78
78
  | 4R | Remediation handoff | `work-on:remediate` | `REVIEW_RESULT: status: NEXT, next: remediate` (CI gate red or in-PR fix requested) |
@@ -665,7 +665,6 @@ The build phase owns classification (`FORGE:FAST_PATH`), the worktree, the `work
665
665
  | `COMPLETE` | Marker gate (below) → record `BRANCH` and `WORKTREE_PATH` from the result → Phase 4 |
666
666
  | `ALREADY_DONE` | Record `BRANCH`/`WORKTREE_PATH` (from the result, or `git worktree list` for the issue branch) → Phase 4 |
667
667
  | `INVESTIGATION_COMPLETE` | Investigation-type task: deliverable issues were filed and the original closed by the phase → Phase 5 with `--terminal-state investigation` (no review) |
668
- | `NEEDS_DECOMPOSE` | The build's diff-size gate found the staged diff over `build.diff_size.threshold` with no `FORGE:SIZE_OVERRIDE` (split proposal in `FORGE:DIFF_SIZE`; not a failure, no `needs-human`) → run Phase 2 (`work-on:decompose`) and apply its `DECOMPOSE_RESULT` table; build never calls decompose itself |
669
668
  | `BLOCKED` | Terminal: confirm `needs-human` is present (the phase adds it with the blocker) and STOP |
670
669
 
671
670
  **Marker gate — Phase 3 exit**: a `FORGE:BUILDER` comment must contain `FORGE:BUILDER:COMPLETE`. If absent, invoke `work-on:build` once more with the same args; if still absent, post `<!-- FORGE:GATE_FAILURE -->` ("builder completion marker missing after one re-invoke"), add `needs-human`, remove `workflow:building`, STOP. <!-- forge#1418 -->