forgedock 1.17.0 → 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 (60) hide show
  1. package/bin/engine/outcome-codes.mjs +24 -2
  2. package/bin/engine/phases.mjs +153 -102
  3. package/bin/engine/runlog.mjs +7 -0
  4. package/bin/engine-cli.mjs +5 -0
  5. package/bin/engine.mjs +84 -10
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/hooks/pre-tool-use.mjs +65 -32
  8. package/bin/runner.mjs +63 -4
  9. package/bin/stats/github-source.mjs +89 -18
  10. package/bin/stats/stats.test.mjs +89 -1
  11. package/commands/cleanup.md +10 -3
  12. package/commands/deploy-pr.md +64 -0
  13. package/commands/fix-ci.md +20 -6
  14. package/commands/milestone.md +34 -17
  15. package/commands/orchestrate/phase-4-execution.md +11 -4
  16. package/commands/review-pr-staging.md +142 -519
  17. package/commands/review-pr.md +490 -3128
  18. package/commands/test-gate.md +25 -10
  19. package/commands/work-on/build/architect.md +13 -1
  20. package/commands/work-on/build/implement.md +2 -1
  21. package/commands/work-on/build/validate.md +2 -0
  22. package/commands/work-on/build.md +107 -86
  23. package/commands/work-on/close.md +50 -15
  24. package/commands/work-on/decompose.md +72 -34
  25. package/commands/work-on/investigate.md +9 -12
  26. package/commands/work-on/remediate.md +27 -13
  27. package/commands/work-on/review.md +5 -3
  28. package/commands/work-on.md +1 -2
  29. package/docs/CONFIG.md +44 -25
  30. package/docs/OUTCOME-CODES.md +7 -2
  31. package/docs/RELEASE-NOTES.md +12 -0
  32. package/docs/STATS.md +2 -1
  33. package/docs/TELEMETRY.md +2 -2
  34. package/docs/WORK-ON-RUNTIME.md +6 -0
  35. package/docs/spec/forge-protocol-v1.md +2 -2
  36. package/package.json +1 -1
  37. package/packages/protocol/src/phases.js +5 -5
  38. package/scripts/cap-findings.sh +1 -1
  39. package/scripts/cap-findings.test.sh +1 -1
  40. package/scripts/check-spec-bash.sh +5 -1
  41. package/scripts/check-spec-bash.test.sh +20 -0
  42. package/scripts/close-parent-c3.test.sh +69 -0
  43. package/scripts/diff-size.sh +93 -4
  44. package/scripts/diff-size.test.sh +59 -2
  45. package/scripts/forge-root.test.sh +13 -11
  46. package/scripts/publish-staging-sync.test.sh +48 -0
  47. package/scripts/resolve-deploy-gate.sh +32 -6
  48. package/scripts/resolve-deploy-gate.test.sh +42 -1
  49. package/scripts/review-finalize.sh +1427 -0
  50. package/scripts/review-finalize.test.sh +833 -0
  51. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  52. package/scripts/review-pr-domain-map.test.sh +18 -13
  53. package/scripts/review-pr-gate2.test.sh +9 -65
  54. package/scripts/review-pr-staging-scope.test.sh +14 -98
  55. package/scripts/review-prepare.sh +48 -12
  56. package/scripts/review-prepare.test.sh +19 -1
  57. package/scripts/review-roster.sh +16 -2
  58. package/scripts/review-roster.test.sh +22 -0
  59. package/scripts/trusted-comments.test.sh +9 -2
  60. package/scripts/wait-ci-green.test.sh +3 -3
@@ -19,10 +19,12 @@ 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",
22
24
  BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
23
25
  BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
24
- BUILD_SIZE_OVER_GATE: "build.size_over_gate",
25
- BUILD_COHESIVE: "build.cohesive",
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
26
28
  REMEDIATE_NO_MARKER: "remediate.no_marker",
27
29
  REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
28
30
  REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
@@ -32,7 +34,10 @@ export const OUTCOME_CODES = Object.freeze({
32
34
  BASE_CONFLICT: "base.conflict",
33
35
  RATE_LIMITED: "rate_limited",
34
36
  ENGINE_ERROR: "engine.error",
37
+ INFRA_DISK_QUOTA: "infra.disk_quota",
35
38
  LEASE_HELD: "lease.held",
39
+ DEPENDENCY_OPEN: "dependency.open",
40
+ DEPENDENCY_UNREADABLE: "dependency.unreadable",
36
41
  INVALID: "invalid",
37
42
  DECOMPOSED: "decomposed",
38
43
  // Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
@@ -55,9 +60,24 @@ export function normalizeCode(code) {
55
60
  return isOutcomeCode(code) ? code : UNCLASSIFIED;
56
61
  }
57
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
+
58
77
  /** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
59
78
  export function codeForRunnerError(err) {
60
79
  if (!err || typeof err !== "object") return UNCLASSIFIED;
80
+ if (isDiskQuotaError(err)) return OUTCOME_CODES.INFRA_DISK_QUOTA;
61
81
  if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
62
82
  return OUTCOME_CODES.RATE_LIMITED;
63
83
  switch (err.code) {
@@ -87,6 +107,8 @@ export function codeForTerminalReason(reason) {
87
107
  case "decomposed": return OUTCOME_CODES.DECOMPOSED;
88
108
  case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
89
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;
90
112
  default: return UNCLASSIFIED;
91
113
  }
92
114
  }
@@ -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) {
@@ -218,15 +225,6 @@ function latestGateMarker(comments) {
218
225
  }
219
226
  return { state: "none", index: -1 };
220
227
  }
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
228
  /**
231
229
  * Investigation exit evidence (forge#3534): a trusted comment led by
232
230
  * `<!-- FORGE:INVESTIGATION:DELIVERABLES -->` as a full line. Anchored like every
@@ -273,48 +271,75 @@ async function hasInvestigationCloseEvidence(issue, io) {
273
271
  || hasPhaseComplete(sliceToCurrentCycle(comments));
274
272
  }
275
273
 
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. */
274
+ /** A SIZE_OVERRIDE needs a non-empty justification — the first non-blank, non-HTML-comment line after the marker line. */
285
275
  function overrideHasJustification(body) {
286
276
  const lines = String(body).replace(/\r\n/g, "\n").split("\n");
287
277
  return lines.slice(1).some((l) => l.trim() !== "" && !l.trimStart().startsWith("<!--"));
288
278
  }
289
279
 
290
280
  /**
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.
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".
298
287
  */
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;
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;
298
+ }
299
+
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; }
311
308
  }
312
- /** Boolean form of sizeGateRouteIndex (index of the routing DIFF_SIZE comment, or -1). */
313
- function sizeGateRoutesToDecompose(comments) {
314
- return sizeGateRouteIndex(comments) >= 0;
309
+
310
+ /**
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`.
318
+ */
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;
315
339
  }
340
+
316
341
  /**
317
- * 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
318
343
  * short reason string when the issue must NOT be routed to decompose, else null:
319
344
  * a `**Parent**: #N` body line (a sub-issue; decompose Guard 1 would BLOCK it), a
320
345
  * TRUSTED `FORGE:DECOMPOSED` comment, or a TRUSTED justified `FORGE:SIZE_OVERRIDE`.
@@ -340,11 +365,6 @@ async function decomposeLoopGuard(issue, comments, io) {
340
365
  return null;
341
366
  }
342
367
 
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
368
  /**
349
369
  * Count commits on `branch` ahead of `lane`'s base. On the first build the
350
370
  * branch does not exist yet, so real git rejects the ref range — swallow
@@ -577,11 +597,6 @@ async function reviewArgs(state, ctx, io) {
577
597
  "--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
578
598
  }
579
599
 
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
600
  /**
586
601
  * Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
587
602
  * trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
@@ -602,42 +617,24 @@ function isDecomposed(report) {
602
617
  console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
603
618
  return false;
604
619
  }
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;
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.
617
626
  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);
627
+ const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
628
+ if (seamsM) {
629
+ const seams = Number(seamsM[1]);
627
630
  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`);
631
+ if (seams < 2 || !justified) {
632
+ console.error(`[engine] unsupported DECOMPOSE:YES (separable seams ${seams}${justified ? "" : ", no DECOMPOSE_JUSTIFICATION"}); treating as not decomposed`);
630
633
  return false;
631
634
  }
632
635
  }
633
636
  return true;
634
637
  }
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
638
  return false;
642
639
  }
643
640
 
@@ -757,13 +754,7 @@ export const PHASES = [
757
754
  // review. Non-retryable: a retry would only re-read the same append-only markers.
758
755
  // A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
759
756
  // 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
- }
757
+ // forge#3862: the build never routes to decompose (no size-gate decomposed outcome).
767
758
  if (latestGateState(comments) === "blocked") {
768
759
  return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, code: OC.BUILD_ACCEPTANCE_FAILED, retryable: false };
769
760
  }
@@ -776,13 +767,6 @@ export const PHASES = [
776
767
  if (hasInvestigationMarker(comments)) {
777
768
  return { status: "committed", terminalReason: "investigation", outputs: {} };
778
769
  }
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
770
  const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
787
771
  // forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
788
772
  // but the resolved (real, ground-truth) branch has zero commits ahead of
@@ -858,11 +842,14 @@ export const PHASES = [
858
842
  // An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
859
843
  // (phase trail, ci gate, base conflict, merge refusal) also label the issue
860
844
  // 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 };
845
+ if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs, code: OC.REVIEW_PR_OPEN };
862
846
  let snap = null;
863
847
  try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
864
848
  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 };
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 };
866
853
  },
867
854
  },
868
855
  {
@@ -1025,6 +1012,9 @@ export const PHASES = [
1025
1012
  return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
1026
1013
  case "RE-ESCALATED":
1027
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` };
1028
1018
  // Both leave the issue at needs-human (a fresh escalation, or a
1029
1019
  // policy judgment call respectively) — reuse the existing
1030
1020
  // "needs-human" terminal reason rather than inventing two more,
@@ -1111,7 +1101,11 @@ export const PHASES = [
1111
1101
  // that label is still a real terminal state (nothing left for this phase
1112
1102
  // to do), just not a "merged" one — reuse the existing "invalid" reason
1113
1103
  // (already in TERMINAL_REASONS) rather than inventing a new one.
1114
- 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))
1115
1109
  return { status: "committed", terminalReason: "merged", outputs: {} };
1116
1110
  // forge#3534: an investigation exit closes the issue itself; keep that reason honest.
1117
1111
  if (snap.state === "CLOSED") {
@@ -1212,16 +1206,73 @@ async function mergedPrForBranch(branch, lane, io) {
1212
1206
  return hit ? { number: hit.number } : null;
1213
1207
  } catch { return null; }
1214
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
+ }
1215
1226
  async function prStatusFor(state, io) {
1216
1227
  const n = await openPrFor(state, io);
1217
1228
  if (!n) return null;
1218
- 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"]);
1219
1230
  let j;
1220
1231
  try { j = JSON.parse(out || "{}"); } catch { return null; }
1221
1232
  const labels = (j.labels || []).map((l) => l.name || l);
1222
1233
  return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
1223
1234
  needsHuman: labels.includes("needs-human"),
1224
- 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; }
1225
1276
  }
1226
1277
 
1227
1278
  /** The engine's transition function: first uncommitted phase whose gate holds. */
@@ -106,6 +106,13 @@ export function deriveState(events) {
106
106
  // retry re-enters remediate/decompose instead of close/build.
107
107
  case "RUN_REOPEN":
108
108
  s.terminal = false; s.terminalReason = lastCommitReason; s.outcomeCode = lastCommitCode; s.v = e.seq;
109
+ // forge#3862: a `decomposed` terminal whose decomposition was revoked (trusted GitHub state,
110
+ // checked by the engine before it appends this event) reopens at build: un-commit the
111
+ // phases named in `uncommit` and drop the stale handoff reason so pickPhase re-selects build.
112
+ if (Array.isArray(e.uncommit) && e.uncommit.length) {
113
+ s.committed = s.committed.filter((p) => !e.uncommit.includes(p));
114
+ s.terminalReason = null; s.outcomeCode = null;
115
+ }
109
116
  // forge#3754: a remediate that ended needs-human is re-run after a human clears the
110
117
  // label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
111
118
  // does not re-terminate and remediate's entryCondition re-selects it.
@@ -420,6 +420,11 @@ export async function runFromCli(argv, deps = {}) {
420
420
  console.log(res.detail ?? "run is not retryable");
421
421
  return res;
422
422
  }
423
+ // forge#3932: refused before any state write (open or unreadable **Depends on**); nothing ran.
424
+ if (res.terminalReason === "dependency-open" || res.terminalReason === "dependency-unreadable") {
425
+ console.log(res.detail ?? "run refused: dependency check");
426
+ return res;
427
+ }
423
428
  // forge#2175: a non-success termination previously printed nothing beyond
424
429
  // the bare reason above — the actual failing phase/attempt/reason was only
425
430
  // recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the