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
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
- import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
9
+ import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
10
10
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
11
11
  import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
12
12
 
@@ -89,6 +89,21 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
89
89
  assert.deepEqual(deferPolicy({ code: "host.busy" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
90
90
  });
91
91
 
92
+ it("#4685 a breaker tripped by transport errors closes itself after the cooldown; any other stays tripped", async () => {
93
+ const run = async (detail) => {
94
+ let t = 0; const updates = []; const saves = [];
95
+ const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 1, readBody: async () => "", now: () => t, sleep: async (ms) => { t += ms; }, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
96
+ onUpdate: (r) => updates.push(r), store: { load: () => null, save: (s) => saves.push(s) },
97
+ runOne: async (n) => { t += 1000; return n <= 3 ? { terminalReason: "engine-error", detail } : { terminalReason: "merged", pr: 5 }; } });
98
+ return { rows, updates, saves };
99
+ };
100
+ const tr = await run("sandbox launch: fetch failed");
101
+ const trip = tr.saves.map((s) => s.breaker).find(Boolean);
102
+ assert.equal(trip?.transport, true);
103
+ const other = await run("boom");
104
+ assert.equal(other.saves.map((s) => s.breaker).find(Boolean)?.transport, undefined);
105
+ });
106
+
92
107
  it("gh.unavailable deferrals (forge#4157) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
93
108
  const { now, sleep } = fakeClock();
94
109
  const updates = [];
@@ -105,6 +120,31 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
105
120
  assert.deepEqual(deferPolicy({ code: "gh.unavailable", retryInSeconds: 60 }, { now: 0 }), { delayMs: 60000 });
106
121
  });
107
122
 
123
+ it("gh.auth deferrals (forge#4662) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
124
+ const { now, sleep } = fakeClock();
125
+ const updates = [];
126
+ let calls = 0;
127
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
128
+ onUpdate: (r) => updates.push(r),
129
+ runOne: async () => (++calls <= 3
130
+ ? { terminalReason: "deferred", code: "gh.auth", retryInSeconds: 120, detail: "gh auth failed (HTTP 401)" }
131
+ : { terminalReason: "merged", pr: 5 }) });
132
+ assert.equal(calls, 4);
133
+ assert.equal(rows[0].outcome, "merged");
134
+ assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
135
+ assert.equal(updates.filter((u) => /requeued in 120s: gh.auth/.test(u.detail ?? "")).length, 3);
136
+ });
137
+
138
+ it("#4690 env.lane_limit (needs-human) is never counted by the breaker and the batch keeps admitting", async () => {
139
+ const { now, sleep } = fakeClock();
140
+ const updates = [], ran = [];
141
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", now, sleep, breaker: { errors: 1, window_min: 30 }, onUpdate: (r) => updates.push(r),
142
+ runOne: async (n) => { ran.push(n); return n === 1 ? { terminalReason: "needs-human", code: "env.lane_limit", detail: "TasksMax" } : { terminalReason: "merged", pr: n }; } });
143
+ assert.deepEqual(ran, [1, 2, 3]);
144
+ assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
145
+ assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "merged", "merged"]);
146
+ });
147
+
108
148
  it("a usage limit holds every admission until its reset; a deferred issue's dependents wait instead of being skipped", async () => {
109
149
  const { now, sleep, c } = fakeClock();
110
150
  const t0 = c.t, starts = {};
@@ -309,6 +349,26 @@ describe("#4318: the batch dispatcher's signal relabel and the needs-human relab
309
349
  const b = hook({ state: "OPEN", body: "" }, { comment: true });
310
350
  assert.equal(await b.run(), true);
311
351
  });
352
+ it("#4736: a mechanical park creates its parked:<code> label before the edit", async () => {
353
+ const calls = [];
354
+ const gh = async (a) => { calls.push(a.slice(0, 3).join(" ")); return a[1] === "view" ? JSON.stringify({ state: "OPEN", body: "" }) : ""; };
355
+ assert.equal(await makeRelabelNeedsHuman({ gh, repo: "o/r", now: () => 1000 })({ issue: 7, evidence: "e", parked: { code: "ci.red", pr: 3 } }), true);
356
+ assert.deepEqual(calls.slice(0, 3), ["issue view 7", "label create parked:ci.red", "issue edit 7"]);
357
+ });
358
+ });
359
+
360
+ it("#4736: a parked relabel that reports failed is not reported as queued for remediation", async () => {
361
+ const leanIo = { gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })), git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") };
362
+ const out = [], l = console.log;
363
+ console.log = (...x) => out.push(x.join(" "));
364
+ try {
365
+ await orchestrateFromCli(["1", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("pf-"), batchRoot: tmpDir("pf-batch-"), allowTmpfs: true,
366
+ emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo, sched: null, decide: () => {},
367
+ orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } },
368
+ relabelNeedsHuman: async (x) => (x.parked ? "failed" : true),
369
+ leanModule: { runCore: async () => ({ terminalReason: "needs-human", code: "ci.red", detail: "x", pr: 5 }) } });
370
+ } finally { console.log = l; }
371
+ assert.doesNotMatch(out.join("\n"), /remediation queue/);
312
372
  });
313
373
  });
314
374
 
@@ -428,6 +488,19 @@ describe("#4408: issues added to a running batch (forgedock orchestrate --add)",
428
488
  assert.deepEqual(plain.map((r) => r.issue), [1]);
429
489
  });
430
490
 
491
+ it("#4687 --add re-queues an issue that is skipped in the batch; any other ended issue stays 'already in batch'", () => {
492
+ const root = tmpDir("batch-add-skipped-");
493
+ const b = openBatch(root, "b1");
494
+ b.writePlan({ argv: ["1"], repo: "o/r" });
495
+ b.store.save({ order: [1, 2, 3], results: { 1: { outcome: "needs-human" }, 2: { outcome: "skipped", detail: "dependency #1 ended needs-human" }, 3: { outcome: "merged" } } });
496
+ const r = b.addIssues([2, 3, 1]);
497
+ assert.deepEqual(r.requeued, [2]);
498
+ assert.deepEqual(r.added, []);
499
+ assert.deepEqual(r.skipped.map((x) => [x.issue, x.reason]), [[3, "already in batch"], [1, "already in batch"]]);
500
+ assert.deepEqual(b.readRequeued().map((e) => e.issue), [2]);
501
+ assert.deepEqual(b.readAdded(), []);
502
+ });
503
+
431
504
  it("the add file: integers only, no issue twice, cumulative; --add through the CLI skips closed and unreadable issues", async () => {
432
505
  const root = tmpDir("batch-add-");
433
506
  const b = openBatch(root, "b1");
@@ -528,6 +601,37 @@ describe("#4376: forgedock orchestrate --cancel", () => {
528
601
  assert.deepEqual(saved.deferrals, { 1: 1 });
529
602
  });
530
603
 
604
+ for (const stopRemote of [false, true]) {
605
+ it(`a cancel ${stopRemote ? "with" : "without"} stopRemote ${stopRemote ? "stops" : "leaves"} a remote lane (#4612)`, async () => {
606
+ let cancel = null, saved = null;
607
+ const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
608
+ const started = [], stops = [];
609
+ let release;
610
+ const gate = new Promise((r) => { release = r; });
611
+ const done = orchestrate({ issues: [1], parallel: 1, pollMs: 5, readBody: async () => "", store, getCancelled: () => cancel,
612
+ stopLane: (n, rec) => { stops.push([n, rec.remote]); release(); },
613
+ runOne: async (n, _lane, ctx) => {
614
+ started.push(n);
615
+ ctx.onStart?.({ remote: true, handle: { id: "sbx-1" } });
616
+ if (!stopRemote) setTimeout(release, 100);
617
+ await gate;
618
+ return { terminalReason: "deferred", code: "run.interrupted" };
619
+ } });
620
+ await until(() => started.includes(1));
621
+ cancel = { at: new Date().toISOString(), reason: "operator", ...(stopRemote ? { stopRemote: true } : {}) };
622
+ await done;
623
+ assert.deepEqual(stops, stopRemote ? [[1, true]] : []);
624
+ });
625
+ }
626
+
627
+ it("requestCancel records stopRemote only when asked (#4612)", () => {
628
+ const b = openBatch(tmpDir("batch-cancel-stop-"), "b1");
629
+ b.requestCancel({});
630
+ assert.equal(b.readCancel().stopRemote, undefined);
631
+ b.requestCancel({ stopRemote: true });
632
+ assert.equal(b.readCancel().stopRemote, true);
633
+ });
634
+
531
635
  it("through the CLI: --cancel during a lane holds the rest, a finished batch is left alone, unknown ids reject, --resume clears it", async () => {
532
636
  const root = tmpDir("batch-cancel-cli-");
533
637
  const leanIo = {
@@ -628,7 +732,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
628
732
 
629
733
  it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
630
734
  const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
631
- if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state"]); if (issue instanceof Error) throw issue; return issue; }
735
+ if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason"]); if (issue instanceof Error) throw issue; return issue; }
632
736
  if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
633
737
  assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
634
738
  if (prs instanceof Error) throw prs;
@@ -642,6 +746,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
642
746
  assert.equal(await mk('{"state":"OPEN"}', '[{"number":12}]')(9), null, "no mergedAt: the lane runs");
643
747
  assert.deepEqual(await mk('{"state":"CLOSED"}', merged, new Error("not read"))(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "closed: no reopen lookup");
644
748
  assert.deepEqual(await mk('{"state":"CLOSED"}', "[]")(9), { outcome: "invalid", detail: "issue closed" });
749
+ assert.deepEqual(await mk('{"state":"CLOSED","stateReason":"NOT_PLANNED"}', "[]")(9), { outcome: "invalid", detail: "issue closed (not planned)", final: true }, "#4687: not planned is final");
645
750
  assert.equal(await mk('{"state":"OPEN"}', "[]")(9), null);
646
751
  assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
647
752
  assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
@@ -711,6 +816,75 @@ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () =>
711
816
  });
712
817
  });
713
818
 
819
+ describe("#4550: forgedock orchestrate --requeue", () => {
820
+ const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
821
+ it("a needs-human issue is requeued and relaunched; its skipped dependent returns to pending and runs after it merges; one row per issue", async () => {
822
+ const bodies = { 1: "", 2: "Depends on #1", 3: "" };
823
+ let entries = [];
824
+ const started = [], g3 = gate(), log = [], decisions = [];
825
+ const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getRequeued: () => entries,
826
+ decide: (d) => { decisions.push(d); }, onUpdate: (r) => log.push(r),
827
+ runOne: async (n) => {
828
+ started.push(n);
829
+ if (n === 3) { await g3.p; return { terminalReason: "merged" }; }
830
+ if (n === 1 && started.filter((x) => x === 1).length === 1) return { terminalReason: "needs-human", detail: "builder.blocked: product-decision" };
831
+ return { terminalReason: "merged" };
832
+ } });
833
+ await until(() => log.some((r) => r.issue === 2 && r.outcome === "skipped"));
834
+ entries = [{ issue: 1, reason: "decided in scope", by: "operator", at: "2026-10-24T00:00:00Z" }];
835
+ await until(() => started.filter((x) => x === 2).length === 1, 3000);
836
+ g3.open();
837
+ const rows = await done;
838
+ assert.deepEqual(started.filter((x) => x === 1).length, 2, "relaunched once");
839
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
840
+ assert.equal(rows[0].requeued.reason, "decided in scope");
841
+ assert.equal(rows[0].requeued.prior[0].outcome, "needs-human");
842
+ assert.deepEqual(formatOutcomeLists(rows), ["requeued: #1 (decided in scope)"]);
843
+ assert.ok(decisions.some((d) => d.issue === 1 && /requeued/.test(d.what) && d.why === "decided in scope"));
844
+ assert.ok(started.indexOf(2) > started.lastIndexOf(1) - 1 && started.filter((x) => x === 2).length === 1, "#2 ran once, after #1 was relaunched");
845
+ });
846
+
847
+ it("an entry is applied once; one for an issue that is not terminal changes nothing", async () => {
848
+ const entries = [{ issue: 1, reason: "r", by: "operator", at: "t1" }];
849
+ let runs = 0;
850
+ const rows = await orchestrate({ issues: [1], parallel: 1, pollMs: 5, readBody: async () => "", getRequeued: () => entries,
851
+ runOne: async () => { runs++; return { terminalReason: "merged" }; } });
852
+ assert.equal(runs, 1);
853
+ assert.equal(rows[0].requeued, undefined);
854
+ });
855
+
856
+ it("batch.requeue: terminal issues are recorded; unknown, merged and double requests are refused; --add still says already in batch", () => {
857
+ const b = openBatch(tmpDir("batch-requeue-"), "b1");
858
+ b.writePlan({ argv: ["1"], repo: "o/r" });
859
+ b.store.save({ order: [1, 2, 3, 4], results: { 1: { outcome: "needs-human" }, 2: { outcome: "merged" }, 3: { outcome: "dependency-blocked" } } });
860
+ const r = b.requeue([1, 2, 3, 4, 9, "x", 1], { reason: "why", by: "me" });
861
+ assert.deepEqual(r.requeued, [1, 3]);
862
+ assert.deepEqual(r.refused.map((x) => [x.issue, x.reason]), [[2, "not terminal"], [4, "not terminal"], [9, "not in batch"], ["x", "not an integer"], [1, "already requeued"]]);
863
+ assert.deepEqual(b.readRequeued().map((e) => [e.issue, e.reason, e.by]), [[1, "why", "me"], [3, "why", "me"]]);
864
+ assert.equal(b.unrunRequeues().length, 2);
865
+ assert.deepEqual(b.addIssues([1]).skipped, [{ issue: 1, reason: "already in batch" }]);
866
+ });
867
+
868
+ it("the CLI refuses a needs-human-labelled or closed issue and writes nothing; an open, cleared one is requeued (a finished batch's report is cleared)", async () => {
869
+ const root = tmpDir("batch-requeue-cli-");
870
+ const b = openBatch(root, "b1");
871
+ b.writePlan({ argv: ["1"], repo: "o/r" });
872
+ b.store.save({ order: [5, 6, 7], results: { 5: { outcome: "needs-human" }, 6: { outcome: "needs-human" }, 7: { outcome: "needs-human" } } });
873
+ b.writeReport({ rows: [], report: "x" });
874
+ const io = { gh: async (a) => JSON.stringify({ state: a[2] === "6" ? "CLOSED" : "OPEN", labels: a[2] === "7" ? [{ name: "needs-human" }] : [] }) };
875
+ const bad = await quiet(() => orchestrateFromCli(["--requeue", "b1", "6", "7", "--reason", "r"], { batchRoot: root, io }));
876
+ assert.deepEqual(bad.requeued, []);
877
+ assert.deepEqual(bad.refused.map((x) => x.issue), [6, 7]);
878
+ assert.match(bad.refused[1].reason, /needs-human/);
879
+ assert.deepEqual(b.readRequeued(), []);
880
+ assert.ok(b.readReport(), "nothing changed");
881
+ const ok = await quiet(() => orchestrateFromCli(["--requeue", "b1", "5", "--reason", "decided"], { batchRoot: root, io }));
882
+ assert.deepEqual(ok.requeued, [5]);
883
+ assert.equal(b.readRequeued()[0].reason, "decided");
884
+ assert.equal(b.readReport(), null, "the finished batch is reopened for --resume");
885
+ });
886
+ });
887
+
714
888
  describe("#4540: remote admissions are capped to the provider's free capacity", () => {
715
889
  const run = async (capacity) => {
716
890
  const c = { t: 1_000_000 };
@@ -56,7 +56,7 @@ const contract = (reqs) => ({ requirements: reqs, deliverables: reqs.filter((r)
56
56
  preserve: [{ id: "P1", what: `${Q.AC3} (src/billing.js price())` }, { id: "P2", what: `${Q.AC2} (test/routes.test.js)` }], validate: [{ id: "V1", command: V1 }] });
57
57
  const FULL = () => contract([req("R1"), req("R2"), req("R3"), req("R4"), req("R5")]);
58
58
  const OMIT = () => contract([req("R1"), req("R2"), req("R4"), req("R5")]);
59
- const DEFER = () => contract([req("R1"), req("R2"), req("R3", "defer", { why: "export.js ships on the data-export module's own release train" }), req("R4"), req("R5")]);
59
+ const DEFER = () => contract([req("R1"), req("R2"), req("R3", "defer", { bucket: "new-issue", why: "export.js ships on the data-export module's own release train" }), req("R4"), req("R5")]);
60
60
 
61
61
  // ---- world ----
62
62
  function makeWorld(dir, script) {
@@ -27,6 +27,8 @@ const INCIDENTAL_RE = /(^|\/)(CHANGELOG|CHANGES|HISTORY)(\.[a-z]+)?$|(^|\/)(pack
27
27
  export const MIN_QUOTE_CHARS = 12;
28
28
  /** A contract larger than this cannot be posted whole in one comment (the comment cap is 60k). */
29
29
  export const MAX_CONTRACT_CHARS = 40000;
30
+ import { CHECK_ID_RE } from "./postdeploy-check.mjs";
31
+ import { normaliseDecision, deadAnchors } from "./decisions.mjs";
30
32
  const C = OUTCOME_CODES;
31
33
 
32
34
  export const norm = (s) => String(s ?? "").replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
@@ -231,7 +233,10 @@ export function validateContract(c, issueText, { repro = false } = {}) {
231
233
  if (!DISPOSITIONS.includes(r?.disposition)) problems.push(`${id}: disposition must be one of ${DISPOSITIONS.join("|")}`);
232
234
  if (!inIssue(r?.quote, issueText)) problems.push(`${id}: quote is not verbatim from the issue (at least ${MIN_QUOTE_CHARS} characters)`);
233
235
  if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
236
+ if (r?.disposition === "post-deploy" && !CHECK_ID_RE.test(String(r?.check ?? ""))) problems.push(`${id}: post-deploy needs a check id (\`check\`, e.g. "login-smoke") naming an entry of .forge/post-deploy-checks.json on the default branch`);
234
237
  if (r?.disposition === "not-applicable" && blank(r?.evidence)) problems.push(`${id}: not-applicable needs file:line evidence`);
238
+ for (const m of bucketProblems(r)) problems.push(`${id}: ${m}`);
239
+ if (r?.class != null && !FOLLOWUP_CLASSES.includes(r.class)) problems.push(`${id}: class must be one of ${FOLLOWUP_CLASSES.join("|")}`);
235
240
  if (r?.verify != null && !VERIFY_MODES.includes(r.verify)) problems.push(`${id}: verify must be one of ${VERIFY_MODES.join("|")}`);
236
241
  if (isOutOfBand(r) && r?.disposition !== "deliver") problems.push(`${id}: verify out_of_band applies only to a deliver requirement`);
237
242
  else if (isOutOfBand(r) && blank(r?.evidence)) problems.push(`${id}: verify out_of_band needs evidence naming the job or workflow that measures it (or set verify "in_lane" if this change can check it)`);
@@ -266,7 +271,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
266
271
  /** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
267
272
  const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
268
273
 
269
- const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement"];
274
+ const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision"];
275
+ const GUARD_ID_RE = /^#?\d+$/;
270
276
  const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
271
277
 
272
278
  /** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
@@ -280,11 +286,26 @@ function normalizeAmendment(a) {
280
286
  return n;
281
287
  }
282
288
 
289
+ const REF_SEP_RE = /\s*(?:[/,;&+]|\band\b)\s*/i, REF_RANGE_RE = /^R(\d+)\s*[-\u2013\u2014]\s*R?(\d+)$/i, REF_MAX = 50;
290
+
291
+ /** forge#4655: a slash/comma-joined or ranged requirement reference (`R2/R3`, `R2, R3`, `R2–R3`) as its individual ids; a single id or anything else is returned as is. */
292
+ function splitRequirementRefs(ref) {
293
+ if (typeof ref !== "string") return [ref];
294
+ const ids = [];
295
+ for (const part of ref.trim().split(REF_SEP_RE)) {
296
+ const m = REF_RANGE_RE.exec(part);
297
+ if (m && +m[1] <= +m[2] && +m[2] - +m[1] < REF_MAX) for (let i = +m[1]; i <= +m[2]; i++) ids.push(`R${i}`);
298
+ else ids.push(part);
299
+ }
300
+ return ids.length > 1 ? ids : [ref];
301
+ }
302
+
283
303
  /** The rejection reason, naming the valid kinds or the missing field so the builder's correction round can fix it. */
284
304
  function amendmentProblem(a, tag) {
285
305
  const kind = a?.kind;
286
306
  const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
287
- : TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)" : null;
307
+ : kind === "supersedes" ? "needs guard: \"#N\" (the guarded issue)" : TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)"
308
+ : kind === "decision" ? "needs decision {choice, anchors: [paths or globs], symbols?, supersedes?: decision id}" : null;
288
309
  const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
289
310
  return `unknown or incomplete amendment: ${why}: ${tag}`;
290
311
  }
@@ -294,14 +315,25 @@ function amendmentProblem(a, tag) {
294
315
  * defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
295
316
  * `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
296
317
  * matching path is refused; the repo's generator or bot owns that file.
318
+ * `decision` {decision:{choice, anchors, symbols?, supersedes?}} (#4695): every anchor must match a file of `baseFiles` (the
319
+ * base tree; null skips the check); `supersedes` names the decision whose engine-authored preserve row it replaces and
320
+ * drops that row; returns the records in `decisions`.
297
321
  */
298
- export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [] } = {}) {
322
+ export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [], baseFiles = null } = {}) {
299
323
  const c = structuredClone(frozen);
300
324
  c.deliverables = c.deliverables ?? [];
301
- const applied = [], toTrack = [], problems = [];
325
+ const applied = [], toTrack = [], problems = [], decisions = [];
302
326
  const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
327
+ const rows = [];
303
328
  for (const raw of Array.isArray(amendments) ? amendments : []) {
304
- const a = normalizeAmendment(raw);
329
+ const n = normalizeAmendment(raw);
330
+ if (n && (TRACKED.includes(n.kind) || n.kind === "amend-requirement") && typeof n.requirement === "string") {
331
+ const ids = splitRequirementRefs(n.requirement);
332
+ if (ids.length > 1) { for (const id of ids) rows.push({ a: { ...n, requirement: id }, raw }); continue; }
333
+ }
334
+ rows.push({ a: n, raw });
335
+ }
336
+ for (const { a, raw } of rows) {
305
337
  const tag = JSON.stringify(raw ?? null).slice(0, 400);
306
338
  if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
307
339
  if (a.kind === "add-deliverable" && !blank(a.path)) {
@@ -323,6 +355,12 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
323
355
  if (tagsOutOfBand(r)) r.verify = "out_of_band"; // #4404: same freeze-time tag as the planned requirements
324
356
  c.requirements.push(r);
325
357
  if (TRACKED.includes(r.disposition)) toTrack.push(r);
358
+ } else if (a.kind === "supersedes" && GUARD_ID_RE.test(String(a.guard ?? "").trim())) {
359
+ // #4693: retires one guard's guard_broken for this lane; the reviewer judges it, the engine tells the guarded issue after merge
360
+ const guard = `#${String(a.guard).trim().replace(/^#/, "")}`;
361
+ c.supersedes = [...(c.supersedes ?? []).filter((s) => s.guard !== guard), { guard, why: String(a.why).trim() }];
362
+ applied.push({ kind: a.kind, target: guard, why: String(a.why).trim() });
363
+ continue;
326
364
  } else if (a.kind === "amend-requirement" && !blank(a.requirement)) {
327
365
  // #4594: the requirement stays deliver and keeps its verbatim quote; the reviewer judges it against amendment.text
328
366
  const r = c.requirements.find((x) => x.id === a.requirement);
@@ -331,11 +369,25 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
331
369
  } else if (TRACKED.includes(a.kind) && a.requirement) {
332
370
  const r = c.requirements.find((x) => x.id === a.requirement);
333
371
  if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
334
- if (!TRACKED.includes(r.disposition)) { r.disposition = a.kind; r.why = a.why; r.amended = true; toTrack.push(r); }
372
+ if (!TRACKED.includes(r.disposition)) {
373
+ if (a.bucket != null) r.bucket = a.bucket; // #4724
374
+ if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { problems.push(`amendment post-deploy ${a.requirement} needs a check id (check)`); continue; }
375
+ r.disposition = a.kind; r.why = a.why; r.amended = true; r.bucket ??= "post-deploy"; toTrack.push(r); // #4724: an amendment without a bucket defaults to post-deploy
376
+ if (a.kind === "post-deploy") r.check = a.check ?? r.check; // #4626
377
+ }
378
+ } else if (a.kind === "decision") {
379
+ const d = normaliseDecision({ ...a.decision, why: a.decision?.why ?? a.why });
380
+ if (!d) { problems.push(amendmentProblem(a, tag)); continue; }
381
+ const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, baseFiles) : [];
382
+ if (dead.length) { problems.push(`decision anchor(s) ${dead.join(", ")} match no file on the base; anchor a path that exists there`); continue; }
383
+ if (!blank(a.decision?.supersedes)) c.preserve = (c.preserve ?? []).filter((p) => p.decision !== a.decision.supersedes && p.id !== a.decision.supersedes);
384
+ decisions.push(a.decision?.supersedes ? { ...d, supersedes: String(a.decision.supersedes) } : d);
385
+ applied.push({ kind: a.kind, target: d.choice.slice(0, 60), why: String(a.why).trim() });
386
+ continue;
335
387
  } else { problems.push(amendmentProblem(a, tag)); continue; }
336
388
  applied.push({ kind: a.kind, target: a.path ?? a.requirement?.id ?? a.requirement, why: String(a.why).trim() });
337
389
  }
338
- return { contract: c, applied, toTrack, problems };
390
+ return { contract: c, applied, toTrack, problems, decisions };
339
391
  }
340
392
 
341
393
  const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
@@ -387,8 +439,12 @@ export function repoTracks(paths) {
387
439
  * `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
388
440
  * tool), for the trajectory; always an array.
389
441
  */
390
- export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null }) {
442
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null }) {
391
443
  const failures = [], superseded = [], ciCovered = [];
444
+ // #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
445
+ if (checkIds) for (const r of contract.requirements ?? []) {
446
+ if (r.disposition === "post-deploy" && !(r.check && checkIds.has(r.check))) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "check_unknown", detail: `${r.id} post-deploy check ${r.check ? `'${r.check}'` : "id"} is neither on the default branch nor added by this PR (.forge/post-deploy-checks.json)` });
447
+ }
392
448
  const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
393
449
  const creep = outside(contract);
394
450
  const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
@@ -425,6 +481,9 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
425
481
  }
426
482
  failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
427
483
  if (repro) failures.push(...reproFailures(contract, repro));
484
+ // #4693: a broken guard fails unless the contract carries a supersedes amendment for it
485
+ const retired = new Set((contract.supersedes ?? []).map((s) => s.guard));
486
+ for (const f of guardFailures ?? []) if (!retired.has(f.guard)) failures.push(f);
428
487
  return { ok: failures.length === 0, failures, superseded, ciCovered };
429
488
  }
430
489
 
@@ -622,6 +681,26 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
622
681
  return { complete: p.length === 0, problems: p, missing: [...ids] };
623
682
  }
624
683
 
684
+
685
+ /** #4726: a requirement quote that carries a `[type:e2e]` criterion. */
686
+ const wantsE2e = (r) => /\[type:e2e\]/i.test(String(r?.quote ?? ""));
687
+ const E2E_CMD_RE = /\b(?:playwright|cypress|e2e|live[- ]stack|wdio)\b/i;
688
+ /**
689
+ * #4726: whether a validate command runs a real end-to-end check: an entry of `verify.e2e_checks`, a file of the e2e spec set
690
+ * (`e2e.isSpec`), or Playwright/Cypress/e2e/live-stack. A unit test file with stubbed I/O is not one.
691
+ */
692
+ export function isE2eCommand(command, e2e = null) {
693
+ const cmd = String(command ?? "");
694
+ if ((e2e?.checks ?? []).some((c) => c && cmd.includes(c))) return true;
695
+ if (typeof e2e?.isSpec === "function" && cmd.split(/\s+/).some((t) => t && e2e.isSpec(t.replace(/^["']+|["']+$/g, "")))) return true;
696
+ return E2E_CMD_RE.test(cmd);
697
+ }
698
+ /** #4726: true when a V-ref named in the row's `validated_by` resolves to a contract validate command that is a real e2e check. */
699
+ export function e2eVerified(row, contract, e2e = null) {
700
+ const by = String(row?.validated_by ?? "");
701
+ const refs = (contract?.validate ?? []).filter((v) => v?.id && new RegExp(`(?:^|[^A-Za-z0-9_])${v.id}(?![A-Za-z0-9_])`).test(by));
702
+ return refs.some((v) => isE2eCommand(v.command, e2e)) || (!refs.length && isE2eCommand(by, e2e));
703
+ }
625
704
  /**
626
705
  * The engine's decision from the reviewer's rows. state: conforms | nonconforming | review.unparseable | review.incomplete
627
706
  * | review.inconsistent. failures[] are {code, detail}; `inconsistent` marks a CONFORMS verdict whose rows fail;
@@ -643,6 +722,8 @@ export function decide(raw, ctx) {
643
722
  if (row.instrumented !== true) f.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} verified out of band${r.evidence ? ` by ${r.evidence}` : ""}: instrumentation missing${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
644
723
  } else if (r.disposition === "deliver" && row.status !== "implemented") {
645
724
  f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.amendment?.text ? `${r.id} amendment not accepted (amended to "${String(r.amendment.text).slice(0, 200)}"): ` : `${r.id} `}${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
725
+ } else if (r.disposition === "deliver" && wantsE2e(r) && !e2eVerified(row, ctx.contract, ctx.e2e)) {
726
+ f.push({ code: C.CONFORMANCE_E2E_UNVERIFIED, detail: `${r.id} is a [type:e2e] criterion but ${row.validated_by || "its validated_by"} is not a real end-to-end check (the repo's e2e set, Playwright/browser/live stack); run one, or mark ${r.id} post-deploy with a tracked follow-up` });
646
727
  }
647
728
  if (TRACKED.includes(r.disposition) && row.deferral_ok === false) f.push({ code: C.CONFORMANCE_UNJUSTIFIED_DEFERRAL, detail: `${r.id} ${r.disposition} is not justified${row.note ? `: ${row.note}` : ""}; implement it` });
648
729
  }
@@ -742,10 +823,54 @@ export function formatShadowSummary(rows) {
742
823
  return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}`;
743
824
  }
744
825
 
745
- /** One line per requirement for the follow-up script: a stable key (id + quote) and a why. Tabs and newlines removed. */
746
- export function followupItems(reqs) {
826
+ /** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724). */
827
+ export const FOLLOWUP_CLASSES = ["engineering", "post-deploy", "external", "ops"];
828
+ /** #4724: the bucket every deferred row gets at contract time. `date:<YYYY-MM-DD>` and `owner:<action>` carry a value. */
829
+ export const BUCKETS = Object.freeze(["post-deploy", "verify-now", "date", "owner", "new-issue"]);
830
+ const DATE_BUCKET_RE = /^date:(\d{4})-(\d{2})-(\d{2})$/;
831
+ /** The kind of a bucket (`date:2026-12-01` -> `date`), or null when absent or unknown. */
832
+ export const bucketKind = (b) => { const k = String(b ?? "").split(":")[0]; return BUCKETS.includes(k) ? k : null; };
833
+ /** #4724: problems with a deferred row's bucket (a defer or post-deploy row needs exactly one; owner actions are never post-deploy). */
834
+ export function bucketProblems(r) {
835
+ const b = r?.bucket;
836
+ if (!TRACKED.includes(r?.disposition)) return b == null ? [] : ["bucket applies only to a defer or post-deploy requirement"];
837
+ if (b == null || b === "") return [`${r.disposition} needs a bucket (${BUCKETS.join("|")}; \`date:<YYYY-MM-DD>\`, \`owner:<action>\`)`];
838
+ const k = bucketKind(b), out = [];
839
+ if (!k || (k !== "date" && k !== "owner" && b !== k)) out.push(`bucket '${b}' must be post-deploy, verify-now, date:<YYYY-MM-DD>, owner:<action> or new-issue`);
840
+ else if (k === "date") {
841
+ const m = DATE_BUCKET_RE.exec(String(b)), d = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
842
+ if (!m || d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) out.push(`bucket '${b}' must be an absolute date:<YYYY-MM-DD>, not a relative wait`);
843
+ } else if (k === "owner" && blank(String(b).slice(6))) out.push("bucket owner needs the action: owner:<action>");
844
+ if (r.disposition === "post-deploy" && (k === "owner" || k === "new-issue")) out.push(`a ${k} item is never post-deploy: use disposition defer`);
845
+ if (k === "owner" && r.class != null && r.class !== "external" && r.class !== "ops") out.push("an owner bucket belongs in class external or ops, never post-deploy");
846
+ return out;
847
+ }
848
+ /** The follow-up class of a requirement: owner -> external, new-issue -> engineering, other buckets post-deploy; else its `class`, else post-deploy for that disposition, else engineering. */
849
+ export function followupClass(r) {
850
+ const k = bucketKind(r?.bucket);
851
+ if (k === "owner") return r?.class === "ops" ? "ops" : "external";
852
+ if (k === "new-issue") return r?.class && r.class !== "post-deploy" && FOLLOWUP_CLASSES.includes(r.class) ? r.class : "engineering";
853
+ if (k) return "post-deploy";
854
+ return FOLLOWUP_CLASSES.includes(r?.class) ? r.class : r?.disposition === "post-deploy" ? "post-deploy" : "engineering";
855
+ }
856
+ /** #4724: deferred items per bucket kind (`none` = no bucket), for the batch telemetry line. */
857
+ export const bucketTally = (reqs) => (reqs ?? []).reduce((t, r) => { const k = bucketKind(r?.bucket) ?? "none"; t[k] = (t[k] ?? 0) + 1; return t; }, {});
858
+
859
+ /**
860
+ * One line per requirement for the follow-up script: a stable key (id + quote), a why and a class. Tabs and newlines removed.
861
+ * #4724: a bucketed row's why carries `[bucket:<kind>]` (the script counts and routes on it) and a date bucket's `due:<date>`
862
+ * plus `from:<date>` (the contract-time anchor, `now` ms) that the gate re-anchors when the parent ships later.
863
+ */
864
+ export function followupItems(reqs, { now = Date.now() } = {}) {
747
865
  const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
748
- return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${r.disposition}: ${r.why ?? ""}`).slice(0, 400).trim() }));
866
+ const check = (r) => (r.disposition === "post-deploy" && r.check ? `[check:${flat(r.check)}] ` : ""); // #4626: the follow-up item names its check
867
+ return reqs.map((r) => {
868
+ const k = bucketKind(r.bucket);
869
+ const tag = k ? `[bucket:${k}] ` : "";
870
+ const due = k === "date" ? `due:${String(r.bucket).slice(5)} from:${new Date(now).toISOString().slice(0, 10)} ` : "";
871
+ const head = k === "owner" ? `owner: ${flat(String(r.bucket).slice(6))}. ` : k && k !== "new-issue" ? "post-deploy: " : `${r.disposition}: `;
872
+ return { id: r.id, class: followupClass(r), key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${head}${tag}${check(r)}${due}${r.why ?? ""}`).slice(0, 400).trim() };
873
+ });
749
874
  }
750
875
 
751
876
  /** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */