forgedock 1.35.0 → 1.37.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 (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -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, OWNER_ASK_MARKER, 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";
@@ -154,17 +157,38 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
154
157
  // post-deploy checks cannot be done before its parent's fix is on the default branch: a git fact, so no lane learns it.
155
158
  export const PRECONDITION_MARKER = "FORGE:PRECONDITION";
156
159
  export const AWAITING_PROMOTION_LABEL = "awaiting-promotion";
160
+ export const AWAITING_DUE_LABEL = "awaiting-due"; // #4689
157
161
  const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
158
162
  /** The parent #P of a deferred follow-up (`<!-- FORGE:DEFERRED_FROM: #P -->`), or null. */
159
163
  export function deferredFrom(body) {
160
164
  const m = DEFERRED_FROM_RE.exec(String(body ?? ""));
161
165
  return m ? Number(m[1]) : null;
162
166
  }
167
+ const DEFERRED_FROM_ALL_RE = new RegExp(DEFERRED_FROM_RE.source, "g");
168
+ /** #4724: every parent #P of a post-deploy rollup (one `<!-- FORGE:DEFERRED_FROM: #P -->` per `### #P` section), in order, unique; [] when none. */
169
+ export const deferredFromAll = (body) => [...new Set([...String(body ?? "").matchAll(DEFERRED_FROM_ALL_RE)].map((m) => Number(m[1])))];
163
170
  const CHECK_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
164
- /** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems). */
171
+ const checkItems = (body) => noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
172
+ const DECISION_ITEM_RE = /^`[^`]*`\s+[—-]+\s*decision:/; // #4689: an item only its owner can decide
173
+ /** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems), or a `decision:` item (#4689). */
165
174
  export function postDeployOnly(body) {
166
- const items = noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
167
- return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t));
175
+ const items = checkItems(body);
176
+ return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t) || DECISION_ITEM_RE.test(t));
177
+ }
178
+ /** #4689: true when a checklist item is a `decision:` item (owner ask, never a lane). */
179
+ export const hasDecisionItem = (body) => checkItems(body).some((t) => DECISION_ITEM_RE.test(t));
180
+ const DUE_RE = /\bdue:(\d{4})-(\d{2})-(\d{2})\b/;
181
+ /** #4689: the latest `due:YYYY-MM-DD` of the post-deploy items (ms, UTC midnight), or null when none states one. */
182
+ export function itemDueMs(body) {
183
+ const ds = checkItems(body).map((t) => POST_DEPLOY_ITEM_RE.exec(t)?.[1]).filter((t) => t != null).map((t) => DUE_RE.exec(t)).filter(Boolean)
184
+ .map((m) => Date.UTC(+m[1], +m[2] - 1, +m[3])).filter(Number.isFinite);
185
+ return ds.length ? Math.max(...ds) : null;
186
+ }
187
+ const ANCHOR_RE = /\bfrom:(\d{4})-(\d{2})-(\d{2})\b/;
188
+ /** #4724: the items' `due:` dates with their contract-time `from:` anchor (ms, UTC midnight; null when absent). */
189
+ export function itemDueAnchors(body) {
190
+ return checkItems(body).map((t) => POST_DEPLOY_ITEM_RE.exec(t)?.[1]).filter((t) => t != null).map((t) => [DUE_RE.exec(t), ANCHOR_RE.exec(t)]).filter(([d]) => d)
191
+ .map(([d, a]) => ({ dueMs: Date.UTC(+d[1], +d[2] - 1, +d[3]), fromMs: a ? Date.UTC(+a[1], +a[2] - 1, +a[3]) : null })).filter((x) => Number.isFinite(x.dueMs));
168
192
  }
169
193
  const SHA_RE = /^[0-9a-f]{7,40}$/i;
170
194
  /**
@@ -240,18 +264,23 @@ const fmtAge = (ms) => (ms % UNIT_MS.d === 0 && ms ? `${ms / UNIT_MS.d}d` : ms %
240
264
  /**
241
265
  * #4389: never throws, no model call. `defaultBranch` is a name or an (async) function resolving it (and fetching it).
242
266
  * #4425: `minAge` (ms, opt-in) parks `awaiting-min-age` until max(item window ?? minAge) after the fix landed on <default>.
243
- * @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
267
+ * @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-due"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
244
268
  * Fail-open: no merge SHA, a git answer other than yes/no, an unknown default branch or a hook error give "unresolved".
245
269
  */
246
270
  export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null, minAge = null, now = null }) {
247
- const parent = deferredFrom(iss?.body);
271
+ const parents = deferredFromAll(iss?.body), parent = parents[0] ?? null; // #4724: a rollup has one parent per section
272
+ const ps = parents.map((p) => `#${p}`).join(", ");
248
273
  const res = (state, detail, extra = {}) => ({ state, parent, shas: [], detail, ...extra });
249
274
  if (!parent || !postDeployOnly(iss.body)) return res("not-applicable", "");
250
275
  try {
251
276
  const def = typeof defaultBranch === "function" ? await defaultBranch() : defaultBranch;
252
277
  if (!def) return res("unresolved", "default branch unknown");
253
- const shas = await parentMergeShas({ parent, repo, io });
254
- if (!shas.length) return res("unresolved", `no merge SHA found for #${parent}`);
278
+ const shas = [];
279
+ for (const p of parents) {
280
+ const found = await parentMergeShas({ parent: p, repo, io });
281
+ if (!found.length) return res("unresolved", `no merge SHA found for #${p}`);
282
+ shas.push(...found);
283
+ }
255
284
  const off = [];
256
285
  for (const sha of shas) {
257
286
  let on;
@@ -259,7 +288,22 @@ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io,
259
288
  if (on === null) return res("unresolved", `${sha.slice(0, 12)} unknown to git`, { shas, default: def });
260
289
  if (!on) off.push(sha);
261
290
  }
262
- if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of #${parent} is not on ${def}${lane ? ` (lane ${lane})` : ""}`, { shas, default: def });
291
+ if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of ${ps} is not on ${def}${lane ? ` (lane ${lane})` : ""}`, { shas, default: def });
292
+ let dueMs = itemDueMs(iss.body); // #4689: an explicit due date (UTC midnight)
293
+ if (dueMs != null) {
294
+ // #4724: re-anchor a date item to when its fix actually landed on <def>: due + (landed - the contract-time anchor)
295
+ let landedMs = null;
296
+ for (const a of itemDueAnchors(iss.body)) {
297
+ if (a.fromMs == null) continue;
298
+ if (landedMs == null) { landedMs = 0; for (const sha of shas) landedMs = Math.max(landedMs, ((await landedAt({ sha, def, io, cwd })) ?? 0) * 1000); }
299
+ if (landedMs > a.fromMs) dueMs = Math.max(dueMs, a.dueMs + (landedMs - a.fromMs));
300
+ }
301
+ const at = now ?? io.now?.() ?? Date.now();
302
+ if (at < dueMs) {
303
+ const eligibleAt = new Date(dueMs).toISOString();
304
+ return res("awaiting-due", `fix of ${ps} is on ${def}; the checks are due ${eligibleAt.slice(0, 10)}`, { shas, default: def, eligibleAt });
305
+ }
306
+ }
263
307
  if (minAge != null) {
264
308
  const required = Math.max(0, ...itemWindowsMs(iss.body).map((w) => w ?? minAge));
265
309
  let landed = 0;
@@ -271,16 +315,16 @@ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io,
271
315
  const at = now ?? io.now?.() ?? Date.now(), until = landed * 1000 + required;
272
316
  if (at < until) {
273
317
  const eligibleAt = new Date(until).toISOString();
274
- return res("awaiting-min-age", `fix of #${parent} on ${def} since ${new Date(landed * 1000).toISOString()}; min age ${fmtAge(required)} until ${eligibleAt}`, { shas, default: def, eligibleAt });
318
+ return res("awaiting-min-age", `fix of ${ps} on ${def} since ${new Date(landed * 1000).toISOString()}; min age ${fmtAge(required)} until ${eligibleAt}`, { shas, default: def, eligibleAt });
275
319
  }
276
320
  }
277
321
  if (preconditionCmd) {
278
322
  const code = await (io.execCode ?? execCode)("sh", ["-c", preconditionCmd, "precondition", String(iss.number)],
279
323
  { cwd, timeout: 60000, env: { ...process.env, FORGE_ISSUE: String(iss.number) } });
280
- if (code === 1) return res("awaiting-precondition", `fix of #${parent} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
324
+ if (code === 1) return res("awaiting-precondition", `fix of ${ps} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
281
325
  if (code !== 0) return res("unresolved", `post_deploy.precondition_cmd exited ${code}`, { shas, default: def });
282
326
  }
283
- return res("ready", `fix of #${parent} is on ${def}`, { shas, default: def });
327
+ return res("ready", `fix of ${ps} is on ${def}`, { shas, default: def });
284
328
  } catch (e) {
285
329
  return res("unresolved", `gate error (${String(e?.message ?? e).slice(0, 120)})`);
286
330
  }
@@ -298,18 +342,30 @@ export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
298
342
  return name;
299
343
  })());
300
344
  }
345
+ /** #4689: one typed `FORGE:OWNER_ASK` comment for the `decision:` items of a follow-up, posted once; best effort. */
346
+ async function ownerAsk({ iss, repo, io, dryRun }) {
347
+ if (dryRun || (iss.comments ?? []).some((c) => String(c?.body ?? "").includes(OWNER_ASK_MARKER) && isTrustedIssueComment(c))) return;
348
+ const asks = checkItems(iss.body).filter((t) => DECISION_ITEM_RE.test(t)).map((t) => `- ${sanitizeMd(t).slice(0, 300)}`);
349
+ try { await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", capComment(`<!-- ${OWNER_ASK_MARKER} -->\n## Owner ask\nNo lane was started: these items need a decision from the owner.\n\n${asks.join("\n")}`)]); } catch { /* the report still shows it */ }
350
+ }
301
351
  /** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
302
352
  async function markParked({ issue, repo, io, had, gate }) {
303
- const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", AWAITING_PROMOTION_LABEL]);
353
+ const measure = gate.state === "awaiting-measurement"; // #4626
354
+ const due = gate.state === "awaiting-due"; // #4689
355
+ const label = measure ? AWAITING_MEASUREMENT_LABEL : due ? AWAITING_DUE_LABEL : AWAITING_PROMOTION_LABEL;
356
+ const labels = due && gate.eligibleAt ? [label, `due:${gate.eligibleAt.slice(0, 10)}`] : [label];
357
+ const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", labels.join(",")]);
304
358
  try { await edit(); } catch {
305
359
  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."]);
360
+ for (const l of labels) await io.gh(["label", "create", l, "-R", repo, "--force", "--color", "C5DEF5", "--description", measure ? "Post-deploy follow-up with no runnable default-branch check; never given a lane. Managed by ForgeDock." : due ? "Post-deploy follow-up parked until its due date; re-queued by the engine. Managed by ForgeDock." : "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
307
361
  await edit();
308
362
  } catch { /* the comment and the report still show it */ }
309
363
  }
310
364
  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.`;
365
+ const what = measure ? "awaiting measurement" : gate.state === "awaiting-promotion" ? "awaiting promotion" : due ? "awaiting its due date" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
366
+ 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."
367
+ : "Every item here is a post-deploy check, so no lane was started. The next orchestrate or triage run, or the daemon's parked sweep, checks again and releases this issue once the precondition holds.";
368
+ const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\n${tail}`;
313
369
  try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
314
370
  }
315
371
 
@@ -328,6 +384,16 @@ export function formatParkedLine(rows) {
328
384
  const defs = uniq(soak.map((r) => r.prefilter.default));
329
385
  out.push(`awaiting min age: ${soak.map((r) => `#${r.issue}`).join(" ")} (fix on ${defs.join(", ") || "the default branch"}, soak window not elapsed)`);
330
386
  }
387
+ const due = parked.filter((r) => r.prefilter.kind === "awaiting-due"); // #4689
388
+ if (due.length) out.push(`due date: ${due.map((r) => `#${r.issue}${r.prefilter.eligibleAt ? ` (${r.prefilter.eligibleAt.slice(0, 10)})` : ""}`).join(" ")} (re-queued on or after the date)`);
389
+ const ask = parked.filter((r) => r.prefilter.kind === "owner-ask");
390
+ if (ask.length) out.push(`owner ask: ${ask.map((r) => `#${r.issue}`).join(" ")} (a decision or measurement only the owner can give; no lane)`);
391
+ const meas = parked.filter((r) => r.prefilter.kind === "awaiting-measurement"); // #4626
392
+ if (meas.length) out.push(`awaiting measurement: ${meas.map((r) => `#${r.issue}`).join(" ")} (no runnable default-branch check; no lane)`);
393
+ const failed = parked.filter((r) => r.prefilter.kind === "check-failed");
394
+ if (failed.length) out.push(`check failed: ${failed.map((r) => `#${r.issue}`).join(" ")} (parent reopened; waiting for a new fix)`);
395
+ const mon = parked.filter((r) => r.prefilter.kind === "monitoring"); // #4627
396
+ if (mon.length) out.push(`monitoring: ${mon.map((r) => `#${r.issue}`).join(" ")} (windowed post-deploy checks re-run on later releases)`);
331
397
  const pre = parked.filter((r) => r.prefilter.kind === "awaiting-precondition");
332
398
  if (pre.length) out.push(`awaiting precondition: ${pre.map((r) => `#${r.issue}`).join(" ")} (post_deploy.precondition_cmd not ready)`);
333
399
  return out.join("\n");
@@ -337,7 +403,7 @@ export function formatParkedLine(rows) {
337
403
  * Pre-filter one issue. Never throws.
338
404
  * @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
339
405
  */
340
- export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null, minAge = null }) {
406
+ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null, minAge = null, gateOnly = false }) {
341
407
  const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
342
408
  try {
343
409
  const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
@@ -346,24 +412,43 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
346
412
  const labelled = names.some((l) => ["needs-human", "workflow:merged"].includes(l));
347
413
  // #4389: a post-deploy follow-up is gated even when an earlier lane escalated it (needs-human); nothing else changes.
348
414
  const gated = !!defaultBranch && !names.includes("workflow:merged") && deferredFrom(iss.body) != null && postDeployOnly(iss.body);
415
+ // #4627: a follow-up under windowed monitoring belongs to `sweepDue`, never to a lane or a second measurement
416
+ if (names.includes(MONITORING_LABEL)) return { ...out, parked: true, kind: "monitoring", detail: "windowed post-deploy checks re-run on later releases" };
349
417
  if (labelled && !gated) return { ...out, detail: "labelled, left to the lane" };
350
418
  if (io.projector) {
351
419
  let st = null;
352
420
  try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
353
421
  if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
354
422
  }
423
+ if (gated && hasDecisionItem(iss.body)) { // #4689: a decision item is the owner's: parked, one typed ask, no lane
424
+ await ownerAsk({ iss, repo, io, dryRun });
425
+ return { ...out, parked: true, kind: "owner-ask", parent: deferredFrom(iss.body), lane, detail: "a decision item needs the owner" };
426
+ }
355
427
  if (gated) {
356
428
  const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd, minAge });
357
429
  const had = names.includes(AWAITING_PROMOTION_LABEL);
358
- if (["awaiting-promotion", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
359
- if (!dryRun) await markParked({ issue, repo, io, had, gate });
360
- return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, detail: gate.detail };
430
+ if (["awaiting-promotion", "awaiting-due", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
431
+ if (!dryRun) await markParked({ issue, repo, io, had: had || (gate.state === "awaiting-due" && names.includes(AWAITING_DUE_LABEL)), gate });
432
+ return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, ...(gate.eligibleAt ? { eligibleAt: gate.eligibleAt } : {}), detail: gate.detail };
361
433
  }
362
- if (gate.state === "ready" && had && !dryRun) {
363
- try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", AWAITING_PROMOTION_LABEL]); } catch { /* best effort */ }
434
+ const stale = names.filter((l) => l === AWAITING_PROMOTION_LABEL || l === AWAITING_DUE_LABEL || /^due:/.test(l)); // #4689: released: every wait label goes
435
+ if (gate.state === "ready" && stale.length && !dryRun) {
436
+ try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", stale.join(",")]); } catch { /* best effort */ }
437
+ }
438
+ if (gate.state === "ready") {
439
+ 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 } : {}) });
440
+ if (m.state === "awaiting-measurement") {
441
+ if (!dryRun) await markParked({ issue, repo, io, had: names.includes(AWAITING_MEASUREMENT_LABEL), gate: { ...gate, state: m.state, detail: m.detail } });
442
+ return { ...out, parked: true, kind: m.state, parent: gate.parent, lane, default: gate.default, detail: m.detail };
443
+ }
444
+ if (m.state === "monitoring") return { ...out, parked: true, kind: "monitoring", parent: gate.parent, lane, default: gate.default, detail: m.detail };
445
+ if (m.state === "pass") return { ...out, closed: !dryRun, kind: "post-deploy-check", parent: gate.parent, lane, default: gate.default, detail: m.detail };
446
+ 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 };
364
447
  }
448
+ if (gateOnly) return { ...out, detail: `released (post-deploy gate: ${gate.state})` }; // #4689: the sweep never runs the already-fixed path
365
449
  if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
366
450
  }
451
+ if (gateOnly) return { ...out, detail: "not a gated follow-up" };
367
452
  // #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
368
453
  if ((iss.comments ?? []).some((c) => probeResultOf(c?.body)?.result === "fail")
369
454
  && flakyReason({ comments: iss.comments }, await ownFor(iss, selfResolver(selfLogins, io.gh)))?.reason === "probe-failed") {
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL, parseMinAge, itemWindowsMs, landedAt } from "./prefilter.mjs";
5
+ import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL, parseMinAge, itemWindowsMs, landedAt, itemDueMs, hasDecisionItem, deferredFromAll, itemDueAnchors } from "./prefilter.mjs";
6
6
  import { writeFileSync } from "node:fs";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
8
  import { execFileSync } from "node:child_process";
@@ -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 () => {
@@ -508,6 +516,36 @@ describe("post_deploy.min_age soak window (#4425)", () => {
508
516
  assert.deepEqual(itemWindowsMs(followup([item("R1", "after 24h of traffic, then 7 days of retention"), item("R2", "only in prod")])), [7 * 24 * H * 1000, null]);
509
517
  });
510
518
 
519
+ describe("post-deploy rollup with several parents (#4724)", () => {
520
+ const M2 = "f".repeat(40);
521
+ const prs = [...parentPr, { number: 91, title: "fix(y): other (#50)", body: "", mergeCommit: { oid: M2 } }];
522
+ const rollup = (items) => `## Acceptance Criteria\n\n### #40\n<!-- FORGE:DEFERRED_FROM: #40 -->\n${items[0]}\n### #50\n<!-- FORGE:DEFERRED_FROM: #50 -->\n${items[1] ?? items[0]}\n`;
523
+ it("deferredFromAll lists every section parent once, in order", () => {
524
+ assert.deepEqual(deferredFromAll(rollup([item("R1", "x")])), [40, 50]);
525
+ assert.deepEqual(deferredFromAll("no marker"), []);
526
+ assert.equal(deferredFrom(rollup([item("R1", "x")])), 40);
527
+ });
528
+ it("the rollup waits while ANY parent is off the default branch, and names them all", async () => {
529
+ const w = soakWorld({ mergedPrs: prs, ancestry: { [MERGE]: 0, [M2]: 1 } });
530
+ const g = await gate(w, rollup([item("R1", "x")]));
531
+ assert.equal(g.state, "awaiting-promotion"); assert.match(g.detail, /#40, #50/); assert.deepEqual(g.shas, [MERGE, M2]);
532
+ });
533
+ it("is ready once every parent is on the default branch; a parent without a merge SHA is unresolved", async () => {
534
+ assert.equal((await gate(soakWorld({ mergedPrs: prs, ancestry: { [MERGE]: 0, [M2]: 0 } }), rollup([item("R1", "x")]))).state, "ready");
535
+ const g = await gate(soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } }), rollup([item("R1", "x")]));
536
+ assert.equal(g.state, "unresolved"); assert.match(g.detail, /#50/);
537
+ });
538
+ it("a date item is re-anchored to when its fix landed: due + (landed - from)", async () => {
539
+ const it1 = item("R1", "[bucket:date] due:2027-02-01 from:2027-01-01 later");
540
+ assert.deepEqual(itemDueAnchors(followup([it1])), [{ dueMs: Date.UTC(2027, 1, 1), fromMs: Date.UTC(2027, 0, 1) }]);
541
+ const w = soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
542
+ const g = await gate(w, followup([it1]), { now: Date.UTC(2027, 1, 10) }); // past the contract-time date, before the shifted one
543
+ assert.equal(g.state, "awaiting-due");
544
+ assert.equal(g.eligibleAt, new Date(Date.UTC(2027, 1, 1) + LANDED * 1000 - Date.UTC(2027, 0, 1)).toISOString());
545
+ assert.equal((await gate(soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } }), followup([it1]), { now: Date.UTC(2027, 2, 1) })).state, "ready");
546
+ });
547
+ });
548
+
511
549
  it("landedAt: the promotion merge time, or the SHA's own time when it is on the mainline", async () => {
512
550
  const w = soakWorld();
513
551
  assert.equal(await landedAt({ sha: MERGE, def: "main", io: w.io, cwd: "/repo" }), LANDED);
@@ -594,3 +632,45 @@ describe("#4467: isTrustedIssueComment", () => {
594
632
  assert.equal(isTrustedIssueComment(null), false);
595
633
  });
596
634
  });
635
+
636
+ describe("due-date and decision follow-ups (#4689)", () => {
637
+ const MERGE = "a".repeat(40);
638
+ const dueItem = (date) => `- [ ] \`R2: the 7-day window shows a health row\` — post-deploy: due:${date} only checkable after the window`;
639
+ const decision = "- [ ] `R3: pick the retention` — decision: owner chooses 30 or 90 days";
640
+ const body = (items) => `## Problem\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
641
+ const parentPr = [{ number: 90, title: "fix(x): health row (#40)", body: "", mergeCommit: { oid: MERGE } }];
642
+ const at = (iso) => Date.parse(iso);
643
+
644
+ it("parses the due token (latest wins) and decision items; a decision-only body counts as verification-only", () => {
645
+ assert.equal(itemDueMs(body([dueItem("2026-11-03"), dueItem("2026-11-10")])), Date.UTC(2026, 10, 10));
646
+ assert.equal(itemDueMs(body(["- [ ] `R2: x` — post-deploy: no date"])), null);
647
+ assert.ok(postDeployOnly(body([decision]))); assert.ok(hasDecisionItem(body([decision])));
648
+ assert.ok(!hasDecisionItem(body([dueItem("2026-11-03")])));
649
+ });
650
+
651
+ it("before the due date the follow-up parks awaiting-due with a due:<date> label, no lane; after it, the labels are removed", async () => {
652
+ const w = world({ issues: { 41: { body: body([dueItem("2026-11-03")]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
653
+ w.io.now = () => at("2026-11-01T00:00:00Z");
654
+ const r = await pf(w, 41, { defaultBranch: "main" });
655
+ assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-due"); assert.equal(r.eligibleAt, "2026-11-03T00:00:00.000Z");
656
+ assert.deepEqual(w.edits, [[41, "--add-label", "awaiting-due,due:2026-11-03"]]);
657
+ assert.equal(w.runner.length, 0);
658
+ assert.match(formatParkedLine([{ issue: 41, prefilter: r }]), /^due date: #41 \(2026-11-03\)/);
659
+ const w2 = world({ issues: { 41: { body: body([dueItem("2026-11-03")]), labels: [{ name: "awaiting-due" }, { name: "due:2026-11-03" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
660
+ w2.io.now = () => at("2026-11-03T00:00:01Z");
661
+ const r2 = await pf(w2, 41, { defaultBranch: "main", gateOnly: true });
662
+ assert.equal(r2.kind, "awaiting-measurement"); // released from the date; with no check on main it waits on its owner, never a lane
663
+ assert.deepEqual(w2.edits[0], [41, "--remove-label", "awaiting-due,due:2026-11-03"]);
664
+ assert.ok(w2.comments.some(([, b]) => /FORGE:OWNER_ASK/.test(b)));
665
+ assert.equal(w2.runner.length, 0);
666
+ });
667
+
668
+ it("a decision item is parked as one typed owner ask (posted once), never a lane, never needs-human", async () => {
669
+ const w = world({ issues: { 42: { body: body([decision]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
670
+ const r = await pf(w, 42, { defaultBranch: "main" });
671
+ assert.equal(r.parked, true); assert.equal(r.kind, "owner-ask"); assert.equal(w.runner.length, 0);
672
+ assert.equal(w.comments.length, 1); assert.match(w.comments[0][1], /FORGE:OWNER_ASK/);
673
+ assert.match(formatParkedLine([{ issue: 42, prefilter: r }]), /^owner ask: #42/);
674
+ assert.deepEqual(w.edits, []);
675
+ });
676
+ });
@@ -244,14 +244,14 @@ export async function probeIssue({ issue, repo, cwd = process.cwd(), io, default
244
244
  try { defaultSha = String(await io.git(["rev-parse", `origin/${defaultBranch}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
245
245
  const r = await runProbe(d.probe, { cwd, env: probeEnv(config.env, env), mask: config.env.map((k) => env[k]), timeoutS: config.timeoutS, ...(spawn ? { spawn } : {}) });
246
246
  const detail = `exit ${r.exit ?? "timeout"} in ${r.ms}ms`;
247
- if (dryRun) return row(r.result, `${detail} (dry run: not recorded)`, { fix, output: r.output });
247
+ if (dryRun) return row(r.result, `${detail} (dry run: not recorded)`, { fix, defaultBranch, output: r.output });
248
248
  const body = renderProbeComment({ probe: d.probe, fix, defaultBranch, defaultSha, r });
249
249
  let note = "";
250
250
  if (r.result === "fail") {
251
251
  try { await io.gh(["issue", "reopen", String(issue), "-R", repo]); } catch (e) { note = `; reopen failed: ${String(e?.message ?? e).slice(0, 120)}`; }
252
252
  }
253
253
  await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", body]);
254
- return row(r.result, detail + note, { fix });
254
+ return row(r.result, detail + note, { fix, defaultBranch });
255
255
  }
256
256
 
257
257
  /** The default branch: forge.yaml `branches.default`, else GitHub's. */
@@ -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
  }
@@ -125,7 +125,7 @@ describe("probe (#4399)", () => {
125
125
  const w = world({ comments: [], prs: [{ number: 3, title: "x", body: "mentions #7 only", mergeCommit: { oid: "1111111" }, mergedAt: "2026-10-02" },
126
126
  { number: 4, title: "fix", body: "Closes #7", mergeCommit: { oid: "2222222" }, mergedAt: "2026-10-01" }] });
127
127
  const r = await run(w);
128
- assert.equal(r.outcome, "pass"); assert.equal(r.fix, "2222222");
128
+ assert.equal(r.outcome, "pass"); assert.equal(r.fix, "2222222"); assert.ok(r.defaultBranch);
129
129
  });
130
130
 
131
131
  it("pass: records FORGE:PROBE with the result JSON, no reopen", async () => {
@@ -63,10 +63,19 @@ 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");
69
- return `These migration files were named by hand, not produced by the repo's migration generator:\n${rows}\n\nA made-up name or timestamp collides with another lane's migration. Delete each such file, then recreate it with the generator (hand-written SQL: the generator's \`--custom\` mode, e.g. \`drizzle-kit generate --custom --name=<name>\`, then put your SQL in the file it wrote), so the journal entry and snapshot come from it. Never name or number migration files by hand. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
75
+ const stale = problems.some((p) => /not above the base max when|is not after/.test(p))
76
+ ? ` Entries reported with a stale \`when\` are stamped older than the base's newest migration, so a newest-only migrator (drizzle \`migrate()\`) skips them on a persistent database: re-stamp each one (a \`when\` and name above the base journal's max, same SQL, snapshot regenerated by the generator) and keep \`when\` increasing in the journal.`
77
+ : "";
78
+ return `These migration problems were found (hand-named files the repo's migration generator did not produce, or journal timestamps older than the base's):\n${rows}\n\nA made-up name or timestamp collides with another lane's migration. Delete each such file, then recreate it with the generator (hand-written SQL: the generator's \`--custom\` mode, e.g. \`drizzle-kit generate --custom --name=<name>\`, then put your SQL in the file it wrote), so the journal entry and snapshot come from it. Never name or number migration files by hand.${stale} ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
70
79
  }
71
80
 
72
81
  /** #4390: byte cap of the diff hunks a context-ceiling restart carries (the stat is always kept). */
@@ -148,12 +157,13 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
148
157
  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
158
 
150
159
  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>"}],
160
+ {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<defer, post-deploy>","bucket":"<defer, post-deploy: post-deploy|verify-now|date:YYYY-MM-DD|owner:<action>|new-issue>","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
161
  "deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
153
162
  "preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
154
163
  "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>"}],
155
164
  "decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
156
- Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
165
+ Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. Every defer or post-deploy row has one "bucket": post-deploy (needs a release), verify-now (parent already on the default branch), date:YYYY-MM-DD (absolute, never a relative wait), owner:<action> (credentials, spend, settings; disposition defer, never post-deploy) or new-issue (future work). One sentence is one row. An outcome criterion this change can ship as a test, metric or alert is "deliver". A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
166
+ A [type:e2e] requirement needs a validate that runs a real e2e check (repo e2e set, Playwright, live stack), not a stubbed unit test; else post-deploy.
157
167
  A validate or repro command must not read a $TMPDIR file it did not create: each run gets a fresh TMPDIR.
158
168
  Scope is enforced by the engine: it checks the changed files against the deliverables plus typed amendments itself, so do not write a file-allowlist or denylist validate (git diff --name-only | grep); the engine drops such a validate at freeze.
159
169
  A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when none. Then stop.
@@ -165,7 +175,7 @@ ${BLOCK_RULES}`;
165
175
  * #4374: the conformance rubric, one definition shared verbatim by `reviewerPrompt` and the builder's self-review in
166
176
  * `builderImplementPrompt`, so the two cannot drift. Each question is answered yes (OK) or no; codebase-agnostic.
167
177
  */
168
- export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result.
178
+ export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result. A [type:e2e] requirement is delivered only when its validate command is a real end-to-end check, not a unit test with stubbed I/O.
169
179
  2. No regression: is the diff free of breakage of an existing caller, test or behaviour (a concrete path:line failure)?
170
180
  3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
171
181
  4. Scope: does the diff change only deliverable paths and tests?`;
@@ -178,12 +188,15 @@ export function builderImplementPrompt(mode = "local", { toolchain = null, spars
178
188
  ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
179
189
 
180
190
  The contract changes only through typed amendments in ${RESULT_FILE}, each with a why:
181
- - {"kind":"add-deliverable","path":"<file>","why":""}: a file the contract did not list.
182
- - {"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.
184
- - {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded; judged against text.
191
+ - {"kind":"add-deliverable","path":"<file>","why":""}: a file not listed.
192
+ - {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: a missed issue requirement.
193
+ - {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}: separate work or a post-deploy check. Filed as a tracked follow-up. Not "type"; unknown kinds are rejected. One requirement id per row (R2, R3 as two rows).
194
+ - {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded.
195
+ - {"kind":"decision","decision":{"choice":"","anchors":["<base path>"],"supersedes":"<id>"},"why":""}: supersedes changes an H-row.
196
+ New keys: translate, never copy English; status entries in place.
185
197
  Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
186
198
  Never name or number migration files by hand; use the generator.
199
+ A [type:e2e] requirement needs a validate that runs a real e2e check (repo e2e set, Playwright, live stack), not a stubbed unit test; else post-deploy.
187
200
  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.
188
201
  If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base first; the engine runs it too.
189
202
 
@@ -364,7 +377,8 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
364
377
  You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
365
378
  - 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
379
  - 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
- - 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.
380
+ - 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, including an outcome criterion (a metric, rate or log expectation) this change could ship as a test, metric or alert, or a read-only production check possible now (bucket verify-now). 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.
381
+ - 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
382
  - 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
383
  - 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
384
  - regressions: concrete breakages only, each with path:line.
@@ -11,7 +11,7 @@ const facts = (validation, files = ["src/a.ts"]) => `<!-- ${FACTS_MARKER} -->\n$
11
11
  const CONTRACT = {
12
12
  requirements: [
13
13
  { id: "R1", quote: "Retries keep the session.", disposition: "deliver" },
14
- { id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod" },
14
+ { id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod", bucket: "post-deploy" },
15
15
  { id: "R3", quote: "Log the retry count.", disposition: "deliver" },
16
16
  ],
17
17
  validate: [{ id: "V1", command: "npm test -- a.test.ts" }, { id: "V2", command: "npm run lint" }],