forgedock 1.29.1 → 1.31.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 (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +356 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -655,7 +655,7 @@ bash "$RF" classify --pr "$PR_NUMBER" --repo "$REPO" --context "$CTX" --out-dir
655
655
  cat "$OUT_DIR/note-list.txt" 2>/dev/null
656
656
  ```
657
657
 
658
- `extract` reads the trusted synthesized block for the head, else its trusted agent bodies (`BASE_SYNC_ONLY`: the carried head; `DELTA`: this head plus the earlier reviewed one), parses every `<!-- FINDING:... -->` marker (an unparseable marker blocks the review), dedupes by file + line range ±5 and by title (3+ shared keywords, higher confidence wins), and applies the **§6B.4 gates** in order, each keeping the finding when its input is unreadable: (1) **head-SHA**: a cited line past the end of the file at `REVIEW_SHA` is stale and DROPPED (`findings_dropped_stale`); (2) **introduced-line**: a MEDIUM-or-lower finding whose range (±5) misses the PR's added lines is `pre-existing` (**CRITICAL/HIGH are never routed as pre-existing**, whatever their confidence, `protocols.md` §5; input comes from the diff, never a reviewer's `**Scope**` tag; removed files, old names of renames and patch-less files are scope `unknown`, never pre-existing and never introduced; a renamed file with a patch is scored by its added lines); (3) **concrete-failure**: a MEDIUM-or-lower finding without a `**Failure scenario**` becomes a NOTE (`notes_demoted_no_scenario`).
658
+ `extract` reads the trusted synthesized block for the head, else its trusted agent bodies (`BASE_SYNC_ONLY`: the carried head; `DELTA`: this head plus the earlier reviewed one), parses every `<!-- FINDING:... -->` marker (an unparseable marker blocks the review), dedupes by file + line range ±5 and by title (3+ shared keywords, higher confidence wins), and applies the **§6B.4 gates** in order, each keeping the finding when its input is unreadable: (1) **head-SHA**: a cited line past the end of the file at `REVIEW_SHA` is stale and DROPPED (`findings_dropped_stale`); (2) **introduced-line**: a MEDIUM-or-lower finding whose range (±5) misses the PR's added lines is `pre-existing` (**CRITICAL/HIGH are never routed as pre-existing**, whatever their confidence, `protocols.md` §5; input comes from the diff, never a reviewer's `**Scope**` tag; removed files, old names of renames and patch-less files are scope `unknown`, never pre-existing and never introduced; a renamed file with a patch is scored by its added lines); (3) **concrete-failure**: a MEDIUM-or-lower finding with no failure scenario in its prose (a `**Failure scenario**`, or a line starting with a bold or plain `Failure scenario`, `Scenario`, `Repro`, `Reproduction` or `Steps to reproduce` label) becomes a NOTE (`notes_demoted_no_scenario`); **CONFIRMED MEDIUM and CRITICAL/HIGH are never demoted** this way and are filed with the agent comment as evidence.
659
659
 
660
660
  Read `STRUCTURED_FINDINGS`, `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the record and summary. The caller's part of 6A: add the Phase 2.5 `INTEG-N` findings no reviewer covered (CONFIRMED HIGH, so ISSUE, filed in 6C); findings come only from `FINDING:` markers, never from free text. If `AGENTS_WITHOUT_BLOCK` is non-empty those agents are undelivered: re-dispatch them once (Phase 4 rule), else take the degraded-panel path; `CLEAN_STRUCTURED` is withheld while it is non-empty. On `CLEAN_STRUCTURED=true` and no `INTEG-N` the review is clean: skip to Phase 7. A finding that somehow has no marker is classified with `classify-finding.sh --unstructured` (empty or unparseable severity gives `NOTE unstructured`, never ISSUE). **Pre-existing route**: a pre-existing finding (MEDIUM or lower) is never filed as `review-finding`/`needs-validation` nor linked to this PR (it stays out of findings-per-PR and the amplification ratio); it still passes the §6B.5 classifier, is listed once in the summary, and at most 3 per review (highest severity first) are filed with the `pre-existing` label plus `priority:*`, citing this PR (`preexisting_filed`, not `findings_filed`).
661
661
 
@@ -167,6 +167,7 @@ Work in `{WORKTREE_PATH}`. Follow the contract deliverables table exactly — im
167
167
  - For each acceptance criterion in the contract: implement it, then mentally verify it's met
168
168
  - Do NOT add unrequested scope — contract out-of-scope items stay out of scope
169
169
  - **Library callback verification**: When writing a lambda or callable that will be passed to a library/framework parameter (e.g., `prepared_statement_name_func=lambda: ""`, `key=lambda x: ...`), you MUST verify the expected calling convention BEFORE writing it. Check the library's default value for that parameter, its documentation, or its source code. A lambda with wrong arity causes `TypeError` at runtime — this is invisible to static analysis and linting. The P0 incident from PR #14391 was caused by `lambda _: ""` (1 arg) passed where SQLAlchemy expects 0 args.
170
+ - **Self-check scope**: verify with the configured `forge.yaml → verification.commands` when present (a configured `typescript.lint` is the only lint you run). Otherwise lint and test only the changed files or packages; never lint a dependency closure (turbo `--filter=...[base]`, nx affected), and leave whole-repo checks to CI. <!-- Added: forge#4381 -->
170
171
  - **Scratch placement (venvs, caches)**: create virtualenvs, package caches and other large scratch data under `"$TMPDIR/forge-venv-{NUMBER}"` (the engine exports a per-run, disk-backed `TMPDIR`) or inside the worktree. Never use a literal `/tmp/<name>` path: a size-limited `/tmp` fails concurrent lanes with EDQUOT/ENOSPC. Remove what you created when the run finishes. <!-- Added: forge#3572 -->
171
172
  - **Worktree-aware path derivation**: When writing shell code that derives repository paths, ALWAYS use `git rev-parse --show-toplevel` for the repo root in regular checkouts, or `git rev-parse --git-common-dir` (then `dirname`) to get the shared `.git` directory when the context may be a linked worktree. For worktree cleanup code specifically, ALWAYS use `--git-common-dir` — `--show-toplevel` returns the worktree path itself, NOT the main repo root. NEVER use `pwd`, relative paths, or `dirname` chains on `--show-toplevel` output for repo root derivation. Test path logic for both regular checkouts and linked worktrees. (Ref: review-findings #104, #105 — 4 PATH_DERIVATION defects, 15% of all review findings)
172
173
  - **State machine completeness verification**: When implementing routing logic, state transition tables, or phase dispatch code (e.g., adding a `Skill()` call in a routing loop, adding a new phase to a state machine, creating a new subcommand file), you MUST run three checks BEFORE staging: (1) **Routing target existence** — for each `Skill("subcommand", ...)` call or file reference in routing logic, verify the target file exists at `commands/{subcommand-path}.md`; (2) **State reachability** — for each declared state or phase, verify at least one prior state or entry condition in the router routes to it; (3) **Subcommand invocation wiring** — for each `commands/work-on/*.md` file that declares its invocation condition (e.g., `Invoked by work-on.md routing loop, when X`), verify that condition is actually handled in the router. A missing target file, an unreachable state, or a declared-but-unwired subcommand will not surface until review. (Ref: review-findings #85, #116, #137 — 4 ROUTING defects, 15% of all review findings)
@@ -214,6 +214,7 @@ FORGE_CFG=forge.yaml; [ -f "$FORGE_CFG" ] || { _gcd=$(git rev-parse --git-common
214
214
  TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' "$FORGE_CFG" 2>/dev/null || echo '')
215
215
  TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' "$FORGE_CFG" 2>/dev/null || echo '')
216
216
  TS_BUILD=$(yq '.verification.commands.typescript.build // ""' "$FORGE_CFG" 2>/dev/null || echo '')
217
+ TS_LINT=$(yq '.verification.commands.typescript.lint // ""' "$FORGE_CFG" 2>/dev/null || echo '')
217
218
 
218
219
  if [ -n "$TS_FORMAT" ]; then
219
220
  run_verification_command "typescript.format" "$TS_FORMAT"
@@ -234,8 +235,17 @@ else
234
235
  SKIPPED_CHECKS="${SKIPPED_CHECKS:+$SKIPPED_CHECKS, }typescript.typecheck/build"
235
236
  TS_EXIT=0
236
237
  fi
238
+
239
+ if [ -n "$TS_LINT" ]; then
240
+ run_verification_command "typescript.lint" "$TS_LINT"
241
+ TS_LINT_EXIT=$?
242
+ else
243
+ echo "SKIPPED — typescript.lint not configured in verification.commands"
244
+ SKIPPED_CHECKS="${SKIPPED_CHECKS:+$SKIPPED_CHECKS, }typescript.lint"
245
+ TS_LINT_EXIT=0
246
+ fi
237
247
  ```
238
- Typecheck or build failures are BLOCKING — fix type errors before continuing.
248
+ Typecheck, build or lint (`TS_LINT_EXIT`) failures are BLOCKING — fix them before continuing. Configure `typescript.lint` as a changed-files, cached lint; it is the lane's only lint (forge#4381).
239
249
 
240
250
  **Shell scripts**: Verify service interactions — read target middleware files, document what was verified in the V5 summary.
241
251
 
package/docs/CONFIG.md CHANGED
@@ -554,17 +554,20 @@ engine:
554
554
  | `<key>_model` | string | No | Model alias (`sonnet`, `opus`, `haiku`) or full model id passed as `--model`. Invalid values are ignored. |
555
555
  | `<key>_max_turns` | integer | No | Positive integer passed as `--max-turns`. Invalid values are ignored. |
556
556
 
557
- | `path` | string | No | `core` (default), `full`, `lean` or `auto`. `core` is the lean work-on (`bin/lean/`): a planning call that writes a frozen contract, an implement call in the same session, one read-only conformance reviewer, GitHub as the register (see `conformance`). `lean` and `auto` are aliases of `core`; `full` runs the legacy multi-phase pipeline and is required for `--retry` and `--wait-lease`. The `--path` flag overrides. `lean_reviewer_model` sets the reviewer model (default `claude-sonnet-5-5`); the builder uses `--model` or the CLI default. `forgedock orchestrate <issues...> [--parallel K] [--lane L]` runs the core per issue (K default 3, lane default `staging`) and honours `**Depends on**` ordering among the given issues. |
557
+ | `path` | string | No | `core` (default), `full`, `lean` or `auto`. `core` is the lean work-on (`bin/lean/`): a planning call that writes a frozen contract, an implement call in the same session, one read-only conformance reviewer, GitHub as the register (see `conformance`). `lean` and `auto` are aliases of `core`; `full` runs the legacy multi-phase pipeline and is required for `--retry` and `--wait-lease`. The `--path` flag overrides. `lean_reviewer_model` sets the reviewer model (default `claude-sonnet-5-5`); the builder uses `--model` or the CLI default. `forgedock orchestrate <issues...> [--parallel K] [--lane L]` runs the core per issue (K default 3, lane default `staging`) and honours `**Depends on**` ordering among the given issues. A lane that reports an open dependency pulls it into the batch, unless that dependency is labelled `needs-human` or already ended `needs-human` in the batch: then the issue and its dependents end `skipped (dependency #N needs-human)` without a lane for it (forge#4387). |
558
558
  | `conformance` | string | No | Core work-on conformance gate. The builder first writes a contract (requirements quoted verbatim from the issue and maintainers' comments, deliverables, preserve constraints, validate commands), which the engine freezes and posts as `FORGE:CONTRACT`; changes go only through typed amendments (`FORGE:AMENDMENT`). A deterministic precheck (scope, validation, deferrals) and a row-based conformance review follow, and the engine computes the verdict. `shadow` (default): the verdict is posted in `FORGE:REVIEW` and logged (`CONFORMANCE_SHADOW` event) but only what the previous reviewer blocked on still blocks (a requirement found not implemented, a regression), plus the precheck's equivalent: any `conformance.unimplemented` precheck or amendment failure (a validate command that failed or has no recorded result, a deliver requirement the builder reported as not implemented, an invalid amendment; #4160). These block before any reviewer run. Scope creep and omissions are recorded only. A review still incomplete or inconsistent after one targeted re-ask (only the missing ids) ends `needs-human`, or with `review_incomplete: degraded` merges with a `review-degraded` PR label and a tracked follow-up. Accepted deferrals are filed and linked, as in enforce. `scripts/calibration.mjs --shadow` reports the per-repo shadow verdict mix and when to switch to enforce. `enforce`: every failure blocks (one same-session builder resume, then `needs-human` with a `conformance.*`/`review.*` code, see docs/OUTCOME-CODES.md); deferrals the reviewer accepts are filed in one batched follow-up issue (`scripts/file-deferred-followups.sh`) and linked in the PR body and a `FORGE:TRACKED` comment before merge. |
559
559
  | `cli_idle_minutes` | number | No | Core builder idle timeout (#4112): the builder CLI runs with `--output-format stream-json` and is killed (code `cli.idle`) when it shows no stream activity for this many minutes. The run log records the last tool call and the live child processes. Default 15. |
560
+ | `cli_busy_minutes` | number | No | Extended idle window for a core builder (#4405). When the idle timeout is reached but the session has a live heavy step (a heavy-shim step, test runner, linter or type checker child) or a queued or running heavy-queue entry of its own processes, the kill waits until `cli_idle_minutes` + this many minutes of silence. The run log records `CLI_IDLE_EXTENDED` once per silent stretch, and a later `CLI_IDLE_KILL` carries `heavyQueue` ({queued, running}) and `extended: true`. A silent session with no such child is still killed at `cli_idle_minutes`. Default 45 (the heavy queue's 30-minute maximum wait plus the 15-minute idle default). |
560
561
  | `cli_max_minutes` | number | No | Absolute ceiling for a core builder session (code `cli.timeout`), replacing the fixed 60-minute kill. A session that keeps making progress is never idle-killed. Default 180. On either kill, uncommitted work is committed as `wip(forge): checkpoint after <reason>` and the lane branch is pushed (with the push retry), the builder retries once from the checkpoint, and a second kill ends `deferred`; a rerun resumes from the branch. |
562
+ | `turn_budget_s` / `turn_budget_m` / `turn_budget_l` | number | No | Core implement turn budget per lane size (#4392). Size comes from a `size:S|M|L` issue label, else the frozen contract's distinct deliverable paths (S ≤ 2, M ≤ 5, else L). The build call and its context/timeout restarts share the budget; at the limit the session is stopped, checkpointed, and one fresh wrap-up session of max(5, budget/5) turns either finishes or writes `status: budget-exceeded` with a digest. A stop or overrun ends `needs-human` with code `budget.exceeded`, which `forgedock orchestrate` recovers once (continue from the checkpoint or split). Lane results carry `turns: {size, budget, build, total, exceeded}`, and the batch report prints `turns by size`. Defaults 25 / 60 / 100; 0 disables that size. |
561
563
  | `lane_limits` | map | No | Cap each core lane's whole process tree (#4115), under `pipeline:` or `engine:`: `memory_max` (for example `3G`) and `cpu_quota` (for example `150%`). When set and `systemd-run --user --scope` works (probed once), the builder CLI is spawned as `systemd-run --user --scope -q -p MemoryMax=... -p CPUQuota=... -- claude ...`; elsewhere the caps are ignored. Write it nested or flat (`lane_limits_memory_max`, `lane_limits_cpu_quota`). While a builder runs the engine polls host load and memory every 15 s against the `admission` thresholds: over them it SIGSTOPs the lane's process group, under them SIGCONT, and never pauses longer than 10 minutes. |
562
564
  | `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores). Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
563
565
  | `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires: the union of classic branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks) and repository rulesets (`gh api repos/{o}/{r}/rules/branches/{base}`, `required_status_checks` rules); with neither (404 or empty) merge right after review PASS. A merge rejected by base branch policy (PR `MERGEABLE` but `BLOCKED`) is waited on and retried within the CI budget, then left `ci-pending`; it is never base-synced and never `base.conflict`. A failing required check is `needs-human`; a pending one is polled with backoff and never `needs-human`. `all`: wait for every check (`scripts/wait-ci-green.sh`). `none`: merge right after review. Always `--match-head-commit` at the reviewed head. The policy used is recorded in the `FORGE:WORK-ON` comment. |
564
- | `context_pack` | string | No | Context pack (#4275). Before the plan call the core compiles the issue's GitHub/git neighbourhood into a small, budgeted pack: linked issues and PRs (parent, `Depends on`, body links, same-repo cross-references, siblings), prior attempts, merged PRs on the issue's files that were reverted or whose decisions/findings name a symbol from the issue, and repo-fact file pointers. `shadow` (default): the pack is compiled, posted on the issue as a collapsed `FORGE:CONTEXT` comment and logged as a `CONTEXT_PACK` event; the builder prompt is unchanged. `on`: it is also appended to the plan prompt as hints. `off`: nothing is gathered, posted or logged. The pack never enters the text contract quotes are checked against. Values read from linked issues' `FORGE:STATE` are kept only when they are outcome codes. Compare with `forgedock stats --context-ab`. |
565
- | `context_pack_sources` | string | No | Comma list of the pack sources to use: `graph`, `attempts`, `ledger`, `facts`. Default: all four. |
566
+ | `context_pack` | string | No | Context pack (#4275). Before the plan call the core compiles the issue's GitHub/git neighbourhood into a small, budgeted pack: linked issues and PRs (parent, `Depends on`, body links, same-repo cross-references, siblings), prior attempts, merged PRs on the issue's files that were reverted or whose decisions/findings name a symbol from the issue, structured `FORGE:FACTS` records of earlier lanes on the issue's files (rejected approaches with why, hot spots, decisions, conventions, baselines; #4401), and repo-fact file pointers (found in the lane worktree or, for untracked caches, the main checkout; #4400). `shadow` (default): the pack is compiled, posted on the issue as a collapsed `FORGE:CONTEXT` comment and logged as a `CONTEXT_PACK` event; the builder prompt is unchanged. `on`: it is also appended to the plan prompt as hints. `off`: nothing is gathered, posted or logged. The pack never enters the text contract quotes are checked against. Values read from linked issues' `FORGE:STATE` are kept only when they are outcome codes. Compare with `forgedock stats --context-ab`. Unless `off`, a resumed or recovery lane also gets a bounded resume digest of its last run in the builder prompt (end reason, validate results per command, deliver requirements, files changed; logged as `RESUME_DIGEST`; #4400), whatever the mode. |
567
+ | `context_pack_sources` | string | No | Comma list of the pack sources to use: `graph`, `attempts`, `pitfalls`, `ledger`, `facts` (earlier lanes' `FORGE:FACTS` on the issue's files plus repo-fact file pointers). Default: all five. |
566
568
  | `context_pack_bytes` | integer | No | Hard byte cap of the pack; items that do not fit are dropped whole and counted. Default: 6144. |
567
569
  | `context_pack_days` | integer | No | Window of the merged-PR ledger, in days. Default: 30. |
570
+ | `lane_instructions` | string | No | Lane profile (#4395). Core lane sessions always run with Claude Code auto-memory off (`CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`). Set this to a repo-relative file, for example `devdocs/lane-instructions.md`, that holds only the hard rules plus pointers into docs. It is read from the lane worktree, so it is versioned with the repo. Lanes then also skip CLAUDE.md discovery (`CLAUDE_CODE_DISABLE_CLAUDE_MDS=1`), and the file text is appended to the system prompt of every fresh builder, reviewer and verifier session. An absolute path, a `..` path, or a missing, empty or over-16 KB file is a trajectory anomaly; lanes then keep CLAUDE.md. Settings and hooks always load (no `--setting-sources`). `FORGEDOCK_FULL_SESSION=1` keeps a full session. Each `phase_attempt` telemetry record carries `lane_profile` (`full`, `memory-off` or `lane`) and `baseline_tokens`, the first turn's context of a fresh session, so baseline growth is visible. |
568
571
  | `ci_pending_max_minutes` | integer | No | Minutes the engine waits for CI when the review phase ends BLOCKED with "checks still pending", or the remediate phase holds at its M7 re-gate on "CI pending" (the merge gate's wait window ended while CI was still running). Default: 90. Invalid values are ignored. This key is flat like the others but is not part of the `<key>_model` / `<key>_max_turns` pattern. |
569
572
 
570
573
  **CI still pending**: the engine polls `scripts/wait-ci-green.sh` (no model calls) one read per poll on a 60 s, 2 min, 4 min, 5 min (cap) backoff with +/-20% jitter, while the lease keeps renewing, then re-dispatches the phase that was waiting, review or remediate (idempotent at an unchanged head: review re-runs the merge gate and merges; remediate resumes at its M7 re-gate). Before each poll it reads `gh api rate_limit` (not itself rate-limited) and, below 10% of the REST budget, sleeps until the window resets instead of polling; a rate-limited or transient (5xx, network) poll is retried and never re-dispatches the phase. Red CI takes the normal ci-gate path. When the budget is exhausted the run ends `needs-human` with outcome code `review.ci_pending_timeout`; `--retry` resumes. Each wait emits a `ci_pending_wait` telemetry event (`docs/TELEMETRY.md`) with the minutes waited, polls made and rate-limit sleeps.
@@ -590,11 +593,56 @@ Settings of the core work-on (`bin/lean/`) that change its contract.
590
593
  ```yaml
591
594
  lean:
592
595
  repro_test: prefer # off | prefer (default)
596
+ precheck:
597
+ max_minutes: 10 # optional pre-PR check round budget in minutes (1-120)
598
+ fix_model: claude-sonnet-5-5 # optional model of fresh fix-round sessions (default: the reviewer model)
599
+ builder_routing: on # on (default) | off: route the build to a mid-tier model by the frozen contract
600
+ builder_mid_model: claude-sonnet-5-5 # optional mid-tier builder model (default claude-sonnet-5-5)
601
+ light:
602
+ mode: shadow # off | shadow (default) | on: light-lane eligibility (#4448; lane wiring #4450)
603
+ max_lines: 40 # upgrade to the full lane above this many non-test diff lines
604
+ max_files: 2 # max predicted / changed non-test files
605
+ sequence:
606
+ regenerate: pnpm --filter @x/db db:generate # optional snapshot generator after a deterministic renumber
593
607
  ```
594
608
 
595
609
  | Field | Type | Required | Description |
596
610
  |-------|------|----------|-------------|
597
- | `repro_test` | string | No | `prefer` (default): on an issue labelled `bug` or `type:bug`, the plan step must freeze a `repro_test` in the contract (`{"path","id","command"}`) when the bug is testable. Before review, the engine runs `scripts/acceptance-red-green.sh` for that command on the host in every verify mode: one targeted test, red on the base (head's changed test files overlaid) and green on head. Any other result, including a test that could not run, fails the precheck as `conformance.unimplemented` (it blocks in shadow too) and goes back to the builder for one in-session fix round. A bug that is not testable (specs, docs, workflows, environment) records `"repro_test":"none"` with a one-line `repro_test_reason` and the lane continues as before. `off` gives exactly the previous contract and precheck. Each proof emits a `REPRO_RED_GREEN` run-log event (`base`, `head`) and adds a trajectory decision. |
611
+ | `repro_test` | string | No | `prefer` (default): on an issue labelled `bug` or `type:bug`, the plan step must freeze a `repro_test` in the contract (`{"path","id","command"}`) when the bug is testable. Before review, the engine runs `scripts/acceptance-red-green.sh` for that command on the host in every verify mode: one targeted test, red on the base (head's changed test files overlaid) and green on head. A test that ran but is not red on the base and green on head fails the precheck as `conformance.unimplemented` (it blocks in shadow too) and goes back to the builder for one in-session fix round. A head the harness could not run at all (timeout, missing runner or dependencies) ends `needs-human` with the recoverable code `env.repro_unrunnable`, not a builder round: the batch runner's recovery lane reruns it through the repo's own toolchain (see docs/OUTCOME-CODES.md). The harness runs with the lane env (`TMPDIR`, toolchain) and the workspace dependency trees, and a repro test file that is new on head is run against the base sources. A bug that is not testable (specs, docs, workflows, environment) records `"repro_test":"none"` with a one-line `repro_test_reason` and the lane continues as before. `off` gives exactly the previous contract and precheck. Each proof emits a `REPRO_RED_GREEN` run-log event (`base`, `head`) and adds a trajectory decision. |
612
+ | `precheck.max_minutes` | number | No | Total budget in minutes (1–120) of one round of pre-PR check commands (#4331). The flat form `lean.precheck_max_minutes` also works and wins. When unset, `verify.precheck_total_seconds` applies (default 600 s). Check commands run change-scoped (a learned command with a test-map entry runs only when its globs match the changed files; unmapped commands always run), concurrently up to the host worker cap under one heavy slot, each timed (`ms` in the `PRECHECK_COMMANDS` run-log event). Commands not started when the budget runs out are recorded `deferred-to-ci` (an anomaly, not a failure) and left to CI. |
613
+ | `fix_model` | string | No | Model of fresh fix-round sessions (#4363; `engine.lean_fix_model` is also read). Default: the reviewer model (`engine.lean_reviewer_model`, else Sonnet). Fix rounds for precheck, CI, docs, structure, enumerating-test and sequence-number failures, and conformance or review FAILs whose failures all cite a `file:line`, run in a fresh session (no resume of the builder's transcript) whose prompt carries only the round's failure output, the capped `git diff <base>...HEAD`, the frozen contract and the issue title and summary (under 60K characters). A fresh session that makes no commit gets one resume of the original builder session in the same round. Other rounds (block or contract corrections, CI unavailable, base-sync conflicts, failures without a file:line) resume the builder as before; round limits (`verify.max_rounds`, the one review retry) are unchanged. The run log's `PHASE_ATTEMPT_USAGE` records `mode: "fresh"\|"resume"` on each fix-round attempt. Plan and build sessions keep the builder model. |
614
+ | `builder_routing` | string | No | `on` (default) or `off` (#4394). After the contract is frozen, the lane is classed from its deliverable paths only: `risk` (any path with a built-in risk word such as auth, billing, payments, session, secret or migration, or matching a `review.risk_paths` glob), `docs-config-test` (every path is a test, doc or config file; lockfiles count as code), `small` (at most 2 non-test deliverables) or `large` (more, or none). `docs-config-test` and `small` build on the mid tier (`builder_mid_model`); `risk` and `large` build on the top model (`--model`, else the CLI default). The plan and contract-correction calls always run on the top model. A failed validate round on the mid tier (`precheck`, `ci_red`, `merge_ci`) escalates the lane once: that fix round and every later builder call, fresh fix sessions included, run on the top model. Escalation adds no round. Conformance or review fix rounds do not escalate and keep `fix_model`. `off` keeps every builder call on the top model, as before, and still records the class. The run log gets `BUILDER_ROUTE {mode, class, tier, model, risk}` and `BUILDER_ESCALATED {from, to, trigger}`, and `RUN_TERMINAL` gets `builder_tier`, `builder_class`, `builder_escalated` and `builder_model`. `forgedock stats --tiers` reports merge, conformance and post-merge-rework rates, first-pass yield and cost per tier (`mid`, `mid->top`, `top`) and class, from local run logs. Target-repo risk areas (for example detection code) belong in `review.risk_paths`. |
615
+ | `builder_mid_model` | string | No | Mid-tier builder model for `builder_routing` (#4394; `engine.lean_builder_mid_model` is also read). Default `claude-sonnet-5-5`. An invalid id is ignored. |
616
+ | `light.mode` | string | No | `off`, `shadow` (default) or `on` (#4448). `bin/lean/light.mjs` decides whether an issue qualifies for the light lane: a repro, steps to reproduce, expected or actual behaviour or a code block in the body; 1 to `max_files` predicted non-test files; no `size:M`/`size:L` label; no predicted path with a built-in risk word or matching `review.risk_paths`; not an `investigate(` issue, a post-deploy issue (`post-deploy`/`awaiting-promotion` label, or only post-deploy checklist items) or a decision issue (`decision`, `needs-decision`, `product-decision`, `rfc` or `adr` label, or a `decision(`/`rfc(`/`adr(` title). A missing repro or file prediction is `ambiguous`, left to a cheap classifier. `off` is never eligible; `shadow` reports the verdict without changing the lane; `on` lets an eligible issue run light. The lane wiring is #4450: until it lands, no lane changes shape in any mode. The flat form `lean.light_mode` also works and wins. |
617
+ | `light.max_lines` | number | No | Upgrade trigger (#4448): a light lane whose non-test diff (added + deleted lines) exceeds this moves to the full lane. Default 40. The other triggers: more than `max_files` non-test changed files, a repro that is not red on the base, two failed test runs, a touched risk path, or a public-API-shape change (#4449). Flat `lean.light_max_lines` wins. |
618
+ | `light.max_files` | number | No | Max predicted non-test files for eligibility and max changed non-test files before an upgrade (#4448). Default 2. Flat `lean.light_max_files` wins. |
619
+ | `sequence.regenerate` | string | No | Command run in the lane worktree (`sh -c`) after the engine renumbers a schema-changing sequence-numbered migration (#4386), to regenerate its snapshot, e.g. `pnpm --filter @x/db db:generate`. The flat form `lean.sequence_regenerate` also works and wins. When a lane-added numbered file (`NNNN_*.sql`, a drizzle `meta/_journal.json` + `meta/NNNN_snapshot.json`, a numbered ADR) shares its number with the base, the engine renumbers it without a builder session: `git mv` to max(base)+1…, the document's own number in its heading, references to the old name repo-wide, the base journal plus the lane's entries, and a data-only migration's snapshot cloned from the base tip with a new `id`/`prevId`. A schema-changing migration without this command, or a migration in another language (`.py`, `.ts`), falls back to the builder renumber, and the trajectory records why. A base-sync whose conflicts are only in such files is resolved the same way, with no re-review. At the merge gate a lane with numbered files holds a host sequence lease per (repo, lane branch, directory) from its last renumber check through the merge, so only one numbered-file PR is in the renumber→CI→merge window at a time. |
620
+
621
+ ## `stats` (OPTIONAL)
622
+
623
+ Thresholds of the fail-soft alerts (#4354). `forgedock stats` prints them as an `ALERTS` section, and the `forgedock orchestrate` batch report ends with the alerts for the batch's repo. Steps that never fail a run (shared dependency cache, pre-PR check budget, toolchain detection) can degrade silently. The alerts compare their rates in the local run logs (`~/.forge/runs`, or `FORGE_RUNS_DIR` for `forgedock stats`) against these thresholds. They read no network source and use no model.
624
+
625
+ ```yaml
626
+ stats:
627
+ alerts:
628
+ window: 50 # last N lanes per repo
629
+ min_lanes: 10 # fewer lanes in the window: nothing is printed
630
+ deps_failed_pct: 20 # DEPS_PREPARED status failed, per ecosystem
631
+ deferred_to_ci_pct: 30 # lanes with a deferred-to-ci pre-PR check
632
+ toolchain_warning_pct: 50 # lanes repeating the same TOOLCHAIN_WARNING
633
+ outcome_share_factor: 2 # outcome code share vs the previous window
634
+ ```
635
+
636
+ | Field | Type | Required | Description |
637
+ |-------|------|----------|-------------|
638
+ | `alerts.window` | number | No | Rolling window: the last N lanes of the repo, ordered by `RUN_START`. Each run in a run log is one lane. Default 50. |
639
+ | `alerts.min_lanes` | number | No | With fewer lanes in the window, or no run logs, nothing is printed. Default 10. |
640
+ | `alerts.deps_failed_pct` | number | No | Alert when `DEPS_PREPARED` status `failed` is above this percentage of the lanes that prepared that ecosystem. Default 20. |
641
+ | `alerts.deferred_to_ci_pct` | number | No | Alert when more than this percentage of lanes left a pre-PR check `deferred-to-ci`. Default 30. |
642
+ | `alerts.toolchain_warning_pct` | number | No | Alert when one `TOOLCHAIN_WARNING` text is repeated on more than this percentage of lanes. Default 50. |
643
+ | `alerts.outcome_share_factor` | number | No | Alert when an outcome code other than `completed` has at least this factor of its share in the previous window. The code must be on at least 2 lanes, and the previous window must have at least `min_lanes` lanes. A code new in this window counts. Default 2. |
644
+
645
+ Each alert line names the event, the rate, the sample size (`count/sample` lanes) and one example reason. When no alert fires, nothing is printed. The flat form `stats.alerts_<key>` also works and wins. `forgedock stats --no-local` skips the alerts.
598
646
 
599
647
  ## `verify` (OPTIONAL)
600
648
 
@@ -608,24 +656,46 @@ verify:
608
656
  skip_paths: "*.md,docs/**" # optional, comma-separated; default *.md,*.mdx,*.rst,*.txt,docs/**,devdocs/**
609
657
  max_rounds: 2 # optional CI fix rounds before needs-human (default 2)
610
658
  precheck_timeout_seconds: 300 # optional per-command timeout of the declared pre-PR checks (default 300)
659
+ precheck_total_seconds: 600 # optional total budget of one pre-PR check round (default 600)
611
660
  check_run: true # optional; post the forgedock/verify check run (default false)
661
+ checks: "lint,test" # optional; the check names that gate CI verification (default: the required checks)
662
+ post_merge: pnpm -r typecheck # optional; run on the merged tree at base-sync before the push (default: the lane's scoped typecheck)
663
+ duplicate_exports: false # optional; turn off the same-app duplicate-export check (default true)
664
+ e2e: auto # optional e2e merge gate: auto (default) | always | never
665
+ e2e_label: full-ci # PR label that makes the repo's CI run e2e (label path)
666
+ e2e_checks: "e2e" # check names of the e2e jobs the label path waits on
667
+ # e2e_workflow: e2e.yml # or: a workflow_dispatch workflow that runs e2e (wins over the label)
668
+ # e2e_paths_input: specs # its input that receives the changed spec paths
669
+ # e2e_paths: "e2e/**,tests/e2e/**" # globs of e2e specs (default below)
670
+ # e2e_blocking: changed # changed (default) | all
612
671
  ```
613
672
 
614
673
  | Key | Type | Required | Description |
615
674
  |-----|------|----------|-------------|
616
675
  | `mode` | string | No | `local` (default): today's behaviour; the builder runs every validate command on the host and no CI is started before review. `ci`: the builder runs only formatters and linters on the files it changed (no type-checks, test suites or test containers) and records validate ids it left to CI as `{"id","command","ci":true}`; the engine verifies on CI. `hybrid`: the builder runs the targeted validate commands too, and CI also verifies. |
617
- | `workflow` | string | No | Workflow file name or id dispatched on the lane branch (`gh workflow run <workflow> --ref <branch>`). The engine waits for the `workflow_dispatch` run of the lane's head commit. Without it, the engine waits on the head commit's push-triggered check runs. |
676
+ | `workflow` | string | No | Workflow file name or id dispatched on the lane branch (`gh workflow run <workflow> --ref <branch>`). The engine waits for the `workflow_dispatch` run of the lane's head commit. Without it, the engine waits on the head commit's check runs. Under `engine.merge_wait: required` only the base branch's required checks gate (other checks are reported, not gating), and each check name counts at its latest attempt (the newest check run, so a green rerun or a later green run of the same job wins). With no required checks configured, or none of them reported within 5 minutes, every check gates as before. |
618
677
  | `paths_input` | string | No | Name of a `workflow_dispatch` input that receives the changed paths, so the workflow can scope its jobs to the change. |
619
678
  | `skip_paths` | string | No | Comma-separated globs. A glob without `/` matches the basename anywhere; `**` spans directories. When every changed file matches, the lane skips CI verification (a docs-only lane runs no CI matrix). |
620
679
  | `max_rounds` | integer | No | Builder fix rounds for red CI (0–10, default 2). Also bounds the merge-time fix rounds and the declared-check fix rounds below, in every mode. |
621
680
  | `precheck_timeout_seconds` | integer | No | Timeout of each declared check command (1–3600, default 300); a timed-out command counts as failed (exit 124). |
681
+ | `precheck_total_seconds` | integer | No | Total budget of one round of pre-PR check commands (1–7200, default 600; it was 1800 before 1.30.0). `lean.precheck.max_minutes` takes precedence. Commands not started within the budget are deferred to CI, not failed. |
682
+ | `checks` | string | No | Comma-separated check names that gate CI verification without `workflow` (forge#4387). Overrides the required-check set; other checks are reported, not gating. |
622
683
  | `check_run` | boolean | No | Default `false` (no Checks API call, behaviour unchanged). `true`: after each push of a reviewed head (PR open, base-sync, merge-time fix round) the engine posts one completed `forgedock/verify` check run on that head (forge#4270), `success` iff every deterministic gate the lane already ran passed: declared check commands, undeclared package edges, docs freshness, contract scope, validate ids, requirement status, hotspot growth, amendments. The output carries each gate's id, pass/fail and evidence as JSON; the run log records a `VERIFY` event. Report only: ForgeDock never makes it required and never waits on it (it is ignored by its own CI waits); requiring it is the repository's branch-protection choice. Creating check runs needs a token that may (a GitHub App with `checks: write`); a Checks API failure is logged as an anomaly and never fails the lane. |
684
+ | `post_merge` | string | No | Command run in the lane worktree on the merged tree after each base-sync merge, before the push (forge#4406). Default: the typecheck rows of the lane's change-scoped checks (`bin/lean/checks.mjs`), in `local` and `hybrid` mode only; `ci` mode runs no host typecheck unless `post_merge` is set. A failure resumes the builder with the output, up to `max_rounds` rounds, and the fixed head is re-reviewed; still failing is a trajectory anomaly and CI stays the gate. |
685
+ | `duplicate_exports` | boolean | No | Default `true`: at hand-off and after each base-sync merge, names the lane newly exports that another non-test module of the same app or package (nearest `package.json`/`pyproject.toml`/`setup.py`/`go.mod`/`Cargo.toml`) already declares go back to the builder once ("rename or reuse", both paths); one left over is an anomaly (forge#4406). Re-exports, default exports and names 3+ other modules declare (framework conventions) are ignored. `false` turns the check off. |
686
+ | `e2e` | string | No | e2e merge gate (forge#4403). `auto` (default): run e2e when the diff touches `e2e_paths` or a contract requirement quote has `[type:e2e]`. `always`: on every lane except docs-only ones. `never`: today's behaviour. Without `e2e_label` or `e2e_workflow` no e2e can run, so every mode keeps today's behaviour (a lane that changed specs records a decision saying so). |
687
+ | `e2e_label` | string | No | Label added to the PR (`gh pr edit --add-label`) so tiered CI runs its e2e jobs. The label must already exist in the repo. |
688
+ | `e2e_checks` | string | No | Comma-separated check-run names of the e2e jobs the label path waits on. Default: every check on the head except `forgedock/verify`. A check run concluded `skipped` (the unlabelled tier) counts as not run. |
689
+ | `e2e_workflow` | string | No | Workflow file name or id dispatched on the lane branch instead of the label (`workflow_dispatch`); the engine waits for its run of the reviewed head. Wins over `e2e_label`. |
690
+ | `e2e_paths_input` | string | No | `workflow_dispatch` input of `e2e_workflow` that receives the changed spec paths (newline-separated). |
691
+ | `e2e_paths` | string | No | Comma-separated globs of e2e specs (same glob rules as `skip_paths`). Default `e2e/**,**/e2e/**,*.e2e.*,*.e2e-spec.*,cypress/**,playwright/**`. |
692
+ | `e2e_blocking` | string | No | `changed` (default): only failures of the lane's own specs block the merge; other e2e failures are reported as an anomaly. `all`: every e2e failure blocks. |
623
693
 
624
694
  **Flow** (`ci`/`hybrid`, `bin/lean/verify.mjs` `runCiVerify`):
625
695
 
626
696
  1. After the build and before every conformance review, the engine pushes the lane branch (a plain push, never forced) and runs CI on the head. A base-sync that moves the head is verified again.
627
697
  2. Green (or skipped by `skip_paths`): the review runs. The precheck accepts validate ids left to CI for that head, but a recorded non-zero exit still fails.
628
- 3. Red: the failed jobs' logs (`gh run view --log-failed`, tails capped at about 12 KB) go back to the builder session. The builder fixes and commits, and the engine pushes and verifies again. Still red after `max_rounds`, or a fix round with no new commit, ends as `needs-human` (`ci.red`) and nothing merges.
698
+ 3. Red: the failed jobs' logs (`gh run view --log-failed`, tails capped at about 12 KB) go back to the builder session. The builder fixes and commits, and the engine pushes and verifies again. Still red after `max_rounds` ends as `needs-human` (`ci.red`) and nothing merges. A fix round with no new commit (the builder's signal that the failure is not the change's) reruns the failed jobs once (`gh run rerun <id> --failed`) and verifies again: green continues to review, still red ends `ci.red`. Each rerun emits a `CI_FLAKE_RERUN` run-log event (`trigger`, `head`, `runs`, `jobs`, `result`).
629
699
  4. Slow CI: once the core CI wait budget (360 min, the same budget the merge wait uses) is used up, the lane ends `ci-pending` and can be resumed. Slow CI is never `needs-human`.
630
700
  5. CI unavailable (dispatch refused, or no run or check for the head within 5 minutes): one local fallback. The builder runs every validate command on the host and records real exit codes.
631
701
 
@@ -634,8 +704,16 @@ Each verification emits a `CI_VERIFY` run-log/telemetry event (`mode`, `round`,
634
704
  **Every mode, including the default `local` (forge#4240):**
635
705
 
636
706
  - **Declared check commands before the PR.** Each `learned.test_commands` entry (a block list under `learned:`) runs with `bash -c` in the lane worktree after the build commit, before review and before the PR, with the per-command timeout above. These run on the host and use no Actions minutes. If they all pass, there is no extra builder turn. A failure resumes the builder in its session with the command, its exit code and the output tail, then the commands run again, up to `max_rounds`. A round with no new commit, or exhausted rounds, is recorded as an anomaly and the lane continues, because CI stays the gate. The engine has no built-in command list: to run `scripts/ci-local.sh`, list it here. Each round emits a `PRECHECK_COMMANDS` run-log event (`round`, `results: [{command, rc}]`).
707
+ - **The same checks in the builder's session (#4364).** The builder runs `forgedock check` in the lane worktree before it writes its result: the contract's validate commands plus the same change-scoped pre-PR commands, budget and worker cap, locally (no GitHub, no Actions minutes). The engine hands it the settings above in `.forge-lean/check-plan.json`; outside a lane, `forgedock check --base REF` reads this file's `learned.test_commands`, `lean.precheck.max_minutes` and `verify:`. A builder result of `fixed` without a passing run on its final tree logs a `SELF_CHECK_MISSING` run-log event (`state`: `skipped`, `stale` or `red`), and a failed pre-PR round's `PRECHECK_COMMANDS` event carries `self_check`. Neither fails the lane: the engine's own checks and CI stay authoritative.
637
708
  - **Fix rounds at merge.** When a required check fails on the PR (`engine.merge_wait: required`), the failed jobs' logs (`gh run view --log-failed`, capped) go back to the builder session. The fixed head is re-reviewed and pushed, then the wait starts again. Still red after `max_rounds` (one budget shared with the `ci`/`hybrid` rounds above), or a round with no new commit, ends `needs-human` (`ci.red`). `max_rounds: 0` ends `ci.red` on the first failure. Each round emits a `CI_FIX_ROUND` run-log event (`trigger`, `round`, `jobs`). `merge_wait: all` is unchanged.
638
709
 
710
+ **e2e merge gate (forge#4403, every verify mode).** Tiered CI usually skips e2e on lane PRs, so required checks alone let a lane merge with its own e2e specs failing. When the gate runs (see `e2e` above), after the merge wait and before `gh pr merge`, once per reviewed head, the engine labels the PR with `e2e_label` and waits on the head's `e2e_checks` (or dispatches `e2e_workflow` and waits on its run), with the same CI wait budget.
711
+ - Green: the PR merges.
712
+ - Red: a failure is the lane's when its failed-job log names one of the spec files the lane changed (path or basename), when the log is empty, or when the lane changed no spec (a `[type:e2e]` contract). The lane's failures (all failures with `e2e_blocking: all`) go back to the builder like a failed required check: one fix round from the shared `max_rounds` budget, re-review, push, then the wait and the e2e run start again; `ci.red` when the rounds are spent. Other failures are recorded as an anomaly and the PR merges.
713
+ - Still pending at the budget: `ci-pending` (resumable). Label refused, dispatch refused, or no e2e run within 5 minutes: `needs-human`, nothing merged.
714
+
715
+ Each run emits an `E2E_VERIFY` run-log/telemetry event (`pr`, `head`, `reason`, `result`, `specs`, `failures`, `own`, `blocking`). It spends Actions minutes only after review, on lanes that touch e2e.
716
+
639
717
  **Cost:** `ci`/`hybrid` spend GitHub Actions minutes before review, so they are opt-in per repo. On the green path they add no LLM calls; each red CI round adds one builder resume. The merge gate (`engine.merge_wait`) is unchanged.
640
718
 
641
719
  **Commands that use this section**: core work-on (`forgedock run-issue`, `forgedock orchestrate`; `bin/engine-cli.mjs` `readVerify`)
@@ -821,6 +899,8 @@ decomposition:
821
899
 
822
900
  **With `close_parent: false`** (default): unchanged. The parent stays open and is closed by C3 when the last child closes.
823
901
 
902
+ The lean (core) path reads the same key (#4337): with `close_parent: true` it closes the parent at decomposition as `not planned`, keeps `workflow:decomposed` and lists the sub-issues in the closing comment; dependents still wait on the sub-issues, and a trusted `FORGE:DECOMPOSED:REVOKED` reopens it. With the default `false` the lean parent stays open.
903
+
824
904
  **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.
825
905
 
826
906
  **Commands that use this section**: `work-on` (`decompose` D6.5, `close` C3), `orchestrate`
@@ -999,6 +1079,8 @@ verification:
999
1079
  | `services[].container` | string | No | Docker container name for `docker exec` verification |
1000
1080
  | `services[].health_url` | string | No | Local URL for container-level health checks |
1001
1081
  | `ci_local` | object | No | Local CI gate settings (see below) |
1082
+ | `commands.<lang>.<step>` | string | No | Toolchain commands (`format`, `lint`, `typecheck`, `test`, `build`) per language key (`python`, `typescript`, ...); see `forge.yaml.example`. An absent key logs `SKIPPED — not configured` |
1083
+ | `commands.typescript.lint` | string | No | TypeScript lint. Validate runs it like `typescript.typecheck` (bounded by `FORGEDOCK_VERIFICATION_TIMEOUT_SECONDS`, default 120 s; a failure blocks). The core engine reads it from the trusted `forge.yaml` (main checkout or base ref, never the PR's tree), and `bin/lean/checks.mjs` prints it in place of the discovered lint. Without it, a turbo lint is scoped to the changed packages (`--filter=[base]`), not their dependents. Prefer a changed-files, cached lint |
1002
1084
  | `consumer_sweep.mode` | string | No | `advisory` (default): the validate consumer sweep (`scripts/consumer-sweep.sh`, V4.6) records unaddressed referencing sites and never fails. `blocking`: unaddressed sites fail validate with `consumer_unaddressed`, build runs one repair round, and sites still unaddressed are recorded as `CONSUMERS: unresolved` and passed to review as known risk (never `needs-human`). Flip to `blocking` only after `bash scripts/consumer-sweep.sh --eval-history 20` shows a mean of at most 2 flagged sites per merge and none above 10 on your repo |
1003
1085
  | `consumer_sweep.budget_seconds` | integer | No | Wall-clock budget for the sweep (default `60`). When it runs out the sweep stops with `BUDGET_EXCEEDED`, reports the partial result, and never fails, in either mode |
1004
1086
 
@@ -47,6 +47,8 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
47
47
  | `cli.timeout` | CLI child ran and was killed at the wall-clock limit; a normal attempt (consumes one of `maxAttempts`), usage recorded when the CLI emitted any | engine | `err.code` `CLI_TIMEOUT` |
48
48
  | `cli.spawn_failed` | The CLI could not be started for a reason that repeats on every rerun (`E2BIG` argv too large, `ENOENT`/`EACCES`/`ENOEXEC` binary missing or not executable, `ENAMETOOLONG`). Core work-on ends `needs-human` with this code instead of `engine-error`, so `forgedock orchestrate` does not spend its relaunch on it | lean, engine | `err.code` `CLI_SPAWN_FAILED` (E2BIG) or `err.spawnDeterministic` (other errnos keep their `err.code`); `err.spawnErrno` holds the errno |
49
49
  | `env.setup_failed` | forge.yaml `env.setup` (run once per fresh lane worktree, before the builder) exited non-zero, timed out or could not start. An environment error, not a build failure: the builder never ran. Core work-on ends `needs-human` with this code instead of `engine-error`, so `forgedock orchestrate` does not relaunch it; fix the setup command or the environment and rerun | lean | `io.runSetup` exit code / spawn error |
50
+ | `env.tool_missing` | Core work-on (forge#4323): a validate command the builder recorded as failed was rerun by the engine and could not run because the host lacks a tool or module (`command not found` / exit 127, Python `No module named x`, Node `Cannot find module|package 'x'` for a bare name the repo does not track). An environment error, not a conformance failure: no builder resume, no reviewer. Ends `needs-human` naming the tool; recoverable by `forgedock orchestrate` (the recovery lane runs the check through the repo's own toolchain). Skipped on a CI-verified head | lean | `gate()` rerun via `io.runCommand`, `{rc, output}` classified by `conformance.mjs` `missingTool` |
51
+ | `env.repro_unrunnable` | Core work-on (forge#4334): on a bug lane the frozen `repro_test` could not be run on head by `scripts/acceptance-red-green.sh` (`HEAD=UNRUNNABLE`: timeout, exit 126/127, unclassifiable output) or the harness returned no result. An environment error, not a conformance failure: no builder resume, no reviewer. Ends `needs-human`; recoverable by `forgedock orchestrate` (the recovery lane reruns the repro command through the repo's own toolchain and installed dependencies). Other non-proofs (passes on base, fails on head, base unrunnable) stay `conformance.unimplemented` | lean | `gate()` `io.redGreen` words, classified by `conformance.mjs` `reproFailures` |
50
52
  | `sandbox.start_failed` | A `forgedock orchestrate` lane sandbox (forge.yaml `execution`, provider `container` or `remote`) could not start: bad image, missing endpoint or credential, refused launch. The lane never ran. Final `needs-human` row: not relaunched and not counted by the engine-error circuit breaker | orchestrate | `bin/lean/execution.mjs` `providerLanes`: `provider.start` threw without `retryable` |
51
53
  | `sandbox.capacity` | The sandbox provider had no capacity for the lane (HTTP 429/503 from the remote transport). `deferred` with backoff (`Retry-After` when sent), bounded by `orchestration.max_deferrals` | orchestrate | `provider.start` threw with `err.retryable: true` |
52
54
  | `lease.held` | Another live agent holds the issue lease | engine | Foreign live lease on acquire |
@@ -58,18 +60,19 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
58
60
  | `lane.branch_missing` | Core work-on: the lane branch (e.g. `milestone/<slug>`) does not exist on origin; fail closed | lean | `git ls-remote --heads origin` |
59
61
  | `lane.unresolved` | Core work-on: the lane could not be worked out (milestone or default branch unreadable, empty milestone slug, bad `feature_pattern`); fail closed | lean | Failed `gh` read or config |
60
62
  | `invalid` | Issue is invalid or in a divergent closed state | investigate, engine | `INVESTIGATION:INVALID` marker, or live snapshot |
61
- | `decomposed` | Issue was decomposed into sub-issues. In core work-on, the builder reported `block.kind: "split-required"`; the engine files the sub-issues (`**Parent**` and `**Depends on**` lines) and returns them as `subissues` for `forgedock orchestrate` to run | investigate, decompose, lean | `FORGE:DECOMPOSED` or the decomposition assessment; `result.json` `block.subissues` |
63
+ | `decomposed` | Issue was decomposed into sub-issues. In core work-on, the builder reported `block.kind: "split-required"`; the engine files the sub-issues (`**Parent**` and `**Depends on**` lines) and returns them as `subissues` for `forgedock orchestrate` to run. With `decomposition.close_parent: true` the parent is then closed `not planned` (label kept) and its dependents wait on the sub-issues | investigate, decompose, lean | `FORGE:DECOMPOSED` or the decomposition assessment; `result.json` `block.subissues` |
62
64
  | `builder.blocked` | Core work-on: the builder reported an external action agents cannot take, or a product decision too critical for agents: `block.kind: "human"` with `why` one of `credentials`, `spend`, `promotion`, `customer`, `product-decision`. An upstream issue, a needed split, or prod data that monitoring access can read never count | lean | `result.json` status `blocked` + typed `block` |
63
65
  | `builder.block_untyped` | Core work-on: the builder stayed `blocked` without a valid typed reason (missing `block`, a non-human `why` such as prod data, or a dependency that is not open) after one corrective resume | lean | `result.json` status `blocked`, twice |
64
66
  | `builder.no_evidence` | Core work-on: the builder reported invalid/already-fixed without file:line evidence, so the issue was not closed | lean | `result.json` with empty `evidence` |
65
67
  | `builder.no_commit` | Core work-on: the builder made no commit for a fix | lean | No changed files after the builder session |
68
+ | `budget.exceeded` | Core work-on (forge#4392): the implement turn budget for the lane's size (forge.yaml `engine.turn_budget_s|m|l`, defaults 25/60/100) was spent and the one bounded wrap-up session did not finish (it wrote `status: budget-exceeded` or overran its grant). Work is checkpointed on the lane branch; the detail carries the builder's digest (else the diff stat). Ends `needs-human`; recoverable once by `forgedock orchestrate` (the recovery lane continues from the checkpoint or ends with a typed split-required block) | lean | Stream turn count (`CLI_TURNS` from `runCliBackendAsync` `turnMax`), wrap-up result.json status |
66
69
  | `review.failed` | Core work-on: the fix/regression review failed twice (or could not resume the builder) | lean | Reviewer verdict FAIL |
67
70
  | `review.no_verdict` | Core work-on: the reviewer returned no parseable verdict twice | lean | Unparsable reviewer output |
68
71
  | `contract.invalid` | Core work-on: the builder's contract.json stayed invalid after one correction (missing requirement rows, a quote not verbatim from the issue, a defer without a why, no validate commands) | lean | `validateContract` problems after the correction resume |
69
72
  | `review.incomplete` | Core work-on: the conformance review twice returned no complete rows (a generic PASS, a missing requirement/scope/preserve row, evidence in a file that does not exist); nothing merged | lean | `checkCompleteness` problems on both reviewer runs |
70
73
  | `review.inconsistent` | Core work-on: the reviewer twice said NONCONFORMING without any failing row; the reviewer is re-asked, the builder's resume is not spent | lean | Verdict/row mismatch on both reviewer runs |
71
74
  | `conformance.omission` | Core work-on: an issue requirement is missing from or narrowed by the frozen contract, after one builder resume | lean | Review `issue_coverage` row not `covered` |
72
- | `conformance.unimplemented` | Core work-on: a contracted requirement is partial, missing or unvalidated, or a validate command failed, after one builder resume | lean | Precheck (result.json, validation) or review requirement row |
75
+ | `conformance.unimplemented` | Core work-on: a contracted requirement is partial, missing or unvalidated, or a validate command failed, after one builder resume (a validate the host cannot run for a missing tool or module is `env.tool_missing`, and a repro test the harness cannot run on head is `env.repro_unrunnable`, instead) | lean | Precheck (result.json, validation) or review requirement row |
73
76
  | `conformance.scope_creep` | Core work-on: a changed file is not authorized by a deliverable or typed amendment, after one builder resume | lean | Precheck changed files or review `scope` row `none` |
74
77
  | `conformance.untracked_deferral` | Core work-on: a requirement was deferred without a typed amendment or a tracking issue, after one builder resume | lean | Precheck or review requirement row `deferred` on a `deliver` requirement |
75
78
  | `conformance.unjustified_deferral` | Core work-on: the reviewer judged a deferred or post-deploy requirement unjustified (it belongs in this change), after one builder resume; nothing is filed or merged | lean | Review requirement row `deferral_ok: false` |
@@ -93,11 +96,11 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
93
96
  `forgedock orchestrate` recovers mechanical outcomes inside the batch:
94
97
 
95
98
  - **recoverable**:
96
- - needs-human with `ci.red`, `base.conflict` or `builder.block_untyped`;
99
+ - needs-human with `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable` or `budget.exceeded`;
97
100
  - `contract.invalid` when the issue was edited mid-run (`issueEdited`);
98
101
  - `ci-pending` on a conflicting PR, recovered as `base.conflict`.
99
102
 
100
- The runner relaunches the issue once per `orchestration.recovery.max_attempts` (default 1) as a recovery lane (`run-issue --recover <code>`), under the normal admission rules. That lane removes the `needs-human` label, posts `FORGE:DECISION`, and resumes the open PR: base-sync, then CI fix rounds from the failed-check logs. Dependents stay queued. A merged recovery is reported as `recovered`. A failed one is final, keeps the recovery evidence on the row, and skips its dependents. A failed recovery stays `needs-human`: the lane re-adds the label (with a `FORGE:NEEDS_HUMAN` comment carrying the evidence) only for a final `needs-human` or `ci-pending`, and only when it is the last attempt (`attempt == max_attempts`); a non-final attempt that ends `needs-human` or `ci-pending` without another attempt following is re-labelled once by the batch runner; the batch runner re-adds it, once, for a final `engine-error` or `deferred` after the recovery pass (those may be requeued, so the lane cannot know). `invalid`, `decomposed`, `dependency-*` and `merged` never re-label.
103
+ The runner relaunches the issue once per `orchestration.recovery.max_attempts` (default 1) as a recovery lane (`run-issue --recover <code>`), under the normal admission rules. That lane removes the `needs-human` label, posts `FORGE:DECISION`, and resumes the open PR: base-sync, then CI fix rounds from the failed-check logs. Dependents stay queued. A merged recovery is reported as `recovered`. A failed one is final, keeps the recovery evidence on the row, and skips its dependents. A failed recovery stays `needs-human`: the lane re-adds the label (with a `FORGE:NEEDS_HUMAN` comment carrying the evidence) only for a final `needs-human` or `ci-pending`, and only when it is the last attempt (`attempt == max_attempts`); a non-final attempt that ends `needs-human` or `ci-pending` without another attempt following is re-labelled once by the batch runner; the batch runner re-adds it, once, for a final `engine-error` or `deferred` after the recovery pass (those may be requeued, so the lane cannot know). `invalid`, `decomposed`, `dependency-*` and `merged` never re-label. In-batch recovery is off when `engine.path: full`: the runner passes no recovery to the batch, and `run-issue --path full` rejects `--recover`.
101
104
  - **human**: `builder.blocked`, which is only reached by a typed human block: credentials, spend, promotion, customer or product-decision. It is never recovered, it skips dependents, and it is the only class in the report's escalation list.
102
105
  - **terminal**: every other code. No recovery runs, dependents are skipped, and the row is reported as `unrecovered`.
103
106
 
@@ -2,6 +2,16 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## v1.30.0: environment failures are recoverable, scoped pre-PR checks, decomposed parents can close
6
+
7
+ - **New recoverable codes.** A validate command the host cannot run (missing interpreter, module or executable) ends `env.tool_missing`, and a repro test the harness could not run on head ends `env.repro_unrunnable`. Both were terminal `conformance.unimplemented` before. The batch runner recovers them like `ci.red` (see docs/OUTCOME-CODES.md).
8
+ - **Contract freeze is stricter.** Validate and repro commands that read `$TMPDIR` files they do not create are rejected at freeze, and validate commands are preflighted for their interpreters and modules (a miss asks the builder for one contract correction). Engine-run commands now get the builder's lane env (`TMPDIR`, toolchain), and the repro harness uses the workspace dependency trees and runs a new head test file against the base.
9
+ - **Pre-PR checks.** Learned check commands run change-scoped (when a test map covers them), concurrently and timed, within a round budget: `lean.precheck.max_minutes`, else `verify.precheck_total_seconds`, whose default drops from 1800 s to **600 s**. Commands left when the budget runs out are `deferred-to-ci`, not failed. Set `FORGE_HOST_PARALLELISM=1` if your check commands share state (cache files, a test database, a fixed port).
10
+ - **Decomposed parents.** The lean path honours `decomposition.close_parent`: with `true` a decomposed parent closes `not planned` right after decomposition (label kept, sub-issues listed); its dependents still wait on the sub-issues, and a trusted revoke reopens it. Default `false` keeps it open.
11
+ - **Hand-off.** Enumerating tests (exhaustiveness guards) are discovered for the change, and sequence-numbered migrations that collide with the base are renumbered by the builder.
12
+
13
+ Action: set `decomposition.close_parent: true` to close decomposed parents. Raise `lean.precheck.max_minutes` if your pre-PR checks legitimately need more than 10 minutes.
14
+
5
15
  ## v1.29.0: in-batch recovery and context pack on by default
6
16
 
7
17
  - **In-batch recovery** (`orchestration.recovery.max_attempts`, default 1): `forgedock orchestrate` now launches one recovery lane per issue for a mechanical `needs-human` (`ci.red`, `base.conflict`, `ci-pending` on a conflicting PR, `contract.invalid` after a mid-run issue edit, `builder.block_untyped`). The lane removes `needs-human`, resumes the PR and re-queues the issue. A recovery that ends anything but `merged` re-applies `needs-human` with a recovery-evidence `FORGE:NEEDS_HUMAN` comment.
package/docs/STATS.md CHANGED
@@ -34,12 +34,64 @@ printed to stderr while crawling, so `--json` stdout stays parseable.
34
34
 
35
35
  With no local state at all, `forgedock stats --repo <r>` reproduces the report from GitHub alone.
36
36
 
37
+ ## Efficiency (`--efficiency`, forge#4402)
38
+
39
+ ```bash
40
+ npx forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--repo <owner/repo>] [--json]
41
+ ```
42
+
43
+ Per batch (`--batch`, a dir under `~/.forge/batches`, `FORGEDOCK_BATCH_DIR`) and over trailing windows (`--window`,
44
+ default `7d,30d`: the union of the repo's batches whose `plan.created` falls in the window, where an issue in several
45
+ batches counts once with its latest row). The orchestrate batch report prints the same lines for the batch it just ran.
46
+ The code is in `bin/stats/efficiency.mjs`.
47
+
48
+ - **Cohort and cost to merge.** A merged issue counts every `RUN_START..RUN_TERMINAL` slice of its run log
49
+ (`~/.forge/runs/<issue>.jsonl`) that started at or before its settle time (its latest batch `log.jsonl` row) and
50
+ belongs to the repo. Earlier failed lanes from other batches or ad-hoc runs count too. Attempt cost is
51
+ `cost_usd_delta`, else `cost_usd`.
52
+ - **$/merge, turns/merge, context/turn**: all-phase cost ÷ merged, with implement and review totals; implement turns ÷
53
+ merged; implement cache-read ÷ implement turns. These are also split by size (the merging run's `turns.size`, else a
54
+ `size:` label, else `unknown`) and by model (the implement attempt's `model`).
55
+ - **Retries/merge by cause.** For builder attempts, the trigger decides the cause:
56
+
57
+ | Cause | Triggers |
58
+ | --- | --- |
59
+ | env | `ci_unavailable` |
60
+ | engine | `timeout_restart`, `context_restart`, `budget_wrapup`, `recovery` |
61
+ | code | `precheck`, `structure`, `docs`, `enumerating`, `sequence`, `ci_red`, `conformance`, `merge_ci`, `block_correction`, `contract_correction` |
62
+ | review | `review` |
63
+
64
+ `plan`, `build` and `verifier` are not retries. A lane rerun (every run slice of a merged issue but the last) counts
65
+ by the code that ended it: `env.*` is env; `engine.*`, `run.*`, `followup.*`, `base.*`, `dependency.*` are engine;
66
+ `ci.*`, `conformance.*`, `precheck.*`, `budget.*`, `builder.*`, `contract.*` are code; `review.*` is review; anything
67
+ else is unknown.
68
+ - **No action**: rows parked by the pre-filter (`awaiting-promotion`, `awaiting-precondition`) and lanes that ended
69
+ `builder.blocked` with `Builder is blocked (promotion)`, with their count and cost.
70
+ - **Issue quality vs. cost**: each merged issue's body is tagged `full` (a root-cause heading, a `file:line` and a
71
+ fix/design/approach/proposal heading), `partial` (some of these) or `none`, and n, $/merge and turns/merge are
72
+ compared per tag.
73
+ - **Acceptance (7d after promotion)**: from trusted `FORGE:PROBE` comments (#4399). An issue whose first record is
74
+ under 7 days old is pending. After that it passes when every record within 7 days of the first one passed. The
75
+ first record is the observed promotion, because a probe runs only once the fix is on the default branch.
76
+
77
+ Limits: logs written before #4362 carry no trigger, so implement attempts beyond the plan and build calls count as
78
+ `unknown`. Logs written before #4407 sum running session totals. Lanes run outside a batch are in no window. `gh` (for
79
+ merged issues only) is the one network call; when it fails, quality and acceptance print `n/a` with the reason.
80
+
37
81
  ## Sections (per version and repo)
38
82
 
39
83
  - **Cost**: cache-read tokens per issue (mean, median), per phase (mean over all runs, so phases add up to the per-issue
40
84
  mean), per merged issue, output tokens, USD where recorded.
41
85
  - **Time**: p50 and p90 duration per phase and wall time per issue.
42
- - **Flow**: merged %, needs-human %, remediation rate, outcome-code distribution (`docs/OUTCOME-CODES.md`).
86
+ - **Flow**: merged %, needs-human %, remediation rate, outcome-code distribution (`docs/OUTCOME-CODES.md`), and the
87
+ **first-pass rate** (#4362): the share of lanes that reached the build (a `build` attempt, or the `timeout_restart` of
88
+ one) with no rework attempt after it, with the count (`k/n lanes`) and how many lanes are `unknown` (their attempts
89
+ carry no `trigger`: logs written before the field). Rework triggers: `precheck`, `structure`, `docs`, `enumerating`,
90
+ `sequence`, `ci_red`, `conformance`, `review`, `merge_ci` (`bin/lean/fixround.mjs` `REWORK_TRIGGERS`); a timeout
91
+ restart, a base-sync `recovery`, a `ci_unavailable` fallback or a `block_correction` does not break first pass.
92
+ - **Rework by trigger** (#4362): count and USD of the builder (`implement`) attempts with a rework trigger, per trigger,
93
+ plus a total. Attempts without a `trigger` are counted as `unknown`, never dropped. JSON also carries every builder
94
+ attempt by trigger (`rework.attempts`).
43
95
  - **Review funnel**: findings per review by severity, then ISSUE / NOTE / in-PR fix, plus `validated` and `false-positive` issues.
44
96
  - **Gates**: block rate per gate (acceptance, diff size OVER, CI, liveness), malformed-check rate, COHESIVE and OVER counts.
45
97
  - **Issue flow**: issues created by source (review-finding, pipeline bot, human), closed by kind, net flow, findings per merged issue.
package/docs/TELEMETRY.md CHANGED
@@ -40,7 +40,7 @@ is dropped. Payload keys are `[A-Za-z0-9_]+` and cannot shadow the shared keys;
40
40
  | `ci_local` | `scripts/ci-local.sh run` (validate V4.8), one per run | `result` = `PASS` \| `FAIL` \| `DEGRADED`, `jobs_run`, `jobs_deferred`, `jobs_failed`, `duration_ms` (execution), `wait_ms` (time queued for a machine slot), `fix_iterations` (0 first run, 1 and 2 after fixes), `checks`, `cache_hits`, `narrowed` (checks run on a subset or skipped for no impact), `widened` (checks widened to the full command), `selected` / `total` (summed scope) |
41
41
  | `ci_local_check` | `scripts/ci-local.sh run`, one per scoped, widened or executed check (at most 20 per run) | `job`, `selector`, `selected`, `total`, `cache` = `hit` \| `miss` \| `dedup` \| `na`, `duration_ms`, `wait_ms` (job slot wait), `widen` (reason or `none`). A remote-CI failure at merge on a check recorded here as narrowed is a selector bug |
42
42
  | `liveness` | `scripts/remediate-liveness.sh` | `decision` = `PROCEED` \| `DEFER` \| `EXHAUSTED`, `why` |
43
- | `phase_attempt` | the engine (`bin/engine/telemetry-seam.mjs`, one per phase attempt; `phase` = the phase id) | `attempt`, `model`, `backend` (`cli`/`api`), `turns`, `duration_ms`, `cost_usd`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` (any value the runner did not report is omitted) |
43
+ | `phase_attempt` | the engine (`bin/engine/telemetry-seam.mjs`, one per phase attempt; `phase` = the phase id) | `attempt`, `trigger` (#4362: why the attempt ran, one of `plan`, `build`, `block_correction`, `contract_correction`, `precheck`, `structure`, `docs`, `enumerating`, `sequence`, `ci_unavailable`, `ci_red`, `conformance`, `review`, `merge_ci`, `timeout_restart`, `recovery`; `restart_of` = the trigger a `timeout_restart` replaced), `model`, `backend` (`cli`/`api`), `turns`, `duration_ms`, `cost_usd` (the raw reported value: a running total when the attempt resumed a CLI session), `cost_usd_delta` (#4407: this attempt's cost; consumers sum it when present, else `cost_usd`), `cost_usd_session` (the CLI session's running total), `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` (any value the runner did not report is omitted) |
44
44
  | `run_terminal` | the engine (`terminate()`, one per run segment) | `reason` (terminal reason), `code` (see `docs/OUTCOME-CODES.md`), `wall_ms` (this engine process, start to terminal) |
45
45
 
46
46
  The engine records carry `run` = `r_<issue>_<start epoch seconds>` (one per `forgedock run-issue` process) so `forgedock stats`
@@ -167,7 +167,7 @@ Append this block at the very end of your comment (after the `---` footer line,
167
167
  8. **HTML comments**: The block is invisible in rendered markdown but parseable by the review system
168
168
  9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the marker domain the orchestrator passes you (the canonical value from the roster-to-marker table in `review-pr.md`, e.g. `infra` and `scraper`; never a lowercased display name such as `infrastructure` or `scraping`).
169
169
  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.
170
- 11. **Failure scenario and scope (provenance inputs)**: every finding's body section (the prose above the findings block) MUST carry a `**Failure scenario**` line — concrete inputs leading to a wrong output or behaviour — and a `**Scope**: changed` or `**Scope**: pre-existing` line (`changed` = the cited line is added or modified by this PR; `pre-existing` = it was already there). `/review-pr` §6B.4 reads these: a finding without a failure scenario is demoted to a note (unless CRITICAL/HIGH, any confidence), and a MEDIUM or lower finding whose cited line is not in the PR diff is routed as pre-existing (CRITICAL/HIGH never are; see §5). The `**Scope**` tag is informational and never gates routing. Speculative hardening with no failure scenario is a note, not a finding. The one-line `<!-- FINDING:... -->` grammar above is unchanged. <!-- Added: forge#3452 -->
170
+ 11. **Failure scenario and scope (provenance inputs)**: every finding's body section (the prose above the findings block) MUST carry a `**Failure scenario**` line — concrete inputs leading to a wrong output or behaviour — and a `**Scope**: changed` or `**Scope**: pre-existing` line (`changed` = the cited line is added or modified by this PR; `pre-existing` = it was already there). `/review-pr` §6B.4 reads these: a finding without a failure scenario is demoted to a note (unless CRITICAL/HIGH, any confidence, or CONFIRMED MEDIUM; a plain `Failure scenario:`, `Scenario:`, `Repro:` or `Steps to reproduce:` line also counts as the scenario), and a MEDIUM or lower finding whose cited line is not in the PR diff is routed as pre-existing (CRITICAL/HIGH never are; see §5). The `**Scope**` tag is informational and never gates routing. Speculative hardening with no failure scenario is a note, not a finding. The one-line `<!-- FINDING:... -->` grammar above is unchanged. <!-- Added: forge#3452 -->
171
171
 
172
172
  ### Domain Prefixes
173
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.29.1",
3
+ "version": "1.31.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"
@@ -6,13 +6,14 @@
6
6
  # Usage: acceptance-red-green.sh --repo-path <worktree> --base-ref <ref> --test-cmd "<cmd>" [--timeout <sec>]
7
7
  # Runs <cmd> in <worktree> (head) first. Only when head RAN does it create a detached base worktree at
8
8
  # `git merge-base HEAD <base-ref>` (under mktemp -d, never inside the repo), overlay head's changed test files
9
- # onto it, and run <cmd> there. Each run is classified with classify-test-run.sh.
9
+ # onto it, link head's untracked dependency trees (every node_modules next to a tracked package.json, a root .venv),
10
+ # and run <cmd> there. Each run is classified with classify-test-run.sh.
10
11
  # Prints exactly one line on stdout: BASE=<FAILED|RAN|RAN_NOTHING|UNRUNNABLE|SKIPPED> HEAD=<FAILED|RAN|RAN_NOTHING|UNRUNNABLE>
11
12
  # FAILED the run executed and failed
12
13
  # RAN the run executed and passed
13
14
  # RAN_NOTHING the runner exercised nothing
14
15
  # UNRUNNABLE could not execute (timeout 124, not executable 126/127, worktree/overlay/classifier failure)
15
- # SKIPPED base was not run because head did not PASS
16
+ # SKIPPED base was not run because head did not PASS (never a missing file: head's new test files are overlaid)
16
17
  # Never prints a verdict on success of cleanup: the base worktree is removed on every exit path (trap + explicit remove).
17
18
  # Callers map: HEAD=RAN BASE=FAILED -> proof; BASE=RAN -> passes on base (gate FAIL); anything else that is not a proof -> DEGRADED/FAIL.
18
19
  # Exit: 0 line printed | 2 usage error (stdout empty so callers fail closed)
@@ -81,8 +82,16 @@ while IFS= read -r f; do
81
82
  [ -f "$REPO/$f" ] && [ ! -L "$REPO/$f" ] || continue # deleted at head, or a symlink: never copied
82
83
  mkdir -p "$BASE_WT/$(dirname "$f")" && cp -p "$REPO/$f" "$BASE_WT/$f" 2>/dev/null && [ -f "$BASE_WT/$f" ] || OVERLAY_OK=false
83
84
  done <<< "$CHANGED"
84
- # Untracked dependency tree (never in git): link it so a missing install is not mistaken for a red run.
85
- [ -d "$REPO/node_modules" ] && [ ! -e "$BASE_WT/node_modules" ] && ln -s "$REPO/node_modules" "$BASE_WT/node_modules" 2>/dev/null
85
+ # Untracked dependency trees (never in git): link head's so a missing install is not mistaken for a red run (#4334):
86
+ # every node_modules next to a tracked package.json (workspace packages too, e.g. apps/web/node_modules) and a root .venv.
87
+ DEPS=$( { git -C "$REPO" ls-files -- package.json '*/package.json' 2>/dev/null | while IFS= read -r p; do
88
+ d=$(dirname "$p"); [ "$d" = "." ] && echo node_modules || echo "$d/node_modules"; done; echo .venv; } | sort -u )
89
+ while IFS= read -r t; do
90
+ [ -n "$t" ] || continue
91
+ case "$t" in */node_modules/*) continue ;; esac # a package.json inside an installed tree
92
+ [ -d "$REPO/$t" ] && [ ! -e "$BASE_WT/$t" ] && [ ! -L "$BASE_WT/$t" ] || continue
93
+ mkdir -p "$BASE_WT/$(dirname "$t")" 2>/dev/null && ln -s "$REPO/$t" "$BASE_WT/$t" 2>/dev/null
94
+ done <<< "$DEPS"
86
95
 
87
96
  if [ "$OVERLAY_OK" != "true" ]; then BASE_RES=UNRUNNABLE; else BASE_RES=$(run_in "$BASE_WT"); fi
88
97
  cleanup # explicit; the EXIT trap is the backstop
@@ -70,5 +70,19 @@ fi
70
70
  out=$(bash "$RG" --repo-path "$R1" 2>/dev/null); rc=$?
71
71
  { [ "$rc" -eq 2 ] && [ -z "$out" ]; } && ok "missing args exit 2, empty stdout" || bad "usage: rc=$rc out='$out'"
72
72
 
73
+ # 11. workspace monorepo (#4334): a new test file in a package needs that package's untracked node_modules and the root
74
+ # .venv; base gets head's trees linked, so it runs and fails on the defect (not 127 on a missing install)
75
+ R5="$TMP/r5"; make_repo "$R5"
76
+ mkdir -p "$R5/apps/web"; printf '{}\n' > "$R5/package.json"; printf '{"name":"web"}\n' > "$R5/apps/web/package.json"
77
+ printf 'node_modules/\n.venv/\n' > "$R5/.gitignore"; git -C "$R5" add -A; git -C "$R5" commit -qm pkgs; git -C "$R5" update-ref refs/remotes/origin/main HEAD
78
+ fix_head "$R5"
79
+ mkdir -p "$R5/apps/web/node_modules/dep" "$R5/.venv/bin"
80
+ printf 'echo dep\n' > "$R5/apps/web/node_modules/dep/run.sh"; printf 'echo venv\n' > "$R5/.venv/bin/tool"
81
+ mkdir -p "$R5/apps/web/__tests__"
82
+ printf 'bash node_modules/dep/run.sh >/dev/null 2>&1 || exit 127\nbash ../../.venv/bin/tool >/dev/null 2>&1 || exit 127\ntest "$(bash ../../app.sh)" = good && echo "1 passed"\n' > "$R5/apps/web/__tests__/t.test.sh"
83
+ git -C "$R5" add -A; git -C "$R5" commit -qm test
84
+ check "workspace node_modules and .venv linked into base; new nested test overlaid" "BASE=FAILED HEAD=RAN" "$R5" "cd apps/web && bash __tests__/t.test.sh"
85
+ [ -d "$R5/apps/web/node_modules/dep" ] && [ -d "$R5/.venv/bin" ] && ok "head dependency trees survive base cleanup" || bad "head dependency trees removed by cleanup"
86
+
73
87
  echo "$PASS passed, $FAILN failed"
74
88
  [ "$FAILN" -eq 0 ]
@@ -93,6 +93,26 @@ export function readAtRef(repo, ref, file) {
93
93
  return r.code === 0 ? r.out : null;
94
94
  }
95
95
 
96
+ /**
97
+ * forge#4040 (SEC-2): forge.yaml from a TRUSTED copy only, never from the PR's own tree: {file, text} ('' when none).
98
+ * In order: the main checkout's forge.yaml (a worktree's git common dir points at it); for a plain checkout, the base
99
+ * ref's tracked copy, or an untracked/ignored working-tree copy (not part of the PR). A tracked worktree copy is never read.
100
+ */
101
+ export function trustedForgeYaml(repo, baseRef, baseSha) {
102
+ let docText = ''; let file = '';
103
+ const gc = git(repo, ['rev-parse', '--git-common-dir']).out.trim();
104
+ const top = fs.realpathSync(path.resolve(git(repo, ['rev-parse', '--show-toplevel']).out.trim() || repo));
105
+ let main = '';
106
+ if (gc) { const abs = path.resolve(repo, gc); if (path.basename(abs) === '.git') main = fs.realpathSync(path.dirname(abs)); }
107
+ if (main && main !== top && fs.existsSync(path.join(main, 'forge.yaml'))) { file = path.join(main, 'forge.yaml'); docText = fs.readFileSync(file, 'utf8'); }
108
+ else if (!main || main === top) {
109
+ const tracked = git(repo, ['ls-files', '--error-unmatch', '--', 'forge.yaml']).code === 0;
110
+ if (tracked) { const t = baseSha ? readAtRef(repo, baseRef, 'forge.yaml') : null; if (t) { file = `${baseRef}:forge.yaml`; docText = t; } }
111
+ else if (fs.existsSync(path.join(repo, 'forge.yaml'))) { file = path.join(repo, 'forge.yaml'); docText = fs.readFileSync(file, 'utf8'); }
112
+ }
113
+ return { file, text: docText };
114
+ }
115
+
96
116
  export function listAtRef(repo, ref) {
97
117
  const r = git(repo, ['ls-tree', '-r', '--name-only', ref]);
98
118
  return r.code === 0 ? r.out.split('\n').filter(Boolean) : [];
@@ -8,7 +8,7 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import {
11
- Degraded, git, run, resolveRef, fileHash, readAtRef, listAtRef, changedFiles, headSha, sha256, hostClass, homeForgeDir, parseYaml, globToRegex,
11
+ Degraded, git, run, resolveRef, fileHash, readAtRef, listAtRef, changedFiles, headSha, sha256, hostClass, homeForgeDir, parseYaml, globToRegex, trustedForgeYaml,
12
12
  } from './lib.mjs';
13
13
  import { makeScopeCtx, scopeCommand } from './scoping.mjs';
14
14
  import { missingTool } from './adapters/common.mjs';
@@ -31,17 +31,7 @@ const CI_ADAPTERS = [gha, gitlab, circle, azure, bitbucket, buildkite];
31
31
  // base ref's tracked copy, or an untracked/ignored working-tree copy (not part of the PR). A tracked forge.yaml in the
32
32
  // worktree is never read.
33
33
  function loadConfig(repo, baseRef, baseSha) {
34
- let docText = ''; let file = '';
35
- const gc = git(repo, ['rev-parse', '--git-common-dir']).out.trim();
36
- const top = fs.realpathSync(path.resolve(git(repo, ['rev-parse', '--show-toplevel']).out.trim() || repo));
37
- let main = '';
38
- if (gc) { const abs = path.resolve(repo, gc); if (path.basename(abs) === '.git') main = fs.realpathSync(path.dirname(abs)); }
39
- if (main && main !== top && fs.existsSync(path.join(main, 'forge.yaml'))) { file = path.join(main, 'forge.yaml'); docText = fs.readFileSync(file, 'utf8'); }
40
- else if (!main || main === top) {
41
- const tracked = git(repo, ['ls-files', '--error-unmatch', '--', 'forge.yaml']).code === 0;
42
- if (tracked) { const t = baseSha ? readAtRef(repo, baseRef, 'forge.yaml') : null; if (t) { file = `${baseRef}:forge.yaml`; docText = t; } }
43
- else if (fs.existsSync(path.join(repo, 'forge.yaml'))) { file = path.join(repo, 'forge.yaml'); docText = fs.readFileSync(file, 'utf8'); }
44
- }
34
+ const { file, text: docText } = trustedForgeYaml(repo, baseRef, baseSha);
45
35
  if (!docText) return { file: '', exclude_jobs: [], include_jobs: [], extra_commands: [], services: 'auto', mode: 'advisory' };
46
36
  let doc; try { doc = parseYaml(docText); } catch (e) { if (e instanceof Degraded) throw e; doc = null; }
47
37
  const c = (doc && doc.verification && doc.verification.ci_local) || {};