forgedock 1.8.1 → 1.8.3

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 (90) 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/design-bench.md +1 -0
  11. package/commands/design-render-critique-loop.md +1 -0
  12. package/commands/design.md +1 -0
  13. package/commands/forge-stats.md +1 -0
  14. package/commands/geo-audit.md +3 -1
  15. package/commands/incident-response.md +3 -1
  16. package/commands/issue.md +4 -2
  17. package/commands/milestone.md +8 -6
  18. package/commands/orchestrate/config.md +3 -2
  19. package/commands/orchestrate/phase-1-resolve.md +57 -12
  20. package/commands/orchestrate/phase-2-triage.md +1 -0
  21. package/commands/orchestrate/phase-2.5-synthesis.md +1 -0
  22. package/commands/orchestrate/phase-3-dependency.md +17 -5
  23. package/commands/orchestrate/phase-4-execution.md +625 -78
  24. package/commands/orchestrate/phase-5-cleanup.md +5 -2
  25. package/commands/orchestrate/phase-6-report.md +8 -3
  26. package/commands/orchestrate/safety.md +1 -0
  27. package/commands/orchestrate.md +13 -9
  28. package/commands/pipeline-health/config.md +1 -0
  29. package/commands/pipeline-health/phase-1-context.md +1 -0
  30. package/commands/pipeline-health/phase-2-metrics.md +1 -0
  31. package/commands/pipeline-health/phase-3-analyze.md +1 -0
  32. package/commands/pipeline-health/phase-4-proposals.md +7 -4
  33. package/commands/pipeline-health/phase-5-report.md +5 -2
  34. package/commands/pipeline-health/phase-6-summary.md +1 -0
  35. package/commands/pipeline-health.md +9 -7
  36. package/commands/pipeline-resume.md +4 -2
  37. package/commands/qa-sweep.md +3 -1
  38. package/commands/quality-gate.md +4 -3
  39. package/commands/recover-orphans.md +219 -13
  40. package/commands/review-pr-agents/api.md +1 -0
  41. package/commands/review-pr-agents/auth.md +1 -0
  42. package/commands/review-pr-agents/billing.md +1 -0
  43. package/commands/review-pr-agents/concurrency.md +1 -0
  44. package/commands/review-pr-agents/database.md +1 -0
  45. package/commands/review-pr-agents/frontend.md +1 -0
  46. package/commands/review-pr-agents/infra.md +1 -0
  47. package/commands/review-pr-agents/protocols.md +1 -0
  48. package/commands/review-pr-agents/scraper.md +1 -0
  49. package/commands/review-pr-agents/security.md +1 -0
  50. package/commands/review-pr-agents/spec-cli.md +1 -0
  51. package/commands/review-pr-agents.md +10 -4
  52. package/commands/review-pr-staging.md +14 -5
  53. package/commands/review-pr.md +187 -19
  54. package/commands/rollback.md +3 -1
  55. package/commands/security-audit.md +3 -1
  56. package/commands/signal-planner.md +6 -4
  57. package/commands/sync-ecosystem.md +4 -2
  58. package/commands/test-gate.md +4 -2
  59. package/commands/upgrade-deps.md +3 -1
  60. package/commands/validate.md +3 -1
  61. package/commands/work-on/build/architect.md +82 -19
  62. package/commands/work-on/build/context.md +62 -18
  63. package/commands/work-on/build/implement.md +63 -18
  64. package/commands/work-on/build/validate.md +241 -60
  65. package/commands/work-on/build.md +345 -146
  66. package/commands/work-on/close.md +618 -246
  67. package/commands/work-on/decompose.md +240 -41
  68. package/commands/work-on/investigate.md +378 -61
  69. package/commands/work-on/remediate.md +43 -9
  70. package/commands/work-on/review.md +241 -29
  71. package/commands/work-on.md +180 -2014
  72. package/docs/CODEX.md +2 -0
  73. package/docs/CONFIG.md +11 -0
  74. package/package.json +1 -1
  75. package/scripts/build-spec-graph.mjs +11 -4
  76. package/scripts/check-spec-markers.sh +21 -1
  77. package/scripts/checkbox-sections.test.sh +8 -23
  78. package/scripts/extract-affected-files.sh +5 -2
  79. package/scripts/extract-affected-files.test.sh +27 -0
  80. package/scripts/forge-root.test.sh +176 -0
  81. package/scripts/is-docs-only.sh +31 -0
  82. package/scripts/is-docs-only.test.sh +64 -0
  83. package/scripts/lint-dispatch-prompt.sh +217 -0
  84. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  85. package/scripts/validate-spec-graph.sh +3 -2
  86. package/scripts/verify-phase-trail.sh +282 -0
  87. package/scripts/verify-phase-trail.test.sh +298 -0
  88. package/scripts/wait-ci-green.sh +98 -0
  89. package/scripts/wait-ci-green.test.sh +99 -0
  90. package/commands/work-on-monolithic.md +0 -326
@@ -1,20 +1,24 @@
1
1
  ---
2
+ user-invocable: false
2
3
  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]"
4
+ context: fork
5
+ argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\""
4
6
  ---
5
7
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
8
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
7
9
 
8
10
  # work-on/decompose — Decomposition Subcommand
9
11
 
12
+ > **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>".
13
+
10
14
  **Input**: $ARGUMENTS
11
15
 
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.
16
+ **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.
17
+ **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
18
 
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.
19
+ **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
20
 
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 -->
21
+ **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
22
  **NEVER use plan mode (EnterPlanMode).**
19
23
 
20
24
  ---
@@ -23,8 +27,111 @@ argument-hint: "[issue number] [--repo GH_REPO] [--gh-flag GH_FLAG]"
23
27
 
24
28
  Parse from $ARGUMENTS:
25
29
  - `{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}`)
30
+ - `--repo {GH_REPO}` — GitHub repo, e.g. `{owner}/{repo}` (required)
31
+ - `--gh-flag {GH_FLAG}` — gh CLI repo flag, e.g. `-R {owner}/{repo}` (required)
32
+
33
+ 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).
34
+
35
+ ---
36
+
37
+ ## Script resolution
38
+
39
+ **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`.
40
+
41
+ ```bash
42
+ # Canonical script resolution (keep byte-identical across specs; guarded by scripts/forge-root.test.sh).
43
+ # Shell state does NOT persist between Bash tool calls: include this whole block at the top of
44
+ # every command that uses resolve_script or FORGE_ROOT.
45
+ REPO_PATH="${REPO_PATH:-$(yq '.paths.root // ""' forge.yaml 2>/dev/null)}"
46
+ [ -n "$REPO_PATH" ] && [ "$REPO_PATH" != "null" ] || REPO_PATH="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
47
+ ADAPTIVE_DIR_RAW="${REPO_PATH}/$(yq '.adaptive_scripts.directory // ".forgedock/scripts"' forge.yaml 2>/dev/null || echo '.forgedock/scripts')"
48
+ ADAPTIVE_DIR=$(realpath -m "$ADAPTIVE_DIR_RAW" 2>/dev/null || echo "$ADAPTIVE_DIR_RAW")
49
+ ADAPTIVE_ENABLED=$(yq '.adaptive_scripts.enabled // "true"' forge.yaml 2>/dev/null || echo 'true')
50
+ # Bounds check: reject adaptive_scripts.directory values that escape the repo root.
51
+ # Normalize REPO_PATH the same way ADAPTIVE_DIR is normalized (realpath -m) so a trailing
52
+ # slash in paths.root does not inject a '//' into the glob and trigger a false positive.
53
+ REPO_PATH_NORM=$(realpath -m "$REPO_PATH" 2>/dev/null || echo "$REPO_PATH")
54
+ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
55
+ echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
56
+ ADAPTIVE_ENABLED=false
57
+ fi
58
+ # ForgeDock's own install root (holds scripts/ and commands/), resolved ONCE by the canonical bootstrap
59
+ # below — never the consumer repo: plugin installs set neither FORGE_HOME nor FORGEDOCK_HOME, and a
60
+ # repo-relative fallback would execute (or miss) a same-named script controlled by the consumer repo.
61
+ # Resolution: $FORGEDOCK_HOME (authoritative when set) > $FORGE_HOME > $CLAUDE_PLUGIN_ROOT > the
62
+ # ~/.claude/commands symlink target > the Claude Code plugin cache/marketplace dirs. <!-- forge#3098 -->
63
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
64
+ FORGE_ROOT=""
65
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$FORGEDOCK_HOME" in /*) FORGE_ROOT="$FORGEDOCK_HOME" ;; esac; else
66
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match).
67
+ _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")")"
68
+ # 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)
69
+ _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-)"
70
+ _m="$(find -L "$HOME/.claude/plugins/marketplaces" -mindepth 1 -maxdepth 1 -type d -iname '*forgedock*' 2>/dev/null)"
71
+ # '${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.
72
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_v" "$_m")"
73
+ while IFS= read -r _c; do
74
+ 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
75
+ done <<< "$_k"
76
+ fi
77
+ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped, prose tier
78
+ # NOTE: never resolve this via `which` or `find` — universal scripts are
79
+ # install-relative, not installed on $PATH, so a PATH lookup always misses.
80
+ # FORGE_ROOT (above) is the deterministic resolution; there is NO repo-path fallback.
81
+ # Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to
82
+ # locate pipeline scripts under any circumstances: if UNIVERSAL_DIR/${operation}.sh
83
+ # does not exist, resolve_script() falls through to Tier 4 (prose) below,
84
+ # which is always safe and available. A missing script is never a reason
85
+ # to search the filesystem. <!-- Added: forge#1984 -->
86
+
87
+ resolve_script() {
88
+ local operation="$1"
89
+ # Tier 2: per-repo adaptive (skip if disabled)
90
+ if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
91
+ echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
92
+ return
93
+ fi
94
+ # Tier 3: universal script
95
+ if [ -n "$UNIVERSAL_DIR" ] && [ -f "${UNIVERSAL_DIR}/${operation}.sh" ]; then
96
+ echo "universal:${UNIVERSAL_DIR}/${operation}.sh"
97
+ return
98
+ fi
99
+ # Tier 4: prose fallback
100
+ echo "prose:"
101
+ }
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Result emission
107
+
108
+ Every exit path of this skill — COMPLETE, ALREADY_DONE, every guard, every failure — ends by printing exactly one block as the final reply:
109
+
110
+ ```
111
+ DECOMPOSE_RESULT:
112
+ status: COMPLETE | ALREADY_DONE | BLOCKED
113
+ sub_issues: [N, N, ...]
114
+ comment_url: {url of the FORGE:DECOMPOSED comment; empty if none}
115
+ blocker: {description if status=BLOCKED, else empty}
116
+ ```
117
+
118
+ `sub_issues` is a plain list of issue numbers on every path (`[]` when none), never objects.
119
+
120
+ ### Blocked exit
121
+
122
+ 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:
123
+
124
+ ```bash
125
+ BLOCKER="{reason}"
126
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
127
+ run gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human" --remove-label "workflow:decomposed" 2>/dev/null || true
128
+ run gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DECOMPOSE_BLOCKED -->
129
+ ## Decomposition Blocked
130
+
131
+ ${BLOCKER}
132
+
133
+ Human attention required (\`needs-human\`)."
134
+ ```
28
135
 
29
136
  ---
30
137
 
@@ -40,9 +147,21 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
40
147
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body'
41
148
  ```
42
149
 
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"
150
+ **MANDATORY — Owner override detection**: After reading the investigation comment, read ALL other comments on the issue to check for owner override signals:
151
+
152
+ ```bash
153
+ gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
154
+ --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR") | not) | {author: .user.login, body: .body}'
155
+ ```
156
+
157
+ 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):
158
+
159
+ 1. **Document the override**: Note which direction the owner is steering (e.g., "remove the feature" vs. investigation's "keep with warnings")
160
+ 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
+ 3. **If override makes a sub-issue obsolete**: Skip creating it. Note the skip reason in the decomposition comment (Phase D5).
162
+ 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.
163
+
164
+ **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
165
 
47
166
  > **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
167
 
@@ -58,14 +177,40 @@ MILESTONE_TITLE=$(gh issue view {NUMBER} {GH_FLAG} --json milestone --jq '.miles
58
177
 
59
178
  ---
60
179
 
61
- ## Phase D1: Resume Check
180
+ ## Phase D1: Guards and Resume Check
181
+
182
+ Evaluate in this order. Each BLOCKED exit runs the Blocked exit procedure.
183
+
184
+ **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 -->
185
+
186
+ ```bash
187
+ ISSUE_BODY=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body')
188
+ PARENT_REF=$(printf '%s\n' "$ISSUE_BODY" | grep -E '^\*\*Parent\*\*: #[0-9]+' | head -1)
189
+ [ -n "$PARENT_REF" ] && echo "SUB_ISSUE_GUARD: ${PARENT_REF}"
190
+ ```
191
+
192
+ 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".
193
+
194
+ **Guard 2 — investigation report present**: If the FORGE:INVESTIGATOR comment is absent → BLOCKED, blocker: "No investigation report found — run investigate first".
195
+
196
+ **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".
197
+
198
+ **Resume check**:
62
199
 
63
200
  ```bash
64
201
  gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
65
- --jq '.[] | select(.body | contains("FORGE:DECOMPOSED")) | .body'
202
+ --jq '[.[] | select(.body | contains("<!-- FORGE:DECOMPOSED -->"))] | last // empty | {url: .html_url, body: .body}'
203
+ ```
204
+
205
+ 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`:
206
+
207
+ ```bash
208
+ # $DECOMP_BODY is the .body of the comment found above
209
+ SUB_LIST=$(printf '%s\n' "$DECOMP_BODY" | sed -nE 's/^- #([0-9]+).*/\1/p' | paste -sd, - | sed 's/,/, /g')
210
+ echo "sub_issues: [${SUB_LIST}]"
66
211
  ```
67
212
 
68
- - If `<!-- FORGE:DECOMPOSED -->` comment exists → decomposition already complete. EXIT with `DECOMPOSE_RESULT: status: ALREADY_DONE`.
213
+ 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
214
 
70
215
  ---
71
216
 
@@ -91,24 +236,26 @@ If `GIST_URLS` is non-empty, a `## Prior Investigation` section will be appended
91
236
 
92
237
  ## Phase D2: Design Sub-Issues
93
238
 
94
- From the Decomposition Assessment in the investigation report, extract:
239
+ From the Decomposition Assessment in the investigation report (adjusted for any owner override detected in Phase D0), extract:
95
240
  1. Sub-issue titles (in dependency order — independent issues first)
96
241
  2. Dependencies between sub-issues (if issue B depends on issue A, A is created first)
97
242
  3. Brief description for each sub-issue body
98
243
 
99
244
  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
245
+ - **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
246
  - **Body**: brief description of scope + `**Parent**: #{NUMBER}` + dependency note if applicable
102
247
  - **Labels**: inherit priority label (P0/P1/P2) from parent; do NOT copy workflow labels
103
248
  - **Milestone**: same milestone title as parent (if parent has one)
104
249
 
250
+ Sub-issues made obsolete by an owner override are not created; record each as `SKIPPED_NOTES` (title + reason) for the Phase D5 comment.
251
+
105
252
  **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
253
 
107
254
  ---
108
255
 
109
256
  ## Phase D2.5: Equivalent Set Check
110
257
 
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.
258
+ 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
259
 
113
260
  ```bash
114
261
  # SUB_TITLES contains the final, ordered titles prepared in D2.
@@ -124,14 +271,19 @@ CANONICAL_SUB_ISSUES=$(jq --arg parent "**Parent**: #{NUMBER}" --argjson expecte
124
271
  | ($canonical | map(.title) | sort) as $actual
125
272
  | if $actual == $expected then $canonical else empty end
126
273
  ' <<< "$PARENT_SUB_ISSUES")
274
+
275
+ # Plain number list for the result block, e.g. "101, 102"
276
+ CANONICAL_NUMBERS=$(printf '%s' "$CANONICAL_SUB_ISSUES" | jq -r 'map(.number) | sort | map(tostring) | join(", ")' 2>/dev/null)
127
277
  ```
128
278
 
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:
279
+ 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
280
 
131
281
  ```
132
282
  DECOMPOSE_RESULT:
133
283
  status: ALREADY_DONE
134
- sub_issues: {CANONICAL_SUB_ISSUES}
284
+ sub_issues: [${CANONICAL_NUMBERS}]
285
+ comment_url:
286
+ blocker:
135
287
  ```
136
288
 
137
289
  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 +292,19 @@ If no exact title-set match exists, continue. A partial set is not equivalent: l
140
292
 
141
293
  ## Phase D3: Create Sub-Issues
142
294
 
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 -->
295
+ 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 -->
296
+
297
+ Initialise once before the loop: `CREATED_NUMBERS=""` (comma-separated, for the result block) and `SKIPPED_NOTES=""`.
144
298
 
145
- **Compose the sub-issue body to a temp file** (avoids quoting issues when passed as `--body-file`):
299
+ **Sanitise the title and compose the sub-issue body to a temp file** (avoids quoting issues when passed as `--body-file`):
146
300
 
147
301
  ```bash
148
302
  SUB_TITLE="{fix|feat|refactor}: {SUB_ISSUE_TITLE}"
303
+ # Defense-in-depth: /issue's arg tokenizer (commands/issue.md, forge#2094) uses
304
+ # an xargs-based tokenizer that never expands backtick/$(...) substitution, so
305
+ # this is no longer required for safety — but strip it anyway so the raw title
306
+ # stays readable if it round-trips through any other eval-based consumer.
307
+ SUB_TITLE=$(printf '%s' "$SUB_TITLE" | tr '`' "'" | sed 's/\$(/$ (/g')
149
308
  SUB_BODY_FILE="$(mktemp)"
150
309
  cat > "$SUB_BODY_FILE" <<'SUB_BODY_EOF'
151
310
  ## Problem
@@ -175,23 +334,52 @@ Files that need changes:
175
334
  SUB_BODY_EOF
176
335
  ```
177
336
 
337
+ **Build the milestone arg** (only when the parent has a milestone — a parent with no milestone must not receive `--milestone ""`):
338
+
339
+ ```bash
340
+ MILESTONE_ARG=""
341
+ [ -n "$MILESTONE_TITLE" ] && MILESTONE_ARG=" --milestone \"${MILESTONE_TITLE}\""
342
+ ```
343
+
178
344
  **Invoke `/issue` in programmatic mode**:
179
345
 
180
346
  ```
181
- ISSUE_SKILL_OUTPUT=$(Skill(skill="issue", args="--title \"${SUB_TITLE}\" --body-file \"${SUB_BODY_FILE}\" --label \"{PRIORITY_LABEL}\" --milestone \"{MILESTONE_TITLE}\""))
347
+ ISSUE_SKILL_OUTPUT=$(Skill(skill="{FORGE_SKILL_PREFIX}issue", args="--title \"${SUB_TITLE}\" --body-file \"${SUB_BODY_FILE}\" --label \"{PRIORITY_LABEL}\"${MILESTONE_ARG}"))
182
348
  ```
183
349
 
350
+ If the skill is not found → BLOCKED, blocker: "skill not found: issue".
351
+
184
352
  `/issue` runs Phase 2D dedup, Phase 3F body validation, then creates the issue (Phase 4) — no separate pre-check needed on this side.
185
353
 
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}`):
354
+ 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.
355
+
356
+ **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
357
 
188
358
  ```bash
189
359
  # Match either the "Created: {url}" line (extract trailing /issues/N) or the "**#{NUMBER}**" bold report line.
190
360
  SUB_NUMBER=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE 'issues/[0-9]+' | head -1 | grep -oE '[0-9]+')
191
361
  [ -z "$SUB_NUMBER" ] && SUB_NUMBER=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE '\*\*#[0-9]+\*\*' | head -1 | grep -oE '[0-9]+')
192
362
 
363
+ # Fallback: exact-title search, restricted to issues whose body carries this parent's reference
364
+ # (so a pre-existing near-duplicate that /issue dedup stopped on is never mistaken for the new child).
365
+ # Retries absorb GitHub Search API indexing lag.
366
+ if [ -z "$SUB_NUMBER" ]; then
367
+ for _resolve_attempt in 1 2 3; do
368
+ SUB_NUMBER=$(gh issue list {GH_FLAG} --search "in:title \"${SUB_TITLE}\"" --state open --limit 10 --json number,title,body 2>/dev/null \
369
+ | jq -r --arg t "$SUB_TITLE" --arg p "**Parent**: #{NUMBER}" '[.[] | select(.title == $t and (.body | contains($p)))][0].number // empty')
370
+ [ -n "$SUB_NUMBER" ] && break
371
+ sleep 2
372
+ done
373
+ fi
374
+
375
+ rm -f "$SUB_BODY_FILE"
376
+
193
377
  if [ -z "$SUB_NUMBER" ]; then
194
378
  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."
379
+ SKIPPED_NOTES="${SKIPPED_NOTES}
380
+ - ${SUB_TITLE} — not created (dedup stop or /issue error)"
381
+ else
382
+ CREATED_NUMBERS="${CREATED_NUMBERS:+${CREATED_NUMBERS}, }${SUB_NUMBER}"
195
383
  fi
196
384
  ```
197
385
 
@@ -221,9 +409,7 @@ Investigation findings from the parent issue are available as Knowledge Gists:
221
409
  fi
222
410
  ```
223
411
 
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.
412
+ 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
413
 
228
414
  ---
229
415
 
@@ -245,7 +431,7 @@ TRACKER="
245
431
  - [ ] #{SUB_ISSUE_2_NUMBER} — {SUB_ISSUE_2_TITLE} _(depends on #{SUB_ISSUE_1_NUMBER})_
246
432
  ..."
247
433
 
248
- gh issue edit {NUMBER} {GH_FLAG} --body "${CURRENT_BODY}${TRACKER}"
434
+ gh issue edit {NUMBER} {GH_FLAG} --body "${CURRENT_BODY}${TRACKER}" # allowlist:check-command-side-effects
249
435
  ```
250
436
 
251
437
  ---
@@ -259,8 +445,11 @@ SHOW_ATTRIBUTION=$(yq '.branding.show_attribution // "true"' forge.yaml 2>/dev/n
259
445
  > Pipeline powered by [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock)"
260
446
  ```
261
447
 
448
+ 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`.
449
+
262
450
  ```bash
263
- gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DECOMPOSED -->
451
+ [ "${DRY_RUN:-false}" = "true" ] && { echo "[DRY_RUN] would post the comment below"; exit 0; }
452
+ COMMENT_URL=$(gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DECOMPOSED -->
264
453
  ## Decomposition Complete
265
454
 
266
455
  This issue has been broken into sub-issues. Each sub-issue runs through its own /work-on pipeline independently.
@@ -270,21 +459,39 @@ This issue has been broken into sub-issues. Each sub-issue runs through its own
270
459
  {for each sub-issue, in dependency order:}
271
460
  - #{SUB_ISSUE_NUMBER}: {TITLE}{if has dependency: _(depends on #{DEP_NUMBER})_}
272
461
 
462
+ {if SKIPPED_NOTES non-empty:}
463
+ ### Skipped Sub-Issues
464
+
465
+ ${SKIPPED_NOTES}
466
+
467
+ {if owner override detected:}
468
+ ### Owner Override
469
+
470
+ {override direction and how it changed the sub-issue scopes/order}
471
+
273
472
  ### Decomposition Rationale
274
473
 
275
474
  {brief summary of why this issue was decomposed and the dependency ordering chosen}
276
475
  ${ATTRIBUTION_LINE}
277
- <!-- FORGE:DECOMPOSED:COMPLETE -->"
476
+ <!-- FORGE:DECOMPOSED:COMPLETE -->") # allowlist:check-command-side-effects
278
477
  ```
279
478
 
280
479
  ---
281
480
 
282
481
  ## Phase D6: Update Labels
283
482
 
483
+ 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.
484
+
284
485
  ```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
486
+ run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
487
+ RESOLUTION=$(resolve_script 'transition-label'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
488
+ case "$TIER" in
489
+ adaptive|universal) run bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} decomposed ;;
490
+ prose)
491
+ run gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:decomposed" \
492
+ --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid" 2>/dev/null || true
493
+ ;;
494
+ esac
288
495
  ```
289
496
 
290
497
  ---
@@ -293,15 +500,7 @@ gh issue edit {NUMBER} {GH_FLAG} \
293
500
 
294
501
  Decomposition is a terminal route for the parent issue. Each sub-issue will be picked up separately by /work-on.
295
502
 
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
- ```
503
+ 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
504
 
306
505
  **Router behavior after DECOMPOSE_RESULT**: `break` — do not continue to build/review/close for the parent issue.
307
506
 
@@ -314,7 +513,7 @@ The subcommand writes its results to GitHub (FORGE:DECOMPOSED comment + sub-issu
314
513
  ```
315
514
  DECOMPOSE_RESULT:
316
515
  status: COMPLETE | ALREADY_DONE | BLOCKED
317
- sub_issues: [{number}, ...]
516
+ sub_issues: [N, N, ...]
318
517
  comment_url: {url of posted FORGE:DECOMPOSED comment}
319
518
  blocker: {description if status=BLOCKED}
320
519
  ```