forgedock 1.16.2 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,114 @@
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
+ REVIEW_BLOCKING_ISSUE: "review.blocking_issue",
23
+ REVIEW_PR_OPEN: "review.pr_open",
24
+ BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
25
+ BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
26
+ BUILD_SIZE_OVER_GATE: "build.size_over_gate", // retired (forge#3862): never emitted; kept so historic runs parse
27
+ BUILD_COHESIVE: "build.cohesive", // retired (forge#3862): never emitted
28
+ REMEDIATE_NO_MARKER: "remediate.no_marker",
29
+ REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
30
+ REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
31
+ REMEDIATE_UNFIXABLE: "remediate.unfixable",
32
+ CI_RED: "ci.red",
33
+ CI_TIMEOUT: "ci.timeout",
34
+ BASE_CONFLICT: "base.conflict",
35
+ RATE_LIMITED: "rate_limited",
36
+ ENGINE_ERROR: "engine.error",
37
+ INFRA_DISK_QUOTA: "infra.disk_quota",
38
+ LEASE_HELD: "lease.held",
39
+ DEPENDENCY_OPEN: "dependency.open",
40
+ DEPENDENCY_UNREADABLE: "dependency.unreadable",
41
+ INVALID: "invalid",
42
+ DECOMPOSED: "decomposed",
43
+ // Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
44
+ COMPLETED: "completed",
45
+ AWAITING_MERGE: "awaiting_merge",
46
+ UNCLASSIFIED,
47
+ });
48
+
49
+ const CODE_SET = new Set(Object.values(OUTCOME_CODES));
50
+
51
+ /** Every code, in declaration order. */
52
+ export const ALL_OUTCOME_CODES = Object.freeze([...CODE_SET]);
53
+
54
+ export function isOutcomeCode(code) {
55
+ return typeof code === "string" && CODE_SET.has(code);
56
+ }
57
+
58
+ /** Absent or unknown -> `unclassified`; never throws. */
59
+ export function normalizeCode(code) {
60
+ return isOutcomeCode(code) ? code : UNCLASSIFIED;
61
+ }
62
+
63
+ const DISK_QUOTA_RE = /EDQUOT|ENOSPC|(?:errno|system error) -?(?:28|122)\b|No space left|Disk quota exceeded/i;
64
+
65
+ /** True when runner/stderr text reports a full disk or exhausted quota (forge#3572). Match runner error text only, never review prose. */
66
+ export function isDiskQuotaText(text) {
67
+ return typeof text === "string" && DISK_QUOTA_RE.test(text);
68
+ }
69
+
70
+ /** True when a thrown runner error is a disk-full/quota failure (message, errno or stderr). */
71
+ export function isDiskQuotaError(err) {
72
+ if (!err || typeof err !== "object") return false;
73
+ if (err.code === "ENOSPC" || err.code === "EDQUOT" || err.errno === -28 || err.errno === -122) return true;
74
+ return isDiskQuotaText(err.message) || isDiskQuotaText(err.stderr);
75
+ }
76
+
77
+ /** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
78
+ export function codeForRunnerError(err) {
79
+ if (!err || typeof err !== "object") return UNCLASSIFIED;
80
+ if (isDiskQuotaError(err)) return OUTCOME_CODES.INFRA_DISK_QUOTA;
81
+ if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
82
+ return OUTCOME_CODES.RATE_LIMITED;
83
+ switch (err.code) {
84
+ case "NO_API_KEY":
85
+ case "NO_SDK":
86
+ case "CLI_BACKEND_FAILED":
87
+ case "PHASE_ARGS_INVALID":
88
+ case "CLI_MAX_TURNS":
89
+ return OUTCOME_CODES.ENGINE_ERROR;
90
+ default:
91
+ return UNCLASSIFIED;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Default code for a terminal reason when the producer supplied none. Only reasons that
97
+ * name one cause map; `needs-human` has many causes, so it stays `unclassified`.
98
+ */
99
+ export function codeForTerminalReason(reason) {
100
+ switch (reason) {
101
+ case "merged":
102
+ case "phase-complete":
103
+ case "investigation":
104
+ return OUTCOME_CODES.COMPLETED;
105
+ case "awaiting-merge": return OUTCOME_CODES.AWAITING_MERGE;
106
+ case "invalid": return OUTCOME_CODES.INVALID;
107
+ case "decomposed": return OUTCOME_CODES.DECOMPOSED;
108
+ case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
109
+ case "deferred": return OUTCOME_CODES.LEASE_HELD;
110
+ case "dependency-open": return OUTCOME_CODES.DEPENDENCY_OPEN;
111
+ case "dependency-unreadable": return OUTCOME_CODES.DEPENDENCY_UNREADABLE;
112
+ default: return UNCLASSIFIED;
113
+ }
114
+ }
@@ -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
  /**
@@ -99,6 +111,8 @@ async function issueMarkers(issue, io) {
99
111
  const REMEDIATION_HELD_MARKER = "FORGE:REMEDIATION_HELD";
100
112
  /** Non-terminal deferral record (remediate.md Phase M0 liveness step): a live earlier run holds the lease. Never a COMPLETE/trail marker. */
101
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";
102
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). */
103
117
  const LEASE_HOLDER_RE = /^[A-Za-z0-9._-]{1,128}$/;
104
118
  const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -151,13 +165,21 @@ export function sliceToCurrentCycle(comments) {
151
165
  * GitHub already carries markers. Errors propagate (caller fails closed).
152
166
  */
153
167
  export async function nextCycleNumber(issue, localCycles, io) {
168
+ return Math.max(await newestCycle(issue, io), localCycles) + 1;
169
+ }
170
+ /**
171
+ * Highest trusted FORGE:CYCLE number on the issue (0 when none). Reads only
172
+ * trusted comments via issueMarkers/cycleNumber; errors propagate so the caller
173
+ * fails closed (forge#3931: hydration must not reset on an unreadable trail).
174
+ */
175
+ export async function newestCycle(issue, io) {
154
176
  const { comments } = await issueMarkers(issue, io);
155
177
  let highest = 0;
156
178
  for (const body of comments) {
157
179
  const c = cycleNumber(body);
158
180
  if (c !== null && c > highest) highest = c;
159
181
  }
160
- return Math.max(highest, localCycles) + 1;
182
+ return highest;
161
183
  }
162
184
  /**
163
185
  * Idempotently publish the FORGE:CYCLE marker for cycle `n`: skipped when the
@@ -186,15 +208,22 @@ export async function postCycleMarker(issue, n, io) {
186
208
  * ALREADY_DONE when the newest gate marker is blocked (forge#3725).
187
209
  */
188
210
  function latestGateState(comments) {
211
+ return latestGateMarker(comments).state;
212
+ }
213
+ /**
214
+ * Single newest-first scan used by latestGateState. Returns the newest gate marker's state and its comment index
215
+ * (`{state: "none", index: -1}` when there is none).
216
+ */
217
+ function latestGateMarker(comments) {
189
218
  const gate = "FORGE:ACCEPTANCE_GATE";
190
219
  for (let i = comments.length - 1; i >= 0; i--) {
191
220
  const b = comments[i];
192
- if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return "blocked";
221
+ if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return { state: "blocked", index: i };
193
222
  if (!startsWithHeader(b, gate)) continue;
194
- if (hasSentinelLine(b, `${gate}:PASSED`)) return "pass";
195
- if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return "blocked";
223
+ if (hasSentinelLine(b, `${gate}:PASSED`)) return { state: "pass", index: i };
224
+ if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return { state: "blocked", index: i };
196
225
  }
197
- return "none";
226
+ return { state: "none", index: -1 };
198
227
  }
199
228
  /**
200
229
  * Investigation exit evidence (forge#3534): a trusted comment led by
@@ -242,44 +271,75 @@ async function hasInvestigationCloseEvidence(issue, io) {
242
271
  || hasPhaseComplete(sliceToCurrentCycle(comments));
243
272
  }
244
273
 
274
+ /** A SIZE_OVERRIDE needs a non-empty justification — the first non-blank, non-HTML-comment line after the marker line. */
275
+ function overrideHasJustification(body) {
276
+ const lines = String(body).replace(/\r\n/g, "\n").split("\n");
277
+ return lines.slice(1).some((l) => l.trim() !== "" && !l.trimStart().startsWith("<!--"));
278
+ }
279
+
245
280
  /**
246
- * Trusted comment bodies, chronological — the same single fetch + trust predicate
247
- * as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
281
+ * forge#3862 / forge#3932: does trusted GitHub state show a REVOKED decomposition?
282
+ * Revoked = a trusted `<!-- FORGE:DECOMPOSED:REVOKED -->` comment newer than the newest
283
+ * trusted `FORGE:DECOMPOSED` comment (or one with no `FORGE:DECOMPOSED` left). The revocation
284
+ * alone is the operator's explicit signal: the size gate that once required a justified
285
+ * `FORGE:SIZE_OVERRIDE` alongside it is gone, so no override clause remains. An untrusted
286
+ * revocation never counts. Fail closed: an unreadable comment trail is `false`, never "revoked".
248
287
  */
249
- async function trustedCommentBodies(issue, io) {
250
- try { return (await issueMarkers(issue, io)).comments; } catch { return []; }
288
+ export async function decompositionRevoked(issue, io) {
289
+ let comments;
290
+ try { comments = (await issueMarkers(issue, io)).comments; } catch { return false; }
291
+ const last = (pred) => { for (let i = comments.length - 1; i >= 0; i--) if (pred(comments[i])) return i; return -1; };
292
+ const revokedAt = last((c) => startsWithHeader(c, "FORGE:DECOMPOSED:REVOKED"));
293
+ if (revokedAt < 0) return false;
294
+ // `startsWithHeader` also matches `FORGE:DECOMPOSED:REVOKED` for the header `FORGE:DECOMPOSED` (":" ends
295
+ // the identifier), so the live decomposition record is the newest one that is not the revocation itself.
296
+ const decomposedAt = last((c) => startsWithHeader(c, "FORGE:DECOMPOSED") && !startsWithHeader(c, "FORGE:DECOMPOSED:REVOKED"));
297
+ return decomposedAt <= revokedAt;
251
298
  }
252
299
 
253
- /** build.md B5.5 Step 2: a SIZE_OVERRIDE needs a non-empty justification — the first non-blank, non-HTML-comment line after the marker line. */
254
- function overrideHasJustification(body) {
255
- const lines = String(body).replace(/\r\n/g, "\n").split("\n");
256
- return lines.slice(1).some((l) => l.trim() !== "" && !l.trimStart().startsWith("<!--"));
300
+ /** Read one issue's REST record (`state`, `body`, author provenance). Throws on a failed read; null on unparsable/non-object output. */
301
+ async function readIssueRecord(n, io) {
302
+ const out = String((await io.gh(["api", `repos/{owner}/{repo}/issues/${n}`])) ?? "").trim();
303
+ if (!out) return { empty: true };
304
+ try {
305
+ const j = JSON.parse(out);
306
+ return j && typeof j === "object" && !Array.isArray(j) ? j : null;
307
+ } catch { return null; }
257
308
  }
258
309
 
259
310
  /**
260
- * Size-gate routing (build.md B5.5). True only when the latest `FORGE:DIFF_SIZE`
261
- * says `result: OVER`, carries a `### Split Proposal` (the discriminator against
262
- * the decompose-loop-guard Blocked exit, which also posts OVER but must not
263
- * re-enter decompose), and no justified `FORGE:SIZE_OVERRIDE` comment follows it,
264
- * and the issue is not already decomposed. `comments` MUST be TRUSTED bodies in
265
- * chronological order (see trustedCommentBodies); an untrusted comment never
266
- * reaches this function.
311
+ * forge#3932: does the issue declare a `**Depends on**: #N` dependency that is still OPEN?
312
+ * Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
313
+ * Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for the exact line decompose.md emits
314
+ * (`**Depends on**: #N` at line start; loose "depends on/after" prose is ignored). A self-reference is
315
+ * ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
316
+ * when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
317
+ * An empty read of the issue itself means "no body to trust" and yields `null`.
267
318
  */
268
- function sizeGateRoutesToDecompose(comments) {
269
- const gate = PHASE_MARKERS.build.sizeGateMarker;
270
- const override = PHASE_MARKERS.build.sizeOverrideMarker;
271
- const starts = (c, m) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${m}`);
272
- if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return false;
273
- let idx = -1;
274
- for (let i = comments.length - 1; i >= 0; i--) if (starts(comments[i], gate)) { idx = i; break; }
275
- if (idx < 0) return false;
276
- const body = comments[idx].replace(/\r\n/g, "\n");
277
- const m = /^result:[ \t]*(OK|OVERRIDDEN|COHESIVE|OVER)[ \t]*$/m.exec(body);
278
- if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return false;
279
- return !comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c));
319
+ export async function openDependency(issue, io) {
320
+ let rec;
321
+ try { rec = await readIssueRecord(issue, io); } catch (e) {
322
+ return { unreadable: true, detail: `issue ${issue} could not be read for its **Depends on** line: ${e?.message || e}` };
323
+ }
324
+ if (rec === null) return { unreadable: true, detail: `issue ${issue} could not be parsed for its **Depends on** line` };
325
+ if (rec.empty || typeof rec.body !== "string" || !isTrustedComment(rec)) return null;
326
+ const deps = [...new Set([...rec.body.replace(/\r\n/g, "\n").matchAll(/^\*\*Depends on\*\*:[ \t]*#(\d+)\b/gm)].map((m) => Number(m[1])))]
327
+ .filter((n) => n > 0 && n !== Number(issue));
328
+ const open = [];
329
+ for (const dep of deps) {
330
+ let d;
331
+ try { d = await readIssueRecord(dep, io); } catch (e) {
332
+ return { unreadable: true, detail: `dependency #${dep} of issue ${issue} could not be read: ${e?.message || e}` };
333
+ }
334
+ const st = typeof d?.state === "string" ? d.state.toLowerCase() : "";
335
+ if (st === "open") open.push(dep);
336
+ else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
337
+ }
338
+ return open.length ? { open } : null;
280
339
  }
340
+
281
341
  /**
282
- * Decompose loop guard (mirrors build.md B5.5 and decompose.md Guard 1). Returns a
342
+ * Decompose loop guard (mirrors decompose.md Guard 1; forge#3676). Returns a
283
343
  * short reason string when the issue must NOT be routed to decompose, else null:
284
344
  * a `**Parent**: #N` body line (a sub-issue; decompose Guard 1 would BLOCK it), a
285
345
  * TRUSTED `FORGE:DECOMPOSED` comment, or a TRUSTED justified `FORGE:SIZE_OVERRIDE`.
@@ -305,11 +365,6 @@ async function decomposeLoopGuard(issue, comments, io) {
305
365
  return null;
306
366
  }
307
367
 
308
- /** Cheap untrusted pre-check: does ANY comment look like an OVER gate? Only then is the trusted fetch worth making. */
309
- function mayRouteToDecompose(comments) {
310
- const gate = PHASE_MARKERS.build.sizeGateMarker;
311
- return comments.some((c) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${gate}`) && /^result:[ \t]*OVER[ \t]*\r?$/m.test(c));
312
- }
313
368
  /**
314
369
  * Count commits on `branch` ahead of `lane`'s base. On the first build the
315
370
  * branch does not exist yet, so real git rejects the ref range — swallow
@@ -542,11 +597,6 @@ async function reviewArgs(state, ctx, io) {
542
597
  "--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
543
598
  }
544
599
 
545
- /** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
546
- const DEFAULT_ESTIMATE_FACTOR = 1.2;
547
- /** Default `build.diff_size.batch_seam_min` (docs/CONFIG.md); the engine cannot read forge.yaml here. */
548
- const DEFAULT_BATCH_SEAM_MIN = 3;
549
-
550
600
  /**
551
601
  * Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
552
602
  * trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
@@ -567,42 +617,24 @@ function isDecomposed(report) {
567
617
  console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
568
618
  return false;
569
619
  }
570
- // forge#3573 safety net inputs: own-line, whole-number fields only; absent/0/non-numeric
571
- // fields have no effect. The factor is the investigator's effective `ESTIMATE_FACTOR:`
572
- // (positive decimal); absent/invalid -> 1.2.
573
- const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
574
- const gate = /^GATE_THRESHOLD:[ \t]*(\d+)[ \t]*$/m.exec(text);
575
- const fac = /^ESTIMATE_FACTOR:[ \t]*(\d+(?:\.\d+)?)[ \t]*$/m.exec(text);
576
- const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
577
- const parsedFactor = fac ? Number(fac[1]) : NaN;
578
- const factor = Number.isFinite(parsedFactor) && parsedFactor > 0 ? parsedFactor : DEFAULT_ESTIMATE_FACTOR;
579
- const sized = est && gate && Number(gate[1]) > 0;
580
- const estimate = sized ? Number(est[1]) : 0;
581
- const threshold = sized ? Number(gate[1]) : 0;
620
+ // forge#3862: decomposition is a planning decision on SEPARABILITY, never on size. The engine has
621
+ // no line-count rule and no estimate net: an estimate, however large, never routes to decompose.
622
+ // A YES must be backed by the separability evidence the investigator records: an own-line
623
+ // `INDEPENDENT_SEAMS:` of at least 2 and an own-line `DECOMPOSE_JUSTIFICATION:`. A report that
624
+ // carries the seam field but not that evidence is downgraded to NO (fail toward building).
625
+ // A YES with no `INDEPENDENT_SEAMS:` field at all (an older or malformed report) is kept.
582
626
  if ((verdict ?? marker) === "YES") {
583
- // forge#3786: a YES the numbers do not require is downgraded to NO unless the report
584
- // carries an own-line `DECOMPOSE_JUSTIFICATION:` line. Required = estimate over
585
- // threshold x factor, or the seam clause (seams >= batch_seam_min and estimate over
586
- // threshold / 2). The engine cannot see the `batch` label or the configured
587
- // `batch_seam_min`, so it uses the default 3 and stays lenient for batches. Absent
588
- // numeric fields keep the YES (no silent downgrade of a malformed report).
589
- if (sized) {
590
- const seams = seamsM ? Number(seamsM[1]) : 0;
591
- const required = estimate > threshold * factor || (seams >= DEFAULT_BATCH_SEAM_MIN && estimate > threshold / 2);
627
+ const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
628
+ if (seamsM) {
629
+ const seams = Number(seamsM[1]);
592
630
  const justified = /^DECOMPOSE_JUSTIFICATION:[ \t]*\S/m.test(text);
593
- if (!required && !justified) {
594
- console.error(`[engine] unsupported DECOMPOSE:YES (estimate ${estimate}, seams ${seams}, gate ${threshold} x ${factor}, no DECOMPOSE_JUSTIFICATION); treating as not decomposed`);
631
+ if (seams < 2 || !justified) {
632
+ console.error(`[engine] unsupported DECOMPOSE:YES (separable seams ${seams}${justified ? "" : ", no DECOMPOSE_JUSTIFICATION"}); treating as not decomposed`);
595
633
  return false;
596
634
  }
597
635
  }
598
636
  return true;
599
637
  }
600
- // forge#3573 safety net: an investigator NO whose own size estimate is clearly over
601
- // the gate (estimate > threshold x factor) fails toward decompose.
602
- if (sized && estimate > threshold * factor) {
603
- console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
604
- return true;
605
- }
606
638
  return false;
607
639
  }
608
640
 
@@ -625,7 +657,7 @@ export const PHASES = [
625
657
  const invalid = `<!-- ${PHASE_MARKERS.investigate.invalidMarker} -->`;
626
658
  const complete = `<!-- ${PHASE_MARKERS.investigate.completionMarker} -->`;
627
659
  if (closing === invalid)
628
- return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
660
+ return { status: "committed", terminalReason: "invalid", code: OC.INVALID, outputs: { verdict: "INVALID" } };
629
661
  if (closing === complete) {
630
662
  // investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
631
663
  if (isDecomposed(report)) {
@@ -636,7 +668,7 @@ export const PHASES = [
636
668
  console.error(`[engine] decompose suppressed for #${state.issue}: ${guard}; treating as not decomposed`);
637
669
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
638
670
  }
639
- return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
671
+ return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: { decompose: true } };
640
672
  }
641
673
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
642
674
  }
@@ -665,7 +697,7 @@ export const PHASES = [
665
697
  async detectOutcome(state, io) {
666
698
  const { comments } = await issueMarkers(state.issue, io);
667
699
  if (hasMarker(comments, PHASE_MARKERS.decompose.header, PHASE_MARKERS.decompose.completionMarker))
668
- return { status: "committed", terminalReason: "decomposed", outputs: {} };
700
+ return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: {} };
669
701
  return { status: "failed", detail: `no ${PHASE_MARKERS.decompose.completionMarker} marker` };
670
702
  },
671
703
  // Always terminal: decomposition spawns independent sub-issues, each of
@@ -722,8 +754,9 @@ export const PHASES = [
722
754
  // review. Non-retryable: a retry would only re-read the same append-only markers.
723
755
  // A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
724
756
  // build invocation does via build.md B0 gate resume (re-enters B6.5; forge#3725).
757
+ // forge#3862: the build never routes to decompose (no size-gate decomposed outcome).
725
758
  if (latestGateState(comments) === "blocked") {
726
- return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
759
+ return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, code: OC.BUILD_ACCEPTANCE_FAILED, retryable: false };
727
760
  }
728
761
  if (complete && ahead > 0) {
729
762
  return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
@@ -734,13 +767,6 @@ export const PHASES = [
734
767
  if (hasInvestigationMarker(comments)) {
735
768
  return { status: "committed", terminalReason: "investigation", outputs: {} };
736
769
  }
737
- // B5.5 NEEDS_DECOMPOSE: the size gate posts no FORGE:BUILDER:COMPLETE and
738
- // build.md says the router (not build) dispatches decompose with no
739
- // needs-human. Non-retryable by construction: a retry re-measures and
740
- // re-emits the same exit.
741
- if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
742
- return { status: "committed", terminalReason: "decomposed", outputs: branch ? { branch } : {} };
743
- }
744
770
  const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
745
771
  // forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
746
772
  // but the resolved (real, ground-truth) branch has zero commits ahead of
@@ -803,7 +829,7 @@ export const PHASES = [
803
829
  // The PR number from GitHub (openPrFor) always wins over parsed text.
804
830
  const outputs = { pr: pr.number };
805
831
  // Opt-in: only an explicit escalation signal sets handoff:true. A PR number alone never hands off.
806
- const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true };
832
+ const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true, code: OC.REVIEW_ESCALATED };
807
833
  const rr = parseReviewResult(result?.text);
808
834
  // forge#3530: carry the review's remediation kind (router Phase 4R parity) so the
809
835
  // remediate phase can post the matching bound marker. Untrusted model output: only
@@ -812,15 +838,18 @@ export const PHASES = [
812
838
  if (rr && rr.status === "NEXT" && rr.next === "remediate" && rr.remediation)
813
839
  outputs.remediation = isRemediationKind(rr.remediation) ? rr.remediation : "unknown";
814
840
  if (pr.needsHuman || pr.remediating) return escalated;
815
- if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
841
+ if (rr && rr.status === "NEXT" && rr.next === "remediate") return { ...escalated, code: OC.REVIEW_CHANGES_REQUESTED };
816
842
  // An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
817
843
  // (phase trail, ci gate, base conflict, merge refusal) also label the issue
818
844
  // needs-human but are NOT remediation handoffs, so skip the label fallback.
819
- if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs };
845
+ if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs, code: OC.REVIEW_PR_OPEN };
820
846
  let snap = null;
821
847
  try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
822
848
  if (snap?.ok && (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating"))) return escalated;
823
- return { status: "failed", detail: "PR open, not merged", retryable: false, outputs };
849
+ // forge#3931: an idempotent re-review at an unchanged head posts nothing new, so the only evidence of a
850
+ // blocking finding is the trusted FORGE:NOTE_DISPOSITION already on the PR for this head. Hand off to remediate.
851
+ if (await blockingDispositionAtHead(pr, io)) return { ...escalated, detail: "blocking ISSUE finding at unchanged head", code: OC.REVIEW_BLOCKING_ISSUE };
852
+ return { status: "failed", detail: "PR open, not merged", retryable: false, outputs, code: OC.REVIEW_PR_OPEN };
824
853
  },
825
854
  },
826
855
  {
@@ -854,7 +883,17 @@ export const PHASES = [
854
883
  // Legacy handoff with no kind (needs-human label fallback, or no REVIEW_RESULT): there
855
884
  // is nothing to bind, so dispatch as before. remediate.md M0 then applies its default
856
885
  // single-attempt guard. A present-but-unknown kind is never dispatched.
857
- if (kind === null) return legacyArgs();
886
+ // forge#3807: every dispatch (kind-bound or legacy) posts a dispatch marker AFTER the bound marker,
887
+ // never deduped, so detectOutcome can tell a stale deferral from an earlier dispatch from this one's.
888
+ const postDispatchMarker = async () => {
889
+ try {
890
+ await io.gh(["issue", "comment", String(state.issue), "--body",
891
+ `<!-- ${REMEDIATION_DISPATCH_MARKER}: pr=${pr} -->\nEngine dispatching remediation for PR #${pr}.`]);
892
+ } catch (e) {
893
+ throw new PhaseArgsError(`github-unavailable: could not post ${REMEDIATION_DISPATCH_MARKER}; remediation not invoked (${e?.message || e})`);
894
+ }
895
+ };
896
+ if (kind === null) { await postDispatchMarker(); return legacyArgs(); }
858
897
  if (!isRemediationKind(kind))
859
898
  throw new PhaseArgsError(`unknown remediation kind: ${JSON.stringify(kind)}`);
860
899
  const boundMarker = REMEDIATION_BOUND_MARKERS[kind];
@@ -870,6 +909,7 @@ export const PHASES = [
870
909
  } catch (e) {
871
910
  throw new PhaseArgsError(`github-unavailable: could not post FORGE:${boundMarker}; remediation not invoked (${e?.message || e})`);
872
911
  }
912
+ await postDispatchMarker();
873
913
  return legacyArgs();
874
914
  },
875
915
  entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
@@ -935,16 +975,20 @@ export const PHASES = [
935
975
  // lease that expired since is the NEXT dispatch's business). Only a trusted, full-line record for THIS
936
976
  // PR counts, and only while it is newer than every `FORGE:REMEDIATION` trail comment: a later run
937
977
  // that proceeded posts one, which retires the deferral.
938
- let deferredAt = -1, trailAt = -1, deferred = null;
978
+ // forge#3807: a deferral older than this dispatch's marker is stale (a later dispatch proceeded and
979
+ // crashed before posting a trail), so it is a failed attempt. No marker (older runs) => dispatchAt -1.
980
+ let deferredAt = -1, trailAt = -1, dispatchAt = -1, deferred = null;
981
+ const dispatchSentinel = `${REMEDIATION_DISPATCH_MARKER}: pr=${state.pr}`;
939
982
  scoped.forEach((b, i) => {
983
+ if (state.pr != null && hasSentinelLine(b, dispatchSentinel)) dispatchAt = i;
940
984
  if (startsWithHeader(b, REMEDIATION_DEFERRED_MARKER) && hasSentinelLine(b, REMEDIATION_DEFERRED_MARKER)) { deferredAt = i; deferred = b; }
941
985
  else if (startsWithHeader(b, PHASE_MARKERS.remediate.header)) trailAt = i;
942
986
  });
943
- if (deferred !== null && deferredAt > trailAt && (state.pr == null || /^pr=(\d+)\s*$/m.exec(deferred)?.[1] === String(state.pr))) {
987
+ if (deferred !== null && deferredAt > trailAt && deferredAt > dispatchAt && (state.pr == null || /^pr=(\d+)\s*$/m.exec(deferred)?.[1] === String(state.pr))) {
944
988
  const kind = /^kind=(\w+)\s*$/m.exec(deferred)?.[1] ?? "lease";
945
- return { status: "blocked", reason: "deferred", retryable: false, detail: `deferred-live-run: ${kind}` };
989
+ return { status: "blocked", reason: "deferred", code: OC.REMEDIATE_DEFERRED_LIVE_RUN, retryable: false, detail: `deferred-live-run: ${kind}` };
946
990
  }
947
- return { status: "failed", detail: `no ${completion} marker` };
991
+ return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `no ${completion} marker` };
948
992
  }
949
993
  // Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
950
994
  // (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
@@ -968,12 +1012,18 @@ export const PHASES = [
968
1012
  return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
969
1013
  case "RE-ESCALATED":
970
1014
  case "UNFIXABLE":
1015
+ // forge#3931: a trail recorded against an older PR head must not be replayed as this run's outcome.
1016
+ if (await remediationHeadStale(state, io))
1017
+ return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
971
1018
  // Both leave the issue at needs-human (a fresh escalation, or a
972
1019
  // policy judgment call respectively) — reuse the existing
973
1020
  // "needs-human" terminal reason rather than inventing two more,
974
1021
  // matching the #2352/#2353 precedent of reusing an existing
975
- // TERMINAL_REASONS value where semantically equivalent.
976
- return { status: "committed", terminalReason: "needs-human", outputs: { reGateOutcome } };
1022
+ // TERMINAL_REASONS value where semantically equivalent. forge#3833:
1023
+ // the two stay distinguishable by outcome code.
1024
+ return { status: "committed", terminalReason: "needs-human",
1025
+ code: reGateOutcome === "UNFIXABLE" ? OC.REMEDIATE_UNFIXABLE : OC.REMEDIATE_RE_ESCALATED,
1026
+ outputs: { reGateOutcome } };
977
1027
  default:
978
1028
  return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
979
1029
  }
@@ -1051,7 +1101,11 @@ export const PHASES = [
1051
1101
  // that label is still a real terminal state (nothing left for this phase
1052
1102
  // to do), just not a "merged" one — reuse the existing "invalid" reason
1053
1103
  // (already in TERMINAL_REASONS) rather than inventing a new one.
1054
- if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
1104
+ // SEC-1: the label alone is not proof either. A reopened or never-closed issue can still
1105
+ // carry a stale `workflow:merged`, so "merged" requires CLOSED together with the label
1106
+ // (same predicate as the remediate `mergedResult`). A label without CLOSED falls through
1107
+ // to the OPEN / PHASE_COMPLETE / "issue not closed" paths below.
1108
+ if (snap.state === "CLOSED" && snap.labels.includes(PHASE_MARKERS.close.completionLabel))
1055
1109
  return { status: "committed", terminalReason: "merged", outputs: {} };
1056
1110
  // forge#3534: an investigation exit closes the issue itself; keep that reason honest.
1057
1111
  if (snap.state === "CLOSED") {
@@ -1152,16 +1206,73 @@ async function mergedPrForBranch(branch, lane, io) {
1152
1206
  return hit ? { number: hit.number } : null;
1153
1207
  } catch { return null; }
1154
1208
  }
1209
+ /**
1210
+ * forge#3931: true when the newest trusted FORGE:NOTE_DISPOSITION on the PR covers the live head
1211
+ * (`reviewed_sha`) and filed at least one ISSUE (`findings_filed` > 0). Trusted comments only; an
1212
+ * unreadable PR, missing head or unparsable record returns false (fail closed: no handoff).
1213
+ */
1214
+ async function blockingDispositionAtHead(pr, io) {
1215
+ if (!pr?.head) return false;
1216
+ try {
1217
+ const { comments } = await issueMarkers(pr.number, io);
1218
+ const recs = comments.filter((b) => /^\s*<!--\s*FORGE:NOTE_DISPOSITION:/.test(b));
1219
+ if (!recs.length) return false;
1220
+ const first = recs[recs.length - 1].split("\n", 1)[0];
1221
+ const sha = /\breviewed_sha=([0-9a-f]{40,64})\b/.exec(first)?.[1];
1222
+ const filed = /\bfindings_filed=(\d+)\b/.exec(first)?.[1];
1223
+ return sha === pr.head && Number(filed) > 0;
1224
+ } catch { return false; }
1225
+ }
1155
1226
  async function prStatusFor(state, io) {
1156
1227
  const n = await openPrFor(state, io);
1157
1228
  if (!n) return null;
1158
- const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt"]);
1229
+ const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt,headRefOid"]);
1159
1230
  let j;
1160
1231
  try { j = JSON.parse(out || "{}"); } catch { return null; }
1161
1232
  const labels = (j.labels || []).map((l) => l.name || l);
1162
1233
  return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
1163
1234
  needsHuman: labels.includes("needs-human"),
1164
- remediating: labels.includes("workflow:remediating") };
1235
+ remediating: labels.includes("workflow:remediating"),
1236
+ head: typeof j.headRefOid === "string" && SHA_RE.test(j.headRefOid) ? j.headRefOid : null };
1237
+ }
1238
+ const SHA_RE = /^[0-9a-f]{40,64}$/;
1239
+
1240
+ /**
1241
+ * forge#3931: true only when the newest trusted FORGE:REMEDIATION:COMPLETE names a
1242
+ * `**Head**:` SHA that differs from the live PR head, i.e. the trail was produced for
1243
+ * an older head. Any unreadable input (no pr, no trail, `unknown`/missing head, gh or
1244
+ * parse failure) returns false so callers keep today's behaviour (fail closed).
1245
+ */
1246
+ export async function remediationHeadStale(state, io) {
1247
+ if (state?.pr == null) return false;
1248
+ try {
1249
+ const { comments } = await issueMarkers(state.issue, io);
1250
+ const latest = latestMarked(comments, PHASE_MARKERS.remediate.header, PHASE_MARKERS.remediate.completionMarker);
1251
+ const recorded = latest === null ? null : /\*\*Head\*\*:[ \t]*([0-9a-f]{40,64})\b/.exec(latest)?.[1] ?? null;
1252
+ if (!recorded) return false;
1253
+ const j = JSON.parse((await io.gh(["pr", "view", String(state.pr), "--json", "headRefOid"])) || "{}");
1254
+ return typeof j.headRefOid === "string" && SHA_RE.test(j.headRefOid) && j.headRefOid !== recorded;
1255
+ } catch { return false; }
1256
+ }
1257
+
1258
+ /**
1259
+ * forge#3931 (CONC-1): hydrate-time cycle reset is safe only when the hydrated progress is provably from an
1260
+ * OLDER cycle. The index carries no cycle number, so a same-cycle mid-flight or blocked run must not reset.
1261
+ * Provable = a terminal index AND live GitHub state showing the issue OPEN with `needs-human` and
1262
+ * `workflow:remediating` both clear AND no OPEN PR on the branch. Any unreadable input returns false.
1263
+ */
1264
+ export async function cycleResetProvable(state, io) {
1265
+ if (!state?.terminal) return false;
1266
+ try {
1267
+ const snap = await issueSnapshot(state.issue, io);
1268
+ if (!snap.ok || snap.state !== "OPEN") return false;
1269
+ if (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating")) return false;
1270
+ if (state.branch) {
1271
+ const a = JSON.parse((await io.gh(["pr", "list", "--head", state.branch, "--json", "number,state", "--state", "all"])) || "[]");
1272
+ if (!Array.isArray(a) || a.some((p) => p && p.state === "OPEN")) return false;
1273
+ }
1274
+ return true;
1275
+ } catch { return false; }
1165
1276
  }
1166
1277
 
1167
1278
  /** The engine's transition function: first uncommitted phase whose gate holds. */