forgedock 1.34.0 → 1.36.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 (61) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +181 -26
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +19 -1
  8. package/bin/hooks/reread-nudge.mjs +44 -5
  9. package/bin/lean/batch.mjs +66 -3
  10. package/bin/lean/batch.test.mjs +176 -1
  11. package/bin/lean/budget.mjs +14 -3
  12. package/bin/lean/budget.test.mjs +11 -1
  13. package/bin/lean/conflictres.mjs +93 -0
  14. package/bin/lean/conflictres.test.mjs +93 -0
  15. package/bin/lean/conformance.mjs +30 -9
  16. package/bin/lean/conformance.test.mjs +51 -0
  17. package/bin/lean/effects.mjs +14 -3
  18. package/bin/lean/execution.mjs +2 -0
  19. package/bin/lean/i18n.mjs +65 -0
  20. package/bin/lean/i18n.test.mjs +47 -0
  21. package/bin/lean/lane.mjs +16 -0
  22. package/bin/lean/lean.mjs +147 -36
  23. package/bin/lean/lean.test.mjs +319 -8
  24. package/bin/lean/orchestrate.mjs +130 -13
  25. package/bin/lean/postdeploy-check.mjs +149 -0
  26. package/bin/lean/postdeploy-check.test.mjs +146 -0
  27. package/bin/lean/postdeploy-monitor.mjs +87 -0
  28. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  29. package/bin/lean/precheck.mjs +58 -8
  30. package/bin/lean/precheck.test.mjs +77 -3
  31. package/bin/lean/prefilter.mjs +29 -4
  32. package/bin/lean/prefilter.test.mjs +13 -5
  33. package/bin/lean/probe.mjs +2 -1
  34. package/bin/lean/prompts.mjs +36 -6
  35. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  36. package/bin/lean/sandbox-remote.mjs +7 -0
  37. package/bin/lean/schedd.mjs +34 -5
  38. package/bin/lean/schedd.test.mjs +84 -0
  39. package/bin/lean/sequence.mjs +26 -0
  40. package/bin/lean/sequence.test.mjs +41 -2
  41. package/bin/lean/toolstate.mjs +77 -0
  42. package/bin/lean/toolstate.test.mjs +83 -0
  43. package/bin/lean/verify.mjs +18 -2
  44. package/bin/lean/verify.test.mjs +29 -3
  45. package/bin/stats/aggregate.mjs +1 -1
  46. package/bin/stats/experiment.mjs +44 -0
  47. package/bin/stats/github-source.mjs +2 -1
  48. package/bin/stats/index.mjs +42 -2
  49. package/bin/stats/release.mjs +75 -0
  50. package/bin/stats/release.test.mjs +68 -0
  51. package/commands/orchestrate.md +8 -3
  52. package/docs/CONFIG.md +61 -2
  53. package/docs/EVENTS.md +68 -0
  54. package/docs/STATS.md +31 -0
  55. package/docs/TELEMETRY.md +3 -1
  56. package/docs/site/install.md +89 -0
  57. package/package.json +1 -1
  58. package/scripts/bench-lightlane.mjs +2 -1
  59. package/scripts/eval-gate-scorecard.mjs +7 -0
  60. package/scripts/file-deferred-followups.sh +1 -0
  61. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -16,6 +16,8 @@ import { readLog, readLogFile, deriveState, runLogPath, migrateRunLogs } from ".
16
16
  import { assertOpenCodeNativeRuntime } from "./runner.mjs";
17
17
  import { parseForgeYaml } from "./forge-utils.mjs";
18
18
  import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
19
+ import { makeEventEmitter, probeEvent, regressionEvent, experimentEvent } from "./engine/events.mjs";
20
+ import { readExperiment } from "./experiment.mjs";
19
21
  import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
20
22
  import { parseVerify } from "./lean/verify.mjs";
21
23
  import { parseExecution, detectNeeds } from "./lean/execution.mjs";
@@ -123,6 +125,31 @@ export function readLaneInstructions(cwd = process.cwd()) {
123
125
  }
124
126
  const warnedContextPack = new Set();
125
127
  const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
128
+ /** Run each emitter in order; one failing never blocks the next (#4630). */
129
+ function composeEmit(...fns) { return async (rec) => { for (const f of fns) { try { await f(rec); } catch { /* telemetry and events never fail a run */ } } }; }
130
+ /**
131
+ * forge.yaml `events:` (#4630): `webhook_url`, `webhook_secret_env` (the NAME of the env var holding the HMAC secret; the secret itself is
132
+ * never read from forge.yaml) and `dir` (local JSONL directory). Returns {webhookUrl, secretEnv, dir}; {} when none is set.
133
+ */
134
+ export function readEvents(cwd = process.cwd()) {
135
+ try {
136
+ const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.events ?? {};
137
+ const v = (k) => String(e[k] ?? "").trim();
138
+ return { ...(v("webhook_url") ? { webhookUrl: v("webhook_url") } : {}), ...(/^[A-Za-z_][A-Za-z0-9_]*$/.test(v("webhook_secret_env")) ? { secretEnv: v("webhook_secret_env") } : {}), ...(v("dir") ? { dir: v("dir") } : {}) };
139
+ } catch { return {}; }
140
+ }
141
+ /** The probe sweep's `onResult`: one probe.passed / probe.failed event per recorded pass or fail row. Never throws. */
142
+ function probeEventSink(repo) {
143
+ const events = makeEventEmitter({ repo, config: readEvents() });
144
+ return async (row) => { for (const p of [probeEvent(row), regressionEvent(row)]) if (p) await events.emitEvent(p.type, p.data, p.ctx); };
145
+ }
146
+ /** `forgedock stats --experiment <id> --emit-event` (#4632): one experiment.verdict event for a met or missed report; inconclusive emits nothing. Never throws. */
147
+ export async function experimentEventSink(repo, report) {
148
+ try {
149
+ const p = experimentEvent(report);
150
+ if (p && p.data.verdict !== "inconclusive") await makeEventEmitter({ repo, config: readEvents() }).emitEvent(p.type, p.data, p.ctx);
151
+ } catch { /* events never fail stats */ }
152
+ }
126
153
  /**
127
154
  * forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
128
155
  * (shadow|on|off|ab, #4444), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
@@ -214,6 +241,19 @@ export function readBuilderRouting(cwd = process.cwd()) {
214
241
  const midModel = /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : readLeanSetting("builder_mid_model", cwd);
215
242
  return mode || midModel ? { ...(mode ? { mode } : {}), ...(midModel ? { midModel } : {}) } : undefined;
216
243
  }
244
+ /**
245
+ * forge.yaml `lean.base_merge_*` (#4588): deterministic base-merge resolution of sibling-lane conflicts. Comma lists of globs
246
+ * `union_globs` (append-only), `json_globs` (JSON maps, key union), `generated_globs` + `generated_command` (base side taken,
247
+ * then regenerated) (flat keys only). undefined when nothing is configured.
248
+ */
249
+ export function readBaseMerge(cwd = process.cwd()) {
250
+ let l = {};
251
+ try { l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; } catch { return undefined; }
252
+ const list = (v) => String(v ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
253
+ const cfg = { unionGlobs: list(l.base_merge_union_globs), jsonGlobs: list(l.base_merge_json_globs), generatedGlobs: list(l.base_merge_generated_globs), generatedCommand: String(l.base_merge_generated_command ?? "").trim() };
254
+ return cfg.unionGlobs.length || cfg.jsonGlobs.length || cfg.generatedGlobs.length ? cfg : undefined;
255
+ }
256
+
217
257
  /**
218
258
  * forge.yaml `lean.sequence.regenerate` (#4386): the command that regenerates a schema-changing migration's snapshot
219
259
  * after a deterministic renumber. The one-level parser flattens the nested block to `lean.regenerate`; flat
@@ -323,7 +363,9 @@ export function readMergeWait(cwd = process.cwd()) {
323
363
  export function readVerify(cwd = process.cwd()) {
324
364
  try {
325
365
  const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.verify;
326
- return sec && typeof sec === "object" ? parseVerify(sec) : undefined;
366
+ if (!sec || typeof sec !== "object") return undefined;
367
+ const invariants = readInvariants(cwd), flaky = readYamlList(cwd, "verify", "flaky");
368
+ return parseVerify({ ...sec, ...(invariants ? { invariants } : {}), ...(flaky ? { flaky } : {}) });
327
369
  } catch { return undefined; }
328
370
  }
329
371
  /** #4542: forge.yaml `execution.host_capabilities` (+ patterns) for a lane's remote-block mapping; undefined when none are declared. */
@@ -350,20 +392,28 @@ export function readExecution(cwd = process.cwd()) {
350
392
  * scalars only. undefined when absent, empty or unreadable.
351
393
  */
352
394
  export function readPrecheckCommands(cwd = process.cwd()) {
395
+ return readYamlList(cwd, "learned", "test_commands");
396
+ }
397
+ /** #4585: forge.yaml `verify.invariants`, a block list of repo-wide check commands; undefined when absent or empty. */
398
+ export function readInvariants(cwd = process.cwd()) {
399
+ return readYamlList(cwd, "verify", "invariants");
400
+ }
401
+ function readYamlList(cwd, section, key) {
353
402
  let raw;
354
403
  try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
355
404
  const out = [];
356
- let inLearned = false, listIndent = null;
405
+ const head = new RegExp(`^${section}:\\s*(#.*)?$`), keyRe = new RegExp(`^\\s+${key}:\\s*(#.*)?$`);
406
+ let inSection = false, listIndent = null;
357
407
  for (const line of raw.split(/\r?\n/)) {
358
408
  if (/^\s*(#.*)?$/.test(line)) continue;
359
- if (/^\S/.test(line)) { inLearned = /^learned:\s*(#.*)?$/.test(line); listIndent = null; continue; }
360
- if (!inLearned) continue;
409
+ if (/^\S/.test(line)) { inSection = head.test(line); listIndent = null; continue; }
410
+ if (!inSection) continue;
361
411
  const indent = line.length - line.trimStart().length;
362
412
  if (listIndent === null) {
363
- if (/^\s+test_commands:\s*(#.*)?$/.test(line)) listIndent = indent;
413
+ if (keyRe.test(line)) listIndent = indent;
364
414
  continue;
365
415
  }
366
- if (indent <= listIndent) { listIndent = /^\s+test_commands:\s*(#.*)?$/.test(line) ? indent : null; continue; }
416
+ if (indent <= listIndent) { listIndent = keyRe.test(line) ? indent : null; continue; }
367
417
  const m = /^\s+-\s+(.*)$/.exec(line);
368
418
  if (!m) continue;
369
419
  let v = m[1].trim();
@@ -853,8 +903,8 @@ export async function runFromCli(argv, deps = {}) {
853
903
  try {
854
904
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
855
905
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
856
- emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
857
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
906
+ emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
907
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}) });
858
908
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
859
909
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
860
910
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1052,6 +1102,7 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
1052
1102
  * daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
1053
1103
  * `--add <id> N...` (#4408) adds issues to that batch instead of starting a second one: they run under its K and DAG
1054
1104
  * (a running batch picks them up on its next pass, a stopped one on its next `--resume`); `--resume <id> N...` adds N too.
1105
+ * `--requeue <id> N... [--reason]` (#4550) returns a terminal issue (and the dependents skipped behind it) to pending after an operator decision.
1055
1106
  * `--cancel <id>` (#4376) stops that one batch, never the shared daemon: it admits nothing more, its running local lanes get
1056
1107
  * SIGTERM (their teardown saves the work), remote lanes run to their result and the rest end `held`; `--resume <id>` continues it.
1057
1108
  */
@@ -1065,8 +1116,8 @@ export async function orchestrateFromCli(argv, deps = {}) {
1065
1116
  const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
1066
1117
  const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
1067
1118
 
1068
- 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>";
1069
- const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
1119
+ 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...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...>";
1120
+ const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason"];
1070
1121
  const issuesOf = (argv) => {
1071
1122
  const issues = [];
1072
1123
  for (let i = 0; i < argv.length; i++) {
@@ -1123,20 +1174,78 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
1123
1174
  return out;
1124
1175
  }
1125
1176
 
1177
+ /** #4592: a resume's release line, only when it freed something. */
1178
+ const onlyReleased = (l, out = (t) => console.log(t)) => { if (!l.endsWith("(none)")) out(l); };
1179
+
1180
+ /**
1181
+ * `--hold <id> N...` / `--release <id> N...` (#4592): the batch's `hold` file, through the daemon (`viaDaemon(op, issues)`, null
1182
+ * when none runs) or directly. A held issue is not admitted (a running lane is not stopped); `--release` (or a `--resume` that
1183
+ * names it) lets it run again. @returns {{ok, batch, changed:number[]}}
1184
+ */
1185
+ async function holdBatch({ op, id, tokens, root, viaDaemon = null, print = (l) => console.log(l) }) {
1186
+ const issues = tokens.filter((t) => /^#?\d+$/.test(String(t))).map((t) => parseInt(String(t).replace("#", ""), 10));
1187
+ if (!id || String(id).startsWith("--") || !issues.length) throw new Error(ORCH_USAGE);
1188
+ let res = viaDaemon ? await viaDaemon(op, issues) : null;
1189
+ if (!res) {
1190
+ const b = (await import("./lean/batch.mjs")).openBatch(root, id);
1191
+ if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
1192
+ res = { changed: op === "hold" ? b.holdIssues(issues) : b.releaseIssues(issues) };
1193
+ }
1194
+ const list = (res.changed ?? []).map((n) => `#${n}`).join(" ") || "(none)";
1195
+ print(op === "hold" ? `batch ${id}: held ${list}; forgedock orchestrate --release ${id} <issue...> (or --resume ${id} <issue...>) re-admits them`
1196
+ : `batch ${id}: released ${list}`);
1197
+ return { ok: true, batch: id, changed: res.changed ?? [] };
1198
+ }
1199
+
1200
+ /**
1201
+ * `--requeue <id> N... [--reason "..."]` (#4550): after an operator decision, a terminal issue of the batch (and the dependents
1202
+ * skipped behind it) goes back to pending. Closed issues and issues still labelled `needs-human` are refused before anything is
1203
+ * written. Through the daemon (`viaDaemon`, null when none runs: the batch's `requeue` file, and a finished batch's report is
1204
+ * cleared so `--resume` runs it again). The runner applies the entries and posts the FORGE:DECISION.
1205
+ */
1206
+ async function requeueBatch({ id, tokens, reason, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
1207
+ if (!id || String(id).startsWith("--") || !tokens.length) throw new Error(ORCH_USAGE);
1208
+ const batchMod = await import("./lean/batch.mjs");
1209
+ const b = batchMod.openBatch(root, id);
1210
+ if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
1211
+ const repo = b.readPlan()?.repo ?? null;
1212
+ const refused = [], open = [];
1213
+ for (const t of tokens) {
1214
+ if (!/^#?\d+$/.test(String(t))) { refused.push({ issue: t, reason: "not an integer" }); continue; }
1215
+ const n = parseInt(String(t).replace("#", ""), 10);
1216
+ if (open.includes(n) || refused.some((x) => x.issue === n)) continue;
1217
+ let v = null;
1218
+ try { v = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state,labels"])); } catch { v = null; }
1219
+ if (!v) refused.push({ issue: n, reason: "unreadable" });
1220
+ else if (v.state !== "OPEN") refused.push({ issue: n, reason: "closed" });
1221
+ else if ((v.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) refused.push({ issue: n, reason: "still labelled needs-human: record the decision and remove the label first" });
1222
+ else open.push(n);
1223
+ }
1224
+ const why = reason || "operator decision", by = "operator";
1225
+ let res = { requeued: [], refused: [] };
1226
+ if (open.length) {
1227
+ res = (viaDaemon ? await viaDaemon(open, why, by) : null) ?? b.requeue(open, { reason: why, by });
1228
+ if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // no daemon: the next --resume reruns it
1229
+ }
1230
+ const out = { ok: true, batch: id, requeued: res.requeued ?? [], refused: [...(res.refused ?? []), ...refused] };
1231
+ print(`batch ${id}: ${out.requeued.map((n) => `requeued #${n}`).join(" ") || "requeued (none)"}${out.refused.length ? `; refused ${out.refused.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
1232
+ return out;
1233
+ }
1234
+
1126
1235
  /**
1127
1236
  * `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
1128
1237
  * A finished batch (report written) is left alone.
1129
1238
  */
1130
- async function cancelBatch({ id, root, viaDaemon = null, print = (l) => console.log(l) }) {
1239
+ async function cancelBatch({ id, root, stopRemote = false, viaDaemon = null, print = (l) => console.log(l) }) {
1131
1240
  if (!id || String(id).startsWith("--")) throw new Error(ORCH_USAGE);
1132
1241
  let res = viaDaemon ? await viaDaemon() : null;
1133
1242
  if (!res) {
1134
1243
  const b = (await import("./lean/batch.mjs")).openBatch(root, id);
1135
1244
  if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
1136
1245
  if (b.readReport()) res = { cancelled: false, finished: true };
1137
- else { b.requestCancel({ reason: "operator" }); res = { cancelled: true }; }
1246
+ else { b.requestCancel({ reason: "operator", stopRemote }); res = { cancelled: true }; }
1138
1247
  }
1139
- print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown, the rest stay held (forgedock orchestrate --resume ${id} continues)`
1248
+ print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown${stopRemote ? " (remote sandboxes too, #4612)" : ""}, the rest stay held (forgedock orchestrate --resume ${id} continues)`
1140
1249
  : `batch ${id}: already finished`);
1141
1250
  return { ok: true, batch: id, cancelled: !!res.cancelled };
1142
1251
  }
@@ -1172,15 +1281,33 @@ async function orchestrateClient(argv, deps = {}) {
1172
1281
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1173
1282
  return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
1174
1283
  };
1284
+ const requeueVia = (id) => async (issues, reason, by) => {
1285
+ let sock = null;
1286
+ try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1287
+ return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }) : null;
1288
+ };
1289
+ const holdVia = (id) => async (op, issues) => {
1290
+ let sock = null;
1291
+ try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1292
+ return sock ? schedd.request(sock, { op, id, issues }) : null;
1293
+ };
1175
1294
  const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
1176
1295
  if (argv.includes("--cancel")) {
1177
1296
  const id = flag(argv, "--cancel");
1178
- return cancelBatch({ id, root: await batchRootOf(), viaDaemon: async () => {
1297
+ return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
1179
1298
  let sock = null;
1180
1299
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1181
- return sock ? schedd.request(sock, { op: "cancel", id }) : null;
1300
+ return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }) : null;
1182
1301
  } });
1183
1302
  }
1303
+ if (argv.includes("--requeue")) {
1304
+ const i = argv.indexOf("--requeue"), id = argv[i + 1];
1305
+ return requeueBatch({ id, tokens: operandsOf(argv.slice(i + 2)), reason: flag(argv, "--reason"), root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: requeueVia(id) });
1306
+ }
1307
+ for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
1308
+ const id = flag(argv, `--${op}`);
1309
+ return holdBatch({ op, id, tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root: await batchRootOf(), viaDaemon: holdVia(id) });
1310
+ }
1184
1311
  if (argv.includes("--add")) {
1185
1312
  const i = argv.indexOf("--add"), id = argv[i + 1];
1186
1313
  if (!id || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
@@ -1190,9 +1317,10 @@ async function orchestrateClient(argv, deps = {}) {
1190
1317
  const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
1191
1318
  if (resumeExtra.length) {
1192
1319
  await addToBatch({ id: resumeId, tokens: resumeExtra, root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: addVia(resumeId) });
1320
+ await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root: await batchRootOf(), viaDaemon: holdVia(resumeId), print: (l) => onlyReleased(l) }); // #4592: a named, held issue is re-admitted
1193
1321
  }
1194
1322
  let msg;
1195
- if (resumeId) msg = { op: "attach", id: resumeId };
1323
+ if (resumeId) msg = { op: "attach", id: resumeId, resume: true };
1196
1324
  else {
1197
1325
  if (!issuesOf(argv).length) throw new Error(ORCH_USAGE);
1198
1326
  parallelOf(argv);
@@ -1254,7 +1382,14 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1254
1382
  sink.line(`batch ${b.id}: parallel set to ${k}`);
1255
1383
  return { ok: true, batch: b.id, parallel: k };
1256
1384
  }
1257
- if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, print: (l) => sink.line(l) });
1385
+ if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, stopRemote: argv.includes("--stop-remote"), print: (l) => sink.line(l) });
1386
+ if (argv.includes("--requeue")) {
1387
+ const i = argv.indexOf("--requeue");
1388
+ return requeueBatch({ id: argv[i + 1], tokens: operandsOf(argv.slice(i + 2)), reason: flag(argv, "--reason"), root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
1389
+ }
1390
+ for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
1391
+ return holdBatch({ op, id: flag(argv, `--${op}`), tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root, print: (l) => sink.line(l) });
1392
+ }
1258
1393
  if (argv.includes("--add")) {
1259
1394
  const i = argv.indexOf("--add");
1260
1395
  if (!argv[i + 1] || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
@@ -1264,6 +1399,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1264
1399
  const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
1265
1400
  if (resumeExtra.length) {
1266
1401
  await addToBatch({ id: resumeId, tokens: resumeExtra, root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
1402
+ await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root, print: (l) => onlyReleased(l, (t) => sink.line(t)) }); // #4592
1267
1403
  }
1268
1404
  let batch, plan = null;
1269
1405
  if (resumeId) {
@@ -1316,7 +1452,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1316
1452
  // #4399: declared acceptance probes of closed issues whose fix reached the default branch; never fails the batch
1317
1453
  const sweep = deps.sweepProbes !== undefined ? deps.sweepProbes : (await import("./lean/probe.mjs")).sweepProbes;
1318
1454
  if (sweep && !argv.includes("--no-probes")) {
1319
- 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)})`); }
1455
+ try { const s = (await import("./lean/probe.mjs")).formatProbeSummary(await sweep({ repo, cwd, io: leanIo, onResult: probeEventSink(repo) })); if (s) sink.line(s); } catch (e) { sink.line(`probes: sweep failed (${String(e?.message ?? e).slice(0, 160)})`); }
1320
1456
  }
1321
1457
  const { makePrefilter, formatPrefilterSummary, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
1322
1458
  const branches = readBranches(cwd);
@@ -1340,7 +1476,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1340
1476
  const { runCore } = deps.leanModule;
1341
1477
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1342
1478
  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,
1343
- admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
1479
+ admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}) }) };
1344
1480
  } else {
1345
1481
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1346
1482
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
@@ -1357,6 +1493,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1357
1493
  const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
1358
1494
  execForNeeds = exec ?? null;
1359
1495
  hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
1496
+ let remoteStop = null; // #4612: the sandbox provider's checkpoint stop, used only when the cancel asks for it
1360
1497
  if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
1361
1498
  // #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
1362
1499
  let gitDir = null;
@@ -1367,6 +1504,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1367
1504
  const bud = exec.budget ?? {};
1368
1505
  const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
1369
1506
  ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
1507
+ remoteStop = remoteLanes.stopLane;
1370
1508
  lanes = withPlacement(remoteLanes);
1371
1509
  {
1372
1510
  // #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
@@ -1412,9 +1550,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1412
1550
  } else {
1413
1551
  lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
1414
1552
  }
1415
- // #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on.
1553
+ // #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on unless `--cancel --stop-remote` (#4612).
1416
1554
  const { killLedgerPid } = await import("./lean/effects.mjs");
1417
- const stopLane = deps.stopLane ?? ((n, rec) => (rec?.pid && !rec.remote && !rec.handle ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
1555
+ const stopLane = deps.stopLane ?? ((n, rec) => (rec?.remote || rec?.handle ? remoteStop?.(n, rec) ?? false : rec?.pid ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
1418
1556
  lanes = { ...lanes, stopLane };
1419
1557
  }
1420
1558
  const orch = deps.orchestration ?? readOrchestration(cwd);
@@ -1520,7 +1658,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1520
1658
  rows = await orchestrate({
1521
1659
  ...(estimate ? { estimate } : {}),
1522
1660
  beforeAdmit,
1523
- issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
1661
+ issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, ...lanes,
1524
1662
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1525
1663
  ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1526
1664
  ...(remote ? { remote } : {}),
@@ -1668,6 +1806,15 @@ export function formatReclaimed(r) {
1668
1806
  return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
1669
1807
  }
1670
1808
 
1809
+ /** #4627: the daemon's timer callback: re-run the due windowed post-deploy checks of the cwd repo. No repo, no work. */
1810
+ async function scheddDue() {
1811
+ const io = makeIo();
1812
+ const repo = await resolveDefaultRepo(io);
1813
+ if (!repo) return;
1814
+ const { sweepDue } = await import("./lean/postdeploy-monitor.mjs");
1815
+ await sweepDue({ repo, cwd: process.cwd(), io: (await import("./lean/effects.mjs")).makeLeanIo({ repo }), onResult: probeEventSink(repo) });
1816
+ }
1817
+
1671
1818
  /**
1672
1819
  * `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
1673
1820
  */
@@ -1682,7 +1829,7 @@ export async function scheddFromCli(argv, deps = {}) {
1682
1829
  return { running: true, ...st };
1683
1830
  }
1684
1831
  if (argv.length) throw new Error("usage: forgedock schedd [status]");
1685
- const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
1832
+ const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }) });
1686
1833
  if (!d.running) return d;
1687
1834
  // #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
1688
1835
  schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
@@ -1757,7 +1904,7 @@ export async function checkFromCli(argv, deps = {}) {
1757
1904
  const plan = await readJson(pre.CHECK_PLAN_FILE);
1758
1905
  const base = o.base ?? plan?.base;
1759
1906
  if (!base) throw Object.assign(new Error(`check: no check plan from the engine; pass --base REF\n${usage}`), { exitCode: 2 });
1760
- const verify = plan ? { mode: plan.mode, precheckTotalSeconds: plan.precheckTotalSeconds, precheckTimeoutSeconds: plan.precheckTimeoutSeconds } : { ...parseVerify({}), ...(readVerify(repo) ?? {}) };
1907
+ const verify = plan ? { mode: plan.mode, precheckTotalSeconds: plan.precheckTotalSeconds, precheckTimeoutSeconds: plan.precheckTimeoutSeconds, invariants: Array.isArray(plan.invariants) ? plan.invariants : [] } : { ...parseVerify({}), ...(readVerify(repo) ?? {}) };
1761
1908
  const learned = plan ? plan.learned : readPrecheckCommands(repo) ?? [];
1762
1909
  const maxMinutes = plan ? plan.maxMinutes : readPrecheckBudget(repo);
1763
1910
  // the engine's changed files (lean.mjs changedFiles: base...HEAD still differing from base), plus uncommitted work
@@ -1977,13 +2124,14 @@ export async function triageFromCli(argv, deps = {}) {
1977
2124
  }
1978
2125
 
1979
2126
  /**
1980
- * `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N]` (#4399)
2127
+ * `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N] [--due]` (#4399; `--due` #4627: re-run due windowed post-deploy checks)
1981
2128
  * Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
1982
2129
  * declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
1983
2130
  */
1984
2131
  export async function probeFromCli(argv, deps = {}) {
1985
2132
  const valued = ["--repo", "--limit"];
1986
2133
  const issues = [];
2134
+ const due = argv.includes("--due"); // #4627
1987
2135
  for (let i = 0; i < argv.length; i++) {
1988
2136
  if (valued.includes(argv[i])) { i++; continue; }
1989
2137
  if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
@@ -1994,8 +2142,15 @@ export async function probeFromCli(argv, deps = {}) {
1994
2142
  const limit = flag(argv, "--limit") == null ? 50 : parseInt(flag(argv, "--limit"), 10);
1995
2143
  if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer");
1996
2144
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
2145
+ if (due) {
2146
+ const { sweepDue, formatDueSummary } = await import("./lean/postdeploy-monitor.mjs");
2147
+ const rows = await sweepDue({ repo, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
2148
+ for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
2149
+ console.log(formatDueSummary(rows) || "monitoring: nothing due");
2150
+ return { rows };
2151
+ }
1997
2152
  const { sweepProbes, formatProbeSummary } = await import("./lean/probe.mjs");
1998
- 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 } : {}) });
2153
+ const rows = await sweepProbes({ repo, cwd: process.cwd(), io: leanIo, issues: [...new Set(issues)], limit, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
1999
2154
  for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
2000
2155
  console.log(formatProbeSummary(rows) || "probes: nothing to run");
2001
2156
  return { rows };
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#4629: live A/B experiments. forge.yaml `experiment:` section (flat keys, the parser has one nesting level):
5
+ * id: light-vs-full arms: light,full ratio: 1,1 (weights, default equal)
6
+ * metrics: usd_median,wall_p50_ms,first_pass_rate,needs_human_rate (default all)
7
+ * targets: light.usd_median<=1.2xfull,light.first_pass_rate>=0.8 min_n: 10
8
+ * The arm is a hash of (id, issue), so a re-run or resume keeps its arm. Pure; the stats report is bin/stats/experiment.mjs.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { parseForgeYaml } from "./forge-utils.mjs";
14
+
15
+ export const EXPERIMENT_METRICS = ["usd_median", "wall_p50_ms", "first_pass_rate", "needs_human_rate"];
16
+ export const BENCH_SCHEMA = "forge.bench.v1";
17
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
18
+ const TARGET = /^([A-Za-z0-9][A-Za-z0-9._-]*)\.([a-z_0-9]+)(<=|>=)(\d+(?:\.\d+)?)(?:x([A-Za-z0-9][A-Za-z0-9._-]*))?$/;
19
+ const list = (v) => String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
20
+
21
+ /** Parsed declaration {id, arms, ratio, metrics, targets, minN}, or null when absent or invalid. */
22
+ export function parseExperiment(sec, { defaultMinN = 10 } = {}) {
23
+ if (!sec || typeof sec !== "object") return null;
24
+ const id = String(sec.id ?? "").trim(), arms = list(sec.arms);
25
+ if (!NAME.test(id) || arms.length < 2 || new Set(arms).size !== arms.length || !arms.every((a) => NAME.test(a))) return null;
26
+ const ratio = sec.ratio == null || sec.ratio === "" ? arms.map(() => 1) : list(sec.ratio).map((x) => (/^[1-9]\d*$/.test(x) ? Number(x) : NaN));
27
+ if (ratio.length !== arms.length || ratio.some(Number.isNaN)) return null;
28
+ const metrics = sec.metrics ? list(sec.metrics) : [...EXPERIMENT_METRICS];
29
+ if (!metrics.length || metrics.some((m) => !EXPERIMENT_METRICS.includes(m))) return null;
30
+ const targets = [];
31
+ for (const t of list(sec.targets)) {
32
+ const m = TARGET.exec(t);
33
+ if (!m || !arms.includes(m[1]) || !metrics.includes(m[2]) || (m[5] && !arms.includes(m[5]))) return null;
34
+ targets.push({ arm: m[1], metric: m[2], op: m[3], limit: Number(m[4]), vs: m[5] ?? null, text: t });
35
+ }
36
+ const mn = String(sec.min_n ?? "").trim();
37
+ if (mn && !/^\d+$/.test(mn)) return null;
38
+ return { id, arms, ratio, metrics, targets, minN: mn ? Number(mn) : defaultMinN };
39
+ }
40
+
41
+ /** forge.yaml `experiment:` of `cwd`, or null. */
42
+ export function readExperiment(cwd = process.cwd()) {
43
+ try { return parseExperiment(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.experiment); } catch { return null; }
44
+ }
45
+
46
+ /** Deterministic arm of an issue: sha1(id:issue) mod the ratio total, walked over the arms in declared order. */
47
+ export function assignArm(exp, issue) {
48
+ const total = exp.ratio.reduce((a, b) => a + b, 0);
49
+ let pick = createHash("sha1").update(`${exp.id}:${issue}`).digest().readUInt32BE(0) % total;
50
+ for (let i = 0; i < exp.arms.length; i++) { if (pick < exp.ratio[i]) return exp.arms[i]; pick -= exp.ratio[i]; }
51
+ return exp.arms[0];
52
+ }
53
+
54
+ /** The shared result record of the bench harness, the eval gate and `stats --experiment`: targets are {pass: true|false|null, value, limit}. */
55
+ export const benchRecord = ({ source, arms = {}, targets = {} }) => ({ schema: BENCH_SCHEMA, source, arms, targets });
package/bin/forgedock.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  renameSync,
14
14
  unlinkSync,
15
15
  } from "fs";
16
+ import { gitignoreAdditions } from "./lean/toolstate.mjs";
16
17
  import { execSync, execFileSync, spawnSync } from "child_process";
17
18
  import { homedir } from "os";
18
19
  import https from "https";
@@ -1025,6 +1026,20 @@ async function statusScreen(c) {
1025
1026
  * Independent of FORGEDOCK_BACKEND, which controls
1026
1027
  * the separate `forgedock run` engine backend.
1027
1028
  */
1029
+ /** #4590: append the tool-state deny-list (.forge-lean/, tool caches) to the repo's .gitignore; append-only, idempotent. */
1030
+ function ensureToolStateIgnored(c) {
1031
+ try {
1032
+ const file = join(c.cwd, ".gitignore");
1033
+ const text = existsSync(file) ? readFileSync(file, "utf-8") : "";
1034
+ let next = false;
1035
+ try { next = Boolean(JSON.parse(readFileSync(join(c.cwd, "package.json"), "utf-8"))?.dependencies?.next); } catch { /* no package.json */ }
1036
+ const add = gitignoreAdditions(text, { next });
1037
+ if (!add.length) return;
1038
+ writeFileSync(file, `${text}${text && !text.endsWith("\n") ? "\n" : ""}# ForgeDock lane scratch and tool caches\n${add.join("\n")}\n`);
1039
+ c.stdout.write(` .gitignore: added ${add.join(", ")}\n`);
1040
+ } catch { /* .gitignore is best effort */ }
1041
+ }
1042
+
1028
1043
  async function initFlow(c) {
1029
1044
  const outputPath = join(c.cwd, "forge.yaml");
1030
1045
  const hasExisting = existsSync(outputPath);
@@ -1078,6 +1093,7 @@ async function initFlow(c) {
1078
1093
  stagingBranch: draft.branches.staging.value,
1079
1094
  });
1080
1095
  atomicWriteFile(outputPath, content);
1096
+ ensureToolStateIgnored(c);
1081
1097
  celebrate(c, { written: true, todoCount: 0, isMinimal: true });
1082
1098
  await maybeOfferDemo(c);
1083
1099
  return 0;
@@ -1107,12 +1123,14 @@ async function initFlow(c) {
1107
1123
  if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
1108
1124
  const lowKeys = manualLowConfidenceKeys(draft, description, v);
1109
1125
  const { todoCount } = writeForgeYaml(v, lowKeys, outputPath);
1126
+ ensureToolStateIgnored(c);
1110
1127
  celebrate(c, { written: true, todoCount });
1111
1128
  await maybeOfferDemo(c);
1112
1129
  return 0;
1113
1130
  }
1114
1131
  const { draft, description } = await setupAfterRead(c, await read(c));
1115
1132
  const reviewed = await review(c, draft, description);
1133
+ if (!reviewed.aborted) ensureToolStateIgnored(c);
1116
1134
  celebrate(c, reviewed);
1117
1135
  if (!reviewed.aborted) await maybeOfferDemo(c);
1118
1136
  return reviewed.aborted ? 1 : 0;
@@ -3297,7 +3315,7 @@ function help() {
3297
3315
  ["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
3298
3316
  ["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
3299
3317
  ["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
3300
- ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --cancel <id>", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume)"],
3318
+ ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N...", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume)"],
3301
3319
  ["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
3302
3320
  ["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
3303
3321
  ["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
@@ -6,9 +6,14 @@
6
6
  * (bin/lean/effects.mjs rereadNudgeFlags). It counts reads per path for one session, using the #4410 telemetry's
7
7
  * definition of a read (toolstats readPaths), and on exactly the REREAD_MIN-th read of a path it returns an
8
8
  * `additionalContext` system reminder. Advisory only: it always exits 0 and an error produces no output.
9
- * State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json (a per-run cache).
9
+ * #4580: the same script is also a PreToolUse guard on Read: a full-file Read (no offset/limit) of a path already read
10
+ * REREAD_MIN-1 times whose mtime is unchanged since its last read is denied with a reason that points at notes or a line
11
+ * range. Ranged reads and Bash never deny, and any error allows. A changed mtime resets the path's count.
12
+ * #4622: the guard counts only full-file Reads ({path: count} in <session_id>.full.json), so ranged Reads and Bash samples never
13
+ * make a first full Read look like a re-read; the nudge still counts every read.
14
+ * State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json plus {path: mtimeMs} in <session_id>.mtime.json (per-run caches).
10
15
  */
11
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
17
  import { tmpdir } from "node:os";
13
18
  import { dirname, join, resolve } from "node:path";
14
19
  import { fileURLToPath } from "node:url";
@@ -38,18 +43,52 @@ export function nudgeFor(payload, counts = {}) {
38
43
  export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =>
39
44
  join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "session"}.json`);
40
45
 
46
+ const mtimeOf = (p) => { try { return statSync(p).mtimeMs; } catch { return null; } };
47
+ const loadJson = (file) => { try { const v = JSON.parse(readFileSync(file, "utf8")); return v && typeof v === "object" ? v : {}; } catch { return {}; } };
48
+
49
+ /** Paths a payload reads whole: a Read with no offset/limit (what the guard counts, #4622). */
50
+ export function fullReads(payload) {
51
+ const inp = payload?.tool_input ?? {};
52
+ return payload?.tool_name === "Read" && inp.offset == null && inp.limit == null ? payloadReads(payload) : [];
53
+ }
54
+
55
+ /** Pure core of the guard: the deny reason for a payload, or null. `counts` are full-read counts; `mtimeNow(path)` is the file's current mtime. */
56
+ export function guardFor(payload, counts = {}, mtimes = {}, mtimeNow = mtimeOf) {
57
+ const inp = payload?.tool_input ?? {};
58
+ if (payload?.tool_name !== "Read" || inp.offset != null || inp.limit != null) return null;
59
+ for (const p of payloadReads(payload)) {
60
+ const now = mtimeNow(p);
61
+ if (now != null && (counts[p] ?? 0) >= REREAD_MIN - 1 && mtimes[p] === now)
62
+ return `${p} has already been read ${counts[p]} times in this session and has not changed: use your notes or the excerpt already in context; `
63
+ + "if you need a different part, read only that line range (offset and limit).";
64
+ }
65
+ return null;
66
+ }
67
+
41
68
  /** One hook call: stdin JSON in, the hookSpecificOutput line (or nothing) out. Fails open. */
42
69
  export function main(input, { dir } = {}) {
43
70
  try {
44
71
  const payload = JSON.parse(input);
45
72
  if (!payload?.session_id) return "";
46
73
  if (!payloadReads(payload).length) return "";
47
- const file = stateFile(payload.session_id, dir);
48
- let counts = {};
49
- try { counts = JSON.parse(readFileSync(file, "utf8")) ?? {}; } catch { /* first read of this session */ }
74
+ const file = stateFile(payload.session_id, dir), mfile = file.replace(/\.json$/, ".mtime.json");
75
+ const ffile = file.replace(/\.json$/, ".full.json");
76
+ const counts = loadJson(file), mtimes = loadJson(mfile), full = loadJson(ffile);
77
+ if (payload.hook_event_name === "PreToolUse") {
78
+ const reason = guardFor(payload, full, mtimes);
79
+ return reason ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } })}\n` : "";
80
+ }
81
+ for (const p of payloadReads(payload)) {
82
+ const now = mtimeOf(p);
83
+ if (now != null && mtimes[p] !== now) { delete counts[p]; delete full[p]; } // the file changed (or is new): earlier reads no longer count
84
+ if (now != null) mtimes[p] = now;
85
+ }
86
+ for (const p of fullReads(payload)) full[p] = (full[p] ?? 0) + 1;
50
87
  const r = nudgeFor(payload, counts);
51
88
  mkdirSync(dirname(file), { recursive: true });
52
89
  writeFileSync(file, JSON.stringify(r.counts));
90
+ writeFileSync(mfile, JSON.stringify(mtimes));
91
+ writeFileSync(ffile, JSON.stringify(full));
53
92
  return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
54
93
  } catch {
55
94
  return "";