forgedock 1.45.0 → 1.47.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 (74) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -1
  2. package/bin/engine-cli.mjs +22 -8
  3. package/bin/lean/conformance.e2e.test.mjs +20 -0
  4. package/bin/lean/conformance.mjs +99 -9
  5. package/bin/lean/conformance.test.mjs +121 -4
  6. package/bin/lean/context.mjs +108 -6
  7. package/bin/lean/context.test.mjs +84 -2
  8. package/bin/lean/coupled.mjs +38 -0
  9. package/bin/lean/coupled.test.mjs +57 -0
  10. package/bin/lean/decision-engine.mjs +17 -2
  11. package/bin/lean/decision-engine.test.mjs +21 -0
  12. package/bin/lean/drift.mjs +3 -2
  13. package/bin/lean/drift.test.mjs +23 -1
  14. package/bin/lean/effects.mjs +3 -2
  15. package/bin/lean/execution.mjs +41 -8
  16. package/bin/lean/execution.test.mjs +72 -2
  17. package/bin/lean/integration.mjs +2 -2
  18. package/bin/lean/integration.test.mjs +22 -1
  19. package/bin/lean/issuewatch-decisions.mjs +175 -0
  20. package/bin/lean/issuewatch-decisions.test.mjs +353 -0
  21. package/bin/lean/issuewatch-release.test.mjs +3 -1
  22. package/bin/lean/issuewatch.mjs +67 -10
  23. package/bin/lean/issuewatch.test.mjs +18 -0
  24. package/bin/lean/lane.mjs +13 -0
  25. package/bin/lean/lane.test.mjs +28 -1
  26. package/bin/lean/lean.mjs +64 -17
  27. package/bin/lean/lean.test.mjs +95 -0
  28. package/bin/lean/limits.mjs +6 -2
  29. package/bin/lean/limits.test.mjs +86 -0
  30. package/bin/lean/milestonesync.mjs +2 -2
  31. package/bin/lean/milestonesync.test.mjs +22 -0
  32. package/bin/lean/orchestrate.mjs +79 -9
  33. package/bin/lean/probe.mjs +4 -2
  34. package/bin/lean/probe.test.mjs +4 -0
  35. package/bin/lean/prompts.mjs +18 -11
  36. package/bin/lean/rotator.mjs +96 -0
  37. package/bin/lean/rotator.test.mjs +143 -0
  38. package/bin/lean/routecheck.mjs +48 -0
  39. package/bin/lean/routecheck.test.mjs +69 -0
  40. package/bin/lean/salvage-resume.mjs +26 -0
  41. package/bin/lean/salvage-resume.test.mjs +51 -0
  42. package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
  43. package/bin/lean/sandbox-remote.mjs +4 -2
  44. package/bin/lean/verify.mjs +2 -1
  45. package/bin/review-staging.mjs +20 -3
  46. package/commands/deploy-info.md +4 -4
  47. package/commands/issue.md +6 -1
  48. package/commands/review-pr-agents/frontend.md +1 -0
  49. package/commands/review-pr-agents/infra.md +2 -0
  50. package/commands/review-pr-staging.md +2 -0
  51. package/commands/work-on/build/context.md +7 -1
  52. package/commands/work-on/investigate.md +15 -3
  53. package/docs/CONFIG.md +22 -3
  54. package/docs/DECISIONS.md +18 -7
  55. package/docs/OUTCOME-CODES.md +2 -1
  56. package/docs/RELEASE-NOTES.md +1 -0
  57. package/package.json +1 -1
  58. package/scripts/file-deferred-followups.sh +125 -28
  59. package/scripts/file-deferred-followups.test.sh +49 -0
  60. package/scripts/issue-create.sh +7 -0
  61. package/scripts/issue-create.test.sh +13 -0
  62. package/scripts/issue-dedup.sh +5 -4
  63. package/scripts/issue-dedup.test.sh +15 -0
  64. package/scripts/owner-route-parity.test.sh +32 -0
  65. package/scripts/prior-attempts.mjs +72 -0
  66. package/scripts/prior-attempts.test.mjs +52 -0
  67. package/scripts/review-finalize.sh +35 -4
  68. package/scripts/review-finalize.test.sh +10 -0
  69. package/scripts/review-prepare.sh +16 -4
  70. package/scripts/review-prepare.test.sh +8 -0
  71. package/scripts/review-roster.sh +3 -1
  72. package/scripts/review-roster.test.sh +30 -2
  73. package/scripts/review-staging-pre.sh +8 -6
  74. package/scripts/review-staging-pre.test.sh +11 -1
@@ -0,0 +1,48 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #5087: route reachability. A fix that adds an HTTP route handler can pass every test while the production ingress sends
5
+ * the path to another service. forge.yaml `topology.route_reachability` is a repo-supplied command that takes the added
6
+ * route paths as arguments and exits non-zero when the production ingress does not deliver them to the service that
7
+ * implements them. Core stays repo-agnostic (#1347): it only derives the paths and runs the command. Absent key = no check.
8
+ */
9
+ import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
10
+
11
+ const APP_ROUTE_RE = /(?:^|\/)app\/(?:(.*)\/)?route\.(?:[cm]?[jt]sx?)$/;
12
+ const PAGES_API_RE = /(?:^|\/)pages\/(api(?:\/.*)?)\.(?:[cm]?[jt]sx?)$/;
13
+
14
+ /** URL path of one Next.js route handler file (app router `route.ts`, pages router `pages/api`), or null. Route groups `(x)` and slots `@x` are not URL segments. */
15
+ export function routePathOf(file) {
16
+ const f = String(file ?? "").replace(/\\/g, "/");
17
+ const app = APP_ROUTE_RE.exec(f);
18
+ if (app) return `/${(app[1] ?? "").split("/").filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith("@")).join("/")}`;
19
+ const pg = PAGES_API_RE.exec(f);
20
+ if (pg) return `/${pg[1].replace(/\/index$/, "")}`;
21
+ return null;
22
+ }
23
+
24
+ /** Sorted unique URL paths of the route-handler files in `files`. */
25
+ export function routePathsFromFiles(files) {
26
+ return [...new Set((files ?? []).map(routePathOf).filter(Boolean))].sort();
27
+ }
28
+
29
+ const sq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
30
+
31
+ /**
32
+ * Blocking failures (conformance.unimplemented, kind route_unreachable) when the declared command exits non-zero for the
33
+ * added route paths. [] when no command is declared or no route was added. A command that cannot start (rc 127) is
34
+ * reported too: a declared blocking check that never ran is no evidence. `run` is ({command}) => {rc, output}
35
+ * (a `{r: {rc, output}}` wrapper is unwrapped).
36
+ */
37
+ export async function routeReachabilityFailures({ command, paths, run }) {
38
+ const cmd = typeof command === "string" ? command.trim() : "";
39
+ if (!cmd || !paths?.length) return [];
40
+ let r;
41
+ try { r = await run({ command: `${cmd} ${paths.map(sq).join(" ")}` }); r = r?.r ?? r; } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
42
+ if (r?.rc === 0) return [];
43
+ const tail = String(r?.output ?? "").trim().split("\n").slice(-12).join("\n").slice(0, 1500);
44
+ return [{
45
+ code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, kind: "route_unreachable",
46
+ detail: `forge.yaml topology.route_reachability (\`${cmd}\`) exited ${r?.rc ?? "?"} for the new route(s) ${paths.join(", ")}: the production ingress does not deliver them to the service that implements them; fix the route or the ingress, citing the ingress rule${tail ? `:\n${tail}` : ""}`,
47
+ }];
48
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { routePathOf, routePathsFromFiles, routeReachabilityFailures } from "./routecheck.mjs";
7
+ import { readEnvConfig } from "../engine-cli.mjs";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+
10
+ // Stand-in for AlterLab's scripts/generate-traefik-config.sh: the api-router sends /api/v1/* to FastAPI;
11
+ // only /api/v1/batch and /api/v1/b2b/jobs go to web (#5087).
12
+ const dir = tmpDir("routecheck-");
13
+ const ingress = join(dir, "check-ingress.sh");
14
+ writeFileSync(ingress, `#!/usr/bin/env bash
15
+ rc=0
16
+ for p in "$@"; do
17
+ case "$p" in
18
+ /api/v1/batch|/api/v1/batch/*|/api/v1/b2b/jobs|/api/v1/b2b/jobs/*) ;;
19
+ /api/v1/*) echo "unreachable: $p is routed to api (FastAPI) by api-router, not web" >&2; rc=1 ;;
20
+ esac
21
+ done
22
+ exit $rc
23
+ `);
24
+ const run = async ({ command }) => { const r = spawnSync("bash", ["-c", command], { encoding: "utf8" }); return { rc: r.status, output: `${r.stdout}${r.stderr}` }; };
25
+ const SIGNUP = ["web/src/app/(site)/api/v1/agent/signup/pending/route.ts", "web/src/app/(site)/api/v1/agent/signup/by-code/[code]/route.ts"];
26
+
27
+ describe("#5087 route reachability", () => {
28
+ it("maps Next.js handler files to URL paths", () => {
29
+ assert.equal(routePathOf(SIGNUP[0]), "/api/v1/agent/signup/pending");
30
+ assert.equal(routePathOf(SIGNUP[1]), "/api/v1/agent/signup/by-code/[code]");
31
+ assert.equal(routePathOf("web/src/pages/api/health/index.ts"), "/api/health");
32
+ assert.equal(routePathOf("web/src/app/route.ts"), "/");
33
+ assert.equal(routePathOf("web/src/app/page.tsx"), null);
34
+ assert.equal(routePathOf("bin/lean/routecheck.mjs"), null);
35
+ assert.deepEqual(routePathsFromFiles([...SIGNUP, SIGNUP[0], "README.md"]), ["/api/v1/agent/signup/by-code/[code]", "/api/v1/agent/signup/pending"]);
36
+ });
37
+ it("fixture ingress routes /api/v1/agent/signup to FastAPI and fails the check", async () => {
38
+ const f = await routeReachabilityFailures({ command: `bash ${ingress}`, paths: routePathsFromFiles(SIGNUP), run });
39
+ assert.equal(f.length, 1);
40
+ assert.equal(f[0].code, "conformance.unimplemented");
41
+ assert.equal(f[0].kind, "route_unreachable");
42
+ assert.match(f[0].detail, /signup\/pending/);
43
+ });
44
+ it("passes a route the ingress delivers to web", async () => {
45
+ assert.deepEqual(await routeReachabilityFailures({ command: `bash ${ingress}`, paths: ["/api/v1/batch"], run }), []);
46
+ });
47
+ it("a passing command wrapped as {r: {rc, output}} (lean io.runCommand shape) is not reported as a failure", async () => {
48
+ const wrapped = async (a) => ({ r: await run(a) });
49
+ assert.deepEqual(await routeReachabilityFailures({ command: `bash ${ingress}`, paths: ["/api/v1/batch"], run: wrapped }), []);
50
+ assert.equal((await routeReachabilityFailures({ command: `bash ${ingress}`, paths: ["/api/v1/agent/signup/pending"], run: wrapped })).length, 1);
51
+ });
52
+ it("is a no-op without a declared command or without added routes", async () => {
53
+ const never = async () => { throw new Error("must not run"); };
54
+ assert.deepEqual(await routeReachabilityFailures({ command: undefined, paths: ["/api/v1/x"], run: never }), []);
55
+ assert.deepEqual(await routeReachabilityFailures({ command: "false", paths: [], run: never }), []);
56
+ });
57
+ it("a command that cannot run is reported, not swallowed", async () => {
58
+ const f = await routeReachabilityFailures({ command: "x", paths: ["/a"], run: async () => { throw new Error("boom"); } });
59
+ assert.equal(f[0].kind, "route_unreachable");
60
+ assert.match(f[0].detail, /exited 127/);
61
+ });
62
+ it("forge.yaml topology.route_reachability is read; repos without topology get no key", () => {
63
+ const withKey = tmpDir("rc-yaml-"), without = tmpDir("rc-yaml-");
64
+ writeFileSync(join(withKey, "forge.yaml"), "topology:\n route_reachability: \"bash scripts/check-ingress.sh\"\n");
65
+ writeFileSync(join(without, "forge.yaml"), "project: x/y\n");
66
+ assert.equal(readEnvConfig(withKey).routeReachability, "bash scripts/check-ingress.sh");
67
+ assert.equal("routeReachability" in readEnvConfig(without), false);
68
+ });
69
+ });
@@ -0,0 +1,26 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #5063: pick the salvage ref a rerun resumes from. The DCO and exit rescues push to `forge-salvage/<issue>-<sha10>`
5
+ * (#4958, #5047). The suffix is a sha, so the newest ref is chosen by commit time, not by name. When the lane branch
6
+ * already exists on origin, only a ref strictly ahead of it (descending from origin/<branch>) can be resumed without
7
+ * losing either side; a behind or diverged ref is ignored.
8
+ */
9
+ const ok = async (git, args, cwd) => { try { await git(args, { cwd }); return true; } catch { return false; } };
10
+
11
+ /** @returns {Promise<{ref: string, sha: string} | null>} */
12
+ export async function pickSalvage({ io, cwd, issue, branch, hasBranch }) {
13
+ const out = String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/forge-salvage/${issue}-*`], { cwd }));
14
+ const refs = out.split("\n").map((l) => l.trim().split(/\s+/)).filter((p) => p[1]).map(([sha, ref]) => ({ sha, ref }));
15
+ if (!refs.length) return null;
16
+ if (refs.length > 1 || hasBranch) await io.git(["fetch", "origin", `+refs/heads/forge-salvage/${issue}-*:refs/remotes/origin/forge-salvage/${issue}-*`], { cwd });
17
+ // release #5068 BUG-2/SPEC-1: compare against the live lane branch; a missing or stale local origin/<branch> would ignore a usable salvage or resume one that is behind the remote
18
+ if (hasBranch) await io.git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { cwd });
19
+ for (const r of refs) r.ct = Number(String(await io.git(["log", "-1", "--format=%ct", r.sha], { cwd }).catch(() => "")).trim()) || 0;
20
+ refs.sort((a, b) => b.ct - a.ct || (a.ref < b.ref ? 1 : -1));
21
+ for (const r of refs) {
22
+ if (!hasBranch) return r;
23
+ if (await ok(io.git, ["merge-base", "--is-ancestor", `origin/${branch}`, r.sha], cwd) && !(await ok(io.git, ["merge-base", "--is-ancestor", r.sha, `origin/${branch}`], cwd))) return r;
24
+ }
25
+ return null;
26
+ }
@@ -0,0 +1,51 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { pickSalvage } from "./salvage-resume.mjs";
6
+
7
+ /** refs: [{ sha, name, ct }]; ahead: shas strictly ahead of origin/<branch>; behind: shas that are ancestors of it. */
8
+ const world = ({ refs = [], ahead = [], behind = [] } = {}) => {
9
+ const calls = [];
10
+ const git = async (a) => {
11
+ calls.push(a);
12
+ if (a[0] === "ls-remote") return refs.map((r) => `${r.sha}\trefs/heads/forge-salvage/7-${r.name}`).join("\n");
13
+ if (a[0] === "log") return String(refs.find((r) => r.sha === a[3])?.ct ?? "");
14
+ if (a[0] === "merge-base") {
15
+ if (a[2].startsWith("origin/") ? ahead.includes(a[3]) : behind.includes(a[2])) return "";
16
+ throw new Error("not an ancestor");
17
+ }
18
+ return "";
19
+ };
20
+ return { io: { git }, calls };
21
+ };
22
+ const pick = (w, hasBranch) => pickSalvage({ io: w.io, cwd: ".", issue: 7, branch: "b", hasBranch });
23
+
24
+ describe("pickSalvage (#5063)", () => {
25
+ it("no salvage refs: null", async () => assert.equal(await pick(world(), false), null));
26
+ it("newest by commit time, not by sha suffix", async () => {
27
+ const w = world({ refs: [{ sha: "f", name: "ffff", ct: 10 }, { sha: "a", name: "aaaa", ct: 20 }] });
28
+ assert.equal((await pick(w, false)).ref, "refs/heads/forge-salvage/7-aaaa");
29
+ });
30
+ it("branch exists: a salvage ref strictly ahead of it is picked", async () => {
31
+ const w = world({ refs: [{ sha: "a", name: "aaaa", ct: 20 }], ahead: ["a"] });
32
+ assert.equal((await pick(w, true)).ref, "refs/heads/forge-salvage/7-aaaa");
33
+ });
34
+ it("branch exists: behind or diverged refs are ignored, an older usable one is kept", async () => {
35
+ const w = world({ refs: [{ sha: "n", name: "new", ct: 30 }, { sha: "b", name: "behind", ct: 20 }, { sha: "o", name: "old", ct: 10 }], ahead: ["o"], behind: ["b"] });
36
+ assert.equal((await pick(w, true)).ref, "refs/heads/forge-salvage/7-old");
37
+ assert.equal(await pick(world({ refs: [{ sha: "d", name: "div", ct: 5 }] }), true), null);
38
+ });
39
+ it("branch exists: the lane branch is fetched before the ancestry check (release #5068 BUG-2)", async () => {
40
+ const w = world({ refs: [{ sha: "a", name: "aaaa", ct: 20 }], ahead: ["a"] });
41
+ await pick(w, true);
42
+ const fetchAt = w.calls.findIndex((a) => a[0] === "fetch" && a.includes("+refs/heads/b:refs/remotes/origin/b"));
43
+ const baseAt = w.calls.findIndex((a) => a[0] === "merge-base");
44
+ assert.ok(fetchAt >= 0 && fetchAt < baseAt, JSON.stringify(w.calls));
45
+ });
46
+ it("no lane branch: the lane branch is not fetched", async () => {
47
+ const w = world({ refs: [{ sha: "a", name: "aaaa", ct: 20 }] });
48
+ await pick(w, false);
49
+ assert.ok(!w.calls.some((a) => a[0] === "fetch" && a.includes("+refs/heads/b:refs/remotes/origin/b")));
50
+ });
51
+ });
@@ -189,6 +189,15 @@ describe("remote adapter (#4254)", () => {
189
189
  await assert.rejects(() => q.status(h), /ECONNRESET/); // #4672: a transport error propagates; providerLanes retries it for the unreachable window
190
190
  });
191
191
 
192
+ it("#5095 httpTransport: a 500 is transient (polled with backoff) and a 5xx carries Retry-After; only 429/503 are retryable", async () => {
193
+ const reply5xx = (status, ra) => async () => ({ status, ok: false, text: async () => "", headers: { get: (h) => (h === "retry-after" ? ra : null) } });
194
+ const t = httpTransport({ endpoint: "https://pool.example", fetchFn: reply5xx(500, "7") });
195
+ await assert.rejects(t.launch({ id: "x" }), (e) => e.status === 500 && e.transient === true && e.retryable !== true && e.retryInSeconds === 7);
196
+ await assert.rejects(t.status("x"), (e) => e.transient === true);
197
+ const t504 = httpTransport({ endpoint: "https://pool.example", fetchFn: reply5xx(504, null) });
198
+ await assert.rejects(t504.launch({ id: "x" }), (e) => e.status === 504 && e.transient === true && e.retryable !== true && e.retryInSeconds === undefined);
199
+ });
200
+
192
201
  it("#5053 httpTransport: a 401 on status is transient and names its hop; a launch 401 is not transient", async () => {
193
202
  const reply = (status, body) => ({ status, ok: status < 300, text: async () => JSON.stringify(body), headers: { get: () => null } });
194
203
  const t = httpTransport({ endpoint: "https://pool.example", token: TOKEN, fetchFn: async () => reply(401, { error: "unauthorized", hop: "agent", agent: "a1", reason: "agent auth: router token rejected by agent" }) });
@@ -68,8 +68,10 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
68
68
  e.status = r.status;
69
69
  if (hop) e.hop = hop;
70
70
  if (reason) e.reason = reason;
71
- if ([502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll
72
- if (r.status === 429 || r.status === 503) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
71
+ if ([500, 502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll; #5095: 500 too
72
+ if (r.status === 429 || r.status === 503) e.retryable = true;
73
+ // #5095: Retry-After is honoured on any 429/5xx, so a launch 5xx (sandbox.transient) backs off as the pool asks
74
+ if (r.status === 429 || r.status >= 500) { const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
73
75
  return e;
74
76
  };
75
77
  const p = (id) => `/sandboxes/${encodeURIComponent(id)}`;
@@ -68,7 +68,8 @@ export function parseVerify(sec = {}) {
68
68
  if (ext.length > 1 && cmd) guardCommand[ext] = cmd;
69
69
  }
70
70
  }
71
- return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, flaky, duplicateExports, invariants, ...(Object.keys(guardCommand).length ? { guardCommand } : {}), e2e: parseE2e(s) };
71
+ const precheckBlock = str(s.precheck_block).toLowerCase() === "true"; // #5079: a still-failing declared check stops the lane (ci.red)
72
+ return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, flaky, duplicateExports, invariants, ...(precheckBlock ? { precheckBlock } : {}), ...(Object.keys(guardCommand).length ? { guardCommand } : {}), e2e: parseE2e(s) };
72
73
  }
73
74
 
74
75
  const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
@@ -211,6 +211,19 @@ async function headSha(io, c, pr) {
211
211
  return head;
212
212
  }
213
213
 
214
+ /** A gh write retried on a transient failure (FORGE_GH_RETRY_ATTEMPTS, default 6; FORGE_GH_RETRY_SLEEP seconds, default 2, doubling to 16), as gh_retry in the scripts. */
215
+ async function retryGh(io, args) {
216
+ const max = Number(io.env.FORGE_GH_RETRY_ATTEMPTS) > 0 ? Math.floor(Number(io.env.FORGE_GH_RETRY_ATTEMPTS)) : 6;
217
+ const base = /^\d+$/.test(io.env.FORGE_GH_RETRY_SLEEP ?? "") ? Number(io.env.FORGE_GH_RETRY_SLEEP) : 2;
218
+ for (let n = 1; ; n++) {
219
+ try { return await io.gh(args); } catch (e) {
220
+ if (n >= max) throw e;
221
+ const w = Math.min(base * 2 ** (n - 1), 16);
222
+ if (w > 0) await new Promise((r) => setTimeout(r, w * 1000));
223
+ }
224
+ }
225
+ }
226
+
214
227
  async function main(argv, io) {
215
228
  const a = parseArgs(argv);
216
229
  if (a.mode === "finalize") return finalize(a, io);
@@ -241,7 +254,7 @@ async function main(argv, io) {
241
254
  } catch { /* unreadable: treated as a first pass */ }
242
255
  const priorOpen = priorRoutes > 0 ? await priorFindings(io, c, pr, "open") : [];
243
256
  const priorResolved = priorRoutes > 0 ? await priorFindings(io, c, pr, "closed") : [];
244
- await io.gh(["pr", "comment", String(pr), "-R", c.ghRepo, "--body", `<!-- FORGE:REVIEW_ROUTE mode=staging-deploy spec=review-pr-staging.md sha=${sha7} -->`]);
257
+ await retryGh(io, ["pr", "comment", String(pr), "-R", c.ghRepo, "--body", `<!-- FORGE:REVIEW_ROUTE mode=staging-deploy spec=review-pr-staging.md sha=${sha7} -->`]);
245
258
  io.writeFile(join(sd, `staging_pr_number.${runKey(io.env)}`), `${pr}\n`);
246
259
 
247
260
  // 0A: bundle PR set, then the open review-finding gate
@@ -365,6 +378,10 @@ async function finalize(a, io) {
365
378
  const { c, sd, env, pr, head } = await postContext(a, io);
366
379
  const common = ["--repo", c.ghRepo, "--pr", String(pr), "--sd", sd, "--head", head];
367
380
 
381
+ // The panel reviewed the head recorded at dispatch: a moved head is a refusal to --delta, never a degraded panel (no label, no gate failure).
382
+ const reviewed = readJson(io, join(sd, `review-ctx-${pr}.json`))?.pr?.review_sha;
383
+ if (SHA_RE.test(reviewed ?? "") && reviewed !== head) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: head moved since the review (${reviewed.slice(0, 7)} -> ${head.slice(0, 7)}): run forgedock review-staging ${pr} --delta ${reviewed}`, BLOCK_DEPLOY]);
384
+
368
385
  // Incomplete-panel guard + 6.75 delivery
369
386
  const d = await bash(io, "review-staging-post.sh", ["delivery", ...common], env);
370
387
  relay(io, d);
@@ -424,7 +441,7 @@ async function finalize(a, io) {
424
441
 
425
442
  const findings = readJson(io, join(outDir, "findings.json"))?.findings ?? [];
426
443
  // A finding whose own filed issue is already closed (file-prepare: skip-resolved, delta pass) is resolved: it never blocks (#4569).
427
- const resolvedBy = new Map(worklist.items.filter((i) => i.action === "skip-resolved").map((i) => [i.id, i.dedup_of]));
444
+ const resolvedBy = new Map(worklist.items.filter((i) => i.action === "skip-resolved" || i.action === "skip-fixed-in-delta").map((i) => [i.id, i.dedup_of]));
428
445
  const resolved = findings.filter((f) => resolvedBy.has(f.id) && upper(f.confidence) === "CONFIRMED" && ["CRITICAL", "HIGH"].includes(upper(f.severity)))
429
446
  .map((f) => ({ id: f.id, severity: upper(f.severity), title: f.title ?? "", issue: resolvedBy.get(f.id) }));
430
447
  const confirmed = (sev) => findings.filter((f) => !resolvedBy.has(f.id) && upper(f.confidence) === "CONFIRMED" && upper(f.severity) === sev).length;
@@ -534,7 +551,7 @@ async function summary(a, io) {
534
551
  `- findings_kept: ${n.findings_kept ?? 0}; dropped_stale: ${n.findings_dropped_stale ?? 0}; pre-existing: ${n.findings_preexisting ?? 0}; notes_demoted_no_scenario: ${n.notes_demoted_no_scenario ?? 0}`,
535
552
  `- findings_filed: ${n.findings_filed ?? 0}${filed.length ? ` (${filed.join(", ")})` : ""}; out_of_scope: ${n.out_of_scope ?? 0}`,
536
553
  `- notes_fixed: 0; notes_listed: ${n.notes_listed ?? 0}; notes_dropped: ${n.notes_dropped ?? 0}; notes_capped: ${n.notes_capped ?? 0}; notes_reflag: ${n.notes_reflag ?? 0}`,
537
- `- resolved (excluded from the verdict): ${(st.resolved ?? []).length ? st.resolved.map((r) => `${r.severity} ${r.id} ${r.title} (closed #${r.issue})`.replace(/\s+/g, " ")).join("; ") : "(none)"}`,
554
+ `- resolved (excluded from the verdict): ${(st.resolved ?? []).length ? st.resolved.map((r) => `${r.severity} ${r.id} ${r.title} (${r.issue ? `closed #${r.issue}` : "fixed in delta"})`.replace(/\s+/g, " ")).join("; ") : "(none)"}`,
538
555
  `- deduped: ${deduped.length ? deduped.join("; ") : "(none)"}`,
539
556
  ...(st.delta?.from ? [`- delta pass: ${String(st.delta.from).slice(0, 7)}..${head.slice(0, 7)} (${(st.delta.files ?? []).length} file(s)); earlier pass at ${st.delta.from} credited (its findings re-read by extract)`] : []),
540
557
  "",
@@ -57,13 +57,13 @@ else
57
57
  OPEN_FINDINGS_SUMMARY=""
58
58
  for pr_num in $ALL_PR_NUMBERS; do
59
59
  # Anchored post-filter (same as review-pr-staging.md): the search is a coarse candidate fetch only
60
- FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
60
+ FINDING_PR_FILTER='[.[] | select(([.labels[]?.name] | (index("blocking") != null) or (index("priority:P0") != null) or (index("priority:P1") != null) or (map(select(startswith("priority:"))) | length == 0))) | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
61
61
  OPEN_FINDINGS=$(gh issue list -R {GH_REPO} \
62
62
  --label "review-finding" \
63
63
  --state open \
64
64
  --search "PR #${pr_num}" \
65
65
  --limit 20 \
66
- --json number,title,body 2>/dev/null \
66
+ --json number,title,body,labels 2>/dev/null \
67
67
  | jq -r --arg n "${pr_num}" "$FINDING_PR_FILTER" 2>/dev/null) # uses FINDING_PR_FILTER
68
68
 
69
69
  if [ -n "$OPEN_FINDINGS" ]; then
@@ -307,13 +307,13 @@ if [ -z "$TRAIN_PR_NUMBER" ]; then
307
307
  else
308
308
  # Step 2: Check whether the train is held (has blocking findings)
309
309
  # Anchored post-filter (same as review-pr-staging.md): the search is a coarse candidate fetch only
310
- FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
310
+ FINDING_PR_FILTER='[.[] | select(([.labels[]?.name] | (index("blocking") != null) or (index("priority:P0") != null) or (index("priority:P1") != null) or (map(select(startswith("priority:"))) | length == 0))) | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
311
311
  HELD_BY=$(gh issue list {GH_FLAG} \
312
312
  --label "review-finding" \
313
313
  --state open \
314
314
  --search "PR #${TRAIN_PR_NUMBER}" \
315
315
  --limit 20 \
316
- --json number,title,body 2>/dev/null \
316
+ --json number,title,body,labels 2>/dev/null \
317
317
  | jq -r --arg n "${TRAIN_PR_NUMBER}" "$FINDING_PR_FILTER" 2>/dev/null | sed 's/^ - //' || true) # uses FINDING_PR_FILTER
318
318
 
319
319
  if [ -n "$HELD_BY" ]; then
package/commands/issue.md CHANGED
@@ -81,7 +81,9 @@ The body MUST contain exactly one marker `<!-- FORGE:BODY-INTEGRITY:<entity>_<ro
81
81
 
82
82
  Read the actual code before drafting: the investigator inherits your file references. Map the domain (`forge.yaml → review.context`, the project's `CLAUDE.md`), read files from `origin/{STAGING_BRANCH}` via `git show`, and list every file the change touches: the primary file, its tests, config layers (`.env.example`, every compose override), migrations for schema changes, importers of a changed function.
83
83
 
84
- Duplicates: the script's token-overlap dedup is authoritative. Before drafting, also run a semantic search (`gh issue list --state open --search "{key_terms}"`, and `--state closed` for regressions). An open duplicate: tell the user and do not create. A closed one: create, and write "Regression of #N." in the body. If the user explicitly overrides a dedup STOP, re-run with `--exclude <that number>`; agents never do this without the user's authorization.
84
+ Duplicates: the script's token-overlap dedup is authoritative; also run a semantic search (`gh issue list --state open --search "{key_terms}"`, and `--state closed` for regressions). An open duplicate: tell the user and do not create. A closed one: create, and write "Regression of #N." in the body. If the user explicitly overrides a dedup STOP, re-run with `--exclude <that number>`; agents never do this without the user's authorization.
85
+
86
+ **Prior attempts (MANDATORY for bug/fix)**: `node "$(forge_resolve prior-attempts.mjs)" --repo {GH_REPO} --title "{TITLE}" --body-file "{DRAFT_BODY_FILE}"` searches by subject, not file. Paste its table as `## Prior Attempts`; fill the `?`s.
85
87
 
86
88
  ### 3. Draft
87
89
 
@@ -108,6 +110,9 @@ Duplicates: the script's token-overlap dedup is authoritative. Before drafting,
108
110
  ## Acceptance Criteria
109
111
  - [ ] {specific, testable criterion} [type:api|unit|e2e|manual]
110
112
 
113
+ ## Prior Attempts
114
+ {prior-attempts.mjs table; bug/fix only}
115
+
111
116
  ## Dependencies
112
117
  Depends on #{N} — {reason} (omit when none)
113
118
 
@@ -35,6 +35,7 @@ If no frontend context is configured above, derive conventions from the changed
35
35
  8. **Accessibility**: Interactive elements have proper labels, keyboard navigation works
36
36
  9. **Performance**: Large bundles, unnecessary client-side rendering, missing dynamic imports
37
37
  10. **Hook provider scope**: For any new `useX()` hook call added to a component, verify the hook is safe to call outside its provider. If `useX` internally calls `useContext(XContext)` and throws when context is undefined (i.e., the hook contains `if (!ctx) throw new Error(...)`), check ALL mount sites of the component across the codebase. If the component is used in both authenticated routes (e.g., `app/dashboard/`) AND public routes (e.g., `app/(public)/`, `app/playground/`) that do not wrap children in the provider, the hook call will crash the public route with a React error boundary cascade. Flag as CONFIRMED HIGH if a public mount site exists without the provider. <!-- Added: forge#381 -->
38
+ 11. **Dynamic API in static/ISR tree**: Flag `headers()`, `cookies()`, `connection()` or `searchParams` reads in layouts, `not-found`/`error` boundaries and any module they import when the route tree is static or ISR (`revalidate`, `generateStaticParams`, `dynamic = 'force-static'`). They opt the route out of static rendering or throw at render, turning pages into HTTP 500. Flag as CONFIRMED HIGH when the call is reachable from such a tree. <!-- Added: forge#5077 -->
38
39
 
39
40
  ## Post Findings
40
41
  ```bash
@@ -25,6 +25,8 @@ You are auditing PR #[PR_NUMBER] for infrastructure and deployment safety in [PR
25
25
  If no infra context is configured above, derive the deployment model from the changed files: read docker-compose files, GitHub Actions workflows, and entrypoint scripts to understand the deployment pipeline.
26
26
 
27
27
  ## What to Check
28
+
29
+ **New HTTP route handler (forge#5087)**: when the diff adds a route handler (Next.js `app/**/route.*`, `pages/api/**`, a FastAPI/Express router), confirm the production ingress delivers this path to this service: read the repo's ingress config (Traefik, nginx, gateway) and cite the rule. An app-level rewrite or fallback is not evidence; a path an ingress prefix rule sends to another service is a HIGH finding.
28
30
  1. **Downtime risk**: Will this change cause container restarts during deploy?
29
31
  1b. **Database container restart risk** (when `docker-compose*.yml` changes touch the `postgres` or `redis` service — `command:`, `image:`, `volumes:`, `environment:`):
30
32
  Changing any of these fields forces container recreation on `docker-compose up`. For stateful services this is **HIGH risk by default** — regardless of how simple the config change appears.
@@ -379,6 +379,8 @@ Invoke `/test-gate` with the manifest's `BUNDLE_PRS` and the default branch. Sav
379
379
  >>> INVOKE: Skill(skill="{FORGE_SKILL_PREFIX}test-gate", args="--prs \"<BUNDLE_PRS>\" --base <default branch>")
380
380
  ```
381
381
 
382
+ **Route reachability (forge#5087, blocking when declared)**: when `forge.yaml` declares `topology.route_reachability`, list the route paths the bundle adds (`git diff --name-only --diff-filter=A <default>...HEAD`, Next.js `app/**/route.*` / `pages/api/**` mapped to URL paths, route groups removed) and run the command with them as arguments. A non-zero exit is a BLOCK finding naming the path; unset = skip.
383
+
382
384
  Do not judge the result yourself. Step 7A reads the output's last `FORGE:TEST_GATE:RESULT=` marker through `scripts/review-staging-post.sh test-gate`. A missing or invalid marker (gate result missing/invalid) is BLOCK, never SKIP (fail closed). The step then applies the override and the posture. It posts `FORGE:GATE_PASS` (PASS) or `FORGE:GATE_FAILURE` (blocking BLOCK, with `RESULT: BLOCK DEPLOY`), and persists the verdict for Phase 8.
383
385
 
384
386
  **Incomplete-panel guard (MANDATORY):** Every reviewer selected in Phases 2–6 is appended to the launched-agent file before it is launched. If any dispatch fails, follow step 4 of Sub-Agent Dispatch Tool Resolution (hard stop). Before triage, step 7A counts unique trusted, current-head `FORGE:REVIEW-AGENT` markers. A marker counts only when all of these hold: the author is trusted per `scripts/trusted-comments.sh`, the body starts with the marker, the `Reviewed-SHA:` line matches the full head SHA, and the findings block is present. All comment pages are read. An unresolvable script or unreadable comments count as 0. If the completed count (`ACTUAL_AGENT_COUNT`) is lower than the selected count (`SELECTED_AGENT_COUNT`), the step labels the PR `review-degraded` and `needs-human`. It posts `FORGE:GATE_FAILURE:TYPE=review-panel-integrity` and stops. Do not create a deploy verdict from partial reviewer output.
@@ -696,6 +696,8 @@ the index is absent or returns no results for a given file.
696
696
  ```bash
697
697
  # Resolve recall CLI path relative to repository root
698
698
  RECALL_PATH="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null)}/bin/recall.mjs"
699
+ # #5085: recall.mjs ships with ForgeDock, not the target repo: also probe the install root
700
+ [ -f "$RECALL_PATH" ] || RECALL_PATH="$(forge_root 2>/dev/null)/bin/recall.mjs"
699
701
  LEDGER_AVAILABLE=0
700
702
 
701
703
  if [ -f "$RECALL_PATH" ]; then
@@ -735,7 +737,11 @@ Query closed issues with `review-finding` label, searching by filename:
735
737
 
736
738
  ```bash
737
739
  if [ "$LEDGER_AVAILABLE" -eq 0 ] || [ -z "$LEDGER_FINDINGS" ]; then
738
- echo "[context:C1] Forge Ledger unavailable or empty — falling back to live gh search"
740
+ echo "WARNING: [context:C1] Forge Ledger index absent or empty — falling back to live gh + subject search"
741
+ # #5085: prior attempts by subject terms (codes, symbols, title words), not only by file
742
+ PA_BODY="${TMPDIR:-/tmp}/pa-{NUMBER}.md"; gh issue view {NUMBER} -R {GH_REPO} --json body --jq .body > "$PA_BODY" 2>/dev/null
743
+ node "$(forge_resolve prior-attempts.mjs)" --repo {GH_REPO} --issue {NUMBER} --body-file "$PA_BODY" \
744
+ --title "$(gh issue view {NUMBER} -R {GH_REPO} --json title --jq .title)" || true # → **Prior Attempts** in output
739
745
  IFS=' ' read -ra AFFECTED_FILES_ARR <<< "{AFFECTED_FILES}"
740
746
  for file in "${AFFECTED_FILES_ARR[@]}"; do
741
747
  basename=$(basename "$file" .py)
@@ -175,7 +175,7 @@ Print these blocks to stdout before Phase 1A begins. During Phase 1B (step 3 —
175
175
 
176
176
  **Goal**: Query the Forge Ledger knowledge index for prior cards matching this issue's title terms, affected files, and symbols — **before reading any code**. Inject above-threshold results with explicit delta-verification framing so the investigator builds on prior knowledge rather than re-deriving it.
177
177
 
178
- **This phase is non-blocking** — if the index is absent, empty, or returns no match above threshold, log a single line and proceed to Phase 1A. Never stall the pipeline for recall.
178
+ **This phase is non-blocking** — if the index is absent or empty, print a WARNING and use the live subject search (Step 1); if it returns no match above threshold, log a single line and proceed to Phase 1A. Never stall the pipeline for recall.
179
179
 
180
180
  **Relationship to Phase 0.5**: Phase 0.5 retrieves structured priors from the Gist-based FORGE:MEMORY_INDEX (coarse keyword scoring over MEMORY_ENTRY lines). Phase 0.6 queries the Forge Ledger index (BM25 over structured knowledge cards, indexed by `build-knowledge-index.mjs`). Both are complementary and both run — neither replaces the other.
181
181
 
@@ -183,6 +183,8 @@ Print these blocks to stdout before Phase 1A begins. During Phase 1B (step 3 —
183
183
 
184
184
  ```bash
185
185
  RECALL_PATH="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null)}/bin/recall.mjs"
186
+ # #5085: recall.mjs ships with ForgeDock, not the target repo: also probe the install root
187
+ [ -f "$RECALL_PATH" ] || RECALL_PATH="${FORGE_ROOT:-$(forge_root 2>/dev/null)}/bin/recall.mjs"
186
188
  LEDGER_AVAILABLE=0
187
189
 
188
190
  if [ -f "$RECALL_PATH" ]; then
@@ -192,8 +194,14 @@ if [ -f "$RECALL_PATH" ]; then
192
194
  fi
193
195
 
194
196
  if [ "$LEDGER_AVAILABLE" -eq 0 ]; then
195
- echo "[recall] Forge Ledger index absent or empty — skipping Phase 0.6, proceeding to Phase 1A"
196
- # → Continue to Phase 1A
197
+ WHICH=$([ -f "$RECALL_PATH" ] && echo "index ~/.forge/index empty or missing" || echo "bin/recall.mjs missing")
198
+ echo "WARNING: [recall] Forge Ledger index absent or empty (${WHICH}) — falling back to live subject search"
199
+ # #5085: prior attempts by subject terms (codes, symbols, title words) + repo-wide git log --grep, not only by file
200
+ PA_BODY="${TMPDIR:-/tmp}/pa-{NUMBER}.md"; gh issue view {NUMBER} {GH_FLAG} --json body --jq .body > "$PA_BODY" 2>/dev/null
201
+ PA=$(node "$(forge_resolve prior-attempts.mjs)" --repo {GH_REPO} --issue {NUMBER} --body-file "$PA_BODY" \
202
+ --title "$(gh issue view {NUMBER} {GH_FLAG} --json title --jq .title)" || true)
203
+ case "$PA" in *"| #"*) RECALL_RESULTS="$PA" ;; esac
204
+ # → Skip Steps 2-3; inject RECALL_RESULTS (the ## Prior Attempts table) via Step 4, then Phase 1A
197
205
  fi
198
206
  ```
199
207
 
@@ -462,6 +470,10 @@ fi
462
470
 
463
471
  **Mission**: Validate whether the issue is real. Assume description is wrong until proven otherwise.
464
472
 
473
+ ### Route-adding fixes: trace the production ingress (forge#5087)
474
+
475
+ When the fix adds or relies on an HTTP route, trace the request from the production ingress inward, using the repo's declared topology (`forge.yaml topology`, ingress/proxy config such as Traefik or nginx), and cite the ingress rule that delivers each path to the service that implements it. "The app config rewrites it" (Next.js rewrites, filesystem-route precedence) is not evidence of production routing: flag it as insufficient. Existing handlers cited as a "working pattern" need the same check.
476
+
465
477
  ### Resolve target repo and branch
466
478
 
467
479
  The target repo is `{GH_REPO}` (resolved from `forge.yaml → project`). The working directory is `{REPO_PATH}` (resolved from `forge.yaml → paths.root`).
package/docs/CONFIG.md CHANGED
@@ -472,7 +472,7 @@ orchestration:
472
472
 
473
473
  ## `decisions` (OPTIONAL)
474
474
 
475
- Decision engine (#4766, see [DECISIONS.md](DECISIONS.md)). **Not yet wired:** nothing calls `settle` in production, so `mode` (including `live`), `veto_hours` and `reversal_max` have no effect today; `policy` and `human_only` are read by prodwatch and issue-watch (`loadPolicy`) only. The parser has one nesting level and no arrays, so lists are comma-separated.
475
+ Decision engine (#4766, see [DECISIONS.md](DECISIONS.md)). The issue-watch stage (#4793) calls `settle` for the open `needs-human` issues a watch selects: it runs when `mode: live` (a repo setting: every watch of the repo runs the stage), or when the watch sets `sweep_needs_human: true`; `policy` and `human_only` are also read by prodwatch (`loadPolicy`). The parser has one nesting level and no arrays, so lists are comma-separated.
476
476
 
477
477
  | Key | Type | Description |
478
478
  |---|---|---|
@@ -718,6 +718,8 @@ issue_watch:
718
718
  | `parallel` | integer | No | K of the batch in `batch` mode. Default 4. |
719
719
  | `include_followups` | string | No | `none` (default) or `defer-only`. |
720
720
  | `postdeploy_rollup` | issue number | No | Release issue that collects post-deploy-only follow-ups as checklist lines. |
721
+ | `sweep_needs_human` | boolean | No | Also settle `needs-human` escalations that predate the watch (the backlog). Default false; `forgedock issue-watch run --dry-run` classifies them first and writes nothing. |
722
+ | `max_launches` | integer | No | Total lane launches (claims plus requeues by the decision engine); at N the watch drains. Default: none. Decision-stage issues also count against `max_issues`. |
721
723
  | `threshold` | map | release | `{ min_issues, min_score, max_age_days }`; any one fires a release. |
722
724
  | `immediate` | list | release | `label:<name>` or `priority:P0..P3` entries that cut a release now with only their theme. |
723
725
  | `max_release_issues` | integer | release | Cap on a release set, closure included. Default 40. |
@@ -728,11 +730,21 @@ issue_watch:
728
730
 
729
731
  ## `followups` (OPTIONAL)
730
732
 
731
- Deferred follow-ups (`scripts/file-deferred-followups.sh`) are filed as one issue per class per parent: `engineering`, `post-deploy`, `external` and `ops`. Each carries the label `followup:<class>` (`external` also `external-prerequisite`, and states the parent it is a prerequisite of) and inherits the parent's milestone. A requirement may set an optional `class` in the contract; otherwise `post-deploy` items are `post-deploy` and the rest `engineering`. A parent milestone that has shipped (closed) is replaced by the next open milestone by due date, else `followups.default_milestone`.
733
+ Deferred follow-ups (`scripts/file-deferred-followups.sh`) are routed by kind (#5078):
734
+
735
+ | Kind | Where it goes |
736
+ |---|---|
737
+ | Post-promotion, production-verify, rollout-promote (`post-deploy`, `owner:orchestrator`, `owner:promote:<flag>`, or an owner why such as "after release", "rollout", "purge") | A checklist line in the repo's ONE open post-deploy rollup (`FORGE:POSTDEPLOY_ROLLUP`, else the lowest open issue labelled `followups.rollup_issue_label`). Never a new issue when one is open. |
738
+ | Read-only production data or capture (bucket `host`, or an owner why naming obs-psql, a `*-monitor` or a read-only prod query) | Class `host`: added to the parent's engineering follow-up of the same run, else one per-parent follow-up. Labels `followup:host` and `host-task`, never `needs-human`. |
739
+ | Truly external (credentials, spend, an account, a third party, a customer) | ONE issue per batch (`FORGE:EXTERNAL_BATCH: <batch id>`) holding every item under a `### #<parent>` heading, labelled `followup:external`, `external-prerequisite` and `needs-human`. |
740
+ | Engineering | The parent's open follow-up when one exists, else one issue per parent with all its items together. `ops` and `verify-now` also keep one issue per parent. |
741
+
742
+ Each issue carries the label `followup:<class>` and inherits the parent's milestone. A requirement may set an optional `class` in the contract. Otherwise `post-deploy` items are `post-deploy` and the rest `engineering`. A parent milestone that has shipped (closed) is replaced by the next open milestone by due date, else `followups.default_milestone`. The batch report lists `follow-ups appended: #N (<class>: k item(s) from #a,#b)` per issue, not "issues filed".
732
743
 
733
744
  ```yaml
734
745
  followups:
735
- default_milestone: "Backlog" # optional: milestone title used when the parent's milestone has shipped and no later one is open
746
+ default_milestone: "Backlog" # optional: milestone title used when the parent's milestone has shipped and no later one is open
747
+ rollup_issue_label: post-deploy # optional: a repo's own open issue with this label is the post-deploy rollup when no marked one is open
736
748
  ```
737
749
 
738
750
  ## `rollouts` (OPTIONAL)
@@ -886,6 +898,7 @@ verify:
886
898
  guard_command: # optional; per-extension command for the guard gate's base test files
887
899
  ".py": "scripts/pytest {path}"
888
900
  e2e: auto # optional e2e merge gate: auto (default) | always | never
901
+ precheck_block: false # optional (#5079); true: a declared pre-PR check still failing after the fix round (or a round with no commit) ends the lane needs-human ci.red (recoverable) instead of opening the PR with an anomaly
889
902
  e2e_label: full-ci # PR label that makes the repo's CI run e2e (label path)
890
903
  e2e_checks: "e2e" # check names of the e2e jobs the label path waits on
891
904
  # e2e_workflow: e2e.yml # or: a workflow_dispatch workflow that runs e2e (wins over the label)
@@ -1016,6 +1029,7 @@ execution:
1016
1029
  | `budget_minutes`, `budget_usd` | number | No | The `execution.budget` per batch: once the batch's sandbox minutes / estimated USD (settled lanes plus the running ones' elapsed time) reach the cap, no new remote lane is admitted. Missing, non-numeric or negative means no cap. |
1017
1030
  | `day_budget_minutes`, `day_budget_usd` | number | No | The same cap per host UTC day, summed over every batch on the host from the host-day store (`execution-budget.json` in `~/.forge/sched/`, or `FORGEDOCK_EXECUTION_BUDGET_FILE`). A corrupt or unreadable store refuses remote lanes while a day cap is set; remove the file to reset it. |
1018
1031
  | `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. |
1032
+ | `account_rotator` | string | No | `tokenbaton`: the `claude` on PATH is the tokenbaton shim, which runs each call on the logged-in account with the most room (`CLAUDE_CONFIG_DIR=~/.claude-<n>`) (forge#5094). forgedock reads `~/.tokenbaton/current` (the account number) and `~/.tokenbaton/live.<n>` (`<5h used %> <5h reset s> <weekly used %> <weekly reset s> <updated s>`), re-read on every check and never written. An account has headroom when its 5-hour use is under 95% and its weekly use under 100% (a window whose reset has passed counts as 0). While any account has headroom no host-wide usage hold is recorded and admissions continue; they stop only when every account is exhausted and resume when the first one resets. A lane whose call hits a usage limit retries that same call (same session) on the next account at once, at most once per account, before it pauses or defers. `forgedock limits show` lists each account's 5-hour use. Any other value, or unreadable files: off (per-account holds as before). |
1019
1033
  | `warm_sandboxes` | integer | No | Warm sandboxes per repo the pool agent keeps ready (checkout at the base ref, dependency cache primed). When `N > 0` the launch spec carries `warm: N` and the lane first calls `POST {endpoint}/sandboxes/claim`; a ready warm sandbox takes the lane (refreshed to the lane's base ref with an incremental fetch before work starts), and when none is ready, the claim fails or the endpoint lacks the route the lane starts cold. Default `0` (off): no claim call and the launch spec is unchanged (forge#4912). |
1020
1034
  | `unreachable_window_min` | number | No | Remote providers: how long a lane keeps polling (with backoff, up to 30s) after the provider's transport fails (connection refused/reset, `fetch failed`, timeout, 502/503/504) before it ends `deferred` with `sandbox.unreachable` (forge#4672). Inside the window the lane never ends and the sandbox is never torn down; after it the sandbox is kept and adopted on the next attempt or `--resume`; the engine-error breaker does not count it. `adopt` applies the same window. Default: 5 |
1021
1035
  | `on_budget` | string | No | `stop` (default): running lanes finish and the rest end `held` (`execution budget: ...`) for a later `--resume`. `local`: the rest run as detached host lanes. |
@@ -1341,6 +1355,9 @@ verification:
1341
1355
  test_runners: # path prefix -> runner command prefix (block-style map)
1342
1356
  services/worker: "poetry -C services/worker run"
1343
1357
  web: "pnpm --filter web exec"
1358
+
1359
+ topology:
1360
+ route_reachability: "bash scripts/check-ingress.sh" # optional; blocking when declared
1344
1361
  ```
1345
1362
 
1346
1363
  | Field | Type | Required | Description |
@@ -1358,7 +1375,9 @@ verification:
1358
1375
  | `consumer_sweep.mode` | string | No | `advisory` (default): the validate consumer sweep (`scripts/consumer-sweep.sh`, V4.6) records unaddressed referencing sites and never fails. `blocking`: unaddressed sites fail validate with `consumer_unaddressed`, build runs one repair round, and sites still unaddressed are recorded as `CONSUMERS: unresolved` and passed to review as known risk (never `needs-human`). Flip to `blocking` only after `bash scripts/consumer-sweep.sh --eval-history 20` shows a mean of at most 2 flagged sites per merge and none above 10 on your repo |
1359
1376
  | `consumer_sweep.budget_seconds` | integer | No | Wall-clock budget for the sweep (default `60`). When it runs out the sweep stops with `BUDGET_EXCEEDED`, reports the partial result, and never fails, in either mode |
1360
1377
  | `test_runners` | map (path prefix → runner) | No | Default unset (no runner enforced). For repos whose validate commands need a managed runner (uv, poetry, pnpm workspace). The lean engine checks it at contract freeze (`testRunnerProblems`, `bin/lean/preflight.mjs`): a validate command that touches a configured path prefix without containing that prefix's runner string is reported as a contract problem, instead of failing later as `contract.invalid_command` / `env.tool_missing`. Paths in the command are relative to the runner's cwd. Write it as a block-style map: `parseForgeYaml` does not read inline `{ ... }` maps. |
1378
+ | `coupled_files` | map (glob → glob list) | No | Default unset (#5079). "If file A changes, file B must change": a block map `{when glob: required glob[, glob]}`, e.g. `"infra/guard/bootstrap.sh": "infra/guard/MANIFEST.sha256"`. A key prefixed `added:` fires only for files the lane adds (`"added:scripts/tests/test_*.py": ".github/workflows/ci.yml"`). A lane that changes a `when` file without a matching required file fails precheck with `coupled_file_missing` (`conformance.unimplemented`, builder resumed once). Quote keys that contain `*`, and list alternatives as separate keys: brace globs (`*.{ts,tsx}`) are not expanded. |
1361
1379
  | `validate.recipes` | map (regex → replacement) | No | Default unset. A top-level `validate:` block with a block-style `recipes:` map, e.g. `"poetry run pytest": "uv run --with-requirements r.txt pytest"`. Each key is a regex source; the first recipe that matches a frozen validate or `repro_test` command rewrites it at contract freeze (`applyValidateRecipes`), recorded as a decision. Use it for commands whose environment is not installed in lane sandboxes. |
1380
+ | `topology.route_reachability` | string (command) | No | Default unset (no check, no behaviour change). A repo-supplied command that takes the route paths a diff adds (Next.js `app/**/route.*`, `pages/api/**`; URL form, route groups removed) as arguments and exits non-zero when the production ingress does not deliver them to the service that implements them. Runs in the lean lane after validate and in `review-pr-staging`; a non-zero exit blocks (`conformance.unimplemented`, kind `route_unreachable`). Read by `readEnvConfig` (`bin/engine-cli.mjs`), run by `bin/lean/routecheck.mjs`. |
1362
1381
 
1363
1382
  **Commands that use this section**: `quality-gate`, `validate` (V2 runs `verification.commands`; V4.8 runs the local CI gate; health checks and `consumer_sweep`), `work-on`
1364
1383
 
package/docs/DECISIONS.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Decision engine (#4766)
2
2
 
3
- > **Status: not yet wired.** No production code calls `settle` yet: the issue-watch stage does not run it, so `decisions.mode`
4
- > (including `live`), `veto_hours` and `reversal_max` have no effect and no `decision_engine` telemetry is emitted. Only the policy
5
- > and authority-matrix reads (`loadPolicy`) are used today, by prodwatch and issue-watch. The flow below describes the intended behaviour.
3
+ > **Status: wired (#4793).** The issue-watch stage runs `settle` each round for the open `needs-human` issues that match the watch's selectors.
6
4
 
7
5
  Settles a lane's decision-only `needs-human` escalation against the target repo's own north star instead of parking it.
8
6
  Only external actions stay with a human. Code: `bin/lean/decision-engine.mjs`. Config: [`decisions:`](CONFIG.md).
@@ -30,7 +28,20 @@ Only external actions stay with a human. Code: `bin/lean/decision-engine.mjs`. C
30
28
  `decision_engine` telemetry ([TELEMETRY.md](TELEMETRY.md)): decided, escalated, vetoed, reversed, needs-human before/after. A reversal rate above
31
29
  `reversal_max` turns the engine back to shadow. The batch report lists every decision under `decided`.
32
30
 
33
- ## Not in this module
34
-
35
- The `issue-watch` stage that lists `needs-human` issues and calls `settle` (`sweep_needs_human`, `--dry-run` via `classify`, caps, status counts)
36
- did not land with #4765 (merged without the sweep); a follow-up issue tracks it. Running a declared monitor query for `evidence` and folding `post-deploy` items into the release checklist are follow-ups.
31
+ ## The issue-watch stage
32
+
33
+ `bin/lean/issuewatch-decisions.mjs` runs inside `forgedock issue-watch` (every mode), not as a separate entry point. It is active when `decisions.mode: live`
34
+ or the watch sets `sweep_needs_human: true`. `decisions.mode: live` is a repo setting, so it activates the stage in every watch of the repo (also watches that never opted in); set it only when each watch's selectors are meant to settle `needs-human` issues. Each round it lists open `needs-human` issues of trusted authors that match the watch selectors, takes the newest
35
+ `FORGE:NEEDS_HUMAN` comment (its comment id is the escalation id `settle` is idempotent on) and the typed `terminalCode` from `FORGE:STATE`, and calls `settle`.
36
+
37
+ - **Backlog.** An escalation older than the watch start is only touched by a watch with `sweep_needs_human: true`. `forgedock issue-watch run --dry-run` prints the `classify` result for every escalation (backlog marked) and writes nothing.
38
+ - **Caps.** Each handled issue counts against `max_issues`; each requeue (a live decision or an evidence hand-off) against `max_launches`, which also stops the watch claiming past it.
39
+ - **Status.** `issue-watch status` gains `needs-human decisions: decided N evidence-gathered N folded N external-kept N`.
40
+ - **Evidence.** A block of "missing read-only production data" on a repo whose forge.yaml declares `prod_watch.sources` clears `needs-human` and goes back to a lane with a comment naming the prod monitor capability (placement `prod_monitor`); without a declared source it keeps the label.
41
+ - **Post-deploy only.** Folded into the watch's `postdeploy_rollup` checklist, `needs-human` cleared and the issue closed; without a rollup it is kept.
42
+ - **Shadow.** Only live mode acts: in shadow (the default) a post-deploy-only or evidence escalation posts a `FORGE:DECISION (shadow)` comment and changes nothing (no fold, close, label change or launch).
43
+ - **Relaunch.** A live decision or evidence hand-off clears `needs-human` and launches the issue through the watcher's own lane launch (per-issue watches); batch and release watches re-select it on the next round.
44
+ - **Trust.** Only `FORGE:NEEDS_HUMAN` and `FORGE:STATE` comments from OWNER/MEMBER/COLLABORATOR or the engine's own logins (`ownLogins`; the ForgeDock App posts as CONTRIBUTOR) count, never other CONTRIBUTORs.
45
+ - **Failures.** A judge or gh failure is not recorded as handled: the escalation is retried for up to three rounds. The judge call has a 2 minute timeout, and a round makes at most 20 judge calls even when `max_issues` is unset; the rest wait for the next round.
46
+ - **Veto.** A `forge:decision-veto` label or a `veto` reply inside `veto_hours` reverts a live decision (`revertVeto`) and counts toward the reversal rate that forces shadow.
47
+ - The judge is the only LLM call (`claude --print` in the real io).