forgedock 1.16.1 → 1.17.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 (100) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +105 -25
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +13 -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 +12 -1
  10. package/bin/engine.mjs +151 -39
  11. package/bin/forgedock.mjs +7 -1
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +187 -0
  14. package/bin/runner.mjs +7 -0
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +140 -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 +224 -0
  23. package/commands/cleanup.md +83 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/orchestrate/phase-1-resolve.md +6 -3
  26. package/commands/orchestrate/phase-4-execution.md +25 -10
  27. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  28. package/commands/pipeline-health/phase-1-context.md +2 -0
  29. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  30. package/commands/pipeline-status.md +21 -1
  31. package/commands/quality-gate.md +4 -4
  32. package/commands/review-pr-agents/protocols.md +1 -0
  33. package/commands/review-pr-agents.md +10 -2
  34. package/commands/review-pr-staging.md +134 -57
  35. package/commands/review-pr.md +135 -119
  36. package/commands/work-on/build/implement.md +26 -5
  37. package/commands/work-on/build/validate.md +5 -4
  38. package/commands/work-on/build.md +143 -80
  39. package/commands/work-on/close.md +19 -8
  40. package/commands/work-on/decompose.md +50 -8
  41. package/commands/work-on/investigate.md +14 -7
  42. package/commands/work-on/remediate.md +40 -14
  43. package/commands/work-on/review.md +22 -5
  44. package/commands/work-on.md +6 -5
  45. package/docs/CONFIG.md +37 -2
  46. package/docs/OUTCOME-CODES.md +48 -0
  47. package/docs/RELEASE-NOTES.md +12 -0
  48. package/docs/STATS.md +62 -0
  49. package/docs/TELEMETRY.md +65 -0
  50. package/docs/WORK-ON-RUNTIME.md +14 -1
  51. package/docs/site/command-reference.md +1 -1
  52. package/docs/spec/forge-protocol-v1.md +1 -1
  53. package/docs/spec/label-state-machine.md +1 -1
  54. package/package.json +3 -2
  55. package/scripts/builder-resume-match.test.sh +58 -0
  56. package/scripts/cap-findings.sh +110 -0
  57. package/scripts/cap-findings.test.sh +130 -0
  58. package/scripts/check-spec-markers.sh +4 -0
  59. package/scripts/classify-finding.sh +65 -16
  60. package/scripts/classify-finding.test.sh +43 -1
  61. package/scripts/classify-grep-exit.sh +20 -0
  62. package/scripts/classify-grep-exit.test.sh +46 -0
  63. package/scripts/close-parent-c3.test.sh +54 -0
  64. package/scripts/cost-report.sh +146 -0
  65. package/scripts/cost-report.test.sh +80 -0
  66. package/scripts/diff-size.sh +41 -3
  67. package/scripts/diff-size.test.sh +47 -0
  68. package/scripts/doctor-pipeline-state.sh +43 -0
  69. package/scripts/file-deferred-followups.sh +159 -0
  70. package/scripts/file-deferred-followups.test.sh +106 -0
  71. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  72. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  73. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  74. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  75. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  76. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  77. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  78. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  79. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  80. package/scripts/forge-root.test.sh +18 -5
  81. package/scripts/lib/fence-bootstrap.sh +4 -3
  82. package/scripts/lib/forge-resolve.sh +13 -4
  83. package/scripts/lib/pr-issue-number.sh +18 -0
  84. package/scripts/lib/review-template-resolve.sh +41 -0
  85. package/scripts/lib/telemetry.sh +168 -0
  86. package/scripts/remediate-liveness.sh +132 -0
  87. package/scripts/review-acceptance-gate.sh +141 -0
  88. package/scripts/review-acceptance-gate.test.sh +103 -0
  89. package/scripts/review-pr-domain-map.test.sh +8 -3
  90. package/scripts/review-pr-gate2.test.sh +15 -0
  91. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  92. package/scripts/review-pr-staging-scope.test.sh +65 -5
  93. package/scripts/review-prepare.sh +517 -0
  94. package/scripts/review-prepare.test.sh +270 -0
  95. package/scripts/review-roster.sh +172 -0
  96. package/scripts/review-roster.test.sh +163 -0
  97. package/scripts/run-shell-tests.sh +3 -0
  98. package/scripts/telemetry.test.sh +106 -0
  99. package/scripts/wait-ci-green.sh +8 -1
  100. package/templates/forge.yaml.minimal +9 -0
package/bin/engine.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  * GitHub state (phase.detectOutcome). All effects are injected → fully testable.
5
5
  */
6
6
  import { fileURLToPath } from "node:url";
7
+ import { readFileSync } from "node:fs";
7
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";
@@ -11,6 +12,7 @@ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postC
11
12
  import { reconcileState } from "./engine/reconcile.mjs";
12
13
  import { makeProjector } from "./engine/projector.mjs";
13
14
  import { VALID_BACKENDS } from "./runner.mjs";
15
+ import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason } 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,16 +390,27 @@ export async function runIssue(opts) {
370
390
  remote = cur;
371
391
  ({ state, action } = reconcileState(local, remote));
372
392
  }
393
+ // forge#3824: hoisted so RUN_START can carry the repo (lazy, memoised; null when unresolved).
394
+ let repoPromise = null;
395
+ const resolveRepo = () => (repoPromise ??= (async () => {
396
+ if (repo) return String(repo).trim();
397
+ try {
398
+ const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
399
+ return String(out || "").trim() || null;
400
+ } catch { return null; }
401
+ })());
402
+
403
+ const runMeta = async () => ({ version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo: await resolveRepo().catch(() => null) });
373
404
  if (!state) {
374
405
  state = freshState(issue, lane);
375
- appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
406
+ appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane, ...(await runMeta()) });
376
407
  } else if (action === "hydrate") {
377
408
  // GitHub is ahead of (or the only source of) local state. Rebuild the
378
409
  // local run-log to match the remote compact index so downstream
379
410
  // `deriveState(readLog(...))` calls stay consistent — otherwise the
380
411
  // post-commit re-derive below would fold over an empty/stale local log
381
412
  // and regress committed/issue/v (C2).
382
- rewriteLog(dir, issue, eventsFromIndex(state));
413
+ rewriteLog(dir, issue, eventsFromIndex(state, await runMeta()));
383
414
  state = deriveState(readLog(dir, issue));
384
415
  }
385
416
 
@@ -494,14 +525,6 @@ export async function runIssue(opts) {
494
525
  // against a nonexistent ref and silently evaluated to 0 (forge#2174). The
495
526
  // build phase's `reconcile`/`detectOutcome` (bin/engine/phases.mjs) resolve
496
527
  // 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
528
 
506
529
  // forge#3574: a relaunch after a crash mid rate-limit pause must not re-run
507
530
  // the phase early. Same-host resume reads the LOCAL run-log's lastRateLimit
@@ -550,7 +573,7 @@ export async function runIssue(opts) {
550
573
  // forge#3512: investigate persists `invalid` on its own commit. Only when investigate is
551
574
  // the LAST committed phase: a later remediate/close commit carrying `invalid` is a handoff.
552
575
  (state.committed[state.committed.length - 1] === "investigate" && state.terminalReason === "invalid")))
553
- return await terminate(state, state.terminalReason);
576
+ return await terminate(state, state.terminalReason, undefined, state.outcomeCode);
554
577
  // forge#2352: state-vs-GitHub divergence guard. Every phase's own
555
578
  // `entryCondition` only ever checked `state.committed` (local run-log
556
579
  // progress) — never the issue's live GitHub state/labels — so a phase
@@ -593,7 +616,7 @@ export async function runIssue(opts) {
593
616
  if (isDead) {
594
617
  const detail = `issue ${issue} is ${snap.state === "CLOSED" ? "closed" : "open"} ` +
595
618
  `with divergent state before phase ${phase.id} (labels: ${snap.labels.join(", ") || "none"})`;
596
- return await terminate(state, "invalid", detail);
619
+ return await terminate(state, "invalid", detail, OUTCOME_CODES.INVALID);
597
620
  }
598
621
  // `needs-human` is a PAUSE, not a death sentence — a human may still
599
622
  // resolve it, and /orchestrate's classify_predecessor_state() already
@@ -699,7 +722,7 @@ export async function runIssue(opts) {
699
722
  let pauseCount = 0;
700
723
  for (;;) {
701
724
  try {
702
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo });
725
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry });
703
726
  break;
704
727
  } catch (e) {
705
728
  const resetAtMs = e.resetAtEpochMs;
@@ -788,7 +811,7 @@ export async function runIssue(opts) {
788
811
  // harmless idempotent safety net for the `throw e;` path.
789
812
  clearInterval(renewTimer);
790
813
  if (pendingRenewal) await pendingRenewal;
791
- return await terminate(state, "engine-error", detail);
814
+ return await terminate(state, "engine-error", detail, codeForRunnerError(e));
792
815
  }
793
816
  throw e;
794
817
  } finally {
@@ -814,8 +837,16 @@ export async function runIssue(opts) {
814
837
  (outcome.outputs?.pr ?? state.pr) != null;
815
838
  if (outcome.status === "blocked") {
816
839
  emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
840
+ // forge#3792: remediate deferring to a live earlier run is neither a commit nor a terminal: a RUN_TERMINAL or
841
+ // PHASE_COMMIT would strand the PR (`deferred` is not --retry-able, and a committed remediate lets `close` run on
842
+ // an unmerged PR). Release our lease and exit with nothing written; the next dispatch re-picks remediate.
843
+ if (phase.id === "remediate" && outcome.reason === "deferred") {
844
+ await releaseLease(state);
845
+ await emitOutcome({ event: "PHASE_DEFERRED", phase: phase.id, code: normalizeCode(outcome.code), reason: outcome.detail });
846
+ return { terminalReason: "deferred", code: normalizeCode(outcome.code), detail: outcome.detail };
847
+ }
817
848
  if (!isRemediationHandoff)
818
- return await terminate(state, outcome.reason || "needs-human", outcome.detail);
849
+ return await terminate(state, outcome.reason || "needs-human", outcome.detail, outcome.code);
819
850
  } else {
820
851
  // committed
821
852
  // forge#2321: only report a phase_exit if a matching phase_enter was
@@ -847,11 +878,15 @@ export async function runIssue(opts) {
847
878
  // `decomposed`, remediate `merged`/`awaiting-merge`/`needs-human`). Persist them on the
848
879
  // commit event too, so a crash before RUN_TERMINAL or a rebuild cannot drop them.
849
880
  const commitReason = blockedReason ?? outcome.terminalReason ?? null;
881
+ // forge#3833: the handoff/blocked cause, persisted on the commit event like the reason.
882
+ const commitCode = commitReason ? normalizeCode(outcome.code ?? codeForTerminalReason(commitReason)) : null;
850
883
  appendEvent(dir, issue, {
851
- event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs, usage: outcome.usage ?? null,
884
+ event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs,
885
+ // forge#3696: `usage` is cumulative over every attempt of the phase; the last attempt's alone is `last_attempt_usage`.
886
+ usage: outcome.usage ?? null, last_attempt_usage: outcome.last_attempt_usage ?? null,
852
887
  // forge#3589: model + turn count per phase (null when the backend does not report them).
853
888
  model: outcome.model ?? null, turns: outcome.turns ?? null,
854
- ...(commitReason ? { terminalReason: commitReason } : {}),
889
+ ...(commitReason ? { terminalReason: commitReason, code: commitCode } : {}),
855
890
  });
856
891
  state = deriveState(readLog(dir, issue));
857
892
  const terminalReason = blockedReason ?? outcome.terminalReason;
@@ -883,29 +918,74 @@ export async function runIssue(opts) {
883
918
  outcome.outputs?.closeHandoff === true && terminalReason === "merged";
884
919
  if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
885
920
  !isDecomposeHandoff && !isRemediationHandoff && !isCloseHandoff)
886
- return await terminate(state, terminalReason, outcome.detail);
921
+ return await terminate(state, terminalReason, outcome.detail, commitCode);
887
922
  if (phase.isTerminalAfter && !isCloseHandoff && phase.isTerminalAfter(state))
888
923
  return await terminate(state, state.terminalReason || "merged");
889
924
  }
890
925
  return await terminate(state, state.terminalReason || "merged");
891
926
 
892
- async function terminate(s, reason, detail) {
893
- appendEvent(dir, issue, { event: "RUN_TERMINAL", reason });
894
- const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, lease: null };
927
+ // Re-read remote state and return the lease another LIVE agent holds, if any. `undefined` = none;
928
+ // `null` = the read failed (unknown). Our stale in-memory snapshot must never overwrite a live
929
+ // holder's lease. Residual TOCTOU: projector.writeState has no CAS, so this narrows the window
930
+ // from a whole phase to one read/write round trip.
931
+ async function foreignLiveLease() {
932
+ try {
933
+ const r = await projector.readState(issue);
934
+ return leaseHeld(r) ? r.lease : undefined;
935
+ } catch { return null; }
936
+ }
937
+
938
+ // Guarded lease release (race: another engine claimed the lease after ours expired).
939
+ // Writes `lease: null` only when no other live agent holds it; on read failure, fail safe (skip).
940
+ async function releaseLease(s) {
941
+ if (await foreignLiveLease() !== undefined) return false;
942
+ await projector.writeState(issue, { ...s, lease: null });
943
+ return true;
944
+ }
945
+
946
+ async function terminate(s, reason, detail, code) {
947
+ // forge#3833: absent -> the reason's own code; unknown -> `unclassified` with the raw text kept in `detail`.
948
+ const outcomeCode = code == null ? codeForTerminalReason(reason) : normalizeCode(code);
949
+ appendEvent(dir, issue, { event: "RUN_TERMINAL", reason, code: outcomeCode,
950
+ ...(outcomeCode === UNCLASSIFIED && detail ? { detail } : {}) });
951
+ // forge#3835: forward the terminal record to the telemetry seam (best-effort, never fails the run).
952
+ try { await emitTelemetry?.({ event: "RUN_TERMINAL", reason, code: outcomeCode, issue }); } catch { /* telemetry never fails a run */ }
953
+ // The terminal write must always land; only the lease field is preserved when another live
954
+ // agent holds it. A failed read falls back to clearing (a lost terminal write would strand the issue).
955
+ const foreign = await foreignLiveLease();
956
+ const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, outcomeCode, lease: foreign ?? null };
895
957
  await projector.writeState(issue, final);
958
+ // forge#3566: the terminal projection must not leave a stranded `workflow:remediating`
959
+ // next to the terminal label. Best-effort: runs after the terminal write and never throws.
960
+ // forge#3876: skipped when another live engine holds the lease (it owns the labels); an unreadable lease (null) falls back to cleanup.
961
+ let wasRemediating = false;
962
+ if (foreign == null && (reason === "needs-human" || reason === "engine-error")) {
963
+ try {
964
+ const lv = JSON.parse(await io.gh(["issue", "view", String(issue), "--json", "labels"]));
965
+ wasRemediating = (lv?.labels ?? []).some((l) => (typeof l === "string" ? l : l?.name) === "workflow:remediating");
966
+ } catch { /* unreadable: treat as not remediating */ }
967
+ try { await projector.removeLabel(issue, "workflow:remediating"); }
968
+ catch { /* best-effort: a transient label failure must not lose the terminal state */ }
969
+ }
896
970
  if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
897
971
  // forge#2261: a distinct label for engine/tool-level failures (broken CLI
898
972
  // invocation, exhausted retries where the runner itself never once
899
973
  // succeeded) — NOT needs-human. /orchestrate's classify_predecessor_state()
900
974
  // (commands/orchestrate/phase-4-execution.md) must not treat this as GATED:
901
975
  // there is no human decision pending, just a tool that needs to be re-run.
902
- else if (reason === "engine-error") await projector.setLabel(issue, "workflow:engine-error");
903
- return { terminalReason: reason, detail };
976
+ else if (reason === "engine-error") {
977
+ await projector.setLabel(issue, "workflow:engine-error");
978
+ // A crashed remediate has no human signal otherwise (remediate.md Stranded-state rule).
979
+ if (wasRemediating) {
980
+ try { await projector.setLabel(issue, "needs-human"); } catch { /* best-effort */ }
981
+ }
982
+ }
983
+ return { terminalReason: reason, code: outcomeCode, detail };
904
984
  }
905
985
  }
906
986
 
907
987
  async function runPhaseWithRetry(phase, state, ctx) {
908
- const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo } = ctx;
988
+ const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
909
989
  // forge#3499: build the phase's full argument set ONCE, up front. Every
910
990
  // work-on phase sub-skill stops with BLOCKED when a required flag is absent,
911
991
  // so an incomplete/invalid set is a deterministic engine failure — fail
@@ -913,12 +993,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
913
993
  // `[issue]`-only invocation that burns the attempt budget.
914
994
  let phaseArgs;
915
995
  try {
916
- phaseArgs = await phase.buildArgs(state, { repo: await resolveRepo() }, io);
996
+ phaseArgs = await phase.buildArgs(state, { repo: await resolveRepo(), agentId }, io);
917
997
  } catch (e) {
918
998
  if (e?.code !== "PHASE_ARGS_INVALID") throw e;
919
999
  const detail = `phase ${phase.id} args unresolved: ${e.message}`;
920
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, maxAttempts });
921
- return { status: "blocked", detail, reason: "engine-error", usage: null };
1000
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, code: OUTCOME_CODES.ENGINE_ERROR, maxAttempts });
1001
+ return { status: "blocked", detail, reason: "engine-error", code: OUTCOME_CODES.ENGINE_ERROR, usage: null };
922
1002
  }
923
1003
  // forge#2261: true only if EVERY attempt failed by the runner itself
924
1004
  // throwing (never once reached phase.detectOutcome()). This is the signal
@@ -929,6 +1009,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
929
1009
  // Flips to false the instant any attempt's runner() call succeeds, even if
930
1010
  // that attempt's detectOutcome() itself reports failure.
931
1011
  let allAttemptsThrew = true;
1012
+ // forge#3833: code of the most recent failed attempt (an attempt whose runner threw counts as engine.error).
1013
+ let lastCode = UNCLASSIFIED;
932
1014
  // forge#2377: the last successful attempt's `usage` (from runner()'s ==
933
1015
  // runCommand()'s resolved value — {input_tokens, output_tokens,
934
1016
  // cache_creation_input_tokens, cache_read_input_tokens} on the API
@@ -938,9 +1020,28 @@ async function runPhaseWithRetry(phase, state, ctx) {
938
1020
  // attempt (if any) set it to, which is correct: it always reflects the
939
1021
  // most recent attempt that actually produced a result.
940
1022
  let lastUsage = null;
1023
+ // forge#3696: usage summed over every attempt (null if none reported usage — never 0).
1024
+ let cumulativeUsage = null;
1025
+ const addUsage = (u) => {
1026
+ if (!u || typeof u !== "object") return;
1027
+ cumulativeUsage ??= { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
1028
+ for (const k of Object.keys(cumulativeUsage)) cumulativeUsage[k] += Number.isFinite(u[k]) ? u[k] : 0;
1029
+ };
1030
+ // One PHASE_ATTEMPT_USAGE event per attempt (null for anything unreported), then the
1031
+ // telemetry seam, which must never throw into the phase.
1032
+ const recordAttempt = async (attempt, usage, t0, extra) => {
1033
+ const rec = {
1034
+ event: "PHASE_ATTEMPT_USAGE", phase: phase.id, attempt,
1035
+ usage: usage ?? null, duration_ms: Math.round(performance.now() - t0),
1036
+ cost_usd: extra?.cost_usd ?? null, model: extra?.model ?? null, turns: extra?.turns ?? null,
1037
+ };
1038
+ appendEvent(dir, issue, rec);
1039
+ try { await emitTelemetry?.({ ...rec, issue }); } catch { /* telemetry never fails a phase */ }
1040
+ };
941
1041
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
942
1042
  appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
943
1043
  let result;
1044
+ const t0 = performance.now();
944
1045
  try {
945
1046
  result = await runner({
946
1047
  commandsDir, commandName: phase.command, args: phaseArgs,
@@ -971,8 +1072,10 @@ async function runPhaseWithRetry(phase, state, ctx) {
971
1072
  // NOT a commit: record the exhausted attempt, then rethrow so runIssue terminates
972
1073
  // `engine-error` with no PHASE_COMMIT — `--retry` then re-enters this same phase.
973
1074
  if (e.code === "CLI_MAX_TURNS") {
1075
+ addUsage(e.usage);
1076
+ await recordAttempt(attempt, e.usage, t0, { turns: e.numTurns });
974
1077
  appendEvent(dir, issue, {
975
- event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts,
1078
+ event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts,
976
1079
  turns: e.numTurns ?? null, usage: e.usage ?? null,
977
1080
  });
978
1081
  throw e;
@@ -983,7 +1086,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
983
1086
  // Do not fabricate a value; omitting the field (rather than a stale
984
1087
  // `lastUsage` from a prior attempt) keeps this event's usage
985
1088
  // trustworthy as "usage actually observed on this attempt".
986
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts });
1089
+ await recordAttempt(attempt, null, t0, null);
1090
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, code: codeForRunnerError(e), maxAttempts });
1091
+ lastCode = OUTCOME_CODES.ENGINE_ERROR;
987
1092
  continue;
988
1093
  }
989
1094
  allAttemptsThrew = false;
@@ -991,9 +1096,11 @@ async function runPhaseWithRetry(phase, state, ctx) {
991
1096
  // forge#3589: model/turns recorded alongside usage (additive; null when absent).
992
1097
  const lastModel = result?.cliModel ?? result?.model ?? null;
993
1098
  const lastTurns = result?.turns ?? null;
1099
+ addUsage(lastUsage);
1100
+ await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns });
994
1101
  const outcome = await phase.detectOutcome(state, io, result);
995
- if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage, model: lastModel, turns: lastTurns };
996
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
1102
+ if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: cumulativeUsage, last_attempt_usage: lastUsage, model: lastModel, turns: lastTurns };
1103
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, code: normalizeCode(outcome.code), maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
997
1104
  // forge#2176: a phase's detectOutcome can mark a failure as a known,
998
1105
  // state-derived fixed point — re-running the phase's runner is
999
1106
  // guaranteed to reproduce the identical failure (e.g. the build phase's
@@ -1004,11 +1111,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
1004
1111
  // Absent/undefined `retryable` defaults to retryable (existing behavior
1005
1112
  // for every phase that doesn't opt in — investigate/context/architect/
1006
1113
  // review/close — is unchanged, preserving transient-failure retries).
1114
+ lastCode = normalizeCode(outcome.code);
1007
1115
  if (outcome.retryable === false) {
1008
1116
  // forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
1009
1117
  // forward `handoff` so only an explicit opt-in escalation is honoured; a
1010
1118
  // no-signal failure carries no handoff and stops at needs-human.
1011
- return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, usage: lastUsage };
1119
+ return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
1012
1120
  }
1013
1121
  }
1014
1122
  // Exhausted transient retries → escalate (spec §7).
@@ -1026,18 +1134,22 @@ async function runPhaseWithRetry(phase, state, ctx) {
1026
1134
  status: "blocked",
1027
1135
  detail: `phase ${phase.id} failed after ${maxAttempts} attempts (runner threw every attempt)`,
1028
1136
  reason: "engine-error",
1137
+ code: OUTCOME_CODES.ENGINE_ERROR,
1029
1138
  usage: null,
1030
1139
  };
1031
1140
  }
1032
- return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, usage: lastUsage };
1141
+ return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
1033
1142
  }
1034
1143
 
1035
1144
  /**
1036
1145
  * Build a run-log event sequence that reproduces a compact FORGE:STATE index,
1037
1146
  * used to reconstruct the local run-log on hydrate (C2).
1038
1147
  */
1039
- function eventsFromIndex(idx) {
1040
- const events = [{ event: "RUN_START", issue: idx.issue, run: idx.run, lane: idx.lane }];
1148
+ function eventsFromIndex(idx, meta = {}) {
1149
+ // forge#3824: `hydrated` marks a reconstructed log (no usage data) so cost-report can exclude it.
1150
+ const events = [{ event: "RUN_START", issue: idx.issue, run: idx.run, lane: idx.lane,
1151
+ version: meta.version ?? null, forge_version: meta.forge_version ?? meta.version ?? null,
1152
+ repo: meta.repo ?? null, hydrated: true }];
1041
1153
  for (const phase of idx.committed) {
1042
1154
  const outputs = {};
1043
1155
  if (phase === "build" && idx.branch) outputs.branch = idx.branch;
@@ -1050,15 +1162,15 @@ function eventsFromIndex(idx) {
1050
1162
  // would terminate the run. Nothing to attach it to when no phase is committed.
1051
1163
  if (!idx.terminal && idx.terminalReason && idx.committed.length > 0) {
1052
1164
  for (let i = events.length - 1; i >= 0; i--) {
1053
- if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; break; }
1165
+ if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; if (idx.outcomeCode) events[i].code = idx.outcomeCode; break; }
1054
1166
  }
1055
1167
  }
1056
- if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
1168
+ if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason, code: normalizeCode(idx.outcomeCode ?? codeForTerminalReason(idx.terminalReason)) });
1057
1169
  return events;
1058
1170
  }
1059
1171
 
1060
1172
  function freshState(issue, lane) {
1061
1173
  return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
1062
1174
  branch: null, pr: null, terminal: false, terminalReason: null, lease: null,
1063
- lastRateLimit: null };
1175
+ lastRateLimit: null, outcomeCode: null };
1064
1176
  }
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);
@@ -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));