forgedock 1.16.2 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
@@ -124,12 +124,25 @@ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ]; then
124
124
  fi
125
125
  ```
126
126
 
127
+ **CI-only and INPR second attempts** <!-- Added: forge#3932 -->: the per-kind scoping also covers `ci-gate` and `inpr-fix`, so a CI-only failure after a remediation of another kind gets its own single attempt, not `ALREADY_DONE`. Trails carry no kind: each completed trail is attributed to the NEWEST trusted bound marker at or before it. When `CURRENT_KIND` is `CI_REMEDIATION` or `INPR_REMEDIATION` and no completed trail is attributed to it, `REMEDIATION_COMPLETE_N` is set to 0: one attempt per kind per PR, keeping the retry bound. An unreadable attribution keeps the kind-agnostic guard:
128
+
129
+ ```bash
130
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$PR_COMMENTS" ] && [ -n "$ISSUE_COMMENTS" ] && { [ "$CURRENT_KIND" = "CI_REMEDIATION" ] || [ "$CURRENT_KIND" = "INPR_REMEDIATION" ]; }; then
131
+ KIND_DONE_N=$( { printf '%s\n' "$ISSUE_COMMENTS"; printf '%s\n' "$PR_COMMENTS"; } | jq -rs --arg pr "{PR_NUMBER}" --arg kind "$CURRENT_KIND" '
132
+ [.[][] | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | select((.body // "") | type == "string")] as $c
133
+ | [$c[] | select(.body | test("\\A<!-- FORGE:(CI|INPR|BASESYNC)_REMEDIATION: pr=" + $pr + " -->")) | {t: .created_at, k: (.body | capture("FORGE:(?<k>CI|INPR|BASESYNC)_REMEDIATION").k + "_REMEDIATION")}] as $m
134
+ | [$c[] | select(.body | test("\\A<!-- FORGE:REMEDIATION -->[\\s\\S]*<!-- FORGE:REMEDIATION:COMPLETE -->")) | .created_at] as $trails
135
+ | [$trails[] as $t | ([$m[] | select(.t <= $t)] | sort_by(.t) | last | .k // "") | select(. == $kind)] | length' 2>/dev/null || echo "")
136
+ if [ -n "$KIND_DONE_N" ] && [ "$KIND_DONE_N" -eq 0 ]; then REMEDIATION_COMPLETE_N=0; fi
137
+ fi
138
+ ```
139
+
127
140
  Completed trails of other kinds are left in place (never deleted); only the interrupted-partial cleanup below deletes anything.
128
141
 
129
142
  - If `REMEDIATION_COMPLETE_N` or `REMEDIATION_TRAIL_N` is empty (comments or `scripts/trusted-comments.sh` unreadable or unresolvable) → fail closed: EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker: "github-unavailable: remediation trail unreadable (comments or scripts/trusted-comments.sh unresolvable)". Never treat unreadable as "no trail" and never as `ALREADY_DONE`.
130
143
  - If `REMEDIATION_COMPLETE_N` is at least 1 (a trusted comment that starts with `<!-- FORGE:REMEDIATION -->` and contains `<!-- FORGE:REMEDIATION:COMPLETE -->`) → EXIT `REMEDIATE_RESULT: status: ALREADY_DONE`. **Single-attempt semantics (AC5)**: once a genuine `FORGE:REMEDIATION:COMPLETE` trail comment exists for this PR, do NOT re-attempt fixes on a subsequent invocation, regardless of the prior verdict — this is what prevents an infinite remediation retry loop on a genuinely-blocked PR. A comment that only quotes the marker mid-body (review findings, an `INPR_FIX` work order, a disposition record) or comes from an untrusted author never counts.
131
144
  - **Merged-PR routing (forge#3685)**: if `PR_ALREADY_MERGED=true` and `REMEDIATION_COMPLETE_N` is 0 (the fail-closed unreadable-trail exit above already ran, so the count is known): if the linked issue is already `CLOSED` with the `workflow:merged` label (`ISSUE_STATE`), EXIT `REMEDIATE_RESULT: status: ALREADY_DONE` (landed and finished, nothing to do). Otherwise (not yet `workflow:merged`/CLOSED, no completed trail) set `RESUME_AT_M7=true` and `PR_ALREADY_MERGED=true`: skip Phases M1-M6 and continue at Phase M7, whose merged-first branch is exclusive, runs the idempotent `merged` label bookkeeping, sets `AUTO-LANDED` and goes to M8, which posts the trail and drives close. No park evidence is required. A `FORGE:REMEDIATION_HELD` record never counts toward `REMEDIATION_COMPLETE_N`.
132
- - **Liveness step (forge#3792)**: when the PR is open, the issue carries `needs-human` or `workflow:remediating` and `REMEDIATION_COMPLETE_N` is 0, run this BEFORE any partial comment is deleted or anything is pushed. Partial trail comments are not evidence of a live run; the script decides from the `FORGE:STATE` lease and the routed re-review. Pass `{LEASE_HOLDER}` only if it matches `^[A-Za-z0-9._-]{1,128}$`, else an empty string.
145
+ - **Liveness step (forge#3792)**: when the PR is open, the issue carries `needs-human` or `workflow:remediating` and `REMEDIATION_COMPLETE_N` is 0, run this BEFORE any partial comment is deleted or anything is pushed. Partial trail comments are not evidence of a live run; the script decides from the `FORGE:STATE` lease and the routed re-review. A review that handed off to remediation (a trusted `FORGE:{CI|INPR|BASESYNC}_REMEDIATION: pr={N}` marker at or after the newest route, e.g. a base-sync handoff before any panel was dispatched) is never in flight: it never defers and spends none of the deferral budget. Pass `{LEASE_HOLDER}` only if it matches `^[A-Za-z0-9._-]{1,128}$`, else an empty string.
133
146
 
134
147
  ```bash
135
148
  LV_SCRIPT=$(forge_resolve remediate-liveness.sh)
@@ -137,7 +150,7 @@ Completed trails of other kinds are left in place (never deleted); only the inte
137
150
  echo "$LV_OUT"
138
151
  ```
139
152
 
140
- `LV_RC` 0 (dead): continue with the cleanup below. 10 (`DEFER`, the script posted a `FORGE:REMEDIATION_DEFERRED` record): EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker `deferred-live-run: {detail}`, touching nothing (no deletes, labels or push; the plain `git push` is the atomic backstop, a non-fast-forward race is rejected and parks at M4). 11 (`EXHAUSTED`, 3 deferrals already): a normal `BLOCKED` exit with `needs-human`. Any other rc: EXIT `BLOCKED`, blocker `github-unavailable: liveness: {detail}`.
153
+ `LV_RC` 0 (dead): continue with the cleanup below. 10 (`DEFER`, the script posted a `FORGE:REMEDIATION_DEFERRED` record): EXIT `REMEDIATE_RESULT: status: BLOCKED`, blocker `deferred-live-run: {detail}`, touching nothing (no deletes, labels or push; the plain `git push` is the atomic backstop, a non-fast-forward race is rejected and parks at M4). 11 (`EXHAUSTED`, 3 deferrals already): a normal `BLOCKED` exit with `needs-human`. Any other rc: EXIT `BLOCKED`, blocker `github-unavailable: liveness: {detail}`. The engine posts a `<!-- FORGE:REMEDIATION_DISPATCH: pr={pr} -->` record on every dispatch to scope a deferral to its own dispatch; it is not a trail or completion marker and the partial-trail cleanup ignores it.
141
154
  - If `REMEDIATION_COMPLETE_N` is 0 and `REMEDIATION_TRAIL_N` is at least 1 and the liveness step above said **dead** → a prior attempt was interrupted mid-flight (same failure mode as the investigation phase's partial-comment case). Delete the partial comment(s) on both the PR and the issue (ids from `PARTIAL_PR_COMMENT_IDS` above and the same anchored filter over the issue's comments), then continue below as a fresh attempt:
142
155
  ```bash
143
156
  for _id in $PARTIAL_PR_COMMENT_IDS; do gh api repos/{GH_REPO}/issues/comments/$_id -X DELETE 2>/dev/null || true; done
@@ -185,7 +198,7 @@ BLOCK_COMMENTS=$(gh api repos/{GH_REPO}/issues/{ISSUE_NUMBER}/comments \
185
198
  ```
186
199
 
187
200
  **Classify into FIXABLE vs. UNFIXABLE**:
188
- - **FIXABLE** — open `review-finding` issues (CONFIRMED/LIKELY code defects), a `VERDICT=CHANGES REQUESTED` block with concrete findings attached, a base conflict (`base-conflict`: `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection — resolvable by merging `origin/{PR_BASE}` in, the base-sync step in Phase M3; `BLOCKED` alone is branch protection that a sync cannot clear and is UNFIXABLE unless a conflict state is also present), a quality-gate/build failure, or a CI-gate refusal (`ci gate not green` — the failing/cancelled/timed-out checks are listed on the linked issue; read each failing job's log with `gh run view --log-failed`, fix the cause on the PR branch, or re-run a check that failed for an infrastructure reason), or an in-PR fix request (`in-pr fix required` — review-pr §6B.6 posted a `<!-- FORGE:INPR_FIX: round=1 head=<sha> findings=<ids> -->` comment on the PR listing CONFIRMED MEDIUM findings in files this PR changed. That comment is the work order: fix exactly the listed findings, at the listed `file:line`, and nothing else. Do not file them as issues; the re-review in Phase M6 files any that remain).
201
+ - **FIXABLE** — open `review-finding` issues (CONFIRMED/LIKELY code defects), a `VERDICT=CHANGES REQUESTED` block with concrete findings attached, a base conflict (`base-conflict`: `CONFLICTING`/`DIRTY`, or `BEHIND` under required-up-to-date protection — resolvable by merging `origin/{PR_BASE}` in, the base-sync step in Phase M3; `BLOCKED` alone is branch protection that a sync cannot clear and is UNFIXABLE unless a conflict state is also present), a quality-gate/build failure, or a CI-gate refusal (`ci gate not green` — the failing/cancelled/timed-out checks are listed on the linked issue; read each failing job's log with `gh run view --log-failed`, fix the cause on the PR branch, or re-run a check that failed for an infrastructure reason), or an in-PR fix request (`in-pr fix required` — review-pr §6B.6 posted a `<!-- FORGE:INPR_FIX: round=1 head=<sha> max_severity=<MEDIUM|HIGH|CRITICAL> findings=<ids> -->` comment on the PR listing CONFIRMED MEDIUM/HIGH/CRITICAL findings in files this PR changed. That comment is the work order: fix exactly the listed findings, at the listed `file:line`, and nothing else. Do not file them as issues; the re-review in Phase M6 files any that remain).
189
202
  - **UNFIXABLE (policy escalation)** — `HAS_PURPOSE_REGRESSION=true` (the PR's behavior diverges from the issue's intent — a judgment call, not a code defect), `CALIBRATION_NEEDS_HUMAN=true` (statistical trust threshold), or `TRUST_NEEDS_HUMAN=true` (provenance `NOVEL_NEEDS_HUMAN` tier, insufficient prior data — a policy gate, not a bug). None of these are mechanically "fixable" by re-editing code.
190
203
 
191
204
  **If the block reason classifies as UNFIXABLE** (and no FIXABLE item accompanies it): do NOT attempt any fix. Skip directly to Phase M8 with verdict `UNFIXABLE`, re-affirm `needs-human` (add it and remove `workflow:remediating` if the issue entered as `workflow:remediating`), and return `REMEDIATE_RESULT: status: UNFIXABLE`. This satisfies AC5 — "genuinely-blocked PRs still terminate at `needs-human`."
@@ -218,12 +231,28 @@ if [ -d "{WORKTREE_PATH}" ]; then
218
231
  git -C "{WORKTREE_PATH}" fetch origin
219
232
  git -C "{WORKTREE_PATH}" checkout {HEAD_BRANCH}
220
233
  git -C "{WORKTREE_PATH}" reset --hard "origin/{HEAD_BRANCH}"
221
- else
222
- git worktree add "{WORKTREE_PATH}" {HEAD_BRANCH} "origin/{HEAD_BRANCH}"
234
+ elif ! WT_ERR=$(git worktree add "{WORKTREE_PATH}" {HEAD_BRANCH} "origin/{HEAD_BRANCH}" 2>&1); then
235
+ # Branch already checked out elsewhere: reuse that worktree (never the main checkout).
236
+ EXISTING_WT=""; MAIN_WT=""; CUR_WT=""
237
+ while IFS= read -r line; do
238
+ case "$line" in
239
+ "worktree "*) CUR_WT="${line#worktree }"; [ -z "$MAIN_WT" ] && MAIN_WT="$CUR_WT" ;;
240
+ "branch refs/heads/{HEAD_BRANCH}") [ -z "$EXISTING_WT" ] && EXISTING_WT="$CUR_WT" ;;
241
+ esac
242
+ done < <(git worktree list --porcelain)
243
+ WORKTREE_PATH="" # stays empty (BLOCKED) unless the reuse is provably safe
244
+ if [ -n "$EXISTING_WT" ] && [ "$EXISTING_WT" != "$MAIN_WT" ] && [ -d "$EXISTING_WT" ] \
245
+ && [ -z "$(git -C "$EXISTING_WT" status --porcelain)" ] \
246
+ && [ "$(git -C "$EXISTING_WT" rev-list --count "origin/{HEAD_BRANCH}..HEAD")" = "0" ]; then
247
+ git -C "$EXISTING_WT" fetch origin && git -C "$EXISTING_WT" reset --hard "origin/{HEAD_BRANCH}" && WORKTREE_PATH="$EXISTING_WT"
248
+ else echo "$WT_ERR" >&2; fi
249
+ echo "WORKTREE_PATH=$WORKTREE_PATH" # use this printed value for every later {WORKTREE_PATH}
223
250
  fi
224
251
  ```
225
252
 
226
- If the worktree/branch checkout fails for any reason (branch deleted, force-pushed out from under us, etc.): post a comment, add `needs-human`, EXIT `REMEDIATE_RESULT: status: BLOCKED`.
253
+ **Main-checkout ban**: remediation never runs `checkout`, `switch`, `reset`, `stash`, or `merge` in the main checkout (`{REPO_PATH}`), not even as a fallback for a failed `git worktree add`; every mutating git command targets `{WORKTREE_PATH}` via `git -C`. A reused worktree is never reset when it has uncommitted changes or commits not on `origin/{HEAD_BRANCH}`, and an unresolvable one is not improvised around: `WORKTREE_PATH` stays empty and an empty value after this block means BLOCKED. <!-- Added: forge#3650 -->
254
+
255
+ If the worktree/branch checkout fails for any reason (branch deleted, force-pushed out from under us, checked out in the main checkout, etc.): post a comment, add `needs-human`, EXIT `REMEDIATE_RESULT: status: BLOCKED`.
227
256
 
228
257
  ---
229
258
 
@@ -326,15 +355,15 @@ If still failing after 3 iterations: post a comment, re-affirm `needs-human`, EX
326
355
  **Pre-push ancestry guard** (same guard as `work-on/review.md` Phase R1; reuse it, do not write a parallel check): a base-sync merge of `origin/{PR_BASE}` passes it, a merge that brings in history from outside the base fails it.
327
356
 
328
357
  ```bash
329
- # <Script resolution block from work-on/review.md, verbatim>
330
358
  cd {WORKTREE_PATH}
331
359
  run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
332
360
  git fetch origin {PR_BASE} >/dev/null 2>&1 || true
333
- RESOLUTION=$(resolve_script 'check-branch-ancestry'); TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
334
- if [ "$TIER" = "prose" ]; then
361
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
362
+ ANCESTRY_SCRIPT=$(forge_resolve check-branch-ancestry.sh)
363
+ if [ -z "$ANCESTRY_SCRIPT" ]; then
335
364
  MERGE_COMMITS="check-branch-ancestry script not resolvable (fail closed)"; ANCESTRY_RC=2
336
365
  else
337
- MERGE_COMMITS=$(bash "$SCRIPT_PATH" {HEAD_BRANCH} origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
366
+ MERGE_COMMITS=$(bash "$ANCESTRY_SCRIPT" {HEAD_BRANCH} origin/{PR_BASE} 2>&1); ANCESTRY_RC=$?
338
367
  fi
339
368
  if [ "$ANCESTRY_RC" -ne 0 ]; then
340
369
  run gh issue comment {ISSUE_NUMBER} {GH_FLAG} --body "## Pre-Push Ancestry Guard Failed
@@ -471,7 +500,7 @@ else:
471
500
 
472
501
  Wait for the completed child result and retain its `REVIEW_RESULT` in remediation state before continuing to Phase M7. A running/progress response is not a completion result. If the child errors or does not return a parseable `REVIEW_RESULT`, stop with `REMEDIATE_RESULT: status: BLOCKED`; do not report remediation in progress as a terminal parent result. The `Skill(...)` invocation above remains the non-OpenCode path.
473
502
 
474
- If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate refused to merge on missing phase markers, not on a code finding. Re-run each missing phase named on the `MISSING:` lines via its `Skill(...)`, then re-invoke this phase once. If it is `PHASE_TRAIL_FAILED` again, keep `needs-human`, post `<!-- FORGE:PHASE_TRAIL_FAILED -->` listing the still-missing markers, and exit `REMEDIATE_RESULT: status: BLOCKED`. Never treat it as `HELD-AWAITING-MERGE` or `AUTO-LANDED`. A still-failing trail can also be cleared by a human `<!-- FORGE:PHASE_TRAIL_OVERRIDE -->` comment (forge#3152, see `scripts/verify-phase-trail.sh -h`); the verifier decides, this phase never posts one.
503
+ If the retained `REVIEW_RESULT` is `PHASE_TRAIL_FAILED` (forge#3102): the gate refused to merge on missing phase markers, not on a code finding. This phase does NOT re-dispatch the missing phases: it is a dispatching phase running in a fork (`docs/WORK-ON-RUNTIME.md` R1), and `work-on:review` Phase R4 is the single re-dispatch owner. Keep `needs-human`, post `<!-- FORGE:PHASE_TRAIL_FAILED -->` listing the missing markers from the `MISSING:` lines, and exit `REMEDIATE_RESULT: status: BLOCKED` with blocker "phase trail incomplete" so the router can hand the `MISSING:` lines back to the review owner. Never treat it as `HELD-AWAITING-MERGE` or `AUTO-LANDED`. A still-failing trail can also be cleared by a human `<!-- FORGE:PHASE_TRAIL_OVERRIDE -->` comment (forge#3152, see `scripts/verify-phase-trail.sh -h`); the verifier decides, this phase never posts one.
475
504
 
476
505
  If the retained `REVIEW_RESULT` is `BLOCKED` with a blocker mentioning the phase trail (e.g. "phase trail unreadable (rc=N)": the Phase 8 verifier exited ≥2 / 127, forge#3147): the gate could not run, so nothing is missing and nothing is re-run. Keep `needs-human` (review-pr Phase 8 already re-asserted it), do not merge, and exit `REMEDIATE_RESULT: status: BLOCKED` with the same blocker.
477
506
 
@@ -524,11 +553,12 @@ PR_LIVE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state,mergedAt,headRefOid 2>/d
524
553
  if [ "$(printf '%s' "$PR_LIVE" | jq -r '.state // ""' 2>/dev/null)" = "MERGED" ] || [ -n "$(printf '%s' "$PR_LIVE" | jq -r '.mergedAt // empty' 2>/dev/null)" ]; then
525
554
  RE_GATE_OUTCOME="AUTO-LANDED"
526
555
  # Idempotent post-merge bookkeeping (same case as the merge branch below; harmless when already applied).
527
- RESOLUTION=$(resolve_script 'transition-label')
528
- TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
556
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
557
+ TRANSITION_SCRIPT=$(forge_resolve transition-label.sh)
558
+ if [ -n "$TRANSITION_SCRIPT" ]; then TIER=universal; else TIER=prose; fi
529
559
  case "$TIER" in
530
- adaptive|universal) bash "$SCRIPT_PATH" {ISSUE_NUMBER} {GH_FLAG} merged ;;
531
- prose)
560
+ universal) bash "$TRANSITION_SCRIPT" {ISSUE_NUMBER} {GH_FLAG} merged ;;
561
+ prose|*)
532
562
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:merged" --remove-label "workflow:awaiting-merge,needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
533
563
  ;;
534
564
  esac
@@ -682,11 +712,11 @@ gh pr merge {PR_NUMBER} {GH_FLAG} --merge --match-head-commit "$GATED_HEAD" # al
682
712
  fi
683
713
  MERGE_STATE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json state --jq '.state')
684
714
  if [ "$MERGE_STATE" = "MERGED" ]; then
685
- RESOLUTION=$(resolve_script 'transition-label')
686
- TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
715
+ TRANSITION_SCRIPT=$(forge_resolve transition-label.sh)
716
+ if [ -n "$TRANSITION_SCRIPT" ]; then TIER=universal; else TIER=prose; fi
687
717
  case "$TIER" in
688
- adaptive|universal) bash "$SCRIPT_PATH" {ISSUE_NUMBER} {GH_FLAG} merged ;;
689
- prose)
718
+ universal) bash "$TRANSITION_SCRIPT" {ISSUE_NUMBER} {GH_FLAG} merged ;;
719
+ prose|*)
690
720
  gh issue edit {ISSUE_NUMBER} {GH_FLAG} --add-label "workflow:merged" --remove-label "workflow:awaiting-merge,needs-human,workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:remediating,workflow:invalid,workflow:decomposed" 2>/dev/null || true # allowlist:check-command-side-effects
691
721
  ;;
692
722
  esac
@@ -512,7 +512,7 @@ else:
512
512
 
513
513
  Wait for that task's completed result before Phase R4. Propagate its `REVIEW_RESULT` as this module's child state; do not return `REVIEW_RESULT`, report progress, release an orchestrator slot, or begin close work while the child is running. If the child errors or returns no parseable `REVIEW_RESULT`, return `REVIEW_RESULT: status: BLOCKED` with the child failure as the blocker. The normal `Skill(...)` invocation above remains the non-OpenCode path.
514
514
 
515
- /review-pr handles: the domain-agent review (it picks FULL, DELTA or BASE_SYNC_ONLY itself: a head that only merged the base is carried forward without reviewers) → post findings as separate issues (non-blocking) → merge the PR. It does NOT close the issue or clean up the worktree — `work-on:close` does both.
515
+ /review-pr handles, in this order: **prepare** (`scripts/review-prepare.sh`: PR data, mergeability, scans, roster, FULL, DELTA or BASE_SYNC_ONLY decision, templates and slices into one context file; exit 3 is `BLOCKED`, never skipped) → **LLM** (domain-agent dispatch, arbitration, verdict rationale; a head that only merged the base is carried forward without reviewers) → **finalize** (`scripts/review-finalize.sh`: extract, classify, file findings as separate non-blocking issues, verdict, merge gates, integrity). The same sequence runs on the Agent-spawn and engine paths because it lives in the spec, so no engine hook is needed. It does NOT close the issue or clean up the worktree — `work-on:close` does both.
516
516
 
517
517
  If `review-pr` is not found under `{FORGE_SKILL_PREFIX}review-pr` or `review-pr`, print `REVIEW_RESULT: status: BLOCKED`, blocker: "skill not found: review-pr" and STOP. Never review inline.
518
518
 
@@ -543,7 +543,7 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
543
543
  \`\`\`"
544
544
  ```
545
545
 
546
- - `REVIEW_RESULT: status: PHASE_TRAIL_FAILED` from /review-pr (forge#3102; internal to this skill — never printed as this skill's status) → the review gate refused to merge because phase markers are missing. Do NOT hand-post markers and do NOT add `needs-human` yet. For each `MISSING: <marker> -> <action>` line, run that phase via its `Skill(...)`, then re-invoke Phase R3 once (this is the single re-dispatch owner; the trail preflight at the top of R3 re-runs). If the second attempt returns `PHASE_TRAIL_FAILED` again, post a `<!-- FORGE:PHASE_TRAIL_FAILED -->` comment listing the still-missing markers, add `needs-human`, and return `REVIEW_RESULT: status: BLOCKED`, blocker: "phase trail incomplete after re-dispatch". The PR stays open and unmerged throughout.
546
+ - `REVIEW_RESULT: status: PHASE_TRAIL_FAILED` from /review-pr (forge#3102; internal to this skill — never printed as this skill's status) → the review gate refused to merge because phase markers are missing. Do NOT hand-post markers and do NOT add `needs-human` yet. For each `MISSING: <marker> -> <action>` line, run that phase via its `Skill(...)`, then re-invoke Phase R3 once (this is the single re-dispatch owner: `/review-pr` and `work-on:remediate` only report the status and never re-run missing phases; the trail preflight at the top of R3 re-runs). If the second attempt returns `PHASE_TRAIL_FAILED` again, post a `<!-- FORGE:PHASE_TRAIL_FAILED -->` comment listing the still-missing markers, add `needs-human`, and return `REVIEW_RESULT: status: BLOCKED`, blocker: "phase trail incomplete after re-dispatch". The PR stays open and unmerged throughout.
547
547
 
548
548
  - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker mentions the phase trail (any blocker containing "phase trail": "phase trail unreadable" when the Phase 8 verifier exited ≥2 / 127, "phase trail incomplete…", or "auto-merge requires --issue", forge#3147): the merge gate refused or could not run. Do NOT re-run phases (nothing is missing) and do NOT attempt the manual merge below, because that would bypass the gate. Add `needs-human` and return `REVIEW_RESULT: status: BLOCKED` with the same blocker. The PR stays open and unmerged.
549
549
 
@@ -560,11 +560,13 @@ gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
560
560
  gh pr edit {PR_NUMBER} {GH_FLAG} --remove-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
561
561
  ```
562
562
 
563
- - `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 — remove `workflow:remediating`, label the issue `needs-human` (the bound is exhausted, so a human is now genuinely needed) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise run the remediation-pending handoff block above (sets `workflow:remediating`, removes the `needs-human` that Phase 8 set) and 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, swaps `workflow:remediating` (or a legacy `needs-human`) for `workflow:in-review`, fixes the failing jobs on the PR branch, re-runs the quality gate and re-reviews the new head (scoped by /review-pr: full, delta or carried forward), and auto-lands through the same CI gate. <!-- forge#3191 -->
563
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "checks still pending" (Phase 8 merge-gate timed out while CI was still running; nothing failed, #3919): do NOT remediate and do NOT add `needs-human`. Return that BLOCKED result unchanged so the caller retries the review phase, whose merge gate re-runs once CI finishes.
564
564
 
565
- - `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).
565
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "ci gate" (Phase 8 refused to merge because checks failed or were cancelled): 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 — remove `workflow:remediating`, label the issue `needs-human` (the bound is exhausted, so a human is now genuinely needed) and return `REVIEW_RESULT: status: BLOCKED` with blocker "ci gate not green after remediation". Otherwise run the remediation-pending handoff block above (sets `workflow:remediating`, removes the `needs-human` that Phase 8 set) and 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, swaps `workflow:remediating` (or a legacy `needs-human`) for `workflow:in-review`, fixes the failing jobs on the PR branch, re-runs the quality gate and re-reviews the new head (scoped by /review-pr: full, delta or carried forward), and auto-lands through the same CI gate. <!-- forge#3191 -->
566
+
567
+ - `REVIEW_RESULT: status: BLOCKED` from /review-pr whose blocker contains "in-pr fix required" (review-pr §6B.6: CONFIRMED MEDIUM/HIGH/CRITICAL 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).
566
568
  - **Bound unused**: run the remediation-pending handoff block above (set `workflow:remediating`, no `needs-human`; remediation accepts that state 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.
567
- - **Bound already used** (including the re-invocation after a remediation that did not land): waive the gate for the current head. Remove `needs-human` and `workflow:remediating` (restoring `workflow:in-review`), 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, label the issue `needs-human`.
569
+ - **Bound already used** (including the re-invocation after a remediation that did not land): first read `max_severity=` from the latest trusted `FORGE:INPR_FIX` marker on the PR (`gh pr view {PR_NUMBER} {GH_FLAG} --json comments`, author-trusted as elsewhere in this phase; absent means MEDIUM). **HIGH/CRITICAL are never waived**: remove `workflow:remediating`, label the issue `needs-human`, leave the PR unmerged and return `REVIEW_RESULT: status: BLOCKED` with blocker "in-pr fix not landed (HIGH/CRITICAL, no waiver)". The `FORGE:INPR_FIX` work order stays on the PR for the human. For MEDIUM, waive the gate for the current head. Remove `needs-human` and `workflow:remediating` (restoring `workflow:in-review`), 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, label the issue `needs-human`.
568
570
  ```bash
569
571
  INPR_WAIVE_HEAD=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq .headRefOid)
570
572
  gh issue edit {NUMBER} {GH_FLAG} --remove-label "needs-human,workflow:remediating" --add-label "workflow:in-review" 2>/dev/null || true # allowlist:check-command-side-effects
@@ -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`) or `BUILD_RESULT: NEEDS_DECOMPOSE` (`FORGE:DIFF_SIZE` over, no override) |
75
+ | 2 | Decompose | `work-on:decompose` | `INVESTIGATE_RESULT.decompose: YES` (or resume: investigation says decompose YES and no `FORGE:DECOMPOSED`). A finished build is never routed here: decomposition is a planning decision (forge#3862) |
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) |
@@ -665,7 +665,6 @@ The build phase owns classification (`FORGE:FAST_PATH`), the worktree, the `work
665
665
  | `COMPLETE` | Marker gate (below) → record `BRANCH` and `WORKTREE_PATH` from the result → Phase 4 |
666
666
  | `ALREADY_DONE` | Record `BRANCH`/`WORKTREE_PATH` (from the result, or `git worktree list` for the issue branch) → Phase 4 |
667
667
  | `INVESTIGATION_COMPLETE` | Investigation-type task: deliverable issues were filed and the original closed by the phase → Phase 5 with `--terminal-state investigation` (no review) |
668
- | `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 |
669
668
  | `BLOCKED` | Terminal: confirm `needs-human` is present (the phase adds it with the blocker) and STOP |
670
669
 
671
670
  **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 -->
package/docs/CONFIG.md CHANGED
@@ -50,7 +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
+ | [`build`](#build-optional) | No | `/work-on` build-phase scope check (`build.scope.*`) and review depth (`review.depth.*`) |
54
54
  | [`services`](#services-optional) | No | External service URLs and IDs |
55
55
  | [`review`](#review-optional) | No | Context injected into review agents |
56
56
  | [`devdocs`](#devdocs-optional) | No | Devdocs knowledge tree path |
@@ -308,6 +308,11 @@ orchestration:
308
308
  # shrink cascade queue growth.
309
309
  max_concurrent: 12
310
310
 
311
+ # Age (hours since the issue last updated) after which a predecessor still at
312
+ # `workflow:remediating` or `workflow:engine-error` is classified GATED
313
+ # (stalled, needs a human) instead of IN_PROGRESS. Default: 48.
314
+ in_progress_max_hours: 48
315
+
311
316
  cascade:
312
317
  # Named preset expanding to the granular keys below. Any granular key set
313
318
  # explicitly overrides just that field — the preset supplies defaults, not
@@ -482,52 +487,95 @@ engine:
482
487
 
483
488
  **Turn exhaustion**: when `--max-turns` is exhausted the run ends `engine-error` (detail `max-turns`) and the phase is never committed, so `forgedock run-issue --retry` re-enters the same phase. Raise `<key>_max_turns` before retrying. Phase runlog events record `model` and `turns` next to `usage` so a limit can be sized from measured turn counts.
484
489
 
490
+ **Per-run scratch dir** (`tmp_root`): every engine child runs with `TMPDIR` and `CLAUDE_CODE_TMPDIR` set to `<tmp_root>/<issue>` so venvs and Claude session data stay off a size-limited `/tmp`. `engine.tmp_root` is an absolute or `~`-prefixed path; default `~/.forge/tmp`. When `tmp_root` is unset and the operator already exported `TMPDIR` or `CLAUDE_CODE_TMPDIR`, that choice is respected and never cleaned. The dir is removed on a terminal outcome and kept on `engine-error` so `--retry` resumes. A disk-full/quota failure (EDQUOT, ENOSPC, errno -28/-122) ends a retryable `engine-error` (outcome code `infra.disk_quota`), never `needs-human`.
491
+
485
492
  **Commands that use this section**: CLI backend (`runCommand` in `bin/runner.mjs`)
486
493
 
487
494
  ---
488
495
 
489
496
  ## `build` (OPTIONAL)
490
497
 
491
- 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.
498
+ Tuning for the `/work-on` build phase. Today it holds the **scope check** (forge#3862, replacing the diff-size gate): after the implement phase stages its changes and before validation commits them, the build compares the staged diff with the architect plan (the `PLANNED_FILES:` list and `ESTIMATED_DIFF_LINES` in the plan's `### Scope Baseline`), measures a deterministic `review_weight` with `scripts/diff-size.sh`, and records the outcome in `FORGE:DIFF_SIZE`. **Size never blocks, a finished build is never discarded, and a completed build is never routed to decompose.**
492
499
 
493
500
  ```yaml
494
501
  build:
495
- diff_size:
496
- # Changed-line ceiling. Default: 1000. 0 disables the gate.
497
- threshold: 1000
498
-
499
- # Pre-build estimate (investigate): an estimate above threshold x factor routes
500
- # straight to decompose without building. Default: 1.2. Skipped when threshold is 0.
501
- estimate_factor: 1.2
502
-
503
- # Pre-build estimate (investigate): a non-batch issue with at least this many
504
- # independent seams and an estimate over half the threshold is decomposed into
505
- # packed sub-issues (not one per seam). Review-finding batches are exempt.
506
- # Default: 3.
507
- batch_seam_min: 3
508
-
502
+ scope:
503
+ test_weight: 50 # percent weight of test lines in review_weight (default 50)
504
+ module_depth: 2 # path segments that define a module (default 2)
505
+ overrun_pct: 200 # whitespace-insensitive lines over the plan estimate, in percent, that count as scope creep (default 200)
506
+ trim_out_of_plan: false # move out-of-plan hunks into the batched follow-up checklist (default false: flag only)
507
+ trim_max_files: 3 # trim only when at most this many files are out of plan (default 3)
509
508
  # Extra exclusion globs, ADDITIVE to the built-in defaults below.
510
509
  exclude_globs:
511
510
  - "docs/api/*"
512
511
  - "*.pb.go"
512
+
513
+ review:
514
+ depth:
515
+ extended_weight: 1500 # review_weight at or above which review is EXTENDED (default 1500)
516
+ extended_modules: 6 # modules touched at or above which review is EXTENDED (default 6)
517
+ slice_files: 12 # changed files per slice in extended review (default 12)
513
518
  ```
514
519
 
515
520
  | Field | Type | Required | Description |
516
521
  |-------|------|----------|-------------|
517
- | `diff_size.threshold` | integer | No | Changed lines allowed before the gate trips. Default: 1000. `0` disables the gate |
518
- | `diff_size.estimate_factor` | number | No | Multiplier on `threshold` for the pre-build estimate: the investigator forces a decomposition when `ESTIMATED_DIFF_LINES` > `threshold` x `estimate_factor`. The investigator emits the effective value as an own-line `ESTIMATE_FACTOR` field, and the engine's fail-toward-decompose net (`isDecomposed`) uses it even if the verdict says NO (1.2 when the field is absent or invalid). That net is suppressed (treated as not decomposed) for a sub-issue (`**Parent**: #N`), an issue with a trusted `FORGE:DECOMPOSED` comment, or a justified `FORGE:SIZE_OVERRIDE`, because decompose Guard 1 refuses those. Default: 1.2. Skipped when `threshold` is `0` |
519
- | `diff_size.batch_seam_min` | integer | No | Independent seams at which an issue is decomposed when `ESTIMATED_DIFF_LINES` is over half of `threshold`. Does not apply to review-finding batches (`batch` label or `FORGE:BATCH_MEMBERS`), which decompose only above `threshold` x `estimate_factor`. Seams are packed into sub-issues, not one per seam. Default: 3. Skipped when `threshold` is `0` |
520
- | `diff_size.exclude_globs` | list of strings | No | Globs excluded from the measurement, in addition to the built-in defaults. Default: `[]` |
522
+ | `build.scope.test_weight` | integer | No | Percent of a test file's changed lines counted in `review_weight` (tests are easier to review). Default: 50 |
523
+ | `build.scope.module_depth` | integer | No | Leading path segments of a file's directory that identify a module for the `modules` count. Default: 2 |
524
+ | `build.scope.overrun_pct` | integer | No | The scope is flagged when whitespace-insensitive changed lines exceed the plan's estimate by more than this percent (200 = more than twice the estimate). Default: 200 |
525
+ | `build.scope.trim_out_of_plan` | boolean | No | Opt-in. Moves each out-of-plan file's hunks out of the PR into the batched follow-up issue (forge#3843); the hunks are saved in a patch file next to the git dir, so nothing is lost. Never triggered by an overrun alone. Default: `false` (flag only) |
526
+ | `build.scope.trim_max_files` | integer | No | Trim only when at most this many files are out of plan; more is flagged, not trimmed. Default: 3 |
527
+ | `build.scope.exclude_globs` | list of strings | No | Globs excluded from the measurement, in addition to the built-in defaults. Default: `[]` |
528
+ | `review.depth.extended_weight` | integer | No | `review_weight` at or above which the build records `review_depth: EXTENDED`. Default: 1500 |
529
+ | `review.depth.extended_modules` | integer | No | Modules touched at or above which the build records `review_depth: EXTENDED`. Default: 6 |
530
+ | `review.depth.slice_files` | integer | No | In extended review, each domain agent reviews slices of at most this many changed files. Default: 12 |
531
+
532
+ All thresholds are config values with generous defaults, to be calibrated from telemetry (`diff_size` events carry the plan estimate, the actual, `review_weight`, the scope outcome and the review depth; see `docs/TELEMETRY.md`).
533
+
534
+ **`review_weight`** (computed by `scripts/diff-size.sh`): changed lines with whitespace ignored (`git diff -w`, so a pure re-indent counts about 0), over the counted files only (generated and vendored code excluded), where test files count `test_weight` percent. `modules` counts the distinct directories (first `module_depth` segments) holding at least one non-whitespace change. It drives review depth, never blocking.
521
535
 
522
536
  **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.
523
537
 
524
- **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, unless the diff is cohesive (next paragraph). The staged diff is also persisted before the exit (pushed ref `forge/wip/{NUMBER}`, or a patch file when the push fails) and each split-proposal line links its hunks, so they survive worktree cleanup; decompose copies the link into each sub-issue. The measured `diff_lines` is recorded in the `FORGE:TRAJECTORY` comment.
538
+ **Scope-check outcomes** (`result:` in `FORGE:DIFF_SIZE`): `OK` (in plan, standard depth); `EXTENDED_REVIEW` (in plan, `review_weight` or `modules` at the extended threshold: review adds reviewers and per-file slices); `SCOPE_FLAGGED` (files outside the plan, or an estimate overrun: listed in the comment for review, review extended, optionally trimmed); `OVERRIDDEN` (scope flagged but a justified `FORGE:SIZE_OVERRIDE` is present). An architect skip marker (empty plan) means no plan baseline: the out-of-plan check is skipped, never blocking. A `FORGE:SIZE_OVERRIDE` comment (trusted author, non-empty justification on the line after the marker) suppresses the scope flag and the decompose loop guard. The measured `diff_lines` is still recorded in the `FORGE:TRAJECTORY` comment.
539
+
540
+ **Decomposition is a planning decision**: `work-on:investigate` answers `**YES**` only when the work is separable (independent modules or services, no shared files, no ordering dependency, each piece independently testable), with the reason recorded; a cohesive change stays one unit at any size. It emits `ESTIMATED_DIFF_LINES` (the scope baseline) and `INDEPENDENT_SEAMS` inside `### Decomposition Assessment`. The engine downgrades a `**YES**` to NO unless `INDEPENDENT_SEAMS` is at least 2 with an own-line `DECOMPOSE_JUSTIFICATION:`; it has no line-count rule. A sub-issue, an issue with a trusted `FORGE:DECOMPOSED` comment, or one with a justified `FORGE:SIZE_OVERRIDE` is never decomposed (decompose Guard 1).
541
+
542
+ **Migration from `build.diff_size.*`** (deprecated; read as aliases for one release, then removed):
525
543
 
526
- **Cohesive sub-issue overage**: a sub-issue (or an issue with `FORGE:DECOMPOSED`) that is over the threshold with no override continues instead of blocking when `scripts/diff-size.sh` reports `cohesive=true`: at least 70% of the counted lines are in files added by the diff or in test files (test directories `test/`, `tests/`, `__tests__/`, or `*.test.*`, `*.spec.*`, `test_*`, `*_test.*`), and `diff_lines` is at most `threshold` x 1.5. The build records `result: COHESIVE` with the cohesion figures and a reviewer note in `FORGE:DIFF_SIZE`, adds no `needs-human`, and continues to validation. Test lines are classified, not discounted. The 70% and 1.5x constants live in the script and are not configurable. A non-sub-issue over the threshold still routes to `NEEDS_DECOMPOSE`, and `FORGE:SIZE_OVERRIDE` still takes precedence. Each sub-issue is packed to at most `0.8 x threshold` and carries an own-line `ESTIMATED_DIFF_LINES: N` in its body, which `FORGE:DIFF_SIZE` records next to the actual `diff_lines` as `estimated_diff_lines`.
544
+ | Old key | New behaviour |
545
+ |---------|---------------|
546
+ | `build.diff_size.threshold` | No longer a gate. A positive value is read as `review.depth.extended_weight` (extended review instead of a block or split); `0` (gate disabled) now just means the defaults. The build records `deprecated_config` in `FORGE:DIFF_SIZE` when an alias supplied a value |
547
+ | `build.diff_size.exclude_globs` | Read as an alias of `build.scope.exclude_globs` (additive) |
548
+ | `build.diff_size.estimate_factor`, `build.diff_size.batch_seam_min` | Ignored. The investigator's decomposition rule is separability, not a line count |
527
549
 
528
- **Pre-build estimate**: `work-on:investigate` emits `ESTIMATED_DIFF_LINES`, `INDEPENDENT_SEAMS`, `GATE_THRESHOLD` and `ESTIMATE_FACTOR` inside `### Decomposition Assessment`. An estimate above `threshold` x `estimate_factor` (or, for a non-batch issue, `batch_seam_min`+ seams whose estimate is over `threshold` / 2) answers `**YES**`, so the issue is decomposed before any build into `max(2, ceil(estimate / (0.8 x threshold)))` packed sub-issues (the parent is a terminal tracker). The engine downgrades a `**YES**` that neither condition requires to NO unless the report has an own-line `DECOMPOSE_JUSTIFICATION:` line (it assumes `batch_seam_min` 3 and cannot see the `batch` label, so it is lenient for batches). The estimate rule is skipped when `threshold` is `0`.
550
+ `BUILD_RESULT: status: NEEDS_DECOMPOSE`, the `forge/wip/{NUMBER}` ref, the cohesive-overage path and the outcome code `build.size_over_gate` are retired. An old run whose build was handed to decompose can be resumed at build with `run-issue --retry` once a trusted `FORGE:DECOMPOSED:REVOKED` comment (newer than the `FORGE:DECOMPOSED` comment) and a justified `FORGE:SIZE_OVERRIDE` are on the issue; untrusted comments never reopen a run.
529
551
 
530
- **Commands that use this section**: `work-on` (build phase B5.5, `decompose`, `close`)
552
+ **Commands that use this section**: `work-on` (build phase B5.5, `close`), `review-pr` (Phase 3 review depth)
553
+
554
+ ---
555
+
556
+ ## `decomposition` (OPTIONAL)
557
+
558
+ Controls what happens to a decomposed parent issue. A parent is only a tracker for its sub-issues, but while it is open it inflates GitHub's open-issue count and every backlog view.
559
+
560
+ ```yaml
561
+ decomposition:
562
+ # Close the parent at decomposition. Default: false (parent stays open until its last child closes).
563
+ close_parent: true
564
+ ```
565
+
566
+ | Field | Type | Required | Description |
567
+ |-------|------|----------|-------------|
568
+ | `close_parent` | boolean | No | When `true`, `work-on:decompose` (Phase D6.5) closes the parent after the sub-issues exist, `FORGE:DECOMPOSED:COMPLETE` is posted and `workflow:decomposed` is set, with the comment "Decomposed into #a #b #c; tracked here until all complete". The label is kept. Every child body is also checked for the `**Parent**: #N` reference. Default: `false`. Any value other than `true`, or an unreadable config, leaves the parent open |
569
+
570
+ **Batch members**: when the parent carries a `FORGE:BATCH_MEMBERS` block, `work-on:decompose` (Phase D2.7) reopens and reuses a closed member that matches a planned sub-issue one to one, instead of filing a duplicate. Members with a merged PR or a `false-positive`, `needs-human` or `operator-only` label are never reopened, and any ambiguity falls back to creating a new issue. There is no config key for this.
571
+
572
+ **With `close_parent: true`**: `work-on:close` Phase C3 keeps checking children off the closed parent's checklist. When the last child closes, the parent gets an "All sub-issues complete" comment and `workflow:merged`; it is not closed a second time and never reopened. `/pipeline-status` and `/pipeline-health` exclude `workflow:decomposed` from work counts and report them separately as `trackers (N)`. `/orchestrate` expands an explicitly requested closed decomposed parent to its open children.
573
+
574
+ **With `close_parent: false`** (default): unchanged. The parent stays open and is closed by C3 when the last child closes.
575
+
576
+ **Limitation**: `--retry` and engine resume refuse closed issues, so a closed decomposed parent cannot be re-run. It has nothing left to run; work on its open children instead.
577
+
578
+ **Commands that use this section**: `work-on` (`decompose` D6.5, `close` C3), `orchestrate`
531
579
 
532
580
  ---
533
581
 
@@ -595,6 +643,8 @@ review:
595
643
  auth: "JWT + refresh token rotation; sessions stored in Redis"
596
644
  database: "Always use docker exec <container> for psql/migrations"
597
645
 
646
+ # max_filed_findings: 2 # per-review cap on filed findings (default 2; 1 on review-finding lineage)
647
+
598
648
  # key_paths:
599
649
  # auth: ["services/api/auth/**", "web/src/lib/auth/**"]
600
650
  # billing: ["services/api/billing/**", "web/src/app/billing/**"]
@@ -606,6 +656,7 @@ review:
606
656
  | `tech_stack` | string | No | One-line tech stack summary. Injected into all review agent prompts |
607
657
  | `context` | string (multiline) | No | Freeform context about architecture, deploy model, and known pitfalls |
608
658
  | `domains.*` | string | No | Domain-specific notes keyed by review agent name (`billing`, `auth`, `database`, `frontend`, `api`, `infra`, `security`) |
659
+ | `max_filed_findings` | integer >= 0 | No | Per-review cap on ISSUE dispositions (`scripts/cap-findings.sh`, applied by `review-pr` §6B.5 and `review-pr-staging` §7B (via `review-finalize.sh classify`) after classification). Default `2`; `1` when the reviewed PR is itself a fix for a `review-finding` issue, where every filed HIGH/CRITICAL consumes that budget. ISSUE findings are ranked by severity, then confidence, then safety-domain agent (security, auth, billing, concurrency, database), then input order; an unparseable severity or confidence ranks as MEDIUM/LIKELY. The overflow becomes `NOTE capped`: listed in the PR body and counted as `notes_capped` in `FORGE:NOTE_DISPOSITION`, never filed and never dropped. **HIGH/CRITICAL findings are never capped** and still block; `ISSUE fix-efficacy-*` findings (the linked issue is not fixed, or the PR re-introduces its defect) are never capped either and count against the review-finding budget like HIGH. `0` is valid (cap every MEDIUM/LOW finding); a non-numeric or negative value falls back to the default with a warning. An explicit value overrides both defaults. |
609
660
  | `key_paths` | map(string→list of strings) | No | Domain-to-file mapping used by `/work-on` investigation and `/review-pr` agents to quickly locate relevant files. Keys are domain names matching issue labels; values are lists of file path patterns (glob-style, relative to repo root). When present, agents use this table instead of inferring files from labels and issue body. |
610
661
 
611
662
  **Commands that use this section**: `review-pr`, `review-pr-agents` (all domain agents), `work-on` (Phase 1B investigation)
@@ -939,6 +990,7 @@ These variables are read from the operator's runner environment, not from `forge
939
990
  | `FORGE_TRAIL_OVERRIDE_ASSOCIATIONS` | the resolved `FORGE_TRAIL_TRUSTED_ASSOCIATIONS` value | Comma-separated `author_association` values prefiltering break-glass override candidates (the permission API stays authoritative). Never affects which FORGE markers count toward the gate. An approver with concealed org membership can show `CONTRIBUTOR`/`NONE`, so widen this variable (not the trail list) to accept them. An explicitly empty value means no override candidates. |
940
991
  | `FORGE_TRAIL_PIPELINE_LOGINS` | empty | Comma-separated GitHub logins (case-insensitive) that are the pipeline's own identity and can never approve a phase-trail break-glass override. The authors of trusted FORGE marker comments on the issue are added automatically. |
941
992
  | `FORGE_TRAIL_QG_SINCE` | `2026-10-07T03:40:12Z` | ISO-8601 UTC cutoff for the legacy `FORGE:QUALITY_GATE` grace. An issue whose trusted `FORGE:BUILDER:COMPLETE` comment was last updated before this time has `QUALITY_GATE` waived. Set to an empty string to disable the grace. A malformed or future value (including a runner clock earlier than this default) fails closed: the verifier exits 2, an infrastructure block rather than `MISSING`. |
993
+ | `FORGE_TELEMETRY` | `on` | Set to `off` to disable gate telemetry. Gate scripts append one structured record per decision to a trusted `FORGE:TELEMETRY v1` comment; emission is best-effort and never changes a gate's output or exit code. See [TELEMETRY.md](TELEMETRY.md). |
942
994
 
943
995
  ### Install root resolution
944
996
 
@@ -0,0 +1,53 @@
1
+ # Engine outcome codes
2
+
3
+ Version: 1 (`OUTCOME_CODES_VERSION` in `bin/engine/outcome-codes.mjs`, the single source of truth).
4
+
5
+ Every `PHASE_FAILED`, blocked `PHASE_COMMIT` (a handoff or block) and `RUN_TERMINAL` run-log event carries a
6
+ `code` from this closed enum, so stall causes can be counted and grouped without reading prose. The code is
7
+ derived in code from structured evidence only (`REVIEW_RESULT` fields, FORGE markers and sentinels,
8
+ `**Re-gate outcome**`, `err.code`), never from the free-text `detail`/`reason`, which stays alongside for humans.
9
+ `terminate()` also records the code in the issue's `FORGE:STATE` block (`outcomeCode`), so it survives a rebuild
10
+ or resume and is recoverable from GitHub alone. Adding a code is additive; renaming or removing one bumps the
11
+ version.
12
+
13
+ An absent or unknown code is recorded as `unclassified`, with the raw reason kept (`PHASE_FAILED.reason`,
14
+ `RUN_TERMINAL.detail`), so gaps show up in a report instead of being hidden.
15
+
16
+ | Code | Meaning | Emitted by | Source evidence |
17
+ |------|---------|------------|-----------------|
18
+ | `review.changes_requested` | Review asked for an in-PR fix and handed off to remediate | review | `REVIEW_RESULT` `status: NEXT`, `next: remediate` |
19
+ | `review.escalated` | Review escalated the PR to a human or remediation | review | PR/issue carries `needs-human` or `workflow:remediating` |
20
+ | `review.rereview_skipped` | Re-review was skipped | reserved | No structured evidence source yet |
21
+ | `review.blocking_issue` | Review left the PR open at an unchanged head with a filed blocking ISSUE and handed off to remediate | review | Trusted `FORGE:NOTE_DISPOSITION` on the PR with `findings_filed` > 0 and `reviewed_sha` equal to the live head |
22
+ | `review.pr_open` | Review ended with the PR open and unmerged and no handoff signal | review | Live PR open, no `REVIEW_RESULT` remediate handoff, no `needs-human`/`workflow:remediating` label, no blocking disposition |
23
+ | `build.acceptance_failed` | Newest acceptance gate marker is blocked | build | `FORGE:ACCEPTANCE_GATE:FAILED`/`:BLOCKED` or `FORGE:BUILD_BLOCKED` |
24
+ | `build.acceptance_malformed` | Acceptance spec was malformed | reserved | Indistinguishable from `build.acceptance_failed` by sentinel alone |
25
+ | `build.size_over_gate` | Retired (forge#3862): the old size gate routed a finished build to decompose; never emitted now, kept so historic runs still parse | reserved | A finished build is never routed to decompose |
26
+ | `build.cohesive` | Retired (forge#3862): the old size gate judged an oversized diff cohesive | reserved | No size gate remains |
27
+ | `remediate.no_marker` | Remediation ended without its completion marker | remediate | No trusted `FORGE:REMEDIATION:COMPLETE` |
28
+ | `remediate.deferred_live_run` | Remediation deferred to a live earlier run | remediate, engine | Trusted `FORGE:REMEDIATION_DEFERRED` record |
29
+ | `remediate.re_escalated` | Remediation re-escalated to a human | remediate | `**Re-gate outcome**: RE-ESCALATED` |
30
+ | `remediate.unfixable` | Remediation judged the findings unfixable | remediate | `**Re-gate outcome**: UNFIXABLE` |
31
+ | `ci.red` | CI failed | reserved | `REVIEW_RESULT` carries no CI field yet |
32
+ | `ci.timeout` | CI timed out | reserved | `REVIEW_RESULT` carries no CI field yet |
33
+ | `base.conflict` | Base-branch conflict blocked the PR | reserved | `REVIEW_RESULT` carries no conflict field yet |
34
+ | `rate_limited` | Session or usage limit outlasted the pause budget | engine | `err.usageLimit` / `err.resetAtEpochMs` |
35
+ | `engine.error` | Engine or tool failure (missing key/SDK, CLI crash, max turns, unresolved args, runner threw every attempt) | engine | `err.code` |
36
+ | `infra.disk_quota` | Disk full or quota exhausted (EDQUOT/ENOSPC, errno -28/-122); retryable infra failure, never `needs-human` | engine | Runner error text |
37
+ | `lease.held` | Another live agent holds the issue lease | engine | Foreign live lease on acquire |
38
+ | `dependency.open` | A trusted `**Depends on**: #N` dependency is still open, so the run was refused before any state write | engine | Trusted issue body `**Depends on**` line + the dependency issue's open state |
39
+ | `dependency.unreadable` | The issue body or a declared dependency could not be read, so the run was refused (fail closed) | engine | Failed or unparsable `gh api` read |
40
+ | `invalid` | Issue is invalid or in a divergent closed state | investigate, engine | `INVESTIGATION:INVALID` marker, or live snapshot |
41
+ | `decomposed` | Issue was decomposed into sub-issues | investigate, decompose | `FORGE:DECOMPOSED` or the decomposition assessment |
42
+ | `completed` | Non-failure terminal (merged, phase-complete, investigation) | engine | Terminal reason |
43
+ | `awaiting_merge` | Clean result held for a merge decision | remediate | Terminal reason `awaiting-merge` |
44
+ | `unclassified` | Fallback for an absent or unknown code | engine | The raw reason is kept alongside |
45
+
46
+ ## Known limits
47
+
48
+ - `build.acceptance_failed` covers malformed specs too until the gate sentinel distinguishes them.
49
+ - `ci.red`, `ci.timeout` and `base.conflict` stay reserved until `REVIEW_RESULT` carries structured fields for
50
+ them; the engine will not infer them from prose. Such outcomes are `unclassified` today.
51
+ - `needs-human` has many causes; a pause on a pre-existing `needs-human` label is `unclassified`.
52
+ - The `FORGE:TELEMETRY` comment write is owned by #3834. The engine forwards coded deferrals through the
53
+ existing `emitTelemetry` hook and exposes the code on the run log and `FORGE:STATE`.
@@ -2,6 +2,30 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## Review specs call the prepare and finalize scripts (#3841)
6
+
7
+ `commands/review-pr.md` is now under 100 KB: Phases 1-3B and 6 run through `scripts/review-prepare.sh` and `scripts/review-finalize.sh`, and `commands/review-pr-staging.md` reuses both (`review-prepare.sh --no-delta` in 0C, then `review-finalize.sh extract|classify|file-prepare|file-verify` with `--scope-files` and `--prior-prs` in Phase 7) instead of carrying its own template resolution, slicing, classifier and cap copies. A staging bundle whose API diff is too large is read from `origin/<base>...origin/<head>`. CI now runs `--fence-funcs` over `commands/review-pr*.md` and `--fence-state` over the staging spec. Engine: `close.detectOutcome` reports `merged` only for a CLOSED issue that also carries `workflow:merged` (a stale label on an open issue no longer counts).
8
+
9
+ Action: none. Staging reviews now stop with `REVIEW_RESULT: status: BLOCKED` when `review-prepare.sh` exits 3 (for example no template source, or an empty trusted `typescript.build` on the full-build lane) instead of continuing.
10
+
11
+ ## Post-build scope check replaces the diff-size gate (#3862)
12
+
13
+ The line-count size gate is gone. A finished build is never discarded and never routed to decompose; `BUILD_RESULT: status: NEEDS_DECOMPOSE`, the `forge/wip/{N}` ref, the `COHESIVE` path and the engine's size-based decompose routing (including the investigate estimate net) are removed. Decomposition is a planning decision: `work-on:investigate` says `**YES**` only for separable work (independent modules or services, no shared files, no ordering), and the engine downgrades a YES without `INDEPENDENT_SEAMS >= 2` and a `DECOMPOSE_JUSTIFICATION`. Build B5.5 is now a scope check: it compares the diff with the architect plan (`### Scope Baseline`: `PLANNED_FILES:` and `ESTIMATED_DIFF_LINES:`), flags files outside the plan (opt-in trim into the batched follow-up), and measures `review_weight` (whitespace-insensitive, generated/vendored excluded, tests weighted, modules counted). A high `review_weight` extends review (more reviewers, per-file slices) instead of blocking; the Security agent and the Phase 2 floor stay unconditional. `FORGE:SIZE_OVERRIDE`, the loop guards and the batch exemption keep working. `run-issue --retry` reopens a stale `decomposed` run at build when a trusted `FORGE:DECOMPOSED:REVOKED` comment and a justified `FORGE:SIZE_OVERRIDE` are present. Telemetry `diff_size` events now carry estimate, actual, `review_weight`, scope outcome and review depth.
14
+
15
+ Action: replace `build.diff_size.*` with `build.scope.*` / `review.depth.*` (see docs/CONFIG.md, "Migration"). The old keys are read as deprecated aliases for one release; `threshold` no longer blocks anything. Stacked-PR splitting and default calibration are tracked in #3907.
16
+
17
+ ## Gate scripts emit structured telemetry (#3834)
18
+
19
+ `diff-size.sh`, `review-acceptance-gate.sh`, `classify-finding.sh`, `wait-ci-green.sh`, `remediate-liveness.sh` and the build/review spec call sites (B5.5, B6.5, review panel) now append one JSONL record per decision to a trusted `<!-- FORGE:TELEMETRY v1 -->` comment on the issue or PR (shell emitter `scripts/lib/telemetry.sh`, Node twin `bin/engine/telemetry.mjs`). A failed write is buffered under `~/.forge/telemetry-buffer/` and flushed on the next emit. Catalogue and privacy note: `docs/TELEMETRY.md`.
20
+
21
+ Action: none. Set `FORGE_TELEMETRY=off` to disable. Gate stdout and exit codes are unchanged.
22
+
23
+ ## Deferred follow-ups are folded or batched into one issue (#3843)
24
+
25
+ Build B2.0 no longer files one issue per `deferred → #N` item. A same-mechanical-change sibling that fits the remaining `0.8 × build.diff_size.threshold` budget (1000 when the threshold is 0 or unset) is folded into the contract's Deliverables. The rest go through `scripts/file-deferred-followups.sh`: one follow-up per parent with a checklist (known-flaw items flagged `known-flaw`), or an append to the open trusted follow-up for the same parent (exact path match). The script prints a `DEFERRED_TELEMETRY deferred_items= folded= batched_into= filed=` line and calls the #3834 emitter only if present. The `FORGE:DEFERRED_FROM` marker and every path stay in the body, so review-pr deferred verification is unchanged.
26
+
27
+ Action: none. Expect fewer, larger follow-up issues titled `follow-up: N deferred item(s) (deferred from #P)`.
28
+
5
29
  ## remediate.md uses the shared resolver stub (#3779)
6
30
 
7
31
  `commands/work-on/remediate.md` no longer inlines the `FORGE_ROOT` bootstrap or the `TRUSTED_SCRIPT` / `CI_GATE_SCRIPT` resolvers; each fence uses the pinned `forge_resolve` locator from `scripts/lib/forge-resolve.sh` (unresolved script => empty => the existing strict branch). The spec shrinks from 95,283 to 80,957 bytes (-15.0%).
package/docs/STATS.md ADDED
@@ -0,0 +1,63 @@
1
+ <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
2
+ <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
3
+
4
+ # forgedock stats
5
+
6
+ A deterministic, version-over-version report. No model is involved: code reads records and counts them.
7
+
8
+ ```bash
9
+ npx forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
10
+ [--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
11
+ ```
12
+
13
+ `--repo` defaults to `project.owner`/`project.repo` in `forge.yaml`, else the `origin` remote. `--since 1.16.0` keeps
14
+ versions at or above it; `--since 2026-10-01` keeps activity on or after the date. `--compact` prints one row per
15
+ version (the table embedded in the staging review summary). `--json` is machine-readable.
16
+
17
+ ## Sources
18
+
19
+ 1. **GitHub (primary).** Issues, PRs and labels of the target repo, plus trusted `<!-- FORGE:TELEMETRY v1 -->` comments
20
+ (`docs/TELEMETRY.md`). Only comments from OWNER/MEMBER/COLLABORATOR or a Bot are read (same predicate as
21
+ `scripts/trusted-comments.sh`); `part=N` comments are concatenated and records are deduped on `eid`. All `gh` calls
22
+ are argv-based. Needs an authenticated `gh`.
23
+ 2. **Local cache (optional).** `~/.forge/stats-cache/<owner>_<repo>/cache.json` (`FORGE_STATS_CACHE_DIR`) holds what was
24
+ fetched plus an `updated_at` watermark, so the next run only re-reads items updated since. It is safe to delete (the
25
+ cost is one full re-fetch); a corrupt cache is treated as empty. `--refresh` ignores it, `--no-cache` never touches it.
26
+ 3. **Local run logs (optional).** `~/.forge/runs/*.jsonl` (`FORGE_RUNS_DIR`) for runs that predate the engine's
27
+ `phase_attempt` / `run_terminal` records. A run already present on GitHub is not counted twice. Only runs whose
28
+ `RUN_START.repo` equals the target repo are used. `--no-local` skips them.
29
+
30
+ With no local state at all, `forgedock stats --repo <r>` reproduces the report from GitHub alone.
31
+
32
+ ## Sections (per version and repo)
33
+
34
+ - **Cost**: cache-read tokens per issue (mean, median), per phase (mean over all runs, so phases add up to the per-issue
35
+ mean), per merged issue, output tokens, USD where recorded.
36
+ - **Time**: p50 and p90 duration per phase and wall time per issue.
37
+ - **Flow**: merged %, needs-human %, remediation rate, outcome-code distribution (`docs/OUTCOME-CODES.md`).
38
+ - **Review funnel**: findings per review by severity, then ISSUE / NOTE / in-PR fix, plus `validated` and `false-positive` issues.
39
+ - **Gates**: block rate per gate (acceptance, diff size OVER, CI, liveness), malformed-check rate, COHESIVE and OVER counts.
40
+ - **Issue flow**: issues created by source (review-finding, pipeline bot, human), closed by kind, net flow, findings per merged issue.
41
+
42
+ Each group shows a delta against the previous version of the same repo and is flagged `low-n` below `--min-n`
43
+ (default 10 finished runs).
44
+
45
+ ## Counting rules
46
+
47
+ They match `scripts/cost-report.sh`, so the two reports agree on the same logs: only finished runs count; hydrated runs
48
+ and runs with no usage events are excluded; tokens are cache-read tokens; `PHASE_ATTEMPT_USAGE` supersedes the cumulative
49
+ `PHASE_COMMIT` usage of the same phase. On GitHub a run is the set of `phase_attempt` records sharing a `run` id and it is
50
+ finished once a `run_terminal` record exists. On GitHub stats count one run per issue: the latest by start timestamp wins and earlier attempts (for example before a
51
+ `--retry`) are discarded, as in `scripts/cost-report.sh`. Wall time covers the winning engine process only.
52
+
53
+ ## Versions and n/a
54
+
55
+ Records carry `forge_version`. Records without one (older runs, issues with no telemetry) are placed by release time:
56
+ `--release-times FILE` (`<version> <epoch-seconds>` lines), else the git tags of the ForgeDock checkout, shown with a `~`
57
+ suffix; otherwise `unknown`. Anything that cannot be computed (no timestamps, no outcome codes, no records) prints `n/a`
58
+ instead of failing.
59
+
60
+ ## Limits
61
+
62
+ Issues and PRs are listed by `updated_at`, so the first run on a large repo makes one comment request per item that has
63
+ comments. Telemetry comments are edited in place; if a cached item looks stale, run with `--refresh`.