forgedock 1.30.0 → 1.31.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 (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
@@ -12,14 +12,16 @@ import { homedir, hostname } from "node:os";
12
12
  import { join, resolve as resolvePath } from "node:path";
13
13
  import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs";
14
14
  import { makeProjector } from "./engine/projector.mjs";
15
- import { readLog, deriveState } from "./engine/runlog.mjs";
15
+ import { readLog, readLogFile, deriveState, runLogPath, migrateRunLogs } from "./engine/runlog.mjs";
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
19
  import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
20
20
  import { parseVerify } from "./lean/verify.mjs";
21
21
  import { parseExecution } from "./lean/execution.mjs";
22
+ import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
22
23
  import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
24
+ import { parseLightConfig, LIGHT_DEFAULTS } from "./lean/light.mjs";
23
25
  import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
24
26
 
25
27
  /**
@@ -112,6 +114,13 @@ export function readReviewIncomplete(cwd = process.cwd()) {
112
114
  return ["needs-human", "degraded"].includes(v) ? v : undefined;
113
115
  } catch { return undefined; }
114
116
  }
117
+ /** forge.yaml `engine.lane_instructions` (#4395): repo-relative lane instruction file; undefined when absent, empty or unreadable. */
118
+ export function readLaneInstructions(cwd = process.cwd()) {
119
+ try {
120
+ const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.lane_instructions ?? "").trim();
121
+ return v || undefined;
122
+ } catch { return undefined; }
123
+ }
115
124
  const warnedContextPack = new Set();
116
125
  const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
117
126
  /**
@@ -129,7 +138,8 @@ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
129
138
  mode = "";
130
139
  }
131
140
  const out = { ...(mode ? { mode } : {}), ...(e.context_pack_sources != null ? { sources: String(e.context_pack_sources) } : {}),
132
- ...(e.context_pack_bytes != null ? { bytes: Number(e.context_pack_bytes) } : {}), ...(e.context_pack_days != null ? { days: Number(e.context_pack_days) } : {}) };
141
+ ...(e.context_pack_bytes != null ? { bytes: Number(e.context_pack_bytes) } : {}), ...(e.context_pack_days != null ? { days: Number(e.context_pack_days) } : {}),
142
+ ...(e.context_pack_pitfall_lanes != null ? { pitfallLanes: Number(e.context_pack_pitfall_lanes) } : {}) }; // #4375
133
143
  return Object.keys(out).length ? out : undefined;
134
144
  } catch { return undefined; }
135
145
  }
@@ -159,6 +169,19 @@ export function readReviewTrigger(cwd = process.cwd()) {
159
169
  }
160
170
  return mode || riskPaths.length ? { mode: mode ?? "shadow", riskPaths } : undefined;
161
171
  }
172
+ /**
173
+ * forge.yaml `review.verifier` (off|shadow|enforce) and `review.verifier_model`, else `engine.lean_verifier_model` (#4397).
174
+ * Returns {mode, model}; undefined when neither is set or valid (the core defaults to off).
175
+ */
176
+ export function readVerifier(cwd = process.cwd()) {
177
+ let sec = {};
178
+ try { sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.review ?? {}; } catch { /* absent or unreadable */ }
179
+ const m = String(sec?.verifier ?? "").trim();
180
+ const mode = ["off", "shadow", "enforce"].includes(m) ? m : undefined;
181
+ const v = String(sec?.verifier_model ?? "").trim();
182
+ const model = /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : readLeanSetting("verifier_model", cwd);
183
+ return mode || model ? { mode: mode ?? "off", ...(model ? { model } : {}) } : undefined;
184
+ }
162
185
  /** forge.yaml `lean.repro_test` (off|prefer, #4271); undefined when absent or invalid (the core defaults to prefer). */
163
186
  export function readReproTest(cwd = process.cwd()) {
164
187
  try {
@@ -170,6 +193,55 @@ export function readReproTest(cwd = process.cwd()) {
170
193
  export function readCloseParent(cwd = process.cwd()) {
171
194
  try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.decomposition?.close_parent ?? "").trim() === "true"; } catch { return false; }
172
195
  }
196
+ /** forge.yaml `lean.fix_model` (#4363, else `engine.lean_fix_model`): the model of fresh fix-round sessions; undefined when absent or invalid. */
197
+ export function readFixModel(cwd = process.cwd()) {
198
+ try {
199
+ const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean?.fix_model ?? "").trim();
200
+ if (/^[A-Za-z0-9._:[\]-]+$/.test(v)) return v;
201
+ } catch { /* fall through to engine.lean_fix_model */ }
202
+ return readLeanSetting("fix_model", cwd);
203
+ }
204
+ /**
205
+ * forge.yaml `lean.builder_routing` (on|off, #4394) and `lean.builder_mid_model` (else `engine.lean_builder_mid_model`):
206
+ * {mode?, midModel?}; undefined when neither is set or valid (the core defaults to on with claude-sonnet-5-5).
207
+ */
208
+ export function readBuilderRouting(cwd = process.cwd()) {
209
+ let sec = {};
210
+ try { sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; } catch { /* absent or unreadable */ }
211
+ const m = String(sec?.builder_routing ?? "").trim().toLowerCase();
212
+ const mode = ["on", "off"].includes(m) ? m : undefined;
213
+ const v = String(sec?.builder_mid_model ?? "").trim();
214
+ const midModel = /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : readLeanSetting("builder_mid_model", cwd);
215
+ return mode || midModel ? { ...(mode ? { mode } : {}), ...(midModel ? { midModel } : {}) } : undefined;
216
+ }
217
+ /**
218
+ * forge.yaml `lean.sequence.regenerate` (#4386): the command that regenerates a schema-changing migration's snapshot
219
+ * after a deterministic renumber. The one-level parser flattens the nested block to `lean.regenerate`; flat
220
+ * `lean.sequence_regenerate` also works and wins. undefined when absent or unreadable.
221
+ */
222
+ export function readSequenceRegenerate(cwd = process.cwd()) {
223
+ try { const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; return String(l.sequence_regenerate ?? l.regenerate ?? "").trim() || undefined; } catch { return undefined; }
224
+ }
225
+ /** #4389: forge.yaml `post_deploy.precondition_cmd` (exit 0 = ready, 1 = parked, other = ignored), or null. */
226
+ export function readPreconditionCmd(cwd = process.cwd()) {
227
+ try {
228
+ const v = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.post_deploy?.precondition_cmd;
229
+ return typeof v === "string" && v.trim() ? v.trim() : null;
230
+ } catch { return null; }
231
+ }
232
+ /** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
233
+ export function readExplore(cwd = process.cwd()) {
234
+ try {
235
+ const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {};
236
+ const mode = String(l.explore ?? "").trim().toLowerCase(), model = String(l.explore_model ?? "").trim();
237
+ const out = { ...(["on", "off"].includes(mode) ? { mode } : {}), ...(/^[A-Za-z0-9._:[\]-]+$/.test(model) ? { model } : {}) };
238
+ return Object.keys(out).length ? out : undefined;
239
+ } catch { return undefined; }
240
+ }
241
+ /** forge.yaml `lean.light` (#4448): {mode: off|shadow|on, maxLines, maxFiles} for bin/lean/light.mjs; the defaults when absent or unreadable. */
242
+ export function readLight(cwd = process.cwd()) {
243
+ try { return parseLightConfig(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}); } catch { return { ...LIGHT_DEFAULTS }; }
244
+ }
173
245
  function readLeanSetting(key, cwd = process.cwd()) {
174
246
  try {
175
247
  const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
@@ -209,6 +281,9 @@ export function readHeavy(cwd = process.cwd()) {
209
281
  * forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
210
282
  * `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
211
283
  * `lane_limits_memory_max`/`lane_limits_cpu_quota`). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
284
+ * `engine.cli_busy_minutes` (default 45, #4405): extra silence allowed while the builder has a live heavy step or heavy-queue entry.
285
+ * #4390: `engine.context_ceiling_tokens` (default 120000 in runCore; 0 = off).
286
+ * #4392: `engine.turn_budget_s|m|l` (implement turn budget per lane size; defaults 25/60/100, always returned; 0 = off).
212
287
  */
213
288
  export function readCoreLimits(cwd = process.cwd()) {
214
289
  try {
@@ -220,8 +295,11 @@ export function readCoreLimits(cwd = process.cwd()) {
220
295
  ...(mem || cpu ? { laneLimits: { ...(mem ? { memory_max: mem } : {}), ...(cpu ? { cpu_quota: cpu } : {}) } } : {}),
221
296
  ...(posNum(y.engine?.cli_idle_minutes) ? { cliIdleMinutes: posNum(y.engine.cli_idle_minutes) } : {}),
222
297
  ...(posNum(y.engine?.cli_max_minutes) ? { cliMaxMinutes: posNum(y.engine.cli_max_minutes) } : {}),
298
+ ...(posNum(y.engine?.cli_busy_minutes) ? { cliBusyMinutes: posNum(y.engine.cli_busy_minutes) } : {}),
299
+ ...(/^\d+$/.test(String(y.engine?.context_ceiling_tokens ?? "").trim()) ? { contextCeilingTokens: Number(y.engine.context_ceiling_tokens) } : {}),
300
+ turnBudgets: parseTurnBudgets(y.engine) ?? { ...TURN_BUDGET_DEFAULTS }, // #4392: lanes are budgeted by default
223
301
  };
224
- } catch { return {}; }
302
+ } catch { return { turnBudgets: { ...TURN_BUDGET_DEFAULTS } }; }
225
303
  }
226
304
  /** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
227
305
  export function readMergeWait(cwd = process.cwd()) {
@@ -392,16 +470,17 @@ export function formatUsageLine(usage) {
392
470
  *
393
471
  * @param {string} dir - runDir() (or an injected override for tests)
394
472
  * @param {number} issue
473
+ * @param {string|null} [repo] - "owner/repo" (#4432: the run log is keyed by repo + issue); null = legacy `<issue>.jsonl`
395
474
  * @returns {string} multi-line diagnostic block (no trailing newline)
396
475
  */
397
- export function formatTerminalDiagnostics(dir, issue) {
476
+ export function formatTerminalDiagnostics(dir, issue, repo = null) {
398
477
  if (!Number.isInteger(issue)) {
399
478
  return ` run-log: <invalid issue: ${JSON.stringify(issue)}>`;
400
479
  }
401
- const runLogPath = join(dir, `${issue}.jsonl`);
480
+ const logPath = runLogPath(dir, issue, repo);
402
481
  let events = [];
403
482
  try {
404
- events = readLog(dir, issue);
483
+ events = readLog(dir, issue, { repo });
405
484
  } catch {
406
485
  // Corrupt/unreadable log — degrade to just the run-log path line below.
407
486
  }
@@ -417,7 +496,7 @@ export function formatTerminalDiagnostics(dir, issue) {
417
496
  const committed = state.committed.length ? state.committed.join(",") : "";
418
497
  lines.push(` state: committed=[${committed}] branch=${state.branch ?? "null"} pr=${state.pr ?? "null"}`);
419
498
  }
420
- lines.push(` run-log: ${runLogPath}`);
499
+ lines.push(` run-log: ${logPath}`);
421
500
  const usageLine = formatUsageLine(aggregateUsage(events));
422
501
  if (usageLine) lines.push(usageLine);
423
502
  return lines.join("\n");
@@ -605,10 +684,12 @@ export function lastLocalRun(dir) {
605
684
  withMtime.sort((a, b) => b.mtime - a.mtime);
606
685
 
607
686
  for (const { f } of withMtime) {
608
- const issue = parseInt(f.slice(0, -".jsonl".length), 10);
609
- if (!Number.isInteger(issue)) continue;
687
+ // #4432: `<issue>.jsonl` (legacy) or `<owner>__<repo>__<issue>.jsonl`; the issue is the trailing number.
688
+ const m = /(?:^|__)(\d+)\.jsonl$/.exec(f);
689
+ if (!m) continue;
690
+ const issue = Number(m[1]);
610
691
  try {
611
- const events = readLog(dir, issue);
692
+ const events = readLogFile(join(dir, f));
612
693
  if (events.length === 0) continue;
613
694
  const state = deriveState(events);
614
695
  return { issue, terminal: state.terminal, terminalReason: state.terminalReason };
@@ -708,12 +789,16 @@ export async function runFromCli(argv, deps = {}) {
708
789
  const agentId = `cli_${sanitizeLeaseHost(hostname())}_${process.pid}`;
709
790
  // Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
710
791
  const dir = deps.dir ?? runDir();
792
+ migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo (best effort, never throws)
793
+ // #4432: the run log is keyed by repo + issue. The core path needs the repo anyway; the full path tries once per
794
+ // backoff step without waiting (engine.mjs resolves it again itself), and an unresolved repo keeps the legacy name.
795
+ const logRepo = repo ?? (await resolveDefaultRepo(io, enginePath === "full" ? { sleep: async () => {} } : {}));
711
796
  // forge#2240: print the run-log path at the very start of the run, not only
712
797
  // in the non-merged completion diagnostics (formatTerminalDiagnostics prints
713
798
  // it too, but only post-completion and only for non-"merged" outcomes). A
714
799
  // caller tailing this process's stdout should be able to find the run-log
715
800
  // to inspect immediately, without waiting for the run to finish.
716
- console.log(`run-log: ${join(dir, `${issue}.jsonl`)}`);
801
+ console.log(`run-log: ${runLogPath(dir, issue, logRepo)}`);
717
802
  // forge#2240: phase-boundary progress lines — the only stdout emitted
718
803
  // during the run itself. `runIssue()`'s `onProgress` callback defaults to a
719
804
  // no-op, so this is purely additive; engine.mjs never calls console.log
@@ -731,7 +816,7 @@ export async function runFromCli(argv, deps = {}) {
731
816
  };
732
817
  if (enginePath !== "full") {
733
818
  // Core work-on (lean): one builder session, one reviewer, GitHub as the register.
734
- const leanRepo = repo ?? (await resolveDefaultRepo(io));
819
+ const leanRepo = logRepo;
735
820
  if (!leanRepo) throw new Error("core path needs the repo: pass --repo or run inside a checkout with a GitHub remote");
736
821
  const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
737
822
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
@@ -757,7 +842,7 @@ export async function runFromCli(argv, deps = {}) {
757
842
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
758
843
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
759
844
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
760
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
845
+ 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() } : {}), ...(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() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
761
846
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
762
847
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
763
848
  if (interrupted) return lres; // the signal handler reported and exits
@@ -803,7 +888,7 @@ export async function runFromCli(argv, deps = {}) {
803
888
  // run-log runIssue() just wrote. Skipped for "merged" (the success case)
804
889
  // to avoid regressing/cluttering the existing happy-path output.
805
890
  if (res.terminalReason !== "merged") {
806
- console.log(formatTerminalDiagnostics(dir, issue));
891
+ console.log(formatTerminalDiagnostics(dir, issue, logRepo));
807
892
  } else {
808
893
  // forge#2399: formatTerminalDiagnostics() (and its phase/reason/state
809
894
  // block) intentionally stays gated to non-merged outcomes above, but the
@@ -811,7 +896,7 @@ export async function runFromCli(argv, deps = {}) {
811
896
  // hold on the common success case too — surface just the usage line here
812
897
  // rather than the full diagnostic block.
813
898
  try {
814
- const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue)));
899
+ const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue, { repo: logRepo })));
815
900
  if (usageLine) console.log(usageLine);
816
901
  } catch {
817
902
  // Corrupt/unreadable log — degrade silently, same as formatTerminalDiagnostics.
@@ -930,10 +1015,12 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
930
1015
 
931
1016
  /**
932
1017
  * `forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] [--no-prefilter]`
933
- * `forgedock orchestrate --resume <batch-id>` · `forgedock orchestrate --set-parallel <batch-id> <K>`
1018
+ * `forgedock orchestrate --resume <batch-id> [issue...]` · `forgedock orchestrate --set-parallel <batch-id> <K>` · `forgedock orchestrate --add <batch-id> <issue...>`
1019
+ * `forgedock orchestrate --cancel <batch-id>`
934
1020
  * Runs core work-on per issue with K concurrency (default 3), honouring `Depends on` / `Blocked by` among the given issues.
935
1021
  * A `dependency-open` issue is requeued behind its dependency; a `decomposed` one adds its sub-issues (#4124).
936
1022
  * The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`.
1023
+ * Declared acceptance probes (#4399, `lean/probe.mjs`) are swept once at batch start unless `--no-probes`.
937
1024
  * Each issue gets its own lane, worked out from its milestone (#4125); `--lane` is an assertion checked per issue (a
938
1025
  * mismatch ends `lane-refused`). Dependency edges between lanes are printed before any lane starts. Prints a summary table.
939
1026
  * Durable (#4121): the batch is kept in `~/.forge/batches/<id>/` and each lane is a detached `run-issue` process, so
@@ -950,6 +1037,10 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
950
1037
  * for every batch on the host. This command submits it and streams the rows; Ctrl-C detaches and the batch keeps
951
1038
  * running; `--resume <id>` reattaches (or replays the report of a finished batch); `--set-parallel` goes through the
952
1039
  * daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
1040
+ * `--add <id> N...` (#4408) adds issues to that batch instead of starting a second one: they run under its K and DAG
1041
+ * (a running batch picks them up on its next pass, a stopped one on its next `--resume`); `--resume <id> N...` adds N too.
1042
+ * `--cancel <id>` (#4376) stops that one batch, never the shared daemon: it admits nothing more, its running local lanes get
1043
+ * SIGTERM (their teardown saves the work), remote lanes run to their result and the rest end `held`; `--resume <id>` continues it.
953
1044
  */
954
1045
  export async function orchestrateFromCli(argv, deps = {}) {
955
1046
  const inProcess = deps.inProcess || deps.leanModule || deps.laneScript || deps.batchRoot || deps.leanIo
@@ -961,7 +1052,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
961
1052
  const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
962
1053
  const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
963
1054
 
964
- const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>";
1055
+ 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>";
965
1056
  const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
966
1057
  const issuesOf = (argv) => {
967
1058
  const issues = [];
@@ -971,12 +1062,72 @@ const issuesOf = (argv) => {
971
1062
  }
972
1063
  return issues;
973
1064
  };
1065
+ /** #4469: the operands among `tokens` (`--add`/`--resume` extras): no flags, and no value of a valued flag (`--parallel 5` is not issue #5). */
1066
+ const operandsOf = (tokens) => {
1067
+ const out = [];
1068
+ for (let i = 0; i < tokens.length; i++) {
1069
+ if (ORCH_VALUED.includes(tokens[i])) { i++; continue; }
1070
+ if (!String(tokens[i]).startsWith("--")) out.push(tokens[i]);
1071
+ }
1072
+ return out;
1073
+ };
974
1074
  const parallelOf = (argv) => {
975
1075
  const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
976
1076
  if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
977
1077
  return parallel;
978
1078
  };
979
1079
 
1080
+ /**
1081
+ * #4408: add `tokens` to batch `id`. Not a plain integer: skipped; not OPEN on the batch's repo (`gh issue view --json
1082
+ * state`): skipped `closed` (`unreadable` when the read fails); the rest go to the batch's `add` file, through the daemon
1083
+ * when `viaDaemon(issues)` answers (non-null), else written here (batch.mjs `addIssues` skips issues already in the batch).
1084
+ */
1085
+ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
1086
+ const batchMod = await import("./lean/batch.mjs");
1087
+ const b = batchMod.openBatch(root, id);
1088
+ if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
1089
+ const repo = b.readPlan()?.repo ?? null;
1090
+ const skipped = [], ints = [], state = new Map();
1091
+ for (const t of tokens) {
1092
+ if (/^#?\d+$/.test(String(t))) ints.push(parseInt(String(t).replace("#", ""), 10));
1093
+ else skipped.push({ issue: t, reason: "not an integer" });
1094
+ }
1095
+ for (const n of new Set(ints)) {
1096
+ let st = null;
1097
+ try { st = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state"]))?.state ?? null; } catch { st = null; }
1098
+ state.set(n, st);
1099
+ }
1100
+ const open = [];
1101
+ for (const n of ints) {
1102
+ const st = state.get(n);
1103
+ if (st === "OPEN") open.push(n);
1104
+ else if (!skipped.some((x) => x.issue === n)) skipped.push({ issue: n, reason: st ? "closed" : "unreadable" });
1105
+ }
1106
+ let res = { added: [], skipped: [] };
1107
+ if (open.length) res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
1108
+ const out = { ok: true, batch: id, added: res.added ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
1109
+ 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(", ")}` : ""}`);
1110
+ return out;
1111
+ }
1112
+
1113
+ /**
1114
+ * `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
1115
+ * A finished batch (report written) is left alone.
1116
+ */
1117
+ async function cancelBatch({ id, root, viaDaemon = null, print = (l) => console.log(l) }) {
1118
+ if (!id || String(id).startsWith("--")) throw new Error(ORCH_USAGE);
1119
+ let res = viaDaemon ? await viaDaemon() : null;
1120
+ if (!res) {
1121
+ const b = (await import("./lean/batch.mjs")).openBatch(root, id);
1122
+ if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
1123
+ if (b.readReport()) res = { cancelled: false, finished: true };
1124
+ else { b.requestCancel({ reason: "operator" }); res = { cancelled: true }; }
1125
+ }
1126
+ print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown, the rest stay held (forgedock orchestrate --resume ${id} continues)`
1127
+ : `batch ${id}: already finished`);
1128
+ return { ok: true, batch: id, cancelled: !!res.cancelled };
1129
+ }
1130
+
980
1131
  /**
981
1132
  * Daemon client (#4192): argument checks here, then submit / attach / set-parallel over the schedd socket. Prints the
982
1133
  * streamed rows and lines like the in-process run and returns the same `{rows, crossLane, batch, ok}`.
@@ -1002,7 +1153,31 @@ async function orchestrateClient(argv, deps = {}) {
1002
1153
  console.log(`batch ${id}: parallel set to ${k}`);
1003
1154
  return { ok: true, batch: id, parallel: k };
1004
1155
  }
1156
+ // #4408: --add, and the extra issues of --resume <id> N..., go to the batch (through the daemon when one runs).
1157
+ const addVia = (id) => async (issues) => {
1158
+ let sock = null;
1159
+ try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1160
+ return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
1161
+ };
1162
+ const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
1163
+ if (argv.includes("--cancel")) {
1164
+ const id = flag(argv, "--cancel");
1165
+ return cancelBatch({ id, root: await batchRootOf(), viaDaemon: async () => {
1166
+ let sock = null;
1167
+ try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1168
+ return sock ? schedd.request(sock, { op: "cancel", id }) : null;
1169
+ } });
1170
+ }
1171
+ if (argv.includes("--add")) {
1172
+ const i = argv.indexOf("--add"), id = argv[i + 1];
1173
+ if (!id || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
1174
+ return addToBatch({ id, tokens: operandsOf(argv.slice(i + 2)), root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: addVia(id) });
1175
+ }
1005
1176
  const resumeId = flag(argv, "--resume");
1177
+ const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
1178
+ if (resumeExtra.length) {
1179
+ await addToBatch({ id: resumeId, tokens: resumeExtra, root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: addVia(resumeId) });
1180
+ }
1006
1181
  let msg;
1007
1182
  if (resumeId) msg = { op: "attach", id: resumeId };
1008
1183
  else {
@@ -1039,7 +1214,7 @@ async function orchestrateClient(argv, deps = {}) {
1039
1214
  * `daemon: true` and the allowlisted env in its plan, so a restarted daemon resumes it on the same account; `keepBreaker`
1040
1215
  * (a daemon restart, not the operator's resume) leaves a tripped breaker tripped. Writes `report.json` when done.
1041
1216
  */
1042
- export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false }) {
1217
+ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false, signal = null }) {
1043
1218
  const batchMod = await import("./lean/batch.mjs");
1044
1219
  const root = deps.batchRoot ?? batchMod.batchRoot();
1045
1220
  if (argv.includes("--set-parallel")) {
@@ -1051,7 +1226,17 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1051
1226
  sink.line(`batch ${b.id}: parallel set to ${k}`);
1052
1227
  return { ok: true, batch: b.id, parallel: k };
1053
1228
  }
1229
+ if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, print: (l) => sink.line(l) });
1230
+ if (argv.includes("--add")) {
1231
+ const i = argv.indexOf("--add");
1232
+ if (!argv[i + 1] || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
1233
+ return addToBatch({ id: argv[i + 1], tokens: operandsOf(argv.slice(i + 2)), root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
1234
+ }
1054
1235
  const resumeId = flag(argv, "--resume");
1236
+ const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
1237
+ if (resumeExtra.length) {
1238
+ await addToBatch({ id: resumeId, tokens: resumeExtra, root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
1239
+ }
1055
1240
  let batch, plan = null;
1056
1241
  if (resumeId) {
1057
1242
  batch = batchMod.openBatch(root, resumeId);
@@ -1086,19 +1271,30 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1086
1271
  // A resume is the operator's decision to continue: the breaker starts closed again.
1087
1272
  const st = batch.store.load();
1088
1273
  if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
1274
+ if (!keepBreaker) batch.clearCancel(); // #4376: the operator's resume continues a cancelled batch
1089
1275
  if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
1090
1276
  batch.clearReport();
1091
1277
  }
1092
1278
  sink.batch?.(batch);
1093
1279
  sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
1094
1280
  const dir = deps.dir ?? runDir();
1095
- const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists } = await import("./lean/orchestrate.mjs");
1281
+ migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo before any lane runs (best effort, never throws)
1282
+ const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists, formatAlertLines, estimateLaneMinutes, laneHistory, formatEstimateSummary } = await import("./lean/orchestrate.mjs");
1096
1283
  const { formatPrecheckSummary } = await import("./lean/precheck.mjs");
1284
+ const { formatFirstPassSummary } = await import("./lean/fixround.mjs");
1285
+ const { formatDecompositionSummary } = await import("./lean/decompose.mjs");
1286
+ const { formatShadowSummary } = await import("./lean/conformance.mjs");
1097
1287
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1098
- const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
1099
- const prefilter = argv.includes("--no-prefilter") ? null
1100
- : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv) });
1288
+ // #4399: declared acceptance probes of closed issues whose fix reached the default branch; never fails the batch
1289
+ const sweep = deps.sweepProbes !== undefined ? deps.sweepProbes : (await import("./lean/probe.mjs")).sweepProbes;
1290
+ if (sweep && !argv.includes("--no-probes")) {
1291
+ 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)})`); }
1292
+ }
1293
+ const { makePrefilter, formatPrefilterSummary, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
1101
1294
  const branches = readBranches(cwd);
1295
+ // #4389: the post-deploy gate's default branch is resolved and fetched once, only when a gated issue asks
1296
+ const prefilter = argv.includes("--no-prefilter") ? null
1297
+ : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd) });
1102
1298
  const fetched = new Set();
1103
1299
  const laneOf = async (n) => {
1104
1300
  const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
@@ -1114,7 +1310,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1114
1310
  const { runCore } = deps.leanModule;
1115
1311
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1116
1312
  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,
1117
- 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) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(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) } : {}) }) };
1313
+ 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) } : {}), ...(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) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
1118
1314
  } else {
1119
1315
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1120
1316
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
@@ -1174,6 +1370,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1174
1370
  } else {
1175
1371
  lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
1176
1372
  }
1373
+ // #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on.
1374
+ const { killLedgerPid } = await import("./lean/effects.mjs");
1375
+ const stopLane = deps.stopLane ?? ((n, rec) => (rec?.pid && !rec.remote && !rec.handle ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
1376
+ lanes = { ...lanes, stopLane };
1177
1377
  }
1178
1378
  const orch = deps.orchestration ?? readOrchestration(cwd);
1179
1379
  // #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
@@ -1220,13 +1420,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1220
1420
  const crossLane = [];
1221
1421
  // #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
1222
1422
  const gcMod = await import("./gc.mjs");
1423
+ // #4361: GC never holds admission for long; what it cannot finish in the budget resumes at the next beforeAdmit interval.
1424
+ const GC_BUDGET_MS = 60 * 1000;
1223
1425
  const hygiene = deps.hygiene ?? gcMod.readHygiene(cwd);
1224
1426
  const testDeps = deps.leanIo || deps.leanModule || deps.laneScript;
1225
1427
  const gcRun = !hygiene.auto_gc ? null : deps.gc !== undefined ? deps.gc
1226
- : testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene });
1428
+ : testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene, budgetMs: GC_BUDGET_MS });
1227
1429
  const reclaimed = { enabled: !!hygiene.auto_gc, items: 0, bytes: 0, preflight: 0, admissions: 0, runs: 0 };
1228
1430
  const nowMs = deps.now ?? (() => Date.now());
1229
- let lastGcAt = null;
1431
+ let lastGcAt = null, gcPending = false;
1230
1432
  const runGcPhase = async (phase) => {
1231
1433
  if (!gcRun) return;
1232
1434
  lastGcAt = nowMs();
@@ -1236,6 +1438,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1236
1438
  reclaimed.items += n; reclaimed.bytes += Number(r.bytes) || 0; reclaimed.runs++;
1237
1439
  reclaimed[phase === "preflight" ? "preflight" : "admissions"] += n;
1238
1440
  for (const note of r.plan?.notes ?? []) console.log(`gc (${phase}): ${note}`);
1441
+ gcPending = Number(r.deferred) > 0;
1442
+ if (gcPending) console.log(`gc (${phase}): ${Number(r.deferred)} item(s) deferred to the next admission`);
1239
1443
  } catch (e) { console.log(`gc (${phase}) failed: ${String(e?.message ?? e).slice(0, 200)}`); }
1240
1444
  };
1241
1445
  await runGcPhase("preflight");
@@ -1248,7 +1452,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1248
1452
  const host = deps.host ?? (await import("./lean/effects.mjs")).makeHostProbe();
1249
1453
  near = await nearCeiling({ cfg: readAdmission(cwd), host, ratio: hygiene.max_host_load_ratio });
1250
1454
  } catch { near = false; }
1251
- if (near) await runGcPhase("admission");
1455
+ if (near || gcPending) await runGcPhase("admission");
1252
1456
  };
1253
1457
  // #4318: SIGINT/SIGTERM of a foreground dispatcher run the batch-exit relabel, then exit 128+signo (the lane pattern above).
1254
1458
  let exitRelabel = null, interrupted = false;
@@ -1262,11 +1466,19 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1262
1466
  exitProc(128 + SIGNO[sig]);
1263
1467
  }]));
1264
1468
  for (const [sig, f] of Object.entries(handlers)) on(sig, f);
1469
+ // #4409: longest-first admission; lane estimates from the size label, a post-deploy-only body and this repo's run history.
1470
+ let estimate = deps.estimate;
1471
+ if (estimate === undefined) {
1472
+ let history = {};
1473
+ try { const { readLocalRuns } = await import("./stats/local-source.mjs"); history = laneHistory(readLocalRuns(dir), repo); } catch { history = {}; }
1474
+ estimate = async (n, { body }) => estimateLaneMinutes({ labels: await labelNames(n), body, history });
1475
+ }
1265
1476
  let rows;
1266
1477
  try {
1267
1478
  rows = await orchestrate({
1479
+ ...(estimate ? { estimate } : {}),
1268
1480
  beforeAdmit,
1269
- issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
1481
+ issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
1270
1482
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1271
1483
  ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1272
1484
  ...(remote ? { remote } : {}),
@@ -1279,8 +1491,12 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1279
1491
  }),
1280
1492
  // A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
1281
1493
  relabelNeedsHuman: deps.relabelNeedsHuman ?? makeRelabelNeedsHuman({ gh: (a) => leanIo.gh(a), repo }),
1494
+ // #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
1495
+ issueLabels: labelNames,
1282
1496
  // #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
1283
1497
  ...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
1498
+ // #4454: the daemon's shutdown stop; orchestrate throws batch.stopped, so no report is written and a resume adopts the lanes
1499
+ ...(signal ? { signal } : {}),
1284
1500
  conflicting: deps.conflicting ?? (async (_n, pr) => {
1285
1501
  try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
1286
1502
  }),
@@ -1303,6 +1519,20 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1303
1519
  if (waitLine) report.push(waitLine);
1304
1520
  const precheckLine = formatPrecheckSummary(rows); // #4331
1305
1521
  if (precheckLine) report.push(precheckLine);
1522
+ const firstPassLine = formatFirstPassSummary(rows); // #4362
1523
+ if (firstPassLine) report.push(firstPassLine);
1524
+ const turnLine = formatTurnSummary(rows); // #4392
1525
+ if (turnLine) report.push(turnLine);
1526
+ const estLine = formatEstimateSummary(rows); // #4409
1527
+ if (estLine) report.push(estLine);
1528
+ try { // #4402: $/merge, turns, context, retries by cause, no-action lanes, issue quality, acceptance; never fails a batch
1529
+ const { batchEfficiency, formatEfficiencyLines } = await import("./stats/efficiency.mjs");
1530
+ report.push(...formatEfficiencyLines(await (deps.efficiency ?? batchEfficiency)({ batchDir: batch.dir, runsDir: dir, repo, gh: (a) => leanIo.gh(a), rows })));
1531
+ } catch (e) { report.push(`efficiency: n/a (${String(e?.message ?? e).split("\n")[0].slice(0, 160)})`); }
1532
+ const shadowLine = formatShadowSummary(rows); // #4398
1533
+ if (shadowLine) report.push(shadowLine);
1534
+ const decompLine = formatDecompositionSummary(rows); // #4396
1535
+ if (decompLine) report.push(decompLine);
1306
1536
  let day = null;
1307
1537
  try { day = execBudget ? execBudget.dayTotals() : null; } catch { day = null; }
1308
1538
  const sbxLine = formatSandboxSummary(rows, { budget: execConf?.budget ?? null, day });
@@ -1310,6 +1540,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1310
1540
  if (crossLane.length) report.push(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
1311
1541
  if (rows.some((r) => r.outcome === "held")) report.push(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
1312
1542
  report.push(formatReclaimed(reclaimed));
1543
+ try { // #4354: fail-soft alerts for the batch's repo from the local run logs, last; nothing when none fired
1544
+ const [{ computeAlerts }, { readLanes }, { readAlertConfig }] = await Promise.all([import("./stats/aggregate.mjs"), import("./stats/local-source.mjs"), import("./stats/index.mjs")]);
1545
+ report.push(...formatAlertLines(computeAlerts(readLanes(dir), { repo, ...(deps.alertConfig ?? readAlertConfig(cwd)) })));
1546
+ } catch { /* alerts never fail a batch */ }
1313
1547
  for (const l of report) sink.line(l);
1314
1548
  const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
1315
1549
  try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
@@ -1363,7 +1597,8 @@ export async function scheddFromCli(argv, deps = {}) {
1363
1597
  if (argv.length) throw new Error("usage: forgedock schedd [status]");
1364
1598
  const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
1365
1599
  if (!d.running) return d;
1366
- for (const sig of ["SIGTERM", "SIGINT"]) process.once(sig, () => d.close());
1600
+ // #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
1601
+ schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
1367
1602
  await d.closed;
1368
1603
  return { running: false };
1369
1604
  }
@@ -1396,6 +1631,71 @@ export async function limitsFromCli(argv, deps = {}) {
1396
1631
  throw new Error(usage);
1397
1632
  }
1398
1633
 
1634
+ /**
1635
+ * `forgedock check [--repo DIR] [--base REF] [--json]` (#4364): runs in the lane worktree what the engine runs after the
1636
+ * builder: the frozen validate commands (.forge-lean/contract.json) and the change-scoped pre-PR checks, with the plan
1637
+ * the engine wrote (.forge-lean/check-plan.json; else this checkout's forge.yaml) and the caller's env (lane TMPDIR,
1638
+ * pinned toolchain, heavy shims). Local only: no gh, no push, no workflow. Prints a pass/fail table, records the run in
1639
+ * .forge-lean/self-check.json, and returns {ok, exitCode} (0 pass, 1 fail, 2 usage).
1640
+ */
1641
+ export async function checkFromCli(argv, deps = {}) {
1642
+ const usage = "usage: forgedock check [--repo DIR] [--base REF] [--json]";
1643
+ const pre = await import("./lean/precheck.mjs");
1644
+ const { readTestMap, readTestMapAtRef } = await import("./lean/testmap.mjs");
1645
+ const fsp = await import("node:fs/promises");
1646
+ const out = deps.out ?? ((l) => console.log(l)), env = deps.env ?? process.env;
1647
+ const o = {};
1648
+ for (let i = 0; i < argv.length; i++) {
1649
+ const t = argv[i];
1650
+ if (t === "--repo" || t === "--base") { if (!argv[i + 1]) throw Object.assign(new Error(usage), { exitCode: 2 }); o[t.slice(2)] = argv[++i]; }
1651
+ else if (t === "--json") o.json = true;
1652
+ else throw Object.assign(new Error(usage), { exitCode: 2 });
1653
+ }
1654
+ const run0 = deps.git ?? (async (args, cwd) => (await promisify(execFile)("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 })).stdout);
1655
+ const repo = resolvePath(o.repo ?? String(await run0(["rev-parse", "--show-toplevel"], deps.cwd ?? process.cwd())).trim());
1656
+ const git = (args) => run0(args, repo);
1657
+ const readJson = async (f) => { try { return JSON.parse(await (deps.readFile ?? ((p) => fsp.readFile(p, "utf8")))(join(repo, f))); } catch { return null; } };
1658
+ const contract = await readJson(".forge-lean/contract.json");
1659
+ if (!contract) throw Object.assign(new Error(`check: no .forge-lean/contract.json in ${repo}\n${usage}`), { exitCode: 2 });
1660
+ const plan = await readJson(pre.CHECK_PLAN_FILE);
1661
+ const base = o.base ?? plan?.base;
1662
+ if (!base) throw Object.assign(new Error(`check: no check plan from the engine; pass --base REF\n${usage}`), { exitCode: 2 });
1663
+ const verify = plan ? { mode: plan.mode, precheckTotalSeconds: plan.precheckTotalSeconds, precheckTimeoutSeconds: plan.precheckTimeoutSeconds } : { ...parseVerify({}), ...(readVerify(repo) ?? {}) };
1664
+ const learned = plan ? plan.learned : readPrecheckCommands(repo) ?? [];
1665
+ const maxMinutes = plan ? plan.maxMinutes : readPrecheckBudget(repo);
1666
+ // the engine's changed files (lean.mjs changedFiles: base...HEAD still differing from base), plus uncommitted work
1667
+ const names = (x) => String(x ?? "").split("\n").map((f) => f.trim()).filter(Boolean);
1668
+ const touched = names(await git(["diff", "--name-only", `${base}...HEAD`])), differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
1669
+ const files = [...new Set([...touched.filter((f) => differs.has(f)), ...names(await git(["diff", "--name-only", "HEAD"])), ...names(await git(["ls-files", "-o", "--exclude-standard"]))])]
1670
+ .filter((f) => !f.startsWith(".forge-lean/"));
1671
+ const testMap = (await readTestMapAtRef(git, base)) ?? (await readTestMap([repo], (p) => fsp.readFile(p, "utf8")));
1672
+ const picked = pre.planPrecheck({ testMap, files, learned, maxMinutes, verify });
1673
+ const par = String(env.FORGE_HOST_PARALLELISM ?? "").trim(), pinned = /^\d+$/.test(par) && Number(par) > 0;
1674
+ const runCommand = deps.runCommand ?? (await import("./lean/effects.mjs")).runCheckCommand;
1675
+ const r = await pre.selfCheck({ contract, plan: { mode: verify.mode, commands: picked.commands, budgetMs: picked.budgetMs, perCommandMs: picked.perCommandMs },
1676
+ parallelism: pinned ? Number(par) : 1, now: deps.now,
1677
+ run: ({ command, timeoutMs, workers }) => runCommand({ command, cwd: repo, timeoutMs, env: pinned ? { FORGE_HOST_PARALLELISM: String(workers) } : null }) });
1678
+ // #4390: each failure's full output goes to .forge-lean/logs/ (git-excluded); the table shows its tail, ids and the path
1679
+ const failing = [...r.validate.map((x) => [x, `check-${x.id}`]), ...r.commands.map((x, i) => [x, `check-cmd${i + 1}`])].filter(([x]) => !x.status && x.rc !== 0);
1680
+ if (failing.length) {
1681
+ try {
1682
+ await (deps.mkdir ?? ((p) => fsp.mkdir(p, { recursive: true })))(join(repo, pre.CHECK_LOG_DIR));
1683
+ for (const [x, name] of failing) {
1684
+ const rel = `${pre.CHECK_LOG_DIR}/${name.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`;
1685
+ await (deps.writeFile ?? ((p, t) => fsp.writeFile(p, t)))(join(repo, rel), String(x.output ?? ""));
1686
+ x.logPath = rel;
1687
+ }
1688
+ } catch { /* no log files: the table still shows the tails */ }
1689
+ }
1690
+ let fingerprint = null;
1691
+ try { fingerprint = await pre.worktreeFingerprint({ git, base }); } catch { /* recorded without one: the engine reads it as skipped */ }
1692
+ const strip = (x) => (x.status ? { ...(x.id ? { id: x.id } : {}), command: x.command, status: x.status } : { ...(x.id ? { id: x.id } : {}), command: x.command, rc: x.rc, ms: x.ms });
1693
+ const record = { v: 1, at: new Date((deps.now ?? Date.now)()).toISOString(), base, fingerprint, ok: r.ok, validate: r.validate.map(strip), commands: [...r.commands.map(strip), ...r.deferred.map((command) => ({ command, status: "deferred-to-ci" }))] };
1694
+ try { await (deps.writeFile ?? ((p, t) => fsp.writeFile(p, t)))(join(repo, pre.SELF_CHECK_FILE), `${JSON.stringify(record)}\n`); } catch { /* .forge-lean missing: still print */ }
1695
+ out(o.json ? JSON.stringify({ ...r, base, fingerprint }, null, 2) : pre.formatCheckTable(r));
1696
+ return { ...r, base, fingerprint, files, exitCode: r.ok ? 0 : 1 };
1697
+ }
1698
+
1399
1699
  /**
1400
1700
  * `forgedock sched show` · `forgedock sched cap <N>|--clear` (#4191) · `forgedock sched weight <owner/repo> <N>|--clear` (#4193)
1401
1701
  * The host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`) shared by every `forgedock orchestrate` batch.
@@ -1516,8 +1816,8 @@ function prefilterModel(argv) {
1516
1816
  * `forgedock triage <issue...> [--parallel K] [--lane L] [--repo owner/repo] [--dry-run] [--prefilter-model M]` (#4123)
1517
1817
  * Issues are checked through a bounded pool of K (default 8, #4141); each lane is fetched once; rows print in input order.
1518
1818
  * Each issue is checked against its own lane (#4125); `--lane` is an assertion, a refused lane is reported and dropped.
1519
- * Runs only the already-fixed pre-filter: closes issues it proves fixed (with evidence) and prints the issues that
1520
- * still need a lane as the last line (`lanes: #1 #2`). `--dry-run` reports candidates without a model call or close.
1819
+ * Runs only the already-fixed pre-filter: closes issues it proves fixed (with evidence), parks post-deploy follow-ups
1820
+ * whose parent fix is not on the default branch (#4389), and prints the issues that still need a lane as the last line (`lanes: #1 #2`). `--dry-run` reports candidates without a model call or close.
1521
1821
  */
1522
1822
  export async function triageFromCli(argv, deps = {}) {
1523
1823
  const valued = ["--lane", "--repo", "--prefilter-model", "--parallel"];
@@ -1532,8 +1832,9 @@ export async function triageFromCli(argv, deps = {}) {
1532
1832
  const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
1533
1833
  if (!repo) throw new Error("triage needs the repo: pass --repo or run inside a checkout with a GitHub remote");
1534
1834
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1535
- const { prefilterIssue, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
1835
+ const { prefilterIssue, formatPrefilterSummary, formatParkedLine, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
1536
1836
  const branches = readBranches();
1837
+ const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd() }; // #4389
1537
1838
  const parallel = flag(argv, "--parallel") == null ? 8 : parseInt(flag(argv, "--parallel"), 10);
1538
1839
  if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
1539
1840
  // #4141: bounded pool. Each lane is fetched once (memoized promise); output stays in input order.
@@ -1549,7 +1850,8 @@ export async function triageFromCli(argv, deps = {}) {
1549
1850
  const ln = await resolveLane({ issue: n, repo, io: leanIo, cwd: process.cwd(), branches, requested: lane, override: argv.includes("--lane-override") });
1550
1851
  if (!ln.ok) return { line: `#${n} → lane-refused (${ln.code}: ${ln.detail})`, row: { issue: n, outcome: "lane-refused", cost_usd: 0, prefilter: null } };
1551
1852
  await fetchLane(ln.lane);
1552
- const pf = await prefilterIssue({ issue: n, repo, lane: ln.lane, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), ...prefilterModel(argv) });
1853
+ const pf = await prefilterIssue({ issue: n, repo, lane: ln.lane, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), ...prefilterModel(argv), ...gate });
1854
+ if (pf.parked) return { line: `#${n} → parked (${pf.kind}: ${pf.detail})`, row: { issue: n, outcome: pf.kind, cost_usd: 0, prefilter: pf } };
1553
1855
  return { line: `#${n} → ${pf.closed ? "closed" : pf.candidate ? "candidate" : "lane"} (${pf.detail})`, row: { issue: n, outcome: pf.closed ? "invalid" : "pending", cost_usd: pf.cost_usd, prefilter: pf } };
1554
1856
  };
1555
1857
  let next = 0, printed = 0;
@@ -1564,8 +1866,35 @@ export async function triageFromCli(argv, deps = {}) {
1564
1866
  }
1565
1867
  }));
1566
1868
  const rows = out.map((r) => r.row);
1567
- const lanes = rows.filter((r) => r.prefilter && !r.prefilter.closed).map((r) => r.issue);
1869
+ const lanes = rows.filter((r) => r.prefilter && !r.prefilter.closed && !r.prefilter.parked).map((r) => r.issue);
1568
1870
  console.log(formatPrefilterSummary(rows));
1871
+ const parked = formatParkedLine(rows);
1872
+ if (parked) console.log(parked);
1569
1873
  console.log(`lanes: ${lanes.map((n) => `#${n}`).join(" ") || "(none)"}`);
1570
1874
  return { rows, lanes };
1571
1875
  }
1876
+
1877
+ /**
1878
+ * `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N]` (#4399)
1879
+ * Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
1880
+ * declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
1881
+ */
1882
+ export async function probeFromCli(argv, deps = {}) {
1883
+ const valued = ["--repo", "--limit"];
1884
+ const issues = [];
1885
+ for (let i = 0; i < argv.length; i++) {
1886
+ if (valued.includes(argv[i])) { i++; continue; }
1887
+ if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
1888
+ }
1889
+ const io = deps.io ?? makeIo();
1890
+ const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
1891
+ if (!repo) throw new Error("probe needs the repo: pass --repo or run inside a checkout with a GitHub remote");
1892
+ const limit = flag(argv, "--limit") == null ? 50 : parseInt(flag(argv, "--limit"), 10);
1893
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer");
1894
+ const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1895
+ const { sweepProbes, formatProbeSummary } = await import("./lean/probe.mjs");
1896
+ 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 } : {}) });
1897
+ for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
1898
+ console.log(formatProbeSummary(rows) || "probes: nothing to run");
1899
+ return { rows };
1900
+ }