forgedock 1.26.0 → 1.28.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
9
9
  import { readdirSync, statSync, readFileSync } from "node:fs";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { homedir, hostname } from "node:os";
12
- import { join } from "node:path";
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
15
  import { readLog, deriveState } from "./engine/runlog.mjs";
@@ -17,7 +17,10 @@ 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
+ import { parseVerify } from "./lean/verify.mjs";
21
+ import { parseExecution } from "./lean/execution.mjs";
20
22
  import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
23
+ import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
21
24
 
22
25
  /**
23
26
  * Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
@@ -101,6 +104,46 @@ export function readConformanceMode(cwd = process.cwd()) {
101
104
  return ["shadow", "enforce"].includes(v) ? v : undefined;
102
105
  } catch { return undefined; }
103
106
  }
107
+ /** forge.yaml `engine.review_incomplete` (needs-human|degraded, #4160); undefined when absent or invalid (the core defaults to needs-human). */
108
+ export function readReviewIncomplete(cwd = process.cwd()) {
109
+ try {
110
+ const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.review_incomplete ?? "").trim();
111
+ return ["needs-human", "degraded"].includes(v) ? v : undefined;
112
+ } catch { return undefined; }
113
+ }
114
+ /**
115
+ * forge.yaml `review.trigger` (shadow|enforce|off) and `review.risk_paths` (#4273): a comma-separated scalar or a block
116
+ * list (` risk_paths:` then ` - glob`). Returns {mode, riskPaths}; undefined when neither is set (the core defaults to
117
+ * shadow with the built-in risk patterns). An invalid mode reads as unset.
118
+ */
119
+ export function readReviewTrigger(cwd = process.cwd()) {
120
+ let raw;
121
+ try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
122
+ let sec = {};
123
+ try { sec = parseForgeYaml(raw)?.review ?? {}; } catch { /* unreadable: no trigger config */ }
124
+ const m = String(sec?.trigger ?? "").trim();
125
+ const mode = ["shadow", "enforce", "off"].includes(m) ? m : undefined;
126
+ const riskPaths = typeof sec?.risk_paths === "string" ? sec.risk_paths.split(",").map((x) => x.trim()).filter(Boolean) : [];
127
+ let inReview = false, listIndent = null;
128
+ for (const line of raw.split(/\r?\n/)) {
129
+ if (/^\s*(#.*)?$/.test(line)) continue;
130
+ if (/^\S/.test(line)) { inReview = /^review:\s*(#.*)?$/.test(line); listIndent = null; continue; }
131
+ if (!inReview) continue;
132
+ const indent = line.length - line.trimStart().length;
133
+ if (listIndent === null) { if (/^\s+risk_paths:\s*(#.*)?$/.test(line)) listIndent = indent; continue; }
134
+ if (indent <= listIndent) { listIndent = /^\s+risk_paths:\s*(#.*)?$/.test(line) ? indent : null; continue; }
135
+ const li = /^\s+-\s+["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line);
136
+ if (li) riskPaths.push(li[1].trim());
137
+ }
138
+ return mode || riskPaths.length ? { mode: mode ?? "shadow", riskPaths } : undefined;
139
+ }
140
+ /** forge.yaml `lean.repro_test` (off|prefer, #4271); undefined when absent or invalid (the core defaults to prefer). */
141
+ export function readReproTest(cwd = process.cwd()) {
142
+ try {
143
+ const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean?.repro_test ?? "").trim();
144
+ return ["off", "prefer"].includes(v) ? v : undefined;
145
+ } catch { return undefined; }
146
+ }
104
147
  function readLeanSetting(key, cwd = process.cwd()) {
105
148
  try {
106
149
  const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
@@ -122,6 +165,20 @@ export function readAdmission(cwd = process.cwd()) {
122
165
  max_load_per_core: num(e.max_load_per_core ?? e.admission_max_load_per_core) };
123
166
  } catch { return {}; }
124
167
  }
168
+ /**
169
+ * forge.yaml heavy-step semaphore (#4169), read next to `engine.admission`: `engine.heavy_enabled|heavy_max|
170
+ * heavy_step_mem_mb|heavy_reserve_mb` (or flat `admission_heavy_*`). Unset keys keep the heavy.mjs defaults.
171
+ */
172
+ export function readHeavy(cwd = process.cwd()) {
173
+ try {
174
+ const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
175
+ const num = (v) => { const n = Number(v); return v !== undefined && v !== "" && Number.isFinite(n) && n > 0 ? n : undefined; };
176
+ const pick = (k) => e[`heavy_${k}`] ?? e[`admission_heavy_${k}`];
177
+ const en = String(pick("enabled") ?? "").trim().toLowerCase();
178
+ const out = { ...(en === "false" ? { enabled: false } : {}), max: num(pick("max")), step_mem_mb: num(pick("step_mem_mb")), reserve_mb: num(pick("reserve_mb")) };
179
+ return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined));
180
+ } catch { return {}; }
181
+ }
125
182
  /**
126
183
  * forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
127
184
  * `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
@@ -147,6 +204,72 @@ export function readMergeWait(cwd = process.cwd()) {
147
204
  return ["required", "all", "none"].includes(v) ? v : undefined;
148
205
  } catch { return undefined; }
149
206
  }
207
+ /**
208
+ * forge.yaml `verify:` section (#4167, block style: `verify:` then ` mode: ci`, ` workflow: verify.yml`, ` paths_input`,
209
+ * ` skip_paths` comma-separated, ` max_rounds`) for the core path; undefined when the section is absent or unreadable.
210
+ */
211
+ export function readVerify(cwd = process.cwd()) {
212
+ try {
213
+ const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.verify;
214
+ return sec && typeof sec === "object" ? parseVerify(sec) : undefined;
215
+ } catch { return undefined; }
216
+ }
217
+ /**
218
+ * forge.yaml `execution:` section (#4253, block style: `execution:` then ` mode: remote`, ` max_remote: 4`,
219
+ * ` provider: container`, ` image`, ` secrets` comma-separated env names; remote provider #4254: ` endpoint`,
220
+ * ` token_env`, ` cache_store`, ` allow_actions`; #4255 budget: ` budget_minutes`, ` budget_usd`, ` day_budget_minutes`,
221
+ * ` day_budget_usd`, ` on_budget: stop|local`, ` cost_per_minute`) for `orchestrate`; undefined when the section is
222
+ * absent or unreadable.
223
+ */
224
+ export function readExecution(cwd = process.cwd()) {
225
+ try {
226
+ const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.execution;
227
+ return sec && typeof sec === "object" ? parseExecution(sec) : undefined;
228
+ } catch { return undefined; }
229
+ }
230
+ /**
231
+ * forge.yaml `learned.test_commands` (#4240): the repo's declared check commands, run by the core path in the lane
232
+ * worktree before the PR. A block list (` test_commands:` then ` - "cmd"` or ` - cmd`); parseForgeYaml reads
233
+ * scalars only. undefined when absent, empty or unreadable.
234
+ */
235
+ export function readPrecheckCommands(cwd = process.cwd()) {
236
+ let raw;
237
+ try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
238
+ const out = [];
239
+ let inLearned = false, listIndent = null;
240
+ for (const line of raw.split(/\r?\n/)) {
241
+ if (/^\s*(#.*)?$/.test(line)) continue;
242
+ if (/^\S/.test(line)) { inLearned = /^learned:\s*(#.*)?$/.test(line); listIndent = null; continue; }
243
+ if (!inLearned) continue;
244
+ const indent = line.length - line.trimStart().length;
245
+ if (listIndent === null) {
246
+ if (/^\s+test_commands:\s*(#.*)?$/.test(line)) listIndent = indent;
247
+ continue;
248
+ }
249
+ if (indent <= listIndent) { listIndent = /^\s+test_commands:\s*(#.*)?$/.test(line) ? indent : null; continue; }
250
+ const m = /^\s+-\s+(.*)$/.exec(line);
251
+ if (!m) continue;
252
+ let v = m[1].trim();
253
+ const q = /^"((?:[^"\\]|\\.)*)"\s*(#.*)?$/.exec(v) ?? /^'((?:[^']|'')*)'\s*(#.*)?$/.exec(v);
254
+ if (q) v = v.startsWith('"') ? q[1].replace(/\\(["\\])/g, "$1") : q[1].replace(/''/g, "'");
255
+ else v = v.replace(/\s+#.*$/, "").trim();
256
+ if (v) out.push(v);
257
+ }
258
+ return out.length ? out : undefined;
259
+ }
260
+ /**
261
+ * forge.yaml `env` for core lanes (#4163): `env.toolchain` ("<pm>@<x.y.z>" or "off") and `env.setup` (a command run once
262
+ * per fresh worktree before the builder); `env.sparse_max_kb` (#4221: sparse lane checkout threshold, 0 = off). Returns runCore options; {} when absent.
263
+ */
264
+ export function readEnvConfig(cwd = process.cwd()) {
265
+ try {
266
+ const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.env ?? {};
267
+ const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
268
+ const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
269
+ return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
270
+ ...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}) };
271
+ } catch { return {}; }
272
+ }
150
273
  export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
151
274
 
152
275
  export function runDir() { return join(homedir(), ".forge", "runs"); }
@@ -256,21 +379,36 @@ export function formatTerminalDiagnostics(dir, issue) {
256
379
  return lines.join("\n");
257
380
  }
258
381
 
382
+ // forge#4157: backoff between `gh repo view` attempts. Under host load one call can exceed makeIo's exec timeout.
383
+ export const REPO_VIEW_BACKOFF_MS = [2000, 4000];
384
+ // forge#4157: how long an orchestrate lane waits before rerunning after `gh` stayed unreachable.
385
+ export const GH_UNAVAILABLE_RETRY_S = 60;
386
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
387
+
259
388
  /**
260
389
  * Resolves the repo `gh` would target by default (i.e. resolved from the cwd
261
390
  * git remote), via `gh repo view`. Used to validate an explicit `--repo` flag
262
- * against the ambient context — see `assertRepoMatchesCwd`.
391
+ * against the ambient context — see `assertRepoMatchesCwd`. A failed call is
392
+ * retried with backoff (forge#4157: it is usually a transient timeout under load).
263
393
  * @param {{gh: Function}} io
264
- * @returns {Promise<string|null>} "owner/repo", or null if it can't be determined
394
+ * @param {{sleep?: (ms:number)=>Promise<void>, detail?: boolean}} [opts] - `detail: true` returns
395
+ * `{repo, failed}` so a caller can tell "every attempt errored" from an answer.
396
+ * @returns {Promise<string|null|{repo:string|null, failed:boolean}>} "owner/repo", or null if it can't be determined
265
397
  */
266
- async function resolveDefaultRepo(io) {
267
- try {
268
- const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
269
- const repo = String(out).trim();
270
- return repo || null;
271
- } catch {
272
- return null;
398
+ async function resolveDefaultRepo(io, { sleep = realSleep, detail = false } = {}) {
399
+ let failed = false;
400
+ for (let attempt = 0; ; attempt++) {
401
+ try {
402
+ const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
403
+ const repo = String(out).trim() || null;
404
+ return detail ? { repo, failed: false } : repo;
405
+ } catch {
406
+ failed = true;
407
+ if (attempt >= REPO_VIEW_BACKOFF_MS.length) break;
408
+ await sleep(REPO_VIEW_BACKOFF_MS[attempt]);
409
+ }
273
410
  }
411
+ return detail ? { repo: null, failed } : null;
274
412
  }
275
413
 
276
414
  /**
@@ -298,16 +436,20 @@ export async function assertLaneNotDefault(io, lane, repo = null, cwd = process.
298
436
  * default, refuse to run at all rather than silently mixing repos.
299
437
  * @param {{gh: Function}} io
300
438
  * @param {string|null} repo
439
+ * @param {{sleep?: Function}} [opts] - backoff seam for the `gh repo view` retries
301
440
  * @returns {Promise<void>}
302
441
  */
303
- async function assertRepoMatchesCwd(io, repo) {
442
+ async function assertRepoMatchesCwd(io, repo, { sleep } = {}) {
304
443
  if (!repo) return;
305
- const defaultRepo = await resolveDefaultRepo(io);
444
+ const { repo: defaultRepo, failed } = await resolveDefaultRepo(io, { sleep, detail: true });
306
445
  if (defaultRepo === null) {
307
- throw new Error(
446
+ const err = new Error(
308
447
  `--repo ${repo} was given, but the current repo could not be determined (\`gh repo view\` failed) to ` +
309
448
  `verify it matches. Refusing to run cross-repo without verification.`
310
449
  );
450
+ // forge#4157: `gh` kept failing after retries; transient, so run-issue defers instead of erroring.
451
+ if (failed) err.code = OUTCOME_CODES.GH_UNAVAILABLE;
452
+ throw err;
311
453
  }
312
454
  if (defaultRepo !== repo) {
313
455
  throw new Error(
@@ -481,7 +623,7 @@ export async function runFromCli(argv, deps = {}) {
481
623
  const backend = flag(argv, "--backend");
482
624
  const model = flag(argv, "--model");
483
625
  // Boolean flag: only counts at a flag position, never as another flag's value.
484
- const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file"].includes(argv[i - 1]));
626
+ const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file", "--placement", "--placement-reason"].includes(argv[i - 1]));
485
627
  // forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
486
628
  // holder's lease expires or becomes reclaimable. Same flag-position guard.
487
629
  const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
@@ -495,7 +637,21 @@ export async function runFromCli(argv, deps = {}) {
495
637
  // The full path takes the lane as given (its spec runs classify-lane), so it must be explicit; no default.
496
638
  if (enginePath === "full" && !lane) throw new Error("--lane is required with --path full: staging or milestone/<slug>. No default to prevent accidental production targeting.");
497
639
  const io = deps.io ?? makeIo();
498
- await assertRepoMatchesCwd(io, repo);
640
+ const resultFile = flag(argv, "--result-file");
641
+ // #4256: where the dispatcher placed this lane (hybrid execution), recorded in the lane's FORGE:STATE.
642
+ const placementWhere = flag(argv, "--placement");
643
+ const placement = placementWhere === "local" || placementWhere === "remote"
644
+ ? { where: placementWhere, reason: String(flag(argv, "--placement-reason") ?? "").slice(0, 300), mode: readExecution()?.mode ?? "local" } : null;
645
+ const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
646
+ try {
647
+ await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
648
+ } catch (e) {
649
+ if (e?.code !== OUTCOME_CODES.GH_UNAVAILABLE) throw e;
650
+ // forge#4157: a scheduling fact, not an engine error; the batch requeues it and the breaker never sees it.
651
+ const res = report({ terminalReason: "deferred", code: OUTCOME_CODES.GH_UNAVAILABLE, detail: e.message, retryInSeconds: GH_UNAVAILABLE_RETRY_S });
652
+ console.log(`issue #${issue} → deferred (${res.detail})`);
653
+ return res;
654
+ }
499
655
  await assertLaneNotDefault(io, lane, repo);
500
656
  const runIssueFn = deps.runIssue ?? runIssue;
501
657
  const agentId = `cli_${sanitizeLeaseHost(hostname())}_${process.pid}`;
@@ -529,14 +685,31 @@ export async function runFromCli(argv, deps = {}) {
529
685
  const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
530
686
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
531
687
  // #4121: a batch lane reports its result through --result-file (written atomically, also for a thrown run).
532
- const resultFile = flag(argv, "--result-file");
533
- const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
688
+ // #4179: SIGTERM/SIGINT run the lane's teardown (ledger, checkpoint, worktree, lease), report deferred run.interrupted, exit 128+signo.
689
+ let teardown = null, interrupted = false;
690
+ const on = deps.processOn ?? ((s, f) => process.on(s, f)), off = deps.processOff ?? ((s, f) => process.removeListener(s, f));
691
+ const exit = deps.exit ?? ((c) => process.exit(c));
692
+ const SIGNO = { SIGINT: 2, SIGTERM: 15 };
693
+ const handlers = Object.fromEntries(Object.keys(SIGNO).map((sig) => [sig, async () => {
694
+ if (interrupted) return;
695
+ interrupted = true;
696
+ let r = null;
697
+ try { r = teardown ? await teardown(sig) : null; } catch { r = null; }
698
+ try { report({ ...(r ?? {}), terminalReason: "deferred", code: OUTCOME_CODES.RUN_INTERRUPTED, detail: r?.detail ?? `interrupted by ${sig}` }); } catch { /* exit anyway */ }
699
+ console.log(`issue #${issue} → deferred (interrupted by ${sig})`);
700
+ exit(128 + SIGNO[sig]);
701
+ }]));
702
+ for (const [sig, f] of Object.entries(handlers)) on(sig, f);
703
+ const env = deps.env ?? process.env;
534
704
  let lres;
535
705
  try {
536
706
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
707
+ onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}),
537
708
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
538
- admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
539
- } catch (e) { report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
709
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(readPrecheckCommands() ? { precheck: { commands: readPrecheckCommands() } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
710
+ } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
711
+ finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
712
+ if (interrupted) return lres; // the signal handler reported and exits
540
713
  report(lres);
541
714
  if (lres.code === "host.busy") { console.log(`issue #${issue} → deferred (${lres.detail})`); return lres; }
542
715
  if (lres.terminalReason === "lane-refused") { console.log(`issue #${issue} → lane-refused ${lres.code} (${lres.detail})`); return lres; }
@@ -629,7 +802,7 @@ export async function resumeStalledFromCli(argv, deps = {}) {
629
802
  const repo = flag(argv, "--repo");
630
803
 
631
804
  const io = deps.io ?? makeIo();
632
- await assertRepoMatchesCwd(io, repo);
805
+ await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
633
806
  await assertLaneNotDefault(io, lane, repo);
634
807
  const dispatch = deps.dispatch ?? runFromCli;
635
808
  const projector = makeProjector(io);
@@ -716,8 +889,106 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
716
889
  * `--resume <id>` continues after the dispatcher dies, re-adopting live lanes. `deferred` lanes are requeued by code,
717
890
  * forge.yaml `orchestration.breaker` ({errors, window_min}, default 3 in 30) stops admissions on an engine-error
718
891
  * burst, and `--set-parallel <id> K` changes K of a running batch.
892
+ * Maintenance budget (#4197), only with forge.yaml `maintenance.enabled: true` (or `deps.maintenance`): open issues carrying
893
+ * a `maintenance.labels` label join the batch, each issue is classified from its labels (maintenance; `priority:P<n>`,
894
+ * unlabelled = P2) and admission reserves `maintenance.share` of lane starts for maintenance (`when_idle` drains the rest).
895
+ * Host lane registry (#4191): every lane takes a slot in `~/.forge/sched/` (or `FORGEDOCK_SCHED_DIR`), capped host-wide by
896
+ * `forgedock sched cap N` or forge.yaml `orchestration.global_parallel`; each issue's priority comes from its
897
+ * `priority:P<n>` / `P<n>` labels (unlabelled = P2), read in the same gh call as its body.
898
+ * Host scheduler (#4192): the batch runs in the `forgedock schedd` daemon (auto-spawned, `lean/schedd.mjs`), one process
899
+ * for every batch on the host. This command submits it and streams the rows; Ctrl-C detaches and the batch keeps
900
+ * running; `--resume <id>` reattaches (or replays the report of a finished batch); `--set-parallel` goes through the
901
+ * daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
719
902
  */
720
903
  export async function orchestrateFromCli(argv, deps = {}) {
904
+ const inProcess = deps.inProcess || deps.leanModule || deps.laneScript || deps.batchRoot || deps.leanIo
905
+ || argv.includes("--foreground") || process.env.FORGEDOCK_SCHEDD === "0";
906
+ if (inProcess) return runBatch({ argv: argv.filter((a) => a !== "--foreground"), cwd: process.cwd(), env: process.env, sink: consoleSink(), deps });
907
+ return orchestrateClient(argv, deps);
908
+ }
909
+
910
+ const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
911
+ const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
912
+
913
+ const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>";
914
+ const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
915
+ const issuesOf = (argv) => {
916
+ const issues = [];
917
+ for (let i = 0; i < argv.length; i++) {
918
+ if (ORCH_VALUED.includes(argv[i])) { i++; continue; }
919
+ if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
920
+ }
921
+ return issues;
922
+ };
923
+ const parallelOf = (argv) => {
924
+ const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
925
+ if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
926
+ return parallel;
927
+ };
928
+
929
+ /**
930
+ * Daemon client (#4192): argument checks here, then submit / attach / set-parallel over the schedd socket. Prints the
931
+ * streamed rows and lines like the in-process run and returns the same `{rows, crossLane, batch, ok}`.
932
+ */
933
+ async function orchestrateClient(argv, deps = {}) {
934
+ const schedd = await import("./lean/schedd.mjs");
935
+ const dir = deps.scheddDir ?? (await import("./lean/sched.mjs")).schedDir();
936
+ const connect = () => schedd.connectDaemon({ dir, ...(deps.spawnDaemon !== undefined ? { spawnDaemon: deps.spawnDaemon } : {}), ...(deps.waitMs ? { waitMs: deps.waitMs } : {}) });
937
+ if (argv.includes("--set-parallel")) {
938
+ const i = argv.indexOf("--set-parallel");
939
+ const id = argv[i + 1], k = Number(argv[i + 2]);
940
+ if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
941
+ let sock = null;
942
+ try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
943
+ if (sock) await schedd.request(sock, { op: "set-parallel", id, k });
944
+ else {
945
+ // No daemon: the batch is not running anywhere; the parallel file is all there is to change.
946
+ const batchMod = await import("./lean/batch.mjs");
947
+ const b = batchMod.openBatch(batchMod.batchRoot(), id);
948
+ if (!b.exists()) throw new Error(`no batch ${id} in ${batchMod.batchRoot()}`);
949
+ b.setParallel(k);
950
+ }
951
+ console.log(`batch ${id}: parallel set to ${k}`);
952
+ return { ok: true, batch: id, parallel: k };
953
+ }
954
+ const resumeId = flag(argv, "--resume");
955
+ let msg;
956
+ if (resumeId) msg = { op: "attach", id: resumeId };
957
+ else {
958
+ if (!issuesOf(argv).length) throw new Error(ORCH_USAGE);
959
+ parallelOf(argv);
960
+ let sub = [...argv];
961
+ if (!flag(sub, "--repo")) {
962
+ const repo = await resolveDefaultRepo(deps.io ?? makeIo());
963
+ if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
964
+ sub = [...sub, "--repo", repo];
965
+ }
966
+ msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
967
+ }
968
+ const sock = await connect();
969
+ let id = resumeId;
970
+ try {
971
+ const done = await schedd.request(sock, msg, (m) => {
972
+ if (m.type === "batch") id = m.id;
973
+ else if (m.type === "row") console.log(formatRowLine(m.row));
974
+ else if (m.type === "line") console.log(m.text);
975
+ else if (m.type === "done" && m.replay && m.report) console.log(m.report);
976
+ });
977
+ return done.result;
978
+ } catch (e) {
979
+ if (id && /closed the connection/.test(e?.message ?? "")) throw new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
980
+ throw e;
981
+ }
982
+ }
983
+
984
+ /**
985
+ * One batch from start to report, in this process: the in-process `orchestrate` path and the schedd daemon's per-batch
986
+ * runner (#4192). `cwd` and `env` are the submitting client's (`env` picks the lanes' account); output goes to `sink`
987
+ * (`batch(b)` once the batch exists, `row(r)` per update, `line(text)` otherwise). A daemon-hosted batch records
988
+ * `daemon: true` and the allowlisted env in its plan, so a restarted daemon resumes it on the same account; `keepBreaker`
989
+ * (a daemon restart, not the operator's resume) leaves a tripped breaker tripped. Writes `report.json` when done.
990
+ */
991
+ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false }) {
721
992
  const batchMod = await import("./lean/batch.mjs");
722
993
  const root = deps.batchRoot ?? batchMod.batchRoot();
723
994
  if (argv.includes("--set-parallel")) {
@@ -726,7 +997,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
726
997
  if (!b.exists()) throw new Error(`no batch ${argv[i + 1]} in ${root}`);
727
998
  const k = Number(argv[i + 2]);
728
999
  b.setParallel(k);
729
- console.log(`batch ${b.id}: parallel set to ${k}`);
1000
+ sink.line(`batch ${b.id}: parallel set to ${k}`);
730
1001
  return { ok: true, batch: b.id, parallel: k };
731
1002
  }
732
1003
  const resumeId = flag(argv, "--resume");
@@ -737,39 +1008,40 @@ export async function orchestrateFromCli(argv, deps = {}) {
737
1008
  if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
738
1009
  argv = plan.argv;
739
1010
  }
740
- const valued = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
741
- const issues = [];
742
- for (let i = 0; i < argv.length; i++) {
743
- if (valued.includes(argv[i])) { i++; continue; }
744
- if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
745
- }
746
- if (!issues.length) throw new Error("usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>");
747
- const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
748
- if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
1011
+ const issues = issuesOf(argv);
1012
+ if (!issues.length) throw new Error(ORCH_USAGE);
1013
+ const parallel = parallelOf(argv);
1014
+ // The lanes' env: a resumed daemon batch uses the env its plan kept, over the daemon's own.
1015
+ // A pre-#4192 batch resumed by the daemon kept no env: its lanes use the daemon's.
1016
+ const kept = plan?.env ?? (resumeId ? null : daemon || callerEnv !== process.env ? callerEnv : null);
1017
+ const env = kept ? (await import("./lean/schedd.mjs")).mergeEnv(process.env, kept) : process.env;
749
1018
  const lane = flag(argv, "--lane");
750
1019
  const laneOverride = argv.includes("--lane-override");
751
1020
  const model = flag(argv, "--model");
752
1021
  const io = deps.io ?? makeIo();
753
1022
  const repo = flag(argv, "--repo") ?? plan?.repo ?? (await resolveDefaultRepo(io));
754
1023
  if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
755
- await assertLaneNotDefault(io, lane, repo);
756
- const cwd = plan?.cwd ?? process.cwd();
1024
+ const cwd = plan?.cwd ?? callerCwd;
1025
+ await assertLaneNotDefault(io, lane, repo, cwd);
757
1026
  if (!deps.allowTmpfs && process.env.FORGEDOCK_BATCH_ALLOW_TMPFS !== "1") batchMod.assertDiskPath(root);
758
1027
  if (!batch) {
759
1028
  const base = deps.batchId ?? batchMod.newBatchId();
760
1029
  batch = batchMod.openBatch(root, base);
761
1030
  for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
762
- plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString() };
1031
+ plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(daemon ? { daemon: true, env: kept } : {}) };
763
1032
  batch.writePlan(plan);
764
1033
  batch.setParallel(parallel);
765
1034
  } else {
766
1035
  // A resume is the operator's decision to continue: the breaker starts closed again.
767
1036
  const st = batch.store.load();
768
- if (st) batch.store.save({ ...st, breaker: null, errorTimes: [] });
1037
+ if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
1038
+ if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
1039
+ batch.clearReport();
769
1040
  }
770
- console.log(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
1041
+ sink.batch?.(batch);
1042
+ sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
771
1043
  const dir = deps.dir ?? runDir();
772
- const { orchestrate, formatTable } = await import("./lean/orchestrate.mjs");
1044
+ const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary } = await import("./lean/orchestrate.mjs");
773
1045
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
774
1046
  const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
775
1047
  const prefilter = argv.includes("--no-prefilter") ? null
@@ -781,40 +1053,280 @@ export async function orchestrateFromCli(argv, deps = {}) {
781
1053
  if (r.ok && prefilter && !fetched.has(r.lane)) { fetched.add(r.lane); try { await leanIo.git(["fetch", "origin", r.lane], { cwd }); } catch { /* offline: use the local ref */ } }
782
1054
  return r;
783
1055
  };
784
- // Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes.
785
- let lanes;
1056
+ // Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes, or
1057
+ // sandboxes when forge.yaml `execution` places them remote (#4253).
1058
+ const labelsOf = new Map(); // issue -> label names, filled by readBody (one gh call per issue)
1059
+ let lanes, remote = null, execBudget = null, execConf = null, hybrid = false;
1060
+ const execMode = () => (deps.execution !== undefined ? deps.execution : readExecution(cwd))?.mode ?? "local";
786
1061
  if (deps.leanModule) {
787
1062
  const { runCore } = deps.leanModule;
788
1063
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
789
- lanes = { adopt: null, runOne: (n, ln) => runCore({ issue: n, repo, lane: ln, laneOverride, branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
790
- admission: readAdmission(cwd), ...readCoreLimits(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
1064
+ lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
1065
+ admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(readPrecheckCommands(cwd) ? { precheck: { commands: readPrecheckCommands(cwd) } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
791
1066
  } else {
792
- lanes = batchMod.detachedLanes({ batch, cwd, script: deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url)), bin: deps.laneBin, pollMs: deps.pollMs,
793
- laneArgs: (n, ln) => ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : [])] });
1067
+ const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1068
+ // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
1069
+ const placementOf = new Map();
1070
+ const laneArgs = (n, ln) => { const pl = placementOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
1071
+ ...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : [])]; };
1072
+ const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => { if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n); return l.runOne(n, ln, ctx); }, adopt: l.adopt });
1073
+ const execMod = await import("./lean/execution.mjs");
1074
+ const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
1075
+ hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
1076
+ if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
1077
+ // #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
1078
+ let gitDir = null;
1079
+ try { gitDir = resolvePath(cwd, String(await leanIo.git(["rev-parse", "--git-common-dir"], { cwd })).trim()); } catch { gitDir = null; }
1080
+ const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env });
1081
+ const { makeLedger } = await import("./lean/ledger.mjs");
1082
+ const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
1083
+ const bud = exec.budget ?? {};
1084
+ const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
1085
+ ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
1086
+ lanes = withPlacement(remoteLanes);
1087
+ if (bud.onBudget === "local" || hybrid) {
1088
+ // #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
1089
+ const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
1090
+ lanes = withPlacement({
1091
+ runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
1092
+ adopt: (n, rec) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec),
1093
+ });
1094
+ }
1095
+ // #4255: hard execution budget per batch and per host UTC day; every settled sandbox is recorded and reported.
1096
+ execConf = exec;
1097
+ execBudget = deps.executionBudget ?? execMod.openExecutionBudget({ budget: bud, file: execMod.executionBudgetFile(env), ...(deps.now ? { now: deps.now } : {}) });
1098
+ const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1099
+ // #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
1100
+ const place = hybrid ? await (async () => {
1101
+ const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
1102
+ let weight = deps.validationWeight ?? null;
1103
+ if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
1104
+ const headroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
1105
+ return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom() });
1106
+ })() : null;
1107
+ remote = {
1108
+ max: exec.maxRemote, isRemote: () => true, ...(place ? { place, fallbackLocal: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1109
+ admit: (u) => execBudget.admit(u),
1110
+ onUsage: (n, sb, rec) => {
1111
+ const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
1112
+ try { execBudget.record(`${batch.id}#${sb.id ?? n}#${startedAt}`, sb); } catch { /* the batch total still counts it */ }
1113
+ Promise.resolve(emitSbx({ event: "SANDBOX_USAGE", issue: n, provider: sb.provider, sandbox_id: sb.id, minutes: sb.minutes, cost_usd: sb.cost_usd, batch: batch.id })).catch(() => {});
1114
+ },
1115
+ };
1116
+ sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
1117
+ } else {
1118
+ lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
1119
+ }
794
1120
  }
795
1121
  const orch = deps.orchestration ?? readOrchestration(cwd);
1122
+ // #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
1123
+ const limitsMod = await import("./lean/limits.mjs");
1124
+ const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
1125
+ const identity = deps.identity ?? (() => limitsMod.laneBackendId(env));
1126
+ const maint = deps.maintenance !== undefined ? deps.maintenance : readMaintenance(cwd);
1127
+ const schedMod = await import("./lean/sched.mjs");
1128
+ const registry = deps.sched !== undefined ? deps.sched : schedMod.openSched(schedMod.schedDir());
1129
+ const labelNames = async (n) => {
1130
+ if (!labelsOf.has(n)) labelsOf.set(n, (JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "labels"])).labels ?? []).map((x) => x.name));
1131
+ return labelsOf.get(n);
1132
+ };
1133
+ let budget = {};
1134
+ if (maint) {
1135
+ const mLabels = maint.labels ?? DEFAULT_MAINTENANCE_LABELS;
1136
+ for (const l of mLabels) {
1137
+ let listed = [];
1138
+ try { listed = JSON.parse(await leanIo.gh(["issue", "list", "-R", repo, "--state", "open", "--label", l, "--json", "number,labels", "--limit", "100"])); } catch { listed = []; }
1139
+ for (const it of Array.isArray(listed) ? listed : []) {
1140
+ const names = (it.labels ?? []).map((x) => x.name);
1141
+ if (!labelsOf.has(it.number)) labelsOf.set(it.number, names);
1142
+ if (!names.includes("needs-human") && !issues.includes(it.number)) issues.push(it.number);
1143
+ }
1144
+ }
1145
+ const classify = async (n) => {
1146
+ const names = await labelNames(n);
1147
+ const p = names.map((x) => /^priority:P(\d+)$/i.exec(x)?.[1]).find((x) => x !== undefined);
1148
+ return { maintenance: names.some((x) => mLabels.includes(x)), priority: p === undefined ? 2 : Number(p) };
1149
+ };
1150
+ budget = { classify, maintenance: { share: maint.share ?? 0.2, whenIdle: maint.whenIdle ?? true } };
1151
+ }
1152
+ // forge#4161: milestone drift. Always folds merged milestone lanes into the summary; thresholds and holds only with config.
1153
+ let drift = deps.drift;
1154
+ if (drift === undefined) {
1155
+ const { makeDrift } = await import("./lean/drift.mjs");
1156
+ const { affectedFiles } = await import("./orchestrate-preflight.mjs");
1157
+ drift = makeDrift({ repo, cwd, io: leanIo, config: readDrift(cwd), stagingBranch: branches.staging ?? "staging", affectedFiles,
1158
+ onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
1159
+ }
796
1160
  const crossLane = [];
1161
+ // #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
1162
+ const gcMod = await import("./gc.mjs");
1163
+ const hygiene = deps.hygiene ?? gcMod.readHygiene(cwd);
1164
+ const testDeps = deps.leanIo || deps.leanModule || deps.laneScript;
1165
+ const gcRun = !hygiene.auto_gc ? null : deps.gc !== undefined ? deps.gc
1166
+ : testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene });
1167
+ const reclaimed = { enabled: !!hygiene.auto_gc, items: 0, bytes: 0, preflight: 0, admissions: 0, runs: 0 };
1168
+ const nowMs = deps.now ?? (() => Date.now());
1169
+ let lastGcAt = null;
1170
+ const runGcPhase = async (phase) => {
1171
+ if (!gcRun) return;
1172
+ lastGcAt = nowMs();
1173
+ try {
1174
+ const r = (await gcRun({ phase })) ?? {};
1175
+ const n = Number(r.reclaimed) || 0;
1176
+ reclaimed.items += n; reclaimed.bytes += Number(r.bytes) || 0; reclaimed.runs++;
1177
+ reclaimed[phase === "preflight" ? "preflight" : "admissions"] += n;
1178
+ for (const note of r.plan?.notes ?? []) console.log(`gc (${phase}): ${note}`);
1179
+ } catch (e) { console.log(`gc (${phase}) failed: ${String(e?.message ?? e).slice(0, 200)}`); }
1180
+ };
1181
+ await runGcPhase("preflight");
1182
+ const GC_ADMISSION_INTERVAL_MS = 5 * 60 * 1000;
1183
+ const beforeAdmit = !gcRun ? null : async () => {
1184
+ if (lastGcAt !== null && nowMs() - lastGcAt < GC_ADMISSION_INTERVAL_MS) return;
1185
+ let near = false;
1186
+ try {
1187
+ const { nearCeiling } = await import("./lean/admission.mjs");
1188
+ const host = deps.host ?? (await import("./lean/effects.mjs")).makeHostProbe();
1189
+ near = await nearCeiling({ cfg: readAdmission(cwd), host, ratio: hygiene.max_host_load_ratio });
1190
+ } catch { near = false; }
1191
+ if (near) await runGcPhase("admission");
1192
+ };
797
1193
  const rows = await orchestrate({
1194
+ beforeAdmit,
798
1195
  issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
799
- breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}),
1196
+ breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1197
+ ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1198
+ ...(remote ? { remote } : {}),
800
1199
  onCrossLane: (e) => {
801
1200
  crossLane.push(e);
802
- console.log(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
1201
+ sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
803
1202
  },
804
- readBody: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
805
- onUpdate: (r) => console.log(r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`),
1203
+ readBody: maint || registry || hybrid
1204
+ ? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); return v.body; }
1205
+ : async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
1206
+ onUpdate: (r) => sink.row(r),
806
1207
  });
807
- console.log("\n" + formatTable(rows));
1208
+ const report = ["\n" + formatTable(rows)];
808
1209
  const pfLine = formatPrefilterSummary(rows);
809
- if (pfLine) console.log(pfLine);
810
- if (crossLane.length) console.log(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
811
- if (rows.some((r) => r.outcome === "held")) console.log(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
812
- return { rows, crossLane, batch: batch.id, ok: rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome)) };
1210
+ if (pfLine) report.push(pfLine);
1211
+ let heavyStats = null; // #4169: live heavy-semaphore queue next to the per-phase waits the lanes reported
1212
+ try { heavyStats = deps.heavyStats !== undefined ? deps.heavyStats : (await import("./lean/heavy.mjs")).openHeavy(schedMod.schedDir()).stats(); } catch { heavyStats = null; }
1213
+ const waitLine = formatWaitSummary(rows, heavyStats);
1214
+ if (waitLine) report.push(waitLine);
1215
+ let day = null;
1216
+ try { day = execBudget ? execBudget.dayTotals() : null; } catch { day = null; }
1217
+ const sbxLine = formatSandboxSummary(rows, { budget: execConf?.budget ?? null, day });
1218
+ if (sbxLine) report.push(sbxLine);
1219
+ if (crossLane.length) report.push(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
1220
+ if (rows.some((r) => r.outcome === "held")) report.push(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
1221
+ report.push(formatReclaimed(reclaimed));
1222
+ for (const l of report) sink.line(l);
1223
+ const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
1224
+ try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
1225
+ return { rows, crossLane, batch: batch.id, reclaimed, ok };
1226
+ }
1227
+
1228
+ /** The batch report's "reclaimed" line (#4183). */
1229
+ export function formatReclaimed(r) {
1230
+ if (!r?.enabled) return "reclaimed: off (hygiene.auto_gc: false)";
1231
+ const b = r.bytes, size = b >= 1073741824 ? `${(b / 1073741824).toFixed(1)}G` : b >= 1048576 ? `${(b / 1048576).toFixed(1)}M` : b >= 1024 ? `${Math.round(b / 1024)}K` : `${b}B`;
1232
+ return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
813
1233
  }
814
1234
 
815
1235
  /**
816
- * forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off) and
817
- * `orchestration.max_deferrals` (default 3). The parser flattens one nested level, so the breaker keys arrive as
1236
+ * `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
1237
+ */
1238
+ export async function scheddFromCli(argv, deps = {}) {
1239
+ const schedd = await import("./lean/schedd.mjs");
1240
+ const dir = deps.scheddDir ?? (await import("./lean/sched.mjs")).schedDir();
1241
+ if (argv[0] === "status") {
1242
+ let sock;
1243
+ try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { console.log("schedd: not running"); return { running: false }; }
1244
+ const st = await schedd.request(sock, { op: "status" });
1245
+ console.log(`schedd: pid ${st.pid}, ${st.batches.length} batch(es)${st.batches.length ? ": " + st.batches.map((b) => b.id).join(", ") : ""}`);
1246
+ return { running: true, ...st };
1247
+ }
1248
+ if (argv.length) throw new Error("usage: forgedock schedd [status]");
1249
+ const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
1250
+ if (!d.running) return d;
1251
+ for (const sig of ["SIGTERM", "SIGINT"]) process.once(sig, () => d.close());
1252
+ await d.closed;
1253
+ return { running: false };
1254
+ }
1255
+
1256
+ /**
1257
+ * `forgedock limits show` · `forgedock limits clear <id>|--all` (#4190)
1258
+ * Usage-limit holds are kept per account (backend identity) in `~/.forge/limits.json` (or `FORGEDOCK_LIMITS_FILE`) and
1259
+ * shared by every batch on the host. `show` lists the active holds (identity, reset time, source; `*` marks the account
1260
+ * this shell's lanes would use); `clear` drops one hold or all of them, so held batches admit on their next pass.
1261
+ */
1262
+ export async function limitsFromCli(argv, deps = {}) {
1263
+ const limitsMod = await import("./lean/limits.mjs");
1264
+ const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile());
1265
+ const current = (deps.identity ?? (() => limitsMod.laneBackendId(process.env)))();
1266
+ const usage = "usage: forgedock limits show | forgedock limits clear <id>|--all";
1267
+ const [sub, arg] = argv;
1268
+ if (!sub || sub === "show") {
1269
+ const holds = limits.list();
1270
+ if (!holds.length) console.log(`no usage-limit holds (this account: ${current})`);
1271
+ for (const h of holds) console.log(`${h.id === current ? "*" : " "} ${h.id} until ${new Date(h.until).toISOString()} source ${h.source ?? "-"}`);
1272
+ return { holds, current };
1273
+ }
1274
+ if (sub === "clear") {
1275
+ if (arg === "--all") { const n = limits.clearAll(); console.log(`cleared ${n} usage-limit hold(s)`); return { cleared: n }; }
1276
+ if (!arg || arg.startsWith("--")) throw new Error(usage);
1277
+ if (!limits.clear(arg)) throw new Error(`no active usage-limit hold for ${arg}`);
1278
+ console.log(`cleared usage-limit hold for ${arg}`);
1279
+ return { cleared: 1 };
1280
+ }
1281
+ throw new Error(usage);
1282
+ }
1283
+
1284
+ /**
1285
+ * `forgedock sched show` · `forgedock sched cap <N>|--clear` (#4191) · `forgedock sched weight <owner/repo> <N>|--clear` (#4193)
1286
+ * The host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`) shared by every `forgedock orchestrate` batch.
1287
+ * `show` prints the cap, the repo weights, the running slots and the queued issues in grant order; `cap N` sets the
1288
+ * host-wide cap live (the next admission pass of every batch uses it); `cap --clear` falls back to each batch's
1289
+ * `orchestration.global_parallel`. `weight <owner/repo> N` sets that repo's share of slots within a priority (default 1);
1290
+ * `--clear` resets it to 1.
1291
+ */
1292
+ export async function schedFromCli(argv, deps = {}) {
1293
+ const schedMod = await import("./lean/sched.mjs");
1294
+ const reg = deps.sched ?? schedMod.openSched(schedMod.schedDir());
1295
+ const usage = "usage: forgedock sched show | forgedock sched cap <N>|--clear | forgedock sched weight <owner/repo> <N>|--clear";
1296
+ const [sub, arg, arg2] = argv;
1297
+ if (!sub || sub === "show") {
1298
+ const st = reg.list();
1299
+ const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
1300
+ console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}`);
1301
+ const ws = Object.entries(st.weights ?? {});
1302
+ console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
1303
+ for (const e of st.slots) console.log(` running ${who(e)} dispatcher ${e.pid}${e.lanePid ? ` lane ${e.lanePid}` : ""}`);
1304
+ for (const e of st.queue) console.log(` queued ${who(e)} dispatcher ${e.pid}`);
1305
+ return st;
1306
+ }
1307
+ if (sub === "cap") {
1308
+ if (arg === "--clear") { reg.setCap(null); console.log("host cap cleared"); return { cap: null }; }
1309
+ const k = Number(arg);
1310
+ if (!/^\d+$/.test(String(arg ?? "")) || !Number.isInteger(k) || k < 1) throw new Error(usage);
1311
+ reg.setCap(k);
1312
+ console.log(`host cap set to ${k}`);
1313
+ return { cap: k };
1314
+ }
1315
+ if (sub === "weight") {
1316
+ if (!arg || arg.startsWith("-")) throw new Error(usage);
1317
+ if (arg2 === "--clear") { reg.setWeight(arg, null); console.log(`weight of ${arg} reset to 1`); return { repo: arg, weight: null }; }
1318
+ const n = Number(arg2);
1319
+ if (!/^\d+$/.test(String(arg2 ?? "")) || !Number.isInteger(n) || n < 1) throw new Error(usage);
1320
+ reg.setWeight(arg, n);
1321
+ console.log(`weight of ${arg} set to ${n}`);
1322
+ return { repo: arg, weight: n };
1323
+ }
1324
+ throw new Error(usage);
1325
+ }
1326
+
1327
+ /**
1328
+ * forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
1329
+ * `orchestration.max_deferrals` (default 3) and `orchestration.global_parallel` (#4191: host-wide lane cap; unset = none). The parser flattens one nested level, so the breaker keys arrive as
818
1330
  * `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
819
1331
  */
820
1332
  export function readOrchestration(cwd = process.cwd()) {
@@ -822,7 +1334,57 @@ export function readOrchestration(cwd = process.cwd()) {
822
1334
  try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestration ?? {}; } catch { o = {}; }
823
1335
  const int = (v, d) => { const n = Number(v); return v !== undefined && v !== "" && Number.isInteger(n) && n >= 0 ? n : d; };
824
1336
  const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
825
- return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3) };
1337
+ const gp = int(o.global_parallel, null);
1338
+ return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null };
1339
+ }
1340
+
1341
+ /**
1342
+ * forge.yaml `orchestrate.drift` (#4161): `{pairGrowth, packageLines, fileLines, blockDispatch, generatedGlobs}` with
1343
+ * only the positive-integer thresholds set, or null when no threshold and no `block_dispatch: true` is set (summary
1344
+ * only: no script run, no issue, no hold). The parser flattens `orchestrate.drift.*` to `orchestrate.*`.
1345
+ */
1346
+ export function readDrift(cwd = process.cwd()) {
1347
+ let o = null;
1348
+ try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestrate ?? null; } catch { o = null; }
1349
+ if (!o || typeof o !== "object") return null;
1350
+ const int = (v) => { const n = Number(v); return v !== undefined && String(v).trim() !== "" && Number.isInteger(n) && n > 0 ? n : null; };
1351
+ const out = { pairGrowth: int(o.pair_growth), packageLines: int(o.package_lines), fileLines: int(o.file_lines),
1352
+ blockDispatch: String(o.block_dispatch ?? "").trim() === "true",
1353
+ generatedGlobs: String(o.generated_globs ?? "").split(",").map((x) => x.trim()).filter(Boolean) };
1354
+ return out.pairGrowth == null && out.packageLines == null && out.fileLines == null && !out.blockDispatch ? null : out;
1355
+ }
1356
+
1357
+ /**
1358
+ * forge.yaml text without the `maintenance.hotspot:` sub-map (forge#4195, read by scripts/hotspots.sh via yq). The
1359
+ * parser flattens one nested level, so its `enabled`/`file_lines` would otherwise overwrite the budget's own keys.
1360
+ */
1361
+ function withoutHotspotMap(raw) {
1362
+ let inMaint = false, sub = -1;
1363
+ return String(raw).split(/\r?\n/).filter((l) => {
1364
+ if (/^[A-Za-z_][A-Za-z0-9_]*:/.test(l)) { inMaint = /^maintenance:\s*(?:#.*)?$/.test(l); sub = -1; return true; }
1365
+ const indent = /^\s*/.exec(l)[0].length;
1366
+ if (sub >= 0) { if (/^\s*(?:#.*)?$/.test(l) || indent > sub) return false; sub = -1; }
1367
+ if (inMaint && /^\s+hotspot:\s*(?:#.*)?$/.test(l)) { sub = indent; return false; }
1368
+ return true;
1369
+ }).join("\n");
1370
+ }
1371
+
1372
+ export const DEFAULT_MAINTENANCE_LABELS = ["maintenance", "refactor:split", "dead-code"];
1373
+
1374
+ /**
1375
+ * forge.yaml `maintenance:` (#4197): null unless `enabled: true`. `share` in (0,1] (default 0.2), `when_idle` (default
1376
+ * true), `labels` comma-separated (default maintenance,refactor:split,dead-code), `scan` (default weekly), and the
1377
+ * maintenance-scan thresholds `file_lines` (2000) / `doc_kb` (64). Block form only: the parser does not read `{ ... }`.
1378
+ */
1379
+ export function readMaintenance(cwd = process.cwd()) {
1380
+ let m = null;
1381
+ try { m = parseForgeYaml(withoutHotspotMap(readFileSync(join(cwd, "forge.yaml"), "utf8")))?.maintenance ?? null; } catch { m = null; }
1382
+ if (!m || typeof m !== "object" || String(m.enabled ?? "").trim() !== "true") return null;
1383
+ const num = (v, d, ok) => { const n = Number(v); return v !== undefined && v !== "" && ok(n) ? n : d; };
1384
+ const labels = String(m.labels ?? "").split(",").map((x) => x.trim()).filter(Boolean);
1385
+ return { share: num(m.share, 0.2, (n) => n > 0 && n <= 1), whenIdle: String(m.when_idle ?? "true").trim() !== "false",
1386
+ labels: labels.length ? labels : [...DEFAULT_MAINTENANCE_LABELS], scan: String(m.scan ?? "weekly").trim() || "weekly",
1387
+ fileLines: num(m.file_lines, 2000, (n) => Number.isInteger(n) && n > 0), docKb: num(m.doc_kb, 64, (n) => n > 0) };
826
1388
  }
827
1389
 
828
1390
  /** `--prefilter-model M`, else forge.yaml `engine.lean_prefilter_model`, else the pre-filter default. */