forgedock 1.17.0 → 1.19.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 (143) hide show
  1. package/bin/engine/outcome-codes.mjs +37 -2
  2. package/bin/engine/phases.mjs +207 -106
  3. package/bin/engine/runlog.mjs +17 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +37 -2
  7. package/bin/engine.mjs +221 -15
  8. package/bin/forgedock.mjs +1 -1
  9. package/bin/hooks/pre-tool-use.mjs +65 -32
  10. package/bin/runner.mjs +125 -6
  11. package/bin/stats/aggregate.mjs +7 -3
  12. package/bin/stats/cache.mjs +7 -3
  13. package/bin/stats/github-source.mjs +203 -24
  14. package/bin/stats/index.mjs +46 -9
  15. package/bin/stats/render.mjs +1 -1
  16. package/bin/stats/stats.test.mjs +264 -15
  17. package/commands/cleanup.md +10 -3
  18. package/commands/deploy-pr.md +64 -0
  19. package/commands/fix-ci.md +20 -6
  20. package/commands/milestone.md +34 -17
  21. package/commands/orchestrate/phase-4-execution.md +11 -4
  22. package/commands/review-pr-staging.md +145 -522
  23. package/commands/review-pr.md +496 -3124
  24. package/commands/test-gate.md +25 -10
  25. package/commands/work-on/build/architect.md +66 -1
  26. package/commands/work-on/build/implement.md +21 -1
  27. package/commands/work-on/build/validate.md +322 -28
  28. package/commands/work-on/build.md +173 -95
  29. package/commands/work-on/close.md +50 -15
  30. package/commands/work-on/decompose.md +72 -34
  31. package/commands/work-on/investigate.md +16 -12
  32. package/commands/work-on/remediate.md +58 -25
  33. package/commands/work-on/review.md +36 -21
  34. package/commands/work-on.md +1 -2
  35. package/docs/CONFIG.md +132 -26
  36. package/docs/OUTCOME-CODES.md +12 -3
  37. package/docs/RELEASE-NOTES.md +12 -0
  38. package/docs/STATS.md +13 -5
  39. package/docs/TELEMETRY.md +7 -4
  40. package/docs/WORK-ON-RUNTIME.md +24 -0
  41. package/docs/spec/forge-protocol-v1.md +2 -2
  42. package/package.json +2 -2
  43. package/packages/protocol/src/phases.js +5 -5
  44. package/packages/protocol/src/types.js +1 -1
  45. package/scripts/acceptance-red-green.sh +90 -0
  46. package/scripts/acceptance-red-green.test.sh +74 -0
  47. package/scripts/cap-findings.sh +1 -1
  48. package/scripts/cap-findings.test.sh +3 -1
  49. package/scripts/check-contract-scope.test.sh +2 -0
  50. package/scripts/check-edge-cases.sh +143 -0
  51. package/scripts/check-edge-cases.test.sh +175 -0
  52. package/scripts/check-spec-bash.sh +5 -1
  53. package/scripts/check-spec-bash.test.sh +20 -0
  54. package/scripts/check-spec-markers.sh +2 -0
  55. package/scripts/check-test-tmp-cleanup.sh +56 -0
  56. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  57. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  58. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  59. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  60. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  61. package/scripts/ci-local/adapters/common.mjs +115 -0
  62. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  63. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  64. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  65. package/scripts/ci-local/adapters/portability.mjs +104 -0
  66. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  67. package/scripts/ci-local/cli.mjs +50 -0
  68. package/scripts/ci-local/lib.mjs +371 -0
  69. package/scripts/ci-local/plan.mjs +186 -0
  70. package/scripts/ci-local/run.mjs +345 -0
  71. package/scripts/ci-local/scoping.mjs +508 -0
  72. package/scripts/ci-local.sh +97 -0
  73. package/scripts/ci-local.test.sh +919 -0
  74. package/scripts/classify-finding.sh +32 -6
  75. package/scripts/classify-finding.test.sh +23 -0
  76. package/scripts/classify-test-run.sh +69 -0
  77. package/scripts/classify-test-run.test.sh +90 -0
  78. package/scripts/close-parent-c3.test.sh +71 -0
  79. package/scripts/consumer-sweep.sh +569 -0
  80. package/scripts/consumer-sweep.test.sh +357 -0
  81. package/scripts/detect-append-registries.sh +106 -0
  82. package/scripts/detect-append-registries.test.sh +64 -0
  83. package/scripts/diff-size.sh +93 -4
  84. package/scripts/diff-size.test.sh +59 -2
  85. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  86. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  87. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  88. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  89. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  90. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  91. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  92. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  93. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  94. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  95. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  96. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  97. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  98. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  99. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  100. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  101. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  102. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  103. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  104. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  105. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  106. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  107. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  108. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  109. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  110. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  111. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  112. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  113. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  114. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  115. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  116. package/scripts/forge-root.test.sh +14 -12
  117. package/scripts/lib/telemetry.sh +1 -0
  118. package/scripts/publish-staging-sync.test.sh +48 -0
  119. package/scripts/remediate-approved-head.test.sh +2 -0
  120. package/scripts/remediate-basesync.test.sh +1 -1
  121. package/scripts/resolve-deploy-gate.sh +32 -6
  122. package/scripts/resolve-deploy-gate.test.sh +42 -1
  123. package/scripts/review-acceptance-gate.sh +2 -2
  124. package/scripts/review-acceptance-gate.test.sh +20 -0
  125. package/scripts/review-delta.sh +8 -1
  126. package/scripts/review-delta.test.sh +19 -0
  127. package/scripts/review-finalize.sh +1579 -0
  128. package/scripts/review-finalize.test.sh +1040 -0
  129. package/scripts/review-pr-delta-wiring.test.sh +32 -72
  130. package/scripts/review-pr-domain-map.test.sh +18 -13
  131. package/scripts/review-pr-gate2.test.sh +9 -65
  132. package/scripts/review-pr-staging-scope.test.sh +14 -98
  133. package/scripts/review-prepare.sh +53 -15
  134. package/scripts/review-prepare.test.sh +37 -3
  135. package/scripts/review-roster.sh +16 -2
  136. package/scripts/review-roster.test.sh +22 -0
  137. package/scripts/run-shell-tests.sh +5 -0
  138. package/scripts/telemetry.test.sh +2 -0
  139. package/scripts/trusted-comments.test.sh +11 -3
  140. package/scripts/verify-phase-trail.test.sh +8 -0
  141. package/scripts/wait-ci-green.sh +61 -9
  142. package/scripts/wait-ci-green.test.sh +44 -6
  143. package/scripts/with-run-tmpdir.sh +20 -0
package/docs/CONFIG.md CHANGED
@@ -50,7 +50,7 @@ If you have both a local `claude` CLI and `ANTHROPIC_API_KEY` set and want to pi
50
50
  | [`project_board`](#project_board-optional) | No | GitHub Projects v2 integration |
51
51
  | [`orchestration`](#orchestration-optional) | No | `/orchestrate` concurrency and cascade admission policy |
52
52
  | [`pipeline`](#pipeline-optional) | No | `/orchestrate` batch-engine tuning (stall detection, token budget, narration) |
53
- | [`build`](#build-optional) | No | `/work-on` build-phase diff-size gate (threshold, exclusion globs) |
53
+ | [`build`](#build-optional) | No | `/work-on` build-phase scope check (`build.scope.*`) and review depth (`review.depth.*`) |
54
54
  | [`services`](#services-optional) | No | External service URLs and IDs |
55
55
  | [`review`](#review-optional) | No | Context injected into review agents |
56
56
  | [`devdocs`](#devdocs-optional) | No | Devdocs knowledge tree path |
@@ -481,58 +481,79 @@ engine:
481
481
  | `<key>_model` | string | No | Model alias (`sonnet`, `opus`, `haiku`) or full model id passed as `--model`. Invalid values are ignored. |
482
482
  | `<key>_max_turns` | integer | No | Positive integer passed as `--max-turns`. Invalid values are ignored. |
483
483
 
484
+ | `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. |
485
+
486
+ **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.
487
+
484
488
  **Precedence** (model): `engine.<key>_model`, then the run-level model the caller passed explicitly, then omitted. `max_turns` comes only from this section. No defaults ship.
485
489
 
486
490
  **Version gate**: the flags are added only when the installed `claude --version` supports them (`--max-turns` is hidden from `claude --help`, so help text is never scraped); an unparseable version omits both flags.
487
491
 
488
492
  **Turn exhaustion**: when `--max-turns` is exhausted the run ends `engine-error` (detail `max-turns`) and the phase is never committed, so `forgedock run-issue --retry` re-enters the same phase. Raise `<key>_max_turns` before retrying. Phase runlog events record `model` and `turns` next to `usage` so a limit can be sized from measured turn counts.
489
493
 
494
+ **Per-run scratch dir** (`tmp_root`): every engine child runs with `TMPDIR` and `CLAUDE_CODE_TMPDIR` set to `<tmp_root>/<repo-key>/<issue>` so venvs and Claude session data stay off a size-limited `/tmp`. `engine.tmp_root` is an absolute or `~`-prefixed path; default `~/.forge/tmp`. The `<repo-key>` is the checkout's directory name plus 8 hex characters of a hash of its real path, so the same issue number in two repos never shares a dir; the dir is created `0700` and an existing symlink or dir owned by another user is not used (the child keeps the inherited tmp). Old `<tmp_root>/<issue>` dirs from earlier versions are not migrated. When `tmp_root` is unset and the operator already exported `TMPDIR` or `CLAUDE_CODE_TMPDIR`, that choice is respected and never cleaned. The dir is removed on a terminal outcome and kept on `engine-error` so `--retry` resumes. A disk-full/quota failure (EDQUOT, ENOSPC, errno -28/-122) ends a retryable `engine-error` (outcome code `infra.disk_quota`), never `needs-human`.
495
+
490
496
  **Commands that use this section**: CLI backend (`runCommand` in `bin/runner.mjs`)
491
497
 
492
498
  ---
493
499
 
494
500
  ## `build` (OPTIONAL)
495
501
 
496
- Tuning for the `/work-on` build phase. Today it holds the diff-size gate: after the implement phase stages its changes and before validation commits them, the build measures the changed lines (added plus deleted) of the index and any earlier commits against `origin/{base}`, using `scripts/diff-size.sh`. Review findings per PR rise steeply with diff size, so an oversized build is split before it is validated.
502
+ Tuning for the `/work-on` build phase. Today it holds the **scope check** (forge#3862, replacing the diff-size gate): after the implement phase stages its changes and before validation commits them, the build compares the staged diff with the architect plan (the `PLANNED_FILES:` list and `ESTIMATED_DIFF_LINES` in the plan's `### Scope Baseline`), measures a deterministic `review_weight` with `scripts/diff-size.sh`, and records the outcome in `FORGE:DIFF_SIZE`. **Size never blocks, a finished build is never discarded, and a completed build is never routed to decompose.**
497
503
 
498
504
  ```yaml
499
505
  build:
500
- diff_size:
501
- # Changed-line ceiling. Default: 1000. 0 disables the gate.
502
- threshold: 1000
503
-
504
- # Pre-build estimate (investigate): an estimate above threshold x factor routes
505
- # straight to decompose without building. Default: 1.2. Skipped when threshold is 0.
506
- estimate_factor: 1.2
507
-
508
- # Pre-build estimate (investigate): a non-batch issue with at least this many
509
- # independent seams and an estimate over half the threshold is decomposed into
510
- # packed sub-issues (not one per seam). Review-finding batches are exempt.
511
- # Default: 3.
512
- batch_seam_min: 3
513
-
506
+ scope:
507
+ test_weight: 50 # percent weight of test lines in review_weight (default 50)
508
+ module_depth: 2 # path segments that define a module (default 2)
509
+ overrun_pct: 200 # whitespace-insensitive lines over the plan estimate, in percent, that count as scope creep (default 200)
510
+ trim_out_of_plan: false # move out-of-plan hunks into the batched follow-up checklist (default false: flag only)
511
+ trim_max_files: 3 # trim only when at most this many files are out of plan (default 3)
514
512
  # Extra exclusion globs, ADDITIVE to the built-in defaults below.
515
513
  exclude_globs:
516
514
  - "docs/api/*"
517
515
  - "*.pb.go"
516
+
517
+ review:
518
+ depth:
519
+ extended_weight: 1500 # review_weight at or above which review is EXTENDED (default 1500)
520
+ extended_modules: 6 # modules touched at or above which review is EXTENDED (default 6)
521
+ slice_files: 12 # changed files per slice in extended review (default 12)
518
522
  ```
519
523
 
520
524
  | Field | Type | Required | Description |
521
525
  |-------|------|----------|-------------|
522
- | `diff_size.threshold` | integer | No | Changed lines allowed before the gate trips. Default: 1000. `0` disables the gate |
523
- | `diff_size.estimate_factor` | number | No | Multiplier on `threshold` for the pre-build estimate: the investigator forces a decomposition when `ESTIMATED_DIFF_LINES` > `threshold` x `estimate_factor`. The investigator emits the effective value as an own-line `ESTIMATE_FACTOR` field, and the engine's fail-toward-decompose net (`isDecomposed`) uses it even if the verdict says NO (1.2 when the field is absent or invalid). That net is suppressed (treated as not decomposed) for a sub-issue (`**Parent**: #N`), an issue with a trusted `FORGE:DECOMPOSED` comment, or a justified `FORGE:SIZE_OVERRIDE`, because decompose Guard 1 refuses those. Default: 1.2. Skipped when `threshold` is `0` |
524
- | `diff_size.batch_seam_min` | integer | No | Independent seams at which an issue is decomposed when `ESTIMATED_DIFF_LINES` is over half of `threshold`. Does not apply to review-finding batches (`batch` label or `FORGE:BATCH_MEMBERS`), which decompose only above `threshold` x `estimate_factor`. Seams are packed into sub-issues, not one per seam. Default: 3. Skipped when `threshold` is `0` |
525
- | `diff_size.exclude_globs` | list of strings | No | Globs excluded from the measurement, in addition to the built-in defaults. Default: `[]` |
526
+ | `build.scope.test_weight` | integer | No | Percent of a test file's changed lines counted in `review_weight` (tests are easier to review). Default: 50 |
527
+ | `build.scope.module_depth` | integer | No | Leading path segments of a file's directory that identify a module for the `modules` count. Default: 2 |
528
+ | `build.scope.overrun_pct` | integer | No | The scope is flagged when whitespace-insensitive changed lines exceed the plan's estimate by more than this percent (200 = more than twice the estimate). Default: 200 |
529
+ | `build.scope.trim_out_of_plan` | boolean | No | Opt-in. Moves each out-of-plan file's hunks out of the PR into the batched follow-up issue (forge#3843); the hunks are saved in a patch file next to the git dir, so nothing is lost. Never triggered by an overrun alone. Default: `false` (flag only) |
530
+ | `build.scope.trim_max_files` | integer | No | Trim only when at most this many files are out of plan; more is flagged, not trimmed. Default: 3 |
531
+ | `build.scope.exclude_globs` | list of strings | No | Globs excluded from the measurement, in addition to the built-in defaults. Default: `[]` |
532
+ | `review.depth.extended_weight` | integer | No | `review_weight` at or above which the build records `review_depth: EXTENDED`. Default: 1500 |
533
+ | `review.depth.extended_modules` | integer | No | Modules touched at or above which the build records `review_depth: EXTENDED`. Default: 6 |
534
+ | `review.depth.slice_files` | integer | No | In extended review, each domain agent reviews slices of at most this many changed files. Default: 12 |
535
+
536
+ All thresholds are config values with generous defaults, to be calibrated from telemetry (`diff_size` events carry the plan estimate, the actual, `review_weight`, the scope outcome and the review depth; see `docs/TELEMETRY.md`).
537
+
538
+ **`review_weight`** (computed by `scripts/diff-size.sh`): changed lines with whitespace ignored (`git diff -w`, so a pure re-indent counts about 0), over the counted files only (generated and vendored code excluded), where test files count `test_weight` percent. `modules` counts the distinct directories (first `module_depth` segments) holding at least one non-whitespace change. It drives review depth, never blocking.
526
539
 
527
540
  **Built-in default exclusions** (always applied, defined once in `scripts/diff-size.sh`): lockfiles (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `poetry.lock`, `uv.lock`, `go.sum`, `Gemfile.lock`, `composer.lock`), `*.min.*`, `*.snap`, and the directories `dist/`, `build/`, `generated/`, `fixtures/`, `vendor/` (anchored to the repo root, so first-party directories such as `commands/work-on/build/` are counted) plus `__generated__/` and `__snapshots__/` (any depth). The diff is measured against the merge base of `HEAD` and `origin/<base>`, so commits that landed on the base after the branch point are not counted. A configured glob ending in `/` matches that directory at any depth; any other glob is matched against the file basename and the full path. Globs are treated as data, never evaluated.
528
541
 
529
- **Over the threshold**: the build posts a `FORGE:DIFF_SIZE` comment with a split proposal and exits `BUILD_RESULT: status: NEEDS_DECOMPOSE`; the router then runs `work-on:decompose`. To build the oversized diff anyway, post a `FORGE:SIZE_OVERRIDE` comment whose body has a non-empty justification on the line after the marker (only comments from trusted authors count). An issue that is already a decomposed child, or that already has `FORGE:DECOMPOSED`, is not decomposed again: the build blocks until an override is posted, unless the diff is cohesive (next paragraph). The staged diff is also persisted before the exit (pushed ref `forge/wip/{NUMBER}`, or a patch file when the push fails) and each split-proposal line links its hunks, so they survive worktree cleanup; decompose copies the link into each sub-issue. The measured `diff_lines` is recorded in the `FORGE:TRAJECTORY` comment.
542
+ **Scope-check outcomes** (`result:` in `FORGE:DIFF_SIZE`): `OK` (in plan, standard depth); `EXTENDED_REVIEW` (in plan, `review_weight` or `modules` at the extended threshold: review adds reviewers and per-file slices); `SCOPE_FLAGGED` (files outside the plan, or an estimate overrun: listed in the comment for review, review extended, optionally trimmed); `OVERRIDDEN` (scope flagged but a justified `FORGE:SIZE_OVERRIDE` is present). An architect skip marker (empty plan) means no plan baseline: the out-of-plan check is skipped, never blocking. A `FORGE:SIZE_OVERRIDE` comment (trusted author, non-empty justification on the line after the marker) suppresses the scope flag and the decompose loop guard. The measured `diff_lines` is still recorded in the `FORGE:TRAJECTORY` comment.
543
+
544
+ **Decomposition is a planning decision**: `work-on:investigate` answers `**YES**` only when the work is separable (independent modules or services, no shared files, no ordering dependency, each piece independently testable), with the reason recorded; a cohesive change stays one unit at any size. It emits `ESTIMATED_DIFF_LINES` (the scope baseline) and `INDEPENDENT_SEAMS` inside `### Decomposition Assessment`. The engine downgrades a `**YES**` to NO unless `INDEPENDENT_SEAMS` is at least 2 with an own-line `DECOMPOSE_JUSTIFICATION:`; it has no line-count rule. A sub-issue, an issue with a trusted `FORGE:DECOMPOSED` comment, or one with a justified `FORGE:SIZE_OVERRIDE` is never decomposed (decompose Guard 1).
530
545
 
531
- **Cohesive sub-issue overage**: a sub-issue (or an issue with `FORGE:DECOMPOSED`) that is over the threshold with no override continues instead of blocking when `scripts/diff-size.sh` reports `cohesive=true`: at least 70% of the counted lines are in files added by the diff or in test files (test directories `test/`, `tests/`, `__tests__/`, or `*.test.*`, `*.spec.*`, `test_*`, `*_test.*`), and `diff_lines` is at most `threshold` x 1.5. The build records `result: COHESIVE` with the cohesion figures and a reviewer note in `FORGE:DIFF_SIZE`, adds no `needs-human`, and continues to validation. Test lines are classified, not discounted. The 70% and 1.5x constants live in the script and are not configurable. A non-sub-issue over the threshold still routes to `NEEDS_DECOMPOSE`, and `FORGE:SIZE_OVERRIDE` still takes precedence. Each sub-issue is packed to at most `0.8 x threshold` and carries an own-line `ESTIMATED_DIFF_LINES: N` in its body, which `FORGE:DIFF_SIZE` records next to the actual `diff_lines` as `estimated_diff_lines`.
546
+ **Migration from `build.diff_size.*`** (deprecated; read as aliases for one release, then removed):
532
547
 
533
- **Pre-build estimate**: `work-on:investigate` emits `ESTIMATED_DIFF_LINES`, `INDEPENDENT_SEAMS`, `GATE_THRESHOLD` and `ESTIMATE_FACTOR` inside `### Decomposition Assessment`. An estimate above `threshold` x `estimate_factor` (or, for a non-batch issue, `batch_seam_min`+ seams whose estimate is over `threshold` / 2) answers `**YES**`, so the issue is decomposed before any build into `max(2, ceil(estimate / (0.8 x threshold)))` packed sub-issues (the parent is a terminal tracker). The engine downgrades a `**YES**` that neither condition requires to NO unless the report has an own-line `DECOMPOSE_JUSTIFICATION:` line (it assumes `batch_seam_min` 3 and cannot see the `batch` label, so it is lenient for batches). The estimate rule is skipped when `threshold` is `0`.
548
+ | Old key | New behaviour |
549
+ |---------|---------------|
550
+ | `build.diff_size.threshold` | No longer a gate. A positive value is read as `review.depth.extended_weight` (extended review instead of a block or split); `0` (gate disabled) now just means the defaults. The build records `deprecated_config` in `FORGE:DIFF_SIZE` when an alias supplied a value |
551
+ | `build.diff_size.exclude_globs` | Read as an alias of `build.scope.exclude_globs` (additive) |
552
+ | `build.diff_size.estimate_factor`, `build.diff_size.batch_seam_min` | Ignored. The investigator's decomposition rule is separability, not a line count |
534
553
 
535
- **Commands that use this section**: `work-on` (build phase B5.5, `decompose`, `close`)
554
+ `BUILD_RESULT: status: NEEDS_DECOMPOSE`, the `forge/wip/{NUMBER}` ref, the cohesive-overage path and the outcome code `build.size_over_gate` are retired. An old run whose build was handed to decompose can be resumed at build with `run-issue --retry` once a trusted `FORGE:DECOMPOSED:REVOKED` comment (newer than the `FORGE:DECOMPOSED` comment) and a justified `FORGE:SIZE_OVERRIDE` are on the issue; untrusted comments never reopen a run.
555
+
556
+ **Commands that use this section**: `work-on` (build phase B5.5, `close`), `review-pr` (Phase 3 review depth)
536
557
 
537
558
  ---
538
559
 
@@ -550,6 +571,8 @@ decomposition:
550
571
  |-------|------|----------|-------------|
551
572
  | `close_parent` | boolean | No | When `true`, `work-on:decompose` (Phase D6.5) closes the parent after the sub-issues exist, `FORGE:DECOMPOSED:COMPLETE` is posted and `workflow:decomposed` is set, with the comment "Decomposed into #a #b #c; tracked here until all complete". The label is kept. Every child body is also checked for the `**Parent**: #N` reference. Default: `false`. Any value other than `true`, or an unreadable config, leaves the parent open |
552
573
 
574
+ **Batch members**: when the parent carries a `FORGE:BATCH_MEMBERS` block, `work-on:decompose` (Phase D2.7) reopens and reuses a closed member that matches a planned sub-issue one to one, instead of filing a duplicate. Members with a merged PR or a `false-positive`, `needs-human` or `operator-only` label are never reopened, and any ambiguity falls back to creating a new issue. There is no config key for this.
575
+
553
576
  **With `close_parent: true`**: `work-on:close` Phase C3 keeps checking children off the closed parent's checklist. When the last child closes, the parent gets an "All sub-issues complete" comment and `workflow:merged`; it is not closed a second time and never reopened. `/pipeline-status` and `/pipeline-health` exclude `workflow:decomposed` from work counts and report them separately as `trackers (N)`. `/orchestrate` expands an explicitly requested closed decomposed parent to its open children.
554
577
 
555
578
  **With `close_parent: false`** (default): unchanged. The parent stays open and is closed by C3 when the last child closes.
@@ -637,7 +660,7 @@ review:
637
660
  | `tech_stack` | string | No | One-line tech stack summary. Injected into all review agent prompts |
638
661
  | `context` | string (multiline) | No | Freeform context about architecture, deploy model, and known pitfalls |
639
662
  | `domains.*` | string | No | Domain-specific notes keyed by review agent name (`billing`, `auth`, `database`, `frontend`, `api`, `infra`, `security`) |
640
- | `max_filed_findings` | integer >= 0 | No | Per-review cap on ISSUE dispositions (`scripts/cap-findings.sh`, applied by `review-pr` §6B.5 and `review-pr-staging` §7B.5 after classification). Default `2`; `1` when the reviewed PR is itself a fix for a `review-finding` issue, where every filed HIGH/CRITICAL consumes that budget. ISSUE findings are ranked by severity, then confidence, then safety-domain agent (security, auth, billing, concurrency, database), then input order; an unparseable severity or confidence ranks as MEDIUM/LIKELY. The overflow becomes `NOTE capped`: listed in the PR body and counted as `notes_capped` in `FORGE:NOTE_DISPOSITION`, never filed and never dropped. **HIGH/CRITICAL findings are never capped** and still block; `ISSUE fix-efficacy-*` findings (the linked issue is not fixed, or the PR re-introduces its defect) are never capped either and count against the review-finding budget like HIGH. `0` is valid (cap every MEDIUM/LOW finding); a non-numeric or negative value falls back to the default with a warning. An explicit value overrides both defaults. |
663
+ | `max_filed_findings` | integer >= 0 | No | Per-review cap on ISSUE dispositions (`scripts/cap-findings.sh`, applied by `review-pr` §6B.5 and `review-pr-staging` §7B (via `review-finalize.sh classify`) after classification). Default `2`; `1` when the reviewed PR is itself a fix for a `review-finding` issue, where every filed HIGH/CRITICAL consumes that budget. ISSUE findings are ranked by severity, then confidence, then safety-domain agent (security, auth, billing, concurrency, database), then input order; an unparseable severity or confidence ranks as MEDIUM/LIKELY. The overflow becomes `NOTE capped`: listed in the PR body and counted as `notes_capped` in `FORGE:NOTE_DISPOSITION`, never filed and never dropped. **HIGH/CRITICAL findings are never capped** and still block; `ISSUE fix-efficacy-*` findings (the linked issue is not fixed, or the PR re-introduces its defect) are never capped either and count against the review-finding budget like HIGH. `0` is valid (cap every MEDIUM/LOW finding); a non-numeric or negative value falls back to the default with a warning. An explicit value overrides both defaults. |
641
664
  | `key_paths` | map(string→list of strings) | No | Domain-to-file mapping used by `/work-on` investigation and `/review-pr` agents to quickly locate relevant files. Keys are domain names matching issue labels; values are lists of file path patterns (glob-style, relative to repo root). When present, agents use this table instead of inferring files from labels and issue body. |
642
665
 
643
666
  **Commands that use this section**: `review-pr`, `review-pr-agents` (all domain agents), `work-on` (Phase 1B investigation)
@@ -717,6 +740,10 @@ verification:
717
740
  - name: "web"
718
741
  container: "acme-web-blue"
719
742
  health_url: "http://localhost:3000"
743
+
744
+ consumer_sweep:
745
+ mode: advisory # advisory (default) | blocking
746
+ budget_seconds: 60 # wall-clock budget for the sweep
720
747
  ```
721
748
 
722
749
  | Field | Type | Required | Description |
@@ -727,8 +754,87 @@ verification:
727
754
  | `services[].name` | string | **Yes (per entry)** | Human-readable service name |
728
755
  | `services[].container` | string | No | Docker container name for `docker exec` verification |
729
756
  | `services[].health_url` | string | No | Local URL for container-level health checks |
757
+ | `ci_local` | object | No | Local CI gate settings (see below) |
758
+ | `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 |
759
+ | `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 |
760
+
761
+ **Commands that use this section**: `quality-gate`, `validate` (V2 runs `verification.commands`; V4.8 runs the local CI gate; health checks and `consumer_sweep`), `work-on`
762
+
763
+ ### `verification.ci_local` (OPTIONAL)
764
+
765
+ Validate Phase V4.8 runs `scripts/ci-local.sh` before review: it derives the repo's pull-request checks from its own CI
766
+ definitions and runs them locally in the build worktree, so deterministic CI failures are fixed in build (at most 2 fix
767
+ iterations; a failure still unfixable after that is DEGRADED by default, see `mode`). It never contacts GitHub and never triggers a remote
768
+ workflow; remote CI still runs once, at merge. Everything works with no configuration.
769
+
770
+ ```yaml
771
+ verification:
772
+ ci_local:
773
+ mode: advisory
774
+ exclude_jobs: ["gha:ci:e2e-*"]
775
+ include_jobs: []
776
+ extra_commands:
777
+ - "make lint-docs"
778
+ services: auto
779
+ budget_minutes: 15
780
+ max_parallel: 4
781
+ ```
730
782
 
731
- **Commands that use this section**: `quality-gate`, `validate`
783
+ | Field | Type | Default | Description |
784
+ |-------|------|---------|-------------|
785
+ | `mode` | `advisory` \| `blocking` | `advisory` | `advisory`: a runnable job still failing after the 2 fix iterations is DEGRADED (`DEGRADED_CHECKS+=ci-local`), recorded in `FORGE:BUILDER` under `### Local CI unresolved` and surfaced to review; the build is not blocked. `blocking` (opt-in): the build is blocked with `ci-local: <jobs>`. Flip to `blocking` only once telemetry (`ci_local` events) shows a low false-fail rate on your repo |
786
+ | `exclude_jobs` | array of globs | `[]` | Job ids or names to drop from the plan (matched against `gha:<workflow>:<job>`, `gitlab:<job>`, and the job name) |
787
+ | `include_jobs` | array of globs | `[]` | When non-empty, only matching CI jobs run (an allowlist); `extra_commands` and the portability checks (when they apply) always run |
788
+ | `extra_commands` | array of strings or `{name, command}` | `[]` | Extra commands always added to the plan. `verification.commands` is not repeated here: V2 already runs it as the fast pre-step |
789
+ | `services` | `auto` \| `off` | `auto` | `auto` starts a job's service containers with docker (same image, env, ports), tearing them down afterwards; without docker, or with `off`, such jobs are deferred |
790
+ | `budget_minutes` | number | `15` | Overall wall-time budget; jobs not finished or started within it leave the run DEGRADED (never PASS, never FAIL). One invocation is capped at 9 minutes whatever this says (the Bash tool limit is 10): beyond that the run is a partial DEGRADED result and re-running resumes from the result cache (`FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS`, default 540) |
791
+ | `max_parallel` | number | `4` | Jobs run in parallel by one run (also bounded machine-wide, see below) |
792
+
793
+ **Trusted config**: every field above is read from the main checkout's `forge.yaml` (for a plain checkout, the base ref's
794
+ copy), never from the PR's own tree, so a PR cannot exclude jobs from the gate that checks it.
795
+
796
+ **Where checks come from**: GitHub Actions (`.github/workflows`), GitLab CI, CircleCI, Azure Pipelines, Bitbucket Pipelines
797
+ and Buildkite definitions, read from the trusted base ref plus the PR's own edits (a PR can add a check but cannot delete
798
+ one). Only when the repo has NO CI definitions are its task runners used; when CI exists but its path filters leave no job
799
+ applicable the run executes nothing and records `no_applicable_jobs` (a docs-only change never triggers a full suite) (`package.json` scripts, Makefile, justfile,
800
+ Taskfile, tox/nox, pre-commit, pyproject tools, Cargo, Go, Maven, Gradle). Deterministic portability checks (Windows-invalid
801
+ filenames, case collisions, CRLF in scripts, bash 3.2 constructs and GNU-only flags on added lines, plus the repo's
802
+ `scripts/check-shell-portability.sh` when present) run only when the repo's CI definitions target macOS or Windows (a
803
+ `macos-*`/`windows-*` runner, matrix entry or executor); for a Linux-only repo they are skipped with a note, so correct
804
+ Linux-only code is never blocked on bash 3.2 rules.
805
+
806
+ **Deferred to CI** (listed in the `FORGE:BUILDER` comment under `### Deferred to CI`, never guessed): jobs that reference
807
+ `secrets.` or `environment:`, deploy/publish jobs, `pull_request_target`, schedule/dispatch-only workflows, unknown `uses:`
808
+ actions, orbs, plugins and templates, service containers without docker, macOS/Windows variants on another host, matrix
809
+ variants beyond the first host-compatible one, and jobs whose tools are not installed locally. A failed dependency install
810
+ (for example offline) defers the job instead of failing it, and so does a failure of a job whose pinned toolchain differs
811
+ from the host by a major version (`setup-node` 18 vs host 26: version drift, listed with its reason). A job timeout is
812
+ DEGRADED, never FAIL, and its clock starts when the job starts executing, not while it waits for a machine slot.
813
+
814
+ **Exact scoping** (runs only what the change can affect): job-level path filters from the CI definition (including
815
+ `dorny/paths-filter` outputs) gate whole jobs; per-file linters (shellcheck, eslint, prettier, ruff, black, flake8, ...) run
816
+ on the changed files only; tests use the ecosystem selector (`jest --findRelatedTests`, `vitest related`, `go` affected
817
+ packages with reverse dependencies, `cargo test -p` affected crates, Maven `-pl ... -amd`, `nx affected`, `turbo --filter`) or a
818
+ git-grep reference graph for file-list runners (pytest, mocha, `node --test`, bats, rspec, direct `*.test.*` invocations);
819
+ changed test files always run. **Fail-safe widening**: a lockfile, build or lint config, CI definition, unreferenced shared
820
+ fixture, an unknown file type, or an empty test selection for a changed source file widens the step to the full command (the
821
+ reason is reported, never silently narrowed). Short module names (`app/db.py`, `__init__.py`) are matched by their qualified
822
+ import forms. `${{ github.* }}` expressions in `run:` scripts are passed to the script as environment variables, never
823
+ substituted into the script text. CircleCI secondary containers run on a per-job docker network and publish only declared
824
+ (or well-known default) ports on 127.0.0.1, never `--network host`.
825
+ Passing results are cached by input hash under `~/.forge/ci-local/results` and reused across build iterations, lanes and
826
+ issues on the same base.
827
+
828
+ **Environment** (all optional): `FORGE_CI_LOCAL_SLOTS` (machine-wide concurrent jobs across all runs, default
829
+ `max(2, min(8, cpus/2))`; slots live under `~/.forge/ci-local.lock` and stale ones are reclaimed), `FORGE_CI_LOCAL_BUDGET_MINUTES`,
830
+ `FORGE_CI_LOCAL_MAX_PARALLEL`, `FORGE_CI_LOCAL_JOB_TIMEOUT` (seconds, default min(900, budget)), `FORGE_CI_LOCAL_SERVICES=off`,
831
+ `FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS` (default 540), `FORGE_CI_LOCAL_HOME` (default `~/.forge`), `FORGE_CI_LOCAL_NO_CACHE=1`. Jobs run in a runner-like environment: an allowlist of
832
+ system and toolchain variables (PATH, HOME, locale, XDG_*, CARGO_*, GOPATH, JAVA_HOME, proxies, ...) plus `CI=true`; credentials and
833
+ ambient operator state (agent tokens, `FORGE_*`/`CLAUDE_*` settings, git hook variables) never reach them. Extra names can be
834
+ listed in `FORGE_CI_LOCAL_PASSTHROUGH` (comma separated). Requires `node`, `git`, and mikefarah `yq` v4 for YAML CI definitions (without `yq`
835
+ the gate is DEGRADED and reported in `verification_skipped`, never a silent pass).
836
+
837
+ **Commands that use this section**: `work-on` (validate V4.8)
732
838
 
733
839
  ---
734
840
 
@@ -18,20 +18,29 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
18
18
  | `review.changes_requested` | Review asked for an in-PR fix and handed off to remediate | review | `REVIEW_RESULT` `status: NEXT`, `next: remediate` |
19
19
  | `review.escalated` | Review escalated the PR to a human or remediation | review | PR/issue carries `needs-human` or `workflow:remediating` |
20
20
  | `review.rereview_skipped` | Re-review was skipped | reserved | No structured evidence source yet |
21
- | `build.acceptance_failed` | Newest acceptance gate marker is blocked | build | `FORGE:ACCEPTANCE_GATE:FAILED`/`:BLOCKED` or `FORGE:BUILD_BLOCKED` |
21
+ | `review.blocking_issue` | Review left the PR open at an unchanged head with a filed blocking ISSUE and handed off to remediate | review | Trusted `FORGE:NOTE_DISPOSITION` on the PR with `findings_filed` > 0 and `reviewed_sha` equal to the live head |
22
+ | `review.pr_open` | Review ended with the PR open and unmerged and no handoff signal | review | Live PR open, no `REVIEW_RESULT` remediate handoff, no `needs-human`/`workflow:remediating` label, no blocking disposition |
23
+ | `review.ci_pending_timeout` | CI was still pending at the merge gate after the engine's bounded wait (`engine.ci_pending_max_minutes`) ran out | engine | Review ended `REVIEW_RESULT` `status: BLOCKED` with a blocker containing `checks still pending` (or remediate held at its M7 re-gate on `CI pending`), and `scripts/wait-ci-green.sh` still reported pending when the budget expired |
24
+ | `build.acceptance_failed` | Newest acceptance gate marker is blocked | build | `FORGE:ACCEPTANCE_GATE:FAILED`/`:BLOCKED` or `FORGE:BUILD_BLOCKED` (`:DEGRADED` is a pass-class marker, not blocked) |
22
25
  | `build.acceptance_malformed` | Acceptance spec was malformed | reserved | Indistinguishable from `build.acceptance_failed` by sentinel alone |
23
- | `build.size_over_gate` | Size gate routed the build to decompose | build | `FORGE:DIFF_SIZE` `result: OVER` with `### Split Proposal` |
24
- | `build.cohesive` | Size gate judged an oversized diff cohesive | reserved | `result: COHESIVE` is a pass, so no failure to code |
26
+ | `build.size_over_gate` | Retired (forge#3862): the old size gate routed a finished build to decompose; never emitted now, kept so historic runs still parse | reserved | A finished build is never routed to decompose |
27
+ | `build.cohesive` | Retired (forge#3862): the old size gate judged an oversized diff cohesive | reserved | No size gate remains |
25
28
  | `remediate.no_marker` | Remediation ended without its completion marker | remediate | No trusted `FORGE:REMEDIATION:COMPLETE` |
26
29
  | `remediate.deferred_live_run` | Remediation deferred to a live earlier run | remediate, engine | Trusted `FORGE:REMEDIATION_DEFERRED` record |
27
30
  | `remediate.re_escalated` | Remediation re-escalated to a human | remediate | `**Re-gate outcome**: RE-ESCALATED` |
28
31
  | `remediate.unfixable` | Remediation judged the findings unfixable | remediate | `**Re-gate outcome**: UNFIXABLE` |
32
+ | `remediate.inpr_requested` | Re-gate approved but requested one further in-PR fix round; the run continues, never `needs-human` | remediate | `**Re-gate outcome**: INPR-FIX-REQUESTED` |
29
33
  | `ci.red` | CI failed | reserved | `REVIEW_RESULT` carries no CI field yet |
30
34
  | `ci.timeout` | CI timed out | reserved | `REVIEW_RESULT` carries no CI field yet |
31
35
  | `base.conflict` | Base-branch conflict blocked the PR | reserved | `REVIEW_RESULT` carries no conflict field yet |
32
36
  | `rate_limited` | Session or usage limit outlasted the pause budget | engine | `err.usageLimit` / `err.resetAtEpochMs` |
33
37
  | `engine.error` | Engine or tool failure (missing key/SDK, CLI crash, max turns, unresolved args, runner threw every attempt) | engine | `err.code` |
38
+ | `infra.disk_quota` | Disk full or quota exhausted (EDQUOT/ENOSPC, errno -28/-122); retryable infra failure, never `needs-human` | engine | Runner error text |
39
+ | `infra.cli_spawn_transient` | CLI spawn failed transiently before the child ran (ETIMEDOUT, EAGAIN, ENOMEM, EMFILE); retried with backoff on a separate bounded counter that does not consume a phase attempt | engine | `err.code` `CLI_SPAWN_TRANSIENT` |
40
+ | `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` |
34
41
  | `lease.held` | Another live agent holds the issue lease | engine | Foreign live lease on acquire |
42
+ | `dependency.open` | A trusted `**Depends on**: #N` dependency is still open, so the run was refused before any state write | engine | Trusted issue body `**Depends on**` line + the dependency issue's open state |
43
+ | `dependency.unreadable` | The issue body or a declared dependency could not be read, so the run was refused (fail closed) | engine | Failed or unparsable `gh api` read |
35
44
  | `invalid` | Issue is invalid or in a divergent closed state | investigate, engine | `INVESTIGATION:INVALID` marker, or live snapshot |
36
45
  | `decomposed` | Issue was decomposed into sub-issues | investigate, decompose | `FORGE:DECOMPOSED` or the decomposition assessment |
37
46
  | `completed` | Non-failure terminal (merged, phase-complete, investigation) | engine | Terminal reason |
@@ -2,6 +2,18 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## Review specs call the prepare and finalize scripts (#3841)
6
+
7
+ `commands/review-pr.md` is now under 100 KB: Phases 1-3B and 6 run through `scripts/review-prepare.sh` and `scripts/review-finalize.sh`, and `commands/review-pr-staging.md` reuses both (`review-prepare.sh --no-delta` in 0C, then `review-finalize.sh extract|classify|file-prepare|file-verify` with `--scope-files` and `--prior-prs` in Phase 7) instead of carrying its own template resolution, slicing, classifier and cap copies. A staging bundle whose API diff is too large is read from `origin/<base>...origin/<head>`. CI now runs `--fence-funcs` over `commands/review-pr*.md` and `--fence-state` over the staging spec. Engine: `close.detectOutcome` reports `merged` only for a CLOSED issue that also carries `workflow:merged` (a stale label on an open issue no longer counts).
8
+
9
+ Action: none. Staging reviews now stop with `REVIEW_RESULT: status: BLOCKED` when `review-prepare.sh` exits 3 (for example no template source, or an empty trusted `typescript.build` on the full-build lane) instead of continuing.
10
+
11
+ ## Post-build scope check replaces the diff-size gate (#3862)
12
+
13
+ The line-count size gate is gone. A finished build is never discarded and never routed to decompose; `BUILD_RESULT: status: NEEDS_DECOMPOSE`, the `forge/wip/{N}` ref, the `COHESIVE` path and the engine's size-based decompose routing (including the investigate estimate net) are removed. Decomposition is a planning decision: `work-on:investigate` says `**YES**` only for separable work (independent modules or services, no shared files, no ordering), and the engine downgrades a YES without `INDEPENDENT_SEAMS >= 2` and a `DECOMPOSE_JUSTIFICATION`. Build B5.5 is now a scope check: it compares the diff with the architect plan (`### Scope Baseline`: `PLANNED_FILES:` and `ESTIMATED_DIFF_LINES:`), flags files outside the plan (opt-in trim into the batched follow-up), and measures `review_weight` (whitespace-insensitive, generated/vendored excluded, tests weighted, modules counted). A high `review_weight` extends review (more reviewers, per-file slices) instead of blocking; the Security agent and the Phase 2 floor stay unconditional. `FORGE:SIZE_OVERRIDE`, the loop guards and the batch exemption keep working. `run-issue --retry` reopens a stale `decomposed` run at build when a trusted `FORGE:DECOMPOSED:REVOKED` comment and a justified `FORGE:SIZE_OVERRIDE` are present. Telemetry `diff_size` events now carry estimate, actual, `review_weight`, scope outcome and review depth.
14
+
15
+ Action: replace `build.diff_size.*` with `build.scope.*` / `review.depth.*` (see docs/CONFIG.md, "Migration"). The old keys are read as deprecated aliases for one release; `threshold` no longer blocks anything. Stacked-PR splitting and default calibration are tracked in #3907.
16
+
5
17
  ## Gate scripts emit structured telemetry (#3834)
6
18
 
7
19
  `diff-size.sh`, `review-acceptance-gate.sh`, `classify-finding.sh`, `wait-ci-green.sh`, `remediate-liveness.sh` and the build/review spec call sites (B5.5, B6.5, review panel) now append one JSONL record per decision to a trusted `<!-- FORGE:TELEMETRY v1 -->` comment on the issue or PR (shell emitter `scripts/lib/telemetry.sh`, Node twin `bin/engine/telemetry.mjs`). A failed write is buffered under `~/.forge/telemetry-buffer/` and flushed on the next emit. Catalogue and privacy note: `docs/TELEMETRY.md`.
package/docs/STATS.md CHANGED
@@ -14,6 +14,10 @@ npx forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--r
14
14
  versions at or above it; `--since 2026-10-01` keeps activity on or after the date. `--compact` prints one row per
15
15
  version (the table embedded in the staging review summary). `--json` is machine-readable.
16
16
 
17
+ `--since` also bounds the GitHub crawl: only issues and comments updated on or after the window start are read (a version
18
+ is resolved to its tag time minus one day; an unknown tag falls back to a full crawl and says so on stderr). Progress is
19
+ printed to stderr while crawling, so `--json` stdout stays parseable.
20
+
17
21
  ## Sources
18
22
 
19
23
  1. **GitHub (primary).** Issues, PRs and labels of the target repo, plus trusted `<!-- FORGE:TELEMETRY v1 -->` comments
@@ -21,8 +25,9 @@ version (the table embedded in the staging review summary). `--json` is machine-
21
25
  `scripts/trusted-comments.sh`); `part=N` comments are concatenated and records are deduped on `eid`. All `gh` calls
22
26
  are argv-based. Needs an authenticated `gh`.
23
27
  2. **Local cache (optional).** `~/.forge/stats-cache/<owner>_<repo>/cache.json` (`FORGE_STATS_CACHE_DIR`) holds what was
24
- fetched plus an `updated_at` watermark, so the next run only re-reads items updated since. It is safe to delete (the
25
- cost is one full re-fetch); a corrupt cache is treated as empty. `--refresh` ignores it, `--no-cache` never touches it.
28
+ fetched, an `updated_at` watermark (the next run only re-reads items updated since) and a `coveredFrom` floor (the
29
+ oldest window start crawled, so a later, wider `--since` crawls the uncovered gap instead of under-reporting). It is
30
+ safe to delete (the cost is one full re-fetch); a corrupt cache is treated as empty. `--refresh` ignores it, `--no-cache` never touches it.
26
31
  3. **Local run logs (optional).** `~/.forge/runs/*.jsonl` (`FORGE_RUNS_DIR`) for runs that predate the engine's
27
32
  `phase_attempt` / `run_terminal` records. A run already present on GitHub is not counted twice. Only runs whose
28
33
  `RUN_START.repo` equals the target repo are used. `--no-local` skips them.
@@ -47,7 +52,8 @@ Each group shows a delta against the previous version of the same repo and is fl
47
52
  They match `scripts/cost-report.sh`, so the two reports agree on the same logs: only finished runs count; hydrated runs
48
53
  and runs with no usage events are excluded; tokens are cache-read tokens; `PHASE_ATTEMPT_USAGE` supersedes the cumulative
49
54
  `PHASE_COMMIT` usage of the same phase. On GitHub a run is the set of `phase_attempt` records sharing a `run` id and it is
50
- finished once a `run_terminal` record exists. Wall time covers one engine process; a resumed run is a separate run.
55
+ finished once a `run_terminal` record exists. On GitHub stats count one run per issue: the latest by start timestamp wins and earlier attempts (for example before a
56
+ `--retry`) are discarded, as in `scripts/cost-report.sh`. Wall time covers the winning engine process only.
51
57
 
52
58
  ## Versions and n/a
53
59
 
@@ -58,5 +64,7 @@ instead of failing.
58
64
 
59
65
  ## Limits
60
66
 
61
- Issues and PRs are listed by `updated_at`, so the first run on a large repo makes one comment request per item that has
62
- comments. Telemetry comments are edited in place; if a cached item looks stale, run with `--refresh`.
67
+ Comments are read from the repo-wide `issues/comments?since=` endpoint (a few paginated calls); only issues with a trusted
68
+ telemetry comment in the window get their full comment list re-read (6 in parallel, backing off when the API rate limit is
69
+ low). A run with no `--since` and no cache still walks the whole history once. Telemetry comments are edited in place, which
70
+ the comment window catches; if a cached item still looks stale, run with `--refresh`.
package/docs/TELEMETRY.md CHANGED
@@ -31,18 +31,21 @@ is dropped. Payload keys are `[A-Za-z0-9_]+` and cannot shadow the shared keys;
31
31
 
32
32
  | Event | Emitted by | Payload |
33
33
  |-------|-----------|---------|
34
- | `diff_size` | `scripts/diff-size.sh`; `work-on/build.md` B5.5 (final result) | `lines`, `excluded`, `threshold`, `result` = `OK` \| `OVER` \| `COHESIVE` (`OVERRIDDEN` from the B5.5 call site) |
34
+ | `diff_size` | `scripts/diff-size.sh`; `work-on/build.md` B5.5 (final result) | `lines`, `excluded`, `ws_lines`, `review_weight`, `modules`, `result`; the script emits the legacy `threshold` and `OK` \| `OVER` \| `COHESIVE`, the B5.5 call site emits the final `result` = `OK` \| `EXTENDED_REVIEW` \| `SCOPE_FLAGGED` \| `OVERRIDDEN` plus `estimate` (plan), `actual` (whitespace-insensitive lines), `scope` = `ok` \| `out_of_plan` \| `no_baseline` and `review_depth` = `STANDARD` \| `EXTENDED` (calibration inputs, forge#3862) |
35
35
  | `acceptance_gate` | `work-on/build.md` B6.5 (`phase=build`); `scripts/review-acceptance-gate.sh` (`phase=review`) | `phase`, `verified`, `failed`, `malformed`, `deferred` |
36
- | `finding_disposition` | `scripts/classify-finding.sh`, one per finding | `severity`, `confidence`, `agent`, `disposition` = `ISSUE` \| `NOTE` \| `INPR_FIX`, `reason` |
36
+ | `finding_disposition` | `scripts/classify-finding.sh`, one per finding | `severity`, `confidence`, `agent`, `scope` = `introduced` \| `pre-existing` \| `unknown` (from the diff; `unknown` when not supplied), `disposition` = `ISSUE` \| `NOTE` \| `INPR_FIX` (also MEDIUM LIKELY when `scope=introduced`), `reason` |
37
37
  | `review_panel` | `review-pr.md` Phase 4 | `agents_selected`, `agents_completed`, `mode` = `FULL` \| `DELTA` \| `BASE_SYNC_ONLY` |
38
- | `ci_gate` | `scripts/wait-ci-green.sh` | `result` = `PASS` \| `FAIL` \| `ERROR` \| `TIMEOUT`, `rc`, `checks` |
38
+ | `ci_gate` | `scripts/wait-ci-green.sh` | `result` = `PASS` \| `FAIL` \| `ERROR` \| `TIMEOUT` \| `RATE_LIMITED` (rc 4: GitHub throttled or briefly unavailable for the whole window; retryable, never a verdict), `rc`, `checks` |
39
+ | `ci_pending_wait` | the engine (`bin/engine/telemetry-seam.mjs`, one per finished CI-pending wait at the merge gate; `phase` = `review` \| `remediate`) | `pr`, `minutes_waited`, `result` = `green` \| `red` \| `error` \| `timeout`, `polls` (CI reads made), `rate_limit_sleeps` (polls that were rate-limited plus sleeps until the budget reset) |
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`, `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
+ | `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` \| `na`, `duration_ms`, `widen` (reason or `none`). A remote-CI failure at merge on a check recorded here as narrowed is a selector bug |
39
42
  | `liveness` | `scripts/remediate-liveness.sh` | `decision` = `PROCEED` \| `DEFER` \| `EXHAUSTED`, `why` |
40
43
  | `phase_attempt` | the engine (`bin/engine/telemetry-seam.mjs`, one per phase attempt; `phase` = the phase id) | `attempt`, `model`, `turns`, `duration_ms`, `cost_usd`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` (any value the runner did not report is omitted) |
41
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) |
42
45
 
43
46
  The engine records carry `run` = `r_<issue>_<start epoch seconds>` (one per `forgedock run-issue` process) so `forgedock stats`
44
47
  (`docs/STATS.md`) can group the attempts of a run and tell a finished run from an unfinished one. They are the GitHub-side
45
- source for Cost, Time and outcome codes: without them a fresh machine can rebuild only the gate and review sections.
48
+ source for Cost, Time and outcome codes (`run` is per process, but stats count one run per issue, last wins): without them a fresh machine can rebuild only the gate and review sections.
46
49
 
47
50
  The classifier and the scripts emit only when a destination is known: callers export `FORGE_ISSUE` and/or `FORGE_PR`
48
51
  (`review-pr.md` exports them before classifying; the other scripts default `FORGE_REPO`/`FORGE_ISSUE`/`FORGE_PR` from
@@ -51,6 +51,12 @@ Only the router may invoke them, so they always run at router layer + 1 (L2 unde
51
51
  L1 solo). When another phase needs one, it returns a handoff (`status: NEXT`, `next: <phase>`) and
52
52
  the router invokes it. Concretely: review returns `next: remediate` instead of calling remediation.
53
53
 
54
+ The same rule gives the `PHASE_TRAIL_FAILED` re-dispatch a single owner: `work-on:review` Phase R4 alone
55
+ re-runs the missing phases. `/review-pr` (Phase 8, via `scripts/review-finalize.sh merge-gate`) and
56
+ `work-on:remediate` (Phase M6) only report the status with its `MISSING:` lines and never re-dispatch. When
57
+ `/review-pr` finds neither `Task` nor `Agent` ("neither tool available"), the usual cause is this depth limit
58
+ (F1/F5): the review was invoked from inside a fork instead of by the router, so `Agent` was withheld.
59
+
54
60
  **R2 — Non-dispatching forks may nest.** Phases and children that never spawn sub-agents may invoke
55
61
  further forks at any depth (F5): build → context/architect/implement/validate → quality-gate,
56
62
  review → quality-gate / missing-phase re-dispatch, remediate → close. Forking them keeps each
@@ -104,6 +110,10 @@ phase hosting `/review-pr` cannot wait on a notification. Therefore:
104
110
  `/orchestrate` Step 4B item 6.4 dispatches a worker that runs `work-on <pr> --remediate`: worker L1,
105
111
  remediate L2, reviewers L3, same as the table.
106
112
 
113
+ ### Pre-handoff base sync and union registries (forge#3969)
114
+
115
+ `work-on:review` merges `origin/<base>` into the branch before the first push, so a PR is never born stale and a conflict is resolved while the build context is warm. Hand-maintained append-only registries (test lists, cron targets) are the usual conflict source; `scripts/detect-append-registries.sh` ranks them from history and proves a `.gitattributes` `merge=union` entry safe (`--check-union`). Union is allowed only for line-independent files, never YAML/JSON maps or ordered lists (see `.gitattributes`). `scripts/review-delta.sh` still treats a hand-resolved merge as not `BASE_SYNC_ONLY`, but accepts a merge whose tree is exactly what a mechanical `git merge-tree` of the same parents produces (a clean or union merge), so union registries take the no-panel fast path.
116
+
107
117
  ## 5. Router routing
108
118
 
109
119
  | Phase result | Router action |
@@ -130,3 +140,17 @@ remediate L2, reviewers L3, same as the table.
130
140
  ## 8. Outcome codes
131
141
 
132
142
  Every `PHASE_FAILED`, blocked `PHASE_COMMIT` and `RUN_TERMINAL` run-log event carries a machine-readable `code` from a closed, versioned enum (`bin/engine/outcome-codes.mjs`), and `FORGE:STATE` records the causing code as `outcomeCode`. Codes come from structured evidence only, never from the prose `detail`; an unmapped outcome is `unclassified` with the raw reason kept. See `docs/OUTCOME-CODES.md`.
143
+
144
+ ## Remediation re-gate outcomes and spawn retries (forge#4021)
145
+
146
+ The `**Re-gate outcome**` token in a `FORGE:REMEDIATION:COMPLETE` trail is a closed vocabulary the engine parses:
147
+ `AUTO-LANDED`, `HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `REREVIEW-REQUIRED` and `INPR-FIX-REQUESTED`.
148
+ `INPR-FIX-REQUESTED` (with `**INPR round**: N`) means the re-review approved but posted a `FORGE:INPR_FIX` work
149
+ order. The engine commits `remediate` with `outputs.remediation = "inpr-fix"`, re-arms it once, and re-reviews. The
150
+ `INPR_REMEDIATION` marker count bounds it: a second request escalates to `needs-human`.
151
+
152
+ A CLI spawn failure (`ETIMEDOUT`, `EAGAIN`, `ENOMEM`, `EMFILE`) carries `code = "CLI_SPAWN_TRANSIENT"`. The engine
153
+ retries it after a 10s/30s/60s backoff on a separate counter (max 3) that does not consume a phase attempt; once
154
+ spent, normal attempt accounting applies. `ETIMEDOUT` (a `FORGEDOCK_CLI_TIMEOUT_MS` kill) is in this class by design,
155
+ so a phase that times out every time can run up to `maxAttempts + 3` full-length CLI sessions; lower the timeout or
156
+ max attempts if that cost is unacceptable.
@@ -535,8 +535,8 @@ needed.
535
535
 
536
536
  | Marker | Category | Meaning |
537
537
  |--------|----------|---------|
538
- | `<!-- FORGE:DIFF_SIZE -->` | Lifecycle (no completion sentinel) | Measured diff size of the staged build: `diff_lines`, `excluded_lines`, `threshold`, `result: OK \| OVERRIDDEN \| COHESIVE \| OVER`, optional `estimated_diff_lines` (sub-issues), cohesion figures and a reviewer note on `COHESIVE`, and on `OVER` a `### Split Proposal`. Refreshed in place; readers take the latest trusted comment. Consumed by decompose (plan source) and close (records `diff_lines` in `FORGE:TRAJECTORY`) |
539
- | `<!-- FORGE:SIZE_OVERRIDE -->` | Control | Posted by a human to let an over-threshold build proceed. Valid only from a trusted author and with a non-empty justification on the line after the marker |
538
+ | `<!-- FORGE:DIFF_SIZE -->` | Lifecycle (no completion sentinel) | Record of the build's post-build scope check (B5.5): `diff_lines`, `excluded_lines`, `ws_lines`, `review_weight`, `modules`, `estimated_diff_lines`, `scope: ok \| out_of_plan \| no_baseline`, `review_depth: STANDARD \| EXTENDED`, `result: OK \| EXTENDED_REVIEW \| SCOPE_FLAGGED \| OVERRIDDEN` plus `out_of_plan:` / `overrun:` / `trimmed:` notes. Never a size gate and never a decompose signal (a finished build is not routed to decompose). Refreshed in place; readers take the latest trusted comment. Consumed by review-pr (review depth) and close (records `diff_lines` in `FORGE:TRAJECTORY`). Older records carrying `threshold` / `result: OVER \| COHESIVE` still parse |
539
+ | `<!-- FORGE:SIZE_OVERRIDE -->` | Control | Posted by a human to suppress the scope-check flag (out-of-plan files or estimate overrun) and the decompose loop guard. Valid only from a trusted author and with a non-empty justification on the line after the marker |
540
540
 
541
541
  Both lookups are anchored at the start of the comment body and trust-filtered
542
542
  (`scripts/trusted-comments.sh`); an unreadable lookup fails closed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.17.0",
3
+ "version": "1.19.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"
@@ -48,7 +48,7 @@
48
48
  "README.md"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"bin/stats/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
51
+ "test": "bash scripts/with-run-tmpdir.sh node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"bin/stats/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
52
52
  "test:sh": "bash scripts/run-shell-tests.sh",
53
53
  "docs:dev": "vitepress dev docs/site",
54
54
  "docs:build": "vitepress build docs/site",
@@ -57,8 +57,8 @@ export const PHASE_IDS = ['investigate', 'decompose', 'build', 'review', 'remedi
57
57
  * investigate.md emits it as the HTML comment `<!-- DECOMPOSE:YES -->`.
58
58
  * @property {string} [notDecomposedMarker] - (investigate only) the explicit not-decomposed
59
59
  * verdict, emitted as `<!-- DECOMPOSE:NO -->`.
60
- * @property {string} [sizeGateMarker] - (build only) bare annotation-opener of the diff-size gate record.
61
- * @property {string} [sizeOverrideMarker] - (build only) bare annotation-opener of the size-gate override.
60
+ * @property {string} [sizeGateMarker] - (build only) bare annotation-opener of the scope-check record (diff size, review weight, scope outcome).
61
+ * @property {string} [sizeOverrideMarker] - (build only) bare annotation-opener of the scope-check override.
62
62
  * @property {string} [investigationMarker] - (build only) bare marker of the durable
63
63
  * investigation-deliverables comment, posted as `<!-- FORGE:INVESTIGATION:DELIVERABLES -->`.
64
64
  * @property {string} [partialMarker] - sentinel for an interrupted/partial annotation.
@@ -108,9 +108,9 @@ export const PHASE_MARKERS = {
108
108
  build: {
109
109
  completionMarker: RESERVED_TYPES.BUILDER.completionSentinel, // 'FORGE:BUILDER:COMPLETE'
110
110
  header: 'FORGE:BUILDER',
111
- // Size-gate (B5.5) records. Neither is a completion sentinel: the gate's
112
- // NEEDS_DECOMPOSE exit posts no FORGE:BUILDER:COMPLETE, so the engine reads
113
- // these to route an oversized build to decompose instead of retrying it.
111
+ // Scope-check (B5.5) records. Neither is a completion sentinel and neither routes anywhere
112
+ // (forge#3862): a finished build is never sent to decompose. DIFF_SIZE is the record of the
113
+ // scope-check outcome; SIZE_OVERRIDE suppresses the scope flag and the decompose loop guard.
114
114
  sizeGateMarker: `FORGE:${RESERVED_TYPES.DIFF_SIZE.type}`, // 'FORGE:DIFF_SIZE'
115
115
  sizeOverrideMarker: `FORGE:${RESERVED_TYPES.SIZE_OVERRIDE.type}`, // 'FORGE:SIZE_OVERRIDE'
116
116
  // Investigation exit (implement.md I2): no code, no PR, so no FORGE:BUILDER:COMPLETE.
@@ -109,7 +109,7 @@ export const RESERVED_TYPES = {
109
109
  partialSentinel: null,
110
110
  inlineValue: false,
111
111
  requiredFields: [],
112
- reGateOutcomeValues: ['AUTO-LANDED', 'HELD-AWAITING-MERGE', 'RE-ESCALATED', 'UNFIXABLE'],
112
+ reGateOutcomeValues: ['AUTO-LANDED', 'HELD-AWAITING-MERGE', 'RE-ESCALATED', 'UNFIXABLE', 'INPR-FIX-REQUESTED', 'REREVIEW-REQUIRED'],
113
113
  },
114
114
  TRAJECTORY: {
115
115
  type: 'TRAJECTORY',