forgedock 1.10.3 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bin/engine/phases-args.test.mjs +112 -0
  2. package/bin/engine/phases.mjs +152 -45
  3. package/bin/engine/runlog.mjs +9 -0
  4. package/bin/engine-cli.mjs +13 -2
  5. package/bin/engine.mjs +144 -18
  6. package/bin/forgedock.mjs +2 -2
  7. package/bin/runner.mjs +89 -12
  8. package/commands/ci-audit.md +3 -3
  9. package/commands/compat-audit.md +1 -1
  10. package/commands/deploy-pr.md +6 -6
  11. package/commands/explain.md +2 -2
  12. package/commands/fix-ci.md +2 -2
  13. package/commands/forge-stats.md +1 -1
  14. package/commands/forgedock-init.md +3 -3
  15. package/commands/optimize.md +5 -5
  16. package/commands/orchestrate/phase-1-resolve.md +2 -2
  17. package/commands/orchestrate/phase-4-execution.md +208 -48
  18. package/commands/orchestrate/phase-6-report.md +29 -0
  19. package/commands/quality-gate.md +97 -13
  20. package/commands/recover-orphans.md +6 -6
  21. package/commands/review-pr-agents/protocols.md +1 -0
  22. package/commands/review-pr-staging.md +28 -6
  23. package/commands/review-pr.md +509 -132
  24. package/commands/security-audit.md +1 -1
  25. package/commands/spec-doctor.md +2 -2
  26. package/commands/test-gate.md +7 -7
  27. package/commands/work-on/build/architect.md +21 -0
  28. package/commands/work-on/build/context.md +1 -1
  29. package/commands/work-on/build/implement.md +17 -0
  30. package/commands/work-on/build/validate.md +60 -9
  31. package/commands/work-on/build.md +98 -9
  32. package/commands/work-on/close.md +33 -11
  33. package/commands/work-on/decompose.md +19 -5
  34. package/commands/work-on/investigate.md +34 -4
  35. package/commands/work-on/remediate.md +117 -13
  36. package/commands/work-on/review.md +66 -26
  37. package/commands/work-on.md +32 -15
  38. package/docs/CONFIG.md +30 -0
  39. package/docs/RELEASE-NOTES.md +9 -0
  40. package/docs/WORK-ON-RUNTIME.md +24 -4
  41. package/docs/site/architecture.md +1 -1
  42. package/docs/spec/forge-protocol-v1.md +10 -0
  43. package/docs/spec/review-protocol.md +1 -0
  44. package/package.json +1 -1
  45. package/packages/protocol/src/phases.js +6 -1
  46. package/packages/protocol/src/types.js +17 -0
  47. package/scripts/check-branch-ancestry.sh +82 -0
  48. package/scripts/check-branch-ancestry.test.sh +78 -0
  49. package/scripts/check-spec-bash.sh +45 -2
  50. package/scripts/check-spec-bash.test.sh +24 -1
  51. package/scripts/check-spec-markers.sh +55 -0
  52. package/scripts/diff-size.sh +84 -0
  53. package/scripts/diff-size.test.sh +117 -0
  54. package/scripts/forge-root.test.sh +16 -1
  55. package/scripts/remediation-marker-match.test.sh +51 -0
  56. package/scripts/review-pr-arguments.test.sh +180 -0
  57. package/scripts/spec-no-worktree-script.test.sh +46 -0
  58. package/scripts/trusted-comments.test.sh +23 -0
@@ -27,7 +27,7 @@ Orchestrator for the full issue lifecycle: investigate → decompose (if needed)
27
27
 
28
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.
29
29
 
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.
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. Spawn-site ownership (`docs/WORK-ON-RUNTIME.md` R6): an `Agent` launched directly by this worker reports to it, but one launched inside a forked phase (including the `/review-pr` reviewers) is attributed to the root, so only GitHub markers are reliable completion signals for those. 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; for review/remediate, a complete current-SHA `FORGE:REVIEW-AGENT` panel plus a `FORGE:REVIEW` verdict is consumed, not counted as a stall), 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.
31
31
 
32
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`.
33
33
 
@@ -72,7 +72,7 @@ A sub-agent that receives no resolved value applies the same rule itself.
72
72
  |------|-------|----------------|-----------------|
73
73
  | 0 | Resolve & resume | — (router) | Always first |
74
74
  | 1 | Investigate | `work-on:investigate` | No `FORGE:INVESTIGATOR` comment containing `INVESTIGATION:COMPLETE` or `INVESTIGATION:INVALID` |
75
- | 2 | Decompose | `work-on:decompose` | `INVESTIGATE_RESULT.decompose: YES` (or resume: investigation says decompose YES and no `FORGE:DECOMPOSED`) |
75
+ | 2 | Decompose | `work-on:decompose` | `INVESTIGATE_RESULT.decompose: YES` (or resume: investigation says decompose YES and no `FORGE:DECOMPOSED`) or `BUILD_RESULT: NEEDS_DECOMPOSE` (`FORGE:DIFF_SIZE` over, no override) |
76
76
  | 3 | Build | `work-on:build` | Investigation complete, decompose NO, no `FORGE:BUILDER:COMPLETE` |
77
77
  | 4 | Review (push, PR, review, merge) | `work-on:review` | `FORGE:BUILDER:COMPLETE` present and PR not merged |
78
78
  | 4R | Remediation handoff | `work-on:remediate` | `REVIEW_RESULT: status: NEXT, next: remediate` (CI gate red or in-PR fix requested) |
@@ -243,7 +243,7 @@ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || r
243
243
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
244
244
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
245
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)"
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
247
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
248
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
249
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -294,9 +294,17 @@ If detected, dispatch immediately and STOP — do NOT fall through to Phase 0B's
294
294
  Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="${REMEDIATE_PR_NUMBER} ${REMEDIATE_ISSUE_FLAG} --repo {GH_REPO} --gh-flag {GH_FLAG}")
295
295
  ```
296
296
 
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`. If the `review-pr` skill does not resolve, cannot dispatch, or returns anything other than `REVIEW_RESULT: status: COMPLETE` while the issue is still at `workflow:in-review` with no `needs-human` (no state anything will advance), apply the **terminal fallback**. Single attempt: no retry loop, no polling. Then STOP. <!-- Added: forge#3406 -->
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, after the **claim pre-check** below — `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`. If the claim cannot be recorded, the `review-pr` skill does not resolve, cannot dispatch, or returns anything other than `REVIEW_RESULT: status: COMPLETE` while the issue is still at `workflow:in-review` with no `needs-human` (no state anything will advance), apply the **terminal fallback**. Single attempt: no retry loop, no polling. Then STOP. <!-- Added: forge#3406; claim pre-check forge#3413 -->
298
298
 
299
- **Terminal fallback** (canonical definition; Phase 4R and `commands/orchestrate/phase-4-execution.md` item 6.4 reference it, so the review and remediate paths share one): a re-review that cannot run must never leave the issue at `workflow:in-review` with nothing to advance it. Post a comment naming the PR and the reason (e.g. `review-pr could not be dispatched: <reason>`), then in ONE `gh issue edit` add `needs-human` and remove `workflow:in-review` (never one without the other), and re-read the labels to verify the write. If the label write failed, report that failure as the outcome instead of treating the fallback as done. Never review inline, and never re-nest remediation to retry.
299
+ **Claim pre-check (forge#3413; one per-head marker shared with the orchestrator)**: the orchestrator's re-review handoff (`commands/orchestrate/phase-4-execution.md` Step 1/2) dedupes on `<!-- FORGE:REREVIEW_DISPATCHED -->`, so the router MUST write the SAME marker before `review-pr`, or the orchestrator re-dispatches after the router already re-reviewed. In order, failing closed (any failure = no dispatch, terminal fallback):
300
+ 1. **Labels first**: read the issue's state and labels (`gh issue view ${ISSUE} {GH_FLAG} --json labels,state`). If the issue is closed, already has `needs-human`, or lacks `workflow:in-review`, do NOT dispatch and do NOT apply the fallback (something else already advanced it); STOP.
301
+ 2. **Existing claim**: list the PR's comments and apply the trusted-author, leading-marker predicate. Bash blocks are independent executions and a standalone router session has none of the orchestrator's shell functions, so every claim, read-back, classification and release block MUST paste verbatim, at its top, the **required helper set** from `phase-4-execution.md`: `trail_cache_dir` and `resolve_orch_login` (defined earlier in that file; transitive deps of `rereview_trusted_rows`), then the Step 1 SHARED HELPERS `rereview_trusted_rows`, `REREVIEW_CLAIMS`, `REREVIEW_DONE_FILTER`, `REREVIEW_LEASE_SECS` / `REREVIEW_MAX_SECS`, `REREVIEW_LEASE_FILTER`, `REREVIEW_FIRST_LEASE_FILTER` and `REREVIEW_RELEASED_FILTER`. Never improvise the predicate: too loose lets an untrusted commenter suppress a re-review, too strict drops the router's own bot-authored claim. If a trusted claim (lowest comment id wins) already embeds the PR's current head SHA (`headRefOid`), classify it with the single definition in `phase-4-execution.md` Step 1 (forge#3428: a `FORGE:REREVIEW_RELEASED` marker means the claimant finished; an unexpired or renewed lease, or an expired lease with liveness such as a recent trusted `FORGE:REVIEW*` comment or running checks, means another actor is live; a merged/closed PR is done): for released, live or done do NOT dispatch; STOP without the fallback. Only an expired lease with no liveness and an open PR (`STALE`, which includes an unparseable `Lease:`) goes to the terminal fallback with reason `stale re-review claim`; a failed liveness lookup fails closed to STOP (never `STALE`). Do not duplicate the predicate here (reference and paste, never re-write).
302
+ 2b. **Unresolved login (fail closed, before the claim)**: with the helpers pasted, run `resolve_orch_login`. If it prints an empty login (and `FORGE_BOT_LOGIN` is unset), the router's own claim could never be verified: do NOT post the claim and do NOT dispatch. Treat it as `UNREADABLE` (writes nothing, no terminal fallback write, never `STALE`), report the unresolved login in the operator-visible report line, and STOP; the orchestrator re-checks next cycle (mirrors `phase-4-execution.md` Step 1 SEC-8).
303
+ 3. **Claim**: unless `DRY_RUN=true` (then record "would claim and re-review" and STOP), post the marker with `Actor: router`, the head SHA, a unique `Claim:` id and a UTC `Lease:` timestamp, in the exact format defined in `phase-4-execution.md` Step 1 (`<!-- FORGE:REREVIEW_DISPATCHED -->` as the first line, then `Head: <sha>`, `Actor: router`, `Claim: router-<epoch>-<pid>`, `Lease: <UTC ISO-8601>`), with the `gh pr comment` call checked (a failed post is a failure, never ignored).
304
+ 4. **Read back** the PR comments (trusted rows only, projecting the comment `id`, after a short settle delay): the lowest-comment-id trusted claim for this head (`.[0]` of the id-sorted `REREVIEW_CLAIMS`) must carry this router's `Claim:` id; re-read once after verifying and treat a late lower-id claim as `LOST`. A missing row, an API failure, or a different earliest claim (`LOST`) means do NOT dispatch, and none of them stands down blindly: re-run the shared Step 1 classification from `phase-4-execution.md` (forge#3431; the same required helper set from step 2, including `trail_cache_dir` and `resolve_orch_login`, pasted at the top of the block, no router-local lease helper or constant) for this head and map the printed `REREVIEW_STATE`: `STALE` (an expired lease with no liveness and an open PR, a lease past `REREVIEW_MAX_SECS`, or an unparseable `Lease:` on the winning claim; a crashed earlier claimant) goes to the terminal fallback with reason `stale re-review claim`; `CLAIMED` (another actor's live claim) and `NONE` (released or done) stand down without the fallback; `UNREADABLE` writes nothing (fail closed, never `STALE`); `PENDING` goes to the fallback only for the missing-row / API-failure case. For the missing-row / API-failure case only, the printed `FIRST_CLAIM_ID` (the winning `.[0]` claim) equal to this router's own `Claim:` id goes to the fallback (our own unverified claim must not stand the router down on itself), while another actor's id is classified like `LOST`. On `LOST` the winner is by definition another actor's claim, so a `STALE` winner is never excused as "owned by another actor".
305
+ Only after the read-back verifies does the router invoke `review-pr`. After `review-pr` returns (any result), the router posts a checked, DRY_RUN-guarded `<!-- FORGE:REREVIEW_RELEASED -->` marker (`Head: <sha>`, `Actor: router`, `Claim: <its Claim id>`) in the format defined in `phase-4-execution.md` Step 2 (Renew and release; the release block also pastes the same required helper set), so the orchestrator reads `NONE` rather than aging the claim into `STALE`; the router may also renew the lease between phases by re-posting the claim with the same `Claim:` id and a fresh `Lease:`.
306
+
307
+ **Terminal fallback** (canonical definition; Phase 4R and `commands/orchestrate/phase-4-execution.md` item 6.4 reference it, so the review and remediate paths share one): a re-review that cannot run must never leave the issue at `workflow:in-review` with nothing to advance it. First re-read the issue's labels: if `needs-human` is already set and `workflow:in-review` is absent, the fallback has already been applied (or something else escalated it), so skip the edit and report that state (idempotent; forge#3413). Otherwise post a comment naming the PR and the reason (e.g. `review-pr could not be dispatched: <reason>`), then in ONE `gh issue edit` add `needs-human` and remove `workflow:in-review` (never one without the other), and re-read the labels to verify the write. If the label write failed, report that failure as the outcome instead of treating the fallback as done. Before commenting, re-check the PR state and liveness (forge#3428): a merged or closed PR, or another actor's claimant with recent activity per `phase-4-execution.md` Step 1 (this actor's own `Claim:` id never counts as that claimant, or an own-claim fallback would cancel itself), means skip the escalation and report that state. Never review inline, and never re-nest remediation to retry.
300
308
 
301
309
  **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.
302
310
 
@@ -324,7 +332,7 @@ _l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || r
324
332
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
325
333
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
326
334
  # 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)
327
- _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)"
335
+ _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)"
328
336
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
329
337
  # '${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.
330
338
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -373,7 +381,7 @@ gh api repos/{GH_REPO}/issues/{NUMBER}/comments --jq '.[] | {id: .id, author: .u
373
381
 
374
382
  **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).
375
383
 
376
- **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.
384
+ **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}`/`FORGE:BASESYNC_REMEDIATION: pr={PR}` marker (for the open PR) has no `FORGE:REMEDIATION:COMPLETE` trail comment on that PR created after it (a trail comment is one that STARTS with `<!-- FORGE:REMEDIATION -->`, contains `<!-- FORGE:REMEDIATION:COMPLETE -->` and comes from a trusted author, read with `scripts/trusted-comments.sh`; a comment that merely quotes the marker never counts, forge#3412; if the comments or the helper are unreadable, fail closed to `needs-human` instead of resuming) → 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.
377
385
 
378
386
  ### 0B.5: Read Phase Checkpoint (MANDATORY — executes before any phase-skip decision)
379
387
 
@@ -481,7 +489,7 @@ if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac
481
489
  # Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
482
490
  _cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
483
491
  # 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)
484
- _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)"
492
+ _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)"
485
493
  _m="$HOME/.claude/plugins/marketplaces/$_mk"
486
494
  # '${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.
487
495
  _k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
@@ -500,7 +508,7 @@ UNIVERSAL_DIR="${FORGE_ROOT:+$FORGE_ROOT/scripts}" # empty => tier 3 skipped,
500
508
  # to search the filesystem. <!-- Added: forge#1984 -->
501
509
 
502
510
  resolve_script() {
503
- local operation="$1"
511
+ local operation="${1}"
504
512
  # Tier 2: per-repo adaptive (skip if disabled)
505
513
  if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
506
514
  echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
@@ -708,6 +716,7 @@ The build phase owns classification (`FORGE:FAST_PATH`), the worktree, the `work
708
716
  | `COMPLETE` | Marker gate (below) → record `BRANCH` and `WORKTREE_PATH` from the result → Phase 4 |
709
717
  | `ALREADY_DONE` | Record `BRANCH`/`WORKTREE_PATH` (from the result, or `git worktree list` for the issue branch) → Phase 4 |
710
718
  | `INVESTIGATION_COMPLETE` | Investigation-type task: deliverable issues were filed and the original closed by the phase → Phase 5 with `--terminal-state investigation` (no review) |
719
+ | `NEEDS_DECOMPOSE` | The build's diff-size gate found the staged diff over `build.diff_size.threshold` with no `FORGE:SIZE_OVERRIDE` (split proposal in `FORGE:DIFF_SIZE`; not a failure, no `needs-human`) → run Phase 2 (`work-on:decompose`) and apply its `DECOMPOSE_RESULT` table; build never calls decompose itself |
711
720
  | `BLOCKED` | Terminal: confirm `needs-human` is present (the phase adds it with the blocker) and STOP |
712
721
 
713
722
  **Marker gate — Phase 3 exit**: a `FORGE:BUILDER` comment must contain `FORGE:BUILDER:COMPLETE`. If absent, invoke `work-on:build` once more with the same args; if still absent, post `<!-- FORGE:GATE_FAILURE -->` ("builder completion marker missing after one re-invoke"), add `needs-human`, remove `workflow:building`, STOP. <!-- forge#1418 -->
@@ -722,13 +731,13 @@ Post the Phase 4 heartbeat, run Lane Resolution including the lane validation, t
722
731
  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}")
723
732
  ```
724
733
 
725
- 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.
734
+ 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, an in-PR fix request or a base conflict (`base-conflict`, merge-only base sync) comes back as `REVIEW_RESULT: status: NEXT` for Phase 4R.
726
735
 
727
736
  | `REVIEW_RESULT` | Router action |
728
737
  |---|---|
729
738
  | `COMPLETE` (PR merged) | Record `PR_NUMBER` → Phase 5 with `--terminal-state merged` |
730
739
  | `ALREADY_MERGED` | Same as COMPLETE |
731
- | `NEXT`, `next: remediate` | Record `PR_NUMBER` and `remediation` (`ci-gate` or `inpr-fix`) → Phase 4R (review handoff to remediation) |
740
+ | `NEXT`, `next: remediate` | Record `PR_NUMBER` and `remediation` (`ci-gate`, `inpr-fix` or `base-sync`) → Phase 4R (review handoff to remediation) |
732
741
  | `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. |
733
742
 
734
743
  ### Phase 4R: Remediation handoff from review
@@ -736,8 +745,14 @@ The review phase owns: ancestry and empty-branch guards, push, PR creation, `wor
736
745
  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.
737
746
 
738
747
  ```bash
739
- # {REMEDIATION_KIND} is the review result's `remediation` value: ci-gate -> CI_REMEDIATION, inpr-fix -> INPR_REMEDIATION.
740
- BOUND_MARKER="CI_REMEDIATION"; [ "{REMEDIATION_KIND}" = "inpr-fix" ] && BOUND_MARKER="INPR_REMEDIATION"
748
+ # {REMEDIATION_KIND} is the review result's `remediation` value: ci-gate -> CI_REMEDIATION, inpr-fix -> INPR_REMEDIATION, base-sync -> BASESYNC_REMEDIATION.
749
+ # An unknown kind fails closed: no marker is posted and remediation is not invoked.
750
+ case "{REMEDIATION_KIND}" in
751
+ ci-gate) BOUND_MARKER="CI_REMEDIATION" ;;
752
+ inpr-fix) BOUND_MARKER="INPR_REMEDIATION" ;;
753
+ base-sync) BOUND_MARKER="BASESYNC_REMEDIATION" ;;
754
+ *) echo "unknown remediation kind: {REMEDIATION_KIND}"; exit 1 ;;
755
+ esac
741
756
  BOUND_BODY="<!-- FORGE:${BOUND_MARKER}: pr={PR_NUMBER} -->
742
757
  Review handed PR #{PR_NUMBER} to remediation ({REMEDIATION_KIND}); the router is dispatching it once."
743
758
  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
@@ -752,12 +767,14 @@ Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", args="{PR_NUMBER} --issue {
752
767
  | `REMEDIATE_RESULT` | Router action |
753
768
  |---|---|
754
769
  | `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. |
755
- | `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`. If the re-review cannot run or returns anything else, apply the terminal fallback defined in 0A.1 (comment with reason, add `needs-human`, remove `workflow:in-review`), then STOP; do not poll. |
770
+ | `status: REREVIEW_REQUIRED` | Fallback only (should not occur at this depth): run the re-review from this router as in 0A.1 (labels checked and the per-head `FORGE:REREVIEW_DISPATCHED` claim posted and read back first, then `FORGE:REREVIEW_RELEASED` posted after `review-pr` returns), then Phase 5 on `REVIEW_RESULT: status: COMPLETE`. If the re-review cannot run or returns anything else, apply the terminal fallback defined in 0A.1 (labels re-read first, comment with reason, add `needs-human`, remove `workflow:in-review`), then STOP; do not poll. |
756
771
  | `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. |
757
772
  | `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. |
758
773
  | `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. |
774
+ | `status: ALREADY_DONE`, `remediation: base-sync` | Make sure `needs-human` is on the issue (add it, with a comment naming the PR, that a base sync already ran and the conflicting files if known, if absent). STOP. Do NOT re-invoke Phase 4: a still-conflicting PR would loop. |
759
775
  | `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. |
760
776
  | 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. |
777
+ | any other outcome, `remediation: base-sync` | Terminal: make sure `needs-human` is on the issue (add it, with a comment naming the PR and the conflicting files from remediation's `FORGE:BASESYNC_FAILED` comment, if absent). STOP. Do NOT re-invoke Phase 4: the bound is used and a still-conflicting PR would loop. |
761
778
  | any other outcome, `remediation: ci-gate` | Terminal: remediation left `needs-human` (or `workflow:awaiting-merge`) with its reason. STOP. |
762
779
 
763
780
  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 -->
@@ -783,7 +800,7 @@ Omit `--pr/--branch/--worktree` when the value is unknown (PR-less terminals).
783
800
 
784
801
  ## Remediation entry (`--remediate`)
785
802
 
786
- Handled in Phase 0A.1: `/work-on <pr> --remediate [--issue N]` dispatches `Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", ...)` (forked) and STOPS after its `REMEDIATE_RESULT`, except that `REREVIEW_REQUIRED` makes the router run `review-pr` itself first, with the terminal fallback if that cannot run (see 0A.1).
803
+ Handled in Phase 0A.1: `/work-on <pr> --remediate [--issue N]` dispatches `Skill(skill="{FORGE_SKILL_PREFIX}work-on:remediate", ...)` (forked) and STOPS after its `REMEDIATE_RESULT`, except that `REREVIEW_REQUIRED` makes the router run `review-pr` itself first (after the label pre-check and the shared per-head claim marker), with the terminal fallback if that cannot run (see 0A.1).
787
804
 
788
805
  ---
789
806
 
package/docs/CONFIG.md CHANGED
@@ -50,6 +50,7 @@ If you have both a local `claude` CLI and `ANTHROPIC_API_KEY` set and want to pi
50
50
  | [`project_board`](#project_board-optional) | No | GitHub Projects v2 integration |
51
51
  | [`orchestration`](#orchestration-optional) | No | `/orchestrate` concurrency and cascade admission policy |
52
52
  | [`pipeline`](#pipeline-optional) | No | `/orchestrate` batch-engine tuning (stall detection, token budget, narration) |
53
+ | [`build`](#build-optional) | No | `/work-on` build-phase diff-size gate (threshold, exclusion globs) |
53
54
  | [`services`](#services-optional) | No | External service URLs and IDs |
54
55
  | [`review`](#review-optional) | No | Context injected into review agents |
55
56
  | [`devdocs`](#devdocs-optional) | No | Devdocs knowledge tree path |
@@ -456,6 +457,35 @@ pipeline:
456
457
 
457
458
  ---
458
459
 
460
+ ## `build` (OPTIONAL)
461
+
462
+ Tuning for the `/work-on` build phase. Today it holds the diff-size gate: after the implement phase stages its changes and before validation commits them, the build measures the changed lines (added plus deleted) of the index and any earlier commits against `origin/{base}`, using `scripts/diff-size.sh`. Review findings per PR rise steeply with diff size, so an oversized build is split before it is validated.
463
+
464
+ ```yaml
465
+ build:
466
+ diff_size:
467
+ # Changed-line ceiling. Default: 1000. 0 disables the gate.
468
+ threshold: 1000
469
+
470
+ # Extra exclusion globs, ADDITIVE to the built-in defaults below.
471
+ exclude_globs:
472
+ - "docs/api/*"
473
+ - "*.pb.go"
474
+ ```
475
+
476
+ | Field | Type | Required | Description |
477
+ |-------|------|----------|-------------|
478
+ | `diff_size.threshold` | integer | No | Changed lines allowed before the gate trips. Default: 1000. `0` disables the gate |
479
+ | `diff_size.exclude_globs` | list of strings | No | Globs excluded from the measurement, in addition to the built-in defaults. Default: `[]` |
480
+
481
+ **Built-in default exclusions** (always applied, defined once in `scripts/diff-size.sh`): lockfiles (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `poetry.lock`, `uv.lock`, `go.sum`, `Gemfile.lock`, `composer.lock`), `*.min.*`, `*.snap`, and the directories `dist/`, `build/`, `generated/`, `fixtures/`, `vendor/` (anchored to the repo root, so first-party directories such as `commands/work-on/build/` are counted) plus `__generated__/` and `__snapshots__/` (any depth). The diff is measured against the merge base of `HEAD` and `origin/<base>`, so commits that landed on the base after the branch point are not counted. A configured glob ending in `/` matches that directory at any depth; any other glob is matched against the file basename and the full path. Globs are treated as data, never evaluated.
482
+
483
+ **Over the threshold**: the build posts a `FORGE:DIFF_SIZE` comment with a split proposal and exits `BUILD_RESULT: status: NEEDS_DECOMPOSE`; the router then runs `work-on:decompose`. To build the oversized diff anyway, post a `FORGE:SIZE_OVERRIDE` comment whose body has a non-empty justification on the line after the marker (only comments from trusted authors count). An issue that is already a decomposed child, or that already has `FORGE:DECOMPOSED`, is not decomposed again: the build blocks until an override is posted. The measured `diff_lines` is recorded in the `FORGE:TRAJECTORY` comment.
484
+
485
+ **Commands that use this section**: `work-on` (build phase B5.5, `decompose`, `close`)
486
+
487
+ ---
488
+
459
489
  ## `services` (OPTIONAL)
460
490
 
461
491
  External service endpoints and identifiers. Used by analytics and monitoring commands.
@@ -2,6 +2,15 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## Spec bash no longer corrupted by Claude Code argument substitution
6
+
7
+ Claude Code replaces `$0`..`$9` in a skill body with the invocation's arguments (0-based), so spec bash loaded via `Skill(...)` with args was silently rewritten: `awk '{print $2}'` became `awk '{print --issue}'`, `local AGENT="$1"` became `local AGENT="--auto-merge"`, which broke `/review-pr` agent selection and the CI/deploy comparisons with no error. `${N}`, `$(N)`, `$NF`, `$@`, `$#` and `$10`+ are not substituted.
8
+
9
+ - Skill-loaded specs now use `${1}` in shell and `$(1)` in awk (`printf "%s", $(0)` instead of `printf $0`). Behavior is otherwise unchanged. The shared FORGE_ROOT bootstrap block was updated byte-identically everywhere, including `orchestrate/**`.
10
+ - New `scripts/check-spec-bash.sh --positional` fails CI on `$0`..`$9` inside fenced blocks of Skill-loaded specs (suppress a deliberate hit with `allowlist:positional-arg` on the line). Read-loaded `orchestrate/**`, `pipeline-health/**` and the `review-pr-agents` catalog are exempt.
11
+
12
+ Action: none.
13
+
5
14
  ## In-PR fix round for CONFIRMED MEDIUM findings (#3387, narrowed)
6
15
 
7
16
  Under `--auto-merge` with `--issue` (the `/work-on` pipeline), a review finding that is **MEDIUM + CONFIRMED** in a file the PR itself changed is no longer merged and filed as a follow-up issue. `/review-pr` §6B.6 posts a `FORGE:INPR_FIX` work order on the PR, and Phase 8 holds the merge for that head (`blocker: in-pr fix required`). `/work-on` review then runs **one** remediation round (bound: `FORGE:INPR_REMEDIATION` on the issue) that fixes exactly those findings and re-reviews.
@@ -17,13 +17,16 @@ agree with it.
17
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
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
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
+ | F6 | Measured on 2.1.294 / 2.1.295 (forge#3437): an `Agent` launched **directly** by a sub-agent reports its completion to that sub-agent, but an `Agent` launched from inside a **forked skill** is attributed to the **root session**, so its completion notification goes to the root and never reaches the worker. Inside a sub-agent the spawn surface is `Agent(description, effort, isolation, prompt, subagent_type)` only: async-only (no `run_in_background`/wait parameter) and no `Task` tool. | orchestrate batch 20261008T170649, probe |
20
21
 
21
22
  So the depth limit constrains exactly one thing: **where a sub-agent can be spawned**. Forking
22
23
  for context isolation is free at any depth; spawning `Agent(...)` sub-agents only works from layer 2
23
24
  or shallower (under the default limit of 3).
24
25
 
25
26
  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
+ completion notifications arriving in the orchestrator's root session instead of the worker. F6 adds
28
+ the spawn-site ownership rule (R6): reviewers that `/review-pr` launches inside a forked phase are
29
+ owned by the root, so their completion is read from GitHub, not from notifications.
27
30
 
28
31
  ## 2. Observed failure (forge#3391, AlterLab#34442)
29
32
 
@@ -56,7 +59,8 @@ R1 applies.
56
59
 
57
60
  **R3 — Phases run synchronously.** Every forked phase and child declares `background: false`, so a
58
61
  `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.
62
+ handling in the router's Hard Rule 3a stays as a defensive fallback only (see R6 for reviewers
63
+ spawned inside those phases).
60
64
 
61
65
  **R4 — Depth preflight, fail loud.** Router Phase 0 checks that a dispatching phase will have the
62
66
  `Agent` tool: router layer + 2 ≤ effective spawn depth (router layer 1 with `--under-orchestration`,
@@ -68,6 +72,22 @@ code is written (`scripts/spawn-depth-check.sh`).
68
72
  handling from forge#3391 / PR #3395 remain as safety nets for an unexpected runtime, not as the
69
73
  normal remediation path.
70
74
 
75
+ **R6 — Spawn-site ownership: completion is read from GitHub, not notifications.** Who receives a
76
+ child's completion notification depends on where it was spawned (F6): directly by the worker, the
77
+ worker; from inside a forked phase (the `/review-pr` domain reviewers launched in `work-on:review` /
78
+ `work-on:remediate`), the root session. Because the sub-agent `Agent` tool is async-only, a forked
79
+ phase hosting `/review-pr` cannot wait on a notification. Therefore:
80
+ - `/review-pr` treats each selected reviewer's `FORGE:REVIEW-AGENT:{domain}` PR comment, carrying a
81
+ `Reviewed-SHA:` line equal to the current head, as the completion signal and polls for it with a
82
+ bounded wait (`REVIEWER_WAIT_SECS`) when its dispatch tool is `Agent`. A missing notification is
83
+ never a missing review. On timeout it keeps the `review-degraded` hard stop.
84
+ - A re-invocation of review or remediate is idempotent: a complete current-SHA panel is reused, only
85
+ missing domains are re-dispatched, and the router consumes an existing current-SHA panel and
86
+ verdict instead of counting the re-invoke as a stall.
87
+ - Dispatching phases remain router-only (R1); this rule adds no nesting and does not raise the
88
+ router's 2-retry cap. Reviewer notifications still reach the root session under `/orchestrate`
89
+ (noise only); fully nesting them needs an upstream Claude Code change.
90
+
71
91
  ## 4. Layer map
72
92
 
73
93
  | Skill | Fork | Dispatches sub-agents | Invoked by | Layer solo / orchestrated |
@@ -88,12 +108,12 @@ remediate L2, reviewers L3, same as the table.
88
108
 
89
109
  | Phase result | Router action |
90
110
  |--------------|---------------|
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. |
111
+ | `REVIEW_RESULT: status: NEXT, next: remediate` | Phase 4R: post the bound marker (`CI_REMEDIATION` / `INPR_REMEDIATION` / `BASESYNC_REMEDIATION`, the last for `remediation: base-sync`, a merge-only base sync) 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
112
  | `REMEDIATE_RESULT` from `--remediate` entry | unchanged (Phase 0A.1) |
93
113
 
94
114
  ## 6. What does not change
95
115
 
96
- - Build still sequences its own children; the headless engine (`bin/engine/phases.mjs`) is unaffected.
116
+ - Build still sequences its own children (worktree, then context / architect / implement / validate). The headless engine (`bin/engine/phases.mjs`) is **not** exempt from the argument contract: every phase sub-skill takes all inputs as arguments, so each engine phase builds them via `buildArgs` (`--repo`, `--gh-flag`, `--base`, `--worktree`, `--branch`, `--pr`, `--terminal-state` as the target spec's `argument-hint` requires). The engine's phase table is investigate, decompose, build, review, remediate, close; `context` and `architect` are no longer engine phases because they need the worktree build creates. `bin/engine/phases-args.test.mjs` fails CI when a phase's args drift from its spec's `argument-hint`.
97
117
  - `/orchestrate` still dispatches one worker `Agent` per issue (L1).
98
118
  - Phases keep their specs, `*_RESULT` contracts and GitHub markers; phase-trail verification is unchanged.
99
119
  - Solo `/work-on` gets one extra layer of headroom.
@@ -171,7 +171,7 @@ The `.md` files in `commands/` are detailed prompt engineering documents — som
171
171
  - **Phase boundaries** are explicit with entry conditions and exit actions
172
172
  - **Universal continuation rule** prevents premature stopping after intermediate results
173
173
  - **Structured output requirements** (e.g., `<!-- INVESTIGATION:COMPLETE -->` marker) are machine-readable gates that downstream phases check before proceeding
174
- - **Domain-specific rules** (INFRA footguns, type invariant verification, sibling pattern sweeps) encode institutional memory directly into the prompt
174
+ - **Domain-specific rules** (INFRA footguns, type invariant verification, sibling pattern sweeps, class-wide finding pattern sweeps with recurrence consolidation) encode institutional memory directly into the prompt
175
175
 
176
176
  ---
177
177
 
@@ -510,6 +510,16 @@ These markers carry no Markdown body; their presence is the signal.
510
510
  Error markers signal that automated processing stopped and human attention is
511
511
  needed.
512
512
 
513
+ **Build diff-size markers** (posted to issue comments by the build phase, `work-on:build` B5.5):
514
+
515
+ | Marker | Category | Meaning |
516
+ |--------|----------|---------|
517
+ | `<!-- FORGE:DIFF_SIZE -->` | Lifecycle (no completion sentinel) | Measured diff size of the staged build: `diff_lines`, `excluded_lines`, `threshold`, `result: OK \| OVERRIDDEN \| OVER`, and on `OVER` a `### Split Proposal`. Refreshed in place; readers take the latest trusted comment. Consumed by decompose (plan source) and close (records `diff_lines` in `FORGE:TRAJECTORY`) |
518
+ | `<!-- FORGE:SIZE_OVERRIDE -->` | Control | Posted by a human to let an over-threshold build proceed. Valid only from a trusted author and with a non-empty justification on the line after the marker |
519
+
520
+ Both lookups are anchored at the start of the comment body and trust-filtered
521
+ (`scripts/trusted-comments.sh`); an unreadable lookup fails closed.
522
+
513
523
  ---
514
524
 
515
525
  ### 4.4 Design pipeline annotations
@@ -147,6 +147,7 @@ Append this block at the very end of your comment (after the `---` footer line,
147
147
  7. **Empty block**: If no findings at all, include just the START/END markers
148
148
  8. **HTML comments**: The block is invisible in rendered markdown but parseable by the review system
149
149
  9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the lowercase dispatched domain.
150
+ 10. **Reviewed SHA**: Include exactly one line `Reviewed-SHA: [REVIEW_SHA]` (the full 40-hex head you were pinned to) in the persisted body, outside the marker. `/review-pr` scopes its completion wait and idempotent re-entry to this line, so a comment without the current head does not count as a completed review. Do NOT put the SHA inside the `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker.
150
151
 
151
152
  ### Domain Prefixes
152
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.10.3",
3
+ "version": "1.12.0",
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"
@@ -34,6 +34,11 @@ import { RESERVED_TYPES } from './types.js';
34
34
  * "Universal Phase Dispatcher" table and `bin/engine/phases.mjs`'s `PHASES`
35
35
  * array).
36
36
  *
37
+ * `context` and `architect` are intentionally NOT engine phase ids (forge#3499):
38
+ * `work-on/build` creates the worktree they need as `--repo-path` and invokes
39
+ * them itself. Their `PHASE_MARKERS` entries below stay — the interactive
40
+ * SubagentStop hook still gates on those markers.
41
+ *
37
42
  * `decompose` and `remediate` (forge#2379) are branch phases, not part of the
38
43
  * six-phase linear happy path — `decompose` is reached only when `investigate`
39
44
  * signals `DECOMPOSE:YES`, and `remediate` only when `review` escalates to
@@ -41,7 +46,7 @@ import { RESERVED_TYPES } from './types.js';
41
46
  * position `bin/engine/phases.mjs`'s `PHASES` array declares them, because
42
47
  * `scripts/check-phase-registry-drift.mjs` requires the two lists to match
43
48
  * index-for-index — see that file's own doc comment. */
44
- export const PHASE_IDS = ['investigate', 'decompose', 'context', 'architect', 'build', 'review', 'remediate', 'close'];
49
+ export const PHASE_IDS = ['investigate', 'decompose', 'build', 'review', 'remediate', 'close'];
45
50
 
46
51
  /**
47
52
  * @typedef {Object} PhaseMarkerEntry
@@ -45,6 +45,14 @@ export const RESERVED_TYPES = {
45
45
  inlineValue: false,
46
46
  requiredFields: [],
47
47
  },
48
+ DIFF_SIZE: {
49
+ type: 'DIFF_SIZE',
50
+ category: Category.LIFECYCLE,
51
+ completionSentinel: null, // single-shot measurement record; refreshed in place, no completion sentinel
52
+ partialSentinel: null,
53
+ inlineValue: false,
54
+ requiredFields: [],
55
+ },
48
56
  CONTRACT: {
49
57
  type: 'CONTRACT',
50
58
  category: Category.LIFECYCLE,
@@ -208,6 +216,15 @@ export const RESERVED_TYPES = {
208
216
  controlMarker: true,
209
217
  requiredFields: [],
210
218
  },
219
+ SIZE_OVERRIDE: {
220
+ type: 'SIZE_OVERRIDE',
221
+ category: Category.CONTROL,
222
+ completionSentinel: null,
223
+ partialSentinel: null,
224
+ inlineValue: false,
225
+ controlMarker: true,
226
+ requiredFields: [],
227
+ },
211
228
  PUSH_BLOCKED: {
212
229
  type: 'PUSH_BLOCKED',
213
230
  category: Category.CONTROL,
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-branch-ancestry.sh — Flag merge commits that bring in history from outside the PR base.
6
+ #
7
+ # Usage: check-branch-ancestry.sh <branch-or-HEAD> <base-ref>
8
+ #
9
+ # For every merge commit in <base-ref>..<branch>, each parent other than the first must be an
10
+ # ancestor of <base-ref>. Merging the base itself into the branch (a base sync) passes; merging
11
+ # a milestone or other foreign branch fails.
12
+ #
13
+ # Exit codes:
14
+ # 0 clean (no merges, or only base-sync merges)
15
+ # 1 foreign merge(s) found; each printed as "<merge-sha> <foreign-parent-sha> <subject>"
16
+ # 2 error (bad usage, unresolvable ref, git failure) — callers MUST treat as a failure, never a pass
17
+ #
18
+ # The first-parent line of <base-ref>..<branch> must also not carry foreign history: a branch cut
19
+ # from a milestone line (then, say, syncing the base) has no foreign non-first parent, so each
20
+ # first-parent commit is also checked against the milestone refs (refs/remotes/origin/milestone/*,
21
+ # refs/heads/milestone/*). A commit reachable from one of them but not from <base-ref> is foreign.
22
+ # Override the ref patterns with CHECK_BRANCH_ANCESTRY_FOREIGN_REFS (space-separated for-each-ref patterns).
23
+ #
24
+ # Known limit: foreign history on a branch with no milestone ref available locally is not detected.
25
+ # Portable to bash 3.2: no mapfile, no grep -P, no state accumulated inside piped subshells.
26
+
27
+ set -u
28
+
29
+ if [ "$#" -ne 2 ] || [ -z "${1:-}" ] || [ -z "${2:-}" ]; then
30
+ echo "usage: check-branch-ancestry.sh <branch-or-HEAD> <base-ref>" >&2
31
+ exit 2
32
+ fi
33
+ BRANCH="$1"
34
+ BASE="$2"
35
+
36
+ git rev-parse --verify --quiet "${BRANCH}^{commit}" >/dev/null 2>&1 || { echo "check-branch-ancestry: cannot resolve branch ref '${BRANCH}'" >&2; exit 2; }
37
+ git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1 || { echo "check-branch-ancestry: cannot resolve base ref '${BASE}'" >&2; exit 2; }
38
+
39
+ MERGES=$(git rev-list --merges "${BASE}..${BRANCH}" 2>/dev/null) || { echo "check-branch-ancestry: git rev-list failed" >&2; exit 2; }
40
+
41
+ FOREIGN=0
42
+ for M in $MERGES; do
43
+ LINE=$(git rev-list --parents -n 1 "$M" 2>/dev/null) || { echo "check-branch-ancestry: cannot read parents of $M" >&2; exit 2; }
44
+ PARENTS=$(printf '%s\n' "$LINE" | cut -d' ' -f3-)
45
+ SUBJECT=$(git log -n 1 --format=%s "$M" 2>/dev/null)
46
+ for P in $PARENTS; do
47
+ git merge-base --is-ancestor "$P" "$BASE" >/dev/null 2>&1
48
+ rc=$?
49
+ if [ "$rc" -eq 1 ]; then
50
+ printf '%s %s %s\n' "$M" "$P" "$SUBJECT"
51
+ FOREIGN=1
52
+ elif [ "$rc" -ne 0 ]; then
53
+ echo "check-branch-ancestry: merge-base --is-ancestor failed (rc=$rc) for $P" >&2
54
+ exit 2
55
+ fi
56
+ done
57
+ done
58
+
59
+ # First-parent line: every commit must be absent from every milestone ref (other than base/branch themselves).
60
+ BASE_SHA=$(git rev-parse "${BASE}^{commit}") || exit 2
61
+ BRANCH_SHA=$(git rev-parse "${BRANCH}^{commit}") || exit 2
62
+ FP=$(git rev-list --first-parent "${BASE}..${BRANCH}" 2>/dev/null) || { echo "check-branch-ancestry: git rev-list --first-parent failed" >&2; exit 2; }
63
+ # shellcheck disable=SC2086
64
+ REFS=$(git for-each-ref --format='%(refname)' ${CHECK_BRANCH_ANCESTRY_FOREIGN_REFS:-refs/remotes/origin/milestone/ refs/heads/milestone/} 2>/dev/null) || { echo "check-branch-ancestry: git for-each-ref failed" >&2; exit 2; }
65
+ for F in $REFS; do
66
+ F_SHA=$(git rev-parse --verify --quiet "${F}^{commit}" 2>/dev/null) || continue
67
+ [ "$F_SHA" = "$BASE_SHA" ] || [ "$F_SHA" = "$BRANCH_SHA" ] && continue
68
+ for C in $FP; do
69
+ git merge-base --is-ancestor "$C" "$F_SHA" >/dev/null 2>&1
70
+ rc=$?
71
+ if [ "$rc" -eq 0 ]; then
72
+ printf '%s %s first-parent history reachable from foreign ref %s\n' "$C" "$F_SHA" "$F"
73
+ FOREIGN=1
74
+ elif [ "$rc" -ne 1 ]; then
75
+ echo "check-branch-ancestry: merge-base --is-ancestor failed (rc=$rc) for $C" >&2
76
+ exit 2
77
+ fi
78
+ done
79
+ done
80
+
81
+ [ "$FOREIGN" -eq 0 ] || exit 1
82
+ exit 0
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/check-branch-ancestry.sh. Run: bash scripts/check-branch-ancestry.test.sh (bash 3.2 compatible)
6
+
7
+ set -u
8
+ DIR=$(cd "$(dirname "$0")" && pwd)
9
+ S="$DIR/check-branch-ancestry.sh"
10
+ T=$(mktemp -d "${TMPDIR:-/tmp}/check-branch-ancestry-test.XXXXXX")
11
+ trap 'rm -rf "$T"' EXIT
12
+ pass=0; fail=0
13
+ OUT=""
14
+
15
+ g() { git -C "$T/r" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
16
+ commit() { echo "$1" > "$T/r/$1.txt"; g add -A; g commit -m "$1"; }
17
+ # run <branch> <base>: sets OUT and RC
18
+ run() { OUT=$(cd "$T/r" && bash "$S" "$@" 2>&1); RC=$?; }
19
+ expect() { # expect <want_rc> <desc> [want_substring]
20
+ if [ "$RC" = "$1" ] && { [ -z "${3:-}" ] || case "$OUT" in *"$3"*) true ;; *) false ;; esac; }; then
21
+ pass=$((pass+1))
22
+ else
23
+ fail=$((fail+1)); echo "FAIL: $2 (rc=$RC, want $1)"; printf '%s\n' "$OUT" | sed 's/^/ /'
24
+ fi
25
+ }
26
+
27
+ mkdir "$T/r"; git -C "$T/r" init -q -b staging >/dev/null 2>&1 || { git -C "$T/r" init -q; g checkout -b staging; }
28
+ commit base
29
+ # milestone diverges from staging after staging2
30
+ commit staging2
31
+ g checkout -b milestone/m1; commit ms1
32
+ g checkout staging
33
+ # feature branch off the original base commit
34
+ g checkout -b feat HEAD~1; commit feat1
35
+
36
+ # 1. no merges
37
+ run feat staging; expect 0 "no merges passes"
38
+
39
+ # 2. base-sync merge passes (staging advances, then is merged into feat)
40
+ g merge --no-ff -m "Merge staging into feat" staging
41
+ run feat staging; expect 0 "base-sync merge passes"
42
+ run HEAD staging; expect 0 "HEAD form passes"
43
+
44
+ # 3. staging advances further and is synced again: still passes
45
+ g checkout -q staging; commit staging3; g checkout -q feat
46
+ g merge --no-ff -m "Merge staging again" staging
47
+ run feat staging; expect 0 "repeated base-sync merges pass"
48
+
49
+ # 4. foreign merge fails, foreign parent printed
50
+ MS=$(git -C "$T/r" rev-parse milestone/m1)
51
+ g merge --no-ff -m "Merge milestone into feat" milestone/m1
52
+ run feat staging; expect 1 "foreign merge fails" "$MS"
53
+ case "$OUT" in *"Merge milestone into feat"*) pass=$((pass+1)) ;; *) fail=$((fail+1)); echo "FAIL: subject not printed"; echo "$OUT" ;; esac
54
+
55
+ # 5. octopus with one foreign parent fails
56
+ g checkout -q -b feat2 staging; commit f2
57
+ g checkout -q staging; g checkout -q -b side; commit side1
58
+ g checkout -q feat2
59
+ g merge --no-ff -m "octopus" side milestone/m1
60
+ run feat2 staging; expect 1 "octopus with foreign parent fails" "$MS"
61
+
62
+ # 5b. branch cut from a milestone line, then syncing the base: foreign first-parent history fails
63
+ MS1=$(git -C "$T/r" rev-parse milestone/m1)
64
+ g checkout -q -b feat3 milestone/m1; commit f3
65
+ g merge --no-ff -m "Merge staging into feat3" staging
66
+ run feat3 staging; expect 1 "milestone-cut branch with base sync fails" "$MS1"
67
+
68
+ # 5c. milestone-cut branch with no merge at all also fails
69
+ g checkout -q -b feat4 milestone/m1; commit f4
70
+ run feat4 staging; expect 1 "milestone-cut branch without merges fails" "$MS1"
71
+
72
+ # 6. bad refs fail closed (exit 2)
73
+ run nosuchbranch staging; expect 2 "bad branch ref exits 2"
74
+ run feat nosuchbase; expect 2 "bad base ref exits 2"
75
+ run feat; expect 2 "missing arg exits 2"
76
+
77
+ echo "check-branch-ancestry tests: $pass passed, $fail failed"
78
+ [ "$fail" -eq 0 ]