forgedock 1.16.2 → 1.17.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 (95) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +86 -26
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +13 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +12 -1
  10. package/bin/engine.mjs +145 -40
  11. package/bin/forgedock.mjs +7 -1
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +187 -0
  14. package/bin/runner.mjs +7 -0
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +140 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +224 -0
  23. package/commands/cleanup.md +83 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/orchestrate/phase-1-resolve.md +4 -1
  26. package/commands/orchestrate/phase-4-execution.md +17 -2
  27. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  28. package/commands/pipeline-health/phase-1-context.md +2 -0
  29. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  30. package/commands/pipeline-status.md +21 -1
  31. package/commands/review-pr-agents/protocols.md +1 -0
  32. package/commands/review-pr-agents.md +10 -2
  33. package/commands/review-pr-staging.md +102 -25
  34. package/commands/review-pr.md +119 -103
  35. package/commands/work-on/build/implement.md +26 -5
  36. package/commands/work-on/build/validate.md +3 -2
  37. package/commands/work-on/build.md +95 -64
  38. package/commands/work-on/close.md +17 -6
  39. package/commands/work-on/decompose.md +45 -4
  40. package/commands/work-on/investigate.md +6 -2
  41. package/commands/work-on/remediate.md +22 -6
  42. package/commands/work-on/review.md +2 -2
  43. package/docs/CONFIG.md +33 -0
  44. package/docs/OUTCOME-CODES.md +48 -0
  45. package/docs/RELEASE-NOTES.md +12 -0
  46. package/docs/STATS.md +62 -0
  47. package/docs/TELEMETRY.md +65 -0
  48. package/docs/WORK-ON-RUNTIME.md +13 -0
  49. package/docs/site/command-reference.md +1 -1
  50. package/docs/spec/label-state-machine.md +1 -1
  51. package/package.json +3 -2
  52. package/scripts/builder-resume-match.test.sh +58 -0
  53. package/scripts/cap-findings.sh +110 -0
  54. package/scripts/cap-findings.test.sh +130 -0
  55. package/scripts/check-spec-markers.sh +2 -0
  56. package/scripts/classify-finding.sh +65 -16
  57. package/scripts/classify-finding.test.sh +43 -1
  58. package/scripts/classify-grep-exit.sh +20 -0
  59. package/scripts/classify-grep-exit.test.sh +46 -0
  60. package/scripts/close-parent-c3.test.sh +54 -0
  61. package/scripts/cost-report.sh +146 -0
  62. package/scripts/cost-report.test.sh +80 -0
  63. package/scripts/diff-size.sh +5 -0
  64. package/scripts/doctor-pipeline-state.sh +43 -0
  65. package/scripts/file-deferred-followups.sh +159 -0
  66. package/scripts/file-deferred-followups.test.sh +106 -0
  67. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  68. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  69. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  70. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  71. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  72. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  73. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  74. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  75. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  76. package/scripts/forge-root.test.sh +8 -3
  77. package/scripts/lib/fence-bootstrap.sh +4 -3
  78. package/scripts/lib/pr-issue-number.sh +18 -0
  79. package/scripts/lib/review-template-resolve.sh +41 -0
  80. package/scripts/lib/telemetry.sh +168 -0
  81. package/scripts/remediate-liveness.sh +42 -6
  82. package/scripts/review-acceptance-gate.sh +16 -7
  83. package/scripts/review-acceptance-gate.test.sh +13 -0
  84. package/scripts/review-pr-domain-map.test.sh +8 -3
  85. package/scripts/review-pr-gate2.test.sh +15 -0
  86. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  87. package/scripts/review-pr-staging-scope.test.sh +65 -5
  88. package/scripts/review-prepare.sh +517 -0
  89. package/scripts/review-prepare.test.sh +270 -0
  90. package/scripts/review-roster.sh +172 -0
  91. package/scripts/review-roster.test.sh +163 -0
  92. package/scripts/run-shell-tests.sh +3 -0
  93. package/scripts/telemetry.test.sh +106 -0
  94. package/scripts/wait-ci-green.sh +8 -1
  95. package/templates/forge.yaml.minimal +9 -0
@@ -141,21 +141,42 @@ Re-read current state before doing anything:
141
141
  ```bash
142
142
  gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state,milestone
143
143
 
144
- # Check investigation report
144
+ # Check investigation report — intentionally NOT cycle-scoped: investigate may commit CONFIRMED in a later cycle without re-posting INVESTIGATOR, so scoping it would block every new-cycle build
145
145
  TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh) # script resolution block verbatim; unresolved/failed read = fail closed
146
146
  gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INVESTIGATOR -->'
147
147
 
148
- # Check if build already completed (require FORGE:BUILDER:COMPLETE — not just FORGE:BUILDER)
149
- gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate \
150
- --jq '.[] | select(.body | contains("FORGE:BUILDER")) | .body'
148
+ # Current-cycle slice (forge#3666): every resume read below runs on the comments AFTER the newest TRUSTED
149
+ # `<!-- FORGE:CYCLE: n -->` marker (same predicate as `cycleNumber`/`sliceToCurrentCycle` in bin/engine/phases.mjs:
150
+ # trusted author incl. the FORGE_TRAIL_TRUSTED_ASSOCIATIONS / FORGE_TRAIL_TRUSTED_LOGINS overrides, same `${VAR-default}`
151
+ # handling as scripts/trusted-comments.sh; body starts with the header, marker on its own line); all comments when no marker exists.
152
+ # A cycle-1 BUILDER:COMPLETE must never yield ALREADY_DONE in cycle 2. Re-declare this function verbatim in every
153
+ # fence that uses it (shell functions do not survive across fences).
154
+ cycle_slice() {
155
+ jq -s --arg assoc "${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}" --arg logins "${FORGE_TRAIL_TRUSTED_LOGINS-}" '[.[][]] as $c
156
+ | ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
157
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
158
+ | ([$c | to_entries[]
159
+ | select(((.value.author_association // "") as $x | $A | index($x) != null) or ((.value.user.type // "") == "Bot") or ((.value.user.login // "") as $x | $L | index($x) != null))
160
+ | select((.value.body // "") | test("\\A\\s*<!--\\s*FORGE:CYCLE:") and test("(?m)^[ \\t]*<!--[ \\t]*FORGE:CYCLE:[ \\t]*[0-9]+[ \\t]*-->[ \\t]*$"))
161
+ | .key] | last) as $i
162
+ | if $i == null then $c else $c[($i + 1):] end'
163
+ }
164
+ COMMENTS_JSON=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate) \
165
+ && CYCLE_SLICE=$(printf '%s' "$COMMENTS_JSON" | cycle_slice) && [ -n "$CYCLE_SLICE" ] \
166
+ || echo "CYCLE_LOOKUP=FAILED" # a failed read is NEVER "no marker"
167
+
168
+ # Check if build already completed (require FORGE:BUILDER:COMPLETE — not just FORGE:BUILDER); current-cycle slice only
169
+ printf '%s' "$CYCLE_SLICE" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:BUILDER'
151
170
 
152
- # Existing classification from a prior run (resume path)
153
- EXISTING_FAST_PATH=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate \
154
- --jq '.[] | select(.body | contains("FORGE:FAST_PATH")) | .body' 2>/dev/null | head -1)
171
+ # Existing classification from a prior run of THIS cycle (resume path); none in a fresh cycle, so B0.5 re-classifies and re-posts
172
+ EXISTING_FAST_PATH=$(printf '%s' "$CYCLE_SLICE" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:FAST_PATH' 2>/dev/null \
173
+ | head -n 1 | jq -r 'select(type == "string")')
155
174
  ```
156
175
 
176
+ `CYCLE_LOOKUP=FAILED` (or `trusted-comments.sh` unresolved) → Blocked exit with blocker `cycle-lookup-unavailable: could not read FORGE:CYCLE` (a transient GitHub failure uses the `github-unavailable:` prefix); never read a failed lookup as "no marker". Below, "present" and "exists" for BUILDER, BUILDER:COMPLETE, FAST_PATH, DIFF_SIZE, ACCEPTANCE_GATE and BUILD_BLOCKED comments always mean present in the **current-cycle slice** (comments after the newest trusted `FORGE:CYCLE` marker); a cycle-1 `BUILDER:COMPLETE` is invisible in cycle 2, so a new cycle with no slice COMPLETE falls through to B1 and builds fresh.
177
+
157
178
  **Resume check**:
158
- - If `<!-- FORGE:BUILDER:COMPLETE -->` is present in a BUILDER comment → the builder finished, but COMPLETE is posted at validate V5, BEFORE the B6.5 acceptance gate, so it is not proof the build passed. **Gate resume** (checked first; first match wins) <!-- Added: forge#3725 -->: read the newest trusted, current-cycle (after the newest `FORGE:CYCLE`) comment that starts with `FORGE:ACCEPTANCE_GATE` or `FORGE:BUILD_BLOCKED`, through the same anchored, trust-filtered lookup as the size-gate resume below (script resolution block verbatim; use `bodies '^<!-- FORGE:(ACCEPTANCE_GATE|BUILD_BLOCKED)'`, and compare by comment order, never by presence):
179
+ - If `<!-- FORGE:BUILDER:COMPLETE -->` is present in a BUILDER comment of the current-cycle slice → the builder finished, but COMPLETE is posted at validate V5, BEFORE the B6.5 acceptance gate, so it is not proof the build passed. **Gate resume** (checked first; first match wins) <!-- Added: forge#3725 -->: read the newest trusted, comment of the current-cycle slice that starts with `FORGE:ACCEPTANCE_GATE` or `FORGE:BUILD_BLOCKED`, through the same anchored, trust-filtered lookup as the size-gate resume below (script resolution block verbatim; pipe `cycle_slice` into `bodies '^<!-- FORGE:(ACCEPTANCE_GATE|BUILD_BLOCKED)'`, and compare by comment order, never by presence):
159
180
  - Lookup failed (`trusted-comments.sh` unresolved or `gh` error) → Blocked exit with blocker `gate-lookup-unavailable: could not read FORGE:ACCEPTANCE_GATE` (a transient GitHub failure uses the `github-unavailable:` prefix). Never read a failed lookup as "no gate marker".
160
181
  - Newest is `FORGE:ACCEPTANCE_GATE:FAILED`, `FORGE:ACCEPTANCE_GATE:BLOCKED`, or a `FORGE:BUILD_BLOCKED` comment → the gate is blocked and the engine fails closed on it, so returning ALREADY_DONE would deadlock every re-run. Derive BRANCH/WORKTREE_PATH/PR_BASE as in B1A/B1C (without creating anything), verify the branch and worktree exist and that `git -C "$WORKTREE_PATH" branch --show-current` equals BRANCH (missing or mismatched → Blocked exit `worktree-missing: cannot re-enter B6.5`; never recreate silently), recover `COMPLEXITY_BAND` from `EXISTING_FAST_PATH` with the B0.5 resume extraction, then skip B0.5-B6 and re-enter at **Phase B6.5**, which posts a fresh PASSED/FAILED/BLOCKED verdict (the one-repair `FORGE:ACCEPTANCE_REPAIR` bound still applies).
161
182
  - Newest is `FORGE:ACCEPTANCE_GATE:PASSED`, or no gate marker exists → build already complete. Derive BRANCH/WORKTREE_PATH as in B1A/B1C (without creating anything), then print `BUILD_RESULT: status: ALREADY_DONE`.
@@ -165,7 +186,8 @@ EXISTING_FAST_PATH=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate
165
186
  set -o pipefail
166
187
  TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
167
188
  [ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ] || { echo "DIFF_SIZE_LOOKUP=FAILED (trusted-comments.sh unresolved)"; exit 0; }
168
- DS=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE') \
189
+ # <cycle_slice function from B0, verbatim>
190
+ DS=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" | cycle_slice | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE') \
169
191
  || { echo "DIFF_SIZE_LOOKUP=FAILED"; exit 0; }
170
192
  LAST_RESULT=$(printf '%s\n' "$DS" | jq -r 'select(type == "string")' | sed -n 's/^result: *\([A-Z]*\).*/\1/p' | tail -n 1)
171
193
  echo "DIFF_SIZE_RESULT=${LAST_RESULT:-<none>}"
@@ -173,13 +195,17 @@ EXISTING_FAST_PATH=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate
173
195
  - `DIFF_SIZE_LOOKUP=FAILED` → Blocked exit with blocker `size-gate-unavailable: could not read FORGE:DIFF_SIZE` (a transient GitHub failure uses the `github-unavailable:` prefix). Never read a failed lookup as "no DIFF_SIZE".
174
196
  - `DIFF_SIZE_RESULT=OVER` or `DIFF_SIZE_RESULT=COHESIVE` → verify, without creating anything, that the branch and worktree exist (derive BRANCH/WORKTREE_PATH as in B1A/B1C), that `git -C "$WORKTREE_PATH" branch --show-current` equals BRANCH, and that a staged diff is present (`! git -C "$WORKTREE_PATH" diff --cached --quiet`). Recover `COMPLEXITY_BAND` from `EXISTING_FAST_PATH` with the B0.5 resume extraction. When all hold, skip B0.5-B5 and continue at B5.5 (it re-measures, then applies the override / `COHESIVE` / `NEEDS_DECOMPOSE` / `OK` logic; never re-run implement).
175
197
  - Anything else (no `FORGE:DIFF_SIZE`, `result` is not `OVER`, worktree or branch missing or mismatched, nothing staged, band unrecoverable) → never skip ahead on missing state; fall through to the next rule.
176
- - If `<!-- FORGE:BUILDER -->` exists BUT `<!-- FORGE:BUILDER:COMPLETE -->` is ABSENT → build was interrupted after the comment was posted but before the commit (validate V5). Delete the partial comment and restart from Phase B2 (contract): <!-- Added: forge#1305 -->
198
+ - If `<!-- FORGE:BUILDER -->` exists in the current-cycle slice BUT `<!-- FORGE:BUILDER:COMPLETE -->` is ABSENT → build was interrupted after the comment was posted but before the commit (validate V5). Delete the partial comment and restart from Phase B2 (contract): <!-- Added: forge#1305 -->
199
+ Select with ONE anchored, trusted selector (body must start with the marker; author must be a Bot or OWNER/MEMBER/COLLABORATOR). Delete ONLY when exactly one non-complete candidate exists and it carries no other FORGE phase marker; unparsable, zero, or multiple matches delete nothing (fail closed). <!-- Added: forge#3668 -->
177
200
  ```bash
178
- PARTIAL_ID=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments --paginate \
179
- --jq '[.[] | select(.body | contains("FORGE:BUILDER") and (contains("FORGE:BUILDER:COMPLETE") | not))] | last | .id // ""')
201
+ # <cycle_slice function from B0, verbatim> — only a current-cycle partial is ever deleted; prior-cycle comments are never touched
202
+ BLD=$(gh api --paginate repos/{GH_REPO}/issues/{NUMBER}/comments | cycle_slice | jq -s '[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]') || BLD=""
203
+ PARTIAL_ID=$(printf '%s' "$BLD" | jq -r '[.[] | select(.complete | not)] | if length == 1 and (.[0].other | not) then .[0].id else empty end' 2>/dev/null)
180
204
  if [ -n "$PARTIAL_ID" ]; then
181
205
  gh api repos/{GH_REPO}/issues/comments/$PARTIAL_ID -X DELETE
182
206
  echo "Deleted partial FORGE:BUILDER comment (no FORGE:BUILDER:COMPLETE) — restarting build from Phase B2"
207
+ else
208
+ echo "builder resume: no single safe partial BUILDER comment — deleting nothing"
183
209
  fi
184
210
  ```
185
211
  - If there is no `<!-- FORGE:INVESTIGATOR -->` comment with `<!-- INVESTIGATION:COMPLETE -->` → Blocked exit with blocker "Investigation not complete — run investigate first".
@@ -267,7 +293,7 @@ Whatever path was taken, set `COMPLEXITY_BAND` (uppercase) for the rest of this
267
293
 
268
294
  ### B1A: Derive branch name
269
295
 
270
- If `--branch` was passed, or a local/remote branch already exists whose name ends in `-{NUMBER}` (`git branch -a --list "*-{NUMBER}"`), reuse it (resume). Otherwise derive from the issue title: lowercase, hyphenated, max 40 chars (truncate if needed).
296
+ If `--branch` was passed, or a local/remote branch already exists whose name ends in `-{NUMBER}` (`git branch -a --list "*-{NUMBER}"`), reuse it (resume). **New-cycle guard** <!-- Added: forge#3666 -->: when a trusted `FORGE:CYCLE` marker exists and the current-cycle slice has no `FORGE:BUILDER`, never re-adopt a prior-cycle branch whose PR is already merged or closed (`gh pr list {GH_FLAG} --head "<branch>" --state all --json state --jq '.[].state'` shows `MERGED`/`CLOSED`, or the branch is already merged into `origin/{PR_BASE}`); derive a fresh name instead (append `-c<cycle>` before `-{NUMBER}`, e.g. `fix/foo-c2-{NUMBER}`). Otherwise derive from the issue title: lowercase, hyphenated, max 40 chars (truncate if needed).
271
297
  - Bug / fix issues → prefix `fix/`
272
298
  - Feature issues → prefix `feat/`
273
299
  - Refactor / maintenance → prefix `fix/` or `refactor/`
@@ -347,7 +373,7 @@ esac
347
373
 
348
374
  **Skip if COMPLEXITY_BAND: INVESTIGATION** — go to B5. For every other band (TRIVIAL included) the contract is mandatory. If a `FORGE:CONTRACT` comment already exists on the issue (resume), do not post a second one — continue to B2.1.
349
375
 
350
- Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why. Run B2.0 first: it files the follow-up issues for `deferred` out-of-scope items and validates the section, so `{OUT_OF_SCOPE_ITEMS}` below already holds real issue numbers.
376
+ Post `<!-- FORGE:CONTRACT -->` comment documenting what will be built and why. Run B2.0 first: it folds small siblings and files one batched follow-up for the `deferred` out-of-scope items and validates the section, so `{OUT_OF_SCOPE_ITEMS}` below already holds real issue numbers.
351
377
 
352
378
  ### B2.0: Out-of-scope dispositions — file deferred follow-ups, validate the section <!-- Added: forge#3447 -->
353
379
 
@@ -365,51 +391,26 @@ Every item in the contract's `### Out of Scope` section must carry exactly one d
365
391
  - **Known-flaw rule**: a contract that keeps a known-flawed design (a design the investigation or architect plan identified as flawed, a fail-open caller, an unsound threshold) must state the flaw and list it as a `deferred → #N` item with a linked follow-up. It may not hide behind `accepted-risk` or free text.
366
392
  - Free-text exclusions without a disposition are invalid. `->` is accepted for `→`.
367
393
 
394
+ **Fold, then batch** <!-- Added: forge#3843 -->: before filing anything, fold small siblings into scope. A candidate deferred item that is the same mechanical change at a sibling path and fits the remaining size budget (estimate within `0.8 × build.diff_size.threshold`, `1000` when the threshold is `0` or unset, minus the current estimate) goes into the contract's Deliverables, not Out of Scope; count it as `folded`. Defer only genuinely separate work (different module, design or owner). All remaining deferred items of this build go into ONE follow-up issue with a checklist; `scripts/file-deferred-followups.sh` appends to an open trusted follow-up for the same parent (exact path match) or creates one.
395
+
368
396
  **File the `deferred` follow-ups** (skip the filing on resume when the contract already exists):
369
397
 
370
398
  ```bash
371
399
  # <Script resolution block from work-on/build.md, verbatim>
372
- run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
373
- # Trusted-author filter (OWNER/MEMBER/COLLABORATOR or Bot, same predicate as trusted-comments.sh) is applied at
374
- # the source in the LOOKUP --jq, before the path match: an untrusted match is never reused. The REST search API is used because it exposes
375
- # author_association and user.type (`gh issue list --json` has no authorAssociation field).
376
- # For each deferred item (DEFERRED_PATH = the backticked path, DEFERRED_WHY = one line), repeat:
377
- DEFERRED_MARKER="<!-- FORGE:DEFERRED_FROM: #{NUMBER} -->"
378
- LOOKUP=$(gh api -X GET search/issues -f per_page=50 \
379
- -f q="\"FORGE:DEFERRED_FROM: #{NUMBER}\" in:body repo:{GH_REPO} is:issue is:open" --jq '[.items[] | select(((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))]' 2>/dev/null) || LOOKUP="FAILED"
380
- if [ "$LOOKUP" = "FAILED" ]; then
381
- echo "DEFERRED_LOOKUP=FAILED" # a failed lookup is not "none found": Blocked exit, never re-file blind
382
- else
383
- DEFERRED_NUM=$(printf '%s' "$LOOKUP" | jq -r --arg p "$DEFERRED_PATH" \
384
- '[.[] | select((.body // "") | contains($p))] | first | .number // empty' 2>/dev/null)
385
- if [ -z "$DEFERRED_NUM" ]; then
386
- DEFERRED_URL=$(run gh issue create {GH_FLAG} --title "follow-up: ${DEFERRED_PATH} (deferred from #{NUMBER})" \
387
- --body "## Problem
388
-
389
- ${DEFERRED_WHY}
390
-
391
- Deferred from #{NUMBER}: \`${DEFERRED_PATH}\` was declared out of scope in its contract and is tracked here instead of surfacing later as a review finding.
392
-
393
- ## Affected Files
394
-
395
- - \`${DEFERRED_PATH}\`
396
-
397
- ## Acceptance Criteria
398
-
399
- - [ ] The concern above is resolved for \`${DEFERRED_PATH}\`
400
-
401
- ${DEFERRED_MARKER}")
402
- DEFERRED_NUM=$(printf '%s' "$DEFERRED_URL" | grep -oE '[0-9]+$')
403
- [ -n "${DRY_RUN:-}" ] && DEFERRED_NUM="0"
404
- fi
405
- echo "DEFERRED_ISSUE=${DEFERRED_NUM:-}"
406
- fi
400
+ # DEFERRED_TSV: one line per remaining deferred item: path<TAB>one-line why<TAB>known-flaw (or empty)
401
+ SCRIPT_REF=$(resolve_script 'file-deferred-followups')
402
+ case "$SCRIPT_REF" in
403
+ prose:*) echo "DEFERRED_SCRIPT=PROSE" ;; # file ONE issue by hand: marker + checklist of every path (see below)
404
+ *) printf '%s\n' "$DEFERRED_TSV" | bash "${SCRIPT_REF#*:}" --parent {NUMBER} --repo {GH_REPO} --folded "${FOLDED_COUNT:-0}"; echo "DEFERRED_RC=$?" ;;
405
+ esac
407
406
  ```
408
407
 
409
- - Only follow-up issues authored by a trusted author (OWNER/MEMBER/COLLABORATOR or Bot) are reused; an untrusted match carrying the marker is ignored and a fresh follow-up is filed.
410
- - `DEFERRED_LOOKUP=FAILED`, or an empty `DEFERRED_NUM` after a real (non-`DRY_RUN`) create → Blocked exit with blocker `github-unavailable: could not file or look up deferred follow-up for <path>` (a transient failure follows the retry rule above; no `needs-human` for `github-unavailable:`).
411
- - On resume the lookup reuses the open issue that carries the marker and the same path, so a retried build never files a duplicate.
412
- - Prefer filing through the `/issue` create-hook's programmatic contract (`Skill(skill="{FORGE_SKILL_PREFIX}issue", ...)`) when it is available; the raw create above is the fallback and must keep the `FORGE:DEFERRED_FROM` marker in the body.
408
+ - Output is one `path<TAB>issue` row per item (rows share the batched issue number: use it as the `#N` in each `deferred → #N` bullet) plus a `DEFERRED_TELEMETRY deferred_items=N folded=N batched_into=#M filed=N` line (forwarded to the #3834 emitter when present).
409
+ - A known-flawed design is recorded as a `known-flaw` checklist item in that batched issue (pass the flag in the TSV); it is never dropped or hidden behind `accepted-risk`.
410
+ - Only follow-ups by a trusted author (OWNER/MEMBER/COLLABORATOR or Bot; `FORGE_TRAIL_TRUSTED_*` honoured) are reused; an untrusted match is ignored.
411
+ - `DEFERRED_LOOKUP=FAILED` or `DEFERRED_FILE=FAILED` (non-zero `DEFERRED_RC`) → Blocked exit with blocker `github-unavailable: could not file or look up deferred follow-ups` (a transient failure follows the retry rule above; no `needs-human` for `github-unavailable:`).
412
+ - Prose tier (script absent): create one issue by hand whose body has the exact `<!-- FORGE:DEFERRED_FROM: #{NUMBER} -->` marker and a `- [ ] \`path\` — why` checklist line per path, after a trusted-author marker lookup; a failed lookup is a Blocked exit.
413
+ - On resume the lookup reuses the open issue carrying the marker and appends only missing paths, so a retried build never files a duplicate.
413
414
 
414
415
  **Validate the composed section** with `scripts/check-contract-scope.sh validate` (the exact text that will replace `{OUT_OF_SCOPE_ITEMS}`):
415
416
 
@@ -655,6 +656,7 @@ if [ -n "$JUSTIFICATION" ]; then echo "OVERRIDE=VALID"; else echo "OVERRIDE=NONE
655
656
  **Step 3 — record.** Set `DIFF_LINES`, `EXCLUDED_LINES`, `THRESHOLD`, `RESULT` (`OK`, `OVERRIDDEN`, `COHESIVE` or `OVER`), `SPLIT_PROPOSAL` and `COHESION_NOTE` (set only for `COHESIVE`) from the steps above, then post one `<!-- FORGE:DIFF_SIZE -->` comment, idempotently: find the latest trusted comment starting with the marker and edit it, otherwise create it (on resume never stack duplicates; readers use the latest trusted one):
656
657
 
657
658
  ```bash
659
+ # <Script resolution block from above goes here, verbatim>
658
660
  BODY="<!-- FORGE:DIFF_SIZE -->
659
661
  ## Diff Size
660
662
 
@@ -663,6 +665,7 @@ excluded_lines: ${EXCLUDED_LINES}
663
665
  threshold: ${THRESHOLD}
664
666
  result: ${RESULT}
665
667
  ${ESTIMATE_LINE:-}${COHESION_NOTE:-}${SPLIT_PROPOSAL:-}"
668
+ _t=$(forge_resolve lib/telemetry.sh); FORGE_REPO="{GH_REPO}" FORGE_ISSUE="{NUMBER}" FORGE_PHASE=build bash "${_t:-/dev/null}" diff_size lines="$DIFF_LINES" excluded="$EXCLUDED_LINES" threshold="$THRESHOLD" result="$RESULT" >/dev/null 2>&1 || true # forge#3834 telemetry (final result incl. OVERRIDDEN)
666
669
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
667
670
  CID=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" --jq '[.[] | select(.body | startswith("<!-- FORGE:DIFF_SIZE")) | select((.author_association | IN("OWNER","MEMBER","COLLABORATOR")) or (.user.type == "Bot"))] | last | .id // ""' 2>/dev/null || true)
668
671
  if [ -n "$CID" ]; then run gh api "repos/{GH_REPO}/issues/comments/${CID}" -X PATCH -f body="$BODY" >/dev/null 2>&1 || run gh issue comment {NUMBER} {GH_FLAG} --body "$BODY"
@@ -791,6 +794,8 @@ The acceptance spec contains only a skip sentinel (\`type=skipped\`). No automat
791
794
  **Otherwise — execute each check**: <!-- Added: forge#1829 -->
792
795
 
793
796
  ```bash
797
+ # <Script resolution block, verbatim>
798
+ GREP_CLS=$(forge_resolve classify-grep-exit.sh) # empty/unresolved = every grep exit>=2 is a real FAIL (fail closed)
794
799
  GATE_PASS=true
795
800
  CORRECTED=""
796
801
  FAILED_CHECKS=""
@@ -798,6 +803,8 @@ VERIFIED_COUNT=0 # checks actually executed and judged (PASS or FAIL); mal
798
803
  MALFORMED_CHECKS="" # malformed-check: generated check that can never pass; reported, never repaired, never needs-human
799
804
  DEFERRED_COUNT=0 # phase=review checks (and legacy "no pull requests found" results): evaluated by review R2.5, never counted here
800
805
  DEFERRED_CHECKS="" # <!-- Added: forge#3793 -->
806
+ MANUAL_CHECKS="" # per-check type=skipped (manual / post-deploy): never executed, listed as unchecked follow-ups <!-- Added: forge#3825 -->
807
+ SPEC_DEFECT=false # nothing verifiable: a spec defect that code repair cannot fix <!-- Added: forge#3825 -->
801
808
 
802
809
  while IFS= read -r check_line; do
803
810
  # Fields are extracted by anchoring each sed pattern to `^ACCEPTANCE_CHECK:` and walking
@@ -837,7 +844,7 @@ while IFS= read -r check_line; do
837
844
  PHASE=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("(([^"\\]|\\.)*)"|[^ ]*) matcher=("(([^"\\]|\\.)*)"|[^ ]*) phase=(build|review)( .*)?$/\7/p')
838
845
  [ "$PHASE" = "review" ] || PHASE=build
839
846
 
840
- [ "$TYPE" = "skipped" ] && continue
847
+ [ "$TYPE" = "skipped" ] && { MANUAL_CHECKS="${MANUAL_CHECKS}\n- [ ] **$ID** ($DESC)"; continue; }
841
848
 
842
849
  # Deferred (review) <!-- Added: forge#3793 -->: never executed, never VERIFIED, never GATE_PASS=false, never in FAILED_CHECKS / --fix-acceptance.
843
850
  if [ "$PHASE" = "review" ]; then DEFERRED_COUNT=$((DEFERRED_COUNT+1)); DEFERRED_CHECKS="${DEFERRED_CHECKS}\n- **$ID** ($DESC): DEFERRED (review)"; continue; fi
@@ -846,6 +853,7 @@ while IFS= read -r check_line; do
846
853
  # check defect, not an implementation failure. Static detection only parses (bash -n), never executes.
847
854
  MALFORMED=""
848
855
  { [ "$TYPE" = "command" ] || [ "$TYPE" = "behavior" ]; } && { case "$TARGET" in *'\\\\'*) MALFORMED="run of 4+ backslashes (over-escaped target)";; esac; [ -n "$MALFORMED" ] || bash -n -c "$TARGET" 2>/dev/null || MALFORMED="shell syntax error (unbalanced quotes)"; }
856
+ { [ "$TYPE" = "command" ] || [ "$TYPE" = "behavior" ]; } && [ -z "$MALFORMED" ] && case "$(printf '%s' "$TARGET" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" in none) MALFORMED='target "none" is not a command (a manual criterion must be type=skipped)';; esac
849
857
  if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
850
858
 
851
859
  RESULT="PASS"
@@ -868,12 +876,14 @@ while IFS= read -r check_line; do
868
876
  if [ "$MATCHER" = "exit_0" ]; then
869
877
  ERR=$(eval "$TARGET" 2>&1 >/dev/null); RC=$?
870
878
  # grep exit >= 2 is a usage/syntax error (e.g. a pattern starting with `-`), never a verdict on the code; exit 1 stays FAIL.
871
- # A missing/unreadable operand file also exits 2 but is a REAL failure (the check asserts a file exists), so it is never malformed.
872
- [ "$RC" -ge 2 ] && case "$TARGET" in grep\ *|egrep\ *|fgrep\ *) printf '%s' "$ERR" | grep -qE 'No such file or directory|Permission denied|Is a directory' || MALFORMED="grep usage error (exit $RC)";; esac
879
+ # Malformed only when stderr positively names a usage error (classify-grep-exit.sh); empty stderr (`2>/dev/null`, `-s`) or any operand error is a REAL failure.
880
+ [ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
881
+ [ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -qF 'command not found' && MALFORMED="command not found (rc 127): the target is not an executable command"
873
882
  [ "$RC" -eq 0 ] || [ -n "$MALFORMED" ] || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
874
883
  else
875
- OUTPUT=$(eval "$TARGET" 2>&1); RC=$?; ERR="$OUTPUT"
876
- [ "$RC" -ge 2 ] && case "$TARGET" in grep\ *|egrep\ *|fgrep\ *) printf '%s' "$ERR" | grep -qE 'No such file or directory|Permission denied|Is a directory' || MALFORMED="grep usage error (exit $RC)";; esac
884
+ ERRF=$(mktemp); OUTPUT=$(eval "$TARGET" 2>"$ERRF"); RC=$?; ERR=$(cat "$ERRF"); rm -f "$ERRF"; [ -z "$ERR" ] || OUTPUT="${OUTPUT}${OUTPUT:+$'\n'}${ERR}" # classify on stderr only, match on both
885
+ [ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
886
+ [ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -qF 'command not found' && MALFORMED="command not found (rc 127): the target is not an executable command"
877
887
  if [ -n "$MALFORMED" ]; then :
878
888
  elif ! printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER"; then
879
889
  if printf '%s\n' "$OUTPUT" | grep -qF -- "$MATCHER"; then CORRECTED="${CORRECTED}\n- **$ID**: output matched as a literal string"
@@ -888,7 +898,7 @@ while IFS= read -r check_line; do
888
898
 
889
899
  if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
890
900
 
891
- # Legacy fallback (untagged spec): a gh pr check that finds no PR is waiting on the review phase, not failing.
901
+ # Legacy fallback (any phase tag): a gh pr check that finds no PR is waiting on the review phase, not failing.
892
902
  if [ "$RESULT" = "FAIL" ] && printf '%s' "$ERR" | grep -qF 'no pull requests found'; then
893
903
  case "$TARGET" in *'gh pr'*) DEFERRED_COUNT=$((DEFERRED_COUNT+1)); DEFERRED_CHECKS="${DEFERRED_CHECKS}\n- **$ID** ($DESC): DEFERRED (review) (no pull request exists yet)"; continue;; esac
894
904
  fi
@@ -903,21 +913,38 @@ done <<< "$ACCEPTANCE_CHECKS"
903
913
  # A gate that verified nothing must not pass: with every check skipped as malformed, PASSED would be a green light on zero evidence.
904
914
  # A spec whose checks are all deferred to review (DEFERRED_COUNT > 0) is not vacuous: review R2.5 evaluates them before merge.
905
915
  if [ "$VERIFIED_COUNT" -eq 0 ] && [ "$DEFERRED_COUNT" -eq 0 ]; then
916
+ # spec-defect <!-- Added: forge#3825 -->: only the spec is wrong, so this is a BLOCKED verdict that never enters the --fix-acceptance repair (#3774).
906
917
  GATE_PASS=false
907
- FAILED_CHECKS="${FAILED_CHECKS}\n- **(gate)**: no acceptance check could be verified (all were malformed or none ran); regenerate the acceptance spec"
918
+ SPEC_DEFECT=true
908
919
  fi
909
920
  ```
910
921
 
911
922
  **Post gate result comment**:
912
923
 
913
924
  ```bash
925
+ # <Script resolution block, verbatim>
914
926
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
915
- if [ "$GATE_PASS" = "true" ]; then
927
+ _nf=$(printf '%b' "$FAILED_CHECKS" | grep -c '^- ' || true); _nm=$(printf '%b' "$MALFORMED_CHECKS" | grep -c '^- ' || true); _t=$(forge_resolve lib/telemetry.sh)
928
+ [ -n "${DRY_RUN:-}" ] || FORGE_REPO="{GH_REPO}" FORGE_ISSUE="{NUMBER}" FORGE_PHASE=build bash "${_t:-/dev/null}" acceptance_gate phase=build verified="$VERIFIED_COUNT" failed="${_nf:-0}" malformed="${_nm:-0}" deferred="$DEFERRED_COUNT" >/dev/null 2>&1 || true # forge#3834 telemetry
929
+ if [ "$SPEC_DEFECT" = "true" ]; then
930
+ run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ACCEPTANCE_GATE -->
931
+ ## Acceptance Gate — BLOCKED (spec-defect)
932
+
933
+ No acceptance check could be verified: every check was malformed, manual, or empty. The implementation is not at fault and a code repair cannot fix this, so the repair step is skipped.
934
+ $( [ -n "$MALFORMED_CHECKS" ] && printf '\n**malformed-check**:%b\n' "$MALFORMED_CHECKS" )
935
+ $( [ -n "$MANUAL_CHECKS" ] && printf '\n**Manual follow-up (type=skipped; not verified at build time)**:%b\n' "$MANUAL_CHECKS" )
936
+
937
+ **Gate result: BLOCKED** — regenerate the acceptance spec by re-running \`/work-on:investigate {NUMBER}\`, then retry the build.
938
+
939
+ <!-- FORGE:ACCEPTANCE_GATE:BLOCKED -->"
940
+ run gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human"
941
+ elif [ "$GATE_PASS" = "true" ]; then
916
942
  run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ACCEPTANCE_GATE -->
917
943
  ## Acceptance Gate — PASSED
918
944
 
919
945
  All machine-checkable build-phase acceptance criteria verified against real behavior.
920
946
  $( [ -n "$DEFERRED_CHECKS" ] && printf '\n**Deferred to review (phase=review; evaluated against the PR before merge)**:%b\n' "$DEFERRED_CHECKS" )
947
+ $( [ -n "$MANUAL_CHECKS" ] && printf '\n**Manual follow-up (type=skipped; not verified at build time, confirm after merge/deploy)**:%b\n' "$MANUAL_CHECKS" )
921
948
  $( [ -n "$CORRECTED" ] && printf '\n**Checks corrected (malformed matcher, literal text present)**:%b\n' "$CORRECTED" )
922
949
  $( [ -n "$MALFORMED_CHECKS" ] && printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS" )
923
950
 
@@ -938,11 +965,15 @@ Merge is blocked. Fix the failing criteria and re-run the validate phase.
938
965
  fi
939
966
  ```
940
967
 
941
- A `malformed-check` (a command/behavior target with a run of 4+ backslashes or a `bash -n` syntax error, or a grep target exiting >= 2 for a reason other than a missing/unreadable operand file, which stays a real FAIL) is reported in the comment and never sets `GATE_PASS=false` by itself, never enters `FAILED_CHECKS`, is never passed to `--fix-acceptance`, and never adds `needs-human` by itself; the gate still posts exactly one of PASSED/FAILED. The gate never posts PASSED with zero verified checks: if every check was malformed (`VERIFIED_COUNT=0` and `DEFERRED_COUNT=0`) it posts FAILED so the repair step regenerates the spec. <!-- Added: forge#3694 -->
968
+ A `malformed-check` (a command/behavior target with a run of 4+ backslashes or a `bash -n` syntax error, or a grep target exiting >= 2 for a reason other than a missing/unreadable operand file, which stays a real FAIL) is reported in the comment and never sets `GATE_PASS=false` by itself, never enters `FAILED_CHECKS`, is never passed to `--fix-acceptance`, and never adds `needs-human` by itself; the gate still posts exactly one of PASSED/FAILED. The gate never posts PASSED with zero verified checks: if every check was malformed (`VERIFIED_COUNT=0` and `DEFERRED_COUNT=0`) it is a spec-defect: it posts `FORGE:ACCEPTANCE_GATE:BLOCKED`, adds `needs-human`, prints `BUILD_RESULT: status: BLOCKED` (blocker: "regenerate acceptance spec via investigate") and skips the `--fix-acceptance` repair entirely, because implement cannot fix a spec defect. <!-- Added: forge#3694, forge#3825 -->
969
+
970
+ Also `malformed-check` <!-- Added: forge#3825 -->: a `command`/`behavior` target that is literally `none` (any case), or that exits 127 with `command not found` on stderr. Classification keys on that stderr text, never on the code alone: a missing script (a `bash` run of a nonexistent script path, with `No such file or directory`) also exits 127 and stays a real FAIL, so the missing-operand class is not reopened. A per-check `type=skipped` is never executed, never counted, and is listed under "Manual follow-up" as unchecked items in the PASSED comment.
971
+
972
+ A `phase=review` check (or, for legacy specs, tagged or not, a `command`/`behavior` check on a `gh pr` target whose output contains `no pull requests found`) is recorded as `DEFERRED (review)` in the PASSED comment: it is never executed here, never counted in `VERIFIED_COUNT`, never sets `GATE_PASS=false`, never enters `FAILED_CHECKS` or `--fix-acceptance`, and never spends the repair. A spec whose checks are all deferred posts PASSED (it is not "No Spec Found"). Review R2.5 evaluates the deferred checks against the real PR and blocks merge on failure. <!-- Added: forge#3793 -->
942
973
 
943
- A `phase=review` check (or, for an untagged legacy spec, a `command`/`behavior` check on a `gh pr` target whose output contains `no pull requests found`) is recorded as `DEFERRED (review)` in the PASSED comment: it is never executed here, never counted in `VERIFIED_COUNT`, never sets `GATE_PASS=false`, never enters `FAILED_CHECKS` or `--fix-acceptance`, and never spends the repair. A spec whose checks are all deferred posts PASSED (it is not "No Spec Found"). Review R2.5 evaluates the deferred checks against the real PR and blocks merge on failure. <!-- Added: forge#3793 -->
974
+ If `SPEC_DEFECT = true`: leave `needs-human`, print `BUILD_RESULT: status: BLOCKED`, blocker: "regenerate acceptance spec via investigate (spec-defect: no check could be verified)", and do NOT run the repair below.
944
975
 
945
- If `GATE_PASS = false`, the build repairs itself **once** before anything escalates (a failing acceptance check is pipeline work, not a human decision):
976
+ If `GATE_PASS = false` and `SPEC_DEFECT` is not `true`, the build repairs itself **once** before anything escalates (a failing acceptance check is pipeline work, not a human decision):
946
977
 
947
978
  1. Count `<!-- FORGE:ACCEPTANCE_REPAIR: issue={NUMBER} -->` comments on the issue. If one already exists, skip to step 4.
948
979
  1.5. **Decomposition guard** <!-- Added: forge#3543 -->: read the `### Decomposition Assessment` of the latest FORGE:INVESTIGATOR comment. If it is `**YES**`, a failing check that belongs to a sub-item the assessment assigns to a separate issue (named in the check's `description`/`# item-N` tag, or matching a listed sub-issue title) is NOT repaired: exclude it from the `--fix-acceptance` list. When ambiguous, treat the check as decomposed-out (fail safe). If every remaining failure is decomposed-out, do not invoke implement: leave `needs-human` and print `BUILD_RESULT: status: BLOCKED`, blocker: "acceptance check {ID} belongs to decomposed item {N} — repair refuses to implement work the investigator assigned to a separate issue".
@@ -57,7 +57,7 @@ PR_BASE="{PR_BASE}"; BRANCH="{BRANCH}"; WORKTREE_PATH="{WORKTREE_PATH}"; TERMINA
57
57
  |---|---|---|
58
58
  | `merged` | C0 → C6, all phases | — |
59
59
  | `investigation` | C0, C0.5, C1, C1.5, C2, C3, C4, C4.5, C5, C5.1–C5.4, C6 | C1.7, C5.5 (no PR); PR lines in the issue body, report and trajectory use the "no PR" form |
60
- | `decomposed` | C0, C0.5, C4, C4.5, C5, C5.1–C5.4, C6 | C1, C1.7, C1.5, C2, C3, C5.5 — the parent issue stays OPEN with `workflow:decomposed` (owned by the decompose phase) |
60
+ | `decomposed` | C0, C0.5, C4, C4.5, C5, C5.1–C5.4, C6 | C1, C1.7, C1.5, C2, C3, C5.5 — the parent keeps `workflow:decomposed` (owned by the decompose phase); it stays OPEN unless `decomposition.close_parent` closed it at decomposition, and C3 of the last child then completes it |
61
61
  | `invalid` | C0, C0.5, C4, C4.5, C5, C6 | C1, C1.7, C1.5, C2, C3, C5.1–C5.5 — the issue was already closed with `workflow:invalid` by the investigate phase |
62
62
 
63
63
  Every `gh pr view {PR_NUMBER}` and every other PR-dependent step in this file is guarded on a non-empty `PR_NUMBER`: with an empty PR, the PR-derived values render as `—` and nothing aborts.
@@ -824,15 +824,26 @@ OPEN_SUBS=$(printf '%s\n' "$UPDATED_PARENT" | grep -cE '^[[:space:]]*[-*+] \[ \]
824
824
  OPEN_SUBS=${OPEN_SUBS:-0}
825
825
  ```
826
826
 
827
- If `OPEN_SUBS == 0` (all sub-issues checked off) — close the parent and transition it to `merged` with the same tiered dispatch and FULL remove list as C2:
827
+ If `OPEN_SUBS == 0` (all sub-issues checked off), branch on the parent's `state` <!-- Added: forge#3736 -->. An OPEN parent is closed and transitioned to `merged` with the same tiered dispatch and FULL remove list as C2. A CLOSED parent (closed at decomposition by `decomposition.close_parent`) is never closed a second time and never reopened: post the completion comment (once) and set `merged`:
828
828
  ```bash
829
829
  if [ "$OPEN_SUBS" -eq 0 ]; then
830
- gh issue close "$PARENT_REF" {GH_FLAG} --comment "All sub-issues complete. Closing parent tracker. Last completed: #{NUMBER}${PR_NUMBER:+ (PR #${PR_NUMBER})}." # allowlist:check-command-side-effects
831
- set_workflow_state "$PARENT_REF" merged
830
+ PARENT_STATE=$(gh issue view "$PARENT_REF" {GH_FLAG} --json state --jq '.state' 2>/dev/null || echo OPEN)
831
+ COMPLETE_MSG="All sub-issues complete. Closing parent tracker. Last completed: #{NUMBER}${PR_NUMBER:+ (PR #${PR_NUMBER})}."
832
+ if [ "$PARENT_STATE" = "CLOSED" ]; then
833
+ ALREADY_POSTED=$(gh api "repos/{GH_REPO}/issues/${PARENT_REF}/comments" \
834
+ --jq '[.[] | select(.body | startswith("All sub-issues complete"))] | length' 2>/dev/null || echo 0)
835
+ [ "${ALREADY_POSTED:-0}" -gt 0 ] || gh issue comment "$PARENT_REF" {GH_FLAG} --body "${COMPLETE_MSG/Closing parent tracker. /}" # allowlist:check-command-side-effects
836
+ set_workflow_state "$PARENT_REF" merged
837
+ else
838
+ gh issue close "$PARENT_REF" {GH_FLAG} --comment "$COMPLETE_MSG" # allowlist:check-command-side-effects
839
+ set_workflow_state "$PARENT_REF" merged
840
+ fi
832
841
  PARENT_CLOSED=true
833
842
  fi
834
843
  ```
835
844
 
845
+ With `decomposition.close_parent: false` (default) the parent is OPEN at this point, so behaviour is unchanged.
846
+
836
847
  Set `PARENT_STATUS="✅ Complete"` and `PARENT_NOTES="Checked off in #${PARENT_REF}"` when the parent was updated.
837
848
 
838
849
  ---
@@ -1068,8 +1079,8 @@ case "$TERMINAL_STATE" in
1068
1079
  PHASE2_ROW="✅ Decomposed | Sub-issues spawned (see FORGE:DECOMPOSED)"
1069
1080
  BUILD_ROW="⏭ Skipped | Handled by sub-issues"
1070
1081
  GATE_CELL="⏭ N/A |"; VERIF_CELL="⏭ N/A |"; REVIEW_CELL="⏭ N/A (no PR) |"
1071
- CLOSE_CELL="✅ Complete | Issue left open (sub-issue tracker)"
1072
- DECISIONS_BLOCK="- Decomposed into sub-issues; this issue stays open as the tracker" ;;
1082
+ CLOSE_CELL="✅ Complete | Tracker for sub-issues (stays open unless decomposition.close_parent)"
1083
+ DECISIONS_BLOCK="- Decomposed into sub-issues; this issue is the tracker (open, or closed at decomposition when `decomposition.close_parent` is true)" ;;
1073
1084
  invalid)
1074
1085
  PHASE2_ROW="⏭ Skipped | Issue invalid"
1075
1086
  BUILD_ROW="⏭ Skipped | Issue invalid"
@@ -18,7 +18,7 @@ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\""
18
18
 
19
19
 
20
20
  **Invoked by**: the work-on router, when `INVESTIGATE_RESULT.decompose = YES`. This skill runs in an isolated forked context: it sees only its args and re-reads everything else from GitHub.
21
- **Output**: Create sub-issues, update parent tracker, post `<!-- FORGE:DECOMPOSED -->` comment, set `workflow:decomposed`. STOP — each sub-issue runs its own /work-on. This skill is the SOLE owner of the `workflow:decomposed` label. On BLOCKED it adds `needs-human` and posts the blocker.
21
+ **Output**: Create sub-issues, update parent tracker, post `<!-- FORGE:DECOMPOSED -->` comment, set `workflow:decomposed`, close the parent when `decomposition.close_parent` is true (D6.5). STOP — each sub-issue runs its own /work-on. This skill is the SOLE owner of the `workflow:decomposed` label. On BLOCKED it adds `needs-human` and posts the blocker.
22
22
 
23
23
  **Engine coverage** (forge#2379): this subcommand's `command` name (`work-on/decompose`) and completion marker (`FORGE:DECOMPOSED:COMPLETE`) are registered as a real phase in the headless engine's phase table — `decompose` in `packages/protocol/src/phases.js`'s `PHASE_IDS`/`PHASE_MARKERS`, and the matching `decompose` entry in `bin/engine/phases.mjs`'s `PHASES` array. The engine's `investigate` phase hands off to it on `<!-- DECOMPOSE:YES -->` (or a `**YES**` verdict under `### Decomposition Assessment`) in the newest trusted FORGE:INVESTIGATOR report, instead of terminating in place, so a headless `runIssue()` walk dispatches this subcommand rather than stopping short.
24
24
 
@@ -217,7 +217,7 @@ SUB_LIST=$(printf '%s\n' "$DECOMP_BODY" | sed -nE 's/^- #([0-9]+).*/\1/p' | past
217
217
  echo "sub_issues: [${SUB_LIST}]"
218
218
  ```
219
219
 
220
- Run Phase D6 (idempotent label re-assert), then EXIT with `DECOMPOSE_RESULT: status: ALREADY_DONE`, `sub_issues: [${SUB_LIST}]`, `comment_url` from the comment.
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
221
 
222
222
  ---
223
223
 
@@ -283,7 +283,7 @@ CANONICAL_SUB_ISSUES=$(jq --arg parent "**Parent**: #{NUMBER}" --argjson expecte
283
283
  CANONICAL_NUMBERS=$(printf '%s' "$CANONICAL_SUB_ISSUES" | jq -r 'map(.number) | sort | map(tostring) | join(", ")' 2>/dev/null)
284
284
  ```
285
285
 
286
- If `CANONICAL_SUB_ISSUES` is non-empty, the decomposition has already created an equivalent set. Do **not** create issues, edit the parent body, or post another decomposition comment. Preserve those original sub-issues, run Phase D6 (idempotent label re-assert), and EXIT with:
286
+ If `CANONICAL_SUB_ISSUES` is non-empty, the decomposition has already created an equivalent set. Do **not** create issues, edit the parent body, or post another decomposition comment. Preserve those original sub-issues, run Phase D6 (idempotent label re-assert) and Phase D6.5 (close parent when `decomposition.close_parent` is true), and EXIT with:
287
287
 
288
288
  ```
289
289
  DECOMPOSE_RESULT:
@@ -493,7 +493,7 @@ ${ATTRIBUTION_LINE}
493
493
 
494
494
  ## Phase D6: Update Labels
495
495
 
496
- Decompose is the SOLE owner of the `workflow:decomposed` label. Transition with tiered dispatch; a transition to `decomposed` removes every other workflow label. Also run this block (idempotently) on both ALREADY_DONE exits.
496
+ Decompose is the SOLE owner of the `workflow:decomposed` label. Transition with tiered dispatch; a transition to `decomposed` removes every other workflow label. Also run this block (idempotently) on both ALREADY_DONE exits, followed by D6.5.
497
497
 
498
498
  ```bash
499
499
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
@@ -509,10 +509,51 @@ esac
509
509
 
510
510
  ---
511
511
 
512
+ ## Phase D6.5: Close Parent (opt-in)
513
+
514
+ <!-- Added: forge#3736 -->
515
+ 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
+
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.
518
+
519
+ ```bash
520
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
521
+ CLOSE_PARENT=$(yq '.decomposition.close_parent // false' forge.yaml 2>/dev/null || echo false)
522
+ [ "$CLOSE_PARENT" = "true" ] || CLOSE_PARENT=false
523
+ if [ "$CLOSE_PARENT" = "true" ]; then
524
+ SUB_NUMBERS=$(printf '%s' "${SUB_NUMBERS:-}" | tr -c '0-9\n' ' ')
525
+
526
+ # Child guard: every child body must carry the parent reference Close C3 matches on.
527
+ for SUB_NUMBER in $SUB_NUMBERS; do
528
+ [ -n "$SUB_NUMBER" ] || continue
529
+ SUB_BODY=$(gh issue view "$SUB_NUMBER" {GH_FLAG} --json body --jq '.body' 2>/dev/null || true)
530
+ [ -n "$SUB_BODY" ] || continue
531
+ printf '%s\n' "$SUB_BODY" | grep -qE '^\*\*Parent\*\*: #{NUMBER}([^0-9]|$)' && continue
532
+ run gh issue edit "$SUB_NUMBER" {GH_FLAG} --body "$(printf '**Parent**: #{NUMBER}\n\n%s' "$SUB_BODY")" # allowlist:check-command-side-effects
533
+ done
534
+
535
+ # Close only an OPEN parent, and only once the DECOMPOSED:COMPLETE marker is on it.
536
+ 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)
539
+ if [ "$PARENT_STATE" = "OPEN" ] && [ "${HAS_COMPLETE:-0}" -gt 0 ]; then
540
+ REFS=$(for n in $SUB_NUMBERS; do printf '#%s ' "$n"; done)
541
+ run gh issue close {NUMBER} {GH_FLAG} --reason completed \
542
+ --comment "Decomposed into ${REFS}; tracked here until all complete." # allowlist:check-command-side-effects
543
+ fi
544
+ fi
545
+ ```
546
+
547
+ If the close fails, do not fail the phase: the parent stays open (today's behaviour) and the decomposition is still complete. A closed parent keeps `workflow:decomposed`; `--retry` and engine resume refuse closed issues, which is acceptable because a decomposed parent has nothing left to run.
548
+
549
+ ---
550
+
512
551
  ## Phase D7: STOP
513
552
 
514
553
  Decomposition is a terminal route for the parent issue. Each sub-issue will be picked up separately by /work-on.
515
554
 
555
+ Phase D6.5 has already run (with `SUB_NUMBERS="${CREATED_NUMBERS}"`) before this point.
556
+
516
557
  Print the result block (see Result emission) as the final reply, with `status: COMPLETE`, `sub_issues: [${CREATED_NUMBERS}]` (numbers only) and `comment_url: ${COMMENT_URL}`.
517
558
 
518
559
  **Router behavior after DECOMPOSE_RESULT**: `break` — do not continue to build/review/close for the parent issue.
@@ -706,8 +706,8 @@ ESTIMATE_FACTOR: {FACTOR — resolved build.diff_size.estimate_factor, positive
706
706
  **Quoting (MANDATORY)**: `target=` and `matcher=` MUST always be wrapped in double quotes — `target="..."` / `matcher="..."` — even when the value is a single token (e.g. a plain file path). The downstream Phase B6.5 parser only extracts quoted values; an unquoted `target=`/`matcher=` will silently truncate at the first space and cause a false-negative gate failure for any multi-word value (shell commands with flags/arguments/pipes are almost always multi-word). Neither `target` nor `matcher` may contain a literal `"` character — use single quotes for any embedded string/regex literal inside the value, as shown below. `id=` and `type=` are always single tokens and are never quoted. `description=` is always the last field on the line and is captured to end-of-line — it does not need quoting.
707
707
 
708
708
  ```
709
- ACCEPTANCE_CHECK: id={ac-1} type={exists|contains|command|behavior} target="{file_path|command|url}" matcher="{string|exit_0|regex}" phase={build|review} description={one-line human description}
710
- ACCEPTANCE_CHECK: id={ac-2} type={exists|contains|command|behavior} target="{file_path|command|url}" matcher="{string|exit_0|regex}" phase={build|review} description={one-line human description}
709
+ ACCEPTANCE_CHECK: id={ac-1} type={exists|contains|command|behavior|skipped} target="{file_path|command|url}" matcher="{string|exit_0|regex}" phase={build|review} description={one-line human description}
710
+ ACCEPTANCE_CHECK: id={ac-2} type={exists|contains|command|behavior|skipped} target="{file_path|command|url}" matcher="{string|exit_0|regex}" phase={build|review} description={one-line human description}
711
711
  ```
712
712
 
713
713
  Example with a multi-word shell command target (the case that previously broke — note the embedded single quotes around the regex, and the double quotes wrapping the whole target):
@@ -726,6 +726,10 @@ ACCEPTANCE_CHECK: id=ac-7 type=command target="gh pr view --json body --jq .body
726
726
 
727
727
  **Literal shell (MANDATORY)** <!-- Added: forge#3694 -->: a `command`/`behavior` `target` is literal shell, executed as written after only `\"` is unescaped. Never JSON-escape it: do not double backslashes (a run of four or more backslashes is flagged `malformed-check` by the acceptance gate and the check is never run). Pass grep patterns with `-e` or `--` so a pattern starting with `-` is not read as an option (exit 2 is also reported as `malformed-check`). For fixed strings prefer `grep -F`: `grep -qF -e '<literal>' <file>` needs no regex escaping at all.
728
728
 
729
+ **Manual and post-deploy criteria** <!-- Added: forge#3825 -->: a criterion that needs production, promotion/deploy, or human judgment cannot be verified at build time. Emit it per check as `ACCEPTANCE_CHECK: id=ac-N type=skipped target="none" matcher="none" phase=build description=<text> (manual)`. The build gate never executes `type=skipped`; it lists each one as an unchecked "Manual follow-up" in the PASSED comment. Never emit `target="none"` (or any placeholder target) with `command`/`behavior`: the gate would run it as a shell command, and the check is reported `malformed-check`. Include at least one machine-checkable check whenever one can be derived: a spec made only of manual checks passes with human review required (like the `ac-skip` sentinel), and a spec whose remaining checks are all malformed verifies nothing and blocks the build as a spec-defect.
730
+
731
+ **Emission-time dry-run of `command` targets** <!-- Added: forge#3825 -->: before posting, run each repo-local, read-only `command` target (`bash scripts/*.sh`, `node bin/*`) once from the base checkout with `timeout 20`. A target that exits 2 with `usage` in stderr is missing a required argument: fix it (add the argument) or reclassify it as manual. Flag only exit 2 plus a usage message; do not treat every non-zero exit as a defect, because a target that fails for a real reason (missing file, failing assertion) is a legitimate check that must still FAIL until the implementation lands. Skip any target that is not clearly read-only.
732
+
729
733
  **Self-defeating pipe guideline**: do NOT chain a `-q`/`--quiet` command into a downstream pipe consumer (e.g. `grep -q ... | grep ...`). A `-q` flag suppresses all stdout, so the next command in the pipe always receives empty input and the check can never pass regardless of the actual file content. If a check needs to verify two conditions against the same output, sequence them instead — e.g. `grep -qE 'first' file && grep -qE 'second' file` — or capture the output once and grep the captured variable.
730
734
 
731
735
  **Class-wide checks (review-finding issues)**: when a Pattern Sweep was recorded, emit at least one `ACCEPTANCE_CHECK` that verifies the fix covers every `fix` row (for example a `command` check that the defective pattern no longer matches anywhere in the swept paths) and one that verifies the added test covers a representative set of instances rather than the reviewer's single repro. <!-- Added: forge#3449 -->
@@ -129,7 +129,7 @@ Completed trails of other kinds are left in place (never deleted); only the inte
129
129
  - 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`.
130
130
  - If `REMEDIATION_COMPLETE_N` is at least 1 (a trusted comment that starts with `<!-- FORGE:REMEDIATION -->` and contains `<!-- FORGE:REMEDIATION:COMPLETE -->`) → EXIT `REMEDIATE_RESULT: status: ALREADY_DONE`. **Single-attempt semantics (AC5)**: once a genuine `FORGE:REMEDIATION:COMPLETE` trail comment exists for this PR, do NOT re-attempt fixes on a subsequent invocation, regardless of the prior verdict — this is what prevents an infinite remediation retry loop on a genuinely-blocked PR. A comment that only quotes the marker mid-body (review findings, an `INPR_FIX` work order, a disposition record) or comes from an untrusted author never counts.
131
131
  - **Merged-PR routing (forge#3685)**: if `PR_ALREADY_MERGED=true` and `REMEDIATION_COMPLETE_N` is 0 (the fail-closed unreadable-trail exit above already ran, so the count is known): if the linked issue is already `CLOSED` with the `workflow:merged` label (`ISSUE_STATE`), EXIT `REMEDIATE_RESULT: status: ALREADY_DONE` (landed and finished, nothing to do). Otherwise (not yet `workflow:merged`/CLOSED, no completed trail) set `RESUME_AT_M7=true` and `PR_ALREADY_MERGED=true`: skip Phases M1-M6 and continue at Phase M7, whose merged-first branch is exclusive, runs the idempotent `merged` label bookkeeping, sets `AUTO-LANDED` and goes to M8, which posts the trail and drives close. No park evidence is required. A `FORGE:REMEDIATION_HELD` record never counts toward `REMEDIATION_COMPLETE_N`.
132
- - **Liveness step (forge#3792)**: when the PR is open, the issue carries `needs-human` or `workflow:remediating` and `REMEDIATION_COMPLETE_N` is 0, run this BEFORE any partial comment is deleted or anything is pushed. Partial trail comments are not evidence of a live run; the script decides from the `FORGE:STATE` lease and the routed re-review. Pass `{LEASE_HOLDER}` only if it matches `^[A-Za-z0-9._-]{1,128}$`, else an empty string.
132
+ - **Liveness step (forge#3792)**: when the PR is open, the issue carries `needs-human` or `workflow:remediating` and `REMEDIATION_COMPLETE_N` is 0, run this BEFORE any partial comment is deleted or anything is pushed. Partial trail comments are not evidence of a live run; the script decides from the `FORGE:STATE` lease and the routed re-review. A review that handed off to remediation (a trusted `FORGE:{CI|INPR|BASESYNC}_REMEDIATION: pr={N}` marker at or after the newest route, e.g. a base-sync handoff before any panel was dispatched) is never in flight: it never defers and spends none of the deferral budget. Pass `{LEASE_HOLDER}` only if it matches `^[A-Za-z0-9._-]{1,128}$`, else an empty string.
133
133
 
134
134
  ```bash
135
135
  LV_SCRIPT=$(forge_resolve remediate-liveness.sh)
@@ -137,7 +137,7 @@ Completed trails of other kinds are left in place (never deleted); only the inte
137
137
  echo "$LV_OUT"
138
138
  ```
139
139
 
140
- `LV_RC` 0 (dead): continue with the cleanup below. 10 (`DEFER`, the script posted a `FORGE:REMEDIATION_DEFERRED` record): EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker `deferred-live-run: {detail}`, touching nothing (no deletes, labels or push; the plain `git push` is the atomic backstop, a non-fast-forward race is rejected and parks at M4). 11 (`EXHAUSTED`, 3 deferrals already): a normal `BLOCKED` exit with `needs-human`. Any other rc: EXIT `BLOCKED`, blocker `github-unavailable: liveness: {detail}`.
140
+ `LV_RC` 0 (dead): continue with the cleanup below. 10 (`DEFER`, the script posted a `FORGE:REMEDIATION_DEFERRED` record): EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker `deferred-live-run: {detail}`, touching nothing (no deletes, labels or push; the plain `git push` is the atomic backstop, a non-fast-forward race is rejected and parks at M4). 11 (`EXHAUSTED`, 3 deferrals already): a normal `BLOCKED` exit with `needs-human`. Any other rc: EXIT `BLOCKED`, blocker `github-unavailable: liveness: {detail}`. The engine posts a `<!-- FORGE:REMEDIATION_DISPATCH: pr={pr} -->` record on every dispatch to scope a deferral to its own dispatch; it is not a trail or completion marker and the partial-trail cleanup ignores it.
141
141
  - If `REMEDIATION_COMPLETE_N` is 0 and `REMEDIATION_TRAIL_N` is at least 1 and the liveness step above said **dead** → a prior attempt was interrupted mid-flight (same failure mode as the investigation phase's partial-comment case). Delete the partial comment(s) on both the PR and the issue (ids from `PARTIAL_PR_COMMENT_IDS` above and the same anchored filter over the issue's comments), then continue below as a fresh attempt:
142
142
  ```bash
143
143
  for _id in $PARTIAL_PR_COMMENT_IDS; do gh api repos/{GH_REPO}/issues/comments/$_id -X DELETE 2>/dev/null || true; done
@@ -185,7 +185,7 @@ BLOCK_COMMENTS=$(gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
185
185
  ```
186
186
 
187
187
  **Classify into FIXABLE vs. UNFIXABLE**:
188
- - **FIXABLE** — open `review-finding` issues (CONFIRMED/LIKELY code defects), a `VERDICT=CHANGES REQUESTED` block with concrete findings attached, a base conflict (`base-conflict`: `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection — resolvable by merging `origin/{PR_BASE}` in, the base-sync step in Phase M3; `BLOCKED` alone is branch protection that a sync cannot clear and is UNFIXABLE unless a conflict state is also present), a quality-gate/build failure, or a CI-gate refusal (`ci gate not green` — the failing/cancelled/timed-out checks are listed on the linked issue; read each failing job's log with `gh run view --log-failed`, fix the cause on the PR branch, or re-run a check that failed for an infrastructure reason), or an in-PR fix request (`in-pr fix required` — review-pr §6B.6 posted a `<!-- FORGE:INPR_FIX: round=1 head=<sha> findings=<ids> -->` comment on the PR listing CONFIRMED MEDIUM findings in files this PR changed. That comment is the work order: fix exactly the listed findings, at the listed `file:line`, and nothing else. Do not file them as issues; the re-review in Phase M6 files any that remain).
188
+ - **FIXABLE** — open `review-finding` issues (CONFIRMED/LIKELY code defects), a `VERDICT=CHANGES REQUESTED` block with concrete findings attached, a base conflict (`base-conflict`: `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection — resolvable by merging `origin/{PR_BASE}` in, the base-sync step in Phase M3; `BLOCKED` alone is branch protection that a sync cannot clear and is UNFIXABLE unless a conflict state is also present), a quality-gate/build failure, or a CI-gate refusal (`ci gate not green` — the failing/cancelled/timed-out checks are listed on the linked issue; read each failing job's log with `gh run view --log-failed`, fix the cause on the PR branch, or re-run a check that failed for an infrastructure reason), or an in-PR fix request (`in-pr fix required` — review-pr §6B.6 posted a `<!-- FORGE:INPR_FIX: round=1 head=<sha> max_severity=<MEDIUM|HIGH|CRITICAL> findings=<ids> -->` comment on the PR listing CONFIRMED MEDIUM/HIGH/CRITICAL findings in files this PR changed. That comment is the work order: fix exactly the listed findings, at the listed `file:line`, and nothing else. Do not file them as issues; the re-review in Phase M6 files any that remain).
189
189
  - **UNFIXABLE (policy escalation)** — `HAS_PURPOSE_REGRESSION=true` (the PR's behavior diverges from the issue's intent — a judgment call, not a code defect), `CALIBRATION_NEEDS_HUMAN=true` (statistical trust threshold), or `TRUST_NEEDS_HUMAN=true` (provenance `NOVEL_NEEDS_HUMAN` tier, insufficient prior data — a policy gate, not a bug). None of these are mechanically "fixable" by re-editing code.
190
190
 
191
191
  **If the block reason classifies as UNFIXABLE** (and no FIXABLE item accompanies it): do NOT attempt any fix. Skip directly to Phase M8 with verdict `UNFIXABLE`, re-affirm `needs-human` (add it and remove `workflow:remediating` if the issue entered as `workflow:remediating`), and return `REMEDIATE_RESULT: status: UNFIXABLE`. This satisfies AC5 — "genuinely-blocked PRs still terminate at `needs-human`."
@@ -218,12 +218,28 @@ if [ -d "{WORKTREE_PATH}" ]; then
218
218
  git -C "{WORKTREE_PATH}" fetch origin
219
219
  git -C "{WORKTREE_PATH}" checkout {HEAD_BRANCH}
220
220
  git -C "{WORKTREE_PATH}" reset --hard "origin/{HEAD_BRANCH}"
221
- else
222
- git worktree add "{WORKTREE_PATH}" {HEAD_BRANCH} "origin/{HEAD_BRANCH}"
221
+ elif ! WT_ERR=$(git worktree add "{WORKTREE_PATH}" {HEAD_BRANCH} "origin/{HEAD_BRANCH}" 2>&1); then
222
+ # Branch already checked out elsewhere: reuse that worktree (never the main checkout).
223
+ EXISTING_WT=""; MAIN_WT=""; CUR_WT=""
224
+ while IFS= read -r line; do
225
+ case "$line" in
226
+ "worktree "*) CUR_WT="${line#worktree }"; [ -z "$MAIN_WT" ] && MAIN_WT="$CUR_WT" ;;
227
+ "branch refs/heads/{HEAD_BRANCH}") [ -z "$EXISTING_WT" ] && EXISTING_WT="$CUR_WT" ;;
228
+ esac
229
+ done < <(git worktree list --porcelain)
230
+ WORKTREE_PATH="" # stays empty (BLOCKED) unless the reuse is provably safe
231
+ if [ -n "$EXISTING_WT" ] && [ "$EXISTING_WT" != "$MAIN_WT" ] && [ -d "$EXISTING_WT" ] \
232
+ && [ -z "$(git -C "$EXISTING_WT" status --porcelain)" ] \
233
+ && [ "$(git -C "$EXISTING_WT" rev-list --count "origin/{HEAD_BRANCH}..HEAD")" = "0" ]; then
234
+ git -C "$EXISTING_WT" fetch origin && git -C "$EXISTING_WT" reset --hard "origin/{HEAD_BRANCH}" && WORKTREE_PATH="$EXISTING_WT"
235
+ else echo "$WT_ERR" >&2; fi
236
+ echo "WORKTREE_PATH=$WORKTREE_PATH" # use this printed value for every later {WORKTREE_PATH}
223
237
  fi
224
238
  ```
225
239
 
226
- If the worktree/branch checkout fails for any reason (branch deleted, force-pushed out from under us, etc.): post a comment, add `needs-human`, EXIT `REMEDIATE_RESULT: status: BLOCKED`.
240
+ **Main-checkout ban**: remediation never runs `checkout`, `switch`, `reset`, `stash`, or `merge` in the main checkout (`{REPO_PATH}`), not even as a fallback for a failed `git worktree add`; every mutating git command targets `{WORKTREE_PATH}` via `git -C`. A reused worktree is never reset when it has uncommitted changes or commits not on `origin/{HEAD_BRANCH}`, and an unresolvable one is not improvised around: `WORKTREE_PATH` stays empty and an empty value after this block means BLOCKED. <!-- Added: forge#3650 -->
241
+
242
+ If the worktree/branch checkout fails for any reason (branch deleted, force-pushed out from under us, checked out in the main checkout, etc.): post a comment, add `needs-human`, EXIT `REMEDIATE_RESULT: status: BLOCKED`.
227
243
 
228
244
  ---
229
245