forgedock 1.16.2 → 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 (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -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 +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -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 +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
@@ -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.
@@ -809,30 +809,74 @@ If no parent reference found → log a warning and skip this phase (`PARENT_STAT
809
809
  echo "WARNING: No parent reference found in issue body — skipping parent tracker update"
810
810
  ```
811
811
 
812
- If parent found:
812
+ If parent found, check this sub-issue off with a verified retry. The parent body is a shared read-modify-write target: a sibling lane closing at the same moment can overwrite this write from a stale copy and silently drop a checkbox. Each attempt re-reads the body, re-applies the substitution to the fresh copy, writes it, then re-reads to verify the box is present, retrying up to 3 times with backoff. Idempotent, so the resume path may re-enter it:
813
813
  ```bash
814
- # Read parent body (fresh read immediately before the body mutation)
815
- PARENT_BODY=$(gh issue view "$PARENT_REF" {GH_FLAG} --json body --jq '.body')
816
-
817
- # Check off this sub-issue in parent body (replace "- [ ] #{NUMBER}" with "- [x] #{NUMBER}"; the trailing guard stops #12 matching #123)
818
- UPDATED_PARENT=$(printf '%s\n' "$PARENT_BODY" | sed -E "s/- \[ \] #${NUMBER}([^0-9]|\$)/- [x] #${NUMBER}\1/g")
819
- gh issue edit "$PARENT_REF" {GH_FLAG} --body "$UPDATED_PARENT" # allowlist:check-command-side-effects
820
- PARENT_UPDATED=true
814
+ PARENT_UPDATED=false
815
+ for ATTEMPT in 1 2 3; do
816
+ # Fresh read immediately before each body mutation
817
+ PARENT_BODY=$(gh issue view "$PARENT_REF" {GH_FLAG} --json body --jq '.body')
818
+ # Already checked (earlier attempt, earlier run, or restored): nothing to write
819
+ if printf '%s\n' "$PARENT_BODY" | grep -qE "^[[:space:]]*[-*+] \[[xX]\] #{NUMBER}([^0-9]|\$)"; then PARENT_UPDATED=true; break; fi
820
+ # No checklist entry for this sub-issue: nothing to check off
821
+ printf '%s\n' "$PARENT_BODY" | grep -qE "^[[:space:]]*[-*+] \[ \] #{NUMBER}([^0-9]|\$)" || break
822
+ # Replace "- [ ] #{NUMBER}" with "- [x] #{NUMBER}"; the trailing guard stops #12 matching #123
823
+ UPDATED_PARENT=$(printf '%s\n' "$PARENT_BODY" | sed -E "s/- \[ \] #{NUMBER}([^0-9]|\$)/- [x] #{NUMBER}\1/g")
824
+ gh issue edit "$PARENT_REF" {GH_FLAG} --body "$UPDATED_PARENT" # allowlist:check-command-side-effects
825
+ # Verify the write survived: re-read and confirm the box is present
826
+ VERIFY_BODY=$(gh issue view "$PARENT_REF" {GH_FLAG} --json body --jq '.body')
827
+ if printf '%s\n' "$VERIFY_BODY" | grep -qE "^[[:space:]]*[-*+] \[[xX]\] #{NUMBER}([^0-9]|\$)"; then PARENT_UPDATED=true; break; fi
828
+ echo "WARNING: parent #${PARENT_REF} checkbox for #{NUMBER} not present after write (attempt ${ATTEMPT}/3) — retrying"
829
+ sleep "$ATTEMPT"
830
+ done
831
+ [ "$PARENT_UPDATED" = true ] || echo "WARNING: could not verify checkbox for #{NUMBER} on parent #${PARENT_REF} — completion is derived from sub-issue state"
832
+ ```
821
833
 
822
- # Check if all sub-issues are now checked off
823
- OPEN_SUBS=$(printf '%s\n' "$UPDATED_PARENT" | grep -cE '^[[:space:]]*[-*+] \[ \]' || true)
834
+ Derive completion from sub-issue state, not from the checklist text, so a lost checkbox can never strand the parent. Every `#N` on a checklist line of a fresh parent read is looked up with `gh issue view N --json state`; this sub-issue is already CLOSED by C1/C2 and counts as closed. Unchecked lines without a leading `#N` ref (including a non-leading mention such as `(see #9)`) still count as open. If the re-read or any state lookup fails, or no refs are found, fall back to the checklist count (fail closed: a failed lookup is never treated as closed):
835
+ ```bash
836
+ PARENT_FRESH=$(gh issue view "$PARENT_REF" {GH_FLAG} --json body --jq '.body' 2>/dev/null) || PARENT_FRESH="${UPDATED_PARENT:-${PARENT_BODY:-}}"
837
+ CHECKLIST_OPEN=$(printf '%s\n' "$PARENT_FRESH" | grep -cE '^[[:space:]]*[-*+] \[ \]' || true)
838
+ CHECKLIST_OPEN=${CHECKLIST_OPEN:-0}
839
+ SUB_REFS=$(printf '%s\n' "$PARENT_FRESH" | grep -E '^[[:space:]]*[-*+] \[[ xX]\] #[0-9]+' \
840
+ | sed -E 's/^[[:space:]]*[-*+] \[[ xX]\] #([0-9]+).*/\1/' | grep -vx "$PARENT_REF" | sort -u || true)
841
+ LOOKUP_FAILED=false; STATE_OPEN=0
842
+ for SUB in $SUB_REFS; do
843
+ [ "$SUB" = "{NUMBER}" ] && continue
844
+ SUB_STATE=$(gh issue view "$SUB" {GH_FLAG} --json state --jq '.state' 2>/dev/null) || SUB_STATE=""
845
+ case "$SUB_STATE" in
846
+ CLOSED) ;;
847
+ OPEN) STATE_OPEN=$((STATE_OPEN + 1)) ;;
848
+ *) LOOKUP_FAILED=true ;;
849
+ esac
850
+ done
851
+ if [ "$LOOKUP_FAILED" = true ] || [ -z "$SUB_REFS" ]; then
852
+ OPEN_SUBS=$CHECKLIST_OPEN
853
+ else
854
+ NOREF_OPEN=$(printf '%s\n' "$PARENT_FRESH" | grep -E '^[[:space:]]*[-*+] \[ \]' | grep -cvE '^[[:space:]]*[-*+] \[ \] #[0-9]+' || true)
855
+ OPEN_SUBS=$((STATE_OPEN + ${NOREF_OPEN:-0}))
856
+ fi
824
857
  OPEN_SUBS=${OPEN_SUBS:-0}
825
858
  ```
826
859
 
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:
860
+ If `OPEN_SUBS == 0` (all sub-issues done), 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
861
  ```bash
829
862
  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
863
+ PARENT_STATE=$(gh issue view "$PARENT_REF" {GH_FLAG} --json state --jq '.state' 2>/dev/null || echo OPEN)
864
+ COMPLETE_MSG="All sub-issues complete. Closing parent tracker. Last completed: #{NUMBER}${PR_NUMBER:+ (PR #${PR_NUMBER})}."
865
+ if [ "$PARENT_STATE" = "CLOSED" ]; then
866
+ ALREADY_POSTED=$(gh api "repos/{GH_REPO}/issues/${PARENT_REF}/comments" \
867
+ --jq '[.[] | select(.body | startswith("All sub-issues complete"))] | length' 2>/dev/null || echo 0)
868
+ [ "${ALREADY_POSTED:-0}" -gt 0 ] || gh issue comment "$PARENT_REF" {GH_FLAG} --body "${COMPLETE_MSG/Closing parent tracker. /}" # allowlist:check-command-side-effects
869
+ set_workflow_state "$PARENT_REF" merged
870
+ else
871
+ gh issue close "$PARENT_REF" {GH_FLAG} --comment "$COMPLETE_MSG" # allowlist:check-command-side-effects
872
+ set_workflow_state "$PARENT_REF" merged
873
+ fi
832
874
  PARENT_CLOSED=true
833
875
  fi
834
876
  ```
835
877
 
878
+ With `decomposition.close_parent: false` (default) the parent is OPEN at this point, so behaviour is unchanged.
879
+
836
880
  Set `PARENT_STATUS="✅ Complete"` and `PARENT_NOTES="Checked off in #${PARENT_REF}"` when the parent was updated.
837
881
 
838
882
  ---
@@ -959,15 +1003,17 @@ First read the build's diff size (forge#3450). `FORGE:DIFF_SIZE` is posted by `w
959
1003
 
960
1004
  ```bash
961
1005
  # <Script resolution block, verbatim>
962
- DIFF_LINES="—"; DIFF_THRESHOLD="—"; SIZE_OVERRIDE_USED="no"
1006
+ DIFF_LINES="—"; REVIEW_WEIGHT="—"; SCOPE_RESULT="—"; SIZE_OVERRIDE_USED="no"
963
1007
  TRUSTED_SCRIPT="${UNIVERSAL_DIR:+$UNIVERSAL_DIR/trusted-comments.sh}"
964
1008
  if [ -n "$TRUSTED_SCRIPT" ] && [ -f "$TRUSTED_SCRIPT" ]; then
965
1009
  _ds=$(gh api --paginate "repos/{GH_REPO}/issues/{NUMBER}/comments" 2>/dev/null \
966
1010
  | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:DIFF_SIZE' 2>/dev/null | tail -n 1 | jq -r 'select(type == "string")' 2>/dev/null) || _ds=""
967
1011
  _n=$(printf '%s\n' "$_ds" | sed -n 's/^diff_lines: *\([0-9][0-9]*\).*/\1/p' | head -n 1)
968
- _t=$(printf '%s\n' "$_ds" | sed -n 's/^threshold: *\([0-9][0-9]*\).*/\1/p' | head -n 1)
1012
+ _t=$(printf '%s\n' "$_ds" | sed -n 's/^review_weight: *\([0-9][0-9]*\).*/\1/p' | head -n 1)
1013
+ _r=$(printf '%s\n' "$_ds" | sed -n 's/^result: *\([A-Z_]*\).*/\1/p' | head -n 1)
969
1014
  [ -n "$_n" ] && DIFF_LINES="$_n"
970
- [ -n "$_t" ] && DIFF_THRESHOLD="$_t"
1015
+ [ -n "$_t" ] && REVIEW_WEIGHT="$_t"
1016
+ [ -n "$_r" ] && SCOPE_RESULT="$_r"
971
1017
  printf '%s\n' "$_ds" | grep -q '^result: *OVERRIDDEN' && SIZE_OVERRIDE_USED="yes"
972
1018
  fi
973
1019
  ```
@@ -1068,8 +1114,8 @@ case "$TERMINAL_STATE" in
1068
1114
  PHASE2_ROW="✅ Decomposed | Sub-issues spawned (see FORGE:DECOMPOSED)"
1069
1115
  BUILD_ROW="⏭ Skipped | Handled by sub-issues"
1070
1116
  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" ;;
1117
+ CLOSE_CELL="✅ Complete | Tracker for sub-issues (stays open unless decomposition.close_parent)"
1118
+ DECISIONS_BLOCK="- Decomposed into sub-issues; this issue is the tracker (open, or closed at decomposition when `decomposition.close_parent` is true)" ;;
1073
1119
  invalid)
1074
1120
  PHASE2_ROW="⏭ Skipped | Issue invalid"
1075
1121
  BUILD_ROW="⏭ Skipped | Issue invalid"
@@ -1171,7 +1217,7 @@ ${DECISIONS_BLOCK}
1171
1217
 
1172
1218
  **Cost (economic scheduling)**: ${COST_DELTA_NOTE}
1173
1219
 
1174
- **Diff size**: ${DIFF_LINES:-—} lines (threshold ${DIFF_THRESHOLD:-—}, override: ${SIZE_OVERRIDE_USED:-no})
1220
+ **Diff size**: ${DIFF_LINES:-—} lines (review weight ${REVIEW_WEIGHT:-—}, scope check ${SCOPE_RESULT:-—}, override: ${SIZE_OVERRIDE_USED:-no})
1175
1221
 
1176
1222
  **Pipeline completed**: ${TIMESTAMP}
1177
1223
 
@@ -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
 
@@ -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), 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
 
@@ -283,7 +284,7 @@ CANONICAL_SUB_ISSUES=$(jq --arg parent "**Parent**: #{NUMBER}" --argjson expecte
283
284
  CANONICAL_NUMBERS=$(printf '%s' "$CANONICAL_SUB_ISSUES" | jq -r 'map(.number) | sort | map(tostring) | join(", ")' 2>/dev/null)
284
285
  ```
285
286
 
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:
287
+ 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
288
 
288
289
  ```
289
290
  DECOMPOSE_RESULT:
@@ -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
 
@@ -493,7 +519,7 @@ ${ATTRIBUTION_LINE}
493
519
 
494
520
  ## Phase D6: Update Labels
495
521
 
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.
522
+ 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
523
 
498
524
  ```bash
499
525
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
@@ -509,10 +535,63 @@ esac
509
535
 
510
536
  ---
511
537
 
538
+ ## Phase D6.5: Close Parent (opt-in)
539
+
540
+ <!-- Added: forge#3736 -->
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.
542
+
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.
544
+
545
+ ```bash
546
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
547
+ CLOSE_PARENT=$(yq '.decomposition.close_parent // false' forge.yaml 2>/dev/null || echo false)
548
+ [ "$CLOSE_PARENT" = "true" ] || CLOSE_PARENT=false
549
+ if [ "$CLOSE_PARENT" = "true" ]; then
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
559
+
560
+ # Child guard: every child body must carry the parent reference Close C3 matches on.
561
+ for SUB_NUMBER in $SUB_NUMBERS; do
562
+ [ -n "$SUB_NUMBER" ] || continue
563
+ SUB_BODY=$(gh issue view "$SUB_NUMBER" {GH_FLAG} --json body --jq '.body' 2>/dev/null || true)
564
+ [ -n "$SUB_BODY" ] || continue
565
+ printf '%s\n' "$SUB_BODY" | grep -qE '^\*\*Parent\*\*: #{NUMBER}([^0-9]|$)' && continue
566
+ run gh issue edit "$SUB_NUMBER" {GH_FLAG} --body "$(printf '**Parent**: #{NUMBER}\n\n%s' "$SUB_BODY")" # allowlist:check-command-side-effects
567
+ done
568
+
569
+ # Close only an OPEN parent, and only once the DECOMPOSED:COMPLETE marker is on it.
570
+ PARENT_STATE=$(gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state' 2>/dev/null || true)
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
577
+ if [ "$PARENT_STATE" = "OPEN" ] && [ "${HAS_COMPLETE:-0}" -gt 0 ]; then
578
+ REFS=$(for n in $SUB_NUMBERS; do printf '#%s ' "$n"; done)
579
+ run gh issue close {NUMBER} {GH_FLAG} --reason completed \
580
+ --comment "Decomposed into ${REFS}; tracked here until all complete." # allowlist:check-command-side-effects
581
+ fi
582
+ fi
583
+ ```
584
+
585
+ 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.
586
+
587
+ ---
588
+
512
589
  ## Phase D7: STOP
513
590
 
514
591
  Decomposition is a terminal route for the parent issue. Each sub-issue will be picked up separately by /work-on.
515
592
 
593
+ Phase D6.5 has already run (with `SUB_NUMBERS="${CREATED_NUMBERS}"`) before this point.
594
+
516
595
  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
596
 
518
597
  **Router behavior after DECOMPOSE_RESULT**: `break` — do not continue to build/review/close for the parent issue.
@@ -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 -->
@@ -706,8 +703,8 @@ ESTIMATE_FACTOR: {FACTOR — resolved build.diff_size.estimate_factor, positive
706
703
  **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
704
 
708
705
  ```
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}
706
+ 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}
707
+ 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
708
  ```
712
709
 
713
710
  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 +723,10 @@ ACCEPTANCE_CHECK: id=ac-7 type=command target="gh pr view --json body --jq .body
726
723
 
727
724
  **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
725
 
726
+ **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.
727
+
728
+ **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.
729
+
729
730
  **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
731
 
731
732
  **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 -->
@@ -739,7 +740,7 @@ ${INVESTIGATION_SENTINEL}"
739
740
 
740
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 -->
741
742
 
742
- **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.
743
744
 
744
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 -->
745
746