forgedock 1.46.0 → 1.48.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 (69) 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 +5 -1
  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.mjs +134 -37
  20. package/bin/lean/issuewatch.test.mjs +123 -1
  21. package/bin/lean/lane.mjs +13 -0
  22. package/bin/lean/lane.test.mjs +28 -1
  23. package/bin/lean/lean.mjs +60 -12
  24. package/bin/lean/lean.test.mjs +80 -0
  25. package/bin/lean/limits.mjs +6 -2
  26. package/bin/lean/limits.test.mjs +86 -0
  27. package/bin/lean/milestonesync.mjs +2 -2
  28. package/bin/lean/milestonesync.test.mjs +22 -0
  29. package/bin/lean/orchestrate.mjs +79 -9
  30. package/bin/lean/probe.mjs +4 -2
  31. package/bin/lean/probe.test.mjs +4 -0
  32. package/bin/lean/prompts.mjs +18 -11
  33. package/bin/lean/rotator.mjs +96 -0
  34. package/bin/lean/rotator.test.mjs +143 -0
  35. package/bin/lean/routecheck.mjs +48 -0
  36. package/bin/lean/routecheck.test.mjs +69 -0
  37. package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
  38. package/bin/lean/sandbox-remote.mjs +4 -2
  39. package/bin/lean/verify.mjs +2 -1
  40. package/bin/review-staging.mjs +20 -3
  41. package/commands/deploy-info.md +4 -4
  42. package/commands/issue.md +6 -1
  43. package/commands/review-pr-agents/frontend.md +1 -0
  44. package/commands/review-pr-agents/infra.md +2 -0
  45. package/commands/review-pr-staging.md +2 -0
  46. package/commands/work-on/build/context.md +7 -1
  47. package/commands/work-on/investigate.md +15 -3
  48. package/docs/CONFIG.md +21 -2
  49. package/docs/ISSUE-WATCH.md +22 -1
  50. package/docs/OUTCOME-CODES.md +2 -1
  51. package/package.json +1 -1
  52. package/scripts/file-deferred-followups.sh +125 -28
  53. package/scripts/file-deferred-followups.test.sh +49 -0
  54. package/scripts/issue-create.sh +7 -0
  55. package/scripts/issue-create.test.sh +13 -0
  56. package/scripts/issue-dedup.sh +5 -4
  57. package/scripts/issue-dedup.test.sh +15 -0
  58. package/scripts/owner-route-parity.test.sh +32 -0
  59. package/scripts/plan-validate-live.mjs +18 -6
  60. package/scripts/prior-attempts.mjs +72 -0
  61. package/scripts/prior-attempts.test.mjs +52 -0
  62. package/scripts/review-finalize.sh +35 -4
  63. package/scripts/review-finalize.test.sh +10 -0
  64. package/scripts/review-prepare.sh +16 -4
  65. package/scripts/review-prepare.test.sh +8 -0
  66. package/scripts/review-roster.sh +3 -1
  67. package/scripts/review-roster.test.sh +30 -2
  68. package/scripts/review-staging-pre.sh +8 -6
  69. package/scripts/review-staging-pre.test.sh +11 -1
@@ -0,0 +1,143 @@
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 { mkdirSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { parseLive, openRotator, rotatorRetry, quotaRequeue, ROTATOR_KINDS, HEADROOM_PCT } from "./rotator.mjs";
9
+ import { laneBackendId, openLimits } from "./limits.mjs";
10
+ import { orchestrate, formatTable } from "./orchestrate.mjs";
11
+ import { parseExecution } from "./execution.mjs";
12
+
13
+ const T0 = 1_800_000_000_000; // epoch ms
14
+ const fakeClock = () => { const c = { t: T0 }; return { c, now: () => c.t, sleep: async (ms) => { c.t += ms; } }; };
15
+ /** A tokenbaton home: `accounts` maps n -> [5h %, 5h reset ms, weekly %, weekly reset ms]. */
16
+ const baton = (current, accounts) => {
17
+ const home = tmpDir("baton-"), dir = join(home, ".tokenbaton");
18
+ mkdirSync(dir, { recursive: true });
19
+ writeFileSync(join(dir, "current"), `${current}\n`);
20
+ const write = (n, [p5, r5, pw, rw]) => writeFileSync(join(dir, `live.${n}`), `${p5} ${Math.round(r5 / 1000)} ${pw} ${Math.round(rw / 1000)} ${Math.round(T0 / 1000)}\n`);
21
+ for (const [n, v] of Object.entries(accounts)) write(n, v);
22
+ return { home, write, setCurrent: (n) => writeFileSync(join(dir, "current"), `${n}\n`) };
23
+ };
24
+ const H = 3_600_000;
25
+
26
+ describe("account rotator adapter (#5094)", () => {
27
+ it("parseLive reads the five fields and rejects malformed lines", () => {
28
+ assert.deepEqual(parseLive("42 1800000100 7 1800500000 1800000000"), { fiveHourPct: 42, fiveHourResetMs: 1_800_000_100_000, weeklyPct: 7, weeklyResetMs: 1_800_500_000_000 });
29
+ assert.equal(parseLive(""), null);
30
+ assert.equal(parseLive("12 abc 3 4"), null);
31
+ assert.equal(parseLive("1 2 3"), null);
32
+ });
33
+
34
+ it("openRotator is null for an unknown kind or unreadable state; parseExecution keeps only tokenbaton", () => {
35
+ assert.deepEqual(ROTATOR_KINDS, ["tokenbaton"]);
36
+ assert.equal(openRotator({ kind: undefined }), null);
37
+ assert.equal(openRotator({ kind: "other", home: baton(1, { 1: [0, T0 + H, 0, T0 + H] }).home }), null);
38
+ assert.equal(openRotator({ kind: "tokenbaton", home: tmpDir("empty-") }), null);
39
+ assert.equal(openRotator({ kind: "tokenbaton", home: baton(1, {}).home }), null, "no live.N");
40
+ assert.equal(parseExecution({ account_rotator: "tokenbaton" }).accountRotator, "tokenbaton");
41
+ assert.equal("accountRotator" in parseExecution({ account_rotator: "nope" }), false);
42
+ assert.equal("accountRotator" in parseExecution({}), false);
43
+ });
44
+
45
+ it("headroom: 5h under 95% and weekly under 100%; a passed reset counts as 0; resumeAt is the earliest regain; state is re-read", () => {
46
+ const { c, now } = fakeClock();
47
+ const b = baton(1, { 1: [HEADROOM_PCT, T0 + 2 * H, 10, T0 + 100 * H], 2: [99, T0 + 4 * H, 100, T0 + 3 * H] });
48
+ const rot = openRotator({ kind: "tokenbaton", home: b.home, now });
49
+ assert.equal(rot.headroom(), false);
50
+ assert.equal(rot.resumeAt(), T0 + 2 * H, "account 1 regains at its 5h reset; account 2 needs both resets (4h)");
51
+ assert.deepEqual(rot.accounts.map((a) => a.n), [1, 2]);
52
+ c.t = T0 + 2 * H; // account 1's 5h window reset: counts as 0
53
+ assert.equal(rot.headroom(), true);
54
+ assert.equal(rot.resumeAt(), c.t);
55
+ c.t = T0;
56
+ b.write(2, [10, T0 + H, 10, T0 + 100 * H]); // tokenbaton refreshed live.2
57
+ assert.equal(rot.headroom(), true, "no cache: the new file is read");
58
+ assert.equal(rot.current(), 1);
59
+ b.setCurrent(2);
60
+ assert.equal(rot.current(), 2);
61
+ });
62
+
63
+ it("account identity is the lane identity on ~/.claude-<n>; isAccount; laneBackendId follows the rotator's current account", () => {
64
+ const b = baton(1, { 1: [0, T0 + H, 0, T0 + H], 2: [0, T0 + H, 0, T0 + H] });
65
+ const rot = openRotator({ kind: "tokenbaton", home: b.home, env: {}, now: () => T0 });
66
+ assert.equal(rot.accountIdentity(2), laneBackendId({ CLAUDE_CONFIG_DIR: join(b.home, ".claude-2") }));
67
+ assert.equal(rot.isAccount(rot.accountIdentity(1)), true);
68
+ assert.equal(rot.isAccount("remote-credential"), false);
69
+ assert.equal(laneBackendId({}, rot), rot.accountIdentity(1));
70
+ b.setCurrent(2);
71
+ assert.equal(laneBackendId({}, rot), rot.accountIdentity(2), "#R13: the identity changes when tokenbaton rotates");
72
+ assert.equal(laneBackendId({ CLAUDE_CONFIG_DIR: "/x" }, null), laneBackendId({ CLAUDE_CONFIG_DIR: "/x" }), "no rotator: unchanged");
73
+ assert.equal(laneBackendId({ CLAUDE_CONFIG_DIR: "/x" }, { currentIdentity: () => { throw new Error("x"); } }), laneBackendId({ CLAUDE_CONFIG_DIR: "/x" }), "fail-open");
74
+ });
75
+
76
+ it("rotatorRetry and quotaRequeue: rotation is capped at the account count; a remote credential waits for its own reset", () => {
77
+ const rot = { accounts: [{ n: 1 }, { n: 2 }], headroom: () => true, isAccount: (id) => id === "acct-1" };
78
+ assert.equal(rotatorRetry(rot, 0), true);
79
+ assert.equal(rotatorRetry(rot, 2), false);
80
+ assert.equal(rotatorRetry(null, 0), false);
81
+ assert.equal(rotatorRetry({ ...rot, headroom: () => false }, 0), false);
82
+ assert.deepEqual(quotaRequeue({ rotator: rot, r: { backendId: "acct-1" }, delayMs: 600_000 }), { delayMs: 0, rotated: true });
83
+ assert.deepEqual(quotaRequeue({ rotator: rot, r: {}, delayMs: 600_000 }), { delayMs: 0, rotated: true });
84
+ assert.deepEqual(quotaRequeue({ rotator: rot, r: { backendId: "acct-1" }, prior: { rotations: 2 }, delayMs: 600_000 }), { delayMs: 600_000, rotated: false });
85
+ assert.deepEqual(quotaRequeue({ rotator: rot, r: { backendId: "sandbox-cred" }, delayMs: 600_000 }), { delayMs: 600_000, rotated: false }, "R6: remote credential");
86
+ assert.equal(quotaRequeue({ rotator: { ...rot, headroom: () => false }, r: {}, delayMs: 1 }), null);
87
+ assert.equal(quotaRequeue({ rotator: null, r: {}, delayMs: 1 }), null);
88
+ });
89
+ });
90
+
91
+ describe("orchestrate with an account rotator (#5094)", () => {
92
+ const limited = (id, until, cost = 0.5, minutes = 3) => ({ terminalReason: "deferred", code: "rate_limited", resetAtEpochMs: until, backendId: id, cost_usd: cost, minutes });
93
+
94
+ it("every account exhausted: admissions stop, and resume by themselves when the first account resets", async () => {
95
+ const { now, sleep } = fakeClock();
96
+ const rot = { accounts: [{ n: 1 }, { n: 2 }], headroom: () => now() >= T0 + 2 * H, resumeAt: () => T0 + 2 * H, isAccount: () => true, currentIdentity: () => "acct-1" };
97
+ const starts = [];
98
+ const rows = await orchestrate({ issues: [1], now, sleep, readBody: async () => "", rotator: rot, limits: openLimits(join(tmpDir("lim-"), "l.json"), { now }), identity: () => "acct-1",
99
+ runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
100
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged"]);
101
+ assert.equal(starts[0], T0 + 2 * H, "held until the rotator's resumeAt, then admitted without operator action");
102
+ });
103
+
104
+ it("report: the requeue row is 'paused (quota)' with its resume time, never 'deferred', and the paused cost is counted once", async () => {
105
+ const { now, sleep } = fakeClock();
106
+ const updates = [];
107
+ let runs = 0;
108
+ const rows = await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits: openLimits(join(tmpDir("lim-"), "l.json"), { now }), identity: () => "A",
109
+ onUpdate: (u) => updates.push(u), runOne: async () => (++runs === 1 ? limited("A", T0 + H) : { terminalReason: "merged", cost_usd: 1.25, minutes: 4 }) });
110
+ const paused = updates.find((u) => u.issue === 1 && u.outcome === "paused");
111
+ assert.ok(paused, `paused row: ${JSON.stringify(updates.map((u) => u.outcome))}`);
112
+ assert.equal(updates.some((u) => u.outcome === "deferred"), false);
113
+ assert.equal(paused.quota.resumeAt, new Date(T0 + H).toISOString());
114
+ assert.match(paused.detail, /^paused \(quota\) until /);
115
+ assert.equal(rows[0].outcome, "merged");
116
+ assert.equal(rows[0].cost_usd, 1.75, "paused attempt + rerun, once");
117
+ assert.match(formatTable([{ ...paused }]), /paused \(quota\) until \d{4}-/);
118
+ });
119
+
120
+ it("a batch that ends while quota-paused leaves a resumable 'held' row with the quota resume time and the paused cost", async () => {
121
+ const { now, sleep } = fakeClock();
122
+ // #2's engine-errors trip the breaker while #1 waits for its quota: the batch ends with #1 pending.
123
+ const rows = await orchestrate({ issues: [2, 1], parallel: 1, now, sleep, readBody: async () => "", breaker: { errors: 2, window_min: 600 },
124
+ limits: openLimits(join(tmpDir("lim-"), "l.json"), { now }), identity: () => "A", runOne: async (n) => { if (n === 2) throw new Error("boom"); return limited("A", T0 + H); } });
125
+ const row = rows.find((r) => r.issue === 1);
126
+ assert.equal(row.outcome, "held", "P4: held stays the resumable marker");
127
+ assert.equal(row.quota?.resumeAt, new Date(T0 + H).toISOString());
128
+ assert.equal(row.cost_usd, 0.5);
129
+ assert.equal(row.detail, `paused (quota) until ${new Date(T0 + H).toISOString()}`);
130
+ assert.match(formatTable(rows), /paused \(quota\) until /);
131
+ });
132
+
133
+ it("a remote lane limited on its own credential waits for its own reset and sets no host hold while the rotator has headroom", async () => {
134
+ const { now, sleep } = fakeClock();
135
+ const limits = openLimits(join(tmpDir("lim-"), "l.json"), { now });
136
+ const rot = { accounts: [{ n: 1 }], headroom: () => true, resumeAt: () => now(), isAccount: (id) => id === "acct-1", currentIdentity: () => "acct-1" };
137
+ const starts = [];
138
+ await orchestrate({ issues: [1], now, sleep, readBody: async () => "", rotator: rot, limits, identity: () => "acct-1",
139
+ runOne: async () => { starts.push(now()); return starts.length === 1 ? limited("sandbox-cred", T0 + H) : { terminalReason: "merged" }; } });
140
+ assert.deepEqual(limits.list(), []);
141
+ assert.equal(starts[1], T0 + H);
142
+ });
143
+ });
@@ -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
+ });
@@ -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
@@ -717,6 +717,8 @@ issue_watch:
717
717
  | `mode` | string | No | `per-issue` (default), `batch` or `release` (pool, then cut themed releases). |
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
+ | `trust_app` | boolean | No | Claim issues authored by this repo's ForgeDock App. Other bots and `NONE`-association authors stay untrusted. Default false. |
721
+ | `trust_app_labels` | list | No | Needs `trust_app`: an App-authored issue must carry one of these labels. |
720
722
  | `postdeploy_rollup` | issue number | No | Release issue that collects post-deploy-only follow-ups as checklist lines. |
721
723
  | `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
724
  | `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`. |
@@ -730,11 +732,21 @@ issue_watch:
730
732
 
731
733
  ## `followups` (OPTIONAL)
732
734
 
733
- 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`.
735
+ Deferred follow-ups (`scripts/file-deferred-followups.sh`) are routed by kind (#5078):
736
+
737
+ | Kind | Where it goes |
738
+ |---|---|
739
+ | 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. |
740
+ | 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`. |
741
+ | 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`. |
742
+ | 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. |
743
+
744
+ 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".
734
745
 
735
746
  ```yaml
736
747
  followups:
737
- default_milestone: "Backlog" # optional: milestone title used when the parent's milestone has shipped and no later one is open
748
+ default_milestone: "Backlog" # optional: milestone title used when the parent's milestone has shipped and no later one is open
749
+ 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
738
750
  ```
739
751
 
740
752
  ## `rollouts` (OPTIONAL)
@@ -888,6 +900,7 @@ verify:
888
900
  guard_command: # optional; per-extension command for the guard gate's base test files
889
901
  ".py": "scripts/pytest {path}"
890
902
  e2e: auto # optional e2e merge gate: auto (default) | always | never
903
+ 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
891
904
  e2e_label: full-ci # PR label that makes the repo's CI run e2e (label path)
892
905
  e2e_checks: "e2e" # check names of the e2e jobs the label path waits on
893
906
  # e2e_workflow: e2e.yml # or: a workflow_dispatch workflow that runs e2e (wins over the label)
@@ -1018,6 +1031,7 @@ execution:
1018
1031
  | `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. |
1019
1032
  | `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. |
1020
1033
  | `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. |
1034
+ | `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). |
1021
1035
  | `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). |
1022
1036
  | `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 |
1023
1037
  | `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. |
@@ -1343,6 +1357,9 @@ verification:
1343
1357
  test_runners: # path prefix -> runner command prefix (block-style map)
1344
1358
  services/worker: "poetry -C services/worker run"
1345
1359
  web: "pnpm --filter web exec"
1360
+
1361
+ topology:
1362
+ route_reachability: "bash scripts/check-ingress.sh" # optional; blocking when declared
1346
1363
  ```
1347
1364
 
1348
1365
  | Field | Type | Required | Description |
@@ -1360,7 +1377,9 @@ verification:
1360
1377
  | `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 |
1361
1378
  | `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 |
1362
1379
  | `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. |
1380
+ | `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. |
1363
1381
  | `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. |
1382
+ | `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`. |
1364
1383
 
1365
1384
  **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`
1366
1385