forgedock 1.17.0 → 1.19.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 (143) hide show
  1. package/bin/engine/outcome-codes.mjs +37 -2
  2. package/bin/engine/phases.mjs +207 -106
  3. package/bin/engine/runlog.mjs +17 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +37 -2
  7. package/bin/engine.mjs +221 -15
  8. package/bin/forgedock.mjs +1 -1
  9. package/bin/hooks/pre-tool-use.mjs +65 -32
  10. package/bin/runner.mjs +125 -6
  11. package/bin/stats/aggregate.mjs +7 -3
  12. package/bin/stats/cache.mjs +7 -3
  13. package/bin/stats/github-source.mjs +203 -24
  14. package/bin/stats/index.mjs +46 -9
  15. package/bin/stats/render.mjs +1 -1
  16. package/bin/stats/stats.test.mjs +264 -15
  17. package/commands/cleanup.md +10 -3
  18. package/commands/deploy-pr.md +64 -0
  19. package/commands/fix-ci.md +20 -6
  20. package/commands/milestone.md +34 -17
  21. package/commands/orchestrate/phase-4-execution.md +11 -4
  22. package/commands/review-pr-staging.md +145 -522
  23. package/commands/review-pr.md +496 -3124
  24. package/commands/test-gate.md +25 -10
  25. package/commands/work-on/build/architect.md +66 -1
  26. package/commands/work-on/build/implement.md +21 -1
  27. package/commands/work-on/build/validate.md +322 -28
  28. package/commands/work-on/build.md +173 -95
  29. package/commands/work-on/close.md +50 -15
  30. package/commands/work-on/decompose.md +72 -34
  31. package/commands/work-on/investigate.md +16 -12
  32. package/commands/work-on/remediate.md +58 -25
  33. package/commands/work-on/review.md +36 -21
  34. package/commands/work-on.md +1 -2
  35. package/docs/CONFIG.md +132 -26
  36. package/docs/OUTCOME-CODES.md +12 -3
  37. package/docs/RELEASE-NOTES.md +12 -0
  38. package/docs/STATS.md +13 -5
  39. package/docs/TELEMETRY.md +7 -4
  40. package/docs/WORK-ON-RUNTIME.md +24 -0
  41. package/docs/spec/forge-protocol-v1.md +2 -2
  42. package/package.json +2 -2
  43. package/packages/protocol/src/phases.js +5 -5
  44. package/packages/protocol/src/types.js +1 -1
  45. package/scripts/acceptance-red-green.sh +90 -0
  46. package/scripts/acceptance-red-green.test.sh +74 -0
  47. package/scripts/cap-findings.sh +1 -1
  48. package/scripts/cap-findings.test.sh +3 -1
  49. package/scripts/check-contract-scope.test.sh +2 -0
  50. package/scripts/check-edge-cases.sh +143 -0
  51. package/scripts/check-edge-cases.test.sh +175 -0
  52. package/scripts/check-spec-bash.sh +5 -1
  53. package/scripts/check-spec-bash.test.sh +20 -0
  54. package/scripts/check-spec-markers.sh +2 -0
  55. package/scripts/check-test-tmp-cleanup.sh +56 -0
  56. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  57. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  58. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  59. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  60. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  61. package/scripts/ci-local/adapters/common.mjs +115 -0
  62. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  63. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  64. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  65. package/scripts/ci-local/adapters/portability.mjs +104 -0
  66. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  67. package/scripts/ci-local/cli.mjs +50 -0
  68. package/scripts/ci-local/lib.mjs +371 -0
  69. package/scripts/ci-local/plan.mjs +186 -0
  70. package/scripts/ci-local/run.mjs +345 -0
  71. package/scripts/ci-local/scoping.mjs +508 -0
  72. package/scripts/ci-local.sh +97 -0
  73. package/scripts/ci-local.test.sh +919 -0
  74. package/scripts/classify-finding.sh +32 -6
  75. package/scripts/classify-finding.test.sh +23 -0
  76. package/scripts/classify-test-run.sh +69 -0
  77. package/scripts/classify-test-run.test.sh +90 -0
  78. package/scripts/close-parent-c3.test.sh +71 -0
  79. package/scripts/consumer-sweep.sh +569 -0
  80. package/scripts/consumer-sweep.test.sh +357 -0
  81. package/scripts/detect-append-registries.sh +106 -0
  82. package/scripts/detect-append-registries.test.sh +64 -0
  83. package/scripts/diff-size.sh +93 -4
  84. package/scripts/diff-size.test.sh +59 -2
  85. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  86. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  87. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  88. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  89. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  90. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  91. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  92. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  93. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  94. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  95. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  96. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  97. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  98. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  99. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  100. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  101. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  102. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  103. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  104. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  105. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  106. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  107. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  108. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  109. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  110. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  111. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  112. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  113. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  114. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  115. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  116. package/scripts/forge-root.test.sh +14 -12
  117. package/scripts/lib/telemetry.sh +1 -0
  118. package/scripts/publish-staging-sync.test.sh +48 -0
  119. package/scripts/remediate-approved-head.test.sh +2 -0
  120. package/scripts/remediate-basesync.test.sh +1 -1
  121. package/scripts/resolve-deploy-gate.sh +32 -6
  122. package/scripts/resolve-deploy-gate.test.sh +42 -1
  123. package/scripts/review-acceptance-gate.sh +2 -2
  124. package/scripts/review-acceptance-gate.test.sh +20 -0
  125. package/scripts/review-delta.sh +8 -1
  126. package/scripts/review-delta.test.sh +19 -0
  127. package/scripts/review-finalize.sh +1579 -0
  128. package/scripts/review-finalize.test.sh +1040 -0
  129. package/scripts/review-pr-delta-wiring.test.sh +32 -72
  130. package/scripts/review-pr-domain-map.test.sh +18 -13
  131. package/scripts/review-pr-gate2.test.sh +9 -65
  132. package/scripts/review-pr-staging-scope.test.sh +14 -98
  133. package/scripts/review-prepare.sh +53 -15
  134. package/scripts/review-prepare.test.sh +37 -3
  135. package/scripts/review-roster.sh +16 -2
  136. package/scripts/review-roster.test.sh +22 -0
  137. package/scripts/run-shell-tests.sh +5 -0
  138. package/scripts/telemetry.test.sh +2 -0
  139. package/scripts/trusted-comments.test.sh +11 -3
  140. package/scripts/verify-phase-trail.test.sh +8 -0
  141. package/scripts/wait-ci-green.sh +61 -9
  142. package/scripts/wait-ci-green.test.sh +44 -6
  143. package/scripts/with-run-tmpdir.sh +20 -0
@@ -19,20 +19,29 @@ export const OUTCOME_CODES = Object.freeze({
19
19
  REVIEW_CHANGES_REQUESTED: "review.changes_requested",
20
20
  REVIEW_ESCALATED: "review.escalated",
21
21
  REVIEW_REREVIEW_SKIPPED: "review.rereview_skipped",
22
+ REVIEW_BLOCKING_ISSUE: "review.blocking_issue",
23
+ REVIEW_PR_OPEN: "review.pr_open",
24
+ REVIEW_CI_PENDING_TIMEOUT: "review.ci_pending_timeout",
22
25
  BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
23
26
  BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
24
- BUILD_SIZE_OVER_GATE: "build.size_over_gate",
25
- BUILD_COHESIVE: "build.cohesive",
27
+ BUILD_SIZE_OVER_GATE: "build.size_over_gate", // retired (forge#3862): never emitted; kept so historic runs parse
28
+ BUILD_COHESIVE: "build.cohesive", // retired (forge#3862): never emitted
26
29
  REMEDIATE_NO_MARKER: "remediate.no_marker",
27
30
  REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
28
31
  REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
29
32
  REMEDIATE_UNFIXABLE: "remediate.unfixable",
33
+ REMEDIATE_INPR_REQUESTED: "remediate.inpr_requested",
30
34
  CI_RED: "ci.red",
31
35
  CI_TIMEOUT: "ci.timeout",
32
36
  BASE_CONFLICT: "base.conflict",
33
37
  RATE_LIMITED: "rate_limited",
34
38
  ENGINE_ERROR: "engine.error",
39
+ INFRA_DISK_QUOTA: "infra.disk_quota",
40
+ INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
41
+ CLI_TIMEOUT: "cli.timeout",
35
42
  LEASE_HELD: "lease.held",
43
+ DEPENDENCY_OPEN: "dependency.open",
44
+ DEPENDENCY_UNREADABLE: "dependency.unreadable",
36
45
  INVALID: "invalid",
37
46
  DECOMPOSED: "decomposed",
38
47
  // Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
@@ -55,12 +64,36 @@ export function normalizeCode(code) {
55
64
  return isOutcomeCode(code) ? code : UNCLASSIFIED;
56
65
  }
57
66
 
67
+ const DISK_QUOTA_RE = /EDQUOT|ENOSPC|(?:errno|system error) -?(?:28|122)\b|No space left|Disk quota exceeded/i;
68
+
69
+ /** True when runner/stderr text reports a full disk or exhausted quota (forge#3572). Match runner error text only, never review prose. */
70
+ export function isDiskQuotaText(text) {
71
+ return typeof text === "string" && DISK_QUOTA_RE.test(text);
72
+ }
73
+
74
+ /** True when a thrown runner error is a disk-full/quota failure (message, errno or stderr). */
75
+ export function isDiskQuotaError(err) {
76
+ if (!err || typeof err !== "object") return false;
77
+ if (err.code === "ENOSPC" || err.code === "EDQUOT" || err.errno === -28 || err.errno === -122) return true;
78
+ return isDiskQuotaText(err.message) || isDiskQuotaText(err.stderr);
79
+ }
80
+
81
+ /** True when a thrown runner error is a transient CLI spawn failure (ETIMEDOUT/EAGAIN/ENOMEM/EMFILE at spawn), forge#4021. */
82
+ export function isTransientSpawnError(err) {
83
+ return !!err && typeof err === "object" && err.code === "CLI_SPAWN_TRANSIENT";
84
+ }
85
+
58
86
  /** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
59
87
  export function codeForRunnerError(err) {
60
88
  if (!err || typeof err !== "object") return UNCLASSIFIED;
89
+ if (isDiskQuotaError(err)) return OUTCOME_CODES.INFRA_DISK_QUOTA;
61
90
  if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
62
91
  return OUTCOME_CODES.RATE_LIMITED;
63
92
  switch (err.code) {
93
+ case "CLI_SPAWN_TRANSIENT":
94
+ return OUTCOME_CODES.INFRA_CLI_SPAWN_TRANSIENT;
95
+ case "CLI_TIMEOUT":
96
+ return OUTCOME_CODES.CLI_TIMEOUT;
64
97
  case "NO_API_KEY":
65
98
  case "NO_SDK":
66
99
  case "CLI_BACKEND_FAILED":
@@ -87,6 +120,8 @@ export function codeForTerminalReason(reason) {
87
120
  case "decomposed": return OUTCOME_CODES.DECOMPOSED;
88
121
  case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
89
122
  case "deferred": return OUTCOME_CODES.LEASE_HELD;
123
+ case "dependency-open": return OUTCOME_CODES.DEPENDENCY_OPEN;
124
+ case "dependency-unreadable": return OUTCOME_CODES.DEPENDENCY_UNREADABLE;
90
125
  default: return UNCLASSIFIED;
91
126
  }
92
127
  }
@@ -165,13 +165,21 @@ export function sliceToCurrentCycle(comments) {
165
165
  * GitHub already carries markers. Errors propagate (caller fails closed).
166
166
  */
167
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) {
168
176
  const { comments } = await issueMarkers(issue, io);
169
177
  let highest = 0;
170
178
  for (const body of comments) {
171
179
  const c = cycleNumber(body);
172
180
  if (c !== null && c > highest) highest = c;
173
181
  }
174
- return Math.max(highest, localCycles) + 1;
182
+ return highest;
175
183
  }
176
184
  /**
177
185
  * Idempotently publish the FORGE:CYCLE marker for cycle `n`: skipped when the
@@ -203,8 +211,7 @@ function latestGateState(comments) {
203
211
  return latestGateMarker(comments).state;
204
212
  }
205
213
  /**
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
214
+ * Single newest-first scan used by latestGateState. Returns the newest gate marker's state and its comment index
208
215
  * (`{state: "none", index: -1}` when there is none).
209
216
  */
210
217
  function latestGateMarker(comments) {
@@ -213,20 +220,12 @@ function latestGateMarker(comments) {
213
220
  const b = comments[i];
214
221
  if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return { state: "blocked", index: i };
215
222
  if (!startsWithHeader(b, gate)) continue;
216
- if (hasSentinelLine(b, `${gate}:PASSED`)) return { state: "pass", index: i };
223
+ // :DEGRADED is pass-class (forge#3628): the build completed, and review gates the merge on the degraded paths.
224
+ if (hasSentinelLine(b, `${gate}:PASSED`) || hasSentinelLine(b, `${gate}:DEGRADED`)) return { state: "pass", index: i };
217
225
  if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return { state: "blocked", index: i };
218
226
  }
219
227
  return { state: "none", index: -1 };
220
228
  }
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;
229
- }
230
229
  /**
231
230
  * Investigation exit evidence (forge#3534): a trusted comment led by
232
231
  * `<!-- FORGE:INVESTIGATION:DELIVERABLES -->` as a full line. Anchored like every
@@ -273,48 +272,75 @@ async function hasInvestigationCloseEvidence(issue, io) {
273
272
  || hasPhaseComplete(sliceToCurrentCycle(comments));
274
273
  }
275
274
 
276
- /**
277
- * Trusted comment bodies, chronological — the same single fetch + trust predicate
278
- * as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
279
- */
280
- async function trustedCommentBodies(issue, io) {
281
- try { return (await issueMarkers(issue, io)).comments; } catch { return []; }
282
- }
283
-
284
- /** 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. */
275
+ /** A SIZE_OVERRIDE needs a non-empty justification — the first non-blank, non-HTML-comment line after the marker line. */
285
276
  function overrideHasJustification(body) {
286
277
  const lines = String(body).replace(/\r\n/g, "\n").split("\n");
287
278
  return lines.slice(1).some((l) => l.trim() !== "" && !l.trimStart().startsWith("<!--"));
288
279
  }
289
280
 
290
281
  /**
291
- * Size-gate routing (build.md B5.5). True only when the latest `FORGE:DIFF_SIZE`
292
- * says `result: OVER`, carries a `### Split Proposal` (the discriminator against
293
- * the decompose-loop-guard Blocked exit, which also posts OVER but must not
294
- * re-enter decompose), and no justified `FORGE:SIZE_OVERRIDE` comment follows it,
295
- * and the issue is not already decomposed. `comments` MUST be TRUSTED bodies in
296
- * chronological order (see trustedCommentBodies); an untrusted comment never
297
- * reaches this function.
282
+ * forge#3862 / forge#3932: does trusted GitHub state show a REVOKED decomposition?
283
+ * Revoked = a trusted `<!-- FORGE:DECOMPOSED:REVOKED -->` comment newer than the newest
284
+ * trusted `FORGE:DECOMPOSED` comment (or one with no `FORGE:DECOMPOSED` left). The revocation
285
+ * alone is the operator's explicit signal: the size gate that once required a justified
286
+ * `FORGE:SIZE_OVERRIDE` alongside it is gone, so no override clause remains. An untrusted
287
+ * revocation never counts. Fail closed: an unreadable comment trail is `false`, never "revoked".
298
288
  */
299
- function sizeGateRouteIndex(comments) {
300
- const gate = PHASE_MARKERS.build.sizeGateMarker;
301
- const override = PHASE_MARKERS.build.sizeOverrideMarker;
302
- const starts = (c, m) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${m}`);
303
- if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return -1;
304
- let idx = -1;
305
- for (let i = comments.length - 1; i >= 0; i--) if (starts(comments[i], gate)) { idx = i; break; }
306
- if (idx < 0) return -1;
307
- const body = comments[idx].replace(/\r\n/g, "\n");
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;
289
+ export async function decompositionRevoked(issue, io) {
290
+ let comments;
291
+ try { comments = (await issueMarkers(issue, io)).comments; } catch { return false; }
292
+ const last = (pred) => { for (let i = comments.length - 1; i >= 0; i--) if (pred(comments[i])) return i; return -1; };
293
+ const revokedAt = last((c) => startsWithHeader(c, "FORGE:DECOMPOSED:REVOKED"));
294
+ if (revokedAt < 0) return false;
295
+ // `startsWithHeader` also matches `FORGE:DECOMPOSED:REVOKED` for the header `FORGE:DECOMPOSED` (":" ends
296
+ // the identifier), so the live decomposition record is the newest one that is not the revocation itself.
297
+ const decomposedAt = last((c) => startsWithHeader(c, "FORGE:DECOMPOSED") && !startsWithHeader(c, "FORGE:DECOMPOSED:REVOKED"));
298
+ return decomposedAt <= revokedAt;
311
299
  }
312
- /** Boolean form of sizeGateRouteIndex (index of the routing DIFF_SIZE comment, or -1). */
313
- function sizeGateRoutesToDecompose(comments) {
314
- return sizeGateRouteIndex(comments) >= 0;
300
+
301
+ /** Read one issue's REST record (`state`, `body`, author provenance). Throws on a failed read; null on unparsable/non-object output. */
302
+ async function readIssueRecord(n, io) {
303
+ const out = String((await io.gh(["api", `repos/{owner}/{repo}/issues/${n}`])) ?? "").trim();
304
+ if (!out) return { empty: true };
305
+ try {
306
+ const j = JSON.parse(out);
307
+ return j && typeof j === "object" && !Array.isArray(j) ? j : null;
308
+ } catch { return null; }
315
309
  }
310
+
316
311
  /**
317
- * Decompose loop guard (mirrors build.md B5.5 and decompose.md Guard 1). Returns a
312
+ * forge#3932: does the issue declare a `**Depends on**: #N` dependency that is still OPEN?
313
+ * Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
314
+ * Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for the exact line decompose.md emits
315
+ * (`**Depends on**: #N` at line start; loose "depends on/after" prose is ignored). A self-reference is
316
+ * ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
317
+ * when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
318
+ * An empty read of the issue itself means "no body to trust" and yields `null`.
319
+ */
320
+ export async function openDependency(issue, io) {
321
+ let rec;
322
+ try { rec = await readIssueRecord(issue, io); } catch (e) {
323
+ return { unreadable: true, detail: `issue ${issue} could not be read for its **Depends on** line: ${e?.message || e}` };
324
+ }
325
+ if (rec === null) return { unreadable: true, detail: `issue ${issue} could not be parsed for its **Depends on** line` };
326
+ if (rec.empty || typeof rec.body !== "string" || !isTrustedComment(rec)) return null;
327
+ const deps = [...new Set([...rec.body.replace(/\r\n/g, "\n").matchAll(/^\*\*Depends on\*\*:[ \t]*#(\d+)\b/gm)].map((m) => Number(m[1])))]
328
+ .filter((n) => n > 0 && n !== Number(issue));
329
+ const open = [];
330
+ for (const dep of deps) {
331
+ let d;
332
+ try { d = await readIssueRecord(dep, io); } catch (e) {
333
+ return { unreadable: true, detail: `dependency #${dep} of issue ${issue} could not be read: ${e?.message || e}` };
334
+ }
335
+ const st = typeof d?.state === "string" ? d.state.toLowerCase() : "";
336
+ if (st === "open") open.push(dep);
337
+ else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
338
+ }
339
+ return open.length ? { open } : null;
340
+ }
341
+
342
+ /**
343
+ * Decompose loop guard (mirrors decompose.md Guard 1; forge#3676). Returns a
318
344
  * short reason string when the issue must NOT be routed to decompose, else null:
319
345
  * a `**Parent**: #N` body line (a sub-issue; decompose Guard 1 would BLOCK it), a
320
346
  * TRUSTED `FORGE:DECOMPOSED` comment, or a TRUSTED justified `FORGE:SIZE_OVERRIDE`.
@@ -340,11 +366,6 @@ async function decomposeLoopGuard(issue, comments, io) {
340
366
  return null;
341
367
  }
342
368
 
343
- /** Cheap untrusted pre-check: does ANY comment look like an OVER gate? Only then is the trusted fetch worth making. */
344
- function mayRouteToDecompose(comments) {
345
- const gate = PHASE_MARKERS.build.sizeGateMarker;
346
- return comments.some((c) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${gate}`) && /^result:[ \t]*OVER[ \t]*\r?$/m.test(c));
347
- }
348
369
  /**
349
370
  * Count commits on `branch` ahead of `lane`'s base. On the first build the
350
371
  * branch does not exist yet, so real git rejects the ref range — swallow
@@ -533,6 +554,9 @@ function isRemediationKind(k) {
533
554
  return typeof k === "string" && Object.hasOwn(REMEDIATION_BOUND_MARKERS, k);
534
555
  }
535
556
 
557
+ // forge#4021: the newest remediation trail reported an in-PR fix request (remediate.md M8).
558
+ const INPR_REQUESTED_RE = /\*\*Re-gate outcome\*\*:\s*INPR-FIX-REQUESTED\b/;
559
+
536
560
  const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
537
561
  const BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._\/-]*$/;
538
562
  const LANE_RE = BRANCH_RE;
@@ -577,11 +601,6 @@ async function reviewArgs(state, ctx, io) {
577
601
  "--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
578
602
  }
579
603
 
580
- /** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
581
- const DEFAULT_ESTIMATE_FACTOR = 1.2;
582
- /** Default `build.diff_size.batch_seam_min` (docs/CONFIG.md); the engine cannot read forge.yaml here. */
583
- const DEFAULT_BATCH_SEAM_MIN = 3;
584
-
585
604
  /**
586
605
  * Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
587
606
  * trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
@@ -602,42 +621,24 @@ function isDecomposed(report) {
602
621
  console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
603
622
  return false;
604
623
  }
605
- // forge#3573 safety net inputs: own-line, whole-number fields only; absent/0/non-numeric
606
- // fields have no effect. The factor is the investigator's effective `ESTIMATE_FACTOR:`
607
- // (positive decimal); absent/invalid -> 1.2.
608
- const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
609
- const gate = /^GATE_THRESHOLD:[ \t]*(\d+)[ \t]*$/m.exec(text);
610
- const fac = /^ESTIMATE_FACTOR:[ \t]*(\d+(?:\.\d+)?)[ \t]*$/m.exec(text);
611
- const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
612
- const parsedFactor = fac ? Number(fac[1]) : NaN;
613
- const factor = Number.isFinite(parsedFactor) && parsedFactor > 0 ? parsedFactor : DEFAULT_ESTIMATE_FACTOR;
614
- const sized = est && gate && Number(gate[1]) > 0;
615
- const estimate = sized ? Number(est[1]) : 0;
616
- const threshold = sized ? Number(gate[1]) : 0;
624
+ // forge#3862: decomposition is a planning decision on SEPARABILITY, never on size. The engine has
625
+ // no line-count rule and no estimate net: an estimate, however large, never routes to decompose.
626
+ // A YES must be backed by the separability evidence the investigator records: an own-line
627
+ // `INDEPENDENT_SEAMS:` of at least 2 and an own-line `DECOMPOSE_JUSTIFICATION:`. A report that
628
+ // carries the seam field but not that evidence is downgraded to NO (fail toward building).
629
+ // A YES with no `INDEPENDENT_SEAMS:` field at all (an older or malformed report) is kept.
617
630
  if ((verdict ?? marker) === "YES") {
618
- // forge#3786: a YES the numbers do not require is downgraded to NO unless the report
619
- // carries an own-line `DECOMPOSE_JUSTIFICATION:` line. Required = estimate over
620
- // threshold x factor, or the seam clause (seams >= batch_seam_min and estimate over
621
- // threshold / 2). The engine cannot see the `batch` label or the configured
622
- // `batch_seam_min`, so it uses the default 3 and stays lenient for batches. Absent
623
- // numeric fields keep the YES (no silent downgrade of a malformed report).
624
- if (sized) {
625
- const seams = seamsM ? Number(seamsM[1]) : 0;
626
- const required = estimate > threshold * factor || (seams >= DEFAULT_BATCH_SEAM_MIN && estimate > threshold / 2);
631
+ const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
632
+ if (seamsM) {
633
+ const seams = Number(seamsM[1]);
627
634
  const justified = /^DECOMPOSE_JUSTIFICATION:[ \t]*\S/m.test(text);
628
- if (!required && !justified) {
629
- console.error(`[engine] unsupported DECOMPOSE:YES (estimate ${estimate}, seams ${seams}, gate ${threshold} x ${factor}, no DECOMPOSE_JUSTIFICATION); treating as not decomposed`);
635
+ if (seams < 2 || !justified) {
636
+ console.error(`[engine] unsupported DECOMPOSE:YES (separable seams ${seams}${justified ? "" : ", no DECOMPOSE_JUSTIFICATION"}); treating as not decomposed`);
630
637
  return false;
631
638
  }
632
639
  }
633
640
  return true;
634
641
  }
635
- // forge#3573 safety net: an investigator NO whose own size estimate is clearly over
636
- // the gate (estimate > threshold x factor) fails toward decompose.
637
- if (sized && estimate > threshold * factor) {
638
- console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
639
- return true;
640
- }
641
642
  return false;
642
643
  }
643
644
 
@@ -757,13 +758,7 @@ export const PHASES = [
757
758
  // review. Non-retryable: a retry would only re-read the same append-only markers.
758
759
  // A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
759
760
  // 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
- }
761
+ // forge#3862: the build never routes to decompose (no size-gate decomposed outcome).
767
762
  if (latestGateState(comments) === "blocked") {
768
763
  return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, code: OC.BUILD_ACCEPTANCE_FAILED, retryable: false };
769
764
  }
@@ -776,13 +771,6 @@ export const PHASES = [
776
771
  if (hasInvestigationMarker(comments)) {
777
772
  return { status: "committed", terminalReason: "investigation", outputs: {} };
778
773
  }
779
- // B5.5 NEEDS_DECOMPOSE: the size gate posts no FORGE:BUILDER:COMPLETE and
780
- // build.md says the router (not build) dispatches decompose with no
781
- // needs-human. Non-retryable by construction: a retry re-measures and
782
- // re-emits the same exit.
783
- if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
784
- return { status: "committed", terminalReason: "decomposed", code: OC.BUILD_SIZE_OVER_GATE, outputs: branch ? { branch } : {} };
785
- }
786
774
  const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
787
775
  // forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
788
776
  // but the resolved (real, ground-truth) branch has zero commits ahead of
@@ -858,11 +846,19 @@ export const PHASES = [
858
846
  // An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
859
847
  // (phase trail, ci gate, base conflict, merge refusal) also label the issue
860
848
  // needs-human but are NOT remediation handoffs, so skip the label fallback.
861
- if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs };
849
+ // CI still running when the merge gate's wait window ended: nothing failed, so this is a wait the engine
850
+ // polls (runIssue), not a block. Structured `blocker` field only, never PR/comment prose; "ci gate"
851
+ // (red CI) is a disjoint phrase and keeps the REVIEW_PR_OPEN path below.
852
+ if (rr && rr.status === "BLOCKED" && isCiPendingBlocker(rr.blocker))
853
+ return { status: "failed", detail: "checks still pending", retryable: false, handoff: false, wait: "ci-pending", outputs, code: OC.REVIEW_PR_OPEN };
854
+ if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs, code: OC.REVIEW_PR_OPEN };
862
855
  let snap = null;
863
856
  try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
864
857
  if (snap?.ok && (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating"))) return escalated;
865
- return { status: "failed", detail: "PR open, not merged", retryable: false, outputs };
858
+ // forge#3931: an idempotent re-review at an unchanged head posts nothing new, so the only evidence of a
859
+ // blocking finding is the trusted FORGE:NOTE_DISPOSITION already on the PR for this head. Hand off to remediate.
860
+ if (await blockingDispositionAtHead(pr, io)) return { ...escalated, detail: "blocking ISSUE finding at unchanged head", code: OC.REVIEW_BLOCKING_ISSUE };
861
+ return { status: "failed", detail: "PR open, not merged", retryable: false, outputs, code: OC.REVIEW_PR_OPEN };
866
862
  },
867
863
  },
868
864
  {
@@ -915,7 +911,15 @@ export const PHASES = [
915
911
  const { comments } = await issueMarkers(state.issue, io);
916
912
  // Resume idempotency: a handoff interrupted after the marker post must not double-count.
917
913
  // forge#3542: only a full-line marker in a trusted comment counts.
918
- if (!comments.some((b) => hasSentinelLine(b, `FORGE:${boundMarker}: pr=${pr}`))) {
914
+ // forge#4021: an in-PR fix re-armed by the re-gate (the newest trail after the newest marker reports
915
+ // INPR-FIX-REQUESTED) needs a FRESH marker, or M0's per-kind guard sees the first trail as this dispatch's
916
+ // and returns ALREADY_DONE. The marker count is also the bound (detectOutcome allows one re-arm).
917
+ const sentinel = `FORGE:${boundMarker}: pr=${pr}`;
918
+ let markerAt = -1;
919
+ comments.forEach((b, i) => { if (hasSentinelLine(b, sentinel)) markerAt = i; });
920
+ const trailAfter = markerAt < 0 ? null : latestMarked(comments.slice(markerAt + 1), PHASE_MARKERS.remediate.header, PHASE_MARKERS.remediate.completionMarker);
921
+ const rearmed = kind === "inpr-fix" && trailAfter !== null && INPR_REQUESTED_RE.test(trailAfter);
922
+ if (markerAt < 0 || rearmed) {
919
923
  await io.gh(["issue", "comment", String(state.issue), "--body",
920
924
  `${marker}\nReview handed PR #${pr} to remediation (${kind}); the engine is dispatching it once.`]);
921
925
  }
@@ -925,7 +929,8 @@ export const PHASES = [
925
929
  await postDispatchMarker();
926
930
  return legacyArgs();
927
931
  },
928
- entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
932
+ // forge#4021: also re-entered (with remediationKind "inpr-fix") after a re-gate that requested one in-PR fix round.
933
+ entryCondition: (s) => s.committed.includes("review") && (s.terminalReason === "needs-human" || s.remediationKind === "inpr-fix"),
929
934
  async detectOutcome(state, io) {
930
935
  // forge#3530: read the NEWEST completed trail only. A concatenated oldest-first blob
931
936
  // returned a stale outcome from an earlier remediation when a later one posted nothing.
@@ -979,6 +984,12 @@ export const PHASES = [
979
984
  if (heldAt >= 0 && heldAt > completeAt) {
980
985
  // Merged PR hands to close; otherwise awaiting-merge (terminal for the engine, re-entered by an operator), never a retry.
981
986
  if (await prMerged()) return mergedResult("AUTO-LANDED");
987
+ // CI still pending at M7 is a wait, not a park: the engine polls CI (no model) and re-dispatches this phase, which
988
+ // resumes at M7. Only the structured `Hold reason` line of the newest trusted held record counts; the stale-head
989
+ // and deploy-gate holds keep the terminal awaiting-merge result below.
990
+ if (/^\*\*Hold reason\*\*:[ \t]*CI pending\b/m.test(scoped[heldAt]))
991
+ return { status: "failed", detail: "checks still pending", retryable: false, handoff: false, wait: "ci-pending",
992
+ outputs: { reGateOutcome: "HELD-AWAITING-MERGE" }, code: OC.REVIEW_PR_OPEN };
982
993
  return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome: "HELD-AWAITING-MERGE" } };
983
994
  }
984
995
  if (latest === null) {
@@ -1025,6 +1036,9 @@ export const PHASES = [
1025
1036
  return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
1026
1037
  case "RE-ESCALATED":
1027
1038
  case "UNFIXABLE":
1039
+ // forge#3931: a trail recorded against an older PR head must not be replayed as this run's outcome.
1040
+ if (await remediationHeadStale(state, io))
1041
+ return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
1028
1042
  // Both leave the issue at needs-human (a fresh escalation, or a
1029
1043
  // policy judgment call respectively) — reuse the existing
1030
1044
  // "needs-human" terminal reason rather than inventing two more,
@@ -1034,11 +1048,30 @@ export const PHASES = [
1034
1048
  return { status: "committed", terminalReason: "needs-human",
1035
1049
  code: reGateOutcome === "UNFIXABLE" ? OC.REMEDIATE_UNFIXABLE : OC.REMEDIATE_RE_ESCALATED,
1036
1050
  outputs: { reGateOutcome } };
1051
+ case "INPR-FIX-REQUESTED": {
1052
+ // forge#4021: the re-review approved but posted a FORGE:INPR_FIX work order. One further in-PR fix
1053
+ // remediation is allowed (never needs-human on the first request); the bound marker count caps it.
1054
+ if (await remediationHeadStale(state, io))
1055
+ return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
1056
+ if (await prMerged()) return mergedResult(reGateOutcome);
1057
+ const round = /\*\*INPR round\*\*:\s*(\d+)/.exec(latest)?.[1] ?? null;
1058
+ const outputs = { reGateOutcome, ...(round ? { inprRound: Number(round) } : {}) };
1059
+ const boundSentinel = `FORGE:${REMEDIATION_BOUND_MARKERS["inpr-fix"]}: pr=${state.pr}`;
1060
+ const priorMarkers = state.pr == null ? 2 : comments.filter((b) => hasSentinelLine(b, boundSentinel)).length;
1061
+ if (priorMarkers >= 2)
1062
+ return { status: "committed", terminalReason: "needs-human", code: OC.REMEDIATE_RE_ESCALATED, outputs };
1063
+ return { status: "committed", code: OC.REMEDIATE_INPR_REQUESTED, outputs: { ...outputs, remediation: "inpr-fix" } };
1064
+ }
1065
+ case "REREVIEW-REQUIRED":
1066
+ if (await remediationHeadStale(state, io))
1067
+ return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `stale ${completion} trail: PR head moved since it was recorded` };
1068
+ return { status: "committed", terminalReason: "needs-human", code: OC.REMEDIATE_RE_ESCALATED, outputs: { reGateOutcome } };
1037
1069
  default:
1038
1070
  return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
1039
1071
  }
1040
1072
  },
1041
- isTerminalAfter: () => true,
1073
+ // forge#4021: a committed in-PR fix request continues the run (remediate re-arms) instead of ending it.
1074
+ isTerminalAfter: (s) => s.remediationKind !== "inpr-fix",
1042
1075
  },
1043
1076
  {
1044
1077
  id: "close",
@@ -1111,7 +1144,11 @@ export const PHASES = [
1111
1144
  // that label is still a real terminal state (nothing left for this phase
1112
1145
  // to do), just not a "merged" one — reuse the existing "invalid" reason
1113
1146
  // (already in TERMINAL_REASONS) rather than inventing a new one.
1114
- if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
1147
+ // SEC-1: the label alone is not proof either. A reopened or never-closed issue can still
1148
+ // carry a stale `workflow:merged`, so "merged" requires CLOSED together with the label
1149
+ // (same predicate as the remediate `mergedResult`). A label without CLOSED falls through
1150
+ // to the OPEN / PHASE_COMPLETE / "issue not closed" paths below.
1151
+ if (snap.state === "CLOSED" && snap.labels.includes(PHASE_MARKERS.close.completionLabel))
1115
1152
  return { status: "committed", terminalReason: "merged", outputs: {} };
1116
1153
  // forge#3534: an investigation exit closes the issue itself; keep that reason honest.
1117
1154
  if (snap.state === "CLOSED") {
@@ -1170,6 +1207,10 @@ export async function detectTrustedOutcome(phaseId, state, io) {
1170
1207
  * Untrusted model output: only the fixed keys below are read, via anchored
1171
1208
  * line matches; `pr_number` must be digits-only. Returns null when absent.
1172
1209
  */
1210
+ export function isCiPendingBlocker(blocker) {
1211
+ return typeof blocker === "string" && /checks still pending/i.test(blocker) && !/ci gate/i.test(blocker);
1212
+ }
1213
+
1173
1214
  export function parseReviewResult(text) {
1174
1215
  if (typeof text !== "string" || !text) return null;
1175
1216
  const lines = text.split(/\r?\n/);
@@ -1180,6 +1221,9 @@ export function parseReviewResult(text) {
1180
1221
  for (let i = start + 1; i < lines.length; i++) {
1181
1222
  const m = /^\s+(status|next|remediation|pr_number):[ \t]*(\S*)[ \t]*$/.exec(lines[i]);
1182
1223
  if (m) { if (!(m[1] in out)) out[m[1]] = m[2]; continue; }
1224
+ // `blocker` is free text (spaces): rest of the anchored line, first wins, only ever substring-tested.
1225
+ const b = /^\s+blocker:[ \t]*(.*?)[ \t]*$/.exec(lines[i]);
1226
+ if (b) { if (!("blocker" in out)) out.blocker = b[1].slice(0, 500); continue; }
1183
1227
  if (/^\s+[A-Za-z_]+:/.test(lines[i]) || !lines[i].trim()) continue;
1184
1228
  break;
1185
1229
  }
@@ -1212,16 +1256,73 @@ async function mergedPrForBranch(branch, lane, io) {
1212
1256
  return hit ? { number: hit.number } : null;
1213
1257
  } catch { return null; }
1214
1258
  }
1259
+ /**
1260
+ * forge#3931: true when the newest trusted FORGE:NOTE_DISPOSITION on the PR covers the live head
1261
+ * (`reviewed_sha`) and filed at least one ISSUE (`findings_filed` > 0). Trusted comments only; an
1262
+ * unreadable PR, missing head or unparsable record returns false (fail closed: no handoff).
1263
+ */
1264
+ async function blockingDispositionAtHead(pr, io) {
1265
+ if (!pr?.head) return false;
1266
+ try {
1267
+ const { comments } = await issueMarkers(pr.number, io);
1268
+ const recs = comments.filter((b) => /^\s*<!--\s*FORGE:NOTE_DISPOSITION:/.test(b));
1269
+ if (!recs.length) return false;
1270
+ const first = recs[recs.length - 1].split("\n", 1)[0];
1271
+ const sha = /\breviewed_sha=([0-9a-f]{40,64})\b/.exec(first)?.[1];
1272
+ const filed = /\bfindings_filed=(\d+)\b/.exec(first)?.[1];
1273
+ return sha === pr.head && Number(filed) > 0;
1274
+ } catch { return false; }
1275
+ }
1215
1276
  async function prStatusFor(state, io) {
1216
1277
  const n = await openPrFor(state, io);
1217
1278
  if (!n) return null;
1218
- const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt"]);
1279
+ const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt,headRefOid"]);
1219
1280
  let j;
1220
1281
  try { j = JSON.parse(out || "{}"); } catch { return null; }
1221
1282
  const labels = (j.labels || []).map((l) => l.name || l);
1222
1283
  return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
1223
1284
  needsHuman: labels.includes("needs-human"),
1224
- remediating: labels.includes("workflow:remediating") };
1285
+ remediating: labels.includes("workflow:remediating"),
1286
+ head: typeof j.headRefOid === "string" && SHA_RE.test(j.headRefOid) ? j.headRefOid : null };
1287
+ }
1288
+ const SHA_RE = /^[0-9a-f]{40,64}$/;
1289
+
1290
+ /**
1291
+ * forge#3931: true only when the newest trusted FORGE:REMEDIATION:COMPLETE names a
1292
+ * `**Head**:` SHA that differs from the live PR head, i.e. the trail was produced for
1293
+ * an older head. Any unreadable input (no pr, no trail, `unknown`/missing head, gh or
1294
+ * parse failure) returns false so callers keep today's behaviour (fail closed).
1295
+ */
1296
+ export async function remediationHeadStale(state, io) {
1297
+ if (state?.pr == null) return false;
1298
+ try {
1299
+ const { comments } = await issueMarkers(state.issue, io);
1300
+ const latest = latestMarked(comments, PHASE_MARKERS.remediate.header, PHASE_MARKERS.remediate.completionMarker);
1301
+ const recorded = latest === null ? null : /\*\*Head\*\*:[ \t]*([0-9a-f]{40,64})\b/.exec(latest)?.[1] ?? null;
1302
+ if (!recorded) return false;
1303
+ const j = JSON.parse((await io.gh(["pr", "view", String(state.pr), "--json", "headRefOid"])) || "{}");
1304
+ return typeof j.headRefOid === "string" && SHA_RE.test(j.headRefOid) && j.headRefOid !== recorded;
1305
+ } catch { return false; }
1306
+ }
1307
+
1308
+ /**
1309
+ * forge#3931 (CONC-1): hydrate-time cycle reset is safe only when the hydrated progress is provably from an
1310
+ * OLDER cycle. The index carries no cycle number, so a same-cycle mid-flight or blocked run must not reset.
1311
+ * Provable = a terminal index AND live GitHub state showing the issue OPEN with `needs-human` and
1312
+ * `workflow:remediating` both clear AND no OPEN PR on the branch. Any unreadable input returns false.
1313
+ */
1314
+ export async function cycleResetProvable(state, io) {
1315
+ if (!state?.terminal) return false;
1316
+ try {
1317
+ const snap = await issueSnapshot(state.issue, io);
1318
+ if (!snap.ok || snap.state !== "OPEN") return false;
1319
+ if (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating")) return false;
1320
+ if (state.branch) {
1321
+ const a = JSON.parse((await io.gh(["pr", "list", "--head", state.branch, "--json", "number,state", "--state", "all"])) || "[]");
1322
+ if (!Array.isArray(a) || a.some((p) => p && p.state === "OPEN")) return false;
1323
+ }
1324
+ return true;
1325
+ } catch { return false; }
1225
1326
  }
1226
1327
 
1227
1328
  /** The engine's transition function: first uncommitted phase whose gate holds. */
@@ -91,7 +91,13 @@ export function deriveState(events) {
91
91
  // forge#3530: the review's remediation kind must survive resume. A later review
92
92
  // commit replaces it; a committed remediate consumes it, so it is never reused.
93
93
  if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
94
- else if (e.phase === "remediate") s.remediationKind = null;
94
+ else if (e.phase === "remediate") {
95
+ // forge#4021: a re-gate that requested an in-PR fix re-arms remediate once (bounded in
96
+ // phases.mjs by the INPR_REMEDIATION marker count); every other remediate commit consumes the kind.
97
+ const rearm = e.outputs?.remediation === "inpr-fix";
98
+ s.remediationKind = rearm ? "inpr-fix" : null;
99
+ if (rearm) s.committed = s.committed.filter((ph) => ph !== "remediate");
100
+ }
95
101
  break;
96
102
  case "RUN_TERMINAL":
97
103
  s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
@@ -106,6 +112,15 @@ export function deriveState(events) {
106
112
  // retry re-enters remediate/decompose instead of close/build.
107
113
  case "RUN_REOPEN":
108
114
  s.terminal = false; s.terminalReason = lastCommitReason; s.outcomeCode = lastCommitCode; s.v = e.seq;
115
+ // forge#3862: a `decomposed` terminal whose decomposition was revoked (trusted GitHub state,
116
+ // checked by the engine before it appends this event) reopens at build: un-commit the
117
+ // phases named in `uncommit` and drop the stale handoff reason so pickPhase re-selects build.
118
+ if (Array.isArray(e.uncommit) && e.uncommit.length) {
119
+ s.committed = s.committed.filter((p) => !e.uncommit.includes(p));
120
+ s.terminalReason = null; s.outcomeCode = null;
121
+ // the revoked handoff reason must not be restored by a later RUN_REOPEN (e.g. after an engine-error)
122
+ lastCommitReason = null; lastCommitCode = null;
123
+ }
109
124
  // forge#3754: a remediate that ended needs-human is re-run after a human clears the
110
125
  // label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
111
126
  // does not re-terminate and remediate's entryCondition re-selects it.
@@ -114,7 +129,7 @@ export function deriveState(events) {
114
129
  // keeps remediate committed so the resume guard is not bypassed.
115
130
  if (lastCommitPhase === "remediate" && lastCommitReason === "needs-human"
116
131
  && lastTerminalReason === "needs-human") {
117
- s.committed = s.committed.filter((p) => p !== "remediate");
132
+ s.committed = s.committed.filter((ph) => ph !== "remediate");
118
133
  }
119
134
  lastTerminalReason = null;
120
135
  s.lastRateLimit = null; // forge#3791: an explicit reopen never re-enters a stale pause
@@ -37,6 +37,9 @@ export function mapRecord(rec, wallMs) {
37
37
  if (rec.event === "RUN_TERMINAL") {
38
38
  return { event: "run_terminal", phase: null, payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs }) };
39
39
  }
40
+ if (rec.event === "CI_PENDING_WAIT") {
41
+ return { event: "ci_pending_wait", phase: rec.phase ?? "review", payload: compact({ pr: rec.pr, minutes_waited: rec.minutes_waited, result: rec.result, polls: rec.polls, rate_limit_sleeps: rec.rate_limit_sleeps }) };
42
+ }
40
43
  return null;
41
44
  }
42
45