forgedock 1.29.0 → 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 (50) 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 +102 -38
  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 +32 -4
  9. package/bin/lean/context.test.mjs +71 -12
  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 +207 -56
  14. package/bin/lean/lean.test.mjs +336 -9
  15. package/bin/lean/orchestrate.mjs +132 -83
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +96 -14
  19. package/bin/lean/prefilter.test.mjs +111 -2
  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 +271 -7
  24. package/bin/lean/render.mjs +13 -0
  25. package/bin/lean/sequence.mjs +102 -0
  26. package/bin/lean/sequence.test.mjs +112 -0
  27. package/bin/lean/testmap.mjs +57 -3
  28. package/bin/lean/testmap.test.mjs +53 -1
  29. package/bin/lean/verify.mjs +7 -2
  30. package/bin/lean/verify.test.mjs +8 -2
  31. package/commands/deploy-pr.md +5 -4
  32. package/commands/orchestrate.md +7 -4
  33. package/commands/review-pr-staging.md +6 -1
  34. package/docs/CONFIG.md +8 -1
  35. package/docs/OUTCOME-CODES.md +6 -4
  36. package/docs/RELEASE-NOTES.md +10 -0
  37. package/package.json +1 -1
  38. package/scripts/acceptance-red-green.sh +13 -4
  39. package/scripts/acceptance-red-green.test.sh +14 -0
  40. package/scripts/code-index.sh +33 -33
  41. package/scripts/code-index.test.sh +21 -2
  42. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
  43. package/scripts/repo-facts.test.sh +5 -5
  44. package/scripts/review-prepare.sh +28 -6
  45. package/scripts/review-prepare.test.sh +22 -2
  46. package/scripts/test-map.sh +186 -34
  47. package/scripts/test-map.test.sh +250 -7
  48. package/scripts/verify-phase-trail.test.sh +44 -44
  49. package/scripts/wait-ci-green.sh +1 -1
  50. package/scripts/wait-ci-green.test.sh +4 -0
@@ -0,0 +1,112 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Fixture: a drizzle-style migrations dir (NNNN_name.sql + meta/_journal.json + meta/NNNN_snapshot.json) where the base
4
+ // gains 0007 while the lane adds its own 0007 (#4322).
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdirSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { tmpDir } from "../tests/_tmp.mjs";
11
+ import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber } from "./sequence.mjs";
12
+ import { sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
13
+
14
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
15
+ const gitIn = (cwd) => async (a) => sh(cwd, ...a);
16
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
17
+ const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
18
+ const journal = (...tags) => `${JSON.stringify({ version: "7", entries: tags.map((tag, idx) => ({ idx, tag, when: 1000 + idx })) }, null, 2)}\n`;
19
+
20
+ const USAGE_SQL = "CREATE TABLE usage (id int);\n";
21
+ /** main: 0006 init; lane (from main): 0007_usage; then main gains 0007_billing_reconcile. */
22
+ function fixture() {
23
+ const d = tmpDir("seq-");
24
+ sh(d, "init", "-q", "-b", "main");
25
+ put(d, { "db/drizzle/0006_init.sql": "CREATE TABLE t (id int);\n", "db/drizzle/meta/_journal.json": journal("0006_init"), "db/drizzle/meta/0006_snapshot.json": '{"id":"s6"}\n' });
26
+ commit(d, "init");
27
+ sh(d, "checkout", "-qb", "lane");
28
+ put(d, { "db/drizzle/0007_usage.sql": USAGE_SQL, "db/drizzle/meta/_journal.json": journal("0006_init", "0007_usage"), "db/drizzle/meta/0007_snapshot.json": '{"id":"lane7","prevId":"s6"}\n' });
29
+ commit(d, "lane migration");
30
+ sh(d, "checkout", "-q", "main");
31
+ put(d, { "db/drizzle/0007_billing_reconcile.sql": "CREATE TABLE billing (id int);\n", "db/drizzle/meta/_journal.json": journal("0006_init", "0007_billing_reconcile"), "db/drizzle/meta/0007_snapshot.json": '{"id":"s7","prevId":"s6"}\n' });
32
+ commit(d, "base migration");
33
+ sh(d, "checkout", "-q", "lane");
34
+ return d;
35
+ }
36
+
37
+ /** What the builder does when asked: merge the base, keep the base's journal/snapshot, rename the lane's SQL, regenerate. */
38
+ function renumber(d, { sql = null, tags = ["0006_init", "0007_billing_reconcile", "0008_usage"] } = {}) {
39
+ try { sh(d, "merge", "--no-edit", "main"); } catch { /* journal and snapshot conflict: resolved below */ }
40
+ sh(d, "checkout", "--theirs", "--", "db/drizzle/meta/_journal.json", "db/drizzle/meta/0007_snapshot.json");
41
+ sh(d, "mv", "db/drizzle/0007_usage.sql", "db/drizzle/0008_usage.sql");
42
+ if (sql) put(d, { "db/drizzle/0008_usage.sql": sql });
43
+ put(d, { "db/drizzle/meta/_journal.json": journal(...tags), "db/drizzle/meta/0008_snapshot.json": '{"id":"lane8","prevId":"s7"}\n' });
44
+ commit(d, "renumber");
45
+ }
46
+
47
+ describe("sequence-numbered migrations at hand-off (#4322)", () => {
48
+ it("detects the lane's 0007 against the base's 0007 (other name, and same snapshot path with other content) and proposes 0008", async () => {
49
+ const d = fixture();
50
+ const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
51
+ assert.deepEqual(c.map((x) => [x.lane, x.base, x.next]), [
52
+ ["db/drizzle/0007_usage.sql", ["db/drizzle/0007_billing_reconcile.sql"], "0008"],
53
+ ["db/drizzle/meta/0007_snapshot.json", ["db/drizzle/meta/0007_snapshot.json"], "0008"],
54
+ ]);
55
+ const p = sequencePrompt(c, "main");
56
+ assert.match(p, /git merge --no-edit origin\/main/);
57
+ assert.match(p, /db\/drizzle\/0007_usage\.sql -> number 0008 \(base has db\/drizzle\/0007_billing_reconcile\.sql\)/);
58
+ assert.match(p, /byte-identical[\s\S]*regenerate the snapshot and journal with the repo's own migration generator \(never hand- or text-merge the journal\)[\s\S]*strictly ordered/);
59
+ });
60
+
61
+ it("a correct renumber verifies: SQL unchanged, snapshot regenerated, journal strictly ordered, no collision left", async () => {
62
+ const d = fixture();
63
+ const before = await snapshotLane({ git: gitIn(d), base: "main" });
64
+ assert.deepEqual(before.map((f) => f.path), ["db/drizzle/0007_usage.sql", "db/drizzle/meta/0007_snapshot.json"]);
65
+ renumber(d);
66
+ assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
67
+ assert.deepEqual(await verifyRenumber({ git: gitIn(d), base: "main", before }), { ok: true, problems: [] });
68
+ });
69
+
70
+ it("an edited SQL body, an unordered journal, or no renumber at all fails verification", async () => {
71
+ let d = fixture(); let before = await snapshotLane({ git: gitIn(d), base: "main" });
72
+ renumber(d, { sql: "CREATE TABLE usage (id bigint);\n" });
73
+ assert.match((await verifyRenumber({ git: gitIn(d), base: "main", before })).problems.join("\n"), /0007_usage\.sql: content changed/);
74
+
75
+ d = fixture(); before = await snapshotLane({ git: gitIn(d), base: "main" });
76
+ renumber(d, { tags: ["0006_init", "0008_usage", "0007_billing_reconcile"] });
77
+ const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
78
+ assert.equal(v.ok, false);
79
+ assert.match(v.problems.join("\n"), /_journal\.json: 0007_billing_reconcile not numbered after 0008_usage/);
80
+
81
+ d = fixture(); before = await snapshotLane({ git: gitIn(d), base: "main" });
82
+ const left = await verifyRenumber({ git: gitIn(d), base: "main", before });
83
+ assert.match(left.problems.join("\n"), /0007_usage\.sql still shares number 0007 with db\/drizzle\/0007_billing_reconcile\.sql/);
84
+ });
85
+
86
+ it("a text-clean base merge still collides (no journal: e.g. app/migrations/0007_x.py), and later lane files move to keep the order", async () => {
87
+ const d = tmpDir("seq-");
88
+ sh(d, "init", "-q", "-b", "main");
89
+ put(d, { "app/migrations/0006_init.py": "x\n" }); commit(d, "init");
90
+ sh(d, "checkout", "-qb", "lane");
91
+ put(d, { "app/migrations/0007_a.py": "a\n", "app/migrations/0008_b.py": "b\n" }); commit(d, "lane");
92
+ sh(d, "checkout", "-q", "main");
93
+ put(d, { "app/migrations/0007_c.py": "c\n" }); commit(d, "base");
94
+ sh(d, "checkout", "-q", "lane");
95
+ sh(d, "merge", "--no-edit", "main");
96
+ const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
97
+ assert.deepEqual(c.map((x) => [x.lane, x.base.length, x.next]), [["app/migrations/0007_a.py", 1, "0008"], ["app/migrations/0008_b.py", 0, "0009"]]);
98
+ assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["app/migrations"] }), []);
99
+ });
100
+
101
+ it("regression guard: no numbered file added, or numbers above the base's, is no collision; a broken git never throws", async () => {
102
+ const d = fixture();
103
+ sh(d, "reset", "-q", "--hard", "main");
104
+ put(d, { "src/a.ts": "x\n", "db/drizzle/0008_new.sql": "y\n" }); commit(d, "lane2");
105
+ assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
106
+ const bad = async () => { throw new Error("boom"); };
107
+ assert.deepEqual(await sequenceCollisions({ git: bad, base: "main" }), []);
108
+ assert.equal(sequenceSyncNote(["src/a.ts", "package.json"]), "");
109
+ assert.match(sequenceSyncNote(["db/drizzle/meta/_journal.json"]), /do not text-merge/);
110
+ assert.match(sequenceSyncNote(["db/drizzle/0007_usage.sql"]), /Renumber the lane's files/);
111
+ });
112
+ });
@@ -6,7 +6,8 @@
6
6
  * command) picks the checks for the lane's changed files; `.forge/repo-facts.json` (scripts/repo-facts.sh) is cited to
7
7
  * the builder by path. The test map's commands are run, so it is read from the base ref, then the main checkout, never
8
8
  * the builder-writable lane worktree (#4297); repo facts are looked up in the worktree, then the main checkout. A missing
9
- * or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts.
9
+ * or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts. With a map, a learned
10
+ * command the map covers runs only when its globs match a changed file (`scopeChecks`, #4331).
10
11
  *
11
12
  * node bin/lean/testmap.mjs --map FILE --select FILE... print the selected commands, one per line
12
13
  */
@@ -52,13 +53,66 @@ export function selectCommands(map, files) {
52
53
  for (const e of map?.entries ?? []) {
53
54
  const res = e.globs.map((g) => globToRegex(g.replace(/^\.\//, "")));
54
55
  if (!changed.some((f) => res.some((re) => re.test(f)))) continue;
55
- const cwd = typeof e.cwd === "string" && e.cwd && e.cwd !== "." ? e.cwd : "";
56
- const cmd = cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
56
+ const cmd = entryCommand(e);
57
57
  if (!out.includes(cmd)) out.push(cmd);
58
58
  }
59
59
  return out;
60
60
  }
61
61
 
62
+ /**
63
+ * #4318: the dedupe key of a check command: whitespace collapsed, a leading `cd . && ` dropped, `<pm> run test` read as
64
+ * `<pm> test` (npm/pnpm/yarn/bun) and an `npx`/`bunx [--yes|-y]` prefix dropped, so one suite under two spellings runs once.
65
+ */
66
+ export function checkKey(c) {
67
+ let k = String(c ?? "").trim().replace(/\s+/g, " ");
68
+ k = k.replace(/^cd \.\/? && /, "");
69
+ const cd = /^((?:cd|pushd) \S+ && )?(.*)$/.exec(k), pre = cd[1] ?? "";
70
+ const cmd = cd[2].replace(/^(?:npx|bunx)(?: (?:--yes|-y))? /, "").replace(/^(npm|pnpm|yarn|bun) run test(?= |$)/, "$1 test");
71
+ return pre + cmd;
72
+ }
73
+
74
+ /**
75
+ * #4315: the pre-PR checks are the learned commands (in order) plus each map-selected command they lack; compared
76
+ * by `checkKey` (#4318), so the map can only add checks, never remove one; the first spelling seen is kept. `source`:
77
+ * "learned" when the map added nothing, "test-map" when there were no learned commands, else "test-map+learned".
78
+ * @returns {{commands:string[], source:string, added:number}}
79
+ */
80
+ export function mergeChecks(selected, learned) {
81
+ const norm = checkKey;
82
+ const commands = [], seen = new Set();
83
+ const add = (c) => { const t = String(c ?? "").trim(), k = norm(t); if (!k || seen.has(k)) return false; seen.add(k); commands.push(t); return true; };
84
+ for (const c of Array.isArray(learned) ? learned : []) add(c);
85
+ const base = commands.length;
86
+ let added = 0;
87
+ for (const c of Array.isArray(selected) ? selected : []) if (add(c)) added++;
88
+ return { commands, added, source: !added ? "learned" : base ? "test-map+learned" : "test-map" };
89
+ }
90
+
91
+ /**
92
+ * #4331: change-scoped pre-PR checks. A learned command the map covers (an entry's command, with its `cd <cwd> && `
93
+ * prefix, has the same `checkKey`) runs only when one of those entries has a glob matching a changed file; a learned
94
+ * command with no map entry always runs (the safe default); map-selected commands the learned list lacks are added
95
+ * as in `mergeChecks`. No map: the learned list unchanged. `source` is "learned" when the map added and dropped
96
+ * nothing, "test-map" when there were no learned commands, else "test-map+learned".
97
+ * @returns {{commands:string[], source:string, added:number, dropped:string[]}}
98
+ */
99
+ export function scopeChecks(map, files, learned) {
100
+ const list = Array.isArray(learned) ? learned : [];
101
+ if (!map) { const m = mergeChecks([], list); return { ...m, dropped: [] }; }
102
+ const selected = selectCommands(map, files), hit = new Set(selected.map(checkKey));
103
+ const mapped = new Set((map.entries ?? []).map((e) => checkKey(entryCommand(e))));
104
+ const dropped = [], kept = [];
105
+ for (const c of list) { const k = checkKey(c); if (!k) continue; (mapped.has(k) && !hit.has(k) ? dropped : kept).push(String(c).trim()); }
106
+ const m = mergeChecks(selected, kept);
107
+ return { commands: m.commands, added: m.added, dropped, source: !m.added && !dropped.length ? "learned" : kept.length || dropped.length ? "test-map+learned" : "test-map" };
108
+ }
109
+
110
+ /** An entry's command as `selectCommands` emits it (`cd <cwd> && cmd` for a non-root cwd). */
111
+ function entryCommand(e) {
112
+ const cwd = typeof e.cwd === "string" && e.cwd && e.cwd !== "." ? e.cwd : "";
113
+ return cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
114
+ }
115
+
62
116
  /** The path of the first repo-facts file under `dirs` that exists, or null; never throws. */
63
117
  export async function factsPath(dirs, exists) {
64
118
  if (typeof exists !== "function") return null;
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { parseTestMap, readTestMap, selectCommands, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
5
+ import { parseTestMap, readTestMap, selectCommands, mergeChecks, scopeChecks, checkKey, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
6
6
 
7
7
  const MAP = {
8
8
  version: 1,
@@ -56,3 +56,55 @@ describe("test map (#4272)", () => {
56
56
  assert.equal(await factsPath(["/wt"], async () => { throw new Error("EACCES"); }), null);
57
57
  });
58
58
  });
59
+
60
+ describe("mergeChecks (#4315): the map can only add checks", () => {
61
+ it("a partial map keeps every learned command and adds the ones it selects that learned lacks", () => {
62
+ assert.deepEqual(mergeChecks(["npm test", "cd pkg && npm test"], ["make lint", "npm test"]),
63
+ { commands: ["make lint", "npm test", "cd pkg && npm test"], added: 1, source: "test-map+learned" });
64
+ });
65
+ it("duplicates, whitespace variants included, appear once", () => {
66
+ assert.deepEqual(mergeChecks(["npm run lint", " npm run lint", "npm test"], ["npm run lint ", "npm run lint"]).commands, ["npm run lint", "npm test"]);
67
+ });
68
+ it("an empty selection keeps the learned commands; no learned commands keep the map's selection", () => {
69
+ assert.deepEqual(mergeChecks([], ["make lint"]), { commands: ["make lint"], added: 0, source: "learned" });
70
+ assert.deepEqual(mergeChecks(["npm test"], []), { commands: ["npm test"], added: 1, source: "test-map" });
71
+ assert.deepEqual(mergeChecks(undefined, undefined), { commands: [], added: 0, source: "learned" });
72
+ });
73
+ });
74
+
75
+ describe("mergeChecks (#4318): one suite under two spellings runs once", () => {
76
+ it("collapses <pm> run test / <pm> test, npx [--yes] X / X and cd . && X / X, keeping the first spelling", () => {
77
+ assert.deepEqual(mergeChecks(["pnpm test", "vitest", "make lint"], ["pnpm run test", "npx --yes vitest", "cd . && make lint"]),
78
+ { commands: ["pnpm run test", "npx --yes vitest", "cd . && make lint"], added: 0, source: "learned" });
79
+ assert.deepEqual(mergeChecks(["cd pkg && npx vitest run", "cd other && vitest run"], ["cd pkg && vitest run"]).commands, ["cd pkg && vitest run", "cd other && vitest run"]);
80
+ });
81
+ it("checkKey keeps different suites apart", () => {
82
+ assert.notEqual(checkKey("npm run test:unit"), checkKey("npm test"));
83
+ assert.notEqual(checkKey("cd a && npm test"), checkKey("npm test"));
84
+ assert.equal(checkKey("bunx -y jest"), "jest");
85
+ });
86
+ });
87
+
88
+ describe("scopeChecks (#4331): a mapped learned command runs only when its globs match", () => {
89
+ const map = { version: 1, entries: [
90
+ { globs: ["commands/**"], command: "bash scripts/check-a.sh", cwd: "." },
91
+ { globs: ["bin/**"], command: "bash scripts/check-b.sh", cwd: "." },
92
+ { globs: ["src/**"], command: "npm test", cwd: "pkg" },
93
+ { globs: ["**/*.py"], command: "uv run pytest", cwd: "." }] };
94
+ const learned = ["bash scripts/check-a.sh", "bash scripts/check-b.sh", "make lint"];
95
+ it("a lane changing only commands/foo.md runs the matching mapped commands plus the unmapped ones", () => {
96
+ assert.deepEqual(scopeChecks(map, ["commands/foo.md"], learned),
97
+ { commands: ["bash scripts/check-a.sh", "make lint"], added: 0, dropped: ["bash scripts/check-b.sh"], source: "test-map+learned" });
98
+ });
99
+ it("no map keeps the learned list; nothing added or dropped is source learned", () => {
100
+ assert.deepEqual(scopeChecks(null, ["commands/foo.md"], learned), { commands: learned, added: 0, dropped: [], source: "learned" });
101
+ assert.deepEqual(scopeChecks(map, ["commands/foo.md"], ["bash scripts/check-a.sh", "make lint"]).source, "learned");
102
+ });
103
+ it("matches by checkKey and by the entry's cwd prefix; map-selected commands are added", () => {
104
+ const r = scopeChecks(map, ["src/x.js", "a.py"], ["cd . && bash scripts/check-a.sh", "cd pkg && npm run test", "npm test"]);
105
+ assert.deepEqual(r.dropped, ["cd . && bash scripts/check-a.sh"]);
106
+ assert.deepEqual(r.commands, ["cd pkg && npm run test", "npm test", "uv run pytest"]);
107
+ assert.equal(r.source, "test-map+learned");
108
+ assert.deepEqual(scopeChecks(map, ["a.py"], []), { commands: ["uv run pytest"], added: 1, dropped: [], source: "test-map" });
109
+ });
110
+ });
@@ -13,6 +13,9 @@ export const DEFAULT_SKIP_PATHS = Object.freeze(["*.md", "*.mdx", "*.rst", "*.tx
13
13
  export const DEFAULT_MAX_ROUNDS = 2;
14
14
  /** Per-command timeout of the pre-PR declared check commands (#4240, `verify.precheck_timeout_seconds`). */
15
15
  export const DEFAULT_PRECHECK_TIMEOUT_SECONDS = 300;
16
+ /** Total budget of one round of pre-PR check commands (#4318, `verify.precheck_total_seconds`; 10 min since #4331, and
17
+ * forge.yaml `lean.precheck.max_minutes` overrides it). The rest of the round is deferred to CI. */
18
+ export const DEFAULT_PRECHECK_TOTAL_SECONDS = 600;
16
19
  /** Total characters of failed-job log handed to the builder. */
17
20
  export const LOG_CAP = 12000;
18
21
  /** No run or check for the head after this long means CI is unavailable (local fallback). */
@@ -26,7 +29,7 @@ const OK = ["success", "neutral", "skipped"];
26
29
 
27
30
  /**
28
31
  * forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
29
- * maxRounds, precheckTimeoutSeconds, checkRun}. Invalid values fall back to the defaults; mode defaults to local (today's
32
+ * maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun}. Invalid values fall back to the defaults; mode defaults to local (today's
30
33
  * behaviour); checkRun (`check_run`, #4270) is true only for the literal `true`.
31
34
  */
32
35
  export function parseVerify(sec = {}) {
@@ -41,8 +44,10 @@ export function parseVerify(sec = {}) {
41
44
  const maxRounds = str(s.max_rounds) !== "" && Number.isInteger(n) && n >= 0 && n <= 10 ? n : DEFAULT_MAX_ROUNDS;
42
45
  const t = Number(str(s.precheck_timeout_seconds));
43
46
  const precheckTimeoutSeconds = str(s.precheck_timeout_seconds) !== "" && Number.isInteger(t) && t >= 1 && t <= 3600 ? t : DEFAULT_PRECHECK_TIMEOUT_SECONDS;
47
+ const tt = Number(str(s.precheck_total_seconds));
48
+ const precheckTotalSeconds = str(s.precheck_total_seconds) !== "" && Number.isInteger(tt) && tt >= 1 && tt <= 7200 ? tt : DEFAULT_PRECHECK_TOTAL_SECONDS;
44
49
  const checkRun = str(s.check_run).toLowerCase() === "true";
45
- return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, checkRun };
50
+ return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun };
46
51
  }
47
52
 
48
53
  const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
@@ -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);
@@ -357,8 +357,9 @@ else
357
357
  # Check current CI status
358
358
  # Fail closed: valid fields only (`status`/`conclusion` do not exist on gh pr checks --json; the
359
359
  # old query errored and defaulted to 0 = "no failures"). An unreadable result counts as failing.
360
+ # forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
360
361
  CURRENT_CI_STATUS=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
361
- --jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
362
+ --jq '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
362
363
 
363
364
  if [ "$CURRENT_CI_STATUS" -eq 0 ]; then
364
365
  echo "CI gate: PASSED (no failing checks)"
@@ -378,9 +379,9 @@ else
378
379
 
379
380
  CI_FIXES=$((CI_FIXES + 1))
380
381
 
381
- # Re-check CI status after fix attempt
382
- FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,status,conclusion \
383
- --jq '[.[] | select(.conclusion == "failure")] | length' 2>/dev/null || echo "0")
382
+ # Re-check CI status after fix attempt (same fail-closed query as above)
383
+ FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
384
+ --jq '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
384
385
 
385
386
  if [ "$FAILING_AFTER" -eq 0 ]; then
386
387
  echo "CI gate: PASSED after $CI_ITER iteration(s)"
@@ -38,7 +38,8 @@ Do this yourself from issue text and the code. Spawn no sub-agents here.
38
38
 
39
39
  It closes, with the evidence on the issue and no lane, every issue that a merged PR already fixed (one
40
40
  short read-only validation on a small model) or an auto-filed CI failure whose workflow is green again
41
- (the green run link). It checks K issues at a time. Its last line is `lanes: #A #B ...`: continue with only
41
+ (the green run link), unless it is flaky (wording or `flaky` label), was reopened after a pre-filter close, or
42
+ the workflow failed and passed at the same SHA. It checks K issues at a time. Its last line is `lanes: #A #B ...`: continue with only
42
43
  those issues. This is the only pre-filter in this command: lanes run `forgedock run-issue`, which does not
43
44
  pre-filter again. Keep its
44
45
  `pre-filter:` line for the report.
@@ -140,16 +141,18 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
140
141
  wait on the sub-issues.
141
142
  - `merged` or `invalid`: start the next ready issue.
142
143
  - `needs-human` with a **recoverable code**: `ci.red`, `base.conflict` (merge conflict), `ci-pending` on a conflicting
143
- 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).
144
147
  These are mechanical. **Do not ask for confirmation on them.** The batch runner recovers each one itself
145
148
  (`orchestration.recovery.max_attempts`, default 1 per issue). It launches one recovery lane under the same lease, lane
146
149
  and admission rules as any lane, with `run-issue --recover <code> [--recover-attempt n]` (core path only:
147
- `--path full` rejects `--recover`). That lane:
150
+ `--path full` rejects `--recover`; recovery is off when `engine.path: full`). That lane:
148
151
  - removes `needs-human`;
149
152
  - resumes the open PR: merges the base, fixes the failed required checks from their logs, and pushes new commits only;
150
153
  - re-queues the issue, keeping its dependents queued in the same batch.
151
154
 
152
- Report what was recovered afterwards. A failed recovery stays `needs-human` with its evidence, exactly once (failed = `needs-human`, `ci-pending`, or a final `engine-error`/`deferred`; `invalid`, `decomposed` and `dependency-*` are not failures and never re-label), and there
155
+ Report what was recovered afterwards. A failed recovery stays `needs-human` with its evidence, re-labelled exactly once per issue, by the final attempt only (failed = `needs-human`, `ci-pending`, or a final `engine-error`/`deferred`; `invalid`, `decomposed` and `dependency-*` are not failures and never re-label). With `max_attempts > 1` a non-final attempt adds no label or comment: the runner either launches the next attempt or, when none follows, re-labels it once itself, so the label does not flap between attempts. There
153
156
  is no second attempt beyond `max_attempts`.
154
157
  - `needs-human` with a **human code** (`builder.blocked`: product-decision, spend, credentials, promotion, customer): an
155
158
  external action agents cannot take, or a product decision too critical for agents. It is never auto-recovered. Read
@@ -660,8 +660,13 @@ git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH | grep -iE "(api[_-]?ke
660
660
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
661
661
  . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
662
662
  gh pr checks "$PR_NUMBER" -R "$GH_REPO" 2>&1
663
+ # forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
664
+ # Fail closed: an unreadable result counts as failing.
665
+ CI_FAILING=$(gh pr checks "$PR_NUMBER" -R "$GH_REPO" --json name,bucket \
666
+ --jq '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
667
+ echo "CI_FAILING: $CI_FAILING"
663
668
  ```
664
- Any CI failure → BLOCK DEPLOY (unless autofixed in Phase 1F).
669
+ `CI_FAILING` > 0 → BLOCK DEPLOY (unless autofixed in Phase 1F). A red `forgedock/verify` alone does not block.
665
670
 
666
671
  ### Phase 1F: CI Autofix
667
672
 
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`; 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.0",
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 ]