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
@@ -4,7 +4,7 @@
4
4
  * `forgedock resume-stalled [--dry-run] [--lane <lane>]` enumerates all in-flight
5
5
  * issues, calls scanStalls, and re-dispatches each stalled issue via run-issue.
6
6
  */
7
- import { execFile } from "node:child_process";
7
+ import { execFile, spawn } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
9
  import { readdirSync, statSync, readFileSync } from "node:fs";
10
10
  import { fileURLToPath } from "node:url";
@@ -16,6 +16,8 @@ import { readLog, readLogFile, deriveState, runLogPath, migrateRunLogs } from ".
16
16
  import { assertOpenCodeNativeRuntime } from "./runner.mjs";
17
17
  import { parseForgeYaml } from "./forge-utils.mjs";
18
18
  import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
19
+ import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/events.mjs";
20
+ import { readExperiment } from "./experiment.mjs";
19
21
  import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
20
22
  import { parseVerify } from "./lean/verify.mjs";
21
23
  import { parseExecution, detectNeeds } from "./lean/execution.mjs";
@@ -123,6 +125,31 @@ export function readLaneInstructions(cwd = process.cwd()) {
123
125
  }
124
126
  const warnedContextPack = new Set();
125
127
  const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
128
+ /** Run each emitter in order; one failing never blocks the next (#4630). */
129
+ function composeEmit(...fns) { return async (rec) => { for (const f of fns) { try { await f(rec); } catch { /* telemetry and events never fail a run */ } } }; }
130
+ /**
131
+ * forge.yaml `events:` (#4630): `webhook_url`, `webhook_secret_env` (the NAME of the env var holding the HMAC secret; the secret itself is
132
+ * never read from forge.yaml) and `dir` (local JSONL directory). Returns {webhookUrl, secretEnv, dir}; {} when none is set.
133
+ */
134
+ export function readEvents(cwd = process.cwd()) {
135
+ try {
136
+ const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.events ?? {};
137
+ const v = (k) => String(e[k] ?? "").trim();
138
+ return { ...(v("webhook_url") ? { webhookUrl: v("webhook_url") } : {}), ...(/^[A-Za-z_][A-Za-z0-9_]*$/.test(v("webhook_secret_env")) ? { secretEnv: v("webhook_secret_env") } : {}), ...(v("dir") ? { dir: v("dir") } : {}) };
139
+ } catch { return {}; }
140
+ }
141
+ /** The probe sweep's `onResult`: one probe.passed / probe.failed event per recorded pass or fail row. Never throws. */
142
+ function probeEventSink(repo) {
143
+ const events = makeEventEmitter({ repo, config: readEvents() });
144
+ return async (row) => { for (const p of probeSweepEvents(row)) await events.emitEvent(p.type, p.data, p.ctx); };
145
+ }
146
+ /** `forgedock stats --experiment <id> --emit-event` (#4632): one experiment.verdict event for a met or missed report; inconclusive emits nothing. Never throws. */
147
+ export async function experimentEventSink(repo, report) {
148
+ try {
149
+ const p = experimentEvent(report);
150
+ if (p && p.data.verdict !== "inconclusive") await makeEventEmitter({ repo, config: readEvents() }).emitEvent(p.type, p.data, p.ctx);
151
+ } catch { /* events never fail stats */ }
152
+ }
126
153
  /**
127
154
  * forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
128
155
  * (shadow|on|off|ab, #4444), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
@@ -235,6 +262,10 @@ export function readBaseMerge(cwd = process.cwd()) {
235
262
  export function readSequenceRegenerate(cwd = process.cwd()) {
236
263
  try { const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; return String(l.sequence_regenerate ?? l.regenerate ?? "").trim() || undefined; } catch { return undefined; }
237
264
  }
265
+ /** #4664: forge.yaml `followups.default_milestone` (milestone title for deferred follow-ups whose parent's milestone has shipped), or undefined. */
266
+ export function readFollowupsDefaultMilestone(cwd = process.cwd()) {
267
+ try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.followups?.default_milestone ?? "").trim() || undefined; } catch { return undefined; }
268
+ }
238
269
  /** #4389: forge.yaml `post_deploy.precondition_cmd` (exit 0 = ready, 1 = parked, other = ignored), or null. */
239
270
  export function readPreconditionCmd(cwd = process.cwd()) {
240
271
  try {
@@ -249,6 +280,10 @@ export async function readPostDeployMinAge(cwd = process.cwd()) {
249
280
  return parseMinAge(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.post_deploy?.min_age);
250
281
  } catch { return null; }
251
282
  }
283
+ /** forge.yaml `milestones.close_on` (#4657): `staging` or `default` (the default). */
284
+ export function readMilestoneCloseOn(cwd = process.cwd()) {
285
+ try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.milestones?.close_on ?? "").trim().toLowerCase() === "staging" ? "staging" : "default"; } catch { return "default"; }
286
+ }
252
287
  /** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
253
288
  export function readExplore(cwd = process.cwd()) {
254
289
  try {
@@ -300,7 +335,7 @@ export function readHeavy(cwd = process.cwd()) {
300
335
  /**
301
336
  * forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
302
337
  * `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
303
- * `lane_limits_memory_max`/`lane_limits_cpu_quota`). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
338
+ * `lane_limits_memory_max`/`lane_limits_cpu_quota`), plus `tasks_max` (#4690, default 1024). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
304
339
  * `engine.cli_busy_minutes` (default 45, #4405): extra silence allowed while the builder has a live heavy step or heavy-queue entry.
305
340
  * #4390: `engine.context_ceiling_tokens` (default 120000 in runCore; 0 = off).
306
341
  * #4392: `engine.turn_budget_s|m|l` (implement turn budget per lane size; defaults 25/60/100, always returned; 0 = off).
@@ -309,17 +344,18 @@ export function readCoreLimits(cwd = process.cwd()) {
309
344
  try {
310
345
  const y = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8")) ?? {};
311
346
  const pick = (k) => { for (const sec of [y.pipeline, y.engine]) { const v = sec?.[k] ?? sec?.[`lane_limits_${k}`]; if (v !== undefined && v !== "") return String(v).trim(); } return undefined; };
312
- const mem = pick("memory_max"), cpu = pick("cpu_quota");
347
+ const mem = pick("memory_max"), cpu = pick("cpu_quota"), tasks = pick("tasks_max");
313
348
  const posNum = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : undefined; };
314
349
  return {
315
- ...(mem || cpu ? { laneLimits: { ...(mem ? { memory_max: mem } : {}), ...(cpu ? { cpu_quota: cpu } : {}) } } : {}),
350
+ // #4690: lanes get TasksMax by default; MemoryMax is opt-in (a default K-derived cap killed healthy lanes, #4740); `off`/0 disables one limit
351
+ laneLimits: { ...(mem ? { memory_max: mem } : {}), ...(cpu ? { cpu_quota: cpu } : {}), ...(tasks ? { tasks_max: tasks } : {}) },
316
352
  ...(posNum(y.engine?.cli_idle_minutes) ? { cliIdleMinutes: posNum(y.engine.cli_idle_minutes) } : {}),
317
353
  ...(posNum(y.engine?.cli_max_minutes) ? { cliMaxMinutes: posNum(y.engine.cli_max_minutes) } : {}),
318
354
  ...(posNum(y.engine?.cli_busy_minutes) ? { cliBusyMinutes: posNum(y.engine.cli_busy_minutes) } : {}),
319
355
  ...(/^\d+$/.test(String(y.engine?.context_ceiling_tokens ?? "").trim()) ? { contextCeilingTokens: Number(y.engine.context_ceiling_tokens) } : {}),
320
356
  turnBudgets: parseTurnBudgets(y.engine) ?? { ...TURN_BUDGET_DEFAULTS }, // #4392: lanes are budgeted by default
321
357
  };
322
- } catch { return { turnBudgets: { ...TURN_BUDGET_DEFAULTS } }; }
358
+ } catch { return { laneLimits: {}, turnBudgets: { ...TURN_BUDGET_DEFAULTS } }; }
323
359
  }
324
360
  /** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
325
361
  export function readMergeWait(cwd = process.cwd()) {
@@ -876,8 +912,8 @@ export async function runFromCli(argv, deps = {}) {
876
912
  try {
877
913
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
878
914
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
879
- emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
880
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}) });
915
+ emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
916
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
881
917
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
882
918
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
883
919
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1075,6 +1111,7 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
1075
1111
  * daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
1076
1112
  * `--add <id> N...` (#4408) adds issues to that batch instead of starting a second one: they run under its K and DAG
1077
1113
  * (a running batch picks them up on its next pass, a stopped one on its next `--resume`); `--resume <id> N...` adds N too.
1114
+ * `--requeue <id> N... [--reason]` (#4550) returns a terminal issue (and the dependents skipped behind it) to pending after an operator decision.
1078
1115
  * `--cancel <id>` (#4376) stops that one batch, never the shared daemon: it admits nothing more, its running local lanes get
1079
1116
  * SIGTERM (their teardown saves the work), remote lanes run to their result and the rest end `held`; `--resume <id>` continues it.
1080
1117
  */
@@ -1085,11 +1122,49 @@ export async function orchestrateFromCli(argv, deps = {}) {
1085
1122
  return orchestrateClient(argv, deps);
1086
1123
  }
1087
1124
 
1088
- const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
1125
+ const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.waiting && /^dependency #(\d+) /.test(r.detail ?? "") ? `waiting on #${/^dependency #(\d+) /.exec(r.detail)[1]}` : r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
1089
1126
  const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
1090
1127
 
1091
- const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --add <id> <issue...> | --cancel <id> | --hold <id> <issue...> | --release <id> <issue...>";
1092
- const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
1128
+ /** forge.yaml `orchestration.env_passthrough` / `path_exclude` (comma-separated) and `execution.token_env` (#4691): what the pinned env keeps beyond the allowlist. */
1129
+ function readPinnedEnvConfig(cwd) {
1130
+ try {
1131
+ const y = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"));
1132
+ const list = (v) => String(v ?? "").split(",").map((x) => x.trim()).filter(Boolean);
1133
+ const tok = String(y?.execution?.token_env ?? "").trim();
1134
+ return { passthrough: [...list(y?.orchestration?.env_passthrough), ...(/^[A-Za-z_][A-Za-z0-9_]*$/.test(tok) ? [tok] : [])], pathExclude: list(y?.orchestration?.path_exclude) };
1135
+ } catch { return { passthrough: [], pathExclude: [] }; }
1136
+ }
1137
+
1138
+ /** `--show-env <id>` (secrets redacted), `--set-env <id> KEY=VAL`, `--unset-env <id> KEY` (#4691): edits the batch's pinned env in plan.json; newly launched lanes read it. */
1139
+ async function envBatch({ argv, root, print = (l) => console.log(l) }) {
1140
+ const op = ["--show-env", "--set-env", "--unset-env"].find((f) => argv.includes(f));
1141
+ const i = argv.indexOf(op), id = argv[i + 1], arg = argv[i + 2];
1142
+ if (!id || id.startsWith("--") || (op !== "--show-env" && !arg)) throw new Error(ORCH_USAGE);
1143
+ const batchMod = await import("./lean/batch.mjs"), { redactEnv } = await import("./lean/schedd.mjs");
1144
+ const b = batchMod.openBatch(root, id);
1145
+ const plan = b.readPlan();
1146
+ if (!plan) throw new Error(`no batch ${id} in ${root}`);
1147
+ if (op === "--show-env") {
1148
+ const env = redactEnv(plan.env);
1149
+ print(plan.env ? Object.entries(env).sort(([a], [c]) => (a < c ? -1 : 1)).map(([k, v]) => `${k}=${v}`).join("\n") : `batch ${id}: pins no env (its lanes use the daemon's)`);
1150
+ return { ok: true, batch: id, env };
1151
+ }
1152
+ const env = { ...(plan.env ?? {}) };
1153
+ if (op === "--set-env") {
1154
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(arg);
1155
+ if (!m) throw new Error("--set-env needs KEY=VAL");
1156
+ env[m[1]] = m[2];
1157
+ print(`batch ${id}: set ${m[1]} (applies to newly launched lanes)`);
1158
+ } else {
1159
+ delete env[arg];
1160
+ print(`batch ${id}: unset ${arg} (applies to newly launched lanes)`);
1161
+ }
1162
+ b.writePlan({ ...plan, env });
1163
+ return { ok: true, batch: id };
1164
+ }
1165
+
1166
+ const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
1167
+ const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force"];
1093
1168
  const issuesOf = (argv) => {
1094
1169
  const issues = [];
1095
1170
  for (let i = 0; i < argv.length; i++) {
@@ -1139,10 +1214,13 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
1139
1214
  if (st === "OPEN") open.push(n);
1140
1215
  else if (!skipped.some((x) => x.issue === n)) skipped.push({ issue: n, reason: st ? "closed" : "unreadable" });
1141
1216
  }
1142
- let res = { added: [], skipped: [] };
1143
- if (open.length) res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
1144
- const out = { ok: true, batch: id, added: res.added ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
1145
- print(`batch ${id}: added ${out.added.map((n) => `#${n}`).join(" ") || "(none)"}${out.skipped.length ? `; skipped ${out.skipped.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
1217
+ let res = { added: [], skipped: [], requeued: [] };
1218
+ if (open.length) {
1219
+ res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
1220
+ if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // #4687: no daemon: the next --resume reruns it
1221
+ }
1222
+ const out = { ok: true, batch: id, added: res.added ?? [], requeued: res.requeued ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
1223
+ print(`batch ${id}: added ${out.added.map((n) => `#${n}`).join(" ") || (out.requeued.length ? "" : "(none)")}${out.requeued.length ? `${out.added.length ? " " : ""}${out.requeued.map((n) => `requeued #${n}`).join(" ")}` : ""}${out.skipped.length ? `; skipped ${out.skipped.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
1146
1224
  return out;
1147
1225
  }
1148
1226
 
@@ -1169,20 +1247,55 @@ async function holdBatch({ op, id, tokens, root, viaDaemon = null, print = (l) =
1169
1247
  return { ok: true, batch: id, changed: res.changed ?? [] };
1170
1248
  }
1171
1249
 
1250
+ /**
1251
+ * `--requeue <id> N... [--reason "..."]` (#4550): after an operator decision, a terminal issue of the batch (and the dependents
1252
+ * skipped behind it) goes back to pending. Closed issues and issues still labelled `needs-human` are refused before anything is
1253
+ * written. Through the daemon (`viaDaemon`, null when none runs: the batch's `requeue` file, and a finished batch's report is
1254
+ * cleared so `--resume` runs it again). The runner applies the entries and posts the FORGE:DECISION.
1255
+ */
1256
+ async function requeueBatch({ id, tokens, reason, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
1257
+ if (!id || String(id).startsWith("--") || !tokens.length) throw new Error(ORCH_USAGE);
1258
+ const batchMod = await import("./lean/batch.mjs");
1259
+ const b = batchMod.openBatch(root, id);
1260
+ if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
1261
+ const repo = b.readPlan()?.repo ?? null;
1262
+ const refused = [], open = [];
1263
+ for (const t of tokens) {
1264
+ if (!/^#?\d+$/.test(String(t))) { refused.push({ issue: t, reason: "not an integer" }); continue; }
1265
+ const n = parseInt(String(t).replace("#", ""), 10);
1266
+ if (open.includes(n) || refused.some((x) => x.issue === n)) continue;
1267
+ let v = null;
1268
+ try { v = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state,labels"])); } catch { v = null; }
1269
+ if (!v) refused.push({ issue: n, reason: "unreadable" });
1270
+ else if (v.state !== "OPEN") refused.push({ issue: n, reason: "closed" });
1271
+ else if ((v.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) refused.push({ issue: n, reason: "still labelled needs-human: record the decision and remove the label first" });
1272
+ else open.push(n);
1273
+ }
1274
+ const why = reason || "operator decision", by = "operator";
1275
+ let res = { requeued: [], refused: [] };
1276
+ if (open.length) {
1277
+ res = (viaDaemon ? await viaDaemon(open, why, by) : null) ?? b.requeue(open, { reason: why, by });
1278
+ if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // no daemon: the next --resume reruns it
1279
+ }
1280
+ const out = { ok: true, batch: id, requeued: res.requeued ?? [], refused: [...(res.refused ?? []), ...refused] };
1281
+ print(`batch ${id}: ${out.requeued.map((n) => `requeued #${n}`).join(" ") || "requeued (none)"}${out.refused.length ? `; refused ${out.refused.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
1282
+ return out;
1283
+ }
1284
+
1172
1285
  /**
1173
1286
  * `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
1174
1287
  * A finished batch (report written) is left alone.
1175
1288
  */
1176
- async function cancelBatch({ id, root, viaDaemon = null, print = (l) => console.log(l) }) {
1289
+ async function cancelBatch({ id, root, stopRemote = false, viaDaemon = null, print = (l) => console.log(l) }) {
1177
1290
  if (!id || String(id).startsWith("--")) throw new Error(ORCH_USAGE);
1178
1291
  let res = viaDaemon ? await viaDaemon() : null;
1179
1292
  if (!res) {
1180
1293
  const b = (await import("./lean/batch.mjs")).openBatch(root, id);
1181
1294
  if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
1182
1295
  if (b.readReport()) res = { cancelled: false, finished: true };
1183
- else { b.requestCancel({ reason: "operator" }); res = { cancelled: true }; }
1296
+ else { b.requestCancel({ reason: "operator", stopRemote }); res = { cancelled: true }; }
1184
1297
  }
1185
- print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown, the rest stay held (forgedock orchestrate --resume ${id} continues)`
1298
+ print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown${stopRemote ? " (remote sandboxes too, #4612)" : ""}, the rest stay held (forgedock orchestrate --resume ${id} continues)`
1186
1299
  : `batch ${id}: already finished`);
1187
1300
  return { ok: true, batch: id, cancelled: !!res.cancelled };
1188
1301
  }
@@ -1218,20 +1331,31 @@ async function orchestrateClient(argv, deps = {}) {
1218
1331
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1219
1332
  return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
1220
1333
  };
1334
+ const requeueVia = (id) => async (issues, reason, by) => {
1335
+ let sock = null;
1336
+ try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1337
+ return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }) : null;
1338
+ };
1221
1339
  const holdVia = (id) => async (op, issues) => {
1222
1340
  let sock = null;
1223
1341
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1224
1342
  return sock ? schedd.request(sock, { op, id, issues }) : null;
1225
1343
  };
1226
1344
  const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
1345
+ if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root: await batchRootOf() });
1346
+ if (argv.includes("--clear-breaker")) return runBatch({ argv, deps, sink: consoleSink() });
1227
1347
  if (argv.includes("--cancel")) {
1228
1348
  const id = flag(argv, "--cancel");
1229
- return cancelBatch({ id, root: await batchRootOf(), viaDaemon: async () => {
1349
+ return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
1230
1350
  let sock = null;
1231
1351
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1232
- return sock ? schedd.request(sock, { op: "cancel", id }) : null;
1352
+ return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }) : null;
1233
1353
  } });
1234
1354
  }
1355
+ if (argv.includes("--requeue")) {
1356
+ const i = argv.indexOf("--requeue"), id = argv[i + 1];
1357
+ return requeueBatch({ id, tokens: operandsOf(argv.slice(i + 2)), reason: flag(argv, "--reason"), root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: requeueVia(id) });
1358
+ }
1235
1359
  for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
1236
1360
  const id = flag(argv, `--${op}`);
1237
1361
  return holdBatch({ op, id, tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root: await batchRootOf(), viaDaemon: holdVia(id) });
@@ -1258,7 +1382,10 @@ async function orchestrateClient(argv, deps = {}) {
1258
1382
  if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
1259
1383
  sub = [...sub, "--repo", repo];
1260
1384
  }
1261
- msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
1385
+ const pinned = schedd.batchEnv(process.env, readPinnedEnvConfig(process.cwd()));
1386
+ const dropped = schedd.droppedEnv(process.env, pinned);
1387
+ if (dropped.length) console.log(`orchestrate: not pinned into the batch (lanes use the daemon's env or resolve them at lane start): ${dropped.join(", ")}; name them in forge.yaml orchestration.env_passthrough to pin them`);
1388
+ msg = { op: "submit", argv: sub, cwd: process.cwd(), env: pinned };
1262
1389
  }
1263
1390
  let sock = await connect(), req = msg, reattached = false, losses = 0;
1264
1391
  let id = resumeId;
@@ -1301,6 +1428,15 @@ async function orchestrateClient(argv, deps = {}) {
1301
1428
  export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false, signal = null }) {
1302
1429
  const batchMod = await import("./lean/batch.mjs");
1303
1430
  const root = deps.batchRoot ?? batchMod.batchRoot();
1431
+ if (argv.includes("--clear-breaker")) {
1432
+ // #4685: close a tripped breaker without editing state.json; the next --resume (or a running batch's next save) starts closed.
1433
+ const b = batchMod.openBatch(root, flag(argv, "--clear-breaker"));
1434
+ if (!b.exists()) throw new Error(`no batch ${flag(argv, "--clear-breaker")} in ${root}`);
1435
+ const st = b.store.load();
1436
+ if (st) b.store.save({ ...st, breaker: null, errorTimes: [] });
1437
+ sink.line(`batch ${b.id}: ${st?.breaker ? "breaker cleared" : "no breaker was tripped"}`);
1438
+ return { ok: true, batch: b.id, cleared: Boolean(st?.breaker) };
1439
+ }
1304
1440
  if (argv.includes("--set-parallel")) {
1305
1441
  const i = argv.indexOf("--set-parallel");
1306
1442
  const b = batchMod.openBatch(root, argv[i + 1]);
@@ -1310,7 +1446,12 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1310
1446
  sink.line(`batch ${b.id}: parallel set to ${k}`);
1311
1447
  return { ok: true, batch: b.id, parallel: k };
1312
1448
  }
1313
- if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, print: (l) => sink.line(l) });
1449
+ if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root, print: (l) => sink.line(l) });
1450
+ if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, stopRemote: argv.includes("--stop-remote"), print: (l) => sink.line(l) });
1451
+ if (argv.includes("--requeue")) {
1452
+ const i = argv.indexOf("--requeue");
1453
+ return requeueBatch({ id: argv[i + 1], tokens: operandsOf(argv.slice(i + 2)), reason: flag(argv, "--reason"), root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
1454
+ }
1314
1455
  for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
1315
1456
  return holdBatch({ op, id: flag(argv, `--${op}`), tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root, print: (l) => sink.line(l) });
1316
1457
  }
@@ -1337,8 +1478,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1337
1478
  const parallel = parallelOf(argv);
1338
1479
  // The lanes' env: a resumed daemon batch uses the env its plan kept, over the daemon's own.
1339
1480
  // A pre-#4192 batch resumed by the daemon kept no env: its lanes use the daemon's.
1340
- const kept = plan?.env ?? (resumeId ? null : daemon || callerEnv !== process.env ? callerEnv : null);
1341
- const env = kept ? (await import("./lean/schedd.mjs")).mergeEnv(process.env, kept) : process.env;
1481
+ // #4691: a fresh pin is allowlisted and PATH-sanitised again here (idempotent for a client that already did it).
1482
+ const scheddMod = await import("./lean/schedd.mjs");
1483
+ const kept = plan?.env ?? (resumeId ? null : daemon || callerEnv !== process.env ? scheddMod.batchEnv(callerEnv, readPinnedEnvConfig(plan?.cwd ?? callerCwd)) : null);
1484
+ const env = kept ? scheddMod.mergeEnv(process.env, kept) : process.env;
1342
1485
  const lane = flag(argv, "--lane");
1343
1486
  const laneOverride = argv.includes("--lane-override");
1344
1487
  const model = flag(argv, "--model");
@@ -1349,16 +1492,29 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1349
1492
  await assertLaneNotDefault(io, lane, repo, cwd);
1350
1493
  if (!deps.allowTmpfs && process.env.FORGEDOCK_BATCH_ALLOW_TMPFS !== "1") batchMod.assertDiskPath(root);
1351
1494
  if (!batch) {
1495
+ // #4659: a milestone start is refused unless every dependency of its issues is merged into its lane, or --force <reason>
1496
+ const force = flag(argv, "--force");
1497
+ if (argv.includes("--force") && (!force || force.startsWith("--"))) throw new Error("--force requires a reason: --force \"<why the unmet dependencies are acceptable>\"");
1498
+ if (!force) {
1499
+ const { unmetDependencies } = await import("../scripts/plan-validate-live.mjs");
1500
+ const gateBranches = readBranches(cwd);
1501
+ const gateIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1502
+ const unmet = await unmetDependencies({ issues, repo, gh: io.gh, baseOf: async (n) => {
1503
+ const r = await resolveLane({ issue: n, repo, io: gateIo, cwd, branches: gateBranches, requested: lane, override: laneOverride });
1504
+ return r.ok ? r.lane : null;
1505
+ } });
1506
+ if (unmet.length) throw new Error(`parallel milestone start refused: ${unmet.map((u) => `#${u.issue} depends on #${u.dep} (${u.why}; base ${u.base})`).join("; ")}. Merge the dependencies first, or pass --force "<reason>".`);
1507
+ }
1352
1508
  const base = deps.batchId ?? batchMod.newBatchId();
1353
1509
  batch = batchMod.openBatch(root, base);
1354
1510
  for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
1355
- plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(daemon ? { daemon: true, env: kept } : {}) };
1511
+ plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
1356
1512
  batch.writePlan(plan);
1357
1513
  batch.setParallel(parallel);
1358
1514
  } else {
1359
1515
  // A resume is the operator's decision to continue: the breaker starts closed again.
1360
1516
  const st = batch.store.load();
1361
- if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
1517
+ if (st && (!keepBreaker || st.breaker?.transport)) batch.store.save({ ...st, breaker: null, errorTimes: [] });
1362
1518
  if (!keepBreaker) batch.clearCancel(); // #4376: the operator's resume continues a cancelled batch
1363
1519
  if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
1364
1520
  batch.clearReport();
@@ -1376,7 +1532,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1376
1532
  // #4399: declared acceptance probes of closed issues whose fix reached the default branch; never fails the batch
1377
1533
  const sweep = deps.sweepProbes !== undefined ? deps.sweepProbes : (await import("./lean/probe.mjs")).sweepProbes;
1378
1534
  if (sweep && !argv.includes("--no-probes")) {
1379
- try { const s = (await import("./lean/probe.mjs")).formatProbeSummary(await sweep({ repo, cwd, io: leanIo })); if (s) sink.line(s); } catch (e) { sink.line(`probes: sweep failed (${String(e?.message ?? e).slice(0, 160)})`); }
1535
+ try { const s = (await import("./lean/probe.mjs")).formatProbeSummary(await sweep({ repo, cwd, io: leanIo, onResult: probeEventSink(repo) })); if (s) sink.line(s); } catch (e) { sink.line(`probes: sweep failed (${String(e?.message ?? e).slice(0, 160)})`); }
1380
1536
  }
1381
1537
  const { makePrefilter, formatPrefilterSummary, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
1382
1538
  const branches = readBranches(cwd);
@@ -1400,9 +1556,20 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1400
1556
  const { runCore } = deps.leanModule;
1401
1557
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1402
1558
  lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
1403
- admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}) }) };
1559
+ admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
1404
1560
  } else {
1405
1561
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1562
+ // #4690: lanes learn K (the default MemoryMax is derived from it); one line says so when no scope caps them.
1563
+ const laneEnv = { ...env, FORGEDOCK_LANE_PARALLEL: String(parallel) };
1564
+ // #4691: --set-env / --unset-env edit plan.json while the batch runs; each newly launched lane re-reads the pinned env.
1565
+ const laneSpawn = (bin, args, o) => {
1566
+ const p = batch.readPlan();
1567
+ const fresh = p?.env ? { ...scheddMod.mergeEnv(process.env, p.env), FORGEDOCK_LANE_PARALLEL: String(parallel) } : null;
1568
+ return (deps.spawnFn ?? spawn)(bin, args, fresh ? { ...o, env: { ...fresh, FORGEDOCK_BATCH_ID: o.env?.FORGEDOCK_BATCH_ID } } : o);
1569
+ };
1570
+ const effects = await import("./lean/effects.mjs");
1571
+ const uncapped = effects.laneCapNotice(deps.laneCapProbe ?? effects.systemdRunAvailable);
1572
+ if (uncapped) (deps.out ?? ((l) => console.log(l)))(uncapped);
1406
1573
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
1407
1574
  // #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
1408
1575
  const placementOf = new Map(), recoverOf = new Map();
@@ -1417,6 +1584,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1417
1584
  const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
1418
1585
  execForNeeds = exec ?? null;
1419
1586
  hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
1587
+ let remoteStop = null; // #4612: the sandbox provider's checkpoint stop, used only when the cancel asks for it
1420
1588
  if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
1421
1589
  // #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
1422
1590
  let gitDir = null;
@@ -1426,11 +1594,13 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1426
1594
  const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
1427
1595
  const bud = exec.budget ?? {};
1428
1596
  const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
1597
+ ...(Number.isFinite(exec.unreachableWindowMin) ? { unreachableWindowMs: exec.unreachableWindowMin * 60000 } : {}),
1429
1598
  ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
1599
+ remoteStop = remoteLanes.stopLane;
1430
1600
  lanes = withPlacement(remoteLanes);
1431
1601
  {
1432
1602
  // #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
1433
- const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
1603
+ const local = batchMod.detachedLanes({ batch, cwd, env: laneEnv, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs, spawnFn: laneSpawn });
1434
1604
  lanes = withPlacement({
1435
1605
  runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
1436
1606
  adopt: (n, rec, ctx) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec, ctx),
@@ -1470,11 +1640,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1470
1640
  };
1471
1641
  sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
1472
1642
  } else {
1473
- lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
1643
+ lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env: laneEnv, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs, spawnFn: laneSpawn }));
1474
1644
  }
1475
- // #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on.
1645
+ // #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on unless `--cancel --stop-remote` (#4612).
1476
1646
  const { killLedgerPid } = await import("./lean/effects.mjs");
1477
- const stopLane = deps.stopLane ?? ((n, rec) => (rec?.pid && !rec.remote && !rec.handle ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
1647
+ const stopLane = deps.stopLane ?? ((n, rec) => (rec?.remote || rec?.handle ? remoteStop?.(n, rec) ?? false : rec?.pid ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
1478
1648
  lanes = { ...lanes, stopLane };
1479
1649
  }
1480
1650
  const orch = deps.orchestration ?? readOrchestration(cwd);
@@ -1519,6 +1689,19 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1519
1689
  drift = makeDrift({ repo, cwd, io: leanIo, config: readDrift(cwd), stagingBranch: branches.staging ?? "staging", affectedFiles,
1520
1690
  onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
1521
1691
  }
1692
+ let lifecycle = deps.lifecycle; // forge#4657: milestone lifecycle; closes at `milestones.close_on`
1693
+ if (lifecycle === undefined) {
1694
+ const { makeLifecycle } = await import("./lean/lifecycle.mjs");
1695
+ lifecycle = makeLifecycle({ gh: leanIo.gh, git: (a) => leanIo.git(a, { cwd }), repo, closeOn: readMilestoneCloseOn(cwd), defaultBranch: branches.default ?? "main" });
1696
+ }
1697
+ // forge#4667: milestone integration runs (every N lane merges) and the red-streak admission hold; off without forge.yaml ci.workflow.
1698
+ let integration = deps.integration;
1699
+ if (integration === undefined) {
1700
+ const { makeIntegration } = await import("./lean/integration.mjs");
1701
+ integration = makeIntegration({ repo, io: leanIo, cfg: await readIntegration(cwd), pattern: branches.featurePattern ?? undefined,
1702
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)), onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
1703
+ try { await integration?.prime(); } catch { /* streaks unknown: nothing is held */ }
1704
+ }
1522
1705
  const crossLane = [];
1523
1706
  // #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
1524
1707
  const gcMod = await import("./gc.mjs");
@@ -1580,8 +1763,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1580
1763
  rows = await orchestrate({
1581
1764
  ...(estimate ? { estimate } : {}),
1582
1765
  beforeAdmit,
1583
- issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, getHeld: batch.readHeld, ...lanes,
1584
- breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1766
+ issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, ...lanes,
1767
+ breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
1585
1768
  ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1586
1769
  ...(remote ? { remote } : {}),
1587
1770
  // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
@@ -1625,6 +1808,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1625
1808
  onUpdate: (r) => sink.row(r),
1626
1809
  });
1627
1810
  } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
1811
+ try { await integration?.settle?.(); } catch { /* best effort: the state block is the record */ }
1628
1812
  const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
1629
1813
  const pfLine = formatPrefilterSummary(rows);
1630
1814
  if (pfLine) report.push(pfLine);
@@ -1682,8 +1866,9 @@ export function makeDispatchCheck({ gh, repo }) {
1682
1866
  } catch { return null; }
1683
1867
  };
1684
1868
  return async (n) => {
1685
- const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state"]);
1869
+ const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state,stateReason"]);
1686
1870
  const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
1871
+ const notPlanned = String(iss?.stateReason ?? "").toUpperCase().replace(/\s/g, "_") === "NOT_PLANNED";
1687
1872
  const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
1688
1873
  const pr = Array.isArray(prs) && Number.isInteger(prs[0]?.number) ? prs[0].number : null;
1689
1874
  if (pr !== null && state === "OPEN") {
@@ -1692,7 +1877,7 @@ export function makeDispatchCheck({ gh, repo }) {
1692
1877
  if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
1693
1878
  }
1694
1879
  if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
1695
- if (state === "CLOSED") return { outcome: "invalid", detail: "issue closed" };
1880
+ if (state === "CLOSED") return notPlanned ? { outcome: "invalid", detail: "issue closed (not planned)", final: true } : { outcome: "invalid", detail: "issue closed" }; // #4687: final = its dependents stay skipped
1696
1881
  return null;
1697
1882
  };
1698
1883
  }
@@ -1703,7 +1888,7 @@ export function makeDispatchCheck({ gh, repo }) {
1703
1888
  * resume retries); then the capped FORGE:NEEDS_HUMAN comment, whose failure keeps the label → true.
1704
1889
  */
1705
1890
  export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
1706
- return async ({ issue, evidence }) => {
1891
+ return async ({ issue, evidence, parked = null }) => {
1707
1892
  const { stateOf } = await import("./lean/context.mjs");
1708
1893
  try {
1709
1894
  const v = JSON.parse(await gh(["issue", "view", String(issue), "-R", repo, "--json", "state,body"]));
@@ -1711,13 +1896,28 @@ export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
1711
1896
  const lease = stateOf(v?.body)?.lease;
1712
1897
  if (lease && Number(lease.until) > now()) return "skipped";
1713
1898
  } catch { /* unreadable: relabel as before */ }
1714
- try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]); } catch { return "failed"; }
1715
- try {
1716
- // #4315: the evidence carries the full lane reason; capped so it never exceeds GitHub's comment limit
1717
- const { capComment } = await import("./lean/render.mjs");
1718
- await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`)]);
1719
- } catch { /* best effort: the label is on, the batch row keeps the evidence */ }
1720
- return true;
1899
+ // #4656: a mechanical park carries its code and PR on `parked:<code>` and joins the remediation queue, not the human queue.
1900
+ if (parked?.code) {
1901
+ const { parkedLabel, REMEDIATION_QUEUE_LABEL } = await import("./engine/outcome-codes.mjs");
1902
+ try { await gh(["label", "create", parkedLabel(parked.code), "-R", repo, "--force", "--color", "C5DEF5", "--description", "Mechanical park on a terminal code. Managed by ForgeDock."]); } catch { /* exists, or no rights: the edit says */ }
1903
+ try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", `${parkedLabel(parked.code)},${REMEDIATION_QUEUE_LABEL}`, "--remove-label", "needs-human"]); } catch { return "failed"; }
1904
+ try {
1905
+ const { capComment } = await import("./lean/render.mjs");
1906
+ await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:PARKED code=${parked.code}${parked.pr ? ` pr=${parked.pr}` : ""} -->\n## Parked (\`${parked.code}\`)\nCode: \`${parked.code}\`\nPR: ${parked.pr ? `#${parked.pr}` : "none"}\nQueue: \`${REMEDIATION_QUEUE_LABEL}\` (\`/work-on:remediate\`)\n${evidence}`)]);
1907
+ } catch { /* best effort */ }
1908
+ return true;
1909
+ }
1910
+ // #4656: a human-owned wait is `needs-human:external` and its comment names the owner and the unblock condition.
1911
+ {
1912
+ const external = { owner: "", unblock: "" }; // the default non-parked relabel is a human-owned wait
1913
+ const { NEEDS_HUMAN_EXTERNAL_LABEL } = await import("./engine/outcome-codes.mjs");
1914
+ try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", `needs-human,${NEEDS_HUMAN_EXTERNAL_LABEL}`]); } catch { return "failed"; }
1915
+ try {
1916
+ const { capComment } = await import("./lean/render.mjs");
1917
+ await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN external=true -->\n## Needs human (external)\nOwner: ${external.owner || "unassigned (name an owner)"}\nUnblock condition: ${external.unblock || "unstated (name the condition that unblocks this issue)"}\n${evidence}`)]);
1918
+ } catch { /* best effort */ }
1919
+ return true;
1920
+ }
1721
1921
  };
1722
1922
  }
1723
1923
 
@@ -1728,6 +1928,26 @@ export function formatReclaimed(r) {
1728
1928
  return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
1729
1929
  }
1730
1930
 
1931
+ /** #4627: the daemon's timer callback: re-run the due windowed post-deploy checks of the cwd repo. No repo, no work. */
1932
+ async function scheddDue() {
1933
+ const io = makeIo();
1934
+ const repo = await resolveDefaultRepo(io);
1935
+ if (!repo) return;
1936
+ const { sweepDue } = await import("./lean/postdeploy-monitor.mjs");
1937
+ const leanIo = (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1938
+ await sweepDue({ repo, cwd: process.cwd(), io: leanIo, onResult: probeEventSink(repo) });
1939
+ await sweepParkedFollowups({ repo, io: leanIo }); // #4689: re-queue parked post-deploy follow-ups on the same pass
1940
+ }
1941
+
1942
+ /** #4689: re-evaluate the parked post-deploy follow-ups of the repo (promotion, due date, check now defined); never throws. */
1943
+ async function sweepParkedFollowups({ repo, io, dryRun = false }) {
1944
+ try {
1945
+ const { sweepParked } = await import("./lean/parked-sweep.mjs");
1946
+ const branches = readBranches();
1947
+ return await sweepParked({ repo, cwd: process.cwd(), io, dryRun, defaultBranch: (await import("./lean/prefilter.mjs")).lazyDefaultBranch({ io, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd(), minAge: await readPostDeployMinAge() });
1948
+ } catch { return []; }
1949
+ }
1950
+
1731
1951
  /**
1732
1952
  * `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
1733
1953
  */
@@ -1742,7 +1962,8 @@ export async function scheddFromCli(argv, deps = {}) {
1742
1962
  return { running: true, ...st };
1743
1963
  }
1744
1964
  if (argv.length) throw new Error("usage: forgedock schedd [status]");
1745
- const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
1965
+ process.title = "forgedock-schedd"; // distinctive name for OOM-killer avoid lists and ps (#4673)
1966
+ const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }) });
1746
1967
  if (!d.running) return d;
1747
1968
  // #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
1748
1969
  schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
@@ -1788,6 +2009,41 @@ export async function limitsFromCli(argv, deps = {}) {
1788
2009
  throw new Error(usage);
1789
2010
  }
1790
2011
 
2012
+ /**
2013
+ * `forgedock decisions list [--stale] [--refresh] [--repo OWNER/NAME] [--base REF]` (#4695): the anchored decisions of the
2014
+ * local index (`~/.forge/decisions/`); `--refresh` rebuilds it from the repo's FORGE:DECISION comments first; `--stale`
2015
+ * lists the active ones with an anchor that matches no file on the base ref (default HEAD). Read-only apart from the index.
2016
+ */
2017
+ export async function decisionsFromCli(argv, deps = {}) {
2018
+ const dec = await import("./lean/decisions.mjs");
2019
+ const usage = "usage: forgedock decisions list [--stale] [--refresh] [--repo OWNER/NAME] [--base REF]";
2020
+ const [sub, ...rest] = argv;
2021
+ if (sub !== "list") throw new Error(usage);
2022
+ const flag = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : null; };
2023
+ const run = deps.exec ?? (async (cmd, args) => String((await pexec(cmd, args, { maxBuffer: 64 * 1024 * 1024 })).stdout));
2024
+ const repo = flag("--repo") ?? String(await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])).trim();
2025
+ if (rest.includes("--refresh")) {
2026
+ const jq = '.[] | select(.body | startswith("<!-- FORGE:DECISION {")) | {body, user, author_association, issue_url} | tojson';
2027
+ const lines = String(await run("gh", ["api", "--paginate", `repos/${repo}/issues/comments?per_page=100`, "--jq", jq])).split("\n").filter(Boolean);
2028
+ const { isTrustedIssueComment } = await import("./lean/prefilter.mjs");
2029
+ const recs = [];
2030
+ for (const l of lines) {
2031
+ try {
2032
+ const c = JSON.parse(l);
2033
+ const r = isTrustedIssueComment(c) ? dec.parseDecisionComment(c.body, { issue: Number(/(\d+)$/.exec(c.issue_url ?? "")?.[1]) || null }) : null;
2034
+ if (r) recs.push(r);
2035
+ } catch { /* skip a malformed comment */ }
2036
+ }
2037
+ dec.writeDecisionIndex(repo, recs);
2038
+ }
2039
+ const all = dec.foldDecisions(dec.readDecisionIndex(repo));
2040
+ let rows = all.filter((d) => d.status !== "superseded").map((d) => ({ decision: d, dead: [] }));
2041
+ if (rest.includes("--stale")) rows = dec.staleDecisions(all, String(await run("git", ["ls-tree", "-r", "--name-only", flag("--base") ?? "HEAD"])).split("\n").filter(Boolean));
2042
+ for (const { decision: d, dead } of rows) console.log(`${d.id} #${d.issue ?? "?"} ${d.choice} [${d.anchors.join(", ")}]${dead.length ? ` stale: ${dead.join(", ")}` : ""}${d.supersedes ? ` supersedes ${d.supersedes}` : ""}`);
2043
+ if (!rows.length) console.log(rest.includes("--stale") ? "no stale decisions" : "no active decisions");
2044
+ return { rows };
2045
+ }
2046
+
1791
2047
  /**
1792
2048
  * `forgedock check [--repo DIR] [--base REF] [--json]` (#4364): runs in the lane worktree what the engine runs after the
1793
2049
  * builder: the frozen validate commands (.forge-lean/contract.json) and the change-scoped pre-PR checks, with the plan
@@ -2037,13 +2293,72 @@ export async function triageFromCli(argv, deps = {}) {
2037
2293
  }
2038
2294
 
2039
2295
  /**
2040
- * `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N]` (#4399)
2296
+ * `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N] [--due]` (#4399; `--due` #4627: re-run due windowed post-deploy checks)
2041
2297
  * Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
2042
2298
  * declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
2043
2299
  */
2300
+ /** `forgedock integration run` (#4667): one pass of the milestone integration runs (lean/integration.mjs). Resolves the exit code. */
2301
+ export async function integrationFromCli(argv, deps = {}) {
2302
+ const { runIntegrationCli: run } = await import("./lean/integration.mjs");
2303
+ return run(argv, deps);
2304
+ }
2305
+
2306
+ /** `forgedock milestone-sync <slug> --reason ...` (#4663): on-demand milestone branch sync (lean/milestonesync.mjs). Resolves the exit code. */
2307
+ export async function milestoneSyncFromCli(argv, deps = {}) {
2308
+ const { runMilestoneSyncCli: run } = await import("./lean/milestonesync.mjs");
2309
+ return run(argv, deps);
2310
+ }
2311
+
2312
+ /**
2313
+ * `forgedock milestone train` (#4658): lean/train.mjs over `orchestrateFromCli` (one batch per milestone) and the #4657 lifecycle.
2314
+ * The train reads forge.yaml (`orchestration.global_parallel`, `milestones.close_on`) and never writes it.
2315
+ */
2316
+ export async function trainFromCli(argv, deps = {}) {
2317
+ const { trainCli } = await import("./lean/train.mjs");
2318
+ const batchMod = await import("./lean/batch.mjs");
2319
+ const cwd = process.cwd();
2320
+ const io = deps.io ?? makeIo();
2321
+ const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
2322
+ if (!repo) throw new Error("milestone train needs the repo: pass --repo or run inside a checkout with a GitHub remote");
2323
+ const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
2324
+ const { makeLifecycle } = await import("./lean/lifecycle.mjs");
2325
+ const { readLive } = await import("../scripts/plan-validate-live.mjs");
2326
+ const orchestrate = deps.orchestrate ?? orchestrateFromCli;
2327
+ return trainCli(argv, {
2328
+ root: deps.batchRoot ?? batchMod.batchRoot(), repo, id: deps.id, print: deps.print,
2329
+ globalParallel: readOrchestration(cwd).globalParallel,
2330
+ readLive: deps.readLive ?? (() => readLive({ gh: io.gh, repo })),
2331
+ onBatchEnd: (titles) => (deps.lifecycle ?? makeLifecycle({ gh: leanIo.gh, git: (a) => leanIo.git(a, { cwd }), repo, closeOn: readMilestoneCloseOn(cwd), defaultBranch: readBranches(cwd).default ?? "main" })).onBatchEnd(titles),
2332
+ runMilestone: async (title, { parallel, resume }) => {
2333
+ if (resume) return orchestrate(["--resume", resume, "--repo", repo]);
2334
+ const open = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--milestone", title, "--state", "open", "--limit", "200", "--json", "number"])) || "[]");
2335
+ if (!open.length) return { ok: true, rows: [] };
2336
+ return orchestrate([...open.map((i) => String(i.number)), "--parallel", String(parallel), "--repo", repo]);
2337
+ },
2338
+ });
2339
+ }
2340
+
2341
+ /** `forgedock guards list|backfill` (#4692): fix guards from the FORGE:GUARD comments on GitHub. */
2342
+ export async function guardsFromCli(argv, deps = {}) {
2343
+ const { guardsCli } = await import("./lean/guards.mjs");
2344
+ const io = deps.io ?? makeIo();
2345
+ const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
2346
+ if (!repo) throw new Error("guards needs the repo: pass --repo or run inside a checkout with a GitHub remote");
2347
+ const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
2348
+ return guardsCli(argv.filter((a, i) => a !== "--repo" && argv[i - 1] !== "--repo"), { gh: (a) => leanIo.gh(a), git: (a) => leanIo.git(a, { cwd: process.cwd() }), repo, print: deps.print, root: deps.root });
2349
+ }
2350
+
2351
+ /** forge.yaml `ci` (#4667): the milestone integration config, or null when no `workflow` is set (feature off). */
2352
+ export function readIntegration(cwd = process.cwd()) {
2353
+ let ci = null;
2354
+ try { ci = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.ci ?? null; } catch { ci = null; }
2355
+ return import("./lean/integration.mjs").then((m) => m.parseIntegration(ci));
2356
+ }
2357
+
2044
2358
  export async function probeFromCli(argv, deps = {}) {
2045
2359
  const valued = ["--repo", "--limit"];
2046
2360
  const issues = [];
2361
+ const due = argv.includes("--due"); // #4627
2047
2362
  for (let i = 0; i < argv.length; i++) {
2048
2363
  if (valued.includes(argv[i])) { i++; continue; }
2049
2364
  if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
@@ -2054,8 +2369,19 @@ export async function probeFromCli(argv, deps = {}) {
2054
2369
  const limit = flag(argv, "--limit") == null ? 50 : parseInt(flag(argv, "--limit"), 10);
2055
2370
  if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer");
2056
2371
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
2372
+ if (due) {
2373
+ const { sweepDue, formatDueSummary } = await import("./lean/postdeploy-monitor.mjs");
2374
+ const rows = await sweepDue({ repo, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
2375
+ for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
2376
+ console.log(formatDueSummary(rows) || "monitoring: nothing due");
2377
+ const parkedRows = await sweepParkedFollowups({ repo, io: leanIo, dryRun: argv.includes("--dry-run") }); // #4689
2378
+ for (const r of parkedRows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
2379
+ const { formatParkedSweepSummary } = await import("./lean/parked-sweep.mjs");
2380
+ console.log(formatParkedSweepSummary(parkedRows) || "parked: nothing released");
2381
+ return { rows, parkedRows };
2382
+ }
2057
2383
  const { sweepProbes, formatProbeSummary } = await import("./lean/probe.mjs");
2058
- const rows = await sweepProbes({ repo, cwd: process.cwd(), io: leanIo, issues: [...new Set(issues)], limit, dryRun: argv.includes("--dry-run"), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
2384
+ const rows = await sweepProbes({ repo, cwd: process.cwd(), io: leanIo, issues: [...new Set(issues)], limit, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
2059
2385
  for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
2060
2386
  console.log(formatProbeSummary(rows) || "probes: nothing to run");
2061
2387
  return { rows };