forgedock 1.35.0 → 1.36.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 (51) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +113 -18
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +1 -1
  8. package/bin/hooks/reread-nudge.mjs +15 -4
  9. package/bin/lean/batch.mjs +42 -2
  10. package/bin/lean/batch.test.mjs +101 -1
  11. package/bin/lean/conformance.mjs +14 -3
  12. package/bin/lean/conformance.test.mjs +26 -0
  13. package/bin/lean/effects.mjs +3 -0
  14. package/bin/lean/execution.mjs +2 -0
  15. package/bin/lean/i18n.mjs +65 -0
  16. package/bin/lean/i18n.test.mjs +47 -0
  17. package/bin/lean/lane.mjs +5 -2
  18. package/bin/lean/lean.mjs +37 -4
  19. package/bin/lean/lean.test.mjs +124 -5
  20. package/bin/lean/orchestrate.mjs +49 -5
  21. package/bin/lean/postdeploy-check.mjs +149 -0
  22. package/bin/lean/postdeploy-check.test.mjs +146 -0
  23. package/bin/lean/postdeploy-monitor.mjs +87 -0
  24. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  25. package/bin/lean/precheck.mjs +6 -4
  26. package/bin/lean/precheck.test.mjs +17 -2
  27. package/bin/lean/prefilter.mjs +29 -4
  28. package/bin/lean/prefilter.test.mjs +13 -5
  29. package/bin/lean/probe.mjs +2 -1
  30. package/bin/lean/prompts.mjs +10 -2
  31. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  32. package/bin/lean/sandbox-remote.mjs +7 -0
  33. package/bin/lean/schedd.mjs +24 -4
  34. package/bin/lean/schedd.test.mjs +54 -0
  35. package/bin/stats/aggregate.mjs +1 -1
  36. package/bin/stats/experiment.mjs +44 -0
  37. package/bin/stats/github-source.mjs +2 -1
  38. package/bin/stats/index.mjs +42 -2
  39. package/bin/stats/release.mjs +75 -0
  40. package/bin/stats/release.test.mjs +68 -0
  41. package/commands/orchestrate.md +8 -3
  42. package/docs/CONFIG.md +49 -0
  43. package/docs/EVENTS.md +68 -0
  44. package/docs/STATS.md +31 -0
  45. package/docs/TELEMETRY.md +3 -1
  46. package/docs/site/install.md +89 -0
  47. package/package.json +1 -1
  48. package/scripts/bench-lightlane.mjs +2 -1
  49. package/scripts/eval-gate-scorecard.mjs +7 -0
  50. package/scripts/file-deferred-followups.sh +1 -0
  51. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -0,0 +1,146 @@
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 { parseChecks, itemCheckIds, measureFollowup, monitorRecord, CHECK_ID_RE } from "./postdeploy-check.mjs";
6
+ import { postDeployOnly } from "./prefilter.mjs";
7
+ import { validateContract, applyAmendments, precheck, followupItems } from "./conformance.mjs";
8
+ import { PROBE_MARKER } from "./probe.mjs";
9
+
10
+ const item = (id, why) => `- [ ] \`${id}: some quote here\` — post-deploy: ${why}`;
11
+ const body = (items) => `## Acceptance Criteria\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
12
+ const CHECKS = JSON.stringify({ smoke: { run: "echo ok", timeout: 5 }, bad: "echo nope", "bad id!": "x", rx: { run: "echo up", pass: "/up/" }, norun: {} });
13
+
14
+ function world({ file = CHECKS, spawn } = {}) {
15
+ const w = { gh: [], git: [], spawned: [] };
16
+ w.io = {
17
+ gh: async (a) => { w.gh.push(a); return ""; },
18
+ git: async (a) => { w.git.push(a); if (a[0] === "show") { if (file == null) throw new Error("missing"); return file; } return "deadbeef\n"; },
19
+ };
20
+ w.spawn = async (o) => { w.spawned.push(o.run); return spawn ? spawn(o) : { code: o.run === "echo nope" ? 1 : 0, stdout: o.run === "echo nope" ? "boom" : "ok", stderr: "" }; };
21
+ return w;
22
+ }
23
+ const run = (w, bodyText, extra = {}) => measureFollowup({ iss: { number: 41, body: bodyText, comments: [] }, repo: "o/r", parent: 40, def: "main", shas: ["abc"], cwd: "/repo", io: w.io, config: { env: [], timeoutS: 5 }, env: {}, spawn: w.spawn, ...extra });
24
+
25
+ describe("post-deploy named checks (#4626)", () => {
26
+ it("parses the checks file; malformed entries are dropped", () => {
27
+ const c = parseChecks(CHECKS);
28
+ assert.deepEqual(Object.keys(c).sort(), ["bad", "rx", "smoke"]);
29
+ assert.equal(c.smoke.timeoutS, 5); assert.equal(c.rx.pass.kind, "regex");
30
+ assert.deepEqual(parseChecks("not json"), {});
31
+ assert.ok(CHECK_ID_RE.test("login-smoke") && !CHECK_ID_RE.test("Bad Id"));
32
+ });
33
+
34
+ it("postDeployOnly still matches items with and without a [check:id] token", () => {
35
+ assert.ok(postDeployOnly(body([item("R1", "[check:smoke] soak"), item("R2", "only in prod")])));
36
+ });
37
+ it("extracts [check:id] per item; an item without one yields null", () => {
38
+ assert.deepEqual(itemCheckIds(body([item("R1", "[check:smoke] soak"), item("R2", "only in prod")])), ["smoke", null]);
39
+ });
40
+
41
+ it("pass: runs the default-branch command, records FORGE:PROBE, closes the follow-up, no reopen", async () => {
42
+ const w = world();
43
+ const r = await run(w, body([item("R1", "[check:smoke] x")]));
44
+ assert.equal(r.state, "pass");
45
+ assert.deepEqual(w.spawned, ["echo ok"]);
46
+ assert.ok(w.git.some((a) => a.join(" ") === "show origin/main:.forge/post-deploy-checks.json"));
47
+ const cm = w.gh.find((a) => a[1] === "comment");
48
+ assert.match(cm[cm.indexOf("--body") + 1], new RegExp(PROBE_MARKER));
49
+ assert.ok(w.gh.some((a) => a[1] === "close" && a[2] === "41"));
50
+ assert.ok(!w.gh.some((a) => a[1] === "reopen"));
51
+ });
52
+
53
+ it("fail: reopens the parent with the output and does not close", async () => {
54
+ const w = world();
55
+ const r = await run(w, body([item("R1", "[check:bad] x")]));
56
+ assert.equal(r.state, "fail");
57
+ assert.ok(w.gh.some((a) => a[1] === "reopen" && a[2] === "40"));
58
+ assert.ok(w.gh.some((a) => a[1] === "comment" && a[2] === "40" && a[a.indexOf("--body") + 1].includes("boom")));
59
+ assert.ok(!w.gh.some((a) => a[1] === "close"));
60
+ });
61
+
62
+ it("no runnable check: awaiting-measurement, nothing runs, no model call", async () => {
63
+ for (const [file, b] of [[CHECKS, body([item("R1", "[check:ghost] x")])], [null, body([item("R1", "[check:smoke] x")])], [CHECKS, body([item("R1", "x"), item("R2", "[check:smoke] y")])]]) {
64
+ const w = world({ file });
65
+ assert.equal((await run(w, b)).state, "awaiting-measurement");
66
+ assert.deepEqual(w.spawned, []); assert.deepEqual(w.gh, []);
67
+ }
68
+ });
69
+
70
+ it("a command declared in the issue body never runs", async () => {
71
+ const w = world();
72
+ const b = body([item("R1", "[check:ghost] x")]) + "\n```forge-probe\nrun: rm -rf /\n```\n";
73
+ assert.equal((await run(w, b)).state, "awaiting-measurement");
74
+ assert.deepEqual(w.spawned, []);
75
+ });
76
+
77
+ it("migration: the six open follow-up shapes (no check id) all park awaiting-measurement", async () => {
78
+ for (const n of [4415, 4421, 4438, 4488, 4495, 4498]) {
79
+ const w = world();
80
+ const r = await run(w, body([item(`R${n % 7}`, "only checkable after the next deploy")]));
81
+ assert.equal(r.state, "awaiting-measurement", `#${n}`);
82
+ assert.deepEqual(w.spawned, []);
83
+ }
84
+ });
85
+
86
+ it("a recorded fail for the same fix is not re-run", async () => {
87
+ const w = world();
88
+ const c = { body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "abc", result: "fail" })}` };
89
+ const r = await run(w, body([item("R1", "[check:smoke] x")]), { iss: { number: 41, body: body([item("R1", "[check:smoke] x")]), comments: [c] }, isTrusted: () => true });
90
+ assert.equal(r.state, "recorded-fail"); assert.deepEqual(w.spawned, []);
91
+ });
92
+
93
+ it("contract: post-deploy needs a check id; amendment too; precheck rejects an unknown id; followup item names it", () => {
94
+ const Q = "The deploy-time health row must exist after promotion.";
95
+ const c = { requirements: [{ id: "R1", quote: Q, disposition: "post-deploy", why: "w" }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] };
96
+ assert.ok(validateContract(c, Q).problems.some((p) => /needs a check id/.test(p)));
97
+ assert.ok(!validateContract({ ...c, requirements: [{ ...c.requirements[0], check: "smoke" }] }, Q).problems.some((p) => /check id/.test(p)));
98
+ const frozen = { ...c, requirements: [{ id: "R1", quote: Q, disposition: "deliver" }] };
99
+ assert.ok(applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q).problems.some((p) => /check id/.test(p)));
100
+ const ok = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "smoke" }], Q);
101
+ assert.equal(ok.contract.requirements[0].check, "smoke");
102
+ const pc = precheck({ contract: { ...c, requirements: [{ ...c.requirements[0], check: "ghost" }] }, result: { requirements: [] }, changedFiles: [], checkIds: new Set(["smoke"]) });
103
+ assert.ok(pc.failures.some((f) => f.kind === "check_unknown"));
104
+ assert.match(followupItems([{ ...c.requirements[0], check: "smoke" }])[0].why, /^post-deploy: \[check:smoke\] w/);
105
+ });
106
+ });
107
+
108
+ describe("windowed post-deploy checks (#4627)", () => {
109
+ const WIN = JSON.stringify({ soak: { run: "echo ok", for: "7d", every: "6h" }, soakd: { run: "echo ok", for: "2d" }, noFor: { run: "echo ok", every: "6h" }, badFor: { run: "echo ok", for: "soon" }, zero: { run: "echo ok", for: "0" }, plain: "echo ok" });
110
+ const NOW = Date.parse("2026-10-26T00:00:00Z");
111
+ it("parses for/every; every defaults to 6h; every without for or a bad duration drops the entry", () => {
112
+ const c = parseChecks(WIN);
113
+ assert.deepEqual(Object.keys(c).sort(), ["plain", "soak", "soakd"]);
114
+ assert.equal(c.soak.forMs, 7 * 86400e3); assert.equal(c.soak.everyMs, 6 * 3600e3);
115
+ assert.equal(c.soakd.everyMs, 6 * 3600e3);
116
+ assert.equal(c.plain.forMs, undefined);
117
+ });
118
+ it("first pass records the release and window, labels, and keeps the follow-up open", async () => {
119
+ const w = world({ file: WIN });
120
+ const r = await run(w, body([item("R1", "[check:soak] x")]), { now: () => NOW });
121
+ assert.equal(r.state, "monitoring");
122
+ assert.ok(!w.gh.some((a) => a[1] === "close") && !w.gh.some((a) => a[1] === "reopen"));
123
+ assert.ok(w.gh.some((a) => a[1] === "edit" && a.includes("post-deploy-monitoring")));
124
+ const cm = w.gh.find((a) => a[1] === "comment");
125
+ const rec = monitorRecord(cm[cm.indexOf("--body") + 1]);
126
+ assert.equal(rec.result, "pass"); assert.equal(rec.until, NOW + 7 * 86400e3); assert.equal(rec.everyMs, 6 * 3600e3); assert.equal(rec.defaultSha, "deadbeef");
127
+ });
128
+ it("a recorded windowed pass is not re-measured by the pre-filter path", async () => {
129
+ const w = world({ file: WIN });
130
+ const rec = { body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "abc", result: "pass", at: new Date(NOW).toISOString(), window_until: new Date(NOW + 1e9).toISOString() })}` };
131
+ const b = body([item("R1", "[check:soak] x")]);
132
+ const r = await run(w, b, { iss: { number: 41, body: b, comments: [rec] }, isTrusted: () => true });
133
+ assert.equal(r.state, "monitoring"); assert.deepEqual(w.spawned, []);
134
+ });
135
+ it("a non-windowed check still closes on pass (part 1 unchanged)", async () => {
136
+ const w = world({ file: WIN });
137
+ assert.equal((await run(w, body([item("R1", "[check:plain] x")]))).state, "pass");
138
+ assert.ok(w.gh.some((a) => a[1] === "close"));
139
+ });
140
+ it("a first-run fail of a windowed check reopens the parent as before", async () => {
141
+ const w = world({ file: JSON.stringify({ soak: { run: "echo nope", for: "7d" } }), spawn: () => ({ code: 1, stdout: "boom", stderr: "" }) });
142
+ assert.equal((await run(w, body([item("R1", "[check:soak] x")]))).state, "fail");
143
+ assert.ok(w.gh.some((a) => a[1] === "reopen" && a[2] === "40"));
144
+ });
145
+ });
146
+
@@ -0,0 +1,87 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Windowed post-deploy monitoring (#4627). A follow-up whose checks declare `for` (and `every`) stays open with the
5
+ * `post-deploy-monitoring` label after its first pass. `sweepDue` (run by schedd's timer and `forgedock probe --due`) re-runs
6
+ * each one when its cadence elapsed or `origin/<default>` moved (a release), with the command read from the default branch
7
+ * (never an issue). A pass is a FORGE:PROBE record; the window end closes the follow-up; a fail after a pass files one
8
+ * regression issue naming the fix commit and the last-passing and breaking release, then closes the follow-up. No model call.
9
+ */
10
+ import { readProbeConfig, resolveDefaultBranch, PROBE_MARKER, OUTPUT_TAIL } from "./probe.mjs";
11
+ import { MONITORING_LABEL, checksAt, itemCheckIds, monitorRecord, runNamedChecks, CHECKS_FILE } from "./postdeploy-check.mjs";
12
+ import { deferredFrom, isTrustedIssueComment } from "./prefilter.mjs";
13
+
14
+ export const REGRESSION_MARKER = "FORGE:REGRESSION";
15
+ const fenced = (s) => { const f = "`".repeat(Math.max(3, ...[...String(s).matchAll(/`+/g)].map((m) => m[0].length + 1))); return `${f}\n${String(s).trim() || "(no output)"}\n${f}`; };
16
+ const short = (s) => String(s ?? "unknown").slice(0, 12);
17
+
18
+ /** One monitored follow-up. Outcomes: skipped | unrunnable | not-due | due (dry run) | pass | window-ended | fail. */
19
+ export async function monitorOne({ issue, repo, def, defaultSha, cwd, io, now, dryRun, config, env, spawn }) {
20
+ const row = (outcome, detail = "", extra = {}) => ({ issue, outcome, detail, ...extra });
21
+ const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
22
+ if (String(iss.state).toUpperCase() !== "OPEN") return row("skipped", "not open");
23
+ const recs = (iss.comments ?? []).filter((c) => isTrustedIssueComment(c)).map((c) => monitorRecord(c?.body)).filter(Boolean);
24
+ const last = recs[recs.length - 1];
25
+ const finish = async (comment) => {
26
+ if (comment) await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", comment]);
27
+ await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]);
28
+ try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", MONITORING_LABEL]); } catch { /* label already gone */ }
29
+ };
30
+ if (!last || last.result !== "pass" || !last.until) return row("skipped", last?.result === "fail" ? "already failed (FORGE:PROBE)" : "no windowed pass record");
31
+ if (now >= last.until) {
32
+ if (!dryRun) await finish(`<!-- FORGE:MONITOR -->\nPost-deploy monitoring window ended ${new Date(last.until).toISOString()} with no failure; closed.`);
33
+ return row("window-ended", dryRun ? "dry run: not closed" : "closed", { fix: last.fix });
34
+ }
35
+ if (now - last.at < last.everyMs && defaultSha && defaultSha === last.defaultSha) return row("not-due", `next run after ${new Date(last.at + last.everyMs).toISOString()} or a new release`);
36
+ const ids = itemCheckIds(iss.body);
37
+ const checks = await checksAt(io.git, [`origin/${def}`], cwd);
38
+ if (!ids.length || ids.some((id) => !id || !checks[id])) return row("unrunnable", `no runnable check on ${def} (${CHECKS_FILE})`);
39
+ if (dryRun) return row("due", "dry run: not run", { fix: last.fix });
40
+ const rows = await runNamedChecks({ ids, checks, cwd, config, env, spawn });
41
+ const ok = rows.every((r) => r.result === "pass");
42
+ const rec = { fix: last.fix, default_sha: defaultSha, result: ok ? "pass" : "fail", at: new Date(now).toISOString(), ...(ok ? { window_until: new Date(last.until).toISOString(), every_ms: last.everyMs } : {}) };
43
+ const sum = rows.map((r) => ({ id: r.id, result: r.result, exit: r.exit, ms: r.ms }));
44
+ if (ok) {
45
+ await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- ${PROBE_MARKER} -->\n## Post-deploy check: pass\n${JSON.stringify({ ...rec, checks: sum })}\n\nRe-run on \`${def}\` at \`${short(defaultSha)}\`.`]);
46
+ return row("pass", `checks ${[...new Set(ids)].join(", ")} passed at ${short(defaultSha)}`, { fix: last.fix });
47
+ }
48
+ const bad = rows.filter((r) => r.result === "fail");
49
+ const parent = deferredFrom(iss.body);
50
+ const title = `Regression: post-deploy check ${bad.map((r) => r.id).join(", ")} failed on ${def}${parent ? ` (fix of #${parent})` : ""}`;
51
+ const body = [
52
+ `<!-- ${REGRESSION_MARKER} -->`, `Post-deploy check ${bad.map((r) => `\`${r.id}\``).join(", ")} passed after the fix and now fails.`, "",
53
+ `- Fix commit: \`${last.fix}\``, `- Last passing release (\`${def}\`): \`${last.defaultSha ?? "unknown"}\``, `- Breaking release (\`${def}\`): \`${defaultSha ?? "unknown"}\``,
54
+ `- Monitored follow-up: #${issue}${parent ? `; parent: #${parent}` : ""}`, "", ...bad.flatMap((r) => [`**${r.id}** (exit ${r.exit ?? "timeout"})`, fenced(r.output), ""]),
55
+ ].join("\n").slice(0, OUTPUT_TAIL * 4);
56
+ const created = String(await io.gh(["issue", "create", "-R", repo, "--title", title, "--body", body]));
57
+ const num = /\/issues\/(\d+)/.exec(created)?.[1] ?? null;
58
+ await finish(`<!-- ${PROBE_MARKER} -->\n## Post-deploy check: fail\n${JSON.stringify({ ...rec, regression: num ? Number(num) : null, checks: sum })}\n\nRegression issue ${num ? `#${num}` : "filed"}; monitoring stops.`);
59
+ return row("fail", `regression ${num ? `#${num}` : "filed"}: ${bad.map((r) => r.id).join(", ")} broke at ${short(defaultSha)} (fix ${short(last.fix)})`, { fix: last.fix, regression: num });
60
+ }
61
+
62
+ /** Re-run every due monitored follow-up of the repo. Sequential; an error is an `error` row, never a throw. */
63
+ export async function sweepDue({ repo, cwd = process.cwd(), io, dryRun = false, now = () => Date.now(), config = readProbeConfig(cwd), env = process.env, spawn, onResult = null } = {}) {
64
+ const def = await resolveDefaultBranch({ repo, io, cwd });
65
+ if (!def) throw new Error("no default branch");
66
+ const found = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--label", MONITORING_LABEL, "--limit", "100", "--json", "number"])));
67
+ const list = (Array.isArray(found) ? found : []).map((i) => i.number).filter(Number.isInteger);
68
+ if (!list.length) return [];
69
+ try { await io.git(["fetch", "origin", def], { cwd }); } catch { /* offline: the local ref */ }
70
+ let defaultSha = null;
71
+ try { defaultSha = String(await io.git(["rev-parse", `origin/${def}`], { cwd })).trim() || null; } catch { /* unknown release */ }
72
+ const rows = [];
73
+ for (const issue of list) {
74
+ try { rows.push(await monitorOne({ issue, repo, def, defaultSha, cwd, io, now: now(), dryRun, config, env, spawn })); } catch (e) {
75
+ rows.push({ issue, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) });
76
+ }
77
+ try { if (!dryRun) await onResult?.(rows[rows.length - 1]); } catch { /* an event sink never fails the sweep */ }
78
+ }
79
+ return rows;
80
+ }
81
+
82
+ /** One line, e.g. `monitoring: pass #1; regression #2; closed #3`; "" when nothing is monitored. */
83
+ export function formatDueSummary(rows) {
84
+ const g = [["pass", "pass"], ["fail", "regression"], ["window-ended", "window ended"], ["due", "due"], ["not-due", "not due"], ["unrunnable", "unrunnable"], ["error", "error"]];
85
+ const parts = g.map(([k, l]) => [l, (rows ?? []).filter((r) => r.outcome === k).map((r) => `#${r.issue}`)]).filter(([, a]) => a.length).map(([l, a]) => `${l} ${a.join(" ")}`);
86
+ return parts.length ? `monitoring: ${parts.join("; ")}` : "";
87
+ }
@@ -0,0 +1,121 @@
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 { sweepDue, formatDueSummary } from "./postdeploy-monitor.mjs";
6
+ import { PROBE_MARKER } from "./probe.mjs";
7
+ import { isTrustedIssueComment } from "./prefilter.mjs";
8
+
9
+ const T0 = Date.parse("2026-10-26T00:00:00Z");
10
+ const H = 3600e3;
11
+ const CHECKS = JSON.stringify({ soak: { run: "echo ok", for: "7d", every: "6h" } });
12
+ const BODY = "## Acceptance Criteria\n\n- [ ] `R1: health row exists` — post-deploy: [check:soak] soak\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n";
13
+ const rec = (o) => ({ authorAssociation: "OWNER", author: { login: "bot" }, body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "fix1234567890", default_sha: "rel1", result: "pass", at: new Date(T0).toISOString(), window_until: new Date(T0 + 7 * 24 * H).toISOString(), every_ms: 6 * H, ...o })}` });
14
+
15
+ function world({ comments = [rec({})], head = "rel1", file = CHECKS, code = 0, state = "OPEN" } = {}) {
16
+ const w = { gh: [], git: [], spawned: [] };
17
+ w.io = {
18
+ gh: async (a) => {
19
+ w.gh.push(a);
20
+ const k = a.slice(0, 2).join(" ");
21
+ if (k === "repo view") return "main\n";
22
+ if (k === "issue list") return JSON.stringify([{ number: 41 }]);
23
+ if (k === "issue view") return JSON.stringify({ number: 41, state, title: "", body: BODY, labels: [{ name: "post-deploy-monitoring" }], comments });
24
+ if (k === "issue create") return "https://github.com/o/r/issues/77\n";
25
+ return "";
26
+ },
27
+ git: async (a) => { w.git.push(a); if (a[0] === "show") { if (file == null) throw new Error("missing"); return file; } if (a[0] === "rev-parse") return `${head}\n`; return ""; },
28
+ };
29
+ w.spawn = async (o) => { w.spawned.push(o.run); return { code, stdout: code ? "broke" : "ok", stderr: "" }; };
30
+ return w;
31
+ }
32
+ const sweep = (w, o = {}) => sweepDue({ repo: "o/r", cwd: "/repo", io: w.io, now: () => T0 + H, config: { env: [], timeoutS: 5 }, env: {}, spawn: w.spawn, ...o });
33
+ const calls = (w, k) => w.gh.filter((a) => a.slice(0, 2).join(" ") === k);
34
+
35
+ const trustedOk = isTrustedIssueComment(rec({}));
36
+
37
+ describe("post-deploy windowed monitoring (#4627)", () => {
38
+ it("fixture comments count as trusted", () => assert.ok(trustedOk));
39
+
40
+ it("not due: cadence not elapsed and no new release", async () => {
41
+ const w = world();
42
+ const rows = await sweep(w);
43
+ assert.equal(rows[0].outcome, "not-due"); assert.deepEqual(w.spawned, []);
44
+ });
45
+
46
+ it("due by cadence: re-runs the default-branch command and records a pass; the follow-up stays open", async () => {
47
+ const w = world();
48
+ const rows = await sweep(w, { now: () => T0 + 7 * H });
49
+ assert.equal(rows[0].outcome, "pass"); assert.deepEqual(w.spawned, ["echo ok"]);
50
+ assert.ok(w.git.some((a) => a.join(" ") === "show origin/main:.forge/post-deploy-checks.json"));
51
+ assert.equal(calls(w, "issue comment").length, 1);
52
+ assert.equal(calls(w, "issue close").length, 0); assert.equal(calls(w, "issue create").length, 0);
53
+ });
54
+
55
+ it("due by a new release (origin/main moved) well before the cadence", async () => {
56
+ const w = world({ head: "rel2" });
57
+ const rows = await sweep(w);
58
+ assert.equal(rows[0].outcome, "pass"); assert.deepEqual(w.spawned, ["echo ok"]);
59
+ });
60
+
61
+ it("window end closes the follow-up and drops the label without running", async () => {
62
+ const w = world();
63
+ const rows = await sweep(w, { now: () => T0 + 8 * 24 * H });
64
+ assert.equal(rows[0].outcome, "window-ended"); assert.deepEqual(w.spawned, []);
65
+ assert.equal(calls(w, "issue close").length, 1);
66
+ assert.ok(calls(w, "issue edit").some((a) => a.includes("--remove-label")));
67
+ });
68
+
69
+ it("pass then fail files one regression naming the fix commit and both releases, then closes the follow-up", async () => {
70
+ const w = world({ head: "rel2", code: 1 });
71
+ const rows = await sweep(w);
72
+ assert.equal(rows[0].outcome, "fail"); assert.equal(rows[0].regression, "77");
73
+ const [create] = calls(w, "issue create");
74
+ const text = create[create.indexOf("--body") + 1];
75
+ assert.match(create[create.indexOf("--title") + 1], /Regression: post-deploy check soak failed on main \(fix of #40\)/);
76
+ assert.match(text, /FORGE:REGRESSION/); assert.match(text, /fix1234567890/); assert.match(text, /`rel1`/); assert.match(text, /`rel2`/); assert.match(text, /broke/);
77
+ assert.equal(calls(w, "issue close").length, 1);
78
+ assert.ok(!w.gh.some((a) => a[1] === "reopen"));
79
+ const cm = calls(w, "issue comment")[0];
80
+ assert.match(cm[cm.indexOf("--body") + 1], /"result":"fail".*"regression":77/s);
81
+ });
82
+
83
+ it("a follow-up whose latest record is a fail is never re-run or re-filed", async () => {
84
+ const w = world({ head: "rel3", code: 1, comments: [rec({}), rec({ result: "fail", default_sha: "rel2", regression: 77 })] });
85
+ const rows = await sweep(w);
86
+ assert.equal(rows[0].outcome, "skipped"); assert.deepEqual(w.spawned, []); assert.equal(calls(w, "issue create").length, 0);
87
+ });
88
+
89
+ it("an untrusted record never counts", async () => {
90
+ const w = world({ comments: [{ authorAssociation: "NONE", author: { login: "rando" }, body: rec({}).body }] });
91
+ assert.equal((await sweep(w, { now: () => T0 + 9 * H }))[0].outcome, "skipped");
92
+ assert.deepEqual(w.spawned, []);
93
+ });
94
+
95
+ it("a check missing on the default branch is unrunnable and never run", async () => {
96
+ for (const file of [null, JSON.stringify({ other: "echo hi" })]) {
97
+ const w = world({ file, head: "rel2" });
98
+ assert.equal((await sweep(w))[0].outcome, "unrunnable"); assert.deepEqual(w.spawned, []);
99
+ }
100
+ });
101
+
102
+ it("dry run runs nothing and writes nothing", async () => {
103
+ const w = world({ head: "rel2" });
104
+ assert.equal((await sweep(w, { dryRun: true }))[0].outcome, "due");
105
+ assert.deepEqual(w.spawned, []);
106
+ assert.equal(calls(w, "issue comment").length + calls(w, "issue close").length + calls(w, "issue create").length, 0);
107
+ });
108
+
109
+ it("no monitored follow-up: no git, no run; the summary names outcomes", async () => {
110
+ const w = world();
111
+ w.io.gh = async (a) => (a[0] === "repo" ? "main\n" : "[]");
112
+ assert.deepEqual(await sweep(w), []); assert.equal(w.git.length, 0);
113
+ assert.equal(formatDueSummary([{ issue: 1, outcome: "pass" }, { issue: 2, outcome: "fail" }]), "monitoring: pass #1; regression #2");
114
+ });
115
+
116
+ it("reports sink rows for pass and fail only when not a dry run", async () => {
117
+ const seen = [];
118
+ await sweep(world({ head: "rel2", code: 1 }), { onResult: (r) => seen.push(r.outcome) });
119
+ assert.deepEqual(seen, ["fail"]);
120
+ });
121
+ });
@@ -42,8 +42,8 @@ export function retryTimeoutMs({ perMs, load1, cores }) {
42
42
 
43
43
  /**
44
44
  * #4589: a check timeout (rc 124) is a busy host, not a failing command: run `run(timeoutMs)`, and when it timed out on its
45
- * own, once more with `retryTimeoutMs`. `left` (ms still available in a round budget) caps the retry; a retry the budget cut
46
- * short is `cut` (left to CI). A repeated timeout keeps rc 124 and says it was infrastructure. Other rcs are never retried.
45
+ * own, once more with `retryTimeoutMs`. `left` (ms still available in a round budget) caps the retry; an exhausted budget, or a retry the budget cut
46
+ * short, is `cut` (left to CI); a budget with room for no longer retry leaves the first rc 124 a failure (#4620). A repeated timeout keeps rc 124 and says it was infrastructure. Other rcs are never retried.
47
47
  * @param {(timeoutMs:number) => Promise<{rc:number, output?:string}>} run
48
48
  * @returns {Promise<{r:{rc:number, output?:string}, retried:boolean, cut:boolean}>}
49
49
  */
@@ -52,8 +52,10 @@ export async function runWithTimeoutRetry(run, timeoutMs, { left = () => Infinit
52
52
  if (r?.rc !== TIMEOUT_RC || !(timeoutMs > 0) || !Number.isFinite(timeoutMs)) return { r, retried: false, cut: false };
53
53
  let want = timeoutMs;
54
54
  try { want = retryTimeoutMs({ perMs: timeoutMs, ...load() }); } catch { /* no load reading: the minimum factor */ want = timeoutMs * 2; }
55
- const t2 = Math.min(want, left());
56
- if (!(t2 > timeoutMs)) return { r, retried: false, cut: t2 < want }; // no budget left to retry in: CI runs it
55
+ const room = left();
56
+ if (!(room > 0)) return { r, retried: false, cut: true }; // the budget is spent: CI runs it
57
+ const t2 = Math.min(want, room);
58
+ if (!(t2 > timeoutMs)) return { r, retried: false, cut: false }; // #4620: no room for a longer retry; its own timeout stays a failure
57
59
  const r2 = await run(t2);
58
60
  const cut = r2?.rc === TIMEOUT_RC && t2 < want;
59
61
  if (r2?.rc === TIMEOUT_RC && !cut) return { r: { ...r2, output: `${String(r2.output ?? "")}\n(infra timeout: host load, not a code failure; retried once with ${Math.round(t2 / 1000)}s)` }, retried: true, cut };
@@ -256,11 +256,26 @@ describe("check timeout is infrastructure (#4589)", () => {
256
256
  assert.equal(h2.calls.length, 1);
257
257
  assert.equal(r2.results[0].rc, 1);
258
258
  });
259
- it("a retry the budget cuts short is deferred", async () => {
259
+ it("an exhausted budget defers a command that hit its own timeout", async () => {
260
260
  const h = harness({ cost: { slow: 9e9 } });
261
- const r = await runChecks({ commands: ["slow"], budgetMs: 1500, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
261
+ const r = await runChecks({ commands: ["slow"], budgetMs: 1000, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
262
+ assert.equal(h.calls.length, 1);
262
263
  assert.deepEqual(r.deferred, ["slow"]);
263
264
  });
265
+ it("default budget: a check hitting its own timeout is reported, not deferred (#4620)", async () => {
266
+ const plan = planPrecheck({ verify: {}, learned: ["npm test"] });
267
+ const h = harness({ cost: { "npm test": 9e9 } });
268
+ const r = await runChecks({ commands: plan.commands, budgetMs: plan.budgetMs, perCommandMs: plan.perCommandMs, now: h.now, run: h.run, load: idle });
269
+ assert.equal(r.results.length, 1);
270
+ assert.equal(r.results[0].rc, 124);
271
+ assert.deepEqual(r.deferred, []);
272
+ });
273
+ it("a budget with room left but no longer retry reports the timeout as a failure", async () => {
274
+ const h = harness({ cost: { slow: 9e9 } });
275
+ const r = await runChecks({ commands: ["slow"], budgetMs: 1500, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
276
+ assert.equal(h.calls.length, 1);
277
+ assert.deepEqual([r.results.map((x) => x.rc), r.deferred], [[124], []]);
278
+ });
264
279
  it("runWithTimeoutRetry reports retried", async () => {
265
280
  let n = 0;
266
281
  const x = await runWithTimeoutRetry(async (ms) => (++n === 1 ? { rc: 124, output: "t" } : { rc: 0, output: String(ms) }), 1000, { load: idle });
@@ -15,6 +15,8 @@
15
15
  * of origin/<default>, or while forge.yaml `post_deploy.precondition_cmd` exits 1. With `post_deploy.min_age` (#4425) it also
16
16
  * stays parked (`awaiting-min-age`) until that long (or the longest window an item's why states) after the fix landed on
17
17
  * the default branch. Anything unresolvable fails open.
18
+ * Named checks (#4626): once ready, the items' `[check:<id>]` checks run from the default branch's checks file (no model
19
+ * call): pass closes the follow-up, fail reopens the parent. An item without a runnable check parks `awaiting-measurement`.
18
20
  * All effects are injected (gh, git, runner, transitionLabel, projector?, execCode?) so it is unit-testable.
19
21
  */
20
22
  import { execFile } from "node:child_process";
@@ -23,6 +25,7 @@ import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prom
23
25
  import { isTrustedComment } from "../engine/phases.mjs";
24
26
  import { APP_SLUG } from "../init-repo.mjs";
25
27
  import { probeResultOf } from "./probe.mjs";
28
+ import { AWAITING_MEASUREMENT_LABEL, MONITORING_LABEL, itemCheckIds, measureFollowup } from "./postdeploy-check.mjs";
26
29
 
27
30
  export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
28
31
  export const PREFILTER_MARKER = "FORGE:PREFILTER";
@@ -300,16 +303,20 @@ export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
300
303
  }
301
304
  /** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
302
305
  async function markParked({ issue, repo, io, had, gate }) {
303
- const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", AWAITING_PROMOTION_LABEL]);
306
+ const measure = gate.state === "awaiting-measurement"; // #4626
307
+ const label = measure ? AWAITING_MEASUREMENT_LABEL : AWAITING_PROMOTION_LABEL;
308
+ const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", label]);
304
309
  try { await edit(); } catch {
305
310
  try {
306
- await io.gh(["label", "create", AWAITING_PROMOTION_LABEL, "-R", repo, "--force", "--color", "C5DEF5", "--description", "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
311
+ await io.gh(["label", "create", label, "-R", repo, "--force", "--color", "C5DEF5", "--description", measure ? "Post-deploy follow-up with no runnable default-branch check; never given a lane. Managed by ForgeDock." : "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
307
312
  await edit();
308
313
  } catch { /* the comment and the report still show it */ }
309
314
  }
310
315
  if (had) return;
311
- const what = gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
312
- const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\nEvery item here is a post-deploy check, so no lane was started. The next orchestrate or triage run checks again and launches this issue once the precondition holds.`;
316
+ const what = measure ? "awaiting measurement" : gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
317
+ const tail = measure ? "Every item here is a post-deploy check, and at least one names no runnable check, so no lane was started and none will be. Name each one with `[check:<id>]` and add `<id>` to the checks file on the default branch, or measure it by hand."
318
+ : "Every item here is a post-deploy check, so no lane was started. The next orchestrate or triage run checks again and launches this issue once the precondition holds.";
319
+ const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\n${tail}`;
313
320
  try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
314
321
  }
315
322
 
@@ -328,6 +335,12 @@ export function formatParkedLine(rows) {
328
335
  const defs = uniq(soak.map((r) => r.prefilter.default));
329
336
  out.push(`awaiting min age: ${soak.map((r) => `#${r.issue}`).join(" ")} (fix on ${defs.join(", ") || "the default branch"}, soak window not elapsed)`);
330
337
  }
338
+ const meas = parked.filter((r) => r.prefilter.kind === "awaiting-measurement"); // #4626
339
+ if (meas.length) out.push(`awaiting measurement: ${meas.map((r) => `#${r.issue}`).join(" ")} (no runnable default-branch check; no lane)`);
340
+ const failed = parked.filter((r) => r.prefilter.kind === "check-failed");
341
+ if (failed.length) out.push(`check failed: ${failed.map((r) => `#${r.issue}`).join(" ")} (parent reopened; waiting for a new fix)`);
342
+ const mon = parked.filter((r) => r.prefilter.kind === "monitoring"); // #4627
343
+ if (mon.length) out.push(`monitoring: ${mon.map((r) => `#${r.issue}`).join(" ")} (windowed post-deploy checks re-run on later releases)`);
331
344
  const pre = parked.filter((r) => r.prefilter.kind === "awaiting-precondition");
332
345
  if (pre.length) out.push(`awaiting precondition: ${pre.map((r) => `#${r.issue}`).join(" ")} (post_deploy.precondition_cmd not ready)`);
333
346
  return out.join("\n");
@@ -346,6 +359,8 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
346
359
  const labelled = names.some((l) => ["needs-human", "workflow:merged"].includes(l));
347
360
  // #4389: a post-deploy follow-up is gated even when an earlier lane escalated it (needs-human); nothing else changes.
348
361
  const gated = !!defaultBranch && !names.includes("workflow:merged") && deferredFrom(iss.body) != null && postDeployOnly(iss.body);
362
+ // #4627: a follow-up under windowed monitoring belongs to `sweepDue`, never to a lane or a second measurement
363
+ if (names.includes(MONITORING_LABEL)) return { ...out, parked: true, kind: "monitoring", detail: "windowed post-deploy checks re-run on later releases" };
349
364
  if (labelled && !gated) return { ...out, detail: "labelled, left to the lane" };
350
365
  if (io.projector) {
351
366
  let st = null;
@@ -362,6 +377,16 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
362
377
  if (gate.state === "ready" && had && !dryRun) {
363
378
  try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", AWAITING_PROMOTION_LABEL]); } catch { /* best effort */ }
364
379
  }
380
+ if (gate.state === "ready") {
381
+ const m = await measureFollowup({ iss, repo, parent: gate.parent, def: gate.default, shas: gate.shas, cwd, io, dryRun, isTrusted: isTrustedIssueComment, ...(io.spawnCheck ? { spawn: io.spawnCheck } : {}) });
382
+ if (m.state === "awaiting-measurement") {
383
+ if (!dryRun) await markParked({ issue, repo, io, had: names.includes(AWAITING_MEASUREMENT_LABEL), gate: { ...gate, state: m.state, detail: m.detail } });
384
+ return { ...out, parked: true, kind: m.state, parent: gate.parent, lane, default: gate.default, detail: m.detail };
385
+ }
386
+ if (m.state === "monitoring") return { ...out, parked: true, kind: "monitoring", parent: gate.parent, lane, default: gate.default, detail: m.detail };
387
+ if (m.state === "pass") return { ...out, closed: !dryRun, kind: "post-deploy-check", parent: gate.parent, lane, default: gate.default, detail: m.detail };
388
+ if (m.state === "fail" || m.state === "recorded-fail") return { ...out, parked: true, kind: "check-failed", parent: gate.parent, lane, default: gate.default, detail: m.detail };
389
+ }
365
390
  if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
366
391
  }
367
392
  // #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
@@ -397,14 +397,22 @@ describe("post-deploy precondition gate (#4389)", () => {
397
397
  assert.deepEqual(w.edits, []); assert.deepEqual(w.comments, []);
398
398
  });
399
399
 
400
- it("an ancestor (after promotion): not parked, the label is removed, and the issue goes on as before", async () => {
400
+ it("an ancestor (after promotion): the promotion label is removed; with no runnable check it parks awaiting-measurement, never a lane (#4626)", async () => {
401
401
  const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "awaiting-promotion" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
402
402
  const r = await gated(w, 41);
403
- assert.ok(!r.parked); assert.equal(r.detail, "no already-fixed signal");
404
- assert.deepEqual(w.edits, [[41, "--remove-label", "awaiting-promotion"]]);
403
+ assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-measurement");
404
+ assert.deepEqual(w.edits, [[41, "--remove-label", "awaiting-promotion"], [41, "--add-label", "awaiting-measurement"]]);
405
+ assert.equal(w.runner.length, 0);
405
406
  const rows = await orchestrate({ issues: [41], readBody: async () => "", prefilter: makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main" }),
406
- runOne: async () => ({ terminalReason: "merged", pr: 7, cost_usd: 1 }) });
407
- assert.deepEqual(rows.map((x) => x.outcome), ["merged"]);
407
+ runOne: async () => { throw new Error("no lane"); } });
408
+ assert.deepEqual(rows.map((x) => x.outcome), ["awaiting-measurement"]);
409
+ });
410
+
411
+ it("a follow-up under windowed monitoring is parked, never a lane or a second measurement (#4627)", async () => {
412
+ const w = world({ issues: { 41: { body: followup(40, [item("R2", "[check:smoke] post-deploy")]), labels: [{ name: "post-deploy-monitoring" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
413
+ const r = await gated(w, 41);
414
+ assert.equal(r.parked, true); assert.equal(r.kind, "monitoring"); assert.equal(r.closed, false);
415
+ assert.deepEqual(w.edits, []); assert.deepEqual(w.comments, []); assert.equal(w.runner.length, 0);
408
416
  });
409
417
 
410
418
  it("an unresolvable parent (no merged PR, no trajectory) or an unknown SHA falls through to the lane", async () => {
@@ -267,7 +267,7 @@ export async function resolveDefaultBranch({ repo, io, cwd = process.cwd() }) {
267
267
  * Probe `issues`, or (none given) the issues closed within `sweepDays` that mention `forge-probe`, capped at `limit`.
268
268
  * Sequential: probes hit production. Never throws per issue: an error is an `error` row.
269
269
  */
270
- export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null, limit = 50, sinceDays = null, dryRun = false, config = readProbeConfig(cwd), now = () => Date.now(), env = process.env, spawn } = {}) {
270
+ export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null, limit = 50, sinceDays = null, dryRun = false, config = readProbeConfig(cwd), now = () => Date.now(), env = process.env, spawn, onResult = null } = {}) {
271
271
  if (!config.enabled && !issues?.length) return [];
272
272
  const defaultBranch = await resolveDefaultBranch({ repo, io, cwd });
273
273
  if (!defaultBranch) throw new Error("no default branch");
@@ -284,6 +284,7 @@ export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null
284
284
  try { rows.push(await probeIssue({ issue, repo, cwd, io, defaultBranch, dryRun, config, env, spawn })); } catch (e) {
285
285
  rows.push({ issue, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) });
286
286
  }
287
+ try { if (!dryRun) await onResult?.(rows[rows.length - 1]); } catch { /* an event sink never fails the sweep (#4630) */ }
287
288
  }
288
289
  return rows;
289
290
  }
@@ -63,6 +63,12 @@ export function duplicateExportsPrompt(findings, mode = "local") {
63
63
  return `This change exports names that another module of the same app or package already exports:\n${rows}\n\nRename the new export or reuse the existing one, and update every reference and any registry, map or test keyed by the name (same-named entries collide or leave one unprobed). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
64
64
  }
65
65
 
66
+ /** #4596: new keys the lane copied from the source-locale catalog into another locale's catalog. */
67
+ export function i18nCopiesPrompt(findings, mode = "local") {
68
+ const rows = findings.slice(0, 30).map((d) => `- ${d.path}: ${d.key} = ${JSON.stringify(d.value)} (same as ${d.source})`).join("\n");
69
+ return `This change adds keys to locale catalogs as English copies of the source locale:\n${rows}\n\nTranslate each into that catalog's language (keep placeholders), and add any translation-status entry the repo tracks as a minimal diff: insert in place, never re-sort or reformat the file. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
70
+ }
71
+
66
72
  /** #4591: lane-added migrations the repo's generator did not produce. */
67
73
  export function migrationGeneratorPrompt(problems, mode = "local") {
68
74
  const rows = problems.slice(0, 30).map((p) => `- ${p}`).join("\n");
@@ -148,7 +154,7 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
148
154
  If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"already-fixed|invalid","summary":"one line","evidence":["file:line ..."]} and stop. If it is blocked, write {"status":"blocked","summary":"",...} with a typed "block" (rules below) and stop.
149
155
 
150
156
  2. Otherwise write ${CONTRACT_FILE}, the frozen hand-off: a developer who never saw the issue must be able to implement and verify it from this file alone.
151
- {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<required for defer and post-deploy>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
157
+ {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<defer, post-deploy>","check":"<post-deploy: id in .forge/post-deploy-checks.json>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
152
158
  "deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
153
159
  "preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
154
160
  "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
@@ -180,8 +186,9 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
180
186
  The contract changes only through typed amendments in ${RESULT_FILE}, each with a why:
181
187
  - {"kind":"add-deliverable","path":"<file>","why":""}: a file the contract did not list.
182
188
  - {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: an issue requirement the contract missed.
183
- - {"kind":"defer|post-deploy","requirement":"R#","why":""}: a requirement that becomes separate work or a post-deploy check. Filed as a tracked follow-up. Not "type"; other kinds are rejected.
189
+ - {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}: a requirement that becomes separate work or a post-deploy check. Filed as a tracked follow-up. Not "type"; other kinds are rejected.
184
190
  - {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded; judged against text.
191
+ New keys: translate, never copy English; status entries in place.
185
192
  Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
186
193
  Never name or number migration files by hand; use the generator.
187
194
  A new workspace-package dependency in a package.json no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
@@ -365,6 +372,7 @@ You receive the GitHub issue with its comments, the frozen contract (requirement
365
372
  - issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
366
373
  - amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. If it does not hold, mark the row partial with note "amendment: <what to change>".
367
374
  - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change. For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
375
+ - post-deploy requirements: deferral_ok is false when the requirement has no \`check\` id, or its check is neither in .forge/post-deploy-checks.json on the default branch nor added by this change (the commands run from that file only).
368
376
  - scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
369
377
  - preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
370
378
  - regressions: concrete breakages only, each with path:line.