forgedock 1.8.0 → 1.8.2

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 (67) hide show
  1. package/bin/engine/admission.mjs +57 -1
  2. package/bin/engine/admission.test.mjs +60 -0
  3. package/commands/analytics.md +3 -1
  4. package/commands/audit-agents.md +3 -1
  5. package/commands/audit.md +10 -8
  6. package/commands/autopilot.md +16 -14
  7. package/commands/ci-audit.md +4 -2
  8. package/commands/cleanup.md +1 -1
  9. package/commands/deploy-pr.md +31 -11
  10. package/commands/geo-audit.md +3 -1
  11. package/commands/incident-response.md +3 -1
  12. package/commands/issue.md +4 -2
  13. package/commands/milestone.md +8 -6
  14. package/commands/orchestrate/config.md +2 -2
  15. package/commands/orchestrate/phase-1-resolve.md +56 -12
  16. package/commands/orchestrate/phase-3-dependency.md +15 -5
  17. package/commands/orchestrate/phase-4-execution.md +593 -78
  18. package/commands/orchestrate/phase-5-cleanup.md +4 -2
  19. package/commands/orchestrate/phase-6-report.md +7 -3
  20. package/commands/orchestrate.md +13 -9
  21. package/commands/pipeline-health/phase-4-proposals.md +6 -4
  22. package/commands/pipeline-health/phase-5-report.md +4 -2
  23. package/commands/pipeline-health.md +9 -7
  24. package/commands/pipeline-resume.md +4 -2
  25. package/commands/qa-sweep.md +3 -1
  26. package/commands/quality-gate.md +4 -3
  27. package/commands/recover-orphans.md +172 -13
  28. package/commands/review-pr-agents.md +6 -4
  29. package/commands/review-pr-staging.md +13 -5
  30. package/commands/review-pr.md +187 -19
  31. package/commands/rollback.md +3 -1
  32. package/commands/security-audit.md +3 -1
  33. package/commands/signal-planner.md +6 -4
  34. package/commands/sync-ecosystem.md +4 -2
  35. package/commands/test-gate.md +4 -2
  36. package/commands/upgrade-deps.md +4 -2
  37. package/commands/validate.md +3 -1
  38. package/commands/work-on/build/architect.md +81 -19
  39. package/commands/work-on/build/context.md +61 -18
  40. package/commands/work-on/build/implement.md +62 -18
  41. package/commands/work-on/build/validate.md +240 -60
  42. package/commands/work-on/build.md +344 -146
  43. package/commands/work-on/close.md +617 -246
  44. package/commands/work-on/decompose.md +239 -41
  45. package/commands/work-on/investigate.md +377 -61
  46. package/commands/work-on/remediate.md +42 -9
  47. package/commands/work-on/review.md +240 -29
  48. package/commands/work-on-monolithic.md +48 -7
  49. package/commands/work-on.md +180 -2014
  50. package/docs/CODEX.md +2 -0
  51. package/docs/CONFIG.md +11 -0
  52. package/package.json +1 -1
  53. package/scripts/build-spec-graph.mjs +11 -4
  54. package/scripts/check-spec-markers.sh +11 -1
  55. package/scripts/checkbox-sections.test.sh +8 -23
  56. package/scripts/extract-affected-files.sh +5 -2
  57. package/scripts/extract-affected-files.test.sh +27 -0
  58. package/scripts/forge-root.test.sh +176 -0
  59. package/scripts/is-docs-only.sh +31 -0
  60. package/scripts/is-docs-only.test.sh +64 -0
  61. package/scripts/lint-dispatch-prompt.sh +217 -0
  62. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  63. package/scripts/validate-spec-graph.sh +3 -2
  64. package/scripts/verify-phase-trail.sh +282 -0
  65. package/scripts/verify-phase-trail.test.sh +298 -0
  66. package/scripts/wait-ci-green.sh +98 -0
  67. package/scripts/wait-ci-green.test.sh +99 -0
@@ -1,20 +1,23 @@
1
1
  ---
2
2
  description: Decompose subcommand — break a complex issue into ordered sub-issues, post FORGE:DECOMPOSED, stop
3
- argument-hint: "[issue number] [--repo GH_REPO] [--gh-flag GH_FLAG]"
3
+ context: fork
4
+ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\""
4
5
  ---
5
6
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
7
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
7
8
 
8
9
  # work-on/decompose — Decomposition Subcommand
9
10
 
11
+ > **Skill Name Resolution (forked phase)**: `{FORGE_SKILL_PREFIX}` is the namespace this skill itself was invoked under — invoked as `forgedock:work-on:…` → `forgedock:` (nesting `:`); as `work-on:…` → empty (`install.sh`); as `forge-work-on-…` → `forge-` (Codex, nesting `-`); OpenCode → empty with `-` nesting. Confirm the target name in the available-skills list before calling it. A forked phase receives no resolved value from its caller; never guess, and if the target skill is not listed return BLOCKED "skill not found: <name>".
12
+
10
13
  **Input**: $ARGUMENTS
11
14
 
12
- **Invoked by**: `work-on.md` routing loop, when `INVESTIGATE_RESULT.decompose = YES`.
13
- **Output**: Create sub-issues, update parent tracker, post `<!-- FORGE:DECOMPOSED -->` comment, set labels. STOP — each sub-issue runs its own /work-on.
15
+ **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.
16
+ **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.
14
17
 
15
- **Engine coverage** (forge#2379): this subcommand's `command` name (`work-on/decompose`) and completion marker (`FORGE:DECOMPOSED:COMPLETE`) are now 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` instead of terminating in place, so a headless `runIssue()` walk now actually dispatches this subcommand rather than stopping short.
18
+ **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` instead of terminating in place, so a headless `runIssue()` walk dispatches this subcommand rather than stopping short.
16
19
 
17
- **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154. This file's mechanical bits (label transitions, sub-issue creation) stay at this tier because they're interleaved with the reasoning-heavy sub-issue design steps in the same `Skill()` invocation — see `work-on.md` section "Model and Effort Tiering — What Actually Applies". <!-- Added: forge#1827 -->
20
+ **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154. This file's mechanical bits (label transitions, sub-issue creation) stay at this tier because they're interleaved with the reasoning-heavy sub-issue design steps in the same `Skill()` invocation. <!-- Added: forge#1827 -->
18
21
  **NEVER use plan mode (EnterPlanMode).**
19
22
 
20
23
  ---
@@ -23,8 +26,111 @@ argument-hint: "[issue number] [--repo GH_REPO] [--gh-flag GH_FLAG]"
23
26
 
24
27
  Parse from $ARGUMENTS:
25
28
  - `{NUMBER}` — issue number (required)
26
- - `--repo {GH_REPO}` — GitHub repo (e.g. `{owner}/{repo}` — resolved from `forge.yaml → project`)
27
- - `--gh-flag {GH_FLAG}` — gh CLI repo flag (e.g. `-R {owner}/{repo}`)
29
+ - `--repo {GH_REPO}` — GitHub repo, e.g. `{owner}/{repo}` (required)
30
+ - `--gh-flag {GH_FLAG}` — gh CLI repo flag, e.g. `-R {owner}/{repo}` (required)
31
+
32
+ If `{NUMBER}`, `--repo` or `--gh-flag` is missing, print the result block with `status: BLOCKED`, blocker: "missing required arg: <name>", and STOP (no GitHub write is possible without them).
33
+
34
+ ---
35
+
36
+ ## Script resolution
37
+
38
+ **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`.
39
+
40
+ ```bash
41
+ # Canonical script resolution (keep byte-identical across specs; guarded by scripts/forge-root.test.sh).
42
+ # Shell state does NOT persist between Bash tool calls: include this whole block at the top of
43
+ # every command that uses resolve_script or FORGE_ROOT.
44
+ REPO_PATH="${REPO_PATH:-$(yq '.paths.root // ""' forge.yaml 2>/dev/null)}"
45
+ [ -n "$REPO_PATH" ] && [ "$REPO_PATH" != "null" ] || REPO_PATH="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
46
+ ADAPTIVE_DIR_RAW="${REPO_PATH}/$(yq '.adaptive_scripts.directory // ".forgedock/scripts"' forge.yaml 2>/dev/null || echo '.forgedock/scripts')"
47
+ ADAPTIVE_DIR=$(realpath -m "$ADAPTIVE_DIR_RAW" 2>/dev/null || echo "$ADAPTIVE_DIR_RAW")
48
+ ADAPTIVE_ENABLED=$(yq '.adaptive_scripts.enabled // "true"' forge.yaml 2>/dev/null || echo 'true')
49
+ # Bounds check: reject adaptive_scripts.directory values that escape the repo root.
50
+ # Normalize REPO_PATH the same way ADAPTIVE_DIR is normalized (realpath -m) so a trailing
51
+ # slash in paths.root does not inject a '//' into the glob and trigger a false positive.
52
+ REPO_PATH_NORM=$(realpath -m "$REPO_PATH" 2>/dev/null || echo "$REPO_PATH")
53
+ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
54
+ echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
55
+ ADAPTIVE_ENABLED=false
56
+ fi
57
+ # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the canonical bootstrap
58
+ # below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
59
+ # repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
60
+ # Resolution: $FORGEDOCK_HOME (authoritative when set) > $FORGE_HOME > $CLAUDE_PLUGIN_ROOT > the
61
+ # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
62
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
63
+ FORGE_ROOT=""
64
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
65
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
66
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
67
+ # newest cached version first: numeric major.minor.patch of the version dir name only; a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
68
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ '$(NF-1)=="forgedock"{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$0}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5-)"
69
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
70
+ # '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
71
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
72
+ while IFS= read -r _c; do
73
+ case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
74
+ done <<< "$_k"
75
+ fi
76
+ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
77
+ # NOTE: never resolve this via `which` or `find` — universal scripts are
78
+ # install-relative, not installed on $PATH, so a PATH lookup always misses.
79
+ # FORGE_ROOT (above) is the deterministic resolution; there is NO repo-path fallback.
80
+ # Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to
81
+ # locate pipeline scripts under any circumstances: if UNIVERSAL_DIR/${operation}.sh
82
+ # does not exist, resolve_script() falls through to Tier 4 (prose) below,
83
+ # which is always safe and available. A missing script is never a reason
84
+ # to search the filesystem. <!-- Added: forge#1984 -->
85
+
86
+ resolve_script() {
87
+ local operation="$1"
88
+ # Tier 2: per-repo adaptive (skip if disabled)
89
+ if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
90
+ echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
91
+ return
92
+ fi
93
+ # Tier 3: universal script
94
+ if [ -n "$UNIVERSAL_DIR" ] && [ -f "${UNIVERSAL_DIR}/${operation}.sh" ]; then
95
+ echo "universal:${UNIVERSAL_DIR}/${operation}.sh"
96
+ return
97
+ fi
98
+ # Tier 4: prose fallback
99
+ echo "prose:"
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Result emission
106
+
107
+ Every exit path of this skill — COMPLETE, ALREADY_DONE, every guard, every failure — ends by printing exactly one block as the final reply:
108
+
109
+ ```
110
+ DECOMPOSE_RESULT:
111
+ status: COMPLETE | ALREADY_DONE | BLOCKED
112
+ sub_issues: [N, N, ...]
113
+ comment_url: {url of the FORGE:DECOMPOSED comment; empty if none}
114
+ blocker: {description if status=BLOCKED, else empty}
115
+ ```
116
+
117
+ `sub_issues` is a plain list of issue numbers on every path (`[]` when none), never objects.
118
+
119
+ ### Blocked exit
120
+
121
+ Every `BLOCKED` exit first runs this procedure (set `BLOCKER` to the reason text), then prints the result block with `status: BLOCKED`. It guarantees the parent never keeps a terminal `workflow:decomposed` label without sub-issues:
122
+
123
+ ```bash
124
+ BLOCKER="{reason}"
125
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
126
+ run gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human" --remove-label "workflow:decomposed" 2>/dev/null || true
127
+ run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DECOMPOSE_BLOCKED -->
128
+ ## Decomposition Blocked
129
+
130
+ ${BLOCKER}
131
+
132
+ Human attention required (\`needs-human\`)."
133
+ ```
28
134
 
29
135
  ---
30
136
 
@@ -40,9 +146,21 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
40
146
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body'
41
147
  ```
42
148
 
43
- **Validation**:
44
- - If FORGE:INVESTIGATOR comment is absent → EXIT with `DECOMPOSE_RESULT: status: BLOCKED`, blocker: "No investigation report found — run investigate first"
45
- - If investigation report has no Decomposition Assessment section, OR the assessment does not list any sub-issues → EXIT with `DECOMPOSE_RESULT: status: BLOCKED`, blocker: "Investigation report has no decomposition plan — re-run investigate with explicit decomposition scope"
149
+ **MANDATORY — Owner override detection**: After reading the investigation comment, read ALL other comments on the issue to check for owner override signals:
150
+
151
+ ```bash
152
+ gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
153
+ --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR") | not) | {author: .user.login, body: .body}'
154
+ ```
155
+
156
+ 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):
157
+
158
+ 1. **Document the override**: Note which direction the owner is steering (e.g., "remove the feature" vs. investigation's "keep with warnings")
159
+ 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.
160
+ 3. **If override makes a sub-issue obsolete**: Skip creating it. Note the skip reason in the decomposition comment (Phase D5).
161
+ 4. **If override changes the sequencing dependency**: Revise the execution order so that the override's primary action (e.g., "strip the feature") completes before any downstream doc/SDK sub-issues are built against it.
162
+
163
+ **Why this matters**: Sub-issues scoped before an override are built against a stale premise. A docs sub-issue scoped as "neutralize liability language" becomes incorrect if the upstream schema sub-issue will fully remove the feature — the docs sub-issue should instead be "remove all references to the deleted feature." Building both in parallel against the pre-override scope produces contradictory staging state.
46
164
 
47
165
  > **Shared Scoping Convention**: This investigation gate (blocks without a `FORGE:INVESTIGATOR` comment) is the **reference pattern** for investigation-gated issue creation across the pipeline. `milestone.md` Step 4 and `orchestrate.md` MUST apply the same principle: read code and identify all affected call sites BEFORE writing any issue body. Sub-issue bodies created in Phase D3 MUST use the Pipeline Issue Template defined in `issue.md` Phase 3D — that template is the single canonical standard for all automated issue creation. <!-- Added: forge#293 -->
48
166
 
@@ -58,14 +176,40 @@ MILESTONE_TITLE=$(gh issue view {NUMBER} {GH_FLAG} --json milestone --jq '.miles
58
176
 
59
177
  ---
60
178
 
61
- ## Phase D1: Resume Check
179
+ ## Phase D1: Guards and Resume Check
180
+
181
+ Evaluate in this order. Each BLOCKED exit runs the Blocked exit procedure.
182
+
183
+ **Guard 1 — issue is itself a sub-issue**: an issue whose body carries a `**Parent**: #N` reference is a child created by an earlier decomposition and is never decomposed again (prevents recursive decomposition). <!-- Added: forge#2379 -->
184
+
185
+ ```bash
186
+ ISSUE_BODY=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body')
187
+ PARENT_REF=$(printf '%s\n' "$ISSUE_BODY" | grep -E '^\*\*Parent\*\*: #[0-9]+' | head -1)
188
+ [ -n "$PARENT_REF" ] && echo "SUB_ISSUE_GUARD: ${PARENT_REF}"
189
+ ```
190
+
191
+ If `PARENT_REF` is non-empty → BLOCKED, blocker: "Issue #{NUMBER} is a sub-issue (${PARENT_REF}) — sub-issues are never decomposed again; run /work-on build on it directly".
192
+
193
+ **Guard 2 — investigation report present**: If the FORGE:INVESTIGATOR comment is absent → BLOCKED, blocker: "No investigation report found — run investigate first".
194
+
195
+ **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".
196
+
197
+ **Resume check**:
62
198
 
63
199
  ```bash
64
200
  gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
65
- --jq '.[] | select(.body | contains("FORGE:DECOMPOSED")) | .body'
201
+ --jq '[.[] | select(.body | contains("<!-- FORGE:DECOMPOSED -->"))] | last // empty | {url: .html_url, body: .body}'
202
+ ```
203
+
204
+ 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`:
205
+
206
+ ```bash
207
+ # $DECOMP_BODY is the .body of the comment found above
208
+ SUB_LIST=$(printf '%s\n' "$DECOMP_BODY" | sed -nE 's/^- #([0-9]+).*/\1/p' | paste -sd, - | sed 's/,/, /g')
209
+ echo "sub_issues: [${SUB_LIST}]"
66
210
  ```
67
211
 
68
- - If `<!-- FORGE:DECOMPOSED -->` comment exists → decomposition already complete. EXIT with `DECOMPOSE_RESULT: status: ALREADY_DONE`.
212
+ Run Phase D6 (idempotent label re-assert), then EXIT with `DECOMPOSE_RESULT: status: ALREADY_DONE`, `sub_issues: [${SUB_LIST}]`, `comment_url` from the comment.
69
213
 
70
214
  ---
71
215
 
@@ -91,24 +235,26 @@ If `GIST_URLS` is non-empty, a `## Prior Investigation` section will be appended
91
235
 
92
236
  ## Phase D2: Design Sub-Issues
93
237
 
94
- From the Decomposition Assessment in the investigation report, extract:
238
+ From the Decomposition Assessment in the investigation report (adjusted for any owner override detected in Phase D0), extract:
95
239
  1. Sub-issue titles (in dependency order — independent issues first)
96
240
  2. Dependencies between sub-issues (if issue B depends on issue A, A is created first)
97
241
  3. Brief description for each sub-issue body
98
242
 
99
243
  For each sub-issue, prepare:
100
- - **Title**: from investigation report's proposed sub-issue title, including the final `{fix|feat|refactor}: ` prefix used in Phase D3
244
+ - **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
101
245
  - **Body**: brief description of scope + `**Parent**: #{NUMBER}` + dependency note if applicable
102
246
  - **Labels**: inherit priority label (P0/P1/P2) from parent; do NOT copy workflow labels
103
247
  - **Milestone**: same milestone title as parent (if parent has one)
104
248
 
249
+ Sub-issues made obsolete by an owner override are not created; record each as `SKIPPED_NOTES` (title + reason) for the Phase D5 comment.
250
+
105
251
  **Ordering rule**: Create independent sub-issues first. If sub-issue B depends on A, create A first so its issue number can be referenced in B's body.
106
252
 
107
253
  ---
108
254
 
109
255
  ## Phase D2.5: Equivalent Set Check
110
256
 
111
- Before creating any sub-issue, check whether the complete planned title set already exists for this parent. This covers an interrupted earlier decomposition: its child issues may exist even though it never reached the `FORGE:DECOMPOSED` comment checked in D1.
257
+ Before creating any sub-issue, check whether the complete planned title set already exists for this parent. This covers an interrupted earlier decomposition: its child issues may exist even though it never reached the `FORGE:DECOMPOSED` comment checked in Phase D1.
112
258
 
113
259
  ```bash
114
260
  # SUB_TITLES contains the final, ordered titles prepared in D2.
@@ -124,14 +270,19 @@ CANONICAL_SUB_ISSUES=$(jq --arg parent "**Parent**: #{NUMBER}" --argjson expecte
124
270
  | ($canonical | map(.title) | sort) as $actual
125
271
  | if $actual == $expected then $canonical else empty end
126
272
  ' <<< "$PARENT_SUB_ISSUES")
273
+
274
+ # Plain number list for the result block, e.g. "101, 102"
275
+ CANONICAL_NUMBERS=$(printf '%s' "$CANONICAL_SUB_ISSUES" | jq -r 'map(.number) | sort | map(tostring) | join(", ")' 2>/dev/null)
127
276
  ```
128
277
 
129
- 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 and EXIT with:
278
+ 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:
130
279
 
131
280
  ```
132
281
  DECOMPOSE_RESULT:
133
282
  status: ALREADY_DONE
134
- sub_issues: {CANONICAL_SUB_ISSUES}
283
+ sub_issues: [${CANONICAL_NUMBERS}]
284
+ comment_url:
285
+ blocker:
135
286
  ```
136
287
 
137
288
  If no exact title-set match exists, continue. A partial set is not equivalent: let `/issue` dedup protect its existing children while creating only the missing work.
@@ -140,12 +291,19 @@ If no exact title-set match exists, continue. A partial set is not equivalent: l
140
291
 
141
292
  ## Phase D3: Create Sub-Issues
142
293
 
143
- 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 — the standalone pre-check below is removed; dedup is now enforced inside the create-hook on every call, with no bypass path. <!-- Changed: forge#2086 — route through /issue create-hook -->
294
+ 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 -->
295
+
296
+ Initialise once before the loop: `CREATED_NUMBERS=""` (comma-separated, for the result block) and `SKIPPED_NOTES=""`.
144
297
 
145
- **Compose the sub-issue body to a temp file** (avoids quoting issues when passed as `--body-file`):
298
+ **Sanitise the title and compose the sub-issue body to a temp file** (avoids quoting issues when passed as `--body-file`):
146
299
 
147
300
  ```bash
148
301
  SUB_TITLE="{fix|feat|refactor}: {SUB_ISSUE_TITLE}"
302
+ # Defense-in-depth: /issue's arg tokenizer (commands/issue.md, forge#2094) uses
303
+ # an xargs-based tokenizer that never expands backtick/$(...) substitution, so
304
+ # this is no longer required for safety — but strip it anyway so the raw title
305
+ # stays readable if it round-trips through any other eval-based consumer.
306
+ SUB_TITLE=$(printf '%s' "$SUB_TITLE" | tr '`' "'" | sed 's/\$(/$ (/g')
149
307
  SUB_BODY_FILE="$(mktemp)"
150
308
  cat > "$SUB_BODY_FILE" <<'SUB_BODY_EOF'
151
309
  ## Problem
@@ -175,23 +333,52 @@ Files that need changes:
175
333
  SUB_BODY_EOF
176
334
  ```
177
335
 
336
+ **Build the milestone arg** (only when the parent has a milestone — a parent with no milestone must not receive `--milestone ""`):
337
+
338
+ ```bash
339
+ MILESTONE_ARG=""
340
+ [ -n "$MILESTONE_TITLE" ] && MILESTONE_ARG=" --milestone \"${MILESTONE_TITLE}\""
341
+ ```
342
+
178
343
  **Invoke `/issue` in programmatic mode**:
179
344
 
180
345
  ```
181
- ISSUE_SKILL_OUTPUT=$(Skill(skill="issue", args="--title \"${SUB_TITLE}\" --body-file \"${SUB_BODY_FILE}\" --label \"{PRIORITY_LABEL}\" --milestone \"{MILESTONE_TITLE}\""))
346
+ ISSUE_SKILL_OUTPUT=$(Skill(skill="{FORGE_SKILL_PREFIX}issue", args="--title \"${SUB_TITLE}\" --body-file \"${SUB_BODY_FILE}\" --label \"{PRIORITY_LABEL}\"${MILESTONE_ARG}"))
182
347
  ```
183
348
 
349
+ If the skill is not found → BLOCKED, blocker: "skill not found: issue".
350
+
184
351
  `/issue` runs Phase 2D dedup, Phase 3F body validation, then creates the issue (Phase 4) — no separate pre-check needed on this side.
185
352
 
186
- **Extract the created sub-issue number from the Skill output** (see `commands/issue.md` Phase 4C/4E — it echoes `Created: {url}` and reports `**#{NUMBER}**: {title}`):
353
+ If the `--milestone` flag fails (milestone not found by name): re-invoke without `${MILESTONE_ARG}`, set `MILESTONE_SKIPPED=1`, and note in the FORGE:DECOMPOSED comment that milestone assignment was skipped.
354
+
355
+ **Extract the created sub-issue number from the Skill output** (see `commands/issue.md` Phase 4C/4E — it echoes `Created: {url}` and reports `**#{NUMBER}**: {title}`). If parsing yields nothing, fall back to an exact-title search scoped to this parent (covers `/issue` output not being captured by the Skill harness):
187
356
 
188
357
  ```bash
189
358
  # Match either the "Created: {url}" line (extract trailing /issues/N) or the "**#{NUMBER}**" bold report line.
190
359
  SUB_NUMBER=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE 'issues/[0-9]+' | head -1 | grep -oE '[0-9]+')
191
360
  [ -z "$SUB_NUMBER" ] && SUB_NUMBER=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE '\*\*#[0-9]+\*\*' | head -1 | grep -oE '[0-9]+')
192
361
 
362
+ # Fallback: exact-title search, restricted to issues whose body carries this parent's reference
363
+ # (so a pre-existing near-duplicate that /issue dedup stopped on is never mistaken for the new child).
364
+ # Retries absorb GitHub Search API indexing lag.
365
+ if [ -z "$SUB_NUMBER" ]; then
366
+ for _resolve_attempt in 1 2 3; do
367
+ SUB_NUMBER=$(gh issue list {GH_FLAG} --search "in:title \"${SUB_TITLE}\"" --state open --limit 10 --json number,title,body 2>/dev/null \
368
+ | jq -r --arg t "$SUB_TITLE" --arg p "**Parent**: #{NUMBER}" '[.[] | select(.title == $t and (.body | contains($p)))][0].number // empty')
369
+ [ -n "$SUB_NUMBER" ] && break
370
+ sleep 2
371
+ done
372
+ fi
373
+
374
+ rm -f "$SUB_BODY_FILE"
375
+
193
376
  if [ -z "$SUB_NUMBER" ]; then
194
377
  echo "WARNING: /issue did not report a created issue number for sub-issue '${SUB_TITLE}' — likely a Phase 2D dedup STOP (near-duplicate found) or a usage error. Skipping this sub-issue: do not reference it in the parent tracker or in dependent sub-issue bodies. Review the Skill output above and, if a near-duplicate exists, comment on the existing issue instead."
378
+ SKIPPED_NOTES="${SKIPPED_NOTES}
379
+ - ${SUB_TITLE} — not created (dedup stop or /issue error)"
380
+ else
381
+ CREATED_NUMBERS="${CREATED_NUMBERS:+${CREATED_NUMBERS}, }${SUB_NUMBER}"
195
382
  fi
196
383
  ```
197
384
 
@@ -221,9 +408,7 @@ Investigation findings from the parent issue are available as Knowledge Gists:
221
408
  fi
222
409
  ```
223
410
 
224
- Capture the created issue number from the output URL for the tracker checklist.
225
-
226
- If `--milestone` flag fails (milestone not found by name): omit the flag and note in the FORGE:DECOMPOSED comment that milestone assignment was skipped.
411
+ 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".
227
412
 
228
413
  ---
229
414
 
@@ -245,7 +430,7 @@ TRACKER="
245
430
  - [ ] #{SUB_ISSUE_2_NUMBER} — {SUB_ISSUE_2_TITLE} _(depends on #{SUB_ISSUE_1_NUMBER})_
246
431
  ..."
247
432
 
248
- gh issue edit {NUMBER} {GH_FLAG} --body "${CURRENT_BODY}${TRACKER}"
433
+ gh issue edit {NUMBER} {GH_FLAG} --body "${CURRENT_BODY}${TRACKER}" # allowlist:check-command-side-effects
249
434
  ```
250
435
 
251
436
  ---
@@ -259,8 +444,11 @@ SHOW_ATTRIBUTION=$(yq '.branding.show_attribution // "true"' forge.yaml 2>/dev/n
259
444
  > Pipeline powered by [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock)"
260
445
  ```
261
446
 
447
+ 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`.
448
+
262
449
  ```bash
263
- gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DECOMPOSED -->
450
+ [ "${DRY_RUN:-false}" = "true" ] && { echo "[DRY_RUN] would post the comment below"; exit 0; }
451
+ COMMENT_URL=$(gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DECOMPOSED -->
264
452
  ## Decomposition Complete
265
453
 
266
454
  This issue has been broken into sub-issues. Each sub-issue runs through its own /work-on pipeline independently.
@@ -270,21 +458,39 @@ This issue has been broken into sub-issues. Each sub-issue runs through its own
270
458
  {for each sub-issue, in dependency order:}
271
459
  - #{SUB_ISSUE_NUMBER}: {TITLE}{if has dependency: _(depends on #{DEP_NUMBER})_}
272
460
 
461
+ {if SKIPPED_NOTES non-empty:}
462
+ ### Skipped Sub-Issues
463
+
464
+ ${SKIPPED_NOTES}
465
+
466
+ {if owner override detected:}
467
+ ### Owner Override
468
+
469
+ {override direction and how it changed the sub-issue scopes/order}
470
+
273
471
  ### Decomposition Rationale
274
472
 
275
473
  {brief summary of why this issue was decomposed and the dependency ordering chosen}
276
474
  ${ATTRIBUTION_LINE}
277
- <!-- FORGE:DECOMPOSED:COMPLETE -->"
475
+ <!-- FORGE:DECOMPOSED:COMPLETE -->") # allowlist:check-command-side-effects
278
476
  ```
279
477
 
280
478
  ---
281
479
 
282
480
  ## Phase D6: Update Labels
283
481
 
482
+ 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.
483
+
284
484
  ```bash
285
- gh issue edit {NUMBER} {GH_FLAG} \
286
- --add-label "workflow:decomposed" \
287
- --remove-label "workflow:ready-to-build,workflow:building,workflow:investigating" 2>/dev/null || true
485
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
486
+ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
487
+ case "$TIER" in
488
+ adaptive|universal) run bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} decomposed ;;
489
+ prose)
490
+ run gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:decomposed" \
491
+ --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid" 2>/dev/null || true
492
+ ;;
493
+ esac
288
494
  ```
289
495
 
290
496
  ---
@@ -293,15 +499,7 @@ gh issue edit {NUMBER} {GH_FLAG} \
293
499
 
294
500
  Decomposition is a terminal route for the parent issue. Each sub-issue will be picked up separately by /work-on.
295
501
 
296
- Return structured output to the router:
297
-
298
- ```
299
- DECOMPOSE_RESULT:
300
- status: COMPLETE | ALREADY_DONE | BLOCKED
301
- sub_issues: [{number}, ...]
302
- comment_url: {url of FORGE:DECOMPOSED comment}
303
- blocker: {description if status=BLOCKED}
304
- ```
502
+ 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}`.
305
503
 
306
504
  **Router behavior after DECOMPOSE_RESULT**: `break` — do not continue to build/review/close for the parent issue.
307
505
 
@@ -314,7 +512,7 @@ The subcommand writes its results to GitHub (FORGE:DECOMPOSED comment + sub-issu
314
512
  ```
315
513
  DECOMPOSE_RESULT:
316
514
  status: COMPLETE | ALREADY_DONE | BLOCKED
317
- sub_issues: [{number}, ...]
515
+ sub_issues: [N, N, ...]
318
516
  comment_url: {url of posted FORGE:DECOMPOSED comment}
319
517
  blocker: {description if status=BLOCKED}
320
518
  ```