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.
- package/bin/engine/outcome-codes.mjs +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +18 -11
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- 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
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
823
|
-
|
|
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
|
|
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
|
|
831
|
-
|
|
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="—";
|
|
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/^
|
|
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" ] &&
|
|
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 |
|
|
1072
|
-
DECISIONS_BLOCK="- Decomposed into sub-issues; this issue
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
**
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
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
|
|
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),
|
|
247
|
-
1. Sub-issue titles (in dependency order — independent issues first). **
|
|
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
|
|
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
|
|
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. **
|
|
588
|
-
-
|
|
589
|
-
- `INDEPENDENT_SEAMS`: the number of independently
|
|
590
|
-
-
|
|
591
|
-
-
|
|
592
|
-
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|