forgedock 1.10.1 → 1.10.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.
@@ -2286,7 +2286,7 @@ done
2286
2286
  run_in_background=true,
2287
2287
  prompt="You are remediating GitHub PR #{GATING_PR} for the {PROJECT_NAME} project (repo: {GH_REPO}), which is currently held at `needs-human` on its linked issue #{PRED}.
2288
2288
 
2289
- **YOUR MISSION**: Invoke `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{GATING_PR} --remediate --issue {PRED} --repo {GH_REPO} --gh-flag {GH_FLAG}')` and let it run to completion. This is a self-contained flow: it classifies the gate first, replaces `needs-human` with `workflow:in-review` only for fixable remediation, checks out the PR branch, fixes any fixable review findings, re-reviews, and either auto-lands the PR, holds it at `workflow:awaiting-merge` for a human, re-escalates back to `needs-human`, or reports the block as policy-level and unfixable. Do NOT intervene manually — do not run raw git/gh commands yourself.
2289
+ **YOUR MISSION**: Invoke `Skill(skill='{FORGE_SKILL_PREFIX}work-on', args='{GATING_PR} --remediate --issue {PRED} --repo {GH_REPO} --gh-flag {GH_FLAG} --under-orchestration')` and let it run to completion. This is a self-contained flow: it classifies the gate first, replaces `needs-human` with `workflow:in-review` only for fixable remediation, checks out the PR branch, fixes any fixable review findings, re-reviews, and either auto-lands the PR, holds it at `workflow:awaiting-merge` for a human, re-escalates back to `needs-human`, or reports the block as policy-level and unfixable. Do NOT intervene manually — do not run raw git/gh commands yourself.
2290
2290
 
2291
2291
  **DO NOT STOP EARLY**: if the Skill call returns without a terminal `REMEDIATE_RESULT.status`, invoke it again — it re-reads GitHub state and resumes. Terminal statuses are: `COMPLETE`, `ALREADY_DONE`, `UNFIXABLE`, `BLOCKED`, `REREVIEW_REQUIRED`.
2292
2292
 
@@ -2,6 +2,7 @@
2
2
  description: Pre-commit quality check — catches defects the review would flag, so the builder can fix them before committing
3
3
  argument-hint: (invoked by /work-on, not directly)
4
4
  context: fork
5
+ background: false
5
6
  ---
6
7
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
7
8
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -1721,8 +1721,13 @@ fi
1721
1721
  # fix round on this PR. Any trusted FORGE:INPR_FIX* marker already on the PR means the round is used.
1722
1722
  INPR_DIFF_FILE=""
1723
1723
  if [ "${AUTO_MERGE:-false}" = "true" ] && [ -n "${MERGE_ISSUE:-}" ]; then
1724
- INPR_MARKERS=$(gh api --paginate "repos/{GH_REPO}/issues/{PR_NUMBER}/comments" 2>/dev/null \
1725
- | jq -s '[.[][] | select(.author_association == "OWNER" or .author_association == "MEMBER" or .author_association == "COLLABORATOR") | select(.body | test("^<!-- FORGE:INPR_FIX"))] | length' 2>/dev/null || echo "")
1724
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
1725
+ TRUSTED_SCRIPT=""
1726
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$PWD"; do
1727
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
1728
+ done
1729
+ if [ -n "$TRUSTED_SCRIPT" ]; then INPR_MARKERS=$(gh api --paginate "repos/{GH_REPO}/issues/{PR_NUMBER}/comments" 2>/dev/null \
1730
+ | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:INPR_FIX' 2>/dev/null || echo ""); else INPR_MARKERS=""; fi
1726
1731
  if [ "$INPR_MARKERS" = "0" ]; then
1727
1732
  mkdir -p "${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"
1728
1733
  INPR_DIFF_FILE=$(mktemp "${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/{PR_NUMBER}_inpr-diff.XXXXXX")
@@ -2288,7 +2293,24 @@ if [ -z "$DISPO_JSON" ]; then
2288
2293
  exit 1
2289
2294
  fi
2290
2295
  FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test("<!-- FINDING:"))] | length')
2291
- DISPOSITION_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.author_association == "OWNER" or .author_association == "MEMBER" or .author_association == "COLLABORATOR") | select(.body | test("^<!-- FORGE:NOTE_DISPOSITION"))] | length')
2296
+ # Trust predicate: ONE shared copy (scripts/trusted-comments.sh, same as verify-phase-trail.sh): trusted association, Bot account, or
2297
+ # FORGE_TRAIL_TRUSTED_LOGINS. A GitHub App bot always has author_association NONE, so an association-only filter drops the pipeline's own markers.
2298
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
2299
+ TRUSTED_SCRIPT=""
2300
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$PWD"; do
2301
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
2302
+ done
2303
+ if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$FINDING_COUNT" ]; then
2304
+ echo "NOTE DISPOSITION: scripts/trusted-comments.sh unresolvable or findings unreadable — refusing to merge (fail closed)" >&2
2305
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "note disposition unreadable".
2306
+ exit 1
2307
+ fi
2308
+ DISPOSITION_COUNT=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:NOTE_DISPOSITION') || DISPOSITION_COUNT=""
2309
+ if [ -z "$DISPOSITION_COUNT" ]; then
2310
+ echo "NOTE DISPOSITION: trust filter failed — refusing to merge (fail closed)" >&2
2311
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "note disposition unreadable".
2312
+ exit 1
2313
+ fi
2292
2314
  if [ "${FINDING_COUNT:-0}" -gt 0 ] && [ "${DISPOSITION_COUNT:-0}" -eq 0 ]; then
2293
2315
  echo "NOTE DISPOSITION: ${FINDING_COUNT} finding comment(s) but no FORGE:NOTE_DISPOSITION record — run §6B.5 before merging" >&2
2294
2316
  gh pr comment {PR_NUMBER} {MERGE_GH_FLAG} --body "Auto-merge skipped: review findings exist but §6B.5 note disposition was not recorded. Re-run \`/review-pr {PR_NUMBER} --auto-merge --issue <N>\`." 2>/dev/null || true # allowlist:check-command-side-effects
@@ -2298,8 +2320,15 @@ fi
2298
2320
  # In-PR fix gate (§6B.6): an unanswered INPR_FIX request at the current head blocks the merge until the fix
2299
2321
  # round runs (a new head) or /work-on waives it for this head. Only trusted authors' markers count.
2300
2322
  PR_HEAD_NOW=$(gh pr view {PR_NUMBER} {MERGE_GH_FLAG} --json headRefOid --jq .headRefOid 2>/dev/null || echo "")
2301
- INPR_REQ=$(printf '%s' "$DISPO_JSON" | jq -s -r '[.[][] | select(.author_association == "OWNER" or .author_association == "MEMBER" or .author_association == "COLLABORATOR") | select(.body | test("^<!-- FORGE:INPR_FIX: ")) | .body | capture("head=(?<h>[0-9a-f]{7,40})").h] | last // ""')
2302
- INPR_WAIVED=$(printf '%s' "$DISPO_JSON" | jq -s -r --arg h "$PR_HEAD_NOW" '[.[][] | select(.author_association == "OWNER" or .author_association == "MEMBER" or .author_association == "COLLABORATOR") | select(.body | test("^<!-- FORGE:INPR_FIX_WAIVED: head=" + $h))] | length')
2323
+ INPR_BODIES=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INPR_FIX: ') || INPR_BODIES="__ERR__"
2324
+ INPR_WAIVED=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:INPR_FIX_WAIVED: head=${PR_HEAD_NOW}") || INPR_WAIVED=""
2325
+ if [ "$INPR_BODIES" = "__ERR__" ] || [ -z "$INPR_WAIVED" ]; then
2326
+ echo "IN-PR FIX: trust filter failed — refusing to merge (fail closed)" >&2
2327
+ # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "note disposition unreadable".
2328
+ exit 1
2329
+ fi
2330
+ # Latest trusted request wins (bodies are JSON strings, one per line).
2331
+ INPR_REQ=$(printf '%s\n' "$INPR_BODIES" | tail -n 1 | jq -r 'capture("head=(?<h>[0-9a-f]{7,40})").h // ""' 2>/dev/null || echo "")
2303
2332
  if [ -n "$INPR_REQ" ] && [ "$INPR_REQ" = "$PR_HEAD_NOW" ] && [ "${INPR_WAIVED:-0}" -eq 0 ]; then
2304
2333
  echo "IN-PR FIX: CONFIRMED MEDIUM findings in this PR's files were requested at head ${PR_HEAD_NOW} — fix them before merging (§6B.6)" >&2
2305
2334
  # STOP — return REVIEW_RESULT: status: BLOCKED, blocker: "in-pr fix required". Not needs-human: /work-on R4 runs one fix round.
@@ -3,6 +3,7 @@ user-invocable: false
3
3
  description: Pre-implementation architecture planning — traces ALL affected code paths and produces an ordered implementation plan before any code is written
4
4
  argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag {GH_FLAG} --repo-path {WORKTREE} --files \"<space-separated>\""
5
5
  context: fork
6
+ background: false
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
8
9
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -3,6 +3,7 @@ user-invocable: false
3
3
  description: Pre-implementation context gathering — surfaces historical findings, bug patterns, and related code paths before the builder writes any code
4
4
  argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag {GH_FLAG} --repo-path {WORKTREE} --files \"<space-separated>\" [--functions \"<names>\"]"
5
5
  context: fork
6
+ background: false
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
8
9
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -3,6 +3,7 @@ user-invocable: false
3
3
  description: Implementation agent — writes code, makes commits, posts builder comment
4
4
  argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE} --branch {BRANCH} --base {PR_BASE} [--fix-acceptance \"<failed checks>\"]"
5
5
  context: fork
6
+ background: false
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
8
9
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -3,6 +3,7 @@ user-invocable: false
3
3
  description: Validation agent — quality gate loop, format/verify, proxy check, deploy check
4
4
  argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE} --branch {BRANCH} --base {PR_BASE} --files \"<changed files>\""
5
5
  context: fork
6
+ background: false
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
8
9
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -2,6 +2,7 @@
2
2
  user-invocable: false
3
3
  description: Build subcommand — create worktree, post contract, sequence context/architect/implement/validate
4
4
  context: fork
5
+ background: false
5
6
  argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --base {PR_BASE}"
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -3,6 +3,7 @@ user-invocable: false
3
3
  description: Close subcommand — update project board, final issue body, parent tracker, summary report, trajectory log
4
4
  argument-hint: "[issue number] [--repo GH_REPO] [--gh-flag GH_FLAG] [--pr PR_NUMBER] [--base PR_BASE] [--branch BRANCH] [--worktree WORKTREE_PATH] --terminal-state merged|investigation|decomposed|invalid"
5
5
  context: fork
6
+ background: false
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
8
9
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -2,6 +2,7 @@
2
2
  user-invocable: false
3
3
  description: Decompose subcommand — break a complex issue into ordered sub-issues, post FORGE:DECOMPOSED, stop
4
4
  context: fork
5
+ background: false
5
6
  argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\""
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -3,6 +3,7 @@ user-invocable: false
3
3
  description: Investigate a GitHub issue — validate it's real, determine root cause, post findings
4
4
  argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\""
5
5
  context: fork
6
+ background: false
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
8
9
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -3,6 +3,7 @@ user-invocable: false
3
3
  description: Remediate subcommand — checkout a needs-human PR, fix review findings, re-review, and re-gate with a FORGE:REMEDIATION paper trail
4
4
  argument-hint: "[PR number] [--issue N] [--repo GH_REPO] [--gh-flag GH_FLAG] [--base PR_BASE]"
5
5
  context: fork
6
+ background: false
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
8
9
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -19,6 +20,9 @@ context: fork
19
20
  **Invoked by**:
20
21
  - `work-on.md` Phase 0A.1 (router), standalone: `/work-on <pr> --remediate` (see forge#1813).
21
22
  - `commands/orchestrate/phase-4-execution.md` item 6.4, auto-dispatched against a `needs-human`-gated predecessor's own open PR.
23
+ - `work-on.md` Phase 4R (router), when the review phase hands off a red CI gate or an in-PR fix request (`REVIEW_RESULT: status: NEXT`).
24
+
25
+ **Only the router invokes this skill** (forge#3398, `work-on.md` Hard Rule 1a). It re-reviews through `/review-pr`, which spawns domain reviewers, so it must run one level below the router. No phase may invoke it from inside its own fork.
22
26
 
23
27
  **Output**: Checkout the PR's existing branch → classify the block reason (fixable vs. policy escalation) → apply fixes → quality-gate → commit/push → re-invoke `/review-pr --auto-merge` → compute the #1809 Q1 auto-land bar → merge-if-verified or hold at `workflow:awaiting-merge` → emit a `FORGE:REMEDIATION` paper trail. Return result to caller.
24
28
 
@@ -234,7 +238,7 @@ Note the marker is `<!-- FORGE:REMEDIATION -->` with **no** `:COMPLETE` suffix y
234
238
 
235
239
  ## Phase M6: Re-Invoke /review-pr
236
240
 
237
- **Dispatch-tool probe (forge#3240 — run FIRST)**: `review-pr` must launch its domain review agents through a sub-agent dispatch tool and refuses to review inline. A forked remediation can be nested deep enough that neither `Task` nor `Agent` is available. Resolve the tool with the identical order `commands/review-pr.md` § "Sub-Agent Dispatch Tool Resolution" uses (`Task`, then `Agent`; OpenCode uses `task`). Do not copy or weaken that section, and never review inline here.
241
+ **Dispatch-tool probe (forge#3240 — run FIRST)**: `review-pr` must launch its domain review agents through a sub-agent dispatch tool and refuses to review inline. This only fails when remediation was invoked deeper than the router allows (see `docs/WORK-ON-RUNTIME.md`); invoked from the router, a dispatch tool is available, so the branch below is a safety net. Resolve the tool with the identical order `commands/review-pr.md` § "Sub-Agent Dispatch Tool Resolution" uses (`Task`, then `Agent`; OpenCode uses `task`). Do not copy or weaken that section, and never review inline here.
238
242
 
239
243
  If no dispatch tool resolves, a missing tool is not a human decision, so do NOT invoke `review-pr`, do NOT add `needs-human`, and leave `workflow:in-review` in place. Set `RE_GATE_OUTCOME="REREVIEW-REQUIRED"`, skip Phase M7, go to Phase M8 (which posts `FORGE:REMEDIATION:COMPLETE` with a `REREVIEW-REQUIRED` re-gate line), and return `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`. The caller re-runs `/review-pr {PR_NUMBER} --auto-merge --issue {ISSUE_NUMBER} --base {PR_BASE}` from a session that has dispatch. If a dispatch tool resolves, continue below.
240
244
 
@@ -2,6 +2,7 @@
2
2
  user-invocable: false
3
3
  description: Review subcommand — push branch, create PR, invoke /review-pr with --auto-merge
4
4
  context: fork
5
+ background: false
5
6
  argument-hint: "{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE} --branch {BRANCH} --base {PR_BASE}"
6
7
  ---
7
8
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
@@ -532,12 +533,11 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
532
533
 
533
534
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "stale review" (a commit landed on the PR after the verdict, or the head moved during the CI wait, so the code that would merge is not the reviewed code): do NOT merge. The re-review bound is persisted, not remembered: count `<!-- FORGE:STALE_REREVIEW: pr={PR_NUMBER} -->` comments on the issue. If the count is 0, post that marker (with the new head SHA), run the quality gate on the new head (`Skill(skill="{FORGE_SKILL_PREFIX}quality-gate", args="<changed files> --worktree {WORKTREE_PATH}")`, which posts a fresh `FORGE:QUALITY_GATE` for the code that will actually merge), then re-invoke Phase R3 once — a full review of the new head. If the count is already ≥ 1 (the head keeps moving after review), add `needs-human` and return `REVIEW_RESULT: status: BLOCKED`, blocker: "PR head keeps moving after review". <!-- forge#3188 -->
534
535
 
535
- - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed, were cancelled, or stayed pending past the gate retries): do NOT attempt the manual merge below — that would bypass the CI gate. Fixing red CI is pipeline work, not a human decision: invoke remediation **once** — `Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="{PR_NUMBER} --issue {NUMBER} --base {PR_BASE}")` (forked; it classifies a CI-gate refusal as FIXABLE, clears `needs-human`, reads the failing job logs, fixes them on the PR branch, re-runs the quality gate and a full review, and auto-lands through the same CI gate). Bound: count `<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->` comments on the issue first; if ≥ 1, do not remediate again. Post that marker before invoking. `REMEDIATE_RESULT` re-gate outcome `AUTO-LANDED` → treat as merged and return `REVIEW_RESULT: status: COMPLETE`; `REMEDIATE_RESULT: status: REREVIEW_REQUIRED` (forge#3240: the fix is pushed but remediation had no sub-agent dispatch tool, so no review ran) → do NOT add `needs-human` and do not re-invoke remediation; return `REVIEW_RESULT: status: BLOCKED` with blocker "re-review required: no dispatch tool" so the caller re-dispatches review from a session that has dispatch; any other outcome → leave `needs-human` (remediation sets it) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". <!-- forge#3191 -->
536
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed, were cancelled, or stayed pending past the gate retries): do NOT attempt the manual merge below — that would bypass the CI gate. Fixing red CI is pipeline work, not a human decision: hand remediation to the router **once**. Bound: count `<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->` comments on the issue first; if ≥ 1, do not remediate again — leave `needs-human` and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: ci-gate` (see Output). Do not post the marker here: the router posts it immediately before it invokes remediation (Phase 4R), so a handoff interrupted before remediation starts is retried instead of being counted as used. **Never invoke `work-on:remediate` from this phase** (forge#3398): remediation re-reviews through `/review-pr`, which spawns domain-reviewer sub-agents, so it must run one level below the router, not nested inside this fork. Nested here, it lands at the sub-agent depth limit under `/orchestrate` with no `Agent` tool and strands the PR at `REREVIEW-REQUIRED` (forge#3391). The router runs `work-on:remediate`, which classifies a CI-gate refusal as FIXABLE, clears `needs-human`, fixes the failing jobs on the PR branch, re-runs the quality gate and a full review, and auto-lands through the same CI gate. <!-- forge#3191 -->
536
537
 
537
538
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "in-pr fix required" (review-pr §6B.6: CONFIRMED MEDIUM findings in files this PR changed, requested on the PR in a `FORGE:INPR_FIX` comment at the current head): do NOT merge and do NOT leave the issue at `needs-human` because of this gate. Run **one** fix round, bounded by `<!-- FORGE:INPR_REMEDIATION: pr={PR_NUMBER} -->` on the issue (`{BOUND}` = `INPR_REMEDIATION` below).
538
- - **Bound unused**: post the marker. Add `needs-human` (remediation only targets `needs-human`-gated PRs, and its Phase M1 clears it as FIXABLE). Then invoke `Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="{PR_NUMBER} --issue {NUMBER} --base {PR_BASE}")`. Remediation reads the `FORGE:INPR_FIX` work order, fixes exactly those findings, re-reviews the new head and auto-lands. On the re-review, any finding still present is filed as an issue (the round is used).
539
- - `REMEDIATE_RESULT` re-gate outcome `AUTO-LANDED` → return `REVIEW_RESULT: status: COMPLETE`. `REREVIEW_REQUIRED` → same handling as the ci-gate case above.
540
- - **Any other outcome, or bound already used**: waive the gate for the current head. Remove `needs-human`, post `<!-- FORGE:INPR_FIX_WAIVED: head=<current head SHA> -->` on the **PR**, then re-invoke Phase R3 once under the `STALE_REREVIEW` bound. That review files the remaining findings as issues and merges as it would have before this gate. Only if `STALE_REREVIEW` is also exhausted, add `needs-human`.
539
+ - **Bound unused**: add `needs-human` (remediation only targets `needs-human`-gated PRs, and its Phase M1 clears it as FIXABLE). Then return `REVIEW_RESULT: status: NEXT` with `next: remediate` and `remediation: inpr-fix` (the router posts the bound marker, as in the ci-gate case) — never invoke `work-on:remediate` from this phase (same reason as the ci-gate case). The router runs remediation, which reads the `FORGE:INPR_FIX` work order, fixes exactly those findings, re-reviews the new head and auto-lands. On the re-review, any finding still present is filed as an issue (the round is used). If remediation does not land, the router re-invokes this phase, which then finds the bound used and takes the branch below.
540
+ - **Bound already used** (including the re-invocation after a remediation that did not land): waive the gate for the current head. Remove `needs-human`, post `<!-- FORGE:INPR_FIX_WAIVED: head=<current head SHA> -->` on the **PR**, then re-invoke Phase R3 once under the `STALE_REREVIEW` bound. That review files the remaining findings as issues and merges as it would have before this gate. Only if `STALE_REREVIEW` is also exhausted, add `needs-human`.
541
541
  ```bash
542
542
  INPR_WAIVE_HEAD=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq .headRefOid)
543
543
  gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
@@ -545,7 +545,7 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
545
545
  In-PR fix round did not land; the remaining CONFIRMED MEDIUM findings are filed as issues on the next review instead of blocking the merge." # allowlist:check-command-side-effects
546
546
  ```
547
547
 
548
- Persisted loop bounds for the cases above — count first, post the marker, then act (`{BOUND}` is `STALE_REREVIEW`, `CI_REMEDIATION` or `INPR_REMEDIATION`):
548
+ Persisted loop bounds for the cases above — count first, then act (`{BOUND}` is `STALE_REREVIEW`, `CI_REMEDIATION` or `INPR_REMEDIATION`). This phase posts only the `STALE_REREVIEW` marker; `CI_REMEDIATION` and `INPR_REMEDIATION` are posted by the router in Phase 4R, immediately before it invokes remediation (forge#3398):
549
549
  ```bash
550
550
  [ "${DRY_RUN:-false}" = "true" ] && { echo "[DRY_RUN] bound check only"; }
551
551
  if [ "{BOUND}" = "STALE_REREVIEW" ]; then
@@ -560,16 +560,12 @@ In-PR fix round did not land; the remaining CONFIRMED MEDIUM findings are filed
560
560
  fi
561
561
  if [ "$BOUND_COUNT" -ge 1 ]; then
562
562
  echo "BOUND_EXHAUSTED: {BOUND} already used for PR #{PR_NUMBER}"
563
- elif [ "${DRY_RUN:-false}" != "true" ]; then
563
+ elif [ "{BOUND}" = "STALE_REREVIEW" ] && [ "${DRY_RUN:-false}" != "true" ]; then
564
564
  gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:STALE_REREVIEW: pr={PR_NUMBER} -->
565
- Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head once." 2>/dev/null || true # when {BOUND}=STALE_REREVIEW
566
- gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CI_REMEDIATION: pr={PR_NUMBER} -->
567
- CI gate refused PR #{PR_NUMBER}: dispatching remediation once to fix the failing checks." 2>/dev/null || true # when {BOUND}=CI_REMEDIATION
568
- gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:INPR_REMEDIATION: pr={PR_NUMBER} -->
569
- In-PR fix requested on PR #{PR_NUMBER}: dispatching remediation once to fix the CONFIRMED MEDIUM findings before merge." 2>/dev/null || true # when {BOUND}=INPR_REMEDIATION
565
+ Stale review on PR #{PR_NUMBER}: quality-gating and re-reviewing the new head once." 2>/dev/null || true
570
566
  fi
571
567
  ```
572
- Post only the comment matching `{BOUND}`. `BOUND_EXHAUSTED` → take the "already ≥ 1" branch of that case.
568
+ `BOUND_EXHAUSTED` → take the "already ≥ 1" branch of that case.
573
569
 
574
570
  - PR NOT MERGED (and not a phase-trail, auto-merge-gate, stale-review, ci-gate or in-pr-fix BLOCKED above) → attempt manual merge, **only if the PR head is still the commit named in the latest `<!-- FORGE:REVIEW -->` APPROVED verdict** (otherwise re-invoke Phase R3 instead, as for "stale review") **and only after the same CI gate**:
575
571
  ```bash
@@ -590,8 +586,17 @@ In-PR fix requested on PR #{PR_NUMBER}: dispatching remediation once to fix the
590
586
  # §6B.5 disposition must not merge, because that step is what bounds the review-finding cascade.
591
587
  DISPO_JSON=$(gh api --paginate "repos/{GH_REPO}/issues/{PR_NUMBER}/comments" 2>/dev/null) || DISPO_JSON=""
592
588
  FINDING_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.body | test("<!-- FINDING:"))] | length' 2>/dev/null || echo "")
593
- DISPOSITION_COUNT=$(printf '%s' "$DISPO_JSON" | jq -s '[.[][] | select(.author_association == "OWNER" or .author_association == "MEMBER" or .author_association == "COLLABORATOR") | select(.body | test("^<!-- FORGE:NOTE_DISPOSITION"))] | length' 2>/dev/null || echo "")
594
- if [ -z "$DISPO_JSON" ] || [ -z "$FINDING_COUNT" ] || { [ "$FINDING_COUNT" -gt 0 ] && [ "${DISPOSITION_COUNT:-0}" -eq 0 ]; }; then
589
+ # Trust predicate: ONE shared copy (scripts/trusted-comments.sh, same as verify-phase-trail.sh): trusted association, Bot account, or
590
+ # FORGE_TRAIL_TRUSTED_LOGINS. A GitHub App bot always has author_association NONE, so an association-only filter drops the pipeline's own disposition.
591
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
592
+ TRUSTED_SCRIPT=""
593
+ for _c in '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$PWD"; do
594
+ case "$_c" in /*) [ -z "$TRUSTED_SCRIPT" ] && [ -f "$_c/scripts/trusted-comments.sh" ] && TRUSTED_SCRIPT="$_c/scripts/trusted-comments.sh" ;; esac
595
+ done
596
+ if [ -n "$TRUSTED_SCRIPT" ]; then DISPOSITION_COUNT=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" count '^<!-- FORGE:NOTE_DISPOSITION' 2>/dev/null || echo ""); else DISPOSITION_COUNT=""; fi
597
+ if [ -z "$DISPO_JSON" ] || [ -z "$FINDING_COUNT" ] || [ -z "$DISPOSITION_COUNT" ]; then
598
+ echo "REVIEW_RESULT: status: BLOCKED, blocker: note disposition unreadable (comments or scripts/trusted-comments.sh unresolvable; fail closed)"
599
+ elif [ "$FINDING_COUNT" -gt 0 ] && [ "$DISPOSITION_COUNT" -eq 0 ]; then
595
600
  echo "REVIEW_RESULT: status: BLOCKED, blocker: note disposition missing"
596
601
  elif [ "$CI_GATE_RC" -eq 0 ]; then
597
602
  gh pr merge {PR_NUMBER} {GH_FLAG} --merge --auto --match-head-commit "$GATED_HEAD" # allowlist:check-command-side-effects (CI-gated merge)
@@ -599,7 +604,7 @@ In-PR fix requested on PR #{PR_NUMBER}: dispatching remediation once to fix the
599
604
  echo "REVIEW_RESULT: status: BLOCKED, blocker: ci gate not green (rc=${CI_GATE_RC})"
600
605
  fi
601
606
  ```
602
- If the note-disposition gate refuses (`blocker: note disposition missing`): do NOT add `needs-human` — treat it like a stale review and re-invoke Phase R3 once under the `STALE_REREVIEW` bound so `/review-pr` records its §6B.5 disposition; only if that bound is exhausted, add `needs-human`. If the CI gate refuses: post the gate output as an issue comment, add `needs-human`, return `REVIEW_RESULT: status: BLOCKED` (blocker "ci gate not green"). If merge fails: post comment, add `needs-human`, return `REVIEW_RESULT: status: BLOCKED`
607
+ If the note-disposition gate refuses with `blocker: note disposition unreadable`: the comment list or the trust helper could not be read; add `needs-human` and return BLOCKED (a re-review cannot fix it). If it refuses with `blocker: note disposition missing` (no trusted disposition exists; bot, trusted-login and trusted-association authors all count): do NOT add `needs-human` — treat it like a stale review and re-invoke Phase R3 once under the `STALE_REREVIEW` bound so `/review-pr` records its §6B.5 disposition; only if that bound is exhausted, add `needs-human`. If the CI gate refuses: post the gate output as an issue comment, add `needs-human`, return `REVIEW_RESULT: status: BLOCKED` (blocker "ci gate not green"). If merge fails: post comment, add `needs-human`, return `REVIEW_RESULT: status: BLOCKED`
603
608
 
604
609
  ---
605
610
 
@@ -611,13 +616,17 @@ Output this structured block:
611
616
 
612
617
  ```
613
618
  REVIEW_RESULT:
614
- status: COMPLETE | ALREADY_MERGED | BLOCKED
619
+ status: COMPLETE | ALREADY_MERGED | NEXT | BLOCKED
615
620
  pr_number: {PR_NUMBER}
616
621
  pr_url: {PR_URL}
617
622
  merged_to: {PR_BASE}
623
+ next: {remediate, only when status=NEXT}
624
+ remediation: {ci-gate | inpr-fix, only when status=NEXT}
618
625
  blocker: {description if status=BLOCKED}
619
626
  ```
620
627
 
628
+ `NEXT` is a handoff, not a terminal state: the PR is open and unmerged, the bound marker is posted, and the router must run `work-on:remediate` for `{PR_NUMBER}` next (see `commands/work-on.md` Phase 4).
629
+
621
630
  ---
622
631
 
623
632
  ## Integration
@@ -628,4 +637,4 @@ This skill is invoked by the `work-on` router (forked) after validate returns `G
628
637
  build/validate → [THIS SKILL] push + PR creation + /review-pr invocation + merge verification → work-on:close
629
638
  ```
630
639
 
631
- /review-pr is invoked within this skill (not by the router). The router sees only the final `REVIEW_RESULT:` block and never re-dispatches phase-trail failures.
640
+ /review-pr is invoked within this skill (not by the router). The router sees only the final `REVIEW_RESULT:` block and never re-dispatches phase-trail failures. Remediation is the one exception that leaves this skill: it is returned as `status: NEXT` and run by the router, because it spawns reviewers of its own (see `docs/WORK-ON-RUNTIME.md`).
@@ -21,11 +21,13 @@ Orchestrator for the full issue lifecycle: investigate → decompose (if needed)
21
21
 
22
22
  1. **This file is a router. Every phase runs as a forked sub-skill via `Skill(...)`; the router never executes phase logic itself.** Each phase sub-skill (`work-on:investigate`, `work-on:decompose`, `work-on:build`, `work-on:review`, `work-on:close`, `work-on:remediate`) declares `context: fork`, so Claude Code runs it in an isolated sub-agent context that holds only that phase's spec and the args you pass. The phase re-reads everything else from GitHub/git and returns exactly one `*_RESULT:` block — that block is all the router sees. Do not read phase spec files, do not re-implement a phase step here, and do not "help" a phase by posting its markers or labels. If a skill name does not resolve, STOP with "skill not found" (see Skill Name Resolution) — never run a phase inline.
23
23
 
24
+ 1a. **Dispatching phases are invoked only by this router.** `work-on:review` and `work-on:remediate` spawn sub-agents (the `/review-pr` domain reviewers), and Claude Code only grants the `Agent` tool down to a fixed depth (see Depth Budget). Invoking them from here keeps them one level below the router, where `Agent` works. No phase may invoke them; a phase that needs one returns `status: NEXT` with `next: <phase>`, and the router runs it (Phase 4R). Non-dispatching phases may nest forks freely. <!-- forge#3398 -->
25
+
24
26
  2. **Route only on `*_RESULT` blocks and GitHub state.** After each phase returns, apply the routing table for that phase (below). Never infer success from a phase's prose; never continue past a `BLOCKED`/`FAILED` result.
25
27
 
26
28
  3. **Follow the Phase Dispatcher.** Do not skip, reorder, or treat an intermediate completion as terminal. Only the terminal states listed in the Dispatcher allow stopping. Between phases: no narration, no summary, no end of turn — invoke the next phase immediately.
27
29
 
28
- 3a. **Phase calls run synchronously to completion in your own turn.** Every phase `Skill(...)` call is consumed by you, in the turn that issued it. Never end or yield your turn, narrate that you are waiting, or "wait for the notification" — completion notifications for forked phases are delivered to the root session, never to this worker, so a turn that ends while a phase is pending is never resumed. A non-final, "running", backgrounded or empty return that carries no `*_RESULT:` block is not a phase result: re-read the issue's labels and `FORGE:*` markers on GitHub (consume a present marker instead of re-invoking), then re-invoke the router or the same phase at most 2 times per phase. After the cap, stop with BLOCKED / the existing GATE_FAILURE path (needs-human via the existing exit) using `child-stalled: <phase>`; a build `BLOCKED` with `child-stalled:` is terminal and is not re-invoked. While the issue is non-terminal, keep working until a terminal state is reached.
30
+ 3a. **Phase calls run synchronously to completion in your own turn.** Every phase sub-skill declares `background: false`, so its `Skill(...)` call returns its `*_RESULT:` block in your turn (a forked skill without it runs in the background and reports to the root session instead). Every phase `Skill(...)` call is consumed by you, in the turn that issued it. Never end or yield your turn, narrate that you are waiting, or "wait for the notification" — completion notifications for forked phases are delivered to the root session, never to this worker, so a turn that ends while a phase is pending is never resumed. A non-final, "running", backgrounded or empty return that carries no `*_RESULT:` block is not a phase result: re-read the issue's labels and `FORGE:*` markers on GitHub (consume a present marker instead of re-invoking), then re-invoke the router or the same phase at most 2 times per phase. After the cap, stop with BLOCKED / the existing GATE_FAILURE path (needs-human via the existing exit) using `child-stalled: <phase>`; a build `BLOCKED` with `child-stalled:` is terminal and is not re-invoked. While the issue is non-terminal, keep working until a terminal state is reached.
29
31
 
30
32
  4. **PRs NEVER target `main`.** Target `staging` (fast lane) or `milestone/{slug}` (feature lane). The router computes and validates the target (Lane Resolution) and passes it to the phases as `--base`.
31
33
 
@@ -73,6 +75,7 @@ A sub-agent that receives no resolved value applies the same rule itself.
73
75
  | 2 | Decompose | `work-on:decompose` | `INVESTIGATE_RESULT.decompose: YES` (or resume: investigation says decompose YES and no `FORGE:DECOMPOSED`) |
74
76
  | 3 | Build | `work-on:build` | Investigation complete, decompose NO, no `FORGE:BUILDER:COMPLETE` |
75
77
  | 4 | Review (push, PR, review, merge) | `work-on:review` | `FORGE:BUILDER:COMPLETE` present and PR not merged |
78
+ | 4R | Remediation handoff | `work-on:remediate` | `REVIEW_RESULT: status: NEXT, next: remediate` (CI gate red or in-PR fix requested) |
76
79
  | 5 | Close & trajectory | `work-on:close` | PR merged, or a PR-less terminal outcome (investigation deliverables, decomposed, invalid) |
77
80
 
78
81
  **Terminal states** (only these allow stopping): `workflow:merged` with the issue closed; `workflow:invalid`; `workflow:decomposed` (sub-issues own the work); `needs-human`; `workflow:awaiting-merge` (held for a human merge decision on the deploy gate); a `CLOSE_RESULT` with `status: COMPLETE | ALREADY_DONE`. Anything else → run the next phase immediately.
@@ -94,13 +97,17 @@ A sub-agent that receives no resolved value applies the same rule itself.
94
97
 
95
98
  ### Depth Budget
96
99
 
97
- | Depth | Agent | Notes |
98
- |-------|-------|-------|
99
- | 1 | `/orchestrate` (or a solo `/work-on` session) | Never implements directly |
100
- | 2 | `/work-on` router (an orchestrated worker) | Routes only |
101
- | 3 | Forked phase (`work-on:<phase>`) | One at a time; `review-pr` runs inside `work-on:review`'s fork |
102
- | 4 | Forked build child (context, architect, implement, validate); domain reviewers dispatched by `review-pr` | |
103
- | 5 | Quality gate (forked, inside validate) | Deepest level used |
100
+ Layers are counted below the main conversation (layer 0). Claude Code grants the `Agent` tool down to `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH` layers (default 3 since 2.1.219): **a sub-agent at layer 1 or 2 can spawn, one at layer 3 cannot**. A `context: fork` skill always runs at any depth, but it gets `Agent` only by the same rule (measured on 2.1.294, forge#3398). Full rationale: `docs/WORK-ON-RUNTIME.md`.
101
+
102
+ | Layer (solo / orchestrated) | Runs here | Spawns sub-agents |
103
+ |-----------------------------|-----------|-------------------|
104
+ | 0 / 0 | solo `/work-on` router / `/orchestrate` | orchestrate: one worker per issue |
105
+ | — / 1 | `/work-on` router (orchestrated worker) | no (`Skill` only) |
106
+ | 1 / 2 | every phase invoked by the router, including `work-on:review` and `work-on:remediate` | review/remediate: `/review-pr` domain reviewers |
107
+ | 2 / 3 | domain reviewers (leaves); build children (context, architect, implement, validate) | no |
108
+ | 3 / 4 | quality gate (inside validate or review) | no |
109
+
110
+ The only layer constraint that matters is the spawner: `work-on:review` and `work-on:remediate` must sit at layer ≤ 2, which Hard Rule 1a guarantees. The Phase 0A spawn-depth preflight checks the effective depth before any work starts.
104
111
 
105
112
  ### Model and Effort Tiering — What Actually Applies
106
113
 
@@ -221,6 +228,40 @@ Extract project prefix and issue number. If `next`/`pick`: list open issues sort
221
228
 
222
229
  **Resolve `UNDER_ORCHESTRATION`**: `true` if the invocation args contain `--under-orchestration`, else `false`. This is a single parse done once, here — every later gated block (heartbeats) just checks this variable, no re-parsing.
223
230
 
231
+ **Spawn-depth preflight (before 0A.1 and before any GitHub write)** <!-- forge#3398 -->: confirm the dispatching phases will have the `Agent` tool (Depth Budget). The router runs at layer 1 under `/orchestrate`, else layer 0. The block is self-contained (it carries the canonical `FORGE_ROOT` bootstrap).
232
+
233
+ ```bash
234
+ # FORGE_ROOT bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
235
+ FORGE_ROOT=""
236
+ # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
237
+ _h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
238
+ # Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
239
+ _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
240
+ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
241
+ # Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
242
+ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
243
+ # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
244
+ _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
245
+ # newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
246
+ _v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{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- || true)"
247
+ _m="$HOME/.claude/plugins/marketplaces/$_mk"
248
+ # '${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.
249
+ _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
250
+ while IFS= read -r _c; do
251
+ 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
252
+ done <<< "$_k"
253
+ fi
254
+ ROUTER_LAYER=0; [ "$UNDER_ORCHESTRATION" = "true" ] && ROUTER_LAYER=1
255
+ if [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/spawn-depth-check.sh" ]; then
256
+ DEPTH_OUT=$(bash "$FORGE_ROOT/scripts/spawn-depth-check.sh" --router-layer "$ROUTER_LAYER"); DEPTH_RC=$?
257
+ else
258
+ DEPTH_OUT="SPAWN_DEPTH: UNKNOWN (spawn-depth-check.sh not resolvable)"; DEPTH_RC=0
259
+ fi
260
+ echo "$DEPTH_OUT"
261
+ ```
262
+
263
+ `DEPTH_RC=0` (`OK`, `SKIP`, `UNKNOWN`) or `2` (usage error — treat as `UNKNOWN`): continue. `DEPTH_RC=1` (`FAIL`): the review phase could not spawn reviewers, so nothing built now could be reviewed. When an issue number is known, post `DEPTH_OUT` as a comment with `<!-- FORGE:GATE_FAILURE:TYPE=spawn-depth -->` and add `needs-human` (raising `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH` is an operator action). STOP either way.
264
+
224
265
  **Resolve `RECOVERY_SWEEP_ID`**: the value after `--recovery-sweep` in the invocation args, else empty (consumed by 0A.4; only `/recover-orphans` passes it).
225
266
 
226
267
  **Optional pre-flight**: Before committing to the full pipeline, run `/scope {NUMBER}` to get a complexity estimate (affected files, blast radius, risk flags, and decomposition recommendation). Especially useful for large or ambiguous issues.
@@ -253,7 +294,7 @@ If detected, dispatch immediately and STOP — do NOT fall through to Phase 0B's
253
294
  Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="${REMEDIATE_PR_NUMBER} ${REMEDIATE_ISSUE_FLAG} --repo {GH_REPO} --gh-flag {GH_FLAG}")
254
295
  ```
255
296
 
256
- **If `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`** (forge#3240: fix pushed, but the forked remediation had no sub-agent dispatch tool): run the re-review from this top-level session, which has dispatch — `Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${REMEDIATE_PR_NUMBER} --auto-merge --issue ${ISSUE} --base ${PR_BASE} --gh-flag {GH_FLAG}")` (`ISSUE` and `PR_BASE` from the result and the PR) — never review inline. If it returns `REVIEW_RESULT: status: COMPLETE` (merged), invoke `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` as remediate M8 does for `AUTO-LANDED`. Then STOP.
297
+ **If `REMEDIATE_RESULT: status: REREVIEW_REQUIRED`** (forge#3240, fallback only since forge#3398: fix pushed, but remediation had no sub-agent dispatch tool, which cannot happen when the Phase 0A spawn-depth preflight passed and remediation is invoked from this router): run the re-review from this top-level session, which has dispatch — `Skill(skill="{FORGE_SKILL_PREFIX}review-pr", args="${REMEDIATE_PR_NUMBER} --auto-merge --issue ${ISSUE} --base ${PR_BASE} --gh-flag {GH_FLAG}")` (`ISSUE` and `PR_BASE` from the result and the PR) — never review inline. If it returns `REVIEW_RESULT: status: COMPLETE` (merged), invoke `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` as remediate M8 does for `AUTO-LANDED`. Then STOP.
257
298
 
258
299
  **After `REMEDIATE_RESULT` returns (any other status), STOP unconditionally** — do not run any further Phase 0–7 logic in this file. `work-on/remediate.md` is self-contained: a FIXABLE remediation replaces `needs-human` with the active `workflow:in-review` state only while it is running, then ends at `workflow:merged`, `workflow:awaiting-merge`, or a newly asserted `needs-human` label. When `re_gate_outcome: AUTO-LANDED`, it drives its own close phase internally (Phase M8 invokes `Skill("{FORGE_SKILL_PREFIX}work-on:close", ...)` directly) before returning. For every other outcome (`HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `BLOCKED`, `ALREADY_DONE`), the issue is already at a terminal state (`workflow:awaiting-merge` or `needs-human`, or already closed) per the Universal Phase Dispatcher — nothing further to do.
259
300
 
@@ -330,7 +371,7 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments --jq '.[] | {id: .id, author: .u
330
371
 
331
372
  **Resume preflight (MANDATORY on any resume past Phase 1)** <!-- Added: forge#3061 -->: before routing to Phase 3 (build), 4 (review) or 5 (close) from existing state, run `bash "$FORGE_ROOT/scripts/verify-phase-trail.sh" {NUMBER} -R {GH_REPO}` (add `--docs-only` for docs-only diffs, or `--code-diff` when the diff has any non-docs file so an INVESTIGATION band cannot waive requirements), with `FORGE_ROOT` resolved by the bootstrap in "Script resolution" below. If `FORGE_ROOT` is empty or the script is missing, treat it as `PHASE_TRAIL: ERROR` (fail closed: stop and add `needs-human`; never skip the preflight). On `PHASE_TRAIL: FAIL`, go BACK and run each missing phase through its `Skill(...)` (investigate, Phase 3B classification, build contract/context/architect, validate) before continuing — never continue forward over a gap, never hand-post a missing marker, and never treat a recovered uncommitted worktree as a substitute for the skipped phases. The same verifier gates PR creation (`work-on/review.md` Phase R1.5) and auto-merge (`review-pr.md` Phase 8).
332
373
 
333
- **Determine resume point**: no `FORGE:INVESTIGATOR` → Phase 1 (investigate). Investigation complete with decompose YES and no `FORGE:DECOMPOSED` → Phase 2 (decompose); with `FORGE:DECOMPOSED` → Phase 5 (`--terminal-state decomposed`). Investigation complete (decompose NO) without `FORGE:BUILDER:COMPLETE` → Phase 3 (build; a partial BUILDER comment is cleaned up by the build phase). `FORGE:BUILDER:COMPLETE` and no merged PR → Phase 4 (review). PR merged and issue open → Phase 5 (close). `workflow:invalid` → STOP.
374
+ **Determine resume point**: no `FORGE:INVESTIGATOR` → Phase 1 (investigate). Investigation complete with decompose YES and no `FORGE:DECOMPOSED` → Phase 2 (decompose); with `FORGE:DECOMPOSED` → Phase 5 (`--terminal-state decomposed`). Investigation complete (decompose NO) without `FORGE:BUILDER:COMPLETE` → Phase 3 (build; a partial BUILDER comment is cleaned up by the build phase). `FORGE:BUILDER:COMPLETE` and no merged PR → Phase 4 (review), except an **unfinished remediation handoff**, which is checked first and overrides the checkpoint routing in 0B.5: the issue does NOT carry `needs-human`, and its latest `FORGE:CI_REMEDIATION: pr={PR}`/`FORGE:INPR_REMEDIATION: pr={PR}` marker (for the open PR) has no `FORGE:REMEDIATION:COMPLETE` comment on that PR created after it → first re-add `needs-human` with a comment ("resuming an interrupted remediation of PR #{PR}"), because remediation's Phase M0 only accepts `needs-human`-gated issues and its M1 clears the label again for a FIXABLE run; then Phase 4R with that marker's kind, skipping the marker post (it is already recorded). This is the silent strand: M1 cleared `needs-human` and the run was lost before M8. It cannot loop: a remediation that finishes posts `FORGE:REMEDIATION:COMPLETE` (its single-attempt guard then returns `ALREADY_DONE`), and any other exit leaves `needs-human` on the issue (Phase 4R adds it on a `BLOCKED` result), so this rule no longer matches. A run lost before M1 still has `needs-human`, so it routes to review, which finds the bound used and stops visibly at `needs-human`. Re-entering review instead would find the bound used and stop without remediation ever finishing. PR merged and issue open → Phase 5 (close). `workflow:invalid` → STOP.
334
375
 
335
376
  ### 0B.5: Read Phase Checkpoint (MANDATORY — executes before any phase-skip decision)
336
377
 
@@ -348,7 +389,7 @@ if [ -n "$CHECKPOINT" ]; then
348
389
  fi
349
390
  ```
350
391
 
351
- **Routing from checkpoint** (overrides prose heuristics above when a checkpoint exists):
392
+ **Routing from checkpoint** (overrides prose heuristics above when a checkpoint exists, except the unfinished-remediation-handoff rule in 0B, which is evaluated first and routes to Phase 4R):
352
393
 
353
394
  | `next_phase` value | Resume at |
354
395
  |--------------------|-----------|
@@ -679,14 +720,46 @@ Post the Phase 4 heartbeat, run Lane Resolution including the lane validation, t
679
720
  Skill(skill="{FORGE_SKILL_PREFIX}work-on:review", args="{NUMBER} --repo {GH_REPO} --gh-flag \"{GH_FLAG}\" --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE}")
680
721
  ```
681
722
 
682
- The review phase owns: ancestry and empty-branch guards, push, PR creation, `workflow:in-review`, the phase-trail preflight (and the single re-dispatch of any missing phase via `Skill`), `/review-pr --auto-merge` (CI gate and reviewed-head guard included), stale-review re-review, and the REVIEW checkpoint. It never closes the issue.
723
+ The review phase owns: ancestry and empty-branch guards, push, PR creation, `workflow:in-review`, the phase-trail preflight (and the single re-dispatch of any missing phase via `Skill`), `/review-pr --auto-merge` (CI gate and reviewed-head guard included), stale-review re-review, and the REVIEW checkpoint. It never closes the issue and never runs remediation: a red CI gate or an in-PR fix request comes back as `REVIEW_RESULT: status: NEXT` for Phase 4R.
683
724
 
684
725
  | `REVIEW_RESULT` | Router action |
685
726
  |---|---|
686
727
  | `COMPLETE` (PR merged) | Record `PR_NUMBER` → Phase 5 with `--terminal-state merged` |
687
728
  | `ALREADY_MERGED` | Same as COMPLETE |
729
+ | `NEXT`, `next: remediate` | Record `PR_NUMBER` and `remediation` (`ci-gate` or `inpr-fix`) → Phase 4R (review handoff to remediation) |
688
730
  | `BLOCKED` | Terminal: the phase already added `needs-human` (or `workflow:awaiting-merge` for a deploy-gate hold) with the blocker. Do NOT merge, re-run phases, or add labels here. STOP. |
689
731
 
732
+ ### Phase 4R: Remediation handoff from review
733
+
734
+ The review phase never invokes remediation itself: remediation re-reviews through `/review-pr`, which spawns domain reviewers, so it must run one level below this router (see the Depth Budget). The review phase has checked the bound; this router posts the bound marker **immediately before** invoking remediation, so a handoff interrupted before remediation starts (compaction, crash) is retried on resume instead of being counted as used. It runs at most once per PR per kind.
735
+
736
+ ```bash
737
+ # {REMEDIATION_KIND} is the review result's `remediation` value: ci-gate -> CI_REMEDIATION, inpr-fix -> INPR_REMEDIATION.
738
+ BOUND_MARKER="CI_REMEDIATION"; [ "{REMEDIATION_KIND}" = "inpr-fix" ] && BOUND_MARKER="INPR_REMEDIATION"
739
+ BOUND_BODY="<!-- FORGE:${BOUND_MARKER}: pr={PR_NUMBER} -->
740
+ Review handed PR #{PR_NUMBER} to remediation ({REMEDIATION_KIND}); the router is dispatching it once."
741
+ gh issue comment {NUMBER} {GH_FLAG} --body "$BOUND_BODY" || { echo "github-unavailable: could not post FORGE:${BOUND_MARKER}; remediation not invoked"; exit 1; } # allowlist:check-command-side-effects
742
+ ```
743
+
744
+ If the marker cannot be posted, do not invoke remediation (an unrecorded bound could repeat): treat it as a `github-unavailable:` blocker under the router-owned retry rule.
745
+
746
+ ```
747
+ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="{PR_NUMBER} --issue {NUMBER} --base {PR_BASE} --repo {GH_REPO} --gh-flag {GH_FLAG}")
748
+ ```
749
+
750
+ | `REMEDIATE_RESULT` | Router action |
751
+ |---|---|
752
+ | `re_gate_outcome: AUTO-LANDED` | Merged. Remediation's Phase M8 already ran `work-on:close`; if the issue is still open, run Phase 5 with `--terminal-state merged`. Then done. |
753
+ | `status: REREVIEW_REQUIRED` | Fallback only (should not occur at this depth): run the re-review from this router as in 0A.1, then Phase 5 on `REVIEW_RESULT: status: COMPLETE`. |
754
+ | `status: BLOCKED`, blocker starts with `github-unavailable:` | Transient, not a decision (see Transient GitHub failures). Re-add `needs-human` with a short comment (remediation's Phase M0 only accepts `needs-human`-gated issues and its M1 clears the label for a FIXABLE run), wait 2 min (5 min before the second retry), then re-invoke `work-on:remediate` with the same args; do NOT re-post the bound marker. At most 2 retries; if the third attempt still returns `github-unavailable:`, fall through to the `status: BLOCKED` (any kind) row below. |
755
+ | `status: BLOCKED` (any kind) | Make sure `needs-human` is on the issue (add it with the blocker as a comment if absent: remediation can exit after its M1 cleared the label). STOP. |
756
+ | `status: ALREADY_DONE` (single-attempt guard: an earlier remediation already completed on this PR), `remediation: inpr-fix` | Same as any other `inpr-fix` outcome below: re-invoke Phase 4 once, which waives the in-PR gate. |
757
+ | `status: ALREADY_DONE`, `remediation: ci-gate` | Make sure `needs-human` is on the issue (add it, with a comment naming the PR and that remediation already ran, if absent). STOP. |
758
+ | any other outcome, `remediation: inpr-fix` | Re-invoke Phase 4 (`work-on:review`) once with the same args. Review finds its `INPR_REMEDIATION` bound used, waives the in-PR gate for the current head, files the remaining findings as issues and re-reviews. |
759
+ | any other outcome, `remediation: ci-gate` | Terminal: remediation left `needs-human` (or `workflow:awaiting-merge`) with its reason. STOP. |
760
+
761
+ Row order matters: the `github-unavailable:` row is evaluated before the generic `status: BLOCKED` (any kind) row, which only handles non-transient blockers and the exhausted retry budget (see Transient GitHub failures). <!-- Added: forge#3402 -->
762
+
690
763
  ---
691
764
 
692
765
  ## Phase 5: Close & trajectory
package/docs/CONFIG.md CHANGED
@@ -873,6 +873,8 @@ These variables are read from the operator's runner environment, not from `forge
873
873
 
874
874
  The merge gate (`scripts/verify-phase-trail.sh`) ignores FORGE markers posted by untrusted commenters. A commenter is trusted when ANY of these hold: `author_association` is in `FORGE_TRAIL_TRUSTED_ASSOCIATIONS`; the account type is `Bot` (the pipeline's GitHub App identity); or the login is in `FORGE_TRAIL_TRUSTED_LOGINS`.
875
875
 
876
+ The note-disposition gate (`FORGE:NOTE_DISPOSITION`) and the in-PR fix markers (`FORGE:INPR_FIX*`) in `commands/review-pr.md` and `commands/work-on/review.md` use the same predicate through one shared helper, `scripts/trusted-comments.sh`, so a disposition posted by the pipeline's bot identity counts. An unresolvable helper blocks the merge (fail closed).
877
+
876
878
  Pipelines that run under a human login with association `CONTRIBUTOR`, `NONE` or `FIRST_TIME_CONTRIBUTOR` will see every marker reported `MISSING`, and the gate prints a NOTE when untrusted-author markers were seen. Fix it by either:
877
879
 
878
880
  ```bash
@@ -0,0 +1,99 @@
1
+ <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
2
+ <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
3
+
4
+ # /work-on runtime architecture: phases, forks and nesting depth
5
+
6
+ This document defines how the `/work-on` router runs its phases on Claude Code, and why.
7
+ It is the source of truth for which skills fork, who may invoke them, and where sub-agents
8
+ can be spawned. `commands/work-on.md` (Hard Rules, Spawn-Decision Policy, Depth Budget) must
9
+ agree with it.
10
+
11
+ ## 1. Runtime facts
12
+
13
+ | # | Fact | Source |
14
+ |---|------|--------|
15
+ | F1 | A sub-agent can spawn sub-agents up to **3 layers below the main conversation** (default since Claude Code 2.1.219). At the limit, the `Agent` tool is withheld. `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH` overrides the limit. Earlier defaults: 2.1.172–2.1.216 = 5 layers, 2.1.217–2.1.218 = 1 layer. | code.claude.com/docs/en/sub-agents |
16
+ | F2 | A skill with `context: fork` starts a **new sub-agent** (type from `agent:`, default `general-purpose`) with the skill body as its prompt. It counts as a layer, sees no caller history, and returns only its final reply. | code.claude.com/docs/en/skills |
17
+ | F3 | A forked skill runs **in the background by default**: the invoking turn does not wait and the result arrives later as a notification to the main conversation. `background: false` makes the invoking turn wait for the result. | code.claude.com/docs/en/skills |
18
+ | F4 | Measured on 2.1.294 with plain agents: layer 1 and layer 2 have `Agent`; layer 3 does not. | probe, 2026-10-08 (forge#3398) |
19
+ | F5 | Measured on 2.1.294 with forked skills: a fork **always runs**, at any layer (a fork invoked from layer 3 ran at layer 4). Only its `Agent` tool follows F1: a fork at layer 2 has `Agent`, a fork at layer 3 or deeper does not. | probe, 2026-10-08 (forge#3398) |
20
+
21
+ So the depth limit constrains exactly one thing: **where a sub-agent can be spawned**. Forking
22
+ for context isolation is free at any depth; spawning `Agent(...)` sub-agents only works from layer 2
23
+ or shallower (under the default limit of 3).
24
+
25
+ F3 explains two field symptoms: phases returning "running"/empty results to the router, and phase
26
+ completion notifications arriving in the orchestrator's root session instead of the worker.
27
+
28
+ ## 2. Observed failure (forge#3391, AlterLab#34442)
29
+
30
+ ```
31
+ orchestrator (main, L0)
32
+ └─ worker Agent: /work-on router L1
33
+ └─ work-on:review (fork) L2
34
+ └─ work-on:remediate (fork, nested) L3 ← fork runs, but has no Agent tool
35
+ └─ review-pr domain reviewers L4 ← cannot be spawned
36
+ ```
37
+
38
+ `commands/work-on/review.md` invoked `work-on:remediate` from inside the review fork (the CI-gate and
39
+ in-PR-fix paths). Remediation still ran, but at L3 its re-review could not spawn reviewers, so it
40
+ posted `REREVIEW-REQUIRED` and stranded the issue. The normal path (worker L1 → review L2 → reviewers
41
+ L3) works, which is why most PRs are reviewed normally.
42
+
43
+ ## 3. Design rules
44
+
45
+ **R1 — Dispatching phases are invoked only by the router.** A *dispatching* phase is one that spawns
46
+ `Agent(...)` sub-agents directly or through `/review-pr`: `work-on:review` and `work-on:remediate`.
47
+ Only the router may invoke them, so they always run at router layer + 1 (L2 under `/orchestrate`,
48
+ L1 solo). When another phase needs one, it returns a handoff (`status: NEXT`, `next: <phase>`) and
49
+ the router invokes it. Concretely: review returns `next: remediate` instead of calling remediation.
50
+
51
+ **R2 — Non-dispatching forks may nest.** Phases and children that never spawn sub-agents may invoke
52
+ further forks at any depth (F5): build → context/architect/implement/validate → quality-gate,
53
+ review → quality-gate / missing-phase re-dispatch, remediate → close. Forking them keeps each
54
+ context small. If one of them ever needs to spawn sub-agents, it becomes a dispatching phase and
55
+ R1 applies.
56
+
57
+ **R3 — Phases run synchronously.** Every forked phase and child declares `background: false`, so a
58
+ `Skill(...)` call returns the `*_RESULT` block in the caller's turn. The "running/empty return"
59
+ handling in the router's Hard Rule 3a stays as a defensive fallback only.
60
+
61
+ **R4 — Depth preflight, fail loud.** Router Phase 0 checks that a dispatching phase will have the
62
+ `Agent` tool: router layer + 2 ≤ effective spawn depth (router layer 1 with `--under-orchestration`,
63
+ else 0; depth from `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH`, else the Claude Code version default).
64
+ If not, the router stops before any work with a clear blocker instead of discovering it after the
65
+ code is written (`scripts/spawn-depth-check.sh`).
66
+
67
+ **R5 — Fallbacks stay fallbacks.** `REREVIEW_REQUIRED` (forge#3240) and the stranded-handoff
68
+ handling from forge#3391 / PR #3395 remain as safety nets for an unexpected runtime, not as the
69
+ normal remediation path.
70
+
71
+ ## 4. Layer map
72
+
73
+ | Skill | Fork | Dispatches sub-agents | Invoked by | Layer solo / orchestrated |
74
+ |-------|------|-----------------------|------------|---------------------------|
75
+ | `work-on` (router) | — | no | user / orchestrator worker | 0 / 1 |
76
+ | `work-on:investigate`, `work-on:decompose`, `work-on:build`, `work-on:close` | yes | no | router | 1 / 2 |
77
+ | `work-on:build:{context,architect,implement,validate}` | yes | no | `work-on:build` (R2) | 2 / 3 |
78
+ | `quality-gate` | yes | no | validate (R2) | 3 / 4 |
79
+ | `quality-gate` (stale-review re-gate) | yes | no | review (R2) | 2 / 3 |
80
+ | `work-on:review` → `/review-pr` | yes | **yes** | router only (R1) | 1 / 2 |
81
+ | `work-on:remediate` → `/review-pr` | yes | **yes** | router only (R1) | 1 / 2 |
82
+ | `/review-pr` domain reviewers | Agent | no (leaves) | `/review-pr` | 2 / 3 |
83
+
84
+ `/orchestrate` Step 4B item 6.4 dispatches a worker that runs `work-on <pr> --remediate`: worker L1,
85
+ remediate L2, reviewers L3, same as the table.
86
+
87
+ ## 5. Router routing
88
+
89
+ | Phase result | Router action |
90
+ |--------------|---------------|
91
+ | `REVIEW_RESULT: status: NEXT, next: remediate` | Phase 4R: post the bound marker (`CI_REMEDIATION` / `INPR_REMEDIATION`) immediately before invoking `work-on:remediate`, so an interrupted handoff is retried, not counted. `AUTO-LANDED` → done (remediation already ran close). `REREVIEW_REQUIRED` → fallback re-review from the router. `ALREADY_DONE` (single-attempt guard) → `inpr-fix`: re-invoke review (waive); `ci-gate`: ensure `needs-human`, stop. `BLOCKED` → ensure `needs-human`, stop. Resume after an interrupted remediation (no `needs-human`, no later `REMEDIATION:COMPLETE`) re-adds `needs-human` and returns to 4R, not to review. Other outcome: `inpr-fix` → re-invoke review once (it waives the in-PR gate); `ci-gate` → terminal. |
92
+ | `REMEDIATE_RESULT` from `--remediate` entry | unchanged (Phase 0A.1) |
93
+
94
+ ## 6. What does not change
95
+
96
+ - Build still sequences its own children; the headless engine (`bin/engine/phases.mjs`) is unaffected.
97
+ - `/orchestrate` still dispatches one worker `Agent` per issue (L1).
98
+ - Phases keep their specs, `*_RESULT` contracts and GitHub markers; phase-trail verification is unchanged.
99
+ - Solo `/work-on` gets one extra layer of headroom.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bash
2
+ # spawn-depth-check.sh — will a /work-on dispatching phase (review, remediate) have the Agent tool?
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Claude Code grants the Agent tool to sub-agents down to CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH layers
7
+ # below the main conversation (default 3 since 2.1.219; 5 for 2.1.172-2.1.216; 1 for 2.1.217-2.1.218).
8
+ # A sub-agent at layer L can spawn only when L < depth. The router invokes review/remediate at
9
+ # router layer + 1, so they can spawn reviewers only when router layer + 2 <= depth (forge#3398,
10
+ # docs/WORK-ON-RUNTIME.md).
11
+ #
12
+ # Usage: spawn-depth-check.sh [--router-layer N] (N = 0 solo, 1 under /orchestrate; default 0)
13
+ # Env: CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH explicit limit (wins over the version default)
14
+ # FORGE_CLAUDE_VERSION version string override (tests); else `claude --version`
15
+ # Exit: 0 OK / SKIP / UNKNOWN (never blocks on what it cannot determine), 1 FAIL, 2 usage error.
16
+ set -u
17
+
18
+ ROUTER_LAYER=0
19
+ while [ $# -gt 0 ]; do
20
+ case "$1" in
21
+ --router-layer) ROUTER_LAYER="${2:-}"; shift 2 || { echo "usage: spawn-depth-check.sh [--router-layer N]" >&2; exit 2; } ;;
22
+ *) echo "usage: spawn-depth-check.sh [--router-layer N]" >&2; exit 2 ;;
23
+ esac
24
+ done
25
+ case "$ROUTER_LAYER" in ''|*[!0-9]*) echo "spawn-depth-check: --router-layer must be a non-negative integer" >&2; exit 2 ;; esac
26
+ ROUTER_LAYER=$((10#$ROUTER_LAYER)) # leading zeros are decimal, not octal
27
+
28
+ # Non-Claude runtimes (Codex, OpenCode) have their own sub-agent models; this check does not apply.
29
+ if [ "${FORGE_RUNTIME:-}" = "codex" ] || [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
30
+ [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_PID:-}" ] || [ -n "${OPENCODE:-}" ]; then
31
+ echo "SPAWN_DEPTH: SKIP (non-Claude runtime)"
32
+ exit 0
33
+ fi
34
+
35
+ REQUIRED=$((ROUTER_LAYER + 2))
36
+ DEPTH=""
37
+ SOURCE=""
38
+ ENV_DEPTH="${CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH:-}"
39
+ if [ -n "$ENV_DEPTH" ]; then
40
+ case "$ENV_DEPTH" in
41
+ *[!0-9]*) echo "SPAWN_DEPTH: UNKNOWN (CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH='${ENV_DEPTH}' is not an integer)"; exit 0 ;;
42
+ esac
43
+ DEPTH=$((10#$ENV_DEPTH)); SOURCE="CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH"
44
+ else
45
+ VERSION="${FORGE_CLAUDE_VERSION:-}"
46
+ [ -n "$VERSION" ] || VERSION=$(claude --version 2>/dev/null | head -1 || true)
47
+ VERSION=$(printf '%s' "$VERSION" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
48
+ if [ -z "$VERSION" ]; then
49
+ echo "SPAWN_DEPTH: UNKNOWN (Claude Code version not readable; required=${REQUIRED})"
50
+ exit 0
51
+ fi
52
+ MAJ=${VERSION%%.*}; REST=${VERSION#*.}; MIN=${REST%%.*}; PAT=${REST#*.}
53
+ MAJ=$((10#$MAJ)); MIN=$((10#$MIN)); PAT=$((10#$PAT))
54
+ if [ "$MAJ" -gt 2 ] || { [ "$MAJ" -eq 2 ] && [ "$MIN" -gt 1 ]; }; then DEPTH=3
55
+ elif [ "$MAJ" -eq 2 ] && [ "$MIN" -eq 1 ]; then
56
+ if [ "$PAT" -ge 219 ]; then DEPTH=3
57
+ elif [ "$PAT" -ge 217 ]; then DEPTH=1
58
+ elif [ "$PAT" -ge 172 ]; then DEPTH=5
59
+ fi
60
+ fi
61
+ if [ -z "$DEPTH" ]; then
62
+ echo "SPAWN_DEPTH: UNKNOWN (no documented nesting default for Claude Code ${VERSION}; required=${REQUIRED})"
63
+ exit 0
64
+ fi
65
+ SOURCE="Claude Code ${VERSION} default"
66
+ fi
67
+
68
+ if [ "$DEPTH" -ge "$REQUIRED" ]; then
69
+ echo "SPAWN_DEPTH: OK depth=${DEPTH} required=${REQUIRED} (${SOURCE})"
70
+ exit 0
71
+ fi
72
+ echo "SPAWN_DEPTH: FAIL depth=${DEPTH} required=${REQUIRED} (${SOURCE}) — review/remediate would run without the Agent tool and could not spawn /review-pr reviewers. Set CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH>=${REQUIRED}."
73
+ exit 1
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # spawn-depth-check.test.sh — cases for scripts/spawn-depth-check.sh (forge#3398).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/spawn-depth-check.sh"
7
+ PASS=0; FAILN=0
8
+ t() { # name want-rc want-prefix env-assignments... -- args...
9
+ local name="$1" want="$2" prefix="$3"; shift 3
10
+ local envs=() args=()
11
+ while [ $# -gt 0 ] && [ "$1" != "--" ]; do envs+=("$1"); shift; done
12
+ [ $# -gt 0 ] && shift
13
+ args=(); [ $# -gt 0 ] && args=("$@")
14
+ local out rc
15
+ out=$(env -u CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH -u FORGE_CLAUDE_VERSION -u FORGE_RUNTIME \
16
+ -u OPENCODE_SESSION_ID -u OPENCODE_PID -u OPENCODE ${envs[@]+"${envs[@]}"} bash "$S" ${args[@]+"${args[@]}"} 2>&1); rc=$?
17
+ if [ "$rc" = "$want" ] && [ "${out#"$prefix"}" != "$out" ]; then PASS=$((PASS+1))
18
+ else FAILN=$((FAILN+1)); echo "FAIL: $name (rc=$rc want $want; out=$out)"; fi
19
+ }
20
+ # Version defaults: 2.1.219+ = 3, 2.1.217-218 = 1, 2.1.172-216 = 5.
21
+ t "current default solo" 0 "SPAWN_DEPTH: OK" FORGE_CLAUDE_VERSION="2.1.294 (Claude Code)" --
22
+ t "current default orchestrated" 0 "SPAWN_DEPTH: OK" FORGE_CLAUDE_VERSION=2.1.294 -- --router-layer 1
23
+ t "boundary 2.1.219 orchestrated" 0 "SPAWN_DEPTH: OK" FORGE_CLAUDE_VERSION=2.1.219 -- --router-layer 1
24
+ t "depth-1 window solo" 1 "SPAWN_DEPTH: FAIL" FORGE_CLAUDE_VERSION=2.1.217 --
25
+ t "depth-1 window 2.1.218" 1 "SPAWN_DEPTH: FAIL" FORGE_CLAUDE_VERSION=2.1.218 -- --router-layer 1
26
+ t "five-layer era orchestrated" 0 "SPAWN_DEPTH: OK" FORGE_CLAUDE_VERSION=2.1.200 -- --router-layer 1
27
+ t "boundary 2.1.172" 0 "SPAWN_DEPTH: OK" FORGE_CLAUDE_VERSION=2.1.172 -- --router-layer 1
28
+ t "pre-nesting version unknown" 0 "SPAWN_DEPTH: UNKNOWN" FORGE_CLAUDE_VERSION=2.1.100 -- --router-layer 1
29
+ t "future minor uses 3" 0 "SPAWN_DEPTH: OK" FORGE_CLAUDE_VERSION=2.2.0 -- --router-layer 1
30
+ t "unparseable version unknown" 0 "SPAWN_DEPTH: UNKNOWN" FORGE_CLAUDE_VERSION=garbage --
31
+ # Explicit env wins over the version default.
32
+ t "env 2 orchestrated fails" 1 "SPAWN_DEPTH: FAIL" CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=2 FORGE_CLAUDE_VERSION=2.1.294 -- --router-layer 1
33
+ t "env 2 solo ok" 0 "SPAWN_DEPTH: OK" CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=2 FORGE_CLAUDE_VERSION=2.1.294 --
34
+ t "env 1 disables nesting" 1 "SPAWN_DEPTH: FAIL" CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=1 --
35
+ t "env raises old window" 0 "SPAWN_DEPTH: OK" CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=3 FORGE_CLAUDE_VERSION=2.1.217 -- --router-layer 1
36
+ t "env non-integer unknown" 0 "SPAWN_DEPTH: UNKNOWN" CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=abc --
37
+ # Non-Claude runtimes skip.
38
+ t "codex skip" 0 "SPAWN_DEPTH: SKIP" FORGE_RUNTIME=codex CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=1 --
39
+ t "opencode marker skip" 0 "SPAWN_DEPTH: SKIP" OPENCODE_SESSION_ID=x CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=1 --
40
+ # Leading zeros are decimal (not octal).
41
+ t "router layer 08 decimal" 1 "SPAWN_DEPTH: FAIL" FORGE_CLAUDE_VERSION=2.1.294 -- --router-layer 08
42
+ t "env depth 09 decimal" 0 "SPAWN_DEPTH: OK" CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=09 -- --router-layer 1
43
+ # Usage errors.
44
+ t "bad router layer" 2 "spawn-depth-check:" -- --router-layer x
45
+ t "unknown flag" 2 "usage:" -- --nope
46
+ echo "spawn-depth-check: ${PASS} passed, ${FAILN} failed"
47
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # trusted-comments.sh — the ONE trust predicate for FORGE marker comments read by the review/merge gates.
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Reads the JSON of `gh api --paginate repos/<repo>/issues/<n>/comments` on stdin (one array, or several
7
+ # concatenated arrays from pagination) and keeps only comments from a trusted author. A comment is trusted when
8
+ # - its author_association is in FORGE_TRAIL_TRUSTED_ASSOCIATIONS (default OWNER,MEMBER,COLLABORATOR), OR
9
+ # - its user.type is "Bot" (a GitHub App installation always reports author_association NONE), OR
10
+ # - its user.login is in FORGE_TRAIL_TRUSTED_LOGINS (default empty).
11
+ # This is the same predicate as scripts/verify-phase-trail.sh; trusted-comments.test.sh guards against drift.
12
+ # ${VAR-default} (no colon): an explicitly empty association list trusts no association (fail closed).
13
+ #
14
+ # Usage:
15
+ # gh api --paginate ... | bash scripts/trusted-comments.sh count '<jq regex>' # prints the number of trusted comments whose body matches
16
+ # gh api --paginate ... | bash scripts/trusted-comments.sh bodies '<jq regex>' # prints each matching trusted body as one JSON string per line
17
+ # The regex is matched against the raw body (use ^ to anchor at the marker start).
18
+ # Exit: 0 ok; 2 usage error, invalid regex or unparsable input (stdout empty) so callers fail closed.
19
+
20
+ set -uo pipefail
21
+
22
+ MODE="${1:-}"; RE="${2-}"
23
+ case "$MODE" in count|bodies) ;; *) echo "usage: trusted-comments.sh count|bodies <regex>" >&2; exit 2 ;; esac
24
+ [ -n "$RE" ] || { echo "trusted-comments.sh: empty regex" >&2; exit 2; }
25
+
26
+ TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"
27
+ TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"
28
+
29
+ if [ "$MODE" = "count" ]; then OUT='length'; JQ_FLAGS=(-r); else OUT='.[]'; JQ_FLAGS=(-c); fi
30
+
31
+ OUTPUT=$(jq -s "${JQ_FLAGS[@]}" --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg re "$RE" '
32
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
33
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
34
+ | [ .[][]
35
+ | select(
36
+ ((.author_association // "") as $x | $A | index($x) != null)
37
+ or ((.user.type // "") == "Bot")
38
+ or ((.user.login // "") as $x | $L | index($x) != null)
39
+ )
40
+ | select((.body // "") | test($re))
41
+ | (.body // "")
42
+ ] | '"$OUT" 2>/dev/null) || { echo "trusted-comments.sh: could not parse comments or regex" >&2; exit 2; }
43
+ [ -z "$OUTPUT" ] && [ "$MODE" = "count" ] && { echo "trusted-comments.sh: no output" >&2; exit 2; }
44
+ [ -z "$OUTPUT" ] || printf '%s\n' "$OUTPUT"
45
+ exit 0
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # trusted-comments.test.sh — tests for scripts/trusted-comments.sh (shared FORGE marker trust predicate)
3
+ # No network. Usage: bash scripts/trusted-comments.test.sh
4
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+
7
+ set -uo pipefail
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ TC="$SCRIPT_DIR/trusted-comments.sh"
10
+ VERIFY="$SCRIPT_DIR/verify-phase-trail.sh"
11
+ unset FORGE_TRAIL_TRUSTED_ASSOCIATIONS FORGE_TRAIL_TRUSTED_LOGINS
12
+
13
+ PASS=0; FAILN=0
14
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
15
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
16
+ eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
17
+
18
+ # c <assoc> <type> <login> <body>
19
+ c() { jq -nc --arg a "$1" --arg t "$2" --arg l "$3" --arg b "$4" '{author_association:$a,user:{type:$t,login:$l},body:$b}'; }
20
+ M='<!-- FORGE:NOTE_DISPOSITION notes_fixed=0 -->'
21
+ RE='^<!-- FORGE:NOTE_DISPOSITION'
22
+ cnt() { printf '%s' "$1" | bash "$TC" count "$RE"; }
23
+
24
+ eq "bot (type=Bot, assoc NONE) accepted" "$(cnt "[$(c NONE Bot 'app[bot]' "$M")]")" 1
25
+ for a in OWNER MEMBER COLLABORATOR; do
26
+ eq "$a accepted" "$(cnt "[$(c $a User u "$M")]")" 1
27
+ done
28
+ for a in NONE CONTRIBUTOR FIRST_TIME_CONTRIBUTOR; do
29
+ eq "human $a rejected" "$(cnt "[$(c $a User u "$M")]")" 0
30
+ done
31
+ eq "trusted login honoured (assoc NONE)" "$(FORGE_TRAIL_TRUSTED_LOGINS="x, alice" cnt "[$(c NONE User alice "$M")]")" 1
32
+ eq "other login still rejected" "$(FORGE_TRAIL_TRUSTED_LOGINS="alice" cnt "[$(c NONE User mallory "$M")]")" 0
33
+ eq "narrowed associations reject MEMBER" "$(FORGE_TRAIL_TRUSTED_ASSOCIATIONS=OWNER cnt "[$(c MEMBER User u "$M")]")" 0
34
+ eq "widened associations accept CONTRIBUTOR" "$(FORGE_TRAIL_TRUSTED_ASSOCIATIONS=OWNER,CONTRIBUTOR cnt "[$(c CONTRIBUTOR User u "$M")]")" 1
35
+ eq "explicit empty associations reject non-bot" "$(FORGE_TRAIL_TRUSTED_ASSOCIATIONS= cnt "[$(c OWNER User u "$M")]")" 0
36
+ eq "explicit empty associations still accept bot" "$(FORGE_TRAIL_TRUSTED_ASSOCIATIONS= cnt "[$(c NONE Bot b "$M")]")" 1
37
+ eq "marker mid-body not counted (^ anchoring)" "$(cnt "[$(c OWNER User u "see $M")]")" 0
38
+ eq "paginated concatenated arrays" "$(cnt "[$(c OWNER User u "$M")] [$(c NONE Bot b "$M")] [$(c NONE User h "$M")]")" 2
39
+ eq "empty array counts 0" "$(cnt '[]')" 0
40
+
41
+ # bodies mode
42
+ B=$(printf '%s' "[$(c NONE Bot b '<!-- FORGE:INPR_FIX: head=abc1234 -->'),$(c NONE User h '<!-- FORGE:INPR_FIX: head=dead999 -->')]" | bash "$TC" bodies '^<!-- FORGE:INPR_FIX: ')
43
+ eq "bodies emits only trusted bodies" "$B" '"<!-- FORGE:INPR_FIX: head=abc1234 -->"'
44
+ eq "bodies with no match prints nothing" "$(printf '[]' | bash "$TC" bodies x)" ""
45
+
46
+ # fail closed
47
+ printf 'not json' | bash "$TC" count "$RE" >/dev/null 2>&1; eq "invalid JSON exits non-zero" "$?" 2
48
+ printf '[{"body":"x","user":{"type":"Bot"}}]' | bash "$TC" count '(' >/dev/null 2>&1; eq "invalid regex exits non-zero" "$?" 2
49
+ printf '[]' | bash "$TC" frobnicate x >/dev/null 2>&1; eq "bad mode exits non-zero" "$?" 2
50
+ printf '[]' | bash "$TC" count >/dev/null 2>&1; eq "missing regex exits non-zero" "$?" 2
51
+
52
+ # drift guard: the predicate lines must match verify-phase-trail.sh verbatim
53
+ for line in \
54
+ '((.author_association // "") as $x | $A | index($x) != null)' \
55
+ '((.user.type // "") == "Bot")' \
56
+ '((.user.login // "") as $x | $L | index($x) != null)' \
57
+ 'TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"' \
58
+ 'TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"'; do
59
+ if grep -qF -- "$line" "$TC" && grep -qF -- "$line" "$VERIFY"; then ok "predicate in sync: $line"; else bad "predicate drift vs verify-phase-trail.sh: $line"; fi
60
+ done
61
+
62
+ echo "passed=$PASS failed=$FAILN"
63
+ [ "$FAILN" -eq 0 ]