forgedock 1.29.1 → 1.30.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 (38) hide show
  1. package/bin/engine/outcome-codes.mjs +4 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine-cli.mjs +94 -36
  4. package/bin/engine.mjs +3 -1
  5. package/bin/lean/batch.test.mjs +62 -1
  6. package/bin/lean/conformance.mjs +129 -8
  7. package/bin/lean/conformance.test.mjs +77 -3
  8. package/bin/lean/context.mjs +17 -12
  9. package/bin/lean/context.test.mjs +36 -5
  10. package/bin/lean/effects.mjs +20 -6
  11. package/bin/lean/enumerating.mjs +189 -0
  12. package/bin/lean/enumerating.test.mjs +97 -0
  13. package/bin/lean/lean.mjs +189 -43
  14. package/bin/lean/lean.test.mjs +305 -6
  15. package/bin/lean/orchestrate.mjs +104 -85
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +21 -4
  19. package/bin/lean/prefilter.test.mjs +24 -1
  20. package/bin/lean/preflight.mjs +133 -0
  21. package/bin/lean/preflight.test.mjs +61 -0
  22. package/bin/lean/prompts.mjs +37 -3
  23. package/bin/lean/recovery.test.mjs +75 -0
  24. package/bin/lean/sequence.mjs +102 -0
  25. package/bin/lean/sequence.test.mjs +112 -0
  26. package/bin/lean/testmap.mjs +43 -6
  27. package/bin/lean/testmap.test.mjs +38 -1
  28. package/bin/lean/verify.mjs +7 -2
  29. package/bin/lean/verify.test.mjs +8 -2
  30. package/commands/orchestrate.md +3 -1
  31. package/docs/CONFIG.md +8 -1
  32. package/docs/OUTCOME-CODES.md +6 -4
  33. package/docs/RELEASE-NOTES.md +10 -0
  34. package/package.json +1 -1
  35. package/scripts/acceptance-red-green.sh +13 -4
  36. package/scripts/acceptance-red-green.test.sh +14 -0
  37. package/scripts/test-map.sh +63 -17
  38. package/scripts/test-map.test.sh +55 -0
@@ -22,9 +22,9 @@ function fake(o = {}) {
22
22
 
23
23
  describe("verify-by-CI config (#4167)", () => {
24
24
  it("parseVerify defaults to local and rejects invalid values", () => {
25
- assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, checkRun: false });
25
+ assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false });
26
26
  const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
27
- assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, checkRun: false });
27
+ assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false });
28
28
  assert.equal(parseVerify({ mode: "remote" }).mode, "local");
29
29
  assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
30
30
  assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
@@ -122,6 +122,12 @@ describe("forgedock/verify check run (#4270)", () => {
122
122
  const result = { validation: [{ id: "V1", exit: 0 }, { id: "V2", ci: true }], requirements: [{ id: "R1", status: "implemented" }] };
123
123
  const out = (r) => JSON.parse(/```json\n([\s\S]*)\n```/.exec(r.text)[1]);
124
124
 
125
+ it("#4318 parseVerify precheck_total_seconds: default 600, integer 1-7200 accepted, others fall back", () => {
126
+ assert.equal(parseVerify({}).precheckTotalSeconds, 600);
127
+ assert.equal(parseVerify({ precheck_total_seconds: "1800" }).precheckTotalSeconds, 1800);
128
+ assert.equal(parseVerify({ precheck_total_seconds: "7200" }).precheckTotalSeconds, 7200);
129
+ for (const bad of ["0", "-5", "1.5", "abc", "7201"]) assert.equal(parseVerify({ precheck_total_seconds: bad }).precheckTotalSeconds, 600, bad);
130
+ });
125
131
  it("parseVerify: check_run is true only for the literal true; default false", () => {
126
132
  assert.equal(parseVerify({}).checkRun, false);
127
133
  assert.equal(parseVerify({ check_run: "true" }).checkRun, true);
@@ -141,7 +141,9 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
141
141
  wait on the sub-issues.
142
142
  - `merged` or `invalid`: start the next ready issue.
143
143
  - `needs-human` with a **recoverable code**: `ci.red`, `base.conflict` (merge conflict), `ci-pending` on a conflicting
144
- PR, `contract.invalid` caused by an issue edited mid-run, and `builder.block_untyped` (an in-scope technical choice).
144
+ PR, `contract.invalid` caused by an issue edited mid-run, `builder.block_untyped` (an in-scope technical choice),
145
+ and the environment codes `env.tool_missing` (a validate command the host cannot run) and `env.repro_unrunnable`
146
+ (the repro harness could not run the repro test).
145
147
  These are mechanical. **Do not ask for confirmation on them.** The batch runner recovers each one itself
146
148
  (`orchestration.recovery.max_attempts`, default 1 per issue). It launches one recovery lane under the same lease, lane
147
149
  and admission rules as any lane, with `run-issue --recover <code> [--recover-attempt n]` (core path only:
package/docs/CONFIG.md CHANGED
@@ -590,11 +590,14 @@ Settings of the core work-on (`bin/lean/`) that change its contract.
590
590
  ```yaml
591
591
  lean:
592
592
  repro_test: prefer # off | prefer (default)
593
+ precheck:
594
+ max_minutes: 10 # optional pre-PR check round budget in minutes (1-120)
593
595
  ```
594
596
 
595
597
  | Field | Type | Required | Description |
596
598
  |-------|------|----------|-------------|
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. |
599
+ | `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. |
600
+ | `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. |
598
601
 
599
602
  ## `verify` (OPTIONAL)
600
603
 
@@ -608,6 +611,7 @@ verify:
608
611
  skip_paths: "*.md,docs/**" # optional, comma-separated; default *.md,*.mdx,*.rst,*.txt,docs/**,devdocs/**
609
612
  max_rounds: 2 # optional CI fix rounds before needs-human (default 2)
610
613
  precheck_timeout_seconds: 300 # optional per-command timeout of the declared pre-PR checks (default 300)
614
+ precheck_total_seconds: 600 # optional total budget of one pre-PR check round (default 600)
611
615
  check_run: true # optional; post the forgedock/verify check run (default false)
612
616
  ```
613
617
 
@@ -619,6 +623,7 @@ verify:
619
623
  | `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
624
  | `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
625
  | `precheck_timeout_seconds` | integer | No | Timeout of each declared check command (1–3600, default 300); a timed-out command counts as failed (exit 124). |
626
+ | `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. |
622
627
  | `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. |
623
628
 
624
629
  **Flow** (`ci`/`hybrid`, `bin/lean/verify.mjs` `runCiVerify`):
@@ -821,6 +826,8 @@ decomposition:
821
826
 
822
827
  **With `close_parent: false`** (default): unchanged. The parent stays open and is closed by C3 when the last child closes.
823
828
 
829
+ 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.
830
+
824
831
  **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
832
 
826
833
  **Commands that use this section**: `work-on` (`decompose` D6.5, `close` C3), `orchestrate`
@@ -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,7 +60,7 @@ 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` |
@@ -69,7 +71,7 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
69
71
  | `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
72
  | `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
73
  | `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 |
74
+ | `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
75
  | `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
76
  | `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
77
  | `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 +95,11 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
93
95
  `forgedock orchestrate` recovers mechanical outcomes inside the batch:
94
96
 
95
97
  - **recoverable**:
96
- - needs-human with `ci.red`, `base.conflict` or `builder.block_untyped`;
98
+ - needs-human with `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing` or `env.repro_unrunnable`;
97
99
  - `contract.invalid` when the issue was edited mid-run (`issueEdited`);
98
100
  - `ci-pending` on a conflicting PR, recovered as `base.conflict`.
99
101
 
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.
102
+ 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
103
  - **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
104
  - **terminal**: every other code. No recovery runs, dependents are skipped, and the row is reported as `unrecovered`.
103
105
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.29.1",
3
+ "version": "1.30.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 ]
@@ -27,6 +27,12 @@
27
27
  # substitution, `${{ }}`, `$GITHUB_*`, redirect or `<(...)`/`>(...)`, a non-check make target, or a fixer form
28
28
  # (`--fix`, `--write`, `-w`, `black`/`ruff format`/`gofmt`/`prettier` without their check flag) drops the step (forge#4316). Globs from the tool (python/go/shell/js, under the cwd), else the
29
29
  # workflow's `paths:` filter, else `DIR/**` for a cwd; a workflow entry never gets a bare `**`.
30
+ # forge#4318: also dropped: any quote, fixer/snapshot flag families (`--fix-only`, `--add-noqa`, `--write=*`, `--w`,
31
+ # `--update*`, `--updateSnapshot`, `--snapshot-update`, `-u` on jest/vitest/package managers), write targets (`go -o`,
32
+ # `-exec`, `-toolexec`, `tsc --outDir/--outFile`, `pytest --junitxml/--basetemp/--html`, any absolute or `..` path) and
33
+ # tox/nox `dist`/`pypi`/`twine` envs in any spelling; `-w` is a workspace on npm/pnpm. Also mapped: `uv run python -m X`,
34
+ # `python3.N -m X`, `npx --yes X`, `pnpm --filter X`, `npm -w PKG`, `make -C DIR` (DIR becomes the cwd), `cargo +TC <sub>`,
35
+ # `cargo fmt --check`, gradle `--tests X` / maven `-pl X`, `./x.test.sh`, `bash -x x.test.sh`. `clean` goals stay unmapped.
30
36
  # Exit: 0 ok | 2 usage or bad repo path. bash 3.2 portable.
31
37
  set -uo pipefail
32
38
 
@@ -207,6 +213,8 @@ check_cmd() {
207
213
  esac
208
214
  c=${c%" && popd"}
209
215
  case "$c" in *';'*|*'|'*|*'&'*) return 1 ;; esac
216
+ # forge#4318: a quoted word hides a flag from the word checks below ("--fix"); a real check never needs quotes
217
+ case "$c" in *\"*|*\'*) return 1 ;; esac
210
218
  CK_CMD=$c
211
219
  set -f
212
220
  # shellcheck disable=SC2086 # split into words on purpose; set -f stops glob expansion
@@ -214,11 +222,24 @@ check_cmd() {
214
222
  set +f
215
223
  while [ $# -gt 0 ]; do case "$1" in [A-Za-z_]*=*) shift ;; *) break ;; esac; done
216
224
  case "${1:-} ${2:-}" in
217
- "uv run"|"poetry run"|"pnpm exec"|"yarn exec"|"python -m"|"python3 -m"|"bundle exec") shift 2 ;;
218
- "npx "*|"bunx "*) shift ;;
225
+ "uv run"|"poetry run") shift 2; case "${1:-} ${2:-}" in "python -m"|"python3 -m") shift 2 ;; esac ;; # forge#4318: uv run python -m X
226
+ "pnpm exec"|"yarn exec"|"python -m"|"python3 -m"|"bundle exec") shift 2 ;;
227
+ "python3."[0-9]" -m"|"python3."[0-9][0-9]" -m") shift 2 ;; # forge#4318: a versioned interpreter
228
+ "npx "*|"bunx "*) shift; case "${1:-}" in --yes|-y) shift ;; esac ;;
219
229
  esac
220
- # forge#4316: a fixer rewrites the tree; only its read-only check form is a check
221
- for w in "$@"; do case "$w" in --fix|--fix=*|--write|-w) return 1 ;; esac; done
230
+ tool=${1:-}
231
+ # forge#4316/#4318: a fixer, snapshot updater or write-target flag rewrites the tree or writes outside it; matched as a
232
+ # flag family (`--fix-only`, `--write=true`, `--w`), and `-w` only off a package manager (there it names a workspace)
233
+ for w in "$@"; do
234
+ case "$w" in
235
+ --fix|--fix[=-]*|--add-noqa*|--unsafe-fixes*|--write|--write[=-]*|--update|--update[=-]*|--updateSnapshot*|--snapshot-update*) return 1 ;;
236
+ --outDir*|--outdir*|--out-dir*|--outFile*|--declarationDir*|--tsBuildInfoFile*|--junitxml*|--junit-xml*|--basetemp*|--html*|-exec|-exec=*|-toolexec*) return 1 ;;
237
+ /*|*=/*|..|../*|*/..|*/../*|*=..|*=../*) return 1 ;; # an absolute or parent path is outside the checkout
238
+ -w|-w=*|--w|--w=*) case "$tool" in npm|pnpm|yarn|bun) ;; *) return 1 ;; esac ;;
239
+ -u) case "$tool" in jest|vitest|npm|pnpm|yarn|bun) return 1 ;; esac ;;
240
+ -o|-o=*) [ "$tool" = go ] && return 1 ;;
241
+ esac
242
+ done
222
243
  case "${1:-} ${2:-}" in
223
244
  "black "*|"ruff format") has_word --check "$@" || return 1 ;;
224
245
  "gofmt "*) has_word -l "$@" || has_word -d "$@" || return 1 ;;
@@ -227,21 +248,26 @@ check_cmd() {
227
248
  case "${1:-}" in
228
249
  npm|pnpm|yarn|bun)
229
250
  pm=$1; shift
230
- case "$pm ${1:-}" in # forge#4315: pnpm -r test, npm --prefix DIR test
231
- "pnpm -r"|"pnpm --recursive") shift ;;
232
- "npm --prefix") [ $# -ge 2 ] && safe_dir "$2" || return 1; shift 2 ;;
251
+ case "$pm ${1:-}" in # forge#4315: pnpm -r test, npm --prefix DIR test; forge#4318: npm -w PKG, pnpm -w, pnpm --filter X
252
+ "pnpm -r"|"pnpm --recursive"|"pnpm -w"|"pnpm --workspace-root") shift ;;
253
+ "npm --prefix"|"npm -w"|"npm --workspace") [ $# -ge 2 ] && safe_dir "$2" || return 1; shift 2 ;;
254
+ "pnpm --filter"|"pnpm -F") [ $# -ge 2 ] || return 1; case "$2" in *[!A-Za-z0-9@._/-]*) return 1 ;; esac; shift 2 ;;
233
255
  esac
234
256
  [ "${1:-}" = test ] && return 0
235
257
  [ "${1:-}" = run ] && pkg_check_script "${2:-}" && return 0
236
258
  case "$pm" in yarn|pnpm) pkg_check_script "${1:-}" && return 0 ;; esac
237
259
  return 1 ;;
238
- cargo) case "${2:-}" in test|clippy|check) return 0 ;; esac; return 1 ;;
260
+ cargo)
261
+ a=${2:-}; [ "${a#+}" != "$a" ] && a=${3:-} # forge#4318: cargo +TOOLCHAIN <sub>
262
+ case "$a" in test|clippy|check) return 0 ;; fmt) has_word --check "$@"; return ;; esac; return 1 ;;
239
263
  ./gradlew|gradlew|gradle|./mvnw|mvnw|mvn)
240
- # every non-flag word is a check task/goal (`:mod:test` qualified for gradle); publish/deploy/install never pass
264
+ # every non-flag word is a check task/goal (`:mod:test` qualified for gradle); publish/deploy/install/clean never pass;
265
+ # forge#4318: the value of --tests / -pl / --projects / -P / -D is not a goal
241
266
  case "$1" in *mvn*) goals="test|verify|validate" ;; *) goals="test|check|lint" ;; esac
242
- shift; nt=0
267
+ shift; nt=0; skipv=""
243
268
  for w in "$@"; do
244
- case "$w" in -*) continue ;; esac
269
+ if [ -n "$skipv" ]; then skipv=""; continue; fi
270
+ case "$w" in --tests|-pl|--projects|-P|-D) skipv=1; continue ;; -*) continue ;; esac
245
271
  t=${w##*:}
246
272
  case "|$goals|" in *"|$t|"*) [ -n "$t" ] && nt=$((nt+1)) ;; *) return 1 ;; esac
247
273
  done
@@ -249,19 +275,39 @@ check_cmd() {
249
275
  rspec|unittest) return 0 ;;
250
276
  python|python3) [ "${2:-}" = manage.py ] && [ "${3:-}" = test ]; return ;;
251
277
  tox|nox)
278
+ # forge#4318: `--env=X`, `-eX` and comma lists are split, case-folded, and packaging envs (dist, pypi) are publish too
252
279
  shift; prev=""
253
280
  for w in "$@"; do
254
- case "$prev" in -e|-s|--env|--session|--sessions) case ",$w," in *publish*|*release*|*deploy*|*upload*) return 1 ;; esac ;; esac
255
- case "$w" in -e*|-s*) case "${w#-?}" in *publish*|*release*|*deploy*|*upload*) return 1 ;; esac ;; esac
281
+ v=""
282
+ case "$prev" in -e|-s|--env|--session|--sessions) v=$w ;; esac
283
+ case "$w" in --env=*|--session=*|--sessions=*) v=${w#*=} ;; --*) ;; -e?*|-s?*) v=${w#-?} ;; esac
284
+ if [ -n "$v" ]; then
285
+ v=$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')
286
+ case ",$v," in *publish*|*release*|*deploy*|*upload*|*pypi*|*twine*|*,dist,*) return 1 ;; esac
287
+ fi
256
288
  prev=$w
257
289
  done
258
290
  return 0 ;;
259
291
  deno) [ "${2:-}" = test ]; return ;;
260
- bash|sh) case "${2:-}" in *.test.sh) safe_dir "$2"; return ;; esac; return 1 ;;
292
+ bash|sh)
293
+ # forge#4318: shell option words (-x, -eu) before the script are allowed
294
+ shift; while [ $# -gt 0 ]; do case "$1" in -[a-z]*) shift ;; *) break ;; esac; done
295
+ case "${1:-}" in *.test.sh) safe_dir "$1"; return ;; esac; return 1 ;;
296
+ ./*.test.sh) safe_dir "${1#./}"; return ;;
261
297
  make)
262
298
  # forge#4316: every target must be a check (`make test deploy` is dropped); VAR=value and flags are not targets
263
- shift; nt=0
264
- for w in "$@"; do case "$w" in -*|*=*) continue ;; esac; make_check_target "$w" || return 1; nt=$((nt+1)); done
299
+ # forge#4318: `make -C DIR` runs in DIR: it becomes the entry cwd, not a target
300
+ shift; nt=0; prev=""; mdir=""
301
+ for w in "$@"; do
302
+ if [ "$prev" = -C ]; then mdir=$w; prev=$w; continue; fi
303
+ prev=$w
304
+ case "$w" in -*|*=*) continue ;; esac; make_check_target "$w" || return 1; nt=$((nt+1))
305
+ done
306
+ if [ -n "$mdir" ]; then
307
+ safe_dir "$mdir" || return 1
308
+ CK_CMD=${CK_CMD/ -C $mdir/}; mdir=${mdir%/}
309
+ if [ "$mdir" != . ]; then if [ "$CK_CWD" = . ]; then CK_CWD=$mdir; else CK_CWD="$CK_CWD/$mdir"; fi; fi
310
+ fi
265
311
  [ "$nt" -gt 0 ]; return ;;
266
312
  go) case "${2:-}" in test|vet|build) return 0 ;; esac; return 1 ;;
267
313
  node) [ "${2:-}" = --test ]; return ;;
@@ -280,7 +326,7 @@ tool_globs() {
280
326
  *\ deno\ *|*\ bun\ *|*\ bunx\ *) ext_globs . "$JS_EXT"; return ;;
281
327
  *[\ /]ruff\ *|*[\ /]flake8\ *|*[\ /]pylint\ *|*[\ /]mypy\ *|*[\ /]black\ *|*[\ /]pytest\ *) printf '**/*.py'; return ;;
282
328
  *[\ /]golangci-lint\ *|*[\ /]gofmt\ *|*[\ /]staticcheck\ *|*\ go\ *) printf '**/*.go'; return ;;
283
- *[\ /]shellcheck\ *|*\ bash\ *.sh\ *|*\ sh\ *.sh\ *) printf '**/*.sh'; return ;;
329
+ *[\ /]shellcheck\ *|*\ bash\ *.sh\ *|*\ sh\ *.sh\ *|*.test.sh\ *) printf '**/*.sh'; return ;;
284
330
  *[\ /]eslint\ *|*[\ /]tsc\ *|*[\ /]prettier\ *|*[\ /]vitest\ *|*[\ /]jest\ *|*\ node\ --test*|*\ npm\ *|*\ pnpm\ *|*\ yarn\ *|*\ npx\ *) ext_globs . "$JS_EXT"; return ;;
285
331
  esac
286
332
  printf ''
@@ -345,6 +345,61 @@ eslint .
345
345
  WF
346
346
  eq "unsafe forms dropped, check forms kept" "$(cmds)" "black --check .|eslint .|gofmt -l .|make test V=1|make test lint|prettier --check .|ruff format --check ."
347
347
 
348
+ # ---- 5g. forge#4318: flag-family fixers, snapshot updaters, write targets, quotes and tox publish envs are dropped ----
349
+ wf_case bypass <<'WF'
350
+ cargo clippy --fix
351
+ tox --env=publish
352
+ tox --session=release
353
+ tox -ePUBLISH
354
+ tox -e dist,pypi
355
+ nox -s Release
356
+ eslint "--fix" .
357
+ gofmt -l --w .
358
+ gofmt -w=true .
359
+ prettier --write=true .
360
+ prettier -cw .
361
+ ruff --fix-only .
362
+ ruff check --add-noqa .
363
+ jest -u
364
+ jest --updateSnapshot
365
+ vitest -u
366
+ vitest --update
367
+ pytest --snapshot-update
368
+ npm test -- -u
369
+ go build -o /tmp/x ./...
370
+ go test -o out.test ./...
371
+ go test -exec foo ./...
372
+ tsc --outDir /tmp/out
373
+ tsc --outFile out.js
374
+ pytest --junitxml=/abs/x.xml
375
+ pytest --basetemp=../t
376
+ mvn clean test
377
+ ./gradlew clean test
378
+ tox -e py311,lint
379
+ pytest -q
380
+ WF
381
+ eq "bypass forms dropped" "$(cmds)" "pytest -q|tox -e py311,lint"
382
+
383
+ # ---- 5h. forge#4318: workspace flags, versioned/wrapped runners, -C, toolchains and test scripts are mapped ----
384
+ wf_case forms <<'WF'
385
+ npm -w pkg test
386
+ pnpm -w test
387
+ uv run python -m pytest
388
+ python3.12 -m pytest
389
+ npx --yes vitest
390
+ pnpm --filter web test
391
+ make -C sub test
392
+ mvn -pl mod test
393
+ ./gradlew test --tests Foo
394
+ cargo +nightly test
395
+ cargo fmt --check
396
+ ./x.test.sh
397
+ bash -x x.test.sh
398
+ WF
399
+ eq "real forms mapped" "$(cmds)" "./gradlew test --tests Foo|./x.test.sh|bash -x x.test.sh|cargo +nightly test|cargo fmt --check|make test|mvn -pl mod test|npm -w pkg test|npx --yes vitest|pnpm --filter web test|pnpm -w test|python3.12 -m pytest|uv run python -m pytest"
400
+ eq "make -C dir is the cwd" "$(printf '%s' "$OUTS" | jq -r '.entries[] | select(.command == "make test") | "\(.cwd) \(.globs | join(","))"')" "sub sub/**"
401
+ eq "test script globs" "$(globs_of "./x.test.sh")" "**/*.sh"
402
+
348
403
  # ---- 6. usage ----
349
404
  bash "$S" --repo-path "$TMP/missing" >/dev/null 2>&1; eq "bad repo path" "$?" 2
350
405
  bash "$S" --nope >/dev/null 2>&1; eq "unknown option" "$?" 2