forgedock 1.30.0 → 1.32.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 (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
package/bin/engine.mjs CHANGED
@@ -9,6 +9,13 @@ import { execFileSync } from "node:child_process";
9
9
  import { createHash, scryptSync } from "node:crypto";
10
10
  import { hostname as osHostname, homedir } from "node:os";
11
11
  import { appendEvent, readLog, deriveState, rewriteLog, scopeEvents } from "./engine/runlog.mjs";
12
+
13
+ /** #4432: the issue's run log in `dir`, keyed by repo + issue (legacy `<issue>.jsonl` when `repo` is null). */
14
+ const makeRunLog = (dir, issue, repo) => ({
15
+ append: (e) => appendEvent(dir, issue, e, { repo }),
16
+ read: () => readLog(dir, issue, { repo }),
17
+ rewrite: (events) => rewriteLog(dir, issue, events, { repo }),
18
+ });
12
19
  import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency, reopenRevokedDecomposition } from "./engine/phases.mjs";
13
20
  import { reconcileState } from "./engine/reconcile.mjs";
14
21
  import { makeProjector } from "./engine/projector.mjs";
@@ -431,7 +438,7 @@ export async function runIssue(opts) {
431
438
  // event and forwards it to the telemetry hook (#3834 owns the FORGE:TELEMETRY writer), which can
432
439
  // never fail the run.
433
440
  const emitOutcome = async (rec) => {
434
- appendEvent(dir, issue, rec);
441
+ runLog.append(rec);
435
442
  try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a run */ }
436
443
  };
437
444
 
@@ -443,11 +450,23 @@ export async function runIssue(opts) {
443
450
  // #4125: no silent lane default; the caller (run-issue --lane) names it, and the CLI refuses the default branch.
444
451
  if (typeof lane !== "string" || !lane.trim()) throw new Error("runIssue: lane is required (staging or milestone/<slug>); there is no default");
445
452
 
453
+ // forge#3824: hoisted so RUN_START can carry the repo; #4432: resolved before the first run-log read, which is keyed by it (lazy, memoised; null when unresolved).
454
+ let repoPromise = null;
455
+ const resolveRepo = () => (repoPromise ??= (async () => {
456
+ if (repo) return String(repo).trim();
457
+ try {
458
+ const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
459
+ return String(out || "").trim() || null;
460
+ } catch { return null; }
461
+ })());
462
+ // #4432: the run log is keyed by repo + issue (issue numbers are per repo); an unresolved repo keeps the legacy name.
463
+ const runLog = makeRunLog(dir, issue, await resolveRepo().catch(() => null));
464
+
446
465
  const projector = makeProjector(io);
447
466
 
448
467
  // 1. Load + reconcile (GitHub wins).
449
468
  // #4109: only this (full) path's own events: core-path commits and terminals in the shared log are never replayed here.
450
- const local = scopeEvents(readLog(dir, issue)).length ? deriveState(readLog(dir, issue)) : null;
469
+ const local = scopeEvents(runLog.read()).length ? deriveState(runLog.read()) : null;
451
470
  let remote = await projector.readState(issue);
452
471
  let { state, action } = reconcileState(local, remote);
453
472
  const leaseHeld = (r) => !!(r?.lease && r.lease.until > now() && r.lease.by !== agentId &&
@@ -515,28 +534,19 @@ export async function runIssue(opts) {
515
534
  };
516
535
  }
517
536
  }
518
- // forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
519
- let repoPromise = null;
520
- const resolveRepo = () => (repoPromise ??= (async () => {
521
- if (repo) return String(repo).trim();
522
- try {
523
- const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
524
- return String(out || "").trim() || null;
525
- } catch { return null; }
526
- })());
527
537
 
528
538
  const runMeta = async () => ({ version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo: await resolveRepo().catch(() => null) });
529
539
  if (!state) {
530
540
  state = freshState(issue, lane);
531
- appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane, ...(await runMeta()) });
541
+ runLog.append({ event: "RUN_START", issue, run: state.run, lane, ...(await runMeta()) });
532
542
  } else if (action === "hydrate") {
533
543
  // GitHub is ahead of (or the only source of) local state. Rebuild the
534
544
  // local run-log to match the remote compact index so downstream
535
545
  // `deriveState(readLog(...))` calls stay consistent — otherwise the
536
546
  // post-commit re-derive below would fold over an empty/stale local log
537
547
  // and regress committed/issue/v (C2).
538
- rewriteLog(dir, issue, eventsFromIndex(state, await runMeta()));
539
- state = deriveState(readLog(dir, issue));
548
+ runLog.rewrite(eventsFromIndex(state, await runMeta()));
549
+ state = deriveState(runLog.read());
540
550
  // forge#3931: the index does not carry the cycle, so a hydrated log always starts at cycle 0. When a
541
551
  // trusted FORGE:CYCLE exists, cycle-scoped progress (or a needs-human/phase-complete/engine-error terminal)
542
552
  // 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
@@ -547,8 +557,8 @@ export async function runIssue(opts) {
547
557
  let cyc = 0;
548
558
  try { cyc = await newestCycle(issue, io); } catch { cyc = 0; }
549
559
  if (cyc > 0) {
550
- appendEvent(dir, issue, { event: "RUN_NEXT_CYCLE", issue });
551
- state = deriveState(readLog(dir, issue));
560
+ runLog.append({ event: "RUN_NEXT_CYCLE", issue });
561
+ state = deriveState(runLog.read());
552
562
  }
553
563
  }
554
564
  }
@@ -630,7 +640,7 @@ export async function runIssue(opts) {
630
640
  // forge#3657: publish the cycle boundary on GitHub BEFORE the log flips, so
631
641
  // build's reconcilers never re-adopt cycle-1 evidence. Fail closed.
632
642
  if (state.terminalReason === "phase-complete") {
633
- const localCycles = readLog(dir, issue).filter((e) => e.event === "RUN_NEXT_CYCLE").length;
643
+ const localCycles = runLog.read().filter((e) => e.event === "RUN_NEXT_CYCLE").length;
634
644
  try { await postCycleMarker(issue, await nextCycleNumber(issue, localCycles, io), io); } catch {
635
645
  return {
636
646
  terminalReason: "not-retryable",
@@ -638,12 +648,12 @@ export async function runIssue(opts) {
638
648
  };
639
649
  }
640
650
  }
641
- appendEvent(dir, issue, {
651
+ runLog.append({
642
652
  event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
643
653
  issue,
644
654
  ...(reopenUncommit ? { uncommit: reopenUncommit } : {}),
645
655
  });
646
- state = deriveState(readLog(dir, issue));
656
+ state = deriveState(runLog.read());
647
657
  reopened = true;
648
658
  }
649
659
  // forge#2239: claim the lease unconditionally here — before the phase loop
@@ -882,7 +892,7 @@ export async function runIssue(opts) {
882
892
  for (;;) {
883
893
  for (;;) {
884
894
  try {
885
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep });
895
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, runLog, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep });
886
896
  break;
887
897
  } catch (e) {
888
898
  const resetAtMs = e.resetAtEpochMs;
@@ -904,7 +914,7 @@ export async function runIssue(opts) {
904
914
  // hasn't committed yet, so a resumed run correctly re-enters
905
915
  // pickPhase at this same phase regardless of whether this event
906
916
  // was ever written.
907
- appendEvent(dir, issue, {
917
+ runLog.append({
908
918
  event: "PHASE_RATE_LIMITED", phase: phase.id,
909
919
  resetAt: hasResetEpoch ? resetAtMs : null, resetAtDisplay: e.resetAt || null, waitMs,
910
920
  backendId: backendIdentity({ backend, env: process.env }),
@@ -1051,7 +1061,7 @@ export async function runIssue(opts) {
1051
1061
  const commitReason = blockedReason ?? outcome.terminalReason ?? null;
1052
1062
  // forge#3833: the handoff/blocked cause, persisted on the commit event like the reason.
1053
1063
  const commitCode = commitReason ? normalizeCode(outcome.code ?? codeForTerminalReason(commitReason)) : null;
1054
- appendEvent(dir, issue, {
1064
+ runLog.append({
1055
1065
  event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs,
1056
1066
  // forge#3696: `usage` is cumulative over every attempt of the phase; the last attempt's alone is `last_attempt_usage`.
1057
1067
  usage: outcome.usage ?? null, last_attempt_usage: outcome.last_attempt_usage ?? null,
@@ -1061,7 +1071,7 @@ export async function runIssue(opts) {
1061
1071
  backend: outcome.backend ?? null,
1062
1072
  ...(commitReason ? { terminalReason: commitReason, code: commitCode } : {}),
1063
1073
  });
1064
- state = deriveState(readLog(dir, issue));
1074
+ state = deriveState(runLog.read());
1065
1075
  const terminalReason = blockedReason ?? outcome.terminalReason;
1066
1076
  if (terminalReason) state.terminalReason = terminalReason;
1067
1077
  await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
@@ -1129,14 +1139,14 @@ export async function runIssue(opts) {
1129
1139
  const repoName = await resolveRepo();
1130
1140
  if (typeof io.waitCiGreen !== "function" || pr == null || !repoName) return {};
1131
1141
  const budgetMs = ciPendingMaxMinutes * 60000;
1132
- const open = readLog(dir, issue).filter((ev) => ev.event === "CI_PENDING_WAIT" && ev.pr === pr).pop();
1142
+ const open = runLog.read().filter((ev) => ev.event === "CI_PENDING_WAIT" && ev.pr === pr).pop();
1133
1143
  const startedAt = open && open.state === "start" && Number.isFinite(open.startedAt) ? open.startedAt : now();
1134
- if (!(open && open.state === "start")) appendEvent(dir, issue, { event: "CI_PENDING_WAIT", state: "start", pr, startedAt });
1144
+ if (!(open && open.state === "start")) runLog.append({ event: "CI_PENDING_WAIT", state: "start", pr, startedAt });
1135
1145
  let slept = 0, polls = 0, rateLimitSleeps = 0, pollIndex = 0;
1136
1146
  const spentMs = () => Math.max(now() - startedAt, slept);
1137
1147
  const report = async (result) => {
1138
1148
  const minutes = Math.round(spentMs() / 600) / 100;
1139
- appendEvent(dir, issue, { event: "CI_PENDING_WAIT", state: "end", pr, startedAt, minutes_waited: minutes, result, polls, rate_limit_sleeps: rateLimitSleeps });
1149
+ runLog.append({ event: "CI_PENDING_WAIT", state: "end", pr, startedAt, minutes_waited: minutes, result, polls, rate_limit_sleeps: rateLimitSleeps });
1140
1150
  try { await emitTelemetry?.({ event: "CI_PENDING_WAIT", phase: phaseId, issue, pr, minutes_waited: minutes, result, polls, rate_limit_sleeps: rateLimitSleeps }); } catch { /* telemetry never fails a run */ }
1141
1151
  return minutes;
1142
1152
  };
@@ -1181,14 +1191,14 @@ export async function runIssue(opts) {
1181
1191
  async function terminate(s, reason, detail, code) {
1182
1192
  // forge#3833: absent -> the reason's own code; unknown -> `unclassified` with the raw text kept in `detail`.
1183
1193
  const outcomeCode = code == null ? codeForTerminalReason(reason) : normalizeCode(code);
1184
- appendEvent(dir, issue, { event: "RUN_TERMINAL", reason, code: outcomeCode,
1194
+ runLog.append({ event: "RUN_TERMINAL", reason, code: outcomeCode,
1185
1195
  ...(outcomeCode === UNCLASSIFIED && detail ? { detail } : {}) });
1186
1196
  // forge#3835: forward the terminal record to the telemetry seam (best-effort, never fails the run).
1187
1197
  try { await emitTelemetry?.({ event: "RUN_TERMINAL", reason, code: outcomeCode, issue }); } catch { /* telemetry never fails a run */ }
1188
1198
  // The terminal write must always land; only the lease field is preserved when another live
1189
1199
  // agent holds it. A failed read falls back to clearing (a lost terminal write would strand the issue).
1190
1200
  const foreign = await foreignLiveLease();
1191
- const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, outcomeCode, lease: foreign ?? null };
1201
+ const final = { ...deriveState(runLog.read()), terminal: true, terminalReason: reason, outcomeCode, lease: foreign ?? null };
1192
1202
  await projector.writeState(issue, final);
1193
1203
  // forge#3566: the terminal projection must not leave a stranded `workflow:remediating`
1194
1204
  // next to the terminal label. Best-effort: runs after the terminal write and never throws.
@@ -1237,7 +1247,7 @@ export function cleanupRunTmpdir(issue, { cwd = process.cwd(), env = process.env
1237
1247
  export const INFRA_SPAWN_BACKOFF_MS = Object.freeze([10_000, 30_000, 60_000]);
1238
1248
 
1239
1249
  async function runPhaseWithRetry(phase, state, ctx) {
1240
- const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = ctx;
1250
+ const { io, runner, dir, issue, runLog, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = ctx;
1241
1251
  // forge#3499: build the phase's full argument set ONCE, up front. Every
1242
1252
  // work-on phase sub-skill stops with BLOCKED when a required flag is absent,
1243
1253
  // so an incomplete/invalid set is a deterministic engine failure — fail
@@ -1249,7 +1259,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1249
1259
  } catch (e) {
1250
1260
  if (e?.code !== "PHASE_ARGS_INVALID") throw e;
1251
1261
  const detail = `phase ${phase.id} args unresolved: ${e.message}`;
1252
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, code: OUTCOME_CODES.ENGINE_ERROR, maxAttempts });
1262
+ runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, code: OUTCOME_CODES.ENGINE_ERROR, maxAttempts });
1253
1263
  return { status: "blocked", detail, reason: "engine-error", code: OUTCOME_CODES.ENGINE_ERROR, usage: null };
1254
1264
  }
1255
1265
  // forge#2261: true only if EVERY attempt failed by the runner itself
@@ -1288,7 +1298,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1288
1298
  cost_usd: extra?.cost_usd ?? null, model: extra?.model ?? null, turns: extra?.turns ?? null,
1289
1299
  backend: extra?.backend ?? null,
1290
1300
  };
1291
- appendEvent(dir, issue, rec);
1301
+ runLog.append(rec);
1292
1302
  try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
1293
1303
  };
1294
1304
  // forge#4021: transient CLI spawn failures (ETIMEDOUT/EAGAIN/ENOMEM at spawn) retry on their own bounded
@@ -1296,7 +1306,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1296
1306
  // so a CLI that merely runs long cannot loop forever.
1297
1307
  let infraRetries = 0;
1298
1308
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1299
- appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
1309
+ runLog.append({ event: "PHASE_START", phase: phase.id, attempt });
1300
1310
  let result;
1301
1311
  const t0 = performance.now();
1302
1312
  try {
@@ -1332,7 +1342,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1332
1342
  if (e.code === "CLI_MAX_TURNS") {
1333
1343
  addUsage(e.usage);
1334
1344
  await recordAttempt(attempt, e.usage, t0, { turns: e.numTurns });
1335
- appendEvent(dir, issue, {
1345
+ runLog.append({
1336
1346
  event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts,
1337
1347
  turns: e.numTurns ?? null, usage: e.usage ?? null,
1338
1348
  });
@@ -1342,13 +1352,13 @@ async function runPhaseWithRetry(phase, state, ctx) {
1342
1352
  // failure, so rethrow fail-fast and let runIssue end it as a retryable `engine-error` (never needs-human).
1343
1353
  if (isDiskQuotaError(e)) {
1344
1354
  await recordAttempt(attempt, null, t0, null);
1345
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
1355
+ runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
1346
1356
  throw e;
1347
1357
  }
1348
1358
  // Transient spawn class: e.code === "CLI_SPAWN_TRANSIENT" (see isTransientSpawnError); retried with infra backoff.
1349
1359
  if (isTransientSpawnError(e) && infraRetries < INFRA_SPAWN_BACKOFF_MS.length) {
1350
1360
  await recordAttempt(attempt, null, t0, null);
1351
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, infraRetry: infraRetries + 1 });
1361
+ runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, infraRetry: infraRetries + 1 });
1352
1362
  await sleep(INFRA_SPAWN_BACKOFF_MS[infraRetries++]);
1353
1363
  attempt--;
1354
1364
  continue;
@@ -1363,7 +1373,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1363
1373
  const killUsage = e.code === "CLI_TIMEOUT" ? e.usage ?? null : null;
1364
1374
  if (killUsage) addUsage(killUsage);
1365
1375
  await recordAttempt(attempt, killUsage, t0, null);
1366
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, ...(killUsage ? { usage: killUsage } : {}) });
1376
+ runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts, ...(killUsage ? { usage: killUsage } : {}) });
1367
1377
  lastCode = OUTCOME_CODES.ENGINE_ERROR;
1368
1378
  continue;
1369
1379
  }
@@ -1377,7 +1387,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
1377
1387
  await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns, backend: lastBackend });
1378
1388
  const outcome = await phase.detectOutcome(state, io, result);
1379
1389
  if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: cumulativeUsage, last_attempt_usage: lastUsage, model: lastModel, turns: lastTurns, backend: lastBackend };
1380
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, code: normalizeCode(outcome.code), maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
1390
+ runLog.append({ event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, code: normalizeCode(outcome.code), maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
1381
1391
  // forge#2176: a phase's detectOutcome can mark a failure as a known,
1382
1392
  // state-derived fixed point — re-running the phase's runner is
1383
1393
  // guaranteed to reproduce the identical failure (e.g. the build phase's
package/bin/forgedock.mjs CHANGED
@@ -3289,8 +3289,10 @@ function help() {
3289
3289
  ["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
3290
3290
  ["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)"],
3291
3291
  ["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"],
3292
- ["npx forgedock orchestrate --resume <id> | --set-parallel <id> K", "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), or change its concurrency live"],
3292
+ ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --cancel <id>", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume)"],
3293
3293
  ["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"],
3294
+ ["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"],
3295
+ ["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)"],
3294
3296
  ["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
3295
3297
  ["npx forgedock sched show | cap <N>|--clear | weight <owner/repo> <N>|--clear", "Show the host lane registry, set the host-wide lane cap shared by orchestrate batches, or set a repo's share of slots within a priority (default equal)"],
3296
3298
  ["npx forgedock schedd [status]", "Host scheduler daemon hosting every orchestrate batch (auto-spawned by orchestrate; run in the foreground for a systemd-user unit); status shows its pid and batches"],
@@ -3301,6 +3303,9 @@ function help() {
3301
3303
  ["npx forgedock config migrate [dir]", "Backfill missing optional sections into an existing forge.yaml (idempotent)"],
3302
3304
  ["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
3303
3305
  ["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
3306
+ ["npx forgedock review-staging [<PR>] [--thorough] [--json]", "Staging->main review pre-dispatch: route marker, open-finding gate, scope, prepare, roster; writes one reviewer brief per domain"],
3307
+ ["npx forgedock review-staging [<PR>] --finalize --test-gate-output <file>", "Staging->main review post-dispatch: delivery, test-gate verdict, extract/classify/file-prepare; prints the findings to file"],
3308
+ ["npx forgedock review-staging [<PR>] --summary [--filed <N>]...", "Staging->main review Phase 8: verdict and summary comment with the FORGE:GATE_PASS / FORGE:GATE_FAILURE marker"],
3304
3309
  ["npx forgedock stats [--since <ver|date>] [--repo owner/repo] [--json] [--refresh]", "Version-over-version cost/time/flow/review/gate report from GitHub telemetry (no model)"],
3305
3310
  ["npx forgedock doctor", "Check installation health"],
3306
3311
  ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block) and reclaim ForgeDock-owned leftovers (forge.yaml hygiene TTLs)"],
@@ -3868,10 +3873,10 @@ async function watch() {
3868
3873
  // The journey-routed commands render their own branded marks (hero/compact);
3869
3874
  // splash() would double-brand them. Engine-surface commands, help, and
3870
3875
  // unknown-command output keep the logo.
3871
- const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3876
+ const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3872
3877
  const KNOWN_COMMANDS = new Set([
3873
3878
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3874
- "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "help", "--help", "-h",
3879
+ "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "check", "help", "--help", "-h",
3875
3880
  "version", "--version", "-v",
3876
3881
  ]);
3877
3882
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -4081,6 +4086,12 @@ switch (command) {
4081
4086
  }
4082
4087
  break;
4083
4088
  }
4089
+ case "review-staging": {
4090
+ // #4358: deterministic staging->main review pre-dispatch; writes one reviewer brief per roster domain.
4091
+ const { reviewStagingFromCli } = await import("./review-staging.mjs");
4092
+ exitCode = await reviewStagingFromCli(restArgs);
4093
+ break;
4094
+ }
4084
4095
  case "gc": {
4085
4096
  // Reaper (#4180): dry-run report by default; --apply reclaims only ForgeDock-owned resources (ledger, label, lane naming, own tmp).
4086
4097
  const { runGc } = await import("./gc.mjs");
@@ -4098,6 +4109,17 @@ switch (command) {
4098
4109
  }
4099
4110
  break;
4100
4111
  }
4112
+ case "check": {
4113
+ // #4364: the builder's in-session run of the engine's validate and pre-PR checks; local only
4114
+ const { checkFromCli } = await import("./engine-cli.mjs");
4115
+ try {
4116
+ exitCode = (await checkFromCli(restArgs)).exitCode;
4117
+ } catch (err) {
4118
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4119
+ exitCode = err.exitCode ?? 1;
4120
+ }
4121
+ break;
4122
+ }
4101
4123
  case "limits": {
4102
4124
  const { limitsFromCli } = await import("./engine-cli.mjs");
4103
4125
  try {
@@ -4139,6 +4161,16 @@ switch (command) {
4139
4161
  }
4140
4162
  break;
4141
4163
  }
4164
+ case "probe": {
4165
+ const { probeFromCli } = await import("./engine-cli.mjs");
4166
+ try {
4167
+ await probeFromCli(restArgs);
4168
+ } catch (err) {
4169
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4170
+ exitCode = 1;
4171
+ }
4172
+ break;
4173
+ }
4142
4174
  case "resume-stalled": {
4143
4175
  const { resumeStalledFromCli } = await import("./engine-cli.mjs");
4144
4176
  try {
package/bin/gc.mjs CHANGED
@@ -223,7 +223,7 @@ export async function planGc({ probes, scopes = new Set(GC_SCOPES), olderThanMs
223
223
  const sameHost = j.host === P.host;
224
224
  // A live owner on this host is a running lane, even past `expires` (long CI waits refresh it only on register).
225
225
  const stale = sameHost ? !(Number.isSafeInteger(j.pid) && P.alive(j.pid)) : Number(j.expires) < now;
226
- ledgers.push({ path, batch: j.batch, lane: j.lane, stale });
226
+ ledgers.push({ path, batch: j.batch, lane: j.lane, repo: j.repo ?? null, stale });
227
227
  for (const r of j.resources) {
228
228
  const type = TYPE_OF_KIND[r.kind];
229
229
  if (!type) continue;
@@ -343,6 +343,9 @@ export async function planGc({ probes, scopes = new Set(GC_SCOPES), olderThanMs
343
343
  const id = local ? name : `origin/${name}`;
344
344
  if (seen.has(`branch:${id}`) || liveIds.has(`branch:${name}`)) continue;
345
345
  const it = { type: "branch", id, branch: name, local, sizeBytes: null, ageMs: age(Number(ts) * 1000), owned: true, action: "report", reason: "" };
346
+ // #4361: a tracking ref is not origin. Missing on origin = a stale ref, pruned locally (no push, no gh lookups).
347
+ if (!local && originHeads === null) { it.reason = "cannot verify origin"; add(it); continue; }
348
+ if (!local && !originHeads.has(name)) { it.gone = true; it.action = "reclaim"; it.reason = "already gone on origin (stale tracking ref)"; add(it); continue; }
346
349
  const why = await branchBlock(name, { local });
347
350
  if (why) it.reason = why;
348
351
  else if (!(it.ageMs >= ttl("branch"))) it.reason = "younger than TTL";
@@ -355,6 +358,12 @@ export async function planGc({ probes, scopes = new Set(GC_SCOPES), olderThanMs
355
358
  }
356
359
  };
357
360
  await scan("refs/heads/", true);
361
+ // #4361: one ls-remote is the truth about origin; the local tracking refs may hold hundreds of long-deleted branches.
362
+ let originHeads = null;
363
+ try {
364
+ originHeads = new Set();
365
+ for (const l of String(await git(["ls-remote", "--heads", "origin"])).split("\n")) { const r = l.split("\t")[1]?.trim(); if (r?.startsWith("refs/heads/")) originHeads.add(r.slice(11)); }
366
+ } catch (e) { originHeads = null; notes.push(`git ls-remote origin failed: ${errText(e)}`); }
358
367
  await scan("refs/remotes/origin/", false);
359
368
  }
360
369
 
@@ -449,14 +458,23 @@ const SANDBOX_TEARDOWN = {
449
458
  };
450
459
  const defaultSandboxTeardown = (P) => async (provider, handle) => (SANDBOX_TEARDOWN[provider] ? SANDBOX_TEARDOWN[provider](P, handle) : false);
451
460
 
452
- /** Reclaim every owned item marked "reclaim"; release ledger entries that were reclaimed. Never touches anything else. */
453
- export async function applyGc(plan, probes) {
461
+ /** Remote branch deletes per `git push` (#4361): one round trip per chunk, not per branch. */
462
+ export const GC_PUSH_CHUNK = 50;
463
+
464
+ /**
465
+ * Reclaim every owned item marked "reclaim"; release ledger entries that were reclaimed. Never touches anything else.
466
+ * With a `deadline` (#4361) items not started by then are returned in `deferred` for a later run.
467
+ */
468
+ export async function applyGc(plan, probes, { deadline = null, now = () => Date.now() } = {}) {
454
469
  const P = probes, fs = P.fs ?? nodeFs;
455
- const reclaimed = [], failed = [];
470
+ const reclaimed = [], failed = [], deferred = [], remote = [];
456
471
  const git = (args) => P.git(args, { cwd: P.cwd });
472
+ const late = () => deadline != null && now() >= deadline;
457
473
  let prune = false;
458
474
  for (const it of plan.items) {
459
475
  if (!(it.owned === true && it.action === "reclaim")) continue;
476
+ if (it.type === "branch" && !it.local) { remote.push(it); continue; }
477
+ if (late()) { deferred.push(it); continue; }
460
478
  try {
461
479
  switch (it.type) {
462
480
  case "container": await P.docker(["rm", "-f", it.id]); break;
@@ -481,24 +499,37 @@ export async function applyGc(plan, probes) {
481
499
  break;
482
500
  case "branch":
483
501
  if (it.gone) break;
484
- if (it.local) await git(["branch", "-D", it.branch]);
485
- else await git(["push", "origin", "--delete", it.branch]);
502
+ await git(["branch", "-D", it.branch]);
486
503
  break;
487
504
  default: throw new Error(`unknown type ${it.type}`);
488
505
  }
489
506
  reclaimed.push(it);
490
507
  if (it.ledger) {
491
508
  const l = plan.ledgers.find((x) => x.path === it.ledger);
492
- if (l) { const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, fs }); if (led.path === l.path) led.release(it.kind, it.value); }
509
+ if (l) { const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, repo: l.repo, fs }); if (led.path === l.path) led.release(it.kind, it.value); }
493
510
  }
494
511
  } catch (e) { failed.push({ ...it, error: errText(e) }); }
495
512
  }
513
+ // #4361: stale tracking refs are pruned locally; branches still on origin are deleted GC_PUSH_CHUNK per push. A failed
514
+ // chunk is retried one branch at a time so each failure stays attributed to its branch.
515
+ const flush = async (items, cmd) => {
516
+ for (let i = 0; i < items.length; i += GC_PUSH_CHUNK) {
517
+ const chunk = items.slice(i, i + GC_PUSH_CHUNK);
518
+ if (late()) { deferred.push(...chunk); continue; }
519
+ try { await git(cmd(chunk)); reclaimed.push(...chunk); continue; } catch { /* retry singly */ }
520
+ for (const it of chunk) {
521
+ try { await git(cmd([it])); reclaimed.push(it); } catch (e) { failed.push({ ...it, error: errText(e) }); }
522
+ }
523
+ }
524
+ };
525
+ await flush(remote.filter((it) => it.gone), (c) => ["branch", "-r", "-d", ...c.map((it) => `origin/${it.branch}`)]);
526
+ await flush(remote.filter((it) => !it.gone), (c) => ["push", "origin", "--delete", ...c.map((it) => it.branch)]);
496
527
  if (prune) { try { await git(["worktree", "prune"]); } catch { /* best effort */ } }
497
528
  for (const l of plan.ledgers.filter((x) => x.stale)) {
498
- const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, fs });
529
+ const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, repo: l.repo, fs }); // #4432: repo-keyed file name
499
530
  if (led.path === l.path && led.read() && led.entries().length === 0) led.remove();
500
531
  }
501
- return { reclaimed, failed };
532
+ return { reclaimed, failed, deferred };
502
533
  }
503
534
 
504
535
  const fmtSize = (b) => (b == null ? "?" : b >= 1073741824 ? `${(b / 1073741824).toFixed(1)}G` : b >= 1048576 ? `${(b / 1048576).toFixed(1)}M` : b >= 1024 ? `${Math.round(b / 1024)}K` : `${b}B`);
@@ -521,7 +552,7 @@ export function formatGcReport(plan, { applied = null } = {}) {
521
552
  for (const n of plan.notes) L.push(` note: ${n}`);
522
553
  for (const f of applied?.failed ?? []) L.push(` failed: ${f.type} ${f.id}: ${f.error}`);
523
554
  const n = plan.items.filter((i) => i.owned && i.action === "reclaim").length;
524
- L.push(applied ? `gc: reclaimed ${applied.reclaimed.length} of ${n} item(s)` : `gc: ${n} item(s) reclaimable; run with --apply to reclaim`);
555
+ L.push(applied ? `gc: reclaimed ${applied.reclaimed.length} of ${n} item(s)${applied.deferred?.length ? `, ${applied.deferred.length} deferred` : ""}` : `gc: ${n} item(s) reclaimable; run with --apply to reclaim`);
525
556
  return L.join("\n") + "\n";
526
557
  }
527
558
 
@@ -541,18 +572,20 @@ export async function runGc(argv = [], { probes, hygiene = null, now = Date.now(
541
572
 
542
573
  /**
543
574
  * Plan + apply with forge.yaml hygiene settings (orchestrate preflight/admission GC, #4183). Never throws: a failure
544
- * becomes a note. Returns {plan, applied, reclaimed, bytes}.
575
+ * becomes a note. With `budgetMs` (#4361) apply stops at the deadline; the rest is counted in `deferred` for the next run.
576
+ * Returns {plan, applied, reclaimed, deferred, bytes}.
545
577
  */
546
- export async function runOwnedGc({ probes, hygiene = HYGIENE_DEFAULTS, scopes = new Set(GC_SCOPES), now = Date.now() } = {}) {
547
- let plan = null, applied = { reclaimed: [], failed: [] };
578
+ export async function runOwnedGc({ probes, hygiene = HYGIENE_DEFAULTS, scopes = new Set(GC_SCOPES), now = Date.now(), budgetMs = null, clock = () => Date.now() } = {}) {
579
+ let plan = null, applied = { reclaimed: [], failed: [], deferred: [] };
580
+ const deadline = Number.isFinite(budgetMs) ? clock() + budgetMs : null;
548
581
  try {
549
582
  plan = await planGc({ probes, scopes, ttlMs: hygieneTtls(hygiene), deleteMergedBranches: hygiene.delete_merged_branches !== false, now });
550
- applied = await applyGc(plan, probes);
583
+ applied = await applyGc(plan, probes, { deadline, now: clock });
551
584
  } catch (e) {
552
585
  plan = plan ?? { items: [], summary: {}, notes: [], ledgers: [], scopes: [...scopes], olderThanMs: LEDGER_TTL_MS };
553
586
  plan.notes.push(`gc failed: ${errText(e)}`);
554
587
  }
555
- return { plan, applied, reclaimed: applied.reclaimed.length, bytes: applied.reclaimed.reduce((s, i) => s + (i.sizeBytes ?? 0), 0) };
588
+ return { plan, applied, reclaimed: applied.reclaimed.length, deferred: applied.deferred?.length ?? 0, bytes: applied.reclaimed.reduce((s, i) => s + (i.sizeBytes ?? 0), 0) };
556
589
  }
557
590
 
558
591
  /**
@@ -569,10 +602,10 @@ export function hygieneCheck(plan, { applied = null, ttlMs = plan?.ttlMs ?? hygi
569
602
  const reclaimable = items.filter((i) => i.owned && i.action === "reclaim").length;
570
603
  const stale = items.filter((i) => !i.owned && i.ageMs != null && i.ageMs >= (ttlMs?.[i.type] ?? LEDGER_TTL_MS)).length;
571
604
  const bits = [`owned: ${parts.length ? parts.join(", ") : "none"}`];
572
- if (applied) bits.push(`reclaimed ${applied.reclaimed.length} of ${reclaimable}${applied.failed.length ? ` (${applied.failed.length} failed)` : ""}`);
605
+ if (applied) bits.push(`reclaimed ${applied.reclaimed.length} of ${reclaimable}${applied.failed.length ? ` (${applied.failed.length} failed)` : ""}${applied.deferred?.length ? ` (${applied.deferred.length} deferred)` : ""}`);
573
606
  else bits.push(`${reclaimable} reclaimable`);
574
607
  if (stale) bits.push(`${stale} unowned stale (report only)`);
575
- const pending = applied ? applied.failed.length : reclaimable;
608
+ const pending = applied ? applied.failed.length + (applied.deferred?.length ?? 0) : reclaimable;
576
609
  const status = pending > 0 || stale > 0 ? "warn" : "pass";
577
610
  let detail = bits.join("; ");
578
611
  if (!applied && reclaimable > 0) detail += ". Run: npx forgedock doctor --fix (or: npx forgedock gc --apply)";
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+ /**
5
+ * Re-read nudge (#4456): a Claude CLI PostToolUse hook the lean engine passes to builder sessions with `--settings`
6
+ * (bin/lean/effects.mjs rereadNudgeFlags). It counts reads per path for one session, using the #4410 telemetry's
7
+ * definition of a read (toolstats readPaths), and on exactly the REREAD_MIN-th read of a path it returns an
8
+ * `additionalContext` system reminder. Advisory only: it always exits 0 and an error produces no output.
9
+ * State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json (a per-run cache).
10
+ */
11
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { readPaths, REREAD_MIN } from "../lean/toolstats.mjs";
16
+
17
+ /** Paths one hook payload reads, resolved against the payload cwd so `sed -n … a.js` and Read `/abs/a.js` agree. */
18
+ export function payloadReads(payload) {
19
+ const inp = payload?.tool_input ?? {};
20
+ const path = inp.file_path ?? inp.path;
21
+ const u = { name: payload?.tool_name ?? null, path: typeof path === "string" ? path : null, cmd: typeof inp.command === "string" ? inp.command : null };
22
+ const cwd = typeof payload?.cwd === "string" && payload.cwd ? payload.cwd : null;
23
+ return [...new Set(readPaths(u).map((p) => (cwd ? resolve(cwd, p) : p)))];
24
+ }
25
+
26
+ /** Pure core: counts after this payload and the reminder to emit (null when no path just reached REREAD_MIN). */
27
+ export function nudgeFor(payload, counts = {}) {
28
+ const next = { ...counts }, hot = [];
29
+ for (const p of payloadReads(payload)) {
30
+ next[p] = (next[p] ?? 0) + 1;
31
+ if (next[p] === REREAD_MIN) hot.push(p);
32
+ }
33
+ const message = hot.length ? `${hot.join(", ")} ${hot.length > 1 ? "have" : "has"} now been read ${REREAD_MIN} times in this session: `
34
+ + "use your notes or the excerpt already in context; if you need a different part, read only that line range." : null;
35
+ return { counts: next, message };
36
+ }
37
+
38
+ export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =>
39
+ join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "session"}.json`);
40
+
41
+ /** One hook call: stdin JSON in, the hookSpecificOutput line (or nothing) out. Fails open. */
42
+ export function main(input, { dir } = {}) {
43
+ try {
44
+ const payload = JSON.parse(input);
45
+ if (!payload?.session_id) return "";
46
+ if (!payloadReads(payload).length) return "";
47
+ const file = stateFile(payload.session_id, dir);
48
+ let counts = {};
49
+ try { counts = JSON.parse(readFileSync(file, "utf8")) ?? {}; } catch { /* first read of this session */ }
50
+ const r = nudgeFor(payload, counts);
51
+ mkdirSync(dirname(file), { recursive: true });
52
+ writeFileSync(file, JSON.stringify(r.counts));
53
+ return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
54
+ } catch {
55
+ return "";
56
+ }
57
+ }
58
+
59
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
60
+ let input = "";
61
+ process.stdin.setEncoding("utf8");
62
+ process.stdin.on("data", (c) => { input += c; });
63
+ process.stdin.on("end", () => { process.stdout.write(main(input)); process.exitCode = 0; });
64
+ process.stdin.on("error", () => { process.exitCode = 0; });
65
+ }
package/bin/labels.json CHANGED
@@ -74,6 +74,11 @@
74
74
  "color": "B60205",
75
75
  "description": "Pipeline stalled — engine/tool failure (not a genuine human-judgment block). Managed by ForgeDock."
76
76
  },
77
+ {
78
+ "name": "awaiting-promotion",
79
+ "color": "C5DEF5",
80
+ "description": "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."
81
+ },
77
82
  {
78
83
  "name": "blocked-on-human-merge",
79
84
  "color": "006B75",