forgedock 1.16.2 → 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 (95) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +86 -26
  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 +145 -40
  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 +4 -1
  26. package/commands/orchestrate/phase-4-execution.md +17 -2
  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/review-pr-agents/protocols.md +1 -0
  32. package/commands/review-pr-agents.md +10 -2
  33. package/commands/review-pr-staging.md +102 -25
  34. package/commands/review-pr.md +119 -103
  35. package/commands/work-on/build/implement.md +26 -5
  36. package/commands/work-on/build/validate.md +3 -2
  37. package/commands/work-on/build.md +95 -64
  38. package/commands/work-on/close.md +17 -6
  39. package/commands/work-on/decompose.md +45 -4
  40. package/commands/work-on/investigate.md +6 -2
  41. package/commands/work-on/remediate.md +22 -6
  42. package/commands/work-on/review.md +2 -2
  43. package/docs/CONFIG.md +33 -0
  44. package/docs/OUTCOME-CODES.md +48 -0
  45. package/docs/RELEASE-NOTES.md +12 -0
  46. package/docs/STATS.md +62 -0
  47. package/docs/TELEMETRY.md +65 -0
  48. package/docs/WORK-ON-RUNTIME.md +13 -0
  49. package/docs/site/command-reference.md +1 -1
  50. package/docs/spec/label-state-machine.md +1 -1
  51. package/package.json +3 -2
  52. package/scripts/builder-resume-match.test.sh +58 -0
  53. package/scripts/cap-findings.sh +110 -0
  54. package/scripts/cap-findings.test.sh +130 -0
  55. package/scripts/check-spec-markers.sh +2 -0
  56. package/scripts/classify-finding.sh +65 -16
  57. package/scripts/classify-finding.test.sh +43 -1
  58. package/scripts/classify-grep-exit.sh +20 -0
  59. package/scripts/classify-grep-exit.test.sh +46 -0
  60. package/scripts/close-parent-c3.test.sh +54 -0
  61. package/scripts/cost-report.sh +146 -0
  62. package/scripts/cost-report.test.sh +80 -0
  63. package/scripts/diff-size.sh +5 -0
  64. package/scripts/doctor-pipeline-state.sh +43 -0
  65. package/scripts/file-deferred-followups.sh +159 -0
  66. package/scripts/file-deferred-followups.test.sh +106 -0
  67. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  68. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  69. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  70. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  71. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  72. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  73. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  74. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  75. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  76. package/scripts/forge-root.test.sh +8 -3
  77. package/scripts/lib/fence-bootstrap.sh +4 -3
  78. package/scripts/lib/pr-issue-number.sh +18 -0
  79. package/scripts/lib/review-template-resolve.sh +41 -0
  80. package/scripts/lib/telemetry.sh +168 -0
  81. package/scripts/remediate-liveness.sh +42 -6
  82. package/scripts/review-acceptance-gate.sh +16 -7
  83. package/scripts/review-acceptance-gate.test.sh +13 -0
  84. package/scripts/review-pr-domain-map.test.sh +8 -3
  85. package/scripts/review-pr-gate2.test.sh +15 -0
  86. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  87. package/scripts/review-pr-staging-scope.test.sh +65 -5
  88. package/scripts/review-prepare.sh +517 -0
  89. package/scripts/review-prepare.test.sh +270 -0
  90. package/scripts/review-roster.sh +172 -0
  91. package/scripts/review-roster.test.sh +163 -0
  92. package/scripts/run-shell-tests.sh +3 -0
  93. package/scripts/telemetry.test.sh +106 -0
  94. package/scripts/wait-ci-green.sh +8 -1
  95. 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, agentId });
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 {
@@ -818,11 +841,12 @@ export async function runIssue(opts) {
818
841
  // PHASE_COMMIT would strand the PR (`deferred` is not --retry-able, and a committed remediate lets `close` run on
819
842
  // an unmerged PR). Release our lease and exit with nothing written; the next dispatch re-picks remediate.
820
843
  if (phase.id === "remediate" && outcome.reason === "deferred") {
821
- await projector.writeState(issue, { ...state, lease: null });
822
- return { terminalReason: "deferred", detail: outcome.detail };
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 };
823
847
  }
824
848
  if (!isRemediationHandoff)
825
- return await terminate(state, outcome.reason || "needs-human", outcome.detail);
849
+ return await terminate(state, outcome.reason || "needs-human", outcome.detail, outcome.code);
826
850
  } else {
827
851
  // committed
828
852
  // forge#2321: only report a phase_exit if a matching phase_enter was
@@ -854,11 +878,15 @@ export async function runIssue(opts) {
854
878
  // `decomposed`, remediate `merged`/`awaiting-merge`/`needs-human`). Persist them on the
855
879
  // commit event too, so a crash before RUN_TERMINAL or a rebuild cannot drop them.
856
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;
857
883
  appendEvent(dir, issue, {
858
- 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,
859
887
  // forge#3589: model + turn count per phase (null when the backend does not report them).
860
888
  model: outcome.model ?? null, turns: outcome.turns ?? null,
861
- ...(commitReason ? { terminalReason: commitReason } : {}),
889
+ ...(commitReason ? { terminalReason: commitReason, code: commitCode } : {}),
862
890
  });
863
891
  state = deriveState(readLog(dir, issue));
864
892
  const terminalReason = blockedReason ?? outcome.terminalReason;
@@ -890,29 +918,74 @@ export async function runIssue(opts) {
890
918
  outcome.outputs?.closeHandoff === true && terminalReason === "merged";
891
919
  if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
892
920
  !isDecomposeHandoff && !isRemediationHandoff && !isCloseHandoff)
893
- return await terminate(state, terminalReason, outcome.detail);
921
+ return await terminate(state, terminalReason, outcome.detail, commitCode);
894
922
  if (phase.isTerminalAfter && !isCloseHandoff && phase.isTerminalAfter(state))
895
923
  return await terminate(state, state.terminalReason || "merged");
896
924
  }
897
925
  return await terminate(state, state.terminalReason || "merged");
898
926
 
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 };
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 };
902
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
+ }
903
970
  if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
904
971
  // forge#2261: a distinct label for engine/tool-level failures (broken CLI
905
972
  // invocation, exhausted retries where the runner itself never once
906
973
  // succeeded) — NOT needs-human. /orchestrate's classify_predecessor_state()
907
974
  // (commands/orchestrate/phase-4-execution.md) must not treat this as GATED:
908
975
  // 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 };
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 };
911
984
  }
912
985
  }
913
986
 
914
987
  async function runPhaseWithRetry(phase, state, ctx) {
915
- const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId } = ctx;
988
+ const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId, emitTelemetry } = ctx;
916
989
  // forge#3499: build the phase's full argument set ONCE, up front. Every
917
990
  // work-on phase sub-skill stops with BLOCKED when a required flag is absent,
918
991
  // so an incomplete/invalid set is a deterministic engine failure — fail
@@ -924,8 +997,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
924
997
  } catch (e) {
925
998
  if (e?.code !== "PHASE_ARGS_INVALID") throw e;
926
999
  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 };
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 };
929
1002
  }
930
1003
  // forge#2261: true only if EVERY attempt failed by the runner itself
931
1004
  // throwing (never once reached phase.detectOutcome()). This is the signal
@@ -936,6 +1009,8 @@ async function runPhaseWithRetry(phase, state, ctx) {
936
1009
  // Flips to false the instant any attempt's runner() call succeeds, even if
937
1010
  // that attempt's detectOutcome() itself reports failure.
938
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;
939
1014
  // forge#2377: the last successful attempt's `usage` (from runner()'s ==
940
1015
  // runCommand()'s resolved value — {input_tokens, output_tokens,
941
1016
  // cache_creation_input_tokens, cache_read_input_tokens} on the API
@@ -945,9 +1020,28 @@ async function runPhaseWithRetry(phase, state, ctx) {
945
1020
  // attempt (if any) set it to, which is correct: it always reflects the
946
1021
  // most recent attempt that actually produced a result.
947
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
+ };
948
1041
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
949
1042
  appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
950
1043
  let result;
1044
+ const t0 = performance.now();
951
1045
  try {
952
1046
  result = await runner({
953
1047
  commandsDir, commandName: phase.command, args: phaseArgs,
@@ -978,8 +1072,10 @@ async function runPhaseWithRetry(phase, state, ctx) {
978
1072
  // NOT a commit: record the exhausted attempt, then rethrow so runIssue terminates
979
1073
  // `engine-error` with no PHASE_COMMIT — `--retry` then re-enters this same phase.
980
1074
  if (e.code === "CLI_MAX_TURNS") {
1075
+ addUsage(e.usage);
1076
+ await recordAttempt(attempt, e.usage, t0, { turns: e.numTurns });
981
1077
  appendEvent(dir, issue, {
982
- 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,
983
1079
  turns: e.numTurns ?? null, usage: e.usage ?? null,
984
1080
  });
985
1081
  throw e;
@@ -990,7 +1086,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
990
1086
  // Do not fabricate a value; omitting the field (rather than a stale
991
1087
  // `lastUsage` from a prior attempt) keeps this event's usage
992
1088
  // trustworthy as "usage actually observed on this attempt".
993
- 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;
994
1092
  continue;
995
1093
  }
996
1094
  allAttemptsThrew = false;
@@ -998,9 +1096,11 @@ async function runPhaseWithRetry(phase, state, ctx) {
998
1096
  // forge#3589: model/turns recorded alongside usage (additive; null when absent).
999
1097
  const lastModel = result?.cliModel ?? result?.model ?? null;
1000
1098
  const lastTurns = result?.turns ?? null;
1099
+ addUsage(lastUsage);
1100
+ await recordAttempt(attempt, lastUsage, t0, { cost_usd: result?.costUsd ?? null, model: lastModel, turns: lastTurns });
1001
1101
  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 });
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 });
1004
1104
  // forge#2176: a phase's detectOutcome can mark a failure as a known,
1005
1105
  // state-derived fixed point — re-running the phase's runner is
1006
1106
  // guaranteed to reproduce the identical failure (e.g. the build phase's
@@ -1011,11 +1111,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
1011
1111
  // Absent/undefined `retryable` defaults to retryable (existing behavior
1012
1112
  // for every phase that doesn't opt in — investigate/context/architect/
1013
1113
  // review/close — is unchanged, preserving transient-failure retries).
1114
+ lastCode = normalizeCode(outcome.code);
1014
1115
  if (outcome.retryable === false) {
1015
1116
  // forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
1016
1117
  // forward `handoff` so only an explicit opt-in escalation is honoured; a
1017
1118
  // 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 };
1119
+ return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, code: lastCode, usage: cumulativeUsage, last_attempt_usage: lastUsage };
1019
1120
  }
1020
1121
  }
1021
1122
  // Exhausted transient retries → escalate (spec §7).
@@ -1033,18 +1134,22 @@ async function runPhaseWithRetry(phase, state, ctx) {
1033
1134
  status: "blocked",
1034
1135
  detail: `phase ${phase.id} failed after ${maxAttempts} attempts (runner threw every attempt)`,
1035
1136
  reason: "engine-error",
1137
+ code: OUTCOME_CODES.ENGINE_ERROR,
1036
1138
  usage: null,
1037
1139
  };
1038
1140
  }
1039
- 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 };
1040
1142
  }
1041
1143
 
1042
1144
  /**
1043
1145
  * Build a run-log event sequence that reproduces a compact FORGE:STATE index,
1044
1146
  * used to reconstruct the local run-log on hydrate (C2).
1045
1147
  */
1046
- function eventsFromIndex(idx) {
1047
- 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 }];
1048
1153
  for (const phase of idx.committed) {
1049
1154
  const outputs = {};
1050
1155
  if (phase === "build" && idx.branch) outputs.branch = idx.branch;
@@ -1057,15 +1162,15 @@ function eventsFromIndex(idx) {
1057
1162
  // would terminate the run. Nothing to attach it to when no phase is committed.
1058
1163
  if (!idx.terminal && idx.terminalReason && idx.committed.length > 0) {
1059
1164
  for (let i = events.length - 1; i >= 0; i--) {
1060
- 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; }
1061
1166
  }
1062
1167
  }
1063
- 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)) });
1064
1169
  return events;
1065
1170
  }
1066
1171
 
1067
1172
  function freshState(issue, lane) {
1068
1173
  return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
1069
1174
  branch: null, pr: null, terminal: false, terminalReason: null, lease: null,
1070
- lastRateLimit: null };
1175
+ lastRateLimit: null, outcomeCode: null };
1071
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));