forgedock 1.17.0 → 1.19.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 (143) hide show
  1. package/bin/engine/outcome-codes.mjs +37 -2
  2. package/bin/engine/phases.mjs +207 -106
  3. package/bin/engine/runlog.mjs +17 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +37 -2
  7. package/bin/engine.mjs +221 -15
  8. package/bin/forgedock.mjs +1 -1
  9. package/bin/hooks/pre-tool-use.mjs +65 -32
  10. package/bin/runner.mjs +125 -6
  11. package/bin/stats/aggregate.mjs +7 -3
  12. package/bin/stats/cache.mjs +7 -3
  13. package/bin/stats/github-source.mjs +203 -24
  14. package/bin/stats/index.mjs +46 -9
  15. package/bin/stats/render.mjs +1 -1
  16. package/bin/stats/stats.test.mjs +264 -15
  17. package/commands/cleanup.md +10 -3
  18. package/commands/deploy-pr.md +64 -0
  19. package/commands/fix-ci.md +20 -6
  20. package/commands/milestone.md +34 -17
  21. package/commands/orchestrate/phase-4-execution.md +11 -4
  22. package/commands/review-pr-staging.md +145 -522
  23. package/commands/review-pr.md +496 -3124
  24. package/commands/test-gate.md +25 -10
  25. package/commands/work-on/build/architect.md +66 -1
  26. package/commands/work-on/build/implement.md +21 -1
  27. package/commands/work-on/build/validate.md +322 -28
  28. package/commands/work-on/build.md +173 -95
  29. package/commands/work-on/close.md +50 -15
  30. package/commands/work-on/decompose.md +72 -34
  31. package/commands/work-on/investigate.md +16 -12
  32. package/commands/work-on/remediate.md +58 -25
  33. package/commands/work-on/review.md +36 -21
  34. package/commands/work-on.md +1 -2
  35. package/docs/CONFIG.md +132 -26
  36. package/docs/OUTCOME-CODES.md +12 -3
  37. package/docs/RELEASE-NOTES.md +12 -0
  38. package/docs/STATS.md +13 -5
  39. package/docs/TELEMETRY.md +7 -4
  40. package/docs/WORK-ON-RUNTIME.md +24 -0
  41. package/docs/spec/forge-protocol-v1.md +2 -2
  42. package/package.json +2 -2
  43. package/packages/protocol/src/phases.js +5 -5
  44. package/packages/protocol/src/types.js +1 -1
  45. package/scripts/acceptance-red-green.sh +90 -0
  46. package/scripts/acceptance-red-green.test.sh +74 -0
  47. package/scripts/cap-findings.sh +1 -1
  48. package/scripts/cap-findings.test.sh +3 -1
  49. package/scripts/check-contract-scope.test.sh +2 -0
  50. package/scripts/check-edge-cases.sh +143 -0
  51. package/scripts/check-edge-cases.test.sh +175 -0
  52. package/scripts/check-spec-bash.sh +5 -1
  53. package/scripts/check-spec-bash.test.sh +20 -0
  54. package/scripts/check-spec-markers.sh +2 -0
  55. package/scripts/check-test-tmp-cleanup.sh +56 -0
  56. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  57. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  58. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  59. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  60. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  61. package/scripts/ci-local/adapters/common.mjs +115 -0
  62. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  63. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  64. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  65. package/scripts/ci-local/adapters/portability.mjs +104 -0
  66. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  67. package/scripts/ci-local/cli.mjs +50 -0
  68. package/scripts/ci-local/lib.mjs +371 -0
  69. package/scripts/ci-local/plan.mjs +186 -0
  70. package/scripts/ci-local/run.mjs +345 -0
  71. package/scripts/ci-local/scoping.mjs +508 -0
  72. package/scripts/ci-local.sh +97 -0
  73. package/scripts/ci-local.test.sh +919 -0
  74. package/scripts/classify-finding.sh +32 -6
  75. package/scripts/classify-finding.test.sh +23 -0
  76. package/scripts/classify-test-run.sh +69 -0
  77. package/scripts/classify-test-run.test.sh +90 -0
  78. package/scripts/close-parent-c3.test.sh +71 -0
  79. package/scripts/consumer-sweep.sh +569 -0
  80. package/scripts/consumer-sweep.test.sh +357 -0
  81. package/scripts/detect-append-registries.sh +106 -0
  82. package/scripts/detect-append-registries.test.sh +64 -0
  83. package/scripts/diff-size.sh +93 -4
  84. package/scripts/diff-size.test.sh +59 -2
  85. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  86. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  87. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  88. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  89. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  90. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  91. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  92. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  93. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  94. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  95. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  96. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  97. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  98. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  99. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  100. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  101. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  102. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  103. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  104. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  105. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  106. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  107. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  108. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  109. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  110. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  111. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  112. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  113. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  114. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  115. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  116. package/scripts/forge-root.test.sh +14 -12
  117. package/scripts/lib/telemetry.sh +1 -0
  118. package/scripts/publish-staging-sync.test.sh +48 -0
  119. package/scripts/remediate-approved-head.test.sh +2 -0
  120. package/scripts/remediate-basesync.test.sh +1 -1
  121. package/scripts/resolve-deploy-gate.sh +32 -6
  122. package/scripts/resolve-deploy-gate.test.sh +42 -1
  123. package/scripts/review-acceptance-gate.sh +2 -2
  124. package/scripts/review-acceptance-gate.test.sh +20 -0
  125. package/scripts/review-delta.sh +8 -1
  126. package/scripts/review-delta.test.sh +19 -0
  127. package/scripts/review-finalize.sh +1579 -0
  128. package/scripts/review-finalize.test.sh +1040 -0
  129. package/scripts/review-pr-delta-wiring.test.sh +32 -72
  130. package/scripts/review-pr-domain-map.test.sh +18 -13
  131. package/scripts/review-pr-gate2.test.sh +9 -65
  132. package/scripts/review-pr-staging-scope.test.sh +14 -98
  133. package/scripts/review-prepare.sh +53 -15
  134. package/scripts/review-prepare.test.sh +37 -3
  135. package/scripts/review-roster.sh +16 -2
  136. package/scripts/review-roster.test.sh +22 -0
  137. package/scripts/run-shell-tests.sh +5 -0
  138. package/scripts/telemetry.test.sh +2 -0
  139. package/scripts/trusted-comments.test.sh +11 -3
  140. package/scripts/verify-phase-trail.test.sh +8 -0
  141. package/scripts/wait-ci-green.sh +61 -9
  142. package/scripts/wait-ci-green.test.sh +44 -6
  143. package/scripts/with-run-tmpdir.sh +20 -0
@@ -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
@@ -47,8 +47,11 @@ import {
47
47
  mkdtempSync,
48
48
  rmSync,
49
49
  realpathSync,
50
+ lstatSync,
51
+ chmodSync,
50
52
  } from "fs";
51
- import { join, dirname, basename, relative, isAbsolute, extname, win32 as win32Path } from "path";
53
+ import { createHash } from "crypto";
54
+ import { join, dirname, basename, resolve, relative, isAbsolute, extname, win32 as win32Path } from "path";
52
55
  import os from "os";
53
56
  import { execSync, spawnSync } from "child_process";
54
57
  import { createRequire } from "module";
@@ -74,6 +77,89 @@ export function withForgedockHome(env) {
74
77
  return env;
75
78
  }
76
79
 
80
+ /** Expand a leading `~` and require an absolute result; anything else -> null. */
81
+ function absoluteTmpRoot(value) {
82
+ const v = typeof value === "string" ? value.trim() : "";
83
+ if (!v) return null;
84
+ const expanded = v === "~" || v.startsWith("~/") ? join(os.homedir(), v.slice(1)) : v;
85
+ return isAbsolute(expanded) ? expanded : null;
86
+ }
87
+
88
+ /**
89
+ * Root of the per-run scratch dirs (forge#3572). Precedence: forge.yaml `engine.tmp_root`
90
+ * (absolute or `~`-prefixed), else null when the operator exported their own
91
+ * CLAUDE_CODE_TMPDIR/TMPDIR (their choice is respected and never cleaned), else `~/.forge/tmp`.
92
+ * A null return means "operator-managed: leave the child env untouched".
93
+ *
94
+ * @param {string} cwd
95
+ * @param {NodeJS.ProcessEnv} [env]
96
+ * @returns {string|null}
97
+ */
98
+ export function resolveRunTmpRoot(cwd, env = process.env) {
99
+ try {
100
+ const forgeYamlPath = join(cwd, "forge.yaml");
101
+ if (existsSync(forgeYamlPath)) {
102
+ const configured = absoluteTmpRoot(parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.engine?.tmp_root);
103
+ if (configured) return configured;
104
+ }
105
+ } catch { /* unreadable config: fall through to the defaults */ }
106
+ if (env.CLAUDE_CODE_TMPDIR || env.TMPDIR) return null;
107
+ return join(os.homedir(), ".forge", "tmp");
108
+ }
109
+
110
+ /**
111
+ * Stable, path-safe identity of the repo checkout: `<sanitized basename>-<8 hex of sha1(realpath)>`.
112
+ * Local only (no network), identical across a `--retry` from the same checkout, distinct per checkout.
113
+ * Never empty, never `.`/`..`, never contains a path separator.
114
+ */
115
+ export function repoKey(cwd = process.cwd()) {
116
+ let real;
117
+ try { real = realpathSync(cwd); } catch { real = resolve(String(cwd)); }
118
+ const name = basename(real).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "") || "repo";
119
+ return `${name}-${createHash("sha1").update(real).digest("hex").slice(0, 8)}`;
120
+ }
121
+
122
+ /** `<root>/<repoKey>/<issue>` for a numeric issue; null for an empty root or non-numeric issue (no path-escape, never the root). */
123
+ export function runTmpDir(root, issue, cwd = process.cwd()) {
124
+ const id = String(issue ?? "").trim();
125
+ return root && /^[0-9]+$/.test(id) ? join(root, repoKey(cwd), id) : null;
126
+ }
127
+
128
+ /** True when `p` is a real directory (not a symlink) owned by the current uid (uid check skipped where unavailable). */
129
+ function isOwnedRealDir(p) {
130
+ const st = lstatSync(p);
131
+ if (!st.isDirectory() || st.isSymbolicLink()) return false;
132
+ return typeof process.getuid !== "function" || st.uid === process.getuid();
133
+ }
134
+
135
+ /**
136
+ * Point TMPDIR and CLAUDE_CODE_TMPDIR at a per-run, disk-backed dir so venvs and Claude session data
137
+ * stay off a size-limited /tmp. The dir is repo-scoped and created 0700; an existing dir that is a symlink
138
+ * or owned by another uid is not used. No-op when the issue is unknown or the operator owns the tmp root.
139
+ * Best-effort: any failure leaves the env unchanged.
140
+ */
141
+ export function withRunTmpdir(env, { issue, cwd = process.cwd() } = {}) {
142
+ const dir = runTmpDir(resolveRunTmpRoot(cwd, env), issue, cwd);
143
+ if (!dir) return env;
144
+ try {
145
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
146
+ const parent = dirname(dir);
147
+ for (const d of [parent, dir]) {
148
+ if (!isOwnedRealDir(d)) return env;
149
+ chmodSync(d, 0o700);
150
+ }
151
+ env.TMPDIR = dir;
152
+ env.CLAUDE_CODE_TMPDIR = dir;
153
+ } catch { /* scratch stays on the inherited tmp */ }
154
+ return env;
155
+ }
156
+
157
+ /** First positional arg when it is an issue number (the phase arg convention), else null. */
158
+ function issueFromArgs(args) {
159
+ const first = Array.isArray(args) ? args[0] : String(args ?? "").trim().split(/\s+/)[0];
160
+ return /^[0-9]+$/.test(String(first ?? "")) ? String(first) : null;
161
+ }
162
+
77
163
  const DEFAULT_MODEL = "claude-sonnet-5";
78
164
  const DEFAULT_MAX_ITERATIONS = 50;
79
165
  const DEFAULT_MAX_TOKENS = 16384;
@@ -1410,7 +1496,7 @@ export function runCliBackend({
1410
1496
  // Mirrors the existing scrub in the `run_bash` tool handler below.
1411
1497
  // GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
1412
1498
  // intact — only the Anthropic key is removed.
1413
- const childEnv = withForgedockHome({ ...process.env });
1499
+ const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd });
1414
1500
  delete childEnv.ANTHROPIC_API_KEY;
1415
1501
 
1416
1502
  // No `shell` option (defaults to false): argv is passed as discrete,
@@ -1435,6 +1521,9 @@ export function runCliBackend({
1435
1521
  // intentionally omitted here since spawnSync's own `timeout` + ETIMEDOUT is
1436
1522
  // reliable for this single, non-looped invocation.
1437
1523
  const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
1524
+ // forge#4037: only a spawn-stage failure is transient infra. A wall-clock kill leaves evidence that the child ran
1525
+ // (a kill signal, a pid, or captured output); an ETIMEDOUT with none of those never started the child.
1526
+ const childRan = !!result.signal || (Number.isInteger(result.pid) && result.pid > 0) || (stdout + stderr).trim().length > 0;
1438
1527
  if (timedOut) {
1439
1528
  const timeoutSecs = Math.round(timeoutMs / 1000);
1440
1529
  // forge#2360: spawnSync's `timeout` option kills the child outright on
@@ -1452,15 +1541,44 @@ export function runCliBackend({
1452
1541
  // review findings — #2277, #2292, #2293, #2355 — for that defect
1453
1542
  // class).
1454
1543
  const partial = (stdout + stderr).trim();
1455
- throw new Error(
1544
+ const timeoutError = new Error(
1456
1545
  `claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
1457
1546
  `Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.` +
1458
1547
  (partial ? ` Partial output: ${sanitizeOutputExcerptForLog(partial)}` : ""),
1459
1548
  );
1549
+ if (!childRan) {
1550
+ // forge#4021: stable transient-infra class; the engine retries it on a
1551
+ // separate bounded counter without consuming a phase attempt.
1552
+ timeoutError.code = "CLI_SPAWN_TRANSIENT";
1553
+ timeoutError.spawnErrno = "ETIMEDOUT";
1554
+ throw timeoutError;
1555
+ }
1556
+ // forge#4037: the child ran and was killed at the wall-clock limit: a normal, attempt-consuming failure
1557
+ // (`cli.timeout`), with usage attached when the CLI had already emitted a parseable envelope.
1558
+ timeoutError.code = "CLI_TIMEOUT";
1559
+ timeoutError.usage = null;
1560
+ try {
1561
+ const u = JSON.parse(stdout.trim())?.usage;
1562
+ if (u && typeof u === "object") {
1563
+ timeoutError.usage = {
1564
+ input_tokens: toFiniteUsageNumber(u.input_tokens),
1565
+ output_tokens: toFiniteUsageNumber(u.output_tokens),
1566
+ cache_creation_input_tokens: toFiniteUsageNumber(u.cache_creation_input_tokens),
1567
+ cache_read_input_tokens: toFiniteUsageNumber(u.cache_read_input_tokens),
1568
+ };
1569
+ }
1570
+ } catch {
1571
+ // No parseable envelope from a killed child: usage stays null (never fabricated).
1572
+ }
1573
+ throw timeoutError;
1460
1574
  }
1461
1575
  if (result.error) {
1462
1576
  const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
1463
1577
  error.code = result.error.code;
1578
+ if (["EAGAIN", "ENOMEM", "EMFILE"].includes(result.error.code)) {
1579
+ error.spawnErrno = result.error.code;
1580
+ error.code = "CLI_SPAWN_TRANSIENT";
1581
+ }
1464
1582
  throw error;
1465
1583
  }
1466
1584
 
@@ -2186,9 +2304,10 @@ export function resolveBashShell() {
2186
2304
  * the loop catches and reports the error back to the model as an error result.
2187
2305
  *
2188
2306
  * @param {string} cwd
2307
+ * @param {string|null} [runIssueNumber] - issue number scoping the per-run TMPDIR (forge#3572)
2189
2308
  * @returns {Record<string, (input: object) => string>}
2190
2309
  */
2191
- export function getToolHandlers(cwd) {
2310
+ export function getToolHandlers(cwd, runIssueNumber = null) {
2192
2311
  return {
2193
2312
  read_file: ({ path }) => {
2194
2313
  if (!path) throw new Error("read_file requires a 'path'");
@@ -2208,7 +2327,7 @@ export function getToolHandlers(cwd) {
2208
2327
  // into the model loop, so a prompt-injection payload could otherwise issue
2209
2328
  // a run_bash command that exfiltrates the key. gh/git auth tokens
2210
2329
  // (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
2211
- const childEnv = withForgedockHome({ ...process.env });
2330
+ const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd });
2212
2331
  delete childEnv.ANTHROPIC_API_KEY;
2213
2332
  // Run under bash when available so bash-style commands the model emits
2214
2333
  // (heredocs, scripts/*.sh, single-quote semantics) behave consistently
@@ -2648,7 +2767,7 @@ export async function runCommand(opts = {}) {
2648
2767
  // is a known Linux limitation at the OS level; closing it would require
2649
2768
  // a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
2650
2769
  // See: issue #1370 for tracking this known limitation.
2651
- const handlers = getToolHandlers(cwd);
2770
+ const handlers = getToolHandlers(cwd, issueFromArgs(args));
2652
2771
  const messages = [{ role: "user", content: userMessage }];
2653
2772
 
2654
2773
  // Accumulate token usage across all messages.create() calls in this run.
@@ -127,8 +127,10 @@ function funnelSection(events, issues) {
127
127
  for (const d of Object.values(bySeverity)) d.perReview = reviews.size ? d.total / reviews.size : null;
128
128
  const dispositions = { ISSUE: 0, NOTE: 0, INPR_FIX: 0 };
129
129
  for (const f of findings) if (f.disposition in dispositions) dispositions[f.disposition]++;
130
+ // Events written before the scope field existed carry none and are not counted.
131
+ const introduced = findings.filter((f) => f.scope === "introduced").length;
130
132
  return {
131
- reviews: reviews.size, findings: findings.length, perReview: ratio(findings.length, reviews.size),
133
+ reviews: reviews.size, findings: findings.length, introduced, perReview: ratio(findings.length, reviews.size),
132
134
  bySeverity, dispositions,
133
135
  validated: issues.filter((i) => i.labels.includes("validated")).length,
134
136
  falsePositive: issues.filter((i) => i.labels.includes("false-positive")).length,
@@ -150,7 +152,7 @@ function gatesSection(events) {
150
152
  };
151
153
  }
152
154
 
153
- function issueFlowSection(issues) {
155
+ function issueFlowSection(issues, introduced = 0) {
154
156
  const real = issues.filter((i) => !i.isPr);
155
157
  const created = {};
156
158
  for (const i of real) { const s = issueSource(i); created[s] = (created[s] ?? 0) + 1; }
@@ -162,6 +164,7 @@ function issueFlowSection(issues) {
162
164
  n: real.length, created, createdTotal: real.length, closed, closedTotal: closedIssues.length,
163
165
  net: real.length ? real.length - closedIssues.length : null,
164
166
  findingsPerMerge: ratio(created["review-finding"] ?? 0, merged),
167
+ introducedPerMerge: ratio(introduced, merged),
165
168
  };
166
169
  }
167
170
 
@@ -214,7 +217,8 @@ export function aggregate({ runs = [], events = [], issues = [] } = {}, opts = {
214
217
  let groups = [...keys.values()].map(({ version, repo }) => {
215
218
  const sel = (list) => list.filter((x) => x.version === version && x.repo === repo);
216
219
  const g = { version, repo, cost: costSection(sel(counted)), time: timeSection(sel(counted)), flow: flowSection(sel(counted)),
217
- funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow: issueFlowSection(sel(iss)) };
220
+ funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow: null };
221
+ g.issueFlow = issueFlowSection(sel(iss), g.funnel.introduced);
218
222
  g.n = g.flow.n;
219
223
  g.lowN = g.n < minN;
220
224
  g.minN = minN;
@@ -17,13 +17,17 @@ export function cacheDir(repo, env = process.env) {
17
17
  return join(root, repo.replace("/", "_"));
18
18
  }
19
19
 
20
- /** { watermark: ISO string | null, items: { "<number>": item } } */
21
- export const emptyCache = () => ({ v: CACHE_VERSION, watermark: null, items: {} });
20
+ /**
21
+ * { watermark: ISO string | null, coveredFrom: ISO string | null, items: { "<number>": item } }
22
+ * The cache covers activity in [coveredFrom, watermark]; coveredFrom null means "from the beginning". A cache written
23
+ * before the field existed loads with coveredFrom undefined (floor unknown), never as full coverage.
24
+ */
25
+ export const emptyCache = () => ({ v: CACHE_VERSION, watermark: null, coveredFrom: null, items: {} });
22
26
 
23
27
  export function loadCache(repo, env = process.env) {
24
28
  try {
25
29
  const c = JSON.parse(readFileSync(join(cacheDir(repo, env), "cache.json"), "utf8"));
26
- if (c && c.v === CACHE_VERSION && c.items && typeof c.items === "object") return { ...emptyCache(), ...c };
30
+ if (c && c.v === CACHE_VERSION && c.items && typeof c.items === "object") return { ...emptyCache(), ...c, coveredFrom: "coveredFrom" in c ? c.coveredFrom : undefined };
27
31
  } catch { /* missing or corrupt: refetch */ }
28
32
  return emptyCache();
29
33
  }