forgedock 1.17.0 → 1.18.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 (60) hide show
  1. package/bin/engine/outcome-codes.mjs +24 -2
  2. package/bin/engine/phases.mjs +153 -102
  3. package/bin/engine/runlog.mjs +7 -0
  4. package/bin/engine-cli.mjs +5 -0
  5. package/bin/engine.mjs +84 -10
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/hooks/pre-tool-use.mjs +65 -32
  8. package/bin/runner.mjs +63 -4
  9. package/bin/stats/github-source.mjs +89 -18
  10. package/bin/stats/stats.test.mjs +89 -1
  11. package/commands/cleanup.md +10 -3
  12. package/commands/deploy-pr.md +64 -0
  13. package/commands/fix-ci.md +20 -6
  14. package/commands/milestone.md +34 -17
  15. package/commands/orchestrate/phase-4-execution.md +11 -4
  16. package/commands/review-pr-staging.md +142 -519
  17. package/commands/review-pr.md +490 -3128
  18. package/commands/test-gate.md +25 -10
  19. package/commands/work-on/build/architect.md +13 -1
  20. package/commands/work-on/build/implement.md +2 -1
  21. package/commands/work-on/build/validate.md +2 -0
  22. package/commands/work-on/build.md +107 -86
  23. package/commands/work-on/close.md +50 -15
  24. package/commands/work-on/decompose.md +72 -34
  25. package/commands/work-on/investigate.md +9 -12
  26. package/commands/work-on/remediate.md +27 -13
  27. package/commands/work-on/review.md +5 -3
  28. package/commands/work-on.md +1 -2
  29. package/docs/CONFIG.md +44 -25
  30. package/docs/OUTCOME-CODES.md +7 -2
  31. package/docs/RELEASE-NOTES.md +12 -0
  32. package/docs/STATS.md +2 -1
  33. package/docs/TELEMETRY.md +2 -2
  34. package/docs/WORK-ON-RUNTIME.md +6 -0
  35. package/docs/spec/forge-protocol-v1.md +2 -2
  36. package/package.json +1 -1
  37. package/packages/protocol/src/phases.js +5 -5
  38. package/scripts/cap-findings.sh +1 -1
  39. package/scripts/cap-findings.test.sh +1 -1
  40. package/scripts/check-spec-bash.sh +5 -1
  41. package/scripts/check-spec-bash.test.sh +20 -0
  42. package/scripts/close-parent-c3.test.sh +69 -0
  43. package/scripts/diff-size.sh +93 -4
  44. package/scripts/diff-size.test.sh +59 -2
  45. package/scripts/forge-root.test.sh +13 -11
  46. package/scripts/publish-staging-sync.test.sh +48 -0
  47. package/scripts/resolve-deploy-gate.sh +32 -6
  48. package/scripts/resolve-deploy-gate.test.sh +42 -1
  49. package/scripts/review-finalize.sh +1427 -0
  50. package/scripts/review-finalize.test.sh +833 -0
  51. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  52. package/scripts/review-pr-domain-map.test.sh +18 -13
  53. package/scripts/review-pr-gate2.test.sh +9 -65
  54. package/scripts/review-pr-staging-scope.test.sh +14 -98
  55. package/scripts/review-prepare.sh +48 -12
  56. package/scripts/review-prepare.test.sh +19 -1
  57. package/scripts/review-roster.sh +16 -2
  58. package/scripts/review-roster.test.sh +22 -0
  59. package/scripts/trusted-comments.test.sh +9 -2
  60. package/scripts/wait-ci-green.test.sh +3 -3
package/bin/engine.mjs CHANGED
@@ -4,15 +4,15 @@
4
4
  * GitHub state (phase.detectOutcome). All effects are injected → fully testable.
5
5
  */
6
6
  import { fileURLToPath } from "node:url";
7
- import { readFileSync } from "node:fs";
7
+ import { readFileSync, rmSync } from "node:fs";
8
8
  import { createHash, scryptSync } from "node:crypto";
9
9
  import { hostname as osHostname, homedir } from "node:os";
10
10
  import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
11
- import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber } from "./engine/phases.mjs";
11
+ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency } from "./engine/phases.mjs";
12
12
  import { reconcileState } from "./engine/reconcile.mjs";
13
13
  import { makeProjector } from "./engine/projector.mjs";
14
- import { VALID_BACKENDS } from "./runner.mjs";
15
- import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason } from "./engine/outcome-codes.mjs";
14
+ import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
15
+ import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason, isDiskQuotaError, isDiskQuotaText } from "./engine/outcome-codes.mjs";
16
16
 
17
17
  // Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
18
18
  // diagnostics without duplicating the retry budget constant.
@@ -390,6 +390,27 @@ export async function runIssue(opts) {
390
390
  remote = cur;
391
391
  ({ state, action } = reconcileState(local, remote));
392
392
  }
393
+ // forge#3932: `Depends on` admission. A trusted `**Depends on**: #N` line whose dependency is still OPEN
394
+ // refuses the run BEFORE any state/lease write (mirrors deferredResult), so building on top of an
395
+ // unmerged dependency cannot conflict with it. Checked on every start; a closed dependency, no
396
+ // declaration or an untrusted body proceeds. An unreadable body/dependency fails closed.
397
+ {
398
+ let dep = null;
399
+ try { dep = await openDependency(issue, io); } catch (e) {
400
+ dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` };
401
+ }
402
+ if (dep?.unreadable) {
403
+ return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
404
+ }
405
+ if (dep?.open?.length) {
406
+ return {
407
+ terminalReason: "dependency-open",
408
+ code: OUTCOME_CODES.DEPENDENCY_OPEN,
409
+ dependencies: dep.open,
410
+ detail: `issue ${issue} **Depends on** ${dep.open.map((n) => `#${n}`).join(", ")} which is still open; re-run once it is closed`,
411
+ };
412
+ }
413
+ }
393
414
  // forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
394
415
  let repoPromise = null;
395
416
  const resolveRepo = () => (repoPromise ??= (async () => {
@@ -412,6 +433,20 @@ export async function runIssue(opts) {
412
433
  // and regress committed/issue/v (C2).
413
434
  rewriteLog(dir, issue, eventsFromIndex(state, await runMeta()));
414
435
  state = deriveState(readLog(dir, issue));
436
+ // forge#3931: the index does not carry the cycle, so a hydrated log always starts at cycle 0. When a
437
+ // trusted FORGE:CYCLE exists, cycle-scoped progress (or a needs-human/phase-complete/engine-error terminal)
438
+ // belongs to an older cycle ONLY when provably so (cycleResetProvable: terminal index, issue OPEN, no needs-human/remediating label, no open PR): reset via the existing RUN_NEXT_CYCLE event so build runs again instead of
439
+ // terminating. An unreadable trail resets nothing (fail closed). merged/decomposed/invalid terminals stay.
440
+ if (state.committed.some((p) => ["build", "review", "remediate", "close"].includes(p)) &&
441
+ state.terminal && ["needs-human", "phase-complete", "engine-error"].includes(state.terminalReason) &&
442
+ await cycleResetProvable(state, io)) {
443
+ let cyc = 0;
444
+ try { cyc = await newestCycle(issue, io); } catch { cyc = 0; }
445
+ if (cyc > 0) {
446
+ appendEvent(dir, issue, { event: "RUN_NEXT_CYCLE", issue });
447
+ state = deriveState(readLog(dir, issue));
448
+ }
449
+ }
415
450
  }
416
451
 
417
452
  // forge#3511: explicit `--retry` reopens a transient `engine-error` terminal
@@ -424,12 +459,19 @@ export async function runIssue(opts) {
424
459
  // forge#3562: `phase-complete` is also retryable, but as a NEW cycle
425
460
  // (RUN_NEXT_CYCLE clears committed) rather than a reopen of the same one.
426
461
  let reopened = false;
462
+ let reopenUncommit = null;
427
463
  if (retry && state?.terminal) {
428
- if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
464
+ // forge#3862 / forge#3932: a `decomposed` terminal is reopened at build when trusted GitHub state shows
465
+ // the decomposition revoked (a trusted FORGE:DECOMPOSED:REVOKED newer than FORGE:DECOMPOSED); the
466
+ // revocation alone suffices now that the size gate is gone. Anything else keeps the terminal; an
467
+ // unreadable comment trail fails closed.
468
+ if (state.terminalReason === "decomposed" && await decompositionRevoked(issue, io)) {
469
+ reopenUncommit = ["build", "decompose"];
470
+ } else if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
429
471
  state.terminalReason !== "needs-human") {
430
472
  return {
431
473
  terminalReason: "not-retryable",
432
- detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run`,
474
+ detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run (or a decomposed run whose FORGE:DECOMPOSED was revoked by a trusted FORGE:DECOMPOSED:REVOKED)`,
433
475
  };
434
476
  }
435
477
  // forge#3571: a human clearing `needs-human` is the hand-back signal. Reopen
@@ -457,6 +499,13 @@ export async function runIssue(opts) {
457
499
  };
458
500
  }
459
501
  }
502
+ // forge#3931: a remediate that ended needs-human was recorded against an older PR head. When the
503
+ // live head differs from the head in its trusted trail, un-commit remediate AND review so review
504
+ // re-runs at the new head instead of the old RE-ESCALATED result replaying. Unreadable => no change.
505
+ if (state.terminalReason === "needs-human" && !reopenUncommit &&
506
+ state.committed[state.committed.length - 1] === "remediate" && await remediationHeadStale(state, io)) {
507
+ reopenUncommit = ["remediate", "review"];
508
+ }
460
509
  // forge#3562 (SEC-3): a new cycle discards `committed`, so check the live
461
510
  // issue first. A CLOSED issue has nothing left to run; an unreadable
462
511
  // snapshot fails closed rather than resetting a possibly-finished run.
@@ -486,6 +535,7 @@ export async function runIssue(opts) {
486
535
  appendEvent(dir, issue, {
487
536
  event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
488
537
  issue,
538
+ ...(reopenUncommit ? { uncommit: reopenUncommit } : {}),
489
539
  });
490
540
  state = deriveState(readLog(dir, issue));
491
541
  reopened = true;
@@ -779,7 +829,7 @@ export async function runIssue(opts) {
779
829
  // "needs-human" — this is the engine/tool breaking, not a genuine
780
830
  // human-judgment block (see #2244/#2261). Any other thrown error is
781
831
  // a true unexpected crash and keeps propagating unchanged.
782
- if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS") {
832
+ if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS" || isDiskQuotaError(e)) {
783
833
  // forge#2241: when the runner attached a session-limit reset time
784
834
  // (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
785
835
  // for a genuine session-limit CLI_BACKEND_FAILED — never
@@ -789,7 +839,7 @@ export async function runIssue(opts) {
789
839
  const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
790
840
  const detail = e.code === "CLI_MAX_TURNS"
791
841
  ? `phase ${phase.id}: CLI_MAX_TURNS - max-turns exhausted${e.numTurns != null ? ` after ${e.numTurns} turns` : ""}: ${e.message}`
792
- : `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
842
+ : `phase ${phase.id}: ${e.code ?? "DISK_QUOTA"} - ${e.message}${resetSuffix}`;
793
843
  // forge#2240 (review finding): this fail-fast path previously left
794
844
  // phase_exit unreported — a caller tailing progress output would see
795
845
  // "→ phase X started" and then nothing, dangling exactly on the
@@ -845,6 +895,9 @@ export async function runIssue(opts) {
845
895
  await emitOutcome({ event: "PHASE_DEFERRED", phase: phase.id, code: normalizeCode(outcome.code), reason: outcome.detail });
846
896
  return { terminalReason: "deferred", code: normalizeCode(outcome.code), detail: outcome.detail };
847
897
  }
898
+ // forge#3572: a phase that returned blocked because the disk/quota ran out is a retryable infra failure.
899
+ if (isDiskQuotaText(outcome.detail))
900
+ return await terminate(state, "engine-error", outcome.detail, OUTCOME_CODES.INFRA_DISK_QUOTA);
848
901
  if (!isRemediationHandoff)
849
902
  return await terminate(state, outcome.reason || "needs-human", outcome.detail, outcome.code);
850
903
  } else {
@@ -910,8 +963,8 @@ export async function runIssue(opts) {
910
963
  // is seen), phase.id is "decompose" — not "investigate" — so this
911
964
  // exemption does not apply and the normal terminate() path below fires,
912
965
  // ending the run for real.
913
- // build also hands off: the B5.5 size gate (NEEDS_DECOMPOSE) reports "decomposed".
914
- const isDecomposeHandoff = (phase.id === "investigate" || phase.id === "build") && terminalReason === "decomposed";
966
+ // forge#3862: only investigate hands off (decomposition is a planning decision); build never reports "decomposed".
967
+ const isDecomposeHandoff = phase.id === "investigate" && terminalReason === "decomposed";
915
968
  // forge#3624: remediate AUTO-LANDED whose issue is not yet CLOSED+workflow:merged hands off
916
969
  // to `close` instead of ending the run (close is idempotent). Exempts BOTH terminate paths.
917
970
  const isCloseHandoff = phase.id === "remediate" && outcome.status === "committed" &&
@@ -980,10 +1033,24 @@ export async function runIssue(opts) {
980
1033
  try { await projector.setLabel(issue, "needs-human"); } catch { /* best-effort */ }
981
1034
  }
982
1035
  }
1036
+ // forge#3572: drop the per-run scratch dir (venvs, session data) on a terminal outcome. Kept for
1037
+ // `engine-error` so `--retry` resumes with it, and when another live engine owns the issue.
1038
+ if (reason !== "engine-error" && foreign === undefined) cleanupRunTmpdir(issue);
983
1039
  return { terminalReason: reason, code: outcomeCode, detail };
984
1040
  }
985
1041
  }
986
1042
 
1043
+ /** Best-effort removal of the issue's per-run scratch dir. Confined to the resolved tmp root; never throws. */
1044
+ export function cleanupRunTmpdir(issue, { cwd = process.cwd(), env = process.env, rm = rmSync } = {}) {
1045
+ try {
1046
+ const root = resolveRunTmpRoot(cwd, env);
1047
+ const dir = runTmpDir(root, issue);
1048
+ if (!dir || dir === root) return false;
1049
+ rm(dir, { recursive: true, force: true });
1050
+ return true;
1051
+ } catch { return false; }
1052
+ }
1053
+
987
1054
  async function runPhaseWithRetry(phase, state, ctx) {
988
1055
  const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
989
1056
  // forge#3499: build the phase's full argument set ONCE, up front. Every
@@ -1080,6 +1147,13 @@ async function runPhaseWithRetry(phase, state, ctx) {
1080
1147
  });
1081
1148
  throw e;
1082
1149
  }
1150
+ // forge#3572: a full disk / exhausted quota (EDQUOT/ENOSPC) is infra, not content: retrying burns the budget on the same
1151
+ // failure, so rethrow fail-fast and let runIssue end it as a retryable `engine-error` (never needs-human).
1152
+ if (isDiskQuotaError(e)) {
1153
+ await recordAttempt(attempt, null, t0, null);
1154
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
1155
+ throw e;
1156
+ }
1083
1157
  if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
1084
1158
  // forge#2377: no `usage` field here — the runner threw, so no result
1085
1159
  // (and therefore no usage data) was ever produced for this attempt.
package/bin/forgedock.mjs CHANGED
@@ -3972,7 +3972,7 @@ switch (command) {
3972
3972
  // an uncaught throw, which already set exitCode = 1 below. Now that
3973
3973
  // runIssue() resolves cleanly with a terminalReason instead of throwing,
3974
3974
  // preserve that same non-zero exit code for this reason too.
3975
- if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable")) exitCode = 1;
3975
+ if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable" || result.terminalReason === "dependency-open" || result.terminalReason === "dependency-unreadable")) exitCode = 1;
3976
3976
  // forge#3574: a lease-held deferral did NOT run the issue; exit 75
3977
3977
  // (EX_TEMPFAIL) so orchestrators neither count it as done nor as a failure.
3978
3978
  if (result && result.terminalReason === "deferred") exitCode = 75;
@@ -116,14 +116,12 @@ async function getInvariants() {
116
116
  * starting Claude Code (operator-set only — same process.env semantics as
117
117
  * the gist guard above).
118
118
  *
119
- * 9. Main-checkout mutation guard (issue #3650) — ADVISORY by default
120
- * Enforced (exit 2) only when FORGE_ENFORCE_MAIN_CHECKOUT_GIT=1. Otherwise it
121
- * writes a warning to stderr and allows the call: shipped specs (fix-ci,
122
- * milestone, test-gate, cleanup, rollback, recover-orphans, spec-doctor,
123
- * orchestrate phase-4) still run these verbs in the main checkout on purpose,
124
- * and a denial blocks the whole Bash call (their `|| true` fallbacks never run).
125
- * Enforcement becomes the default once those specs use worktrees (#3873).
126
- * When enforced it denies `git checkout|switch|stash|merge`, `git reset --hard`, and
119
+ * 9. Main-checkout mutation guard (issue #3650) — enforced by default
120
+ * Enforced (exit 2) unless FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0, which downgrades it
121
+ * to a stderr warning (#3873). Ref-only verbs are narrowed: `git branch -d/-D/-m/-f`
122
+ * is denied only for the branch checked out in the target dir, and
123
+ * `git restore --staged` (index only) is allowed.
124
+ * It denies `git checkout|switch|stash|merge`, `git reset --hard`, and
127
125
  * `gh pr checkout` when the shell's cwd is the main (non-worktree) checkout
128
126
  * (`git rev-parse --git-dir` equals `--git-common-dir`). Pipeline workers and
129
127
  * reviewers share that tree with the operator; mutating it races with them.
@@ -589,12 +587,12 @@ async function main() {
589
587
  // forge.yaml), and `git -C <main> ...` from there is the case this rule exists for.
590
588
  const mainCheckoutViolation = checkMainCheckoutMutation(command);
591
589
  if (mainCheckoutViolation) {
592
- if (process.env.FORGE_ENFORCE_MAIN_CHECKOUT_GIT === "1") {
590
+ if (process.env.FORGE_ENFORCE_MAIN_CHECKOUT_GIT !== "0") {
593
591
  process.stderr.write(mainCheckoutViolation);
594
592
  process.exit(2);
595
593
  return;
596
594
  }
597
- // Advisory (default): several shipped specs still mutate the main checkout on purpose (#3873).
595
+ // Advisory only when the operator opts out with FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0 (#3873).
598
596
  process.stderr.write("WARNING (advisory, not blocked): " + mainCheckoutViolation.replace(/^BLOCKED: /, ""));
599
597
  }
600
598
 
@@ -1304,9 +1302,11 @@ function mutatingGitCommand(tokens, depth = 0) {
1304
1302
  const k = w === "eval" ? i + 1 : tokens.findIndex((t, n) => n > i && /^-[a-z]*c$/.test(t)) + 1;
1305
1303
  if (k <= i || k >= tokens.length) return null;
1306
1304
  const inner = w === "eval" ? tokens.slice(k).join(" ") : tokens[k];
1307
- let hit = null;
1308
- for (const seg of extractCommandSegments(inner)) hit = hit || mutatingGitCommand(seg, depth + 1);
1309
- return hit;
1305
+ // Every hit of the payload, not just the first (#3920): an allowed ref-only segment (`git branch -d other`)
1306
+ // must not mask a later mutating one. The caller decides per hit.
1307
+ const hits = [];
1308
+ for (const seg of extractCommandSegments(inner)) hits.push(...mutatingGitHits(seg, depth + 1));
1309
+ return hits.length ? { name: hits[0].name, dir: hits[0].dir, hits } : null;
1310
1310
  }
1311
1311
  break;
1312
1312
  }
@@ -1338,12 +1338,27 @@ function mutatingGitCommand(tokens, depth = 0) {
1338
1338
  return opts.some((t) => t === "-b") ? null : hit("git checkout");
1339
1339
  case "switch":
1340
1340
  return opts.some((t) => t === "-c" || t === "--create") ? null : hit("git switch");
1341
- case "merge": case "pull": case "rebase": case "cherry-pick": case "revert": case "am": case "restore":
1341
+ case "merge": case "pull": case "rebase": case "cherry-pick": case "revert": case "am":
1342
1342
  return hit(`git ${sub}`);
1343
+ case "restore": {
1344
+ // `restore --staged` only rewrites the index (not the working tree); anything else (or --worktree/-W) does.
1345
+ const flag = (long, ch) => opts.some((t) => t === long || (/^-[a-zA-Z]+$/.test(t) && t.includes(ch)));
1346
+ return flag("--worktree", "W") || !flag("--staged", "S") ? hit("git restore") : null;
1347
+ }
1343
1348
  case "clean":
1344
1349
  return opts.some((t) => t === "-n" || t === "--dry-run") ? null : hit("git clean");
1345
- case "branch":
1346
- return opts.some((t) => /^(?:-[a-zA-Z]*[fFmMdD][a-zA-Z]*|--force|--move|--delete)$/.test(t)) ? hit("git branch") : null;
1350
+ case "branch": {
1351
+ // Ref-only verbs: a hit only when the delete/move/force target is the branch checked out at the
1352
+ // target dir (resolved by the caller). `-m <new>` with one positional renames the current branch.
1353
+ if (!opts.some((t) => /^(?:-[a-zA-Z]*[fFmMdD][a-zA-Z]*|--force|--move|--delete)$/.test(t))) return null;
1354
+ // Positionals after `--` count too (`git branch -m -- renamed` renames the checked-out branch, #3920).
1355
+ const pos = [...opts.filter((t) => !t.startsWith("-")), ...(dd >= 0 ? rest.slice(dd + 1) : [])];
1356
+ if (!pos.length) return null;
1357
+ const moves = opts.some((t) => t === "--move" || /^-[a-zA-Z]*[mM][a-zA-Z]*$/.test(t));
1358
+ const deletes = opts.some((t) => t === "--delete" || /^-[a-zA-Z]*[dD][a-zA-Z]*$/.test(t));
1359
+ const branches = moves && !deletes ? (pos.length === 1 ? ["@current"] : [pos[0]]) : deletes ? pos : [pos[0]];
1360
+ return { name: "git branch", dir, branches };
1361
+ }
1347
1362
  case "reset":
1348
1363
  return opts.some((t) => t === "--hard" || t === "--merge" || t === "--keep") ? hit("git reset") : null;
1349
1364
  case "stash": {
@@ -1355,6 +1370,18 @@ function mutatingGitCommand(tokens, depth = 0) {
1355
1370
  }
1356
1371
  }
1357
1372
 
1373
+ /**
1374
+ * All main-checkout mutation hits in one command segment, flattening `bash -c` / `eval` payloads (#3920).
1375
+ * @param {string[]} tokens
1376
+ * @param {number} [depth]
1377
+ * @returns {Array<{name: string, dir: string|null, branches?: string[]}>}
1378
+ */
1379
+ function mutatingGitHits(tokens, depth = 0) {
1380
+ const h = mutatingGitCommand(tokens, depth);
1381
+ if (!h) return [];
1382
+ return h.hits ? h.hits : [h];
1383
+ }
1384
+
1358
1385
  /**
1359
1386
  * Is `cwd` the main (non-linked-worktree) checkout of a git repo?
1360
1387
  * Fails closed-to-allow: any error or non-git dir returns false.
@@ -1386,9 +1413,8 @@ function checkMainCheckoutMutation(command) {
1386
1413
  if (process.env.FORGE_ALLOW_MAIN_CHECKOUT_GIT === "1") return null;
1387
1414
  if (!/\b(?:git|gh)\b/.test(command)) return null;
1388
1415
  try {
1389
- let offender = null;
1390
1416
  let baseDir = process.cwd();
1391
- let targetDir = baseDir;
1417
+ // Decide per segment: an allowed ref-only verb (`git branch -d other`) must not mask a later mutating segment.
1392
1418
  for (const tokens of extractCommandSegments(command)) {
1393
1419
  const head = tokens.slice();
1394
1420
  while (head.length && (/^[({]+$/.test(head[0]) || ["then", "do", "else", "!"].includes(head[0]))) head.shift();
@@ -1400,21 +1426,28 @@ function checkMainCheckoutMutation(command) {
1400
1426
  baseDir = resolvePath(baseDir, t);
1401
1427
  continue;
1402
1428
  }
1403
- const hit = mutatingGitCommand(tokens);
1404
- if (hit) { offender = hit.name; targetDir = hit.dir === null ? baseDir : resolvePath(baseDir, hit.dir); break; }
1429
+ for (const hit of mutatingGitHits(tokens)) {
1430
+ const targetDir = hit.dir === null ? baseDir : resolvePath(baseDir, hit.dir);
1431
+ if (!isMainCheckout(targetDir)) continue;
1432
+ if (hit.branches) {
1433
+ // Ref-only verb: only the checked-out branch matters; unresolvable (detached HEAD, error) = allow.
1434
+ const cur = spawnSync("git", ["-C", targetDir, "branch", "--show-current"], { encoding: "utf-8", timeout: 5000 });
1435
+ const current = cur.status === 0 ? String(cur.stdout).trim() : "";
1436
+ if (!current || !hit.branches.some((b) => b === "@current" || b === current)) continue;
1437
+ }
1438
+ // Hook is global: only protect ForgeDock-managed main checkouts (issue #1591 scoping), never unrelated repos.
1439
+ if (!existsSync(join(targetDir, "forge.yaml")) && !existsSync(join(targetDir, ".forgedock"))) continue;
1440
+ return (
1441
+ `BLOCKED: \`${hit.name}\` would mutate the main checkout (${targetDir}).\n` +
1442
+ "The main checkout is shared with the operator and other agents. Work in a git worktree " +
1443
+ "(`git worktree list`, `git worktree add`) and use `git -C <worktree> ...`, or read code with " +
1444
+ "`git show <sha>:<path>`, `gh pr diff`, or `gh api`.\n" +
1445
+ "Operator override: set FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the shell before starting Claude Code " +
1446
+ "(set FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0 to make this advisory only).\n"
1447
+ );
1448
+ }
1405
1449
  }
1406
- if (!offender) return null;
1407
- if (!isMainCheckout(targetDir)) return null;
1408
- // Hook is global: only protect ForgeDock-managed main checkouts (issue #1591 scoping), never unrelated repos.
1409
- if (!existsSync(join(targetDir, "forge.yaml")) && !existsSync(join(targetDir, ".forgedock"))) return null;
1410
- return (
1411
- `BLOCKED: \`${offender}\` would mutate the main checkout (${targetDir}).\n` +
1412
- "The main checkout is shared with the operator and other agents. Work in a git worktree " +
1413
- "(`git worktree list`, `git worktree add`) and use `git -C <worktree> ...`, or read code with " +
1414
- "`git show <sha>:<path>`, `gh pr diff`, or `gh api`.\n" +
1415
- "Operator override: set FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the shell before starting Claude Code " +
1416
- "(enforcement itself is opt-in: FORGE_ENFORCE_MAIN_CHECKOUT_GIT=1).\n"
1417
- );
1450
+ return null;
1418
1451
  } catch {
1419
1452
  return null; // fail open
1420
1453
  }
package/bin/runner.mjs CHANGED
@@ -74,6 +74,64 @@ export function withForgedockHome(env) {
74
74
  return env;
75
75
  }
76
76
 
77
+ /** Expand a leading `~` and require an absolute result; anything else -> null. */
78
+ function absoluteTmpRoot(value) {
79
+ const v = typeof value === "string" ? value.trim() : "";
80
+ if (!v) return null;
81
+ const expanded = v === "~" || v.startsWith("~/") ? join(os.homedir(), v.slice(1)) : v;
82
+ return isAbsolute(expanded) ? expanded : null;
83
+ }
84
+
85
+ /**
86
+ * Root of the per-run scratch dirs (forge#3572). Precedence: forge.yaml `engine.tmp_root`
87
+ * (absolute or `~`-prefixed), else null when the operator exported their own
88
+ * CLAUDE_CODE_TMPDIR/TMPDIR (their choice is respected and never cleaned), else `~/.forge/tmp`.
89
+ * A null return means "operator-managed: leave the child env untouched".
90
+ *
91
+ * @param {string} cwd
92
+ * @param {NodeJS.ProcessEnv} [env]
93
+ * @returns {string|null}
94
+ */
95
+ export function resolveRunTmpRoot(cwd, env = process.env) {
96
+ try {
97
+ const forgeYamlPath = join(cwd, "forge.yaml");
98
+ if (existsSync(forgeYamlPath)) {
99
+ const configured = absoluteTmpRoot(parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.engine?.tmp_root);
100
+ if (configured) return configured;
101
+ }
102
+ } catch { /* unreadable config: fall through to the defaults */ }
103
+ if (env.CLAUDE_CODE_TMPDIR || env.TMPDIR) return null;
104
+ return join(os.homedir(), ".forge", "tmp");
105
+ }
106
+
107
+ /** `<root>/<issue>` for a numeric issue; null for an empty root or non-numeric issue (no path-escape, never the root). */
108
+ export function runTmpDir(root, issue) {
109
+ const id = String(issue ?? "").trim();
110
+ return root && /^[0-9]+$/.test(id) ? join(root, id) : null;
111
+ }
112
+
113
+ /**
114
+ * Point TMPDIR and CLAUDE_CODE_TMPDIR at a per-run, disk-backed dir so venvs and Claude session data
115
+ * stay off a size-limited /tmp. No-op when the issue is unknown or the operator owns the tmp root.
116
+ * Best-effort: a failed mkdir leaves the env unchanged.
117
+ */
118
+ export function withRunTmpdir(env, { issue, cwd = process.cwd() } = {}) {
119
+ const dir = runTmpDir(resolveRunTmpRoot(cwd, env), issue);
120
+ if (!dir) return env;
121
+ try {
122
+ mkdirSync(dir, { recursive: true });
123
+ env.TMPDIR = dir;
124
+ env.CLAUDE_CODE_TMPDIR = dir;
125
+ } catch { /* scratch stays on the inherited tmp */ }
126
+ return env;
127
+ }
128
+
129
+ /** First positional arg when it is an issue number (the phase arg convention), else null. */
130
+ function issueFromArgs(args) {
131
+ const first = Array.isArray(args) ? args[0] : String(args ?? "").trim().split(/\s+/)[0];
132
+ return /^[0-9]+$/.test(String(first ?? "")) ? String(first) : null;
133
+ }
134
+
77
135
  const DEFAULT_MODEL = "claude-sonnet-5";
78
136
  const DEFAULT_MAX_ITERATIONS = 50;
79
137
  const DEFAULT_MAX_TOKENS = 16384;
@@ -1410,7 +1468,7 @@ export function runCliBackend({
1410
1468
  // Mirrors the existing scrub in the `run_bash` tool handler below.
1411
1469
  // GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
1412
1470
  // intact — only the Anthropic key is removed.
1413
- const childEnv = withForgedockHome({ ...process.env });
1471
+ const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd });
1414
1472
  delete childEnv.ANTHROPIC_API_KEY;
1415
1473
 
1416
1474
  // No `shell` option (defaults to false): argv is passed as discrete,
@@ -2186,9 +2244,10 @@ export function resolveBashShell() {
2186
2244
  * the loop catches and reports the error back to the model as an error result.
2187
2245
  *
2188
2246
  * @param {string} cwd
2247
+ * @param {string|null} [runIssueNumber] - issue number scoping the per-run TMPDIR (forge#3572)
2189
2248
  * @returns {Record<string, (input: object) => string>}
2190
2249
  */
2191
- export function getToolHandlers(cwd) {
2250
+ export function getToolHandlers(cwd, runIssueNumber = null) {
2192
2251
  return {
2193
2252
  read_file: ({ path }) => {
2194
2253
  if (!path) throw new Error("read_file requires a 'path'");
@@ -2208,7 +2267,7 @@ export function getToolHandlers(cwd) {
2208
2267
  // into the model loop, so a prompt-injection payload could otherwise issue
2209
2268
  // a run_bash command that exfiltrates the key. gh/git auth tokens
2210
2269
  // (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
2211
- const childEnv = withForgedockHome({ ...process.env });
2270
+ const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd });
2212
2271
  delete childEnv.ANTHROPIC_API_KEY;
2213
2272
  // Run under bash when available so bash-style commands the model emits
2214
2273
  // (heredocs, scripts/*.sh, single-quote semantics) behave consistently
@@ -2648,7 +2707,7 @@ export async function runCommand(opts = {}) {
2648
2707
  // is a known Linux limitation at the OS level; closing it would require
2649
2708
  // a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
2650
2709
  // See: issue #1370 for tracking this known limitation.
2651
- const handlers = getToolHandlers(cwd);
2710
+ const handlers = getToolHandlers(cwd, issueFromArgs(args));
2652
2711
  const messages = [{ role: "user", content: userMessage }];
2653
2712
 
2654
2713
  // Accumulate token usage across all messages.create() calls in this run.
@@ -5,7 +5,7 @@
5
5
  * Reads labels and ONLY trusted `<!-- FORGE:TELEMETRY v1 -->` comments (same predicate as
6
6
  * scripts/trusted-comments.sh: an author_association in FORGE_TRAIL_TRUSTED_ASSOCIATIONS, a Bot, or a login in
7
7
  * FORGE_TRAIL_TRUSTED_LOGINS). Every `gh` call is argv-based (no shell). The `gh` runner is injectable for tests.
8
- * Fetching is incremental: only items updated after the cached watermark are re-read.
8
+ * Fetching is incremental: only items updated after the cached watermark (minus a small overlap window) are re-read.
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { emptyCache, loadCache, saveCache } from "./cache.mjs";
@@ -58,8 +58,10 @@ export function parseTelemetryComments(comments, env = process.env) {
58
58
  const num = (x) => { const v = Number(x); return x != null && x !== "" && Number.isFinite(v) ? v : null; };
59
59
 
60
60
  /**
61
- * Turn `phase_attempt` / `run_terminal` records into run summaries (one per repo#issue#run, finished only when a
62
- * `run_terminal` exists). Field names are the ones bin/engine/telemetry-seam.mjs writes (docs/TELEMETRY.md).
61
+ * Turn `phase_attempt` / `run_terminal` records into run summaries, finished only when a `run_terminal` exists.
62
+ * The run id is per engine process, so a `--retry` yields several; they collapse to ONE run per repo#issue, the latest
63
+ * by start timestamp (tie: greater run id), matching scripts/cost-report.sh (last RUN_START slice). Finished/usage
64
+ * filtering stays downstream, so an unfinished latest run still excludes the issue. Field names are the ones bin/engine/telemetry-seam.mjs writes (docs/TELEMETRY.md).
63
65
  */
64
66
  export function runsFromRecords(records) {
65
67
  const byRun = new Map();
@@ -86,13 +88,74 @@ export function runsFromRecords(records) {
86
88
  if (num(r.cost_usd) != null) p.usd = (p.usd ?? 0) + num(r.cost_usd);
87
89
  if (id === "remediate") run.remediated = true;
88
90
  }
89
- return [...byRun.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first }));
91
+ const latest = new Map();
92
+ for (const run of byRun.values()) {
93
+ const k = `${run.repo}#${run.issue}`;
94
+ const cur = latest.get(k);
95
+ const a = run._first ?? "", b = cur?._first ?? "";
96
+ if (!cur || a > b || (a === b && (run.run ?? "") > (cur.run ?? ""))) latest.set(k, run);
97
+ }
98
+ return [...latest.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first }));
90
99
  }
91
100
 
92
101
  /** Gate/review records (everything that is not a run record) become aggregate events. */
93
102
  export const gateEvents = (records) => records.filter((r) => r.event !== "phase_attempt" && r.event !== "run_terminal")
94
103
  .map((r) => ({ ...r, version: r.forge_version ?? null }));
95
104
 
105
+ const CHECKPOINT_EVERY = 25;
106
+ const PAGE_SIZE = 100;
107
+ // The next incremental run re-reads this much before the persisted watermark (an overlap window), so a write that races a
108
+ // crawl or a small clock skew is re-listed. Re-reads are idempotent: items are keyed by issue number.
109
+ const WATERMARK_OVERLAP_MS = 5 * 60 * 1000;
110
+ const MAX_LIST_REQUESTS = 100000;
111
+
112
+ /** Shift an ISO-8601 timestamp by `ms`; an unparsable value is returned unchanged. */
113
+ export function shiftIso(ts, ms) {
114
+ const t = Date.parse(ts);
115
+ return Number.isNaN(t) ? ts : new Date(t + ms).toISOString().replace(/\.\d{3}Z$/, "Z");
116
+ }
117
+
118
+ const sinceParam = (ts) => encodeURIComponent(ts).replace(/%3A/g, ":");
119
+
120
+ /**
121
+ * Keyset crawl of the issue listing. The listing is sorted by a MUTABLE key (updated_at), so page-number pagination
122
+ * (`gh --paginate`) can skip an item updated mid-crawl: it moves to the end of the ordering and the items behind it shift
123
+ * one page earlier. Instead every request is page 1 from `since=CURSOR`, the cursor moves to the newest `updated_at` read,
124
+ * and already-seen (number, updated_at) pairs are dropped (the `since` boundary is inclusive). A full page that cannot move
125
+ * the cursor (more than PAGE_SIZE items share one timestamp) is drained with `page=N` for that timestamp only.
126
+ * Yields each page's unseen items sorted updated-asc.
127
+ */
128
+ function* listIssuePages(gh, repo, startSince) {
129
+ const base = ["state=all", "sort=updated", "direction=asc", `per_page=${PAGE_SIZE}`];
130
+ const seen = new Set();
131
+ const key = (it) => `${it.number}@${it.updated_at}`;
132
+ const take = (page) => {
133
+ const fresh = page.filter((it) => Number.isInteger(it?.number) && !seen.has(key(it)));
134
+ for (const it of fresh) seen.add(key(it));
135
+ return fresh.sort((a, b) => String(a.updated_at).localeCompare(String(b.updated_at)));
136
+ };
137
+ const maxTs = (page) => page.reduce((m, it) => (it?.updated_at && (!m || it.updated_at > m) ? it.updated_at : m), null);
138
+ let cursor = startSince ?? null;
139
+ for (let n = 0; n < MAX_LIST_REQUESTS; n++) {
140
+ const get = (extra = []) => parsePages(gh(["api", `repos/${repo}/issues?${[...base, ...(cursor ? [`since=${sinceParam(cursor)}`] : []), ...extra].join("&")}`]));
141
+ const page = get();
142
+ const fresh = take(page);
143
+ if (fresh.length > 0) yield fresh;
144
+ if (page.length < PAGE_SIZE) return;
145
+ const next = maxTs(page);
146
+ if (next && next !== cursor) { cursor = next; continue; }
147
+ // No cursor progress on a full page: drain this timestamp by page number, then step past it.
148
+ for (let p = 2; p < MAX_LIST_REQUESTS; p++) {
149
+ const more = get([`page=${p}`]);
150
+ const freshMore = take(more);
151
+ if (freshMore.length > 0) yield freshMore;
152
+ if (more.length < PAGE_SIZE || freshMore.length === 0) break;
153
+ }
154
+ if (!cursor) return;
155
+ cursor = shiftIso(cursor, 1000);
156
+ }
157
+ }
158
+
96
159
  const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
97
160
  updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
98
161
 
@@ -103,25 +166,33 @@ const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), creat
103
166
  export function fetchGithubRecords({ repo, refresh = false, useCache = true, gh = defaultGh, env = process.env } = {}) {
104
167
  if (!isValidRepo(repo)) throw new Error(`invalid repo: ${repo}`);
105
168
  const cache = useCache && !refresh ? loadCache(repo, env) : emptyCache();
106
- const q = ["state=all", "sort=updated", "direction=asc", "per_page=100", ...(cache.watermark ? [`since=${cache.watermark}`] : [])];
107
- const listed = parsePages(gh(["api", "--paginate", `repos/${repo}/issues?${q.join("&")}`]));
169
+ const startSince = cache.watermark ? shiftIso(cache.watermark, -WATERMARK_OVERLAP_MS) : null;
108
170
  const items = { ...cache.items };
109
171
  let watermark = cache.watermark;
110
172
  let fetched = 0;
111
- for (const it of listed) {
112
- if (!Number.isInteger(it?.number)) continue;
113
- const m = meta(it);
114
- let records = items[it.number]?.records ?? [];
115
- // A telemetry comment is edited in place (which may not bump the issue's updated_at), so every listed item with
116
- // comments is re-read rather than trusting a cached copy.
117
- if ((it.comments ?? 0) > 0) {
118
- records = parseTelemetryComments(parsePages(gh(["api", "--paginate", `repos/${repo}/issues/${it.number}/comments`])), env);
119
- fetched++;
173
+ // Checkpoint: the watermark is the newest updated_at actually processed, and the next run starts WATERMARK_OVERLAP_MS
174
+ // before it, so it is a safe resume point. A failure mid-crawl (rate limit, 5xx, a hook timeout) then keeps the
175
+ // progress instead of restarting at zero.
176
+ const checkpoint = () => { if (useCache) saveCache(repo, { ...emptyCache(), watermark, items }, env); };
177
+ try {
178
+ for (const page of listIssuePages(gh, repo, startSince)) for (const it of page) {
179
+ const m = meta(it);
180
+ let records = items[it.number]?.records ?? [];
181
+ // A telemetry comment is edited in place (which may not bump the issue's updated_at), so every listed item with
182
+ // comments is re-read rather than trusting a cached copy.
183
+ if ((it.comments ?? 0) > 0) {
184
+ records = parseTelemetryComments(parsePages(gh(["api", "--paginate", `repos/${repo}/issues/${it.number}/comments`])), env);
185
+ fetched++;
186
+ }
187
+ items[it.number] = { ...m, records };
188
+ if (m.updatedAt && (!watermark || m.updatedAt > watermark)) watermark = m.updatedAt;
189
+ if (fetched > 0 && fetched % CHECKPOINT_EVERY === 0 && (it.comments ?? 0) > 0) checkpoint();
120
190
  }
121
- items[it.number] = { ...m, records };
122
- if (m.updatedAt && (!watermark || m.updatedAt > watermark)) watermark = m.updatedAt;
191
+ } catch (err) {
192
+ checkpoint();
193
+ throw err;
123
194
  }
124
- if (useCache) saveCache(repo, { ...emptyCache(), watermark, items }, env);
195
+ checkpoint();
125
196
  const seen = new Set();
126
197
  const records = [];
127
198
  for (const it of Object.values(items)) for (const r of it.records ?? []) {