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
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Closed, versioned enum of engine outcome codes (forge#3833).
3
+ *
4
+ * Every PHASE_FAILED, blocked PHASE_COMMIT and RUN_TERMINAL run-log event carries
5
+ * one `code` from this enum so stall causes can be counted and grouped without a
6
+ * model reading prose. Codes are derived in code from structured evidence
7
+ * (REVIEW_RESULT fields, FORGE markers, `err.code`), never from free-text `detail`.
8
+ * The free-text `reason`/`detail` stays alongside for humans. Documented in
9
+ * docs/OUTCOME-CODES.md (a test asserts enum/doc parity). Pure module: no I/O.
10
+ */
11
+
12
+ /** Bump when a code is renamed or removed; adding a code is additive. */
13
+ export const OUTCOME_CODES_VERSION = 1;
14
+
15
+ /** Fallback for an absent or unknown code; the raw reason is kept alongside. */
16
+ export const UNCLASSIFIED = "unclassified";
17
+
18
+ export const OUTCOME_CODES = Object.freeze({
19
+ REVIEW_CHANGES_REQUESTED: "review.changes_requested",
20
+ REVIEW_ESCALATED: "review.escalated",
21
+ REVIEW_REREVIEW_SKIPPED: "review.rereview_skipped",
22
+ BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
23
+ BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
24
+ BUILD_SIZE_OVER_GATE: "build.size_over_gate",
25
+ BUILD_COHESIVE: "build.cohesive",
26
+ REMEDIATE_NO_MARKER: "remediate.no_marker",
27
+ REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
28
+ REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
29
+ REMEDIATE_UNFIXABLE: "remediate.unfixable",
30
+ CI_RED: "ci.red",
31
+ CI_TIMEOUT: "ci.timeout",
32
+ BASE_CONFLICT: "base.conflict",
33
+ RATE_LIMITED: "rate_limited",
34
+ ENGINE_ERROR: "engine.error",
35
+ LEASE_HELD: "lease.held",
36
+ INVALID: "invalid",
37
+ DECOMPOSED: "decomposed",
38
+ // Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
39
+ COMPLETED: "completed",
40
+ AWAITING_MERGE: "awaiting_merge",
41
+ UNCLASSIFIED,
42
+ });
43
+
44
+ const CODE_SET = new Set(Object.values(OUTCOME_CODES));
45
+
46
+ /** Every code, in declaration order. */
47
+ export const ALL_OUTCOME_CODES = Object.freeze([...CODE_SET]);
48
+
49
+ export function isOutcomeCode(code) {
50
+ return typeof code === "string" && CODE_SET.has(code);
51
+ }
52
+
53
+ /** Absent or unknown -> `unclassified`; never throws. */
54
+ export function normalizeCode(code) {
55
+ return isOutcomeCode(code) ? code : UNCLASSIFIED;
56
+ }
57
+
58
+ /** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
59
+ export function codeForRunnerError(err) {
60
+ if (!err || typeof err !== "object") return UNCLASSIFIED;
61
+ if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
62
+ return OUTCOME_CODES.RATE_LIMITED;
63
+ switch (err.code) {
64
+ case "NO_API_KEY":
65
+ case "NO_SDK":
66
+ case "CLI_BACKEND_FAILED":
67
+ case "PHASE_ARGS_INVALID":
68
+ case "CLI_MAX_TURNS":
69
+ return OUTCOME_CODES.ENGINE_ERROR;
70
+ default:
71
+ return UNCLASSIFIED;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Default code for a terminal reason when the producer supplied none. Only reasons that
77
+ * name one cause map; `needs-human` has many causes, so it stays `unclassified`.
78
+ */
79
+ export function codeForTerminalReason(reason) {
80
+ switch (reason) {
81
+ case "merged":
82
+ case "phase-complete":
83
+ case "investigation":
84
+ return OUTCOME_CODES.COMPLETED;
85
+ case "awaiting-merge": return OUTCOME_CODES.AWAITING_MERGE;
86
+ case "invalid": return OUTCOME_CODES.INVALID;
87
+ case "decomposed": return OUTCOME_CODES.DECOMPOSED;
88
+ case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
89
+ case "deferred": return OUTCOME_CODES.LEASE_HELD;
90
+ default: return UNCLASSIFIED;
91
+ }
92
+ }
@@ -12,6 +12,8 @@
12
12
  // imports the identical registry, so the two can no longer drift apart the way
13
13
  // they did in forge#2375/PR#2395.
14
14
  import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
15
+ // forge#3833: closed outcome-code enum; every code below comes from structured evidence, never `detail` prose.
16
+ import { OUTCOME_CODES as OC } from "./outcome-codes.mjs";
15
17
 
16
18
  // forge#2261: "engine-error" is a distinct terminal reason for engine/tool-level
17
19
  // failures (e.g. an exhausted retry loop where the runner itself never once
@@ -48,12 +50,22 @@ export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed
48
50
  */
49
51
  export function isTrustedComment(c) {
50
52
  if (!c || typeof c !== "object") return false;
53
+ return isTrustedActor({ association: c.author_association, type: c.user?.type, login: c.user?.login });
54
+ }
55
+
56
+ /**
57
+ * The ONE actor predicate behind `isTrustedComment` and the issue-body
58
+ * FORGE:STATE gate (projector). `association` is the GitHub author association,
59
+ * `type` the actor's `__typename`/`user.type` ("Bot", "User"...), `login` its
60
+ * login. Missing fields fail closed.
61
+ */
62
+ export function isTrustedActor({ association, type, login } = {}) {
51
63
  const list = (v) => String(v).split(",").map((x) => x.trim()).filter(Boolean);
52
64
  const assoc = list(process.env.FORGE_TRAIL_TRUSTED_ASSOCIATIONS ?? "OWNER,MEMBER,COLLABORATOR");
53
65
  const logins = list(process.env.FORGE_TRAIL_TRUSTED_LOGINS ?? "");
54
- return assoc.includes(c.author_association ?? "")
55
- || (c.user?.type ?? "") === "Bot"
56
- || logins.includes(c.user?.login ?? "");
66
+ return assoc.includes(association ?? "")
67
+ || (type ?? "") === "Bot"
68
+ || logins.includes(login ?? "");
57
69
  }
58
70
 
59
71
  /**
@@ -97,6 +109,12 @@ async function issueMarkers(issue, io) {
97
109
 
98
110
  /** Non-terminal held-resume record (remediate.md Phase M8, forge#3685): header and sentinel are the same line; never a COMPLETE marker. */
99
111
  const REMEDIATION_HELD_MARKER = "FORGE:REMEDIATION_HELD";
112
+ /** Non-terminal deferral record (remediate.md Phase M0 liveness step): a live earlier run holds the lease. Never a COMPLETE/trail marker. */
113
+ const REMEDIATION_DEFERRED_MARKER = "FORGE:REMEDIATION_DEFERRED";
114
+ /** Per-dispatch record the engine posts on every remediate dispatch (forge#3807): scopes a DEFERRED record to its own dispatch. Never a COMPLETE/trail marker. */
115
+ const REMEDIATION_DISPATCH_MARKER = "FORGE:REMEDIATION_DISPATCH";
116
+ /** Lease holder ids the engine mints (`cli_<host>_<pid>`) are passed to remediate only when they match this shape (same as remediate-liveness.sh). */
117
+ const LEASE_HOLDER_RE = /^[A-Za-z0-9._-]{1,128}$/;
100
118
  const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
101
119
  /** True when `body` STARTS with the typed header `<!-- {header}` (e.g. `FORGE:BUILDER`). */
102
120
  function startsWithHeader(body, header) {
@@ -182,15 +200,32 @@ export async function postCycleMarker(issue, n, io) {
182
200
  * ALREADY_DONE when the newest gate marker is blocked (forge#3725).
183
201
  */
184
202
  function latestGateState(comments) {
203
+ return latestGateMarker(comments).state;
204
+ }
205
+ /**
206
+ * Single newest-first scan shared by latestGateState and latestBlockedIndex so their
207
+ * predicates cannot drift. Returns the newest gate marker's state and its comment index
208
+ * (`{state: "none", index: -1}` when there is none).
209
+ */
210
+ function latestGateMarker(comments) {
185
211
  const gate = "FORGE:ACCEPTANCE_GATE";
186
212
  for (let i = comments.length - 1; i >= 0; i--) {
187
213
  const b = comments[i];
188
- if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return "blocked";
214
+ if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return { state: "blocked", index: i };
189
215
  if (!startsWithHeader(b, gate)) continue;
190
- if (hasSentinelLine(b, `${gate}:PASSED`)) return "pass";
191
- if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return "blocked";
216
+ if (hasSentinelLine(b, `${gate}:PASSED`)) return { state: "pass", index: i };
217
+ if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return { state: "blocked", index: i };
192
218
  }
193
- return "none";
219
+ return { state: "none", index: -1 };
220
+ }
221
+ /**
222
+ * Comment index of the newest blocked gate marker, or -1 when the newest gate marker is
223
+ * not blocked (a newer PASSED clears it). Recency anchor for build.detectOutcome so an
224
+ * older blocked marker cannot shadow a strictly newer size-gate routing marker.
225
+ */
226
+ function latestBlockedIndex(comments) {
227
+ const m = latestGateMarker(comments);
228
+ return m.state === "blocked" ? m.index : -1;
194
229
  }
195
230
  /**
196
231
  * Investigation exit evidence (forge#3534): a trusted comment led by
@@ -261,18 +296,22 @@ function overrideHasJustification(body) {
261
296
  * chronological order (see trustedCommentBodies); an untrusted comment never
262
297
  * reaches this function.
263
298
  */
264
- function sizeGateRoutesToDecompose(comments) {
299
+ function sizeGateRouteIndex(comments) {
265
300
  const gate = PHASE_MARKERS.build.sizeGateMarker;
266
301
  const override = PHASE_MARKERS.build.sizeOverrideMarker;
267
302
  const starts = (c, m) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${m}`);
268
- if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return false;
303
+ if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return -1;
269
304
  let idx = -1;
270
305
  for (let i = comments.length - 1; i >= 0; i--) if (starts(comments[i], gate)) { idx = i; break; }
271
- if (idx < 0) return false;
306
+ if (idx < 0) return -1;
272
307
  const body = comments[idx].replace(/\r\n/g, "\n");
273
- const m = /^result:[ \t]*(OK|OVERRIDDEN|OVER)[ \t]*$/m.exec(body);
274
- if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return false;
275
- return !comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c));
308
+ const m = /^result:[ \t]*(OK|OVERRIDDEN|COHESIVE|OVER)[ \t]*$/m.exec(body);
309
+ if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return -1;
310
+ return comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c)) ? -1 : idx;
311
+ }
312
+ /** Boolean form of sizeGateRouteIndex (index of the routing DIFF_SIZE comment, or -1). */
313
+ function sizeGateRoutesToDecompose(comments) {
314
+ return sizeGateRouteIndex(comments) >= 0;
276
315
  }
277
316
  /**
278
317
  * Decompose loop guard (mirrors build.md B5.5 and decompose.md Guard 1). Returns a
@@ -621,7 +660,7 @@ export const PHASES = [
621
660
  const invalid = `<!-- ${PHASE_MARKERS.investigate.invalidMarker} -->`;
622
661
  const complete = `<!-- ${PHASE_MARKERS.investigate.completionMarker} -->`;
623
662
  if (closing === invalid)
624
- return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
663
+ return { status: "committed", terminalReason: "invalid", code: OC.INVALID, outputs: { verdict: "INVALID" } };
625
664
  if (closing === complete) {
626
665
  // investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
627
666
  if (isDecomposed(report)) {
@@ -632,7 +671,7 @@ export const PHASES = [
632
671
  console.error(`[engine] decompose suppressed for #${state.issue}: ${guard}; treating as not decomposed`);
633
672
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
634
673
  }
635
- return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
674
+ return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: { decompose: true } };
636
675
  }
637
676
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
638
677
  }
@@ -661,7 +700,7 @@ export const PHASES = [
661
700
  async detectOutcome(state, io) {
662
701
  const { comments } = await issueMarkers(state.issue, io);
663
702
  if (hasMarker(comments, PHASE_MARKERS.decompose.header, PHASE_MARKERS.decompose.completionMarker))
664
- return { status: "committed", terminalReason: "decomposed", outputs: {} };
703
+ return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: {} };
665
704
  return { status: "failed", detail: `no ${PHASE_MARKERS.decompose.completionMarker} marker` };
666
705
  },
667
706
  // Always terminal: decomposition spawns independent sub-issues, each of
@@ -718,8 +757,15 @@ export const PHASES = [
718
757
  // review. Non-retryable: a retry would only re-read the same append-only markers.
719
758
  // A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
720
759
  // build invocation does via build.md B0 gate resume (re-enters B6.5; forge#3725).
760
+ // forge#3712: an OVER + Split Proposal size-gate marker STRICTLY NEWER (comment order)
761
+ // than the newest blocked marker is the retry's real outcome; route to decompose.
762
+ // Equal/absent index stays fail-closed, as does a newer blocked marker.
763
+ if (!complete && latestGateState(comments) === "blocked" && mayRouteToDecompose(comments)
764
+ && sizeGateRouteIndex(comments) > latestBlockedIndex(comments)) {
765
+ return { status: "committed", terminalReason: "decomposed", code: OC.BUILD_SIZE_OVER_GATE, outputs: branch ? { branch } : {} };
766
+ }
721
767
  if (latestGateState(comments) === "blocked") {
722
- return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
768
+ return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, code: OC.BUILD_ACCEPTANCE_FAILED, retryable: false };
723
769
  }
724
770
  if (complete && ahead > 0) {
725
771
  return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
@@ -735,7 +781,7 @@ export const PHASES = [
735
781
  // needs-human. Non-retryable by construction: a retry re-measures and
736
782
  // re-emits the same exit.
737
783
  if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
738
- return { status: "committed", terminalReason: "decomposed", outputs: branch ? { branch } : {} };
784
+ return { status: "committed", terminalReason: "decomposed", code: OC.BUILD_SIZE_OVER_GATE, outputs: branch ? { branch } : {} };
739
785
  }
740
786
  const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
741
787
  // forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
@@ -799,7 +845,7 @@ export const PHASES = [
799
845
  // The PR number from GitHub (openPrFor) always wins over parsed text.
800
846
  const outputs = { pr: pr.number };
801
847
  // Opt-in: only an explicit escalation signal sets handoff:true. A PR number alone never hands off.
802
- const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true };
848
+ const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true, code: OC.REVIEW_ESCALATED };
803
849
  const rr = parseReviewResult(result?.text);
804
850
  // forge#3530: carry the review's remediation kind (router Phase 4R parity) so the
805
851
  // remediate phase can post the matching bound marker. Untrusted model output: only
@@ -808,7 +854,7 @@ export const PHASES = [
808
854
  if (rr && rr.status === "NEXT" && rr.next === "remediate" && rr.remediation)
809
855
  outputs.remediation = isRemediationKind(rr.remediation) ? rr.remediation : "unknown";
810
856
  if (pr.needsHuman || pr.remediating) return escalated;
811
- if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
857
+ if (rr && rr.status === "NEXT" && rr.next === "remediate") return { ...escalated, code: OC.REVIEW_CHANGES_REQUESTED };
812
858
  // An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
813
859
  // (phase trail, ci gate, base conflict, merge refusal) also label the issue
814
860
  // needs-human but are NOT remediation handoffs, so skip the label fallback.
@@ -844,11 +890,23 @@ export const PHASES = [
844
890
  buildArgs: async (state, ctx, io) => {
845
891
  const pr = need(state.pr, "pr", /^[0-9]+$/);
846
892
  const kind = state.remediationKind ?? null;
847
- const legacyArgs = () => [pr, "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
893
+ // Pass the dispatching lease holder so remediate.md M0 can tell a live OTHER run from this engine's own lease.
894
+ const holderArgs = () => (typeof ctx.agentId === "string" && LEASE_HOLDER_RE.test(ctx.agentId) ? ["--lease-holder", ctx.agentId] : []);
895
+ const legacyArgs = () => [pr, "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state), ...holderArgs()];
848
896
  // Legacy handoff with no kind (needs-human label fallback, or no REVIEW_RESULT): there
849
897
  // is nothing to bind, so dispatch as before. remediate.md M0 then applies its default
850
898
  // single-attempt guard. A present-but-unknown kind is never dispatched.
851
- if (kind === null) return legacyArgs();
899
+ // forge#3807: every dispatch (kind-bound or legacy) posts a dispatch marker AFTER the bound marker,
900
+ // never deduped, so detectOutcome can tell a stale deferral from an earlier dispatch from this one's.
901
+ const postDispatchMarker = async () => {
902
+ try {
903
+ await io.gh(["issue", "comment", String(state.issue), "--body",
904
+ `<!-- ${REMEDIATION_DISPATCH_MARKER}: pr=${pr} -->\nEngine dispatching remediation for PR #${pr}.`]);
905
+ } catch (e) {
906
+ throw new PhaseArgsError(`github-unavailable: could not post ${REMEDIATION_DISPATCH_MARKER}; remediation not invoked (${e?.message || e})`);
907
+ }
908
+ };
909
+ if (kind === null) { await postDispatchMarker(); return legacyArgs(); }
852
910
  if (!isRemediationKind(kind))
853
911
  throw new PhaseArgsError(`unknown remediation kind: ${JSON.stringify(kind)}`);
854
912
  const boundMarker = REMEDIATION_BOUND_MARKERS[kind];
@@ -864,6 +922,7 @@ export const PHASES = [
864
922
  } catch (e) {
865
923
  throw new PhaseArgsError(`github-unavailable: could not post FORGE:${boundMarker}; remediation not invoked (${e?.message || e})`);
866
924
  }
925
+ await postDispatchMarker();
867
926
  return legacyArgs();
868
927
  },
869
928
  entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
@@ -924,7 +983,25 @@ export const PHASES = [
924
983
  }
925
984
  if (latest === null) {
926
985
  if (await prMerged()) return mergedResult("AUTO-LANDED");
927
- return { status: "failed", detail: `no ${completion} marker` };
986
+ // A deferral to a live earlier run (remediate.md M0 liveness step) is not a failed attempt. The
987
+ // decision was made by the script at M0 time, so the record is not re-judged against the clock (a
988
+ // lease that expired since is the NEXT dispatch's business). Only a trusted, full-line record for THIS
989
+ // PR counts, and only while it is newer than every `FORGE:REMEDIATION` trail comment: a later run
990
+ // that proceeded posts one, which retires the deferral.
991
+ // forge#3807: a deferral older than this dispatch's marker is stale (a later dispatch proceeded and
992
+ // crashed before posting a trail), so it is a failed attempt. No marker (older runs) => dispatchAt -1.
993
+ let deferredAt = -1, trailAt = -1, dispatchAt = -1, deferred = null;
994
+ const dispatchSentinel = `${REMEDIATION_DISPATCH_MARKER}: pr=${state.pr}`;
995
+ scoped.forEach((b, i) => {
996
+ if (state.pr != null && hasSentinelLine(b, dispatchSentinel)) dispatchAt = i;
997
+ if (startsWithHeader(b, REMEDIATION_DEFERRED_MARKER) && hasSentinelLine(b, REMEDIATION_DEFERRED_MARKER)) { deferredAt = i; deferred = b; }
998
+ else if (startsWithHeader(b, PHASE_MARKERS.remediate.header)) trailAt = i;
999
+ });
1000
+ if (deferred !== null && deferredAt > trailAt && deferredAt > dispatchAt && (state.pr == null || /^pr=(\d+)\s*$/m.exec(deferred)?.[1] === String(state.pr))) {
1001
+ const kind = /^kind=(\w+)\s*$/m.exec(deferred)?.[1] ?? "lease";
1002
+ return { status: "blocked", reason: "deferred", code: OC.REMEDIATE_DEFERRED_LIVE_RUN, retryable: false, detail: `deferred-live-run: ${kind}` };
1003
+ }
1004
+ return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `no ${completion} marker` };
928
1005
  }
929
1006
  // Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
930
1007
  // (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
@@ -952,8 +1029,11 @@ export const PHASES = [
952
1029
  // policy judgment call respectively) — reuse the existing
953
1030
  // "needs-human" terminal reason rather than inventing two more,
954
1031
  // matching the #2352/#2353 precedent of reusing an existing
955
- // TERMINAL_REASONS value where semantically equivalent.
956
- return { status: "committed", terminalReason: "needs-human", outputs: { reGateOutcome } };
1032
+ // TERMINAL_REASONS value where semantically equivalent. forge#3833:
1033
+ // the two stay distinguishable by outcome code.
1034
+ return { status: "committed", terminalReason: "needs-human",
1035
+ code: reGateOutcome === "UNFIXABLE" ? OC.REMEDIATE_UNFIXABLE : OC.REMEDIATE_RE_ESCALATED,
1036
+ outputs: { reGateOutcome } };
957
1037
  default:
958
1038
  return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
959
1039
  }
@@ -3,6 +3,16 @@
3
3
  * `io.gh(args)` is injected (async, returns stdout) so this is testable offline.
4
4
  */
5
5
  import { parseState, upsertStateBlock } from "./state.mjs";
6
+ import { isTrustedActor } from "./phases.mjs";
7
+
8
+ // Defence in depth: a lease further out than 3x the engine's 10 min TTL (same
9
+ // bound as scripts/remediate-liveness.sh MAX_LEASE_TTL_MS) is never honoured.
10
+ export const MAX_LEASE_TTL_MS = 3 * 600_000;
11
+
12
+ // Body + provenance in ONE call. `gh issue view --json` cannot return the last
13
+ // editor or authorAssociation, so GraphQL is used. -F variables, never interpolation.
14
+ const PROVENANCE_QUERY = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){"
15
+ + "issue(number:$number){body authorAssociation author{login __typename} editor{login __typename}}}}";
6
16
 
7
17
  export function makeProjector(io) {
8
18
  const gh = io.gh;
@@ -12,9 +22,46 @@ export function makeProjector(io) {
12
22
  try { return JSON.parse(out).body ?? ""; } catch { return ""; }
13
23
  }
14
24
 
25
+ /**
26
+ * Body of the issue iff its LAST EDITOR (`editor ?? author`) is trusted, else
27
+ * null. The reporter can edit their own body, so creator-only trust is wrong
28
+ * and engine writes (bot editor) must stay readable. Any error, malformed
29
+ * JSON or missing provenance fails closed (null).
30
+ */
31
+ async function getTrustedBody(issue) {
32
+ try {
33
+ const out = await gh(["api", "graphql", "-f", `query=${PROVENANCE_QUERY}`,
34
+ "-F", "owner={owner}", "-F", "name={repo}", "-F", `number=${Number(issue)}`]);
35
+ const i = JSON.parse(out)?.data?.repository?.issue;
36
+ if (!i || typeof i.body !== "string") return null;
37
+ const actor = i.editor ?? i.author;
38
+ if (!actor || typeof actor !== "object") return null;
39
+ // authorAssociation describes the CREATOR: valid for the actor only when
40
+ // the actor IS the creator. A distinct human editor's standing comes from
41
+ // the collaborator permission endpoint (fail closed to NONE).
42
+ let association = "NONE";
43
+ if (!i.editor || i.editor.login === i.author?.login) association = i.authorAssociation;
44
+ else if (actor.__typename !== "Bot" && typeof actor.login === "string" && /^[A-Za-z0-9-]+(\[bot\])?$/.test(actor.login)) {
45
+ try {
46
+ const perm = JSON.parse(await gh(["api", `repos/{owner}/{repo}/collaborators/${actor.login}/permission`, "--jq", "{permission}"])).permission;
47
+ association = perm === "admin" ? "OWNER" : (perm === "maintain" || perm === "write") ? "COLLABORATOR" : "NONE";
48
+ } catch { /* fail closed */ }
49
+ }
50
+ return isTrustedActor({ association, type: actor.__typename, login: actor.login }) ? i.body : null;
51
+ } catch { return null; }
52
+ }
53
+
15
54
  return {
16
55
  /** @returns {Promise<import("./phases.mjs").RunState|null>} */
17
- async readState(issue) { return parseState(await getBody(issue)); },
56
+ async readState(issue) {
57
+ const body = await getTrustedBody(issue);
58
+ if (body === null) return null;
59
+ const st = parseState(body);
60
+ if (st?.lease && !(Number.isFinite(st.lease.until) && st.lease.until <= Date.now() + MAX_LEASE_TTL_MS)) {
61
+ return { ...st, lease: null };
62
+ }
63
+ return st;
64
+ },
18
65
 
19
66
  async writeState(issue, index) {
20
67
  const body = upsertStateBlock(await getBody(issue), index);
@@ -12,7 +12,10 @@ export function appendEvent(dir, issue, event) {
12
12
  mkdirSync(dir, { recursive: true });
13
13
  const path = logPath(dir, issue);
14
14
  const seq = readLog(dir, issue).length + 1;
15
- appendFileSync(path, JSON.stringify({ seq, ...event }) + "\n");
15
+ // forge#3696: wall-clock ISO-8601 `ts` on every appended event. Placed before the
16
+ // spread so a caller-supplied `ts` wins. rewriteLog() deliberately does not stamp:
17
+ // a hydrated rewrite time would be a false timestamp. Old logs lack `ts` and still parse.
18
+ appendFileSync(path, JSON.stringify({ seq, ts: new Date().toISOString(), ...event }) + "\n");
16
19
  }
17
20
 
18
21
  /**
@@ -58,7 +61,9 @@ export function deriveState(events) {
58
61
  const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
59
62
  phase: null, branch: null, pr: null, terminal: false,
60
63
  terminalReason: null, lease: null, lastRateLimit: null,
61
- remediationKind: null };
64
+ remediationKind: null, outcomeCode: null };
65
+ // forge#3833: outcome code of the LAST PHASE_COMMIT only (replaced, like lastCommitReason).
66
+ let lastCommitCode = null;
62
67
  // forge#3528: handoff reason of the LAST PHASE_COMMIT only (replaced, not sticky).
63
68
  let lastCommitReason = null;
64
69
  // forge#3754: phase of the LAST PHASE_COMMIT, so a reopen can tell a remediate-terminal run.
@@ -80,6 +85,8 @@ export function deriveState(events) {
80
85
  // remediate instead of defaulting to a "merged" close.
81
86
  if (e.terminalReason) s.terminalReason = e.terminalReason;
82
87
  lastCommitReason = e.terminalReason ?? null;
88
+ lastCommitCode = e.code ?? null;
89
+ if (e.code) s.outcomeCode = e.code;
83
90
  lastCommitPhase = e.phase ?? null;
84
91
  // forge#3530: the review's remediation kind must survive resume. A later review
85
92
  // commit replaces it; a committed remediate consumes it, so it is never reused.
@@ -89,6 +96,7 @@ export function deriveState(events) {
89
96
  case "RUN_TERMINAL":
90
97
  s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
91
98
  lastTerminalReason = s.terminalReason;
99
+ if (e.code) s.outcomeCode = e.code;
92
100
  break;
93
101
  // forge#3511: an explicit `run-issue --retry` reopens an engine-error
94
102
  // terminal. Bumping `v` makes the local state newer than the stale
@@ -97,7 +105,7 @@ export function deriveState(events) {
97
105
  // (needs-human / decomposed); restore the last commit's reason so the
98
106
  // retry re-enters remediate/decompose instead of close/build.
99
107
  case "RUN_REOPEN":
100
- s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
108
+ s.terminal = false; s.terminalReason = lastCommitReason; s.outcomeCode = lastCommitCode; s.v = e.seq;
101
109
  // forge#3754: a remediate that ended needs-human is re-run after a human clears the
102
110
  // label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
103
111
  // does not re-terminate and remediate's entryCondition re-selects it.
@@ -119,9 +127,9 @@ export function deriveState(events) {
119
127
  // lastCommitReason is reset so a stale handoff reason cannot leak into
120
128
  // the new cycle through a later RUN_REOPEN.
121
129
  case "RUN_NEXT_CYCLE":
122
- s.terminal = false; s.terminalReason = null; s.committed = [];
130
+ s.terminal = false; s.terminalReason = null; s.committed = []; s.outcomeCode = null;
123
131
  s.branch = null; s.pr = null; s.remediationKind = null;
124
- lastCommitReason = null; lastCommitPhase = null; lastTerminalReason = null; s.v = e.seq;
132
+ lastCommitReason = null; lastCommitPhase = null; lastCommitCode = null; lastTerminalReason = null; s.v = e.seq;
125
133
  s.lastRateLimit = null; // forge#3791
126
134
  break;
127
135
  // forge#2524: a session-limit pause is purely informational — it does
@@ -0,0 +1,62 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#3835: the engine's `emitTelemetry` seam writer. Maps the run-log shaped records the engine hands to the
5
+ * seam onto FORGE:TELEMETRY events so `forgedock stats` can rebuild Cost, Time and outcome codes from GitHub alone:
6
+ * PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, turns, duration_ms, cost_usd, *_tokens)
7
+ * RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms)
8
+ * Other records are ignored. The returned function NEVER throws and honours FORGE_TELEMETRY=off. Telemetry values
9
+ * are scalars (the emitter stringifies objects), so the `usage` object is flattened here; absent values are omitted.
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { emit } from "./telemetry.mjs";
13
+
14
+ export function defaultResolveRepo() {
15
+ try {
16
+ const r = execFileSync("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
17
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }).trim();
18
+ return /^[\w.-]+\/[\w.-]+$/.test(r) ? r : null;
19
+ } catch { return null; }
20
+ }
21
+
22
+ const scalar = (v) => (typeof v === "number" ? Number.isFinite(v) : typeof v === "string" && v !== "");
23
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => scalar(v)));
24
+ const tok = (u, k) => (u && typeof u === "object" && Number.isFinite(u[k]) ? u[k] : undefined);
25
+
26
+ /** Pure mapping, exported for tests: returns { event, payload, phase } or null when the record is not mapped. */
27
+ export function mapRecord(rec, wallMs) {
28
+ if (!rec || typeof rec !== "object") return null;
29
+ if (rec.event === "PHASE_ATTEMPT_USAGE") {
30
+ return { event: "phase_attempt", phase: rec.phase, payload: compact({
31
+ attempt: rec.attempt, model: rec.model, turns: rec.turns, duration_ms: rec.duration_ms,
32
+ cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined,
33
+ input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
34
+ cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
35
+ }) };
36
+ }
37
+ if (rec.event === "RUN_TERMINAL") {
38
+ return { event: "run_terminal", phase: null, payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs }) };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * @param {{repo?: string|null, emitFn?: Function, now?: () => number, resolveRepo?: () => string|null, env?: object}} o
45
+ * @returns {(rec: object) => Promise<void>}
46
+ */
47
+ export function makeEmitTelemetry({ repo = null, emitFn = emit, now = Date.now, resolveRepo = defaultResolveRepo, env = process.env } = {}) {
48
+ const startedAt = now();
49
+ let resolved = repo || env.FORGE_REPO || null;
50
+ let tried = Boolean(resolved);
51
+ return async (rec) => {
52
+ try {
53
+ if ((env.FORGE_TELEMETRY || "on") === "off") return;
54
+ const mapped = mapRecord(rec, now() - startedAt);
55
+ if (!mapped || rec.issue == null) return;
56
+ if (!tried) { tried = true; resolved = resolveRepo() || null; }
57
+ if (!resolved) return; // no destination: the Node emitter would drop it anyway
58
+ // ctx carries repo/issue/run/phase: emit() drops those reserved keys from the payload.
59
+ emitFn(mapped.event, mapped.payload, { repo: resolved, issue: rec.issue, run: `r_${rec.issue}_${Math.floor(startedAt / 1000)}`, phase: mapped.phase ?? undefined });
60
+ } catch { /* telemetry never fails a run */ }
61
+ };
62
+ }
@@ -0,0 +1,60 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { makeEmitTelemetry, mapRecord } from "./telemetry-seam.mjs";
6
+
7
+ const attempt = { event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 2, issue: 7, duration_ms: 1234, cost_usd: 0.123456789, model: "sonnet", turns: null,
8
+ usage: { input_tokens: 1, output_tokens: 2, cache_read_input_tokens: 3, cache_creation_input_tokens: 4 } };
9
+
10
+ function harness(over = {}) {
11
+ const calls = [];
12
+ const emit = makeEmitTelemetry({ repo: "o/r", emitFn: (...a) => { calls.push(a); }, now: () => 5000, env: {}, ...over });
13
+ return { calls, emit };
14
+ }
15
+
16
+ describe("engine telemetry seam", () => {
17
+ it("flattens usage, omits nulls and passes repo/issue/run/phase as ctx (not payload)", async () => {
18
+ const { calls, emit } = harness();
19
+ await emit(attempt);
20
+ const [event, payload, ctx] = calls[0];
21
+ assert.equal(event, "phase_attempt");
22
+ assert.deepEqual(payload, { attempt: 2, model: "sonnet", duration_ms: 1234, cost_usd: 0.123457, input_tokens: 1, output_tokens: 2, cache_read_tokens: 3, cache_creation_tokens: 4 });
23
+ assert.equal(ctx.repo, "o/r");
24
+ assert.equal(ctx.issue, 7);
25
+ assert.equal(ctx.phase, "build");
26
+ assert.match(ctx.run, /^r_7_\d+$/);
27
+ for (const v of Object.values(payload)) assert.notEqual(typeof v, "object");
28
+ });
29
+ it("maps RUN_TERMINAL with reason, code and wall time; ignores other records", async () => {
30
+ let t = 1000;
31
+ const { calls, emit } = harness({ now: () => t });
32
+ t = 61000;
33
+ await emit({ event: "RUN_TERMINAL", reason: "needs-human", code: "review.escalated", issue: 7 });
34
+ await emit({ event: "PHASE_START", phase: "build", issue: 7 });
35
+ assert.equal(calls.length, 1);
36
+ assert.equal(calls[0][0], "run_terminal");
37
+ assert.deepEqual(calls[0][1], { reason: "needs-human", code: "review.escalated", wall_ms: 60000 });
38
+ assert.equal(calls[0][2].phase, undefined);
39
+ });
40
+ it("null usage yields a record with no token fields", () => {
41
+ assert.deepEqual(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, usage: null, duration_ms: 5 }).payload, { attempt: 1, duration_ms: 5 });
42
+ });
43
+ it("never throws, even when the emitter throws, and honours FORGE_TELEMETRY=off", async () => {
44
+ const boom = harness({ emitFn: () => { throw new Error("down"); } });
45
+ await boom.emit(attempt);
46
+ const off = harness({ env: { FORGE_TELEMETRY: "off" } });
47
+ await off.emit(attempt);
48
+ assert.equal(off.calls.length, 0);
49
+ });
50
+ it("drops records with no resolvable repo (the Node emitter would drop them) and resolves lazily once", async () => {
51
+ let resolves = 0;
52
+ const none = harness({ repo: null, resolveRepo: () => { resolves++; return null; } });
53
+ await none.emit(attempt); await none.emit(attempt);
54
+ assert.equal(none.calls.length, 0);
55
+ assert.equal(resolves, 1);
56
+ const some = harness({ repo: null, resolveRepo: () => "a/b" });
57
+ await some.emit(attempt);
58
+ assert.equal(some.calls[0][2].repo, "a/b");
59
+ });
60
+ });