forgedock 1.48.1 → 1.50.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.
@@ -24,6 +24,7 @@ import { readExperiment } from "./experiment.mjs";
24
24
  import { readBranches, resolveLane, isDefaultLane, makeLaneCache } from "./lean/lane.mjs";
25
25
  import { parseVerify } from "./lean/verify.mjs";
26
26
  import { normalizeKindModes } from "./lean/conformance.mjs";
27
+ import { parseEnforceDomains } from "./lean/review-trigger.mjs";
27
28
  import { viewIssue } from "./lean/gh-assoc.mjs";
28
29
  import { otherLaneOwner } from "./lean/lanelock.mjs";
29
30
  import { parseExecution, detectNeeds, placementText } from "./lean/execution.mjs";
@@ -192,7 +193,7 @@ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
192
193
  }
193
194
  /**
194
195
  * forge.yaml `review.trigger` (shadow|enforce|off) and `review.risk_paths` (#4273): a comma-separated scalar or a block
195
- * list (` risk_paths:` then ` - glob`). Returns {mode, riskPaths}; undefined when neither is set (the core defaults to
196
+ * list (` risk_paths:` then ` - glob`); `review.enforce_domains` (#5099) the same way. Returns {mode, riskPaths, enforceDomains?}; undefined when none is set (the core defaults to
196
197
  * shadow with the built-in risk patterns). An invalid mode reads as unset.
197
198
  */
198
199
  export function readReviewTrigger(cwd = process.cwd()) {
@@ -202,19 +203,24 @@ export function readReviewTrigger(cwd = process.cwd()) {
202
203
  try { sec = parseForgeYaml(raw)?.review ?? {}; } catch { /* unreadable: no trigger config */ }
203
204
  const m = String(sec?.trigger ?? "").trim();
204
205
  const mode = ["shadow", "enforce", "off"].includes(m) ? m : undefined;
205
- const riskPaths = typeof sec?.risk_paths === "string" ? sec.risk_paths.split(",").map((x) => x.trim()).filter(Boolean) : [];
206
- let inReview = false, listIndent = null;
206
+ const scalar = (v) => (typeof v === "string" ? v.split(",").map((x) => x.trim()).filter(Boolean) : []);
207
+ const lists = { risk_paths: scalar(sec?.risk_paths), enforce_domains: scalar(sec?.enforce_domains) }; // #5099: enforce_domains
208
+ const keyOf = (line) => /^\s+(risk_paths|enforce_domains):\s*(#.*)?$/.exec(line)?.[1] ?? null;
209
+ let inReview = false, listIndent = null, listKey = null;
207
210
  for (const line of raw.split(/\r?\n/)) {
208
211
  if (/^\s*(#.*)?$/.test(line)) continue;
209
212
  if (/^\S/.test(line)) { inReview = /^review:\s*(#.*)?$/.test(line); listIndent = null; continue; }
210
213
  if (!inReview) continue;
211
214
  const indent = line.length - line.trimStart().length;
212
- if (listIndent === null) { if (/^\s+risk_paths:\s*(#.*)?$/.test(line)) listIndent = indent; continue; }
213
- if (indent <= listIndent) { listIndent = /^\s+risk_paths:\s*(#.*)?$/.test(line) ? indent : null; continue; }
215
+ if (listIndent === null) { listKey = keyOf(line); if (listKey) listIndent = indent; continue; }
216
+ if (indent <= listIndent) { listKey = keyOf(line); listIndent = listKey ? indent : null; continue; }
214
217
  const li = /^\s+-\s+["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line);
215
- if (li) riskPaths.push(li[1].trim());
218
+ if (li) lists[listKey].push(li[1].trim());
216
219
  }
217
- return mode || riskPaths.length ? { mode: mode ?? "shadow", riskPaths } : undefined;
220
+ const riskPaths = lists.risk_paths;
221
+ const enforceDomains = parseEnforceDomains(lists.enforce_domains);
222
+ if (!mode && !riskPaths.length && !enforceDomains.length) return undefined;
223
+ return { mode: mode ?? "shadow", riskPaths, ...(enforceDomains.length ? { enforceDomains } : {}) };
218
224
  }
219
225
  /**
220
226
  * forge.yaml `review.verifier` (off|shadow|enforce) and `review.verifier_model`, else `engine.lean_verifier_model` (#4397).
@@ -1177,6 +1183,7 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
1177
1183
  * `--resume <id>` continues after the dispatcher dies, re-adopting live lanes. `deferred` lanes are requeued by code,
1178
1184
  * forge.yaml `orchestration.breaker` ({errors, window_min}, default 3 in 30) stops admissions on an engine-error
1179
1185
  * burst, and `--set-parallel <id> K` changes K of a running batch.
1186
+ * `--set-max-remote <id> N` and `--set-placement <id> remote|hybrid|local` (#5138) change its execution live (live-execution.mjs).
1180
1187
  * Maintenance budget (#4197), only with forge.yaml `maintenance.enabled: true` (or `deps.maintenance`): open issues carrying
1181
1188
  * a `maintenance.labels` label join the batch, each issue is classified from its labels (maintenance; `priority:P<n>`,
1182
1189
  * unlabelled = P2) and admission reserves `maintenance.share` of lane starts for maintenance (`when_idle` drains the rest).
@@ -1242,7 +1249,7 @@ async function envBatch({ argv, root, print = (l) => console.log(l) }) {
1242
1249
  return { ok: true, batch: id };
1243
1250
  }
1244
1251
 
1245
- const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] [--auto-children off|decomposed|epic|all] [--epic N] | --resume <id> [issue...] | --set-parallel <id> <K> | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
1252
+ const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] [--auto-children off|decomposed|epic|all] [--epic N] | --resume <id> [issue...] | --set-parallel <id> <K> | --set-max-remote <id> <N> | --set-placement <id> remote|hybrid|local | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
1246
1253
  const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force", "--auto-children", "--epic"];
1247
1254
  const issuesOf = (argv) => {
1248
1255
  const issues = [];
@@ -1457,6 +1464,17 @@ async function orchestrateClient(argv, deps = {}) {
1457
1464
  console.log(`batch ${id}: parallel set to ${k}`);
1458
1465
  return { ok: true, batch: id, parallel: k };
1459
1466
  }
1467
+ if (argv.includes("--set-max-remote") || argv.includes("--set-placement")) {
1468
+ // #5138: through the daemon when one runs, else the batch's `execution` file directly (nothing hosts the batch).
1469
+ const liveExec = await import("./lean/live-execution.mjs");
1470
+ const { id, patch } = liveExec.parseSetExecution(argv);
1471
+ let sock = null, execution = null;
1472
+ try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1473
+ if (sock) execution = (await schedd.request(sock, { op: "set-execution", id, ...patch }, undefined, { timeoutMs: schedd.REQUEST_MS }))?.execution ?? null;
1474
+ else { const batchMod = await import("./lean/batch.mjs"); execution = liveExec.applySetExecution(batchMod.openBatch(batchMod.batchRoot(), id), patch); }
1475
+ console.log(`batch ${id}: ${liveExec.formatOverride(patch)}`);
1476
+ return { ok: true, batch: id, execution };
1477
+ }
1460
1478
  // #4408: --add, and the extra issues of --resume <id> N..., go to the batch (through the daemon when one runs).
1461
1479
  const addVia = (id) => async (issues) => {
1462
1480
  let sock = null;
@@ -1578,6 +1596,14 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1578
1596
  sink.line(`batch ${b.id}: parallel set to ${k}`);
1579
1597
  return { ok: true, batch: b.id, parallel: k };
1580
1598
  }
1599
+ if (argv.includes("--set-max-remote") || argv.includes("--set-placement")) {
1600
+ // #5138: live max_remote / placement mode of a batch, re-read by its runner on every admission.
1601
+ const liveExec = await import("./lean/live-execution.mjs");
1602
+ const { id, patch } = liveExec.parseSetExecution(argv);
1603
+ const execution = liveExec.applySetExecution(batchMod.openBatch(root, id), patch);
1604
+ sink.line(`batch ${id}: ${liveExec.formatOverride(patch)}`);
1605
+ return { ok: true, batch: id, execution };
1606
+ }
1581
1607
  if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root, print: (l) => sink.line(l) });
1582
1608
  if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, stopRemote: argv.includes("--stop-remote"), print: (l) => sink.line(l) });
1583
1609
  if (argv.includes("--requeue")) {
@@ -1741,7 +1767,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1741
1767
  // #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
1742
1768
  let gitDir = null;
1743
1769
  try { gitDir = resolvePath(cwd, String(await leanIo.git(["rev-parse", "--git-common-dir"], { cwd })).trim()); } catch { gitDir = null; }
1744
- const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env, prefetch: (n) => prefetchOf.get(n) ?? [] });
1770
+ const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env, prefetch: (n) => prefetchOf.get(n) ?? [], ...(deps.sandboxFetch ? { fetchFn: deps.sandboxFetch } : {}) });
1745
1771
  const { makeLedger } = await import("./lean/ledger.mjs");
1746
1772
  const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
1747
1773
  const bud = exec.budget ?? {};
@@ -1766,13 +1792,16 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1766
1792
  const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1767
1793
  // #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
1768
1794
  const relays = Array.isArray(provider.relays) ? provider.relays : [];
1769
- const place = !hybrid ? (n) => execMod.placeLane({ exec, needs: needsOf.get(n) ?? [], relays }) : await (async () => {
1770
- const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
1771
- let weight = deps.validationWeight ?? null;
1772
- if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
1773
- const headroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
1774
- return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom(), needs: needsOf.get(n) ?? [], relays });
1775
- })();
1795
+ // #5112: the host-busy guard, shared by hybrid placement and the fallback_local capacity fallback.
1796
+ const hostHeadroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
1797
+ // #5138: placement and max_remote re-read the batch's live `execution` override (--set-placement / --set-max-remote).
1798
+ const liveExec = await import("./lean/live-execution.mjs");
1799
+ const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
1800
+ const computeWeight = () => deps.validationWeight ?? execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] });
1801
+ const weight = liveExec.lazyWeight({ initial: hybrid ? (await Promise.resolve().then(computeWeight).catch(() => null)) ?? { heavy: false, signals: [] } : null, compute: computeWeight });
1802
+ const place = (e, n) => (e.mode !== "hybrid" ? execMod.placeLane({ exec: e, needs: needsOf.get(n) ?? [], relays })
1803
+ : execMod.placeLane({ exec: e, verifyMode, labels: labelsOf.get(n) ?? [], weight: weight.get(), headroom: hostHeadroom(), needs: needsOf.get(n) ?? [], relays }));
1804
+ if (batch.readExecution()?.provider !== true) batch.setExecution({ provider: true });
1776
1805
  // #4540: provider free slots, refreshed in the background (admission is sync); unknown (null) never blocks.
1777
1806
  let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity, capFailed = false; // #5048: capFailed = last probe rejected // #4547: online = agents alive, null unknown
1778
1807
  const capacity = () => {
@@ -1784,18 +1813,19 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1784
1813
  }
1785
1814
  return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
1786
1815
  };
1787
- remote = {
1788
- capacity, max: exec.maxRemote, isRemote: () => true, place, identity: () => holdId, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1816
+ remote = liveExec.liveRemote({
1817
+ capacity, isRemote: () => true, identity: () => holdId, ...(exec.fallbackLocal ? { offlineFallback: true, headroom: hostHeadroom, ...(exec.capacityFallbackAfter ? { capacityFallbackAfter: exec.capacityFallbackAfter } : {}) } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1789
1818
  admit: (u) => execBudget.admit(u),
1790
1819
  onUsage: (n, sb, rec) => {
1791
1820
  const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
1792
1821
  try { execBudget.record(`${batch.id}#${sb.id ?? n}#${startedAt}`, sb); } catch { /* the batch total still counts it */ }
1793
1822
  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(() => {});
1794
1823
  },
1795
- };
1824
+ }, { exec, readOverride: batch.readExecution, place, onChange: (e) => sink.line(`execution: live placement ${e.mode}, max_remote ${e.maxRemote}`) });
1796
1825
  sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
1797
1826
  } else {
1798
1827
  lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env: laneEnv, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs, spawnFn: laneSpawn }));
1828
+ if (batch.readExecution()?.provider === true) batch.setExecution({ provider: false }); // #5138: a resume without a provider refuses live remote/hybrid
1799
1829
  }
1800
1830
  // #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).
1801
1831
  const { killLedgerPid } = await import("./lean/effects.mjs");
package/bin/forgedock.mjs CHANGED
@@ -3381,7 +3381,7 @@ function help() {
3381
3381
  ["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)"],
3382
3382
  ["npx forgedock plan validate --live [--repo owner/repo] [--json]", "Re-run the bootstrap dependency-order rules on live GitHub state: each cross-milestone Depends-on edge is ok, held or invalid (exit 1 on invalid)"],
3383
3383
  ["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"],
3384
- ["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... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "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), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
3384
+ ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --set-max-remote <id> N | --set-placement <id> remote|hybrid|local | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "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, change its max_remote or placement mode live (remote/hybrid need a batch started with a remote provider; running lanes are not stopped), 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), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
3385
3385
  ["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"],
3386
3386
  ["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"],
3387
3387
  ["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)"],
@@ -19,6 +19,7 @@ import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fsyncSync
19
19
  import { spawn } from "node:child_process";
20
20
  import { homedir } from "node:os";
21
21
  import { dirname, join, resolve, sep } from "node:path";
22
+ import { sanitizeOverride, validateExecutionPatch } from "./live-execution.mjs";
22
23
 
23
24
  export const RAM_FS = new Set(["tmpfs", "ramfs"]);
24
25
  const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -145,6 +146,14 @@ export function openBatch(root, id) {
145
146
  if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
146
147
  put(f("parallel"), `${k}\n`);
147
148
  },
149
+ /** Live execution override (#5138): the `execution` file `{maxRemote?, mode?, provider?}`, read on every admission; null when missing or invalid. */
150
+ readExecution: () => sanitizeOverride(readJson(f("execution"))),
151
+ /** Merge a validated patch into the `execution` file. @returns the stored override. */
152
+ setExecution: (patch) => {
153
+ const next = { ...(sanitizeOverride(readJson(f("execution"))) ?? {}), ...validateExecutionPatch(patch) };
154
+ put(f("execution"), JSON.stringify(next) + "\n");
155
+ return next;
156
+ },
148
157
  /** Issues added by `--add` (#4408): the `add` file, sorted unique positive integers; [] when missing or invalid. */
149
158
  readAdded: () => readAdded(f("add")),
150
159
  /**
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { resolveLane, makeLaneCache } from "./lane.mjs";
10
- import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
10
+ import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists, CAPACITY_FALLBACK_AFTER } from "./orchestrate.mjs";
11
11
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
12
12
  import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
13
13
 
@@ -47,7 +47,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
47
47
  const pids = () => Object.values(batch.store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean);
48
48
  let lanePids = [];
49
49
  try {
50
- const child = spawn(process.execPath, [dispatcher], { stdio: "ignore" });
50
+ // The two-lane wait must not depend on host memory: the live local lane cap (#4777) reads forge.yaml from the dispatcher's cwd.
51
+ writeFileSync(join(work, "forge.yaml"), "engine:\n admission_enabled: false\n");
52
+ const child = spawn(process.execPath, [dispatcher], { stdio: "ignore", cwd: work });
51
53
  const exited = new Promise((r) => child.on("exit", r));
52
54
  await until(() => readFileSync(launches, "utf8").trim().split("\n").filter(Boolean).length === 2 && pids().length === 2);
53
55
  lanePids = pids();
@@ -1065,6 +1067,108 @@ describe("#4547: opt-in local fallback in mode remote when no pool agent is onli
1065
1067
  });
1066
1068
  });
1067
1069
 
1070
+ describe("#5112: late dependents of a decomposed issue, edge re-read on --requeue, capacity fallback_local", () => {
1071
+ const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
1072
+ it("#5112 an issue added after its dependency decomposed waits on the sub-issues, not skipped", async () => {
1073
+ const bodies = { 1: "", 3: "Depends on #1", 5: "Depends on #1", 6: "", 11: "", 12: "" };
1074
+ let added = [];
1075
+ const started = [], finished = [], log = [], g = gate();
1076
+ const done = orchestrate({ issues: [1, 3], parallel: 4, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getAdded: () => added, onUpdate: (r) => log.push(r),
1077
+ runOne: async (n) => {
1078
+ started.push(n);
1079
+ if (n === 1) return { terminalReason: "decomposed", subissues: [11, 12] };
1080
+ if (n === 11 || n === 12) await g.p;
1081
+ finished.push(n);
1082
+ return { terminalReason: "merged", pr: 100 + n };
1083
+ } });
1084
+ await until(() => started.includes(11) && started.includes(12));
1085
+ added = [5];
1086
+ await until(() => log.some((r) => r.outcome === "added" && /#5/.test(r.detail ?? "")));
1087
+ added = [5, 6]; // an unrelated later --add re-reads the pending bodies: #3's edge to #1 must stay resolved
1088
+ await until(() => log.some((r) => r.outcome === "added" && /#6/.test(r.detail ?? "")));
1089
+ g.open();
1090
+ const rows = await done;
1091
+ const out = Object.fromEntries(rows.map((r) => [r.issue, r.outcome]));
1092
+ assert.equal(out[5], "merged", JSON.stringify(rows.find((r) => r.issue === 5)));
1093
+ assert.equal(out[3], "merged", JSON.stringify(rows.find((r) => r.issue === 3)));
1094
+ for (const n of [3, 5]) assert.ok(finished.indexOf(n) > finished.indexOf(11) && finished.indexOf(n) > finished.indexOf(12), `#${n} ran after the sub-issues`);
1095
+ });
1096
+
1097
+ it("#5112 --requeue re-reads the edited Depends on edges", async () => {
1098
+ const bodies = { 1: "", 2: "Depends on #1", 3: "" };
1099
+ let entries = [];
1100
+ const started = [], log = [], decisions = [], g3 = gate();
1101
+ const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getRequeued: () => entries,
1102
+ decide: (d) => { decisions.push(d); }, onUpdate: (r) => log.push(r),
1103
+ runOne: async (n) => {
1104
+ started.push(n);
1105
+ if (n === 3) { await g3.p; return { terminalReason: "merged" }; }
1106
+ return n === 1 ? { terminalReason: "needs-human", detail: "builder.blocked: product-decision" } : { terminalReason: "merged" };
1107
+ } });
1108
+ await until(() => log.some((r) => r.issue === 2 && r.outcome === "skipped"));
1109
+ bodies[2] = ""; // the operator dropped the wrong edge
1110
+ entries = [{ issue: 2, reason: "edge dropped", by: "operator", at: "2026-11-05T00:00:00Z" }];
1111
+ await until(() => started.includes(2), 3000);
1112
+ g3.open();
1113
+ const rows = await done;
1114
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "needs-human"], [2, "merged"], [3, "merged"]]);
1115
+ assert.ok(decisions.some((d) => d.issue === 2 && /edges re-read: #2 -> none/.test(d.what)));
1116
+ });
1117
+
1118
+ it("#5112 --requeue of a dependent skipped behind a decomposed issue waits on its sub-issues", async () => {
1119
+ const saved = { v: 1, order: [1, 2, 11], deps: { 1: [], 2: [1], 11: [] }, pending: [11],
1120
+ results: { 1: { issue: 1, outcome: "decomposed", subissues: [11] }, 2: { issue: 2, outcome: "skipped", detail: "dependency #1 ended decomposed" } } };
1121
+ let entries = [];
1122
+ const finished = [], g = gate();
1123
+ const done = orchestrate({ issues: [], parallel: 2, pollMs: 5, readBody: async (n) => (n === 2 ? "Depends on #1" : ""), getRequeued: () => entries,
1124
+ store: { load: () => saved, save: () => {} },
1125
+ runOne: async (n) => { if (n === 11) await g.p; finished.push(n); return { terminalReason: "merged" }; } });
1126
+ entries = [{ issue: 2, reason: "parent decomposed", by: "operator", at: "2026-11-05T00:00:00Z" }];
1127
+ await new Promise((r) => setTimeout(r, 50));
1128
+ g.open();
1129
+ const rows = await done;
1130
+ assert.equal(rows.find((r) => r.issue === 2)?.outcome, "merged");
1131
+ assert.deepEqual(finished, [11, 2]);
1132
+ });
1133
+
1134
+ const runCap = async (remoteExtra) => {
1135
+ const c = { t: 1_000_000 };
1136
+ const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
1137
+ const where = [], decisions = [];
1138
+ let remoteTries = 0;
1139
+ const rows = await orchestrate({ issues: [1], parallel: 1, now: () => c.t, sleep, pollMs: 1, readBody: async () => "", decide: (d) => decisions.push(d),
1140
+ remote: { max: 1, isRemote: () => true, capacity: () => ({ free: null, online: 1, at: 1 }), ...remoteExtra },
1141
+ runOne: async (n, _ln, ctx = {}) => {
1142
+ where.push(ctx.placement ?? { where: "remote" });
1143
+ if (ctx.placement?.where === "local") return { terminalReason: "merged", pr: 101 };
1144
+ remoteTries++;
1145
+ return remoteTries >= 5 ? { terminalReason: "merged", pr: 101 } : { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 1, detail: "HTTP 503 capacity" };
1146
+ } });
1147
+ return { rows, where, remoteTries, decisions };
1148
+ };
1149
+ it("#5112 fallback_local runs a lane locally after N sandbox.capacity deferrals when the host has headroom", async () => {
1150
+ const { rows, where, remoteTries, decisions } = await runCap({ offlineFallback: true, capacityFallbackAfter: 2, headroom: () => ({ ok: true, detail: "" }) });
1151
+ assert.equal(rows[0].outcome, "merged");
1152
+ assert.equal(remoteTries, 2);
1153
+ assert.equal(where.at(-1).where, "local");
1154
+ assert.match(where.at(-1).reason, /pool at capacity after 2 deferrals/);
1155
+ assert.equal(decisions.filter((d) => d.issue === 1 && /capacity/.test(d.what)).length, 1);
1156
+ });
1157
+ it("#5112 a busy host (headroom not ok, or a throwing probe) or fallback_local off keeps the lane remote", async () => {
1158
+ for (const extra of [{ offlineFallback: true, capacityFallbackAfter: 2, headroom: () => ({ ok: false, detail: "mem" }) },
1159
+ { offlineFallback: true, capacityFallbackAfter: 2, headroom: () => { throw new Error("probe"); } }, { capacityFallbackAfter: 2 }]) {
1160
+ const { where, remoteTries } = await runCap(extra);
1161
+ assert.equal(remoteTries, 5);
1162
+ assert.ok(where.every((w) => w.where !== "local"));
1163
+ }
1164
+ });
1165
+ it("#5112 without capacityFallbackAfter the default CAPACITY_FALLBACK_AFTER applies", async () => {
1166
+ const { remoteTries, where } = await runCap({ offlineFallback: true });
1167
+ assert.equal(remoteTries, CAPACITY_FALLBACK_AFTER);
1168
+ assert.equal(where.at(-1).where, "local");
1169
+ });
1170
+ });
1171
+
1068
1172
  describe("#4788: a pool offline for longer than N minutes is reported once", () => {
1069
1173
  const run = async (onlineAt) => {
1070
1174
  const c = { t: 1_000_000 };
@@ -1241,3 +1345,71 @@ describe("batch plan secrets (#4787)", () => {
1241
1345
  assert.ok(fast < 10 * LAT, `first lane after ${fast} ms`);
1242
1346
  });
1243
1347
  });
1348
+
1349
+ describe("#5138: live max_remote and placement of a running batch", () => {
1350
+ const tick = () => new Promise((r) => setTimeout(r, 1));
1351
+ it("live max_remote raised from 1 to 2 admits a waiting remote lane while the first still runs", async () => {
1352
+ let m = 1, open;
1353
+ const gate = new Promise((r) => { open = r; });
1354
+ const live = new Set(), seen = [];
1355
+ const done = orchestrate({ issues: [1, 2], parallel: 2, pollMs: 1, readBody: async () => "", sleep: tick,
1356
+ remote: { get max() { return m; }, isRemote: () => true, capacity: () => null },
1357
+ runOne: async (n, _ln, ctx = {}) => { live.add(n); seen.push([n, ctx.placement?.where, [...live].sort()]); if (n === 1) await gate; live.delete(n); return { terminalReason: "merged", pr: 100 + n }; } });
1358
+ await until(() => live.has(1));
1359
+ for (let i = 0; i < 20; i++) await tick();
1360
+ assert.ok(!seen.some(([n]) => n === 2), "#2 waits: max_remote 1 is taken by #1");
1361
+ m = 2;
1362
+ await until(() => seen.some(([n]) => n === 2));
1363
+ assert.deepEqual(seen.find(([n]) => n === 2), [2, "remote", [1, 2]]);
1364
+ open();
1365
+ const rows = await done;
1366
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
1367
+ });
1368
+ it("live max_remote raised from 0 to 1 admits a waiting remote lane (nothing running)", async () => {
1369
+ let m = 0;
1370
+ const seen = [];
1371
+ const done = orchestrate({ issues: [1], parallel: 2, pollMs: 1, readBody: async () => "", sleep: tick,
1372
+ remote: { get max() { return m; }, isRemote: () => true, capacity: () => null },
1373
+ runOne: async (n, _ln, ctx = {}) => { seen.push([n, ctx.placement?.where]); return { terminalReason: "merged", pr: 100 + n }; } });
1374
+ for (let i = 0; i < 20; i++) await tick();
1375
+ assert.deepEqual(seen, [], "#1 waits under max_remote 0");
1376
+ m = 1;
1377
+ const rows = await done;
1378
+ assert.deepEqual(seen, [[1, "remote"]]);
1379
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged"]);
1380
+ });
1381
+ it("live max_remote lowered from 2 to 1 stops no running lane and holds new remote admissions", async () => {
1382
+ let m = 2;
1383
+ const live = new Set(), gates = new Map(), seen = [];
1384
+ const gate = (n) => { let open; const p = new Promise((r) => { open = r; }); gates.set(n, open); return p; };
1385
+ const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 1, readBody: async () => "", sleep: tick,
1386
+ remote: { get max() { return m; }, isRemote: () => true, capacity: () => null },
1387
+ runOne: async (n) => { live.add(n); seen.push([n, [...live].sort()]); if (n !== 3) await gate(n); live.delete(n); return { terminalReason: "merged", pr: 100 + n }; } });
1388
+ await until(() => live.size === 2);
1389
+ m = 1;
1390
+ gates.get(1)();
1391
+ for (let i = 0; i < 20; i++) await tick();
1392
+ assert.ok(!seen.some(([n]) => n === 3), "#3 waits while #2 holds the one remote slot");
1393
+ assert.ok(live.has(2), "#2 keeps running");
1394
+ gates.get(2)();
1395
+ const rows = await done;
1396
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
1397
+ assert.deepEqual(seen.find(([n]) => n === 3)[1], [3]);
1398
+ });
1399
+ it("--set-max-remote / --set-placement write the batch's execution file; bad values and provider-less remote refused", async () => {
1400
+ const root = tmpDir("batch-live-exec-");
1401
+ const b = openBatch(root, "b1");
1402
+ b.writePlan({ argv: ["1"] });
1403
+ await quiet(() => orchestrateFromCli(["--set-max-remote", "b1", "3"], { batchRoot: root }));
1404
+ assert.deepEqual(b.readExecution(), { maxRemote: 3 });
1405
+ await quiet(() => orchestrateFromCli(["--set-placement", "b1", "local"], { batchRoot: root }));
1406
+ assert.deepEqual(b.readExecution(), { maxRemote: 3, mode: "local" });
1407
+ await assert.rejects(orchestrateFromCli(["--set-placement", "b1", "hybrid"], { batchRoot: root }), /no remote provider/);
1408
+ await assert.rejects(orchestrateFromCli(["--set-max-remote", "b1", "-1"], { batchRoot: root }), /non-negative integer/);
1409
+ await assert.rejects(orchestrateFromCli(["--set-placement", "b1", "cloud"], { batchRoot: root }), /placement must be one of/);
1410
+ await assert.rejects(orchestrateFromCli(["--set-max-remote", "nope", "1"], { batchRoot: root }), /no batch nope/);
1411
+ b.setExecution({ provider: true });
1412
+ await quiet(() => orchestrateFromCli(["--set-placement", "b1", "hybrid"], { batchRoot: root }));
1413
+ assert.equal(b.readExecution().mode, "hybrid");
1414
+ });
1415
+ });
@@ -268,15 +268,19 @@ const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does no
268
268
 
269
269
  const E2E_TERM = String.raw`(?:e2e|end-to-end|integration|playwright|cypress|live[- ]stack)`;
270
270
  const E2E_WORD_RE = new RegExp(String.raw`\b${E2E_TERM}\b`, "i");
271
- const MOCKED_TERM = String.raw`(?:mock\w*|stub\w*|fake\w*|unit|vitest|jest|component tests?|route tests?)`;
272
- const MOCKED_RE = new RegExp(String.raw`\b${MOCKED_TERM}`, "i");
271
+ const MOCKED_TERM = String.raw`(?:mock\w*|stub\w*|fake\w*|unit(?:[- ]?test\w*)?|(?:use[sd]?|using|via|by)\s+units|vitest|jest|component tests?|route tests?)`;
272
+ const MOCKED_RE = new RegExp(String.raw`\b${MOCKED_TERM}\b`, "i");
273
273
  // #5116: a negated mock ("do not mock", "no stubs") or a fake clock is not a substitute for the e2e check.
274
274
  const NEG_MOCK_RE = /\b(?:do(?:es)? not|don't|never|no|not|without)\s+(?:use\s+|any\s+)?(?:mock\w*|stub\w*|fake\w*)/gi;
275
+ // #5130: a negated weakener ("do not skip the e2e suite", "e2e tests are not replaced") keeps the e2e check.
276
+ const NEG_DROP_RE = /\b(?:do(?:es)? not|don't|never|not)\s+(?:be\s+|been\s+|being\s+)?(?:skip|drop|replac|swap|downgrad)\w*/gi;
275
277
  const FAKE_CLOCK_RE = /\bfake[- ](?:timers?|clocks?)\b/gi;
276
278
  // A weakener governs the e2e term only within a few words and the same clause (no . or ; between them).
277
- const GAP = String.raw`(?:[\s,]+\w+){0,3}?[\s,]+`;
278
- const DROP_BEFORE_RE = new RegExp(String.raw`\b(?:no|not|without|never|drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|swap\w*)${GAP}${E2E_TERM}\b`, "i");
279
- const DROP_AFTER_RE = new RegExp(String.raw`\b${E2E_TERM}${GAP}(?:replaced|dropped|skipped|swapped|downgraded)\b`, "i");
279
+ const GAP = String.raw`(?:[\s,]+\w+){0,5}?[\s,]+`;
280
+ // #5145: a bare negator ("no new fixtures needed for e2e") stops at a preposition; the verb weakeners keep the wide GAP.
281
+ const NEG_GAP = String.raw`(?:[\s,]+(?!(?:for|to|of|in|on|at|by|with|from|via)\b)\w+){0,5}?[\s,]+`;
282
+ const DROP_BEFORE_RE = new RegExp(String.raw`\b(?:(?:no|not|without|never)${NEG_GAP}|(?:drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|swap\w*)${GAP})${E2E_TERM}\b`, "i");
283
+ const DROP_AFTER_RE = new RegExp(String.raw`\b${E2E_TERM}${GAP}(?:replaced|dropped|skipped|swapped|downgraded|unnecessary|(?:not|no longer)\s+(?:needed|required|necessary|run\w*|used|executed))\b`, "i");
280
284
  const INSTEAD_OF_RE = new RegExp(String.raw`\b${MOCKED_TERM}[^.;]{0,60}?\b(?:instead of|rather than|in lieu of)\b${GAP}${E2E_TERM}\b`, "i");
281
285
  const PROBE_RE = /forge-probe|acceptance[- ]probe/i;
282
286
  const NEG_PROBE_RE = /\b(?:no|not|without|never)\b[^.;]{0,30}(?:forge-probe|acceptance[- ]probe)/i;
@@ -292,7 +296,7 @@ export function weakenedE2eDecisions(c) {
292
296
  return decisions.filter((k) => {
293
297
  const choice = String(k?.choice ?? "");
294
298
  if (!E2E_WORD_RE.test(choice)) return false;
295
- const mocked = choice.replace(NEG_MOCK_RE, " ").replace(FAKE_CLOCK_RE, " ");
299
+ const mocked = choice.replace(NEG_MOCK_RE, " ").replace(FAKE_CLOCK_RE, " ").replace(NEG_DROP_RE, " ");
296
300
  if (!MOCKED_RE.test(mocked) || !(DROP_BEFORE_RE.test(mocked) || DROP_AFTER_RE.test(mocked) || INSTEAD_OF_RE.test(mocked))) return false;
297
301
  const own = `${choice} ${k?.justification ?? ""}`;
298
302
  return !((PROBE_RE.test(own) && !NEG_PROBE_RE.test(own)) || /forge-probe/i.test(elsewhere(k?.id)));
@@ -1281,12 +1285,37 @@ export function followupItems(reqs, { now = Date.now() } = {}) {
1281
1285
  });
1282
1286
  }
1283
1287
 
1284
- /** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */
1288
+ /**
1289
+ * #5141: the FORGE:FOLLOWUP_PENDING note for follow-ups the lane could not file before or after the merge. Each row keeps the
1290
+ * requirement text and the `FORGE:DEFERRED_FROM: #<n>` marker the follow-up script looks up, so a later pass files it once.
1291
+ */
1292
+ export function renderPendingFollowups(rows, pr) {
1293
+ const flat = (s) => String(s ?? "").replace(/[\t\r\n]+/g, " ").trim();
1294
+ return [`## Follow-up pending`, `Merged by PR #${pr}, but the tracking issue could not be filed, so this issue stays open until it is. Still to file (idempotent by the marker):`, "",
1295
+ ...rows.map((r) => `- ${flat(r.id)} (${flat(r.disposition)}): ${flat(r.quote)} | why: ${flat(r.why)} | \`${r.marker}\``)].join("\n");
1296
+ }
1297
+
1298
+ /**
1299
+ * #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}; unmatched items omitted. Both sides get every
1300
+ * identity rule of scripts/file-deferred-followups.sh: trimmed (#4388), backticks stripped (#4984) and repeated leading
1301
+ * `R<n>: R<m>:` prefixes collapsed to the first (#5141: a quote starting with its own id looped on followup.unavailable).
1302
+ * An item still unmatched takes the one echoed row whose identity starts with `<item.id>:`; never when several do.
1303
+ */
1285
1304
  export function followupMap(out, items) {
1305
+ const norm = (k) => {
1306
+ let s = String(k).replace(/`/g, "").trim(), m;
1307
+ while ((m = /^(R\d+:)\s*R\d+:\s*(.*)$/s.exec(s))) s = `${m[1]} ${m[2]}`;
1308
+ return s;
1309
+ };
1286
1310
  const echoed = new Map();
1287
- const norm = (k) => String(k).replace(/`/g, "").trim(); // #4984: the script strips backticks from the label it echoes
1288
1311
  for (const l of String(out ?? "").split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) echoed.set(norm(k), Number(n)); }
1289
- return Object.fromEntries(items.filter((i) => echoed.has(norm(i.key))).map((i) => [i.key, echoed.get(norm(i.key))]));
1312
+ const byId = (id) => { const hits = [...echoed].filter(([k]) => k.startsWith(`${id}:`)); return hits.length === 1 ? hits[0][1] : undefined; };
1313
+ const res = {};
1314
+ for (const i of items) {
1315
+ const n = echoed.get(norm(i.key)) ?? (i.id ? byId(String(i.id)) : undefined);
1316
+ if (n !== undefined) res[i.key] = n;
1317
+ }
1318
+ return res;
1290
1319
  }
1291
1320
 
1292
1321
  /**
@@ -3,7 +3,7 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { describe, it } from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
6
+ import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, renderPendingFollowups, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
7
7
 
8
8
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
9
9
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -903,6 +903,22 @@ describe("#4594 amend-requirement", () => {
903
903
  it("the reviewer prompt tells it to compare amended_from with the amendment (#4996)", () => {
904
904
  assert.match(readFileSync(new URL("./prompts.mjs", import.meta.url), "utf8"), /amended_from\.quote and amended_from\.why with amendment\.text/);
905
905
  });
906
+ it("followupMap matches a key whose quote repeats its own R<n>: prefix, as the script collapses it (#5141)", () => {
907
+ const items = followupItems([{ id: "R6", disposition: "defer", bucket: "host", quote: "R6: Fleet: x in (browser, replay) over 48h.", why: "w" }, { id: "R1", disposition: "defer", bucket: "new-issue", quote: "R3: Named accounts", why: "w" }]);
908
+ assert.deepEqual(followupMap("R6: Fleet: x in (browser, replay) over 48h.\t901\nR1: Named accounts\t902\n", items), { [items[0].key]: 901, [items[1].key]: 902 });
909
+ });
910
+
911
+ it("renderPendingFollowups lists each unfiled follow-up with its text and the FORGE:DEFERRED_FROM marker (#5141)", () => {
912
+ const md = renderPendingFollowups([{ id: "R2", disposition: "defer", quote: "a\tb\nc", why: "w", marker: "FORGE:DEFERRED_FROM: #7" }], 12);
913
+ assert.match(md, /^## Follow-up pending\nMerged by PR #12/);
914
+ assert.match(md, /\n- R2 \(defer\): a b c \| why: w \| `FORGE:DEFERRED_FROM: #7`$/);
915
+ });
916
+ it("followupMap falls back to the single echoed row carrying the item id, never to an ambiguous one (#5141)", () => {
917
+ assert.deepEqual(followupMap("R2: drifted text\t7\n", [{ id: "R2", key: "R2: original text" }]), { "R2: original text": 7 });
918
+ assert.deepEqual(followupMap("R2: a\t7\nR2: b\t8\n", [{ id: "R2", key: "R2: c" }]), {});
919
+ assert.deepEqual(followupMap("R20: a\t7\n", [{ id: "R2", key: "R2: c" }]), {});
920
+ });
921
+
906
922
  it("followupMap matches an item key whose backticks the script stripped (#4984)", () => {
907
923
  assert.deepEqual(followupMap("R2: wire pd.py in\t20\n", [{ key: "R2: wire `pd.py` in" }]), { "R2: wire `pd.py` in": 20 });
908
924
  });
@@ -1335,6 +1351,22 @@ describe("#5087 a plan decision may not weaken an e2e/integration criterion to m
1335
1351
  assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
1336
1352
  }
1337
1353
  });
1354
+ it("#5130 double negatives and word prefixes are not weakenings; trailing negations and distant weakeners are", () => {
1355
+ for (const choice of ["Do not skip the e2e suite; mock the mailer", "E2E tests are not replaced by mocks", "Skip the e2e run for billing units", "No integration with United Airlines API", "Skip e2e; ship the Unity build"]) {
1356
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), [], choice);
1357
+ }
1358
+ for (const choice of ["Mock everything; the e2e suite is not needed", "e2e is not run, jest mocks cover it", "e2e tests are not required, rely on mocked unit tests", "e2e suite no longer runs; mocks used", "Skip the slow flaky nightly e2e and use mocks", "Use stubbed unit tests, e2e not needed"]) {
1359
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
1360
+ }
1361
+ });
1362
+ it("#5145 unittest/unittests and substitute units are mocks; a negator across a preposition is not a weakener", () => {
1363
+ for (const choice of ["Replace e2e with unittest cases", "Skip e2e, covered by unittests", "e2e dropped, use units", "Use unittest cases instead of the e2e check"]) {
1364
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
1365
+ }
1366
+ for (const choice of ["Keep playwright e2e coverage; add unit tests (no new fixtures needed for e2e)"]) {
1367
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), [], choice);
1368
+ }
1369
+ });
1338
1370
  it("leaves unrelated decisions and contracts without decisions alone", () => {
1339
1371
  assert.deepEqual(weakenedE2eDecisions(C), []);
1340
1372
  assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Run the Playwright e2e suite against the live stack", source: "doc" }, { id: "K3", choice: "Use unit tests with mocks for the parser", source: "spec" }] }), []);
@@ -94,11 +94,22 @@ export const ownedModelEnv = (subagentModel = null) => {
94
94
  const m = sanitizeCliModel(subagentModel);
95
95
  return { ...(m ? { CLAUDE_CODE_SUBAGENT_MODEL: m } : {}), CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "0" };
96
96
  };
97
- /** The one `--settings` of a lean session: the owned model env, plus the re-read hooks for builder sessions (#4456). */
98
- export const leanSettingsFlags = ({ subagentModel = null, reread = false } = {}) =>
99
- ["--settings", JSON.stringify({ env: ownedModelEnv(subagentModel), ...(reread ? { hooks: rereadHooks() } : {}) })];
97
+ /** #5118: ForgeDock's pre-tool-use guard (bin/hooks/pre-tool-use.mjs, rule 6 blocks Claude attribution trailers). */
98
+ export const PRE_TOOL_USE_HOOK = fileURLToPath(new URL("../hooks/pre-tool-use.mjs", import.meta.url));
99
+ /** #5118: a lane container has no operator settings.json, so it carries neither the guard nor the attribution pins. */
100
+ export const inSandbox = (env = process.env) => env.IS_SANDBOX === "1" || Boolean(env.CS_SANDBOX_ID);
101
+ /**
102
+ * The one `--settings` of a lean session: the owned model env, the attribution pins (#5118: Claude Code's default
103
+ * Co-Authored-By trailer off, whatever the config dir holds), the re-read hooks for builder sessions (#4456), and in a
104
+ * sandbox the pre-tool-use guard on Bash (#5118: second line of defence; local lanes get it from operator settings).
105
+ */
106
+ export const leanSettingsFlags = ({ subagentModel = null, reread = false, guard = false } = {}) => {
107
+ const hooks = reread ? rereadHooks() : {};
108
+ if (guard) hooks.PreToolUse = [...(hooks.PreToolUse ?? []), { matcher: "Bash", hooks: [{ type: "command", command: `${JSON.stringify(process.execPath)} ${JSON.stringify(PRE_TOOL_USE_HOOK)}` }] }];
109
+ return ["--settings", JSON.stringify({ env: ownedModelEnv(subagentModel), includeCoAuthoredBy: false, attribution: { commit: "", pr: "" }, ...(Object.keys(hooks).length ? { hooks } : {}) })];
110
+ };
100
111
 
101
- export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable, subagentModel = null } = {}) {
112
+ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable, subagentModel = null, sandboxed = inSandbox() } = {}) {
102
113
  return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
103
114
  const bin = resolveClaudeCliBinary(cwd) || "claude";
104
115
  const caps = getClaudeCliCapabilities(cwd);
@@ -106,7 +117,7 @@ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRun
106
117
  const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])];
107
118
  // #4843 owned models, in extraArgs so FORGEDOCK_FULL_SESSION=1 (which drops sessionFlags) keeps them (#4848 BUG-1);
108
119
  // #4456 re-read hooks: builder sessions only, and like the other session flags not in a full session
109
- const settings = leanSettingsFlags({ subagentModel, reread: rereadNudge && !readOnly && !noTools && process.env.FORGEDOCK_FULL_SESSION !== "1" });
120
+ const settings = leanSettingsFlags({ subagentModel, reread: rereadNudge && !readOnly && !noTools && process.env.FORGEDOCK_FULL_SESSION !== "1", guard: sandboxed });
110
121
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
111
122
  args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: [...(resume ? ["--resume", resume] : []), ...settings],
112
123
  logger: { log() {} },