forgedock 1.16.2 → 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 (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
package/bin/engine.mjs CHANGED
@@ -4,13 +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, rmSync } from "node:fs";
7
8
  import { createHash, scryptSync } from "node:crypto";
8
9
  import { hostname as osHostname, homedir } from "node:os";
9
10
  import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
10
- 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";
11
12
  import { reconcileState } from "./engine/reconcile.mjs";
12
13
  import { makeProjector } from "./engine/projector.mjs";
13
- import { VALID_BACKENDS } from "./runner.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";
14
16
 
15
17
  // Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
16
18
  // diagnostics without duplicating the retry budget constant.
@@ -297,6 +299,12 @@ export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
297
299
  * @param {string} [opts.hostname] - injectable host name (tests).
298
300
  * @param {Function} [opts.killFn] - injectable `process.kill` (tests).
299
301
  */
302
+ // forge#3824: stamped on RUN_START for per-release cost attribution; null when unreadable.
303
+ export const ENGINE_VERSION = (() => {
304
+ try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || null; }
305
+ catch { return null; }
306
+ })();
307
+
300
308
  export async function runIssue(opts) {
301
309
  const { issue, dir, agentId, lane = "staging", io, runner,
302
310
  now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
@@ -322,12 +330,23 @@ export async function runIssue(opts) {
322
330
  waitLease = false,
323
331
  hostname = osHostname(),
324
332
  killFn = process.kill.bind(process),
325
- onProgress = () => {} } = opts;
333
+ onProgress = () => {},
334
+ // forge#3696: per-attempt telemetry seam (FORGE:TELEMETRY emitter, #3834). No-op by
335
+ // default; failures are swallowed so telemetry can never fail a phase.
336
+ emitTelemetry = () => {} } = opts;
326
337
 
327
338
  // forge#2452: extracted into makeProgressEmitter() — see its docstring for
328
339
  // the forge#2240 rationale this wrapper implements.
329
340
  const emitProgress = makeProgressEmitter(onProgress);
330
341
 
342
+ // forge#3833: one seam for coded outcome records that are not run-log phase events. Appends the
343
+ // event and forwards it to the telemetry hook (#3834 owns the FORGE:TELEMETRY writer), which can
344
+ // never fail the run.
345
+ const emitOutcome = async (rec) => {
346
+ appendEvent(dir, issue, rec);
347
+ try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a run */ }
348
+ };
349
+
331
350
  // forge#2452: extracted into validateRunIssueOptions() — see its docstring
332
351
  // for the forge#2054/#2313/#2329 rationale and required call-order (before
333
352
  // any state I/O or timer creation, unchanged from the inline block this
@@ -344,6 +363,7 @@ export async function runIssue(opts) {
344
363
  !isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }));
345
364
  const deferredResult = (r) => ({
346
365
  terminalReason: "deferred",
366
+ code: OUTCOME_CODES.LEASE_HELD,
347
367
  leaseUntil: r.lease.until,
348
368
  detail: `issue ${issue} leased by ${r.lease.by} until ${new Date(r.lease.until).toISOString()}`,
349
369
  });
@@ -370,17 +390,63 @@ export async function runIssue(opts) {
370
390
  remote = cur;
371
391
  ({ state, action } = reconcileState(local, remote));
372
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
+ }
414
+ // forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
415
+ let repoPromise = null;
416
+ const resolveRepo = () => (repoPromise ??= (async () => {
417
+ if (repo) return String(repo).trim();
418
+ try {
419
+ const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
420
+ return String(out || "").trim() || null;
421
+ } catch { return null; }
422
+ })());
423
+
424
+ const runMeta = async () => ({ version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo: await resolveRepo().catch(() => null) });
373
425
  if (!state) {
374
426
  state = freshState(issue, lane);
375
- appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
427
+ appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane, ...(await runMeta()) });
376
428
  } else if (action === "hydrate") {
377
429
  // GitHub is ahead of (or the only source of) local state. Rebuild the
378
430
  // local run-log to match the remote compact index so downstream
379
431
  // `deriveState(readLog(...))` calls stay consistent — otherwise the
380
432
  // post-commit re-derive below would fold over an empty/stale local log
381
433
  // and regress committed/issue/v (C2).
382
- rewriteLog(dir, issue, eventsFromIndex(state));
434
+ rewriteLog(dir, issue, eventsFromIndex(state, await runMeta()));
383
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
+ }
384
450
  }
385
451
 
386
452
  // forge#3511: explicit `--retry` reopens a transient `engine-error` terminal
@@ -393,12 +459,19 @@ export async function runIssue(opts) {
393
459
  // forge#3562: `phase-complete` is also retryable, but as a NEW cycle
394
460
  // (RUN_NEXT_CYCLE clears committed) rather than a reopen of the same one.
395
461
  let reopened = false;
462
+ let reopenUncommit = null;
396
463
  if (retry && state?.terminal) {
397
- 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" &&
398
471
  state.terminalReason !== "needs-human") {
399
472
  return {
400
473
  terminalReason: "not-retryable",
401
- 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)`,
402
475
  };
403
476
  }
404
477
  // forge#3571: a human clearing `needs-human` is the hand-back signal. Reopen
@@ -426,6 +499,13 @@ export async function runIssue(opts) {
426
499
  };
427
500
  }
428
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
+ }
429
509
  // forge#3562 (SEC-3): a new cycle discards `committed`, so check the live
430
510
  // issue first. A CLOSED issue has nothing left to run; an unreadable
431
511
  // snapshot fails closed rather than resetting a possibly-finished run.
@@ -455,6 +535,7 @@ export async function runIssue(opts) {
455
535
  appendEvent(dir, issue, {
456
536
  event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
457
537
  issue,
538
+ ...(reopenUncommit ? { uncommit: reopenUncommit } : {}),
458
539
  });
459
540
  state = deriveState(readLog(dir, issue));
460
541
  reopened = true;
@@ -494,14 +575,6 @@ export async function runIssue(opts) {
494
575
  // against a nonexistent ref and silently evaluated to 0 (forge#2174). The
495
576
  // build phase's `reconcile`/`detectOutcome` (bin/engine/phases.mjs) resolve
496
577
  // the real branch from the `FORGE:BUILDER` comment instead.
497
- let repoPromise = null;
498
- const resolveRepo = () => (repoPromise ??= (async () => {
499
- if (repo) return String(repo).trim();
500
- try {
501
- const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
502
- return String(out || "").trim() || null;
503
- } catch { return null; }
504
- })());
505
578
 
506
579
  // forge#3574: a relaunch after a crash mid rate-limit pause must not re-run
507
580
  // the phase early. Same-host resume reads the LOCAL run-log's lastRateLimit
@@ -550,7 +623,7 @@ export async function runIssue(opts) {
550
623
  // forge#3512: investigate persists `invalid` on its own commit. Only when investigate is
551
624
  // the LAST committed phase: a later remediate/close commit carrying `invalid` is a handoff.
552
625
  (state.committed[state.committed.length - 1] === "investigate" && state.terminalReason === "invalid")))
553
- return await terminate(state, state.terminalReason);
626
+ return await terminate(state, state.terminalReason, undefined, state.outcomeCode);
554
627
  // forge#2352: state-vs-GitHub divergence guard. Every phase's own
555
628
  // `entryCondition` only ever checked `state.committed` (local run-log
556
629
  // progress) — never the issue's live GitHub state/labels — so a phase
@@ -593,7 +666,7 @@ export async function runIssue(opts) {
593
666
  if (isDead) {
594
667
  const detail = `issue ${issue} is ${snap.state === "CLOSED" ? "closed" : "open"} ` +
595
668
  `with divergent state before phase ${phase.id} (labels: ${snap.labels.join(", ") || "none"})`;
596
- return await terminate(state, "invalid", detail);
669
+ return await terminate(state, "invalid", detail, OUTCOME_CODES.INVALID);
597
670
  }
598
671
  // `needs-human` is a PAUSE, not a death sentence — a human may still
599
672
  // resolve it, and /orchestrate's classify_predecessor_state() already
@@ -699,7 +772,7 @@ export async function runIssue(opts) {
699
772
  let pauseCount = 0;
700
773
  for (;;) {
701
774
  try {
702
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId });
775
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry });
703
776
  break;
704
777
  } catch (e) {
705
778
  const resetAtMs = e.resetAtEpochMs;
@@ -756,7 +829,7 @@ export async function runIssue(opts) {
756
829
  // "needs-human" — this is the engine/tool breaking, not a genuine
757
830
  // human-judgment block (see #2244/#2261). Any other thrown error is
758
831
  // a true unexpected crash and keeps propagating unchanged.
759
- 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)) {
760
833
  // forge#2241: when the runner attached a session-limit reset time
761
834
  // (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
762
835
  // for a genuine session-limit CLI_BACKEND_FAILED — never
@@ -766,7 +839,7 @@ export async function runIssue(opts) {
766
839
  const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
767
840
  const detail = e.code === "CLI_MAX_TURNS"
768
841
  ? `phase ${phase.id}: CLI_MAX_TURNS - max-turns exhausted${e.numTurns != null ? ` after ${e.numTurns} turns` : ""}: ${e.message}`
769
- : `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
842
+ : `phase ${phase.id}: ${e.code ?? "DISK_QUOTA"} - ${e.message}${resetSuffix}`;
770
843
  // forge#2240 (review finding): this fail-fast path previously left
771
844
  // phase_exit unreported — a caller tailing progress output would see
772
845
  // "→ phase X started" and then nothing, dangling exactly on the
@@ -788,7 +861,7 @@ export async function runIssue(opts) {
788
861
  // harmless idempotent safety net for the `throw e;` path.
789
862
  clearInterval(renewTimer);
790
863
  if (pendingRenewal) await pendingRenewal;
791
- return await terminate(state, "engine-error", detail);
864
+ return await terminate(state, "engine-error", detail, codeForRunnerError(e));
792
865
  }
793
866
  throw e;
794
867
  } finally {
@@ -818,11 +891,15 @@ export async function runIssue(opts) {
818
891
  // PHASE_COMMIT would strand the PR (`deferred` is not --retry-able, and a committed remediate lets `close` run on
819
892
  // an unmerged PR). Release our lease and exit with nothing written; the next dispatch re-picks remediate.
820
893
  if (phase.id === "remediate" && outcome.reason === "deferred") {
821
- await projector.writeState(issue, { ...state, lease: null });
822
- return { terminalReason: "deferred", detail: outcome.detail };
894
+ await releaseLease(state);
895
+ await emitOutcome({ event: "PHASE_DEFERRED", phase: phase.id, code: normalizeCode(outcome.code), reason: outcome.detail });
896
+ return { terminalReason: "deferred", code: normalizeCode(outcome.code), detail: outcome.detail };
823
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);
824
901
  if (!isRemediationHandoff)
825
- return await terminate(state, outcome.reason || "needs-human", outcome.detail);
902
+ return await terminate(state, outcome.reason || "needs-human", outcome.detail, outcome.code);
826
903
  } else {
827
904
  // committed
828
905
  // forge#2321: only report a phase_exit if a matching phase_enter was
@@ -854,11 +931,15 @@ export async function runIssue(opts) {
854
931
  // `decomposed`, remediate `merged`/`awaiting-merge`/`needs-human`). Persist them on the
855
932
  // commit event too, so a crash before RUN_TERMINAL or a rebuild cannot drop them.
856
933
  const commitReason = blockedReason ?? outcome.terminalReason ?? null;
934
+ // forge#3833: the handoff/blocked cause, persisted on the commit event like the reason.
935
+ const commitCode = commitReason ? normalizeCode(outcome.code ?? codeForTerminalReason(commitReason)) : null;
857
936
  appendEvent(dir, issue, {
858
- event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs, usage: outcome.usage ?? null,
937
+ event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs,
938
+ // forge#3696: `usage` is cumulative over every attempt of the phase; the last attempt's alone is `last_attempt_usage`.
939
+ usage: outcome.usage ?? null, last_attempt_usage: outcome.last_attempt_usage ?? null,
859
940
  // forge#3589: model + turn count per phase (null when the backend does not report them).
860
941
  model: outcome.model ?? null, turns: outcome.turns ?? null,
861
- ...(commitReason ? { terminalReason: commitReason } : {}),
942
+ ...(commitReason ? { terminalReason: commitReason, code: commitCode } : {}),
862
943
  });
863
944
  state = deriveState(readLog(dir, issue));
864
945
  const terminalReason = blockedReason ?? outcome.terminalReason;
@@ -882,37 +963,96 @@ export async function runIssue(opts) {
882
963
  // is seen), phase.id is "decompose" — not "investigate" — so this
883
964
  // exemption does not apply and the normal terminate() path below fires,
884
965
  // ending the run for real.
885
- // build also hands off: the B5.5 size gate (NEEDS_DECOMPOSE) reports "decomposed".
886
- 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";
887
968
  // forge#3624: remediate AUTO-LANDED whose issue is not yet CLOSED+workflow:merged hands off
888
969
  // to `close` instead of ending the run (close is idempotent). Exempts BOTH terminate paths.
889
970
  const isCloseHandoff = phase.id === "remediate" && outcome.status === "committed" &&
890
971
  outcome.outputs?.closeHandoff === true && terminalReason === "merged";
891
972
  if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
892
973
  !isDecomposeHandoff && !isRemediationHandoff && !isCloseHandoff)
893
- return await terminate(state, terminalReason, outcome.detail);
974
+ return await terminate(state, terminalReason, outcome.detail, commitCode);
894
975
  if (phase.isTerminalAfter && !isCloseHandoff && phase.isTerminalAfter(state))
895
976
  return await terminate(state, state.terminalReason || "merged");
896
977
  }
897
978
  return await terminate(state, state.terminalReason || "merged");
898
979
 
899
- async function terminate(s, reason, detail) {
900
- appendEvent(dir, issue, { event: "RUN_TERMINAL", reason });
901
- const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, lease: null };
980
+ // Re-read remote state and return the lease another LIVE agent holds, if any. `undefined` = none;
981
+ // `null` = the read failed (unknown). Our stale in-memory snapshot must never overwrite a live
982
+ // holder's lease. Residual TOCTOU: projector.writeState has no CAS, so this narrows the window
983
+ // from a whole phase to one read/write round trip.
984
+ async function foreignLiveLease() {
985
+ try {
986
+ const r = await projector.readState(issue);
987
+ return leaseHeld(r) ? r.lease : undefined;
988
+ } catch { return null; }
989
+ }
990
+
991
+ // Guarded lease release (race: another engine claimed the lease after ours expired).
992
+ // Writes `lease: null` only when no other live agent holds it; on read failure, fail safe (skip).
993
+ async function releaseLease(s) {
994
+ if (await foreignLiveLease() !== undefined) return false;
995
+ await projector.writeState(issue, { ...s, lease: null });
996
+ return true;
997
+ }
998
+
999
+ async function terminate(s, reason, detail, code) {
1000
+ // forge#3833: absent -> the reason's own code; unknown -> `unclassified` with the raw text kept in `detail`.
1001
+ const outcomeCode = code == null ? codeForTerminalReason(reason) : normalizeCode(code);
1002
+ appendEvent(dir, issue, { event: "RUN_TERMINAL", reason, code: outcomeCode,
1003
+ ...(outcomeCode === UNCLASSIFIED && detail ? { detail } : {}) });
1004
+ // forge#3835: forward the terminal record to the telemetry seam (best-effort, never fails the run).
1005
+ try { await emitTelemetry?.({ event: "RUN_TERMINAL", reason, code: outcomeCode, issue }); } catch { /* telemetry never fails a run */ }
1006
+ // The terminal write must always land; only the lease field is preserved when another live
1007
+ // agent holds it. A failed read falls back to clearing (a lost terminal write would strand the issue).
1008
+ const foreign = await foreignLiveLease();
1009
+ const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, outcomeCode, lease: foreign ?? null };
902
1010
  await projector.writeState(issue, final);
1011
+ // forge#3566: the terminal projection must not leave a stranded `workflow:remediating`
1012
+ // next to the terminal label. Best-effort: runs after the terminal write and never throws.
1013
+ // forge#3876: skipped when another live engine holds the lease (it owns the labels); an unreadable lease (null) falls back to cleanup.
1014
+ let wasRemediating = false;
1015
+ if (foreign == null && (reason === "needs-human" || reason === "engine-error")) {
1016
+ try {
1017
+ const lv = JSON.parse(await io.gh(["issue", "view", String(issue), "--json", "labels"]));
1018
+ wasRemediating = (lv?.labels ?? []).some((l) => (typeof l === "string" ? l : l?.name) === "workflow:remediating");
1019
+ } catch { /* unreadable: treat as not remediating */ }
1020
+ try { await projector.removeLabel(issue, "workflow:remediating"); }
1021
+ catch { /* best-effort: a transient label failure must not lose the terminal state */ }
1022
+ }
903
1023
  if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
904
1024
  // forge#2261: a distinct label for engine/tool-level failures (broken CLI
905
1025
  // invocation, exhausted retries where the runner itself never once
906
1026
  // succeeded) — NOT needs-human. /orchestrate's classify_predecessor_state()
907
1027
  // (commands/orchestrate/phase-4-execution.md) must not treat this as GATED:
908
1028
  // there is no human decision pending, just a tool that needs to be re-run.
909
- else if (reason === "engine-error") await projector.setLabel(issue, "workflow:engine-error");
910
- return { terminalReason: reason, detail };
1029
+ else if (reason === "engine-error") {
1030
+ await projector.setLabel(issue, "workflow:engine-error");
1031
+ // A crashed remediate has no human signal otherwise (remediate.md Stranded-state rule).
1032
+ if (wasRemediating) {
1033
+ try { await projector.setLabel(issue, "needs-human"); } catch { /* best-effort */ }
1034
+ }
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);
1039
+ return { terminalReason: reason, code: outcomeCode, detail };
911
1040
  }
912
1041
  }
913
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
+
914
1054
  async function runPhaseWithRetry(phase, state, ctx) {
915
- const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId } = ctx;
1055
+ const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
916
1056
  // forge#3499: build the phase's full argument set ONCE, up front. Every
917
1057
  // work-on phase sub-skill stops with BLOCKED when a required flag is absent,
918
1058
  // so an incomplete/invalid set is a deterministic engine failure — fail
@@ -924,8 +1064,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
924
1064
  } catch (e) {
925
1065
  if (e?.code !== "PHASE_ARGS_INVALID") throw e;
926
1066
  const detail = `phase ${phase.id} args unresolved: ${e.message}`;
927
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, maxAttempts });
928
- return { status: "blocked", detail, reason: "engine-error", usage: null };
1067
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, code: OUTCOME_CODES.ENGINE_ERROR, maxAttempts });
1068
+ return { status: "blocked", detail, reason: "engine-error", code: OUTCOME_CODES.ENGINE_ERROR, usage: null };
929
1069
  }
930
1070
  // forge#2261: true only if EVERY attempt failed by the runner itself
931
1071
  // throwing (never once reached phase.detectOutcome()). This is the signal
@@ -936,6 +1076,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
936
1076
  // Flips to false the instant any attempt's runner() call succeeds, even if
937
1077
  // that attempt's detectOutcome() itself reports failure.
938
1078
  let allAttemptsThrew = true;
1079
+ // forge#3833: code of the most recent failed attempt (an attempt whose runner threw counts as engine.error).
1080
+ let lastCode = UNCLASSIFIED;
939
1081
  // forge#2377: the last successful attempt's `usage` (from runner()'s ==
940
1082
  // runCommand()'s resolved value — {input_tokens, output_tokens,
941
1083
  // cache_creation_input_tokens, cache_read_input_tokens} on the API
@@ -945,9 +1087,28 @@ async function runPhaseWithRetry(phase, state, ctx) {
945
1087
  // attempt (if any) set it to, which is correct: it always reflects the
946
1088
  // most recent attempt that actually produced a result.
947
1089
  let lastUsage = null;
1090
+ // forge#3696: usage summed over every attempt (null if none reported usage — never 0).
1091
+ let cumulativeUsage = null;
1092
+ const addUsage = (u) => {
1093
+ if (!u || typeof u !== "object") return;
1094
+ cumulativeUsage ??= { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
1095
+ for (const k of Object.keys(cumulativeUsage)) cumulativeUsage[k] += Number.isFinite(u[k]) ? u[k] : 0;
1096
+ };
1097
+ // One PHASE_ATTEMPT_USAGE event per attempt (null for anything unreported), then the
1098
+ // telemetry seam, which must never throw into the phase.
1099
+ const recordAttempt = async (attempt, usage, t0, extra) => {
1100
+ const rec = {
1101
+ event: "PHASE_ATTEMPT_USAGE", phase: phase.id, attempt,
1102
+ usage: usage ?? null, duration_ms: Math.round(performance.now() - t0),
1103
+ cost_usd: extra?.cost_usd ?? null, model: extra?.model ?? null, turns: extra?.turns ?? null,
1104
+ };
1105
+ appendEvent(dir, issue, rec);
1106
+ try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
1107
+ };
948
1108
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
949
1109
  appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
950
1110
  let result;
1111
+ const t0 = performance.now();
951
1112
  try {
952
1113
  result = await runner({
953
1114
  commandsDir, commandName: phase.command, args: phaseArgs,
@@ -978,19 +1139,30 @@ async function runPhaseWithRetry(phase, state, ctx) {
978
1139
  // NOT a commit: record the exhausted attempt, then rethrow so runIssue terminates
979
1140
  // `engine-error` with no PHASE_COMMIT — `--retry` then re-enters this same phase.
980
1141
  if (e.code === "CLI_MAX_TURNS") {
1142
+ addUsage(e.usage);
1143
+ await recordAttempt(attempt, e.usage, t0, { turns: e.numTurns });
981
1144
  appendEvent(dir, issue, {
982
- event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts,
1145
+ event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts,
983
1146
  turns: e.numTurns ?? null, usage: e.usage ?? null,
984
1147
  });
985
1148
  throw e;
986
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
+ }
987
1157
  if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
988
1158
  // forge#2377: no `usage` field here — the runner threw, so no result
989
1159
  // (and therefore no usage data) was ever produced for this attempt.
990
1160
  // Do not fabricate a value; omitting the field (rather than a stale
991
1161
  // `lastUsage` from a prior attempt) keeps this event's usage
992
1162
  // trustworthy as "usage actually observed on this attempt".
993
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts });
1163
+ await recordAttempt(attempt, null, t0, null);
1164
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
1165
+ lastCode = OUTCOME_CODES.ENGINE_ERROR;
994
1166
  continue;
995
1167
  }
996
1168
  allAttemptsThrew = false;
@@ -998,9 +1170,11 @@ async function runPhaseWithRetry(phase, state, ctx) {
998
1170
  // forge#3589: model/turns recorded alongside usage (additive; null when absent).
999
1171
  const lastModel = result?.cliModel ?? result?.model ?? null;
1000
1172
  const lastTurns = result?.turns ?? null;
1173
+ addUsage(lastUsage);
1174
+ await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns });
1001
1175
  const outcome = await phase.detectOutcome(state, io, result);
1002
- if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage, model: lastModel, turns: lastTurns };
1003
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
1176
+ if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: cumulativeUsage, last_attempt_usage: lastUsage, model: lastModel, turns: lastTurns };
1177
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, code: normalizeCode(outcome.code), maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
1004
1178
  // forge#2176: a phase's detectOutcome can mark a failure as a known,
1005
1179
  // state-derived fixed point — re-running the phase's runner is
1006
1180
  // guaranteed to reproduce the identical failure (e.g. the build phase's
@@ -1011,11 +1185,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
1011
1185
  // Absent/undefined `retryable` defaults to retryable (existing behavior
1012
1186
  // for every phase that doesn't opt in — investigate/context/architect/
1013
1187
  // review/close — is unchanged, preserving transient-failure retries).
1188
+ lastCode = normalizeCode(outcome.code);
1014
1189
  if (outcome.retryable === false) {
1015
1190
  // forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
1016
1191
  // forward `handoff` so only an explicit opt-in escalation is honoured; a
1017
1192
  // no-signal failure carries no handoff and stops at needs-human.
1018
- return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, usage: lastUsage };
1193
+ return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
1019
1194
  }
1020
1195
  }
1021
1196
  // Exhausted transient retries → escalate (spec §7).
@@ -1033,18 +1208,22 @@ async function runPhaseWithRetry(phase, state, ctx) {
1033
1208
  status: "blocked",
1034
1209
  detail: `phase ${phase.id} failed after ${maxAttempts} attempts (runner threw every attempt)`,
1035
1210
  reason: "engine-error",
1211
+ code: OUTCOME_CODES.ENGINE_ERROR,
1036
1212
  usage: null,
1037
1213
  };
1038
1214
  }
1039
- return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, usage: lastUsage };
1215
+ return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
1040
1216
  }
1041
1217
 
1042
1218
  /**
1043
1219
  * Build a run-log event sequence that reproduces a compact FORGE:STATE index,
1044
1220
  * used to reconstruct the local run-log on hydrate (C2).
1045
1221
  */
1046
- function eventsFromIndex(idx) {
1047
- const events = [{ event: "RUN_START", issue: idx.issue, run: idx.run, lane: idx.lane }];
1222
+ function eventsFromIndex(idx, meta = {}) {
1223
+ // forge#3824: `hydrated` marks a reconstructed log (no usage data) so cost-report can exclude it.
1224
+ const events = [{ event: "RUN_START", issue: idx.issue, run: idx.run, lane: idx.lane,
1225
+ version: meta.version ?? null, forge_version: meta.forge_version ?? meta.version ?? null,
1226
+ repo: meta.repo ?? null, hydrated: true }];
1048
1227
  for (const phase of idx.committed) {
1049
1228
  const outputs = {};
1050
1229
  if (phase === "build" && idx.branch) outputs.branch = idx.branch;
@@ -1057,15 +1236,15 @@ function eventsFromIndex(idx) {
1057
1236
  // would terminate the run. Nothing to attach it to when no phase is committed.
1058
1237
  if (!idx.terminal && idx.terminalReason && idx.committed.length > 0) {
1059
1238
  for (let i = events.length - 1; i >= 0; i--) {
1060
- if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; break; }
1239
+ if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; if (idx.outcomeCode) events[i].code = idx.outcomeCode; break; }
1061
1240
  }
1062
1241
  }
1063
- if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
1242
+ if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason, code: normalizeCode(idx.outcomeCode ?? codeForTerminalReason(idx.terminalReason)) });
1064
1243
  return events;
1065
1244
  }
1066
1245
 
1067
1246
  function freshState(issue, lane) {
1068
1247
  return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
1069
1248
  branch: null, pr: null, terminal: false, terminalReason: null, lease: null,
1070
- lastRateLimit: null };
1249
+ lastRateLimit: null, outcomeCode: null };
1071
1250
  }
package/bin/forgedock.mjs CHANGED
@@ -3214,6 +3214,7 @@ function help() {
3214
3214
  ["npx forgedock config migrate [dir]", "Backfill missing optional sections into an existing forge.yaml (idempotent)"],
3215
3215
  ["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
3216
3216
  ["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
3217
+ ["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)"],
3217
3218
  ["npx forgedock doctor", "Check installation health"],
3218
3219
  ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
3219
3220
  ["npx forgedock update", "Pull latest & reinstall"],
@@ -3783,7 +3784,7 @@ async function watch() {
3783
3784
  const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3784
3785
  const KNOWN_COMMANDS = new Set([
3785
3786
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3786
- "run", "backend-check", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
3787
+ "run", "backend-check", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
3787
3788
  "version", "--version", "-v",
3788
3789
  ]);
3789
3790
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -3971,7 +3972,7 @@ switch (command) {
3971
3972
  // an uncaught throw, which already set exitCode = 1 below. Now that
3972
3973
  // runIssue() resolves cleanly with a terminalReason instead of throwing,
3973
3974
  // preserve that same non-zero exit code for this reason too.
3974
- 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;
3975
3976
  // forge#3574: a lease-held deferral did NOT run the issue; exit 75
3976
3977
  // (EX_TEMPFAIL) so orchestrators neither count it as done nor as a failure.
3977
3978
  if (result && result.terminalReason === "deferred") exitCode = 75;
@@ -4003,6 +4004,11 @@ switch (command) {
4003
4004
  await runReport(restArgs);
4004
4005
  break;
4005
4006
  }
4007
+ case "stats": {
4008
+ const { runStats } = await import("./stats/index.mjs");
4009
+ exitCode = await runStats(restArgs);
4010
+ break;
4011
+ }
4006
4012
  case "watch":
4007
4013
  await watch();
4008
4014
  break;
@@ -66,6 +66,7 @@ import { dirname, join, resolve } from "path";
66
66
  import { existsSync, readFileSync } from "fs";
67
67
  import { execFileSync } from "child_process";
68
68
  import { PHASE_MARKERS as PHASE_MARKER_REGISTRY } from "../../packages/protocol/src/phases.js";
69
+ import { normalizeCode, codeForTerminalReason } from "../engine/outcome-codes.mjs";
69
70
 
70
71
  // ---------------------------------------------------------------------------
71
72
  // Bootstrap
@@ -236,11 +237,18 @@ async function main() {
236
237
  if (freshLog.length && deriveState(freshLog).committed.includes(phaseId)) return;
237
238
 
238
239
  if (bootstrapped) {
240
+ // forge#3824: attribute the run to a release and repo (best-effort; null when unavailable).
241
+ let version = null, repo = null;
242
+ try { version = JSON.parse(readFileSync(join(FORGE_HOME, "package.json"), "utf8")).version || null; } catch { /* fail-soft */ }
243
+ try { repo = String(await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]) || "").trim() || null; } catch { /* fail-soft */ }
239
244
  appendEvent(runLogDir, issueNumber, {
240
245
  event: "RUN_START",
241
246
  issue: issueNumber,
242
247
  run: state.run,
243
248
  lane: state.lane,
249
+ version,
250
+ forge_version: version,
251
+ repo,
244
252
  source: "interactive",
245
253
  });
246
254
  }
@@ -258,6 +266,8 @@ async function main() {
258
266
  appendEvent(runLogDir, issueNumber, {
259
267
  event: "RUN_TERMINAL",
260
268
  reason: terminalReason,
269
+ // forge#3833: same closed enum as the headless engine (absent -> the reason's own code).
270
+ code: normalizeCode(outcome.code ?? codeForTerminalReason(terminalReason)),
261
271
  source: "interactive",
262
272
  });
263
273
  state = deriveState(readLog(runLogDir, issueNumber));