forgedock 1.13.1 → 1.15.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 (45) hide show
  1. package/bin/engine/phases-args.test.mjs +11 -0
  2. package/bin/engine/phases.mjs +268 -25
  3. package/bin/engine/phases.test.mjs +24 -0
  4. package/bin/engine-cli.mjs +18 -5
  5. package/bin/engine.mjs +149 -12
  6. package/bin/forgedock.mjs +4 -1
  7. package/commands/issue.md +17 -2
  8. package/commands/orchestrate/phase-3-dependency.md +5 -2
  9. package/commands/orchestrate/phase-4-execution.md +154 -27
  10. package/commands/pipeline-health/phase-2-metrics.md +5 -3
  11. package/commands/review-pr-staging.md +554 -54
  12. package/commands/review-pr.md +662 -51
  13. package/commands/validate.md +11 -3
  14. package/commands/work-on/build/architect.md +11 -8
  15. package/commands/work-on/build/implement.md +15 -1
  16. package/commands/work-on/build.md +34 -10
  17. package/commands/work-on/close.md +5 -4
  18. package/commands/work-on/decompose.md +7 -2
  19. package/commands/work-on/investigate.md +13 -0
  20. package/commands/work-on/remediate.md +332 -91
  21. package/commands/work-on/review.md +3 -3
  22. package/docs/CONFIG.md +14 -1
  23. package/docs/RELEASE-NOTES.md +20 -0
  24. package/package.json +1 -1
  25. package/packages/protocol/src/phases.js +6 -0
  26. package/scripts/architect-resume-match.test.sh +53 -0
  27. package/scripts/check-spec-bash.sh +73 -3
  28. package/scripts/check-spec-bash.test.sh +50 -0
  29. package/scripts/check-spec-markers.sh +2 -0
  30. package/scripts/checkbox-sections.test.sh +19 -0
  31. package/scripts/classify-finding.sh +103 -1
  32. package/scripts/classify-finding.test.sh +56 -0
  33. package/scripts/forge-root.test.sh +86 -2
  34. package/scripts/orchestrate-soft-edge.test.sh +58 -0
  35. package/scripts/remediate-approved-head.test.sh +43 -0
  36. package/scripts/remediate-basesync.test.sh +62 -3
  37. package/scripts/remediation-marker-match.test.sh +6 -0
  38. package/scripts/resolve-deploy-gate.sh +51 -0
  39. package/scripts/resolve-deploy-gate.test.sh +59 -0
  40. package/scripts/review-delta.sh +3 -2
  41. package/scripts/review-delta.test.sh +1 -1
  42. package/scripts/review-pr-delta-wiring.test.sh +274 -0
  43. package/scripts/review-pr-domain-map.test.sh +10 -0
  44. package/scripts/review-pr-staging-scope.test.sh +159 -0
  45. package/scripts/trusted-comments.test.sh +11 -0
@@ -100,6 +100,17 @@ describe("engine phase args satisfy each target spec's argument-hint (forge#3499
100
100
  await assert.rejects(() => close.buildArgs({ ...state, pr: null, terminalReason: "merged" }, ctx, io), PhaseArgsError);
101
101
  });
102
102
 
103
+ it("close investigation: --terminal-state investigation, never --pr (forge#3534)", async () => {
104
+ const close = PHASES.find((p) => p.id === "close");
105
+ for (const pr of [null, 12]) {
106
+ const args = await close.buildArgs({ ...state, pr, terminalReason: "investigation" }, ctx, io);
107
+ assert.equal(args[args.indexOf("--terminal-state") + 1], "investigation");
108
+ assert.ok(!args.includes("--pr"));
109
+ }
110
+ for (const reason of ["needs-human", "awaiting-merge", "engine-error"])
111
+ await assert.rejects(() => close.buildArgs({ ...state, terminalReason: reason }, ctx, io), PhaseArgsError);
112
+ });
113
+
103
114
  it("close derives --terminal-state from the run's terminal reason", async () => {
104
115
  const close = PHASES.find((p) => p.id === "close");
105
116
  for (const [reason, expected] of [[null, "merged"], ["invalid", "invalid"], ["decomposed", "decomposed"]]) {
@@ -27,7 +27,11 @@ import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
27
27
  // matching engine-level terminal reason to report instead of overloading
28
28
  // "needs-human" (which would misrepresent a clean-but-unmet-bar re-review as
29
29
  // a fresh human-judgment escalation).
30
- export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed", "engine-error", "awaiting-merge"];
30
+ export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed", "engine-error", "awaiting-merge", "phase-complete"];
31
+ // forge#3545: "phase-complete" is close's CLOSE_RESULT PHASE_COMPLETE exit: the PR
32
+ // merged but later phases of a multi-phase issue remain, so the issue is
33
+ // deliberately left OPEN (FORGE:PHASE:COMPLETE posted). Distinct from "merged"
34
+ // (nothing shipped-and-closed) and "awaiting-merge" (no PR merged yet).
31
35
 
32
36
  /**
33
37
  * Trust predicate for FORGE marker comments (forge#3542) — a JS port of
@@ -91,6 +95,8 @@ async function issueMarkers(issue, io) {
91
95
  return { comments };
92
96
  }
93
97
 
98
+ /** Non-terminal held-resume record (remediate.md Phase M8, forge#3685): header and sentinel are the same line; never a COMPLETE marker. */
99
+ const REMEDIATION_HELD_MARKER = "FORGE:REMEDIATION_HELD";
94
100
  const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95
101
  /** True when `body` STARTS with the typed header `<!-- {header}` (e.g. `FORGE:BUILDER`). */
96
102
  function startsWithHeader(body, header) {
@@ -114,6 +120,104 @@ function latestMarked(comments, header, sentinel) {
114
120
  const all = markedComments(comments, header, sentinel);
115
121
  return all.length ? all[all.length - 1] : null;
116
122
  }
123
+ /**
124
+ * Cycle boundary (forge#3657). `--retry` on a phase-complete run starts a new
125
+ * cycle; the engine publishes `<!-- FORGE:CYCLE: n -->` so builder evidence from
126
+ * earlier cycles (branch, merged PR) is not re-adopted. Callers pass TRUSTED
127
+ * comments only (issueMarkers), so an untrusted comment cannot move the boundary.
128
+ */
129
+ export function cycleMarker(n) { return `<!-- FORGE:CYCLE: ${n} -->`; }
130
+ const CYCLE_RE = /^[ \t]*<!--[ \t]*FORGE:CYCLE:[ \t]*(\d+)[ \t]*-->[ \t]*$/m;
131
+ function cycleNumber(body) {
132
+ if (!startsWithHeader(body, "FORGE:CYCLE")) return null;
133
+ const m = String(body).match(CYCLE_RE);
134
+ return m ? Number(m[1]) : null;
135
+ }
136
+ /** Comments after the newest cycle marker; all comments when none is present. */
137
+ export function sliceToCurrentCycle(comments) {
138
+ for (let i = comments.length - 1; i >= 0; i--) {
139
+ if (cycleNumber(comments[i]) !== null) return comments.slice(i + 1);
140
+ }
141
+ return comments;
142
+ }
143
+ /**
144
+ * Next cycle number: one past the higher of the newest trusted FORGE:CYCLE on
145
+ * GitHub and `localCycles` (the local RUN_NEXT_CYCLE count). A rehydrated log
146
+ * never replays RUN_NEXT_CYCLE, so the local count alone can reset to 0 while
147
+ * GitHub already carries markers. Errors propagate (caller fails closed).
148
+ */
149
+ export async function nextCycleNumber(issue, localCycles, io) {
150
+ const { comments } = await issueMarkers(issue, io);
151
+ let highest = 0;
152
+ for (const body of comments) {
153
+ const c = cycleNumber(body);
154
+ if (c !== null && c > highest) highest = c;
155
+ }
156
+ return Math.max(highest, localCycles) + 1;
157
+ }
158
+ /**
159
+ * Idempotently publish the FORGE:CYCLE marker for cycle `n`: skipped when the
160
+ * newest trusted cycle marker already is `n` (crash between post and the log
161
+ * append retries safely). Errors propagate so the caller can fail closed.
162
+ */
163
+ export async function postCycleMarker(issue, n, io) {
164
+ const { comments } = await issueMarkers(issue, io);
165
+ for (let i = comments.length - 1; i >= 0; i--) {
166
+ const c = cycleNumber(comments[i]);
167
+ if (c !== null) { if (c === n) return { posted: false }; break; }
168
+ }
169
+ await io.gh(["issue", "comment", String(issue), "--body", cycleMarker(n)]);
170
+ return { posted: true };
171
+ }
172
+
173
+ /**
174
+ * Acceptance-gate state (build.md B6.5; forge#3545). `FORGE:BUILDER:COMPLETE` is
175
+ * appended at validate V5, BEFORE the gate runs, so the marker alone is not proof
176
+ * the build passed. Newest marker wins, by comment ORDER (never blob presence):
177
+ * `FORGE:ACCEPTANCE_GATE:PASSED` => "pass"; `:FAILED`, `:BLOCKED` or a
178
+ * `FORGE:BUILD_BLOCKED` comment => "blocked"; none => "none" (legacy / crash
179
+ * paths keep their prior behaviour). `comments` are trusted bodies, oldest first.
180
+ * Stays fail-closed by design. A blocked state clears only via a newer PASSED, which a
181
+ * re-invocation posts: build.md B0 "gate resume" re-enters B6.5 instead of returning
182
+ * ALREADY_DONE when the newest gate marker is blocked (forge#3725).
183
+ */
184
+ function latestGateState(comments) {
185
+ const gate = "FORGE:ACCEPTANCE_GATE";
186
+ for (let i = comments.length - 1; i >= 0; i--) {
187
+ const b = comments[i];
188
+ if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return "blocked";
189
+ if (!startsWithHeader(b, gate)) continue;
190
+ if (hasSentinelLine(b, `${gate}:PASSED`)) return "pass";
191
+ if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return "blocked";
192
+ }
193
+ return "none";
194
+ }
195
+ /**
196
+ * Investigation exit evidence (forge#3534): a trusted comment led by
197
+ * `<!-- FORGE:INVESTIGATION:DELIVERABLES -->` as a full line. Anchored like every
198
+ * other marker, so the string quoted inside another comment never counts.
199
+ */
200
+ function hasInvestigationMarker(comments) {
201
+ const m = PHASE_MARKERS.build.investigationMarker;
202
+ return hasMarker(comments, m, m);
203
+ }
204
+ /**
205
+ * forge#3534 (SEC-3716-1): does the issue carry a trusted current-cycle investigation exit marker?
206
+ * The implement child closes an investigation issue itself (CLOSED, no `workflow:merged`), so the
207
+ * engine's divergence guard must not read that as a dead issue before build.reconcile runs.
208
+ * Fail closed: any fetch error means "no marker" (the guard then behaves as before).
209
+ */
210
+ export async function hasInvestigationExit(issue, io) {
211
+ try {
212
+ const { comments } = await issueMarkers(issue, io);
213
+ return hasInvestigationMarker(sliceToCurrentCycle(comments));
214
+ } catch { return false; }
215
+ }
216
+ /** close.md C2 PHASE_COMPLETE evidence in the current cycle (forge#3545). */
217
+ function hasPhaseComplete(comments) {
218
+ return hasMarker(comments, "FORGE:PHASE:COMPLETE", "FORGE:PHASE:COMPLETE");
219
+ }
220
+
117
221
  /**
118
222
  * Trusted comment bodies, chronological — the same single fetch + trust predicate
119
223
  * as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
@@ -150,6 +254,33 @@ function sizeGateRoutesToDecompose(comments) {
150
254
  if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return false;
151
255
  return !comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c));
152
256
  }
257
+ /**
258
+ * Decompose loop guard (mirrors build.md B5.5 and decompose.md Guard 1). Returns a
259
+ * short reason string when the issue must NOT be routed to decompose, else null:
260
+ * a `**Parent**: #N` body line (a sub-issue; decompose Guard 1 would BLOCK it), a
261
+ * TRUSTED `FORGE:DECOMPOSED` comment, or a TRUSTED justified `FORGE:SIZE_OVERRIDE`.
262
+ * `comments` MUST be trusted bodies. A body-fetch failure fails toward the guard
263
+ * (not decomposed) so an error can never route into a refusing decompose phase.
264
+ */
265
+ async function decomposeLoopGuard(issue, comments, io) {
266
+ const starts = (c, m) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${m}`);
267
+ if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return "already decomposed (FORGE:DECOMPOSED)";
268
+ if (comments.some((c) => starts(c, PHASE_MARKERS.build.sizeOverrideMarker) && overrideHasJustification(c)))
269
+ return "justified FORGE:SIZE_OVERRIDE";
270
+ let body;
271
+ try {
272
+ const out = String((await io.gh(["issue", "view", String(issue), "--json", "body", "--jq", ".body"])) ?? "");
273
+ body = out;
274
+ if (out.trimStart().startsWith("{")) {
275
+ try { const j = JSON.parse(out); if (j && typeof j.body === "string") body = j.body; } catch { /* raw text */ }
276
+ }
277
+ } catch {
278
+ return "issue body unavailable (failing toward not decomposed)";
279
+ }
280
+ if (/^\*\*Parent\*\*:[ \t]*#\d+/m.test(body.replace(/\r\n/g, "\n"))) return "sub-issue (**Parent**: #N)";
281
+ return null;
282
+ }
283
+
153
284
  /** Cheap untrusted pre-check: does ANY comment look like an OVER gate? Only then is the trusted fetch worth making. */
154
285
  function mayRouteToDecompose(comments) {
155
286
  const gate = PHASE_MARKERS.build.sizeGateMarker;
@@ -387,6 +518,9 @@ async function reviewArgs(state, ctx, io) {
387
518
  "--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
388
519
  }
389
520
 
521
+ /** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
522
+ const DEFAULT_ESTIMATE_FACTOR = 1.2;
523
+
390
524
  /**
391
525
  * Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
392
526
  * trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
@@ -407,7 +541,25 @@ function isDecomposed(report) {
407
541
  console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
408
542
  return false;
409
543
  }
410
- return (verdict ?? marker) === "YES";
544
+ if ((verdict ?? marker) === "YES") return true;
545
+ // forge#3573 safety net: an investigator NO whose own size estimate is clearly over
546
+ // the gate (estimate > threshold x factor) fails toward decompose. Own-line,
547
+ // whole-number fields only; absent/0/non-numeric fields have no effect. The factor is
548
+ // the investigator's effective `ESTIMATE_FACTOR:` (positive decimal); absent/invalid -> 1.2.
549
+ const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
550
+ const gate = /^GATE_THRESHOLD:[ \t]*(\d+)[ \t]*$/m.exec(text);
551
+ const fac = /^ESTIMATE_FACTOR:[ \t]*(\d+(?:\.\d+)?)[ \t]*$/m.exec(text);
552
+ const parsedFactor = fac ? Number(fac[1]) : NaN;
553
+ const factor = Number.isFinite(parsedFactor) && parsedFactor > 0 ? parsedFactor : DEFAULT_ESTIMATE_FACTOR;
554
+ if (est && gate) {
555
+ const estimate = Number(est[1]);
556
+ const threshold = Number(gate[1]);
557
+ if (threshold > 0 && estimate > threshold * factor) {
558
+ console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
559
+ return true;
560
+ }
561
+ }
562
+ return false;
411
563
  }
412
564
 
413
565
  /** @type {Phase[]} */
@@ -432,8 +584,16 @@ export const PHASES = [
432
584
  return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
433
585
  if (closing === complete) {
434
586
  // investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
435
- if (isDecomposed(report))
587
+ if (isDecomposed(report)) {
588
+ // Lazy: the body fetch only happens on the decompose path. A child / already
589
+ // decomposed / overridden issue cannot be decomposed (decompose Guard 1 BLOCKs).
590
+ const guard = await decomposeLoopGuard(state.issue, comments, io);
591
+ if (guard) {
592
+ console.error(`[engine] decompose suppressed for #${state.issue}: ${guard}; treating as not decomposed`);
593
+ return { status: "committed", outputs: { verdict: "CONFIRMED" } };
594
+ }
436
595
  return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
596
+ }
437
597
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
438
598
  }
439
599
  return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
@@ -481,8 +641,11 @@ export const PHASES = [
481
641
  // Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
482
642
  // comment) rather than trusting a possibly-stale/absent state.branch, then
483
643
  // check it's already ahead of base → treat as done, skip the LLM (forge#2174).
484
- const { comments } = await issueMarkers(state.issue, io);
644
+ const { comments: allComments } = await issueMarkers(state.issue, io);
645
+ const comments = sliceToCurrentCycle(allComments); // forge#3657: current-cycle evidence only
485
646
  const branch = resolveBranch(state, comments);
647
+ // forge#3545: a failed/blocked acceptance gate newer than any PASSED means build is NOT done.
648
+ if (latestGateState(comments) === "blocked") return { satisfied: false };
486
649
  if (branch && hasMarker(comments, PHASE_MARKERS.build.header, PHASE_MARKERS.build.completionMarker) && (await commitsAhead(state.lane, branch, io)) > 0) {
487
650
  return { satisfied: true, outputs: { branch } };
488
651
  }
@@ -492,10 +655,13 @@ export const PHASES = [
492
655
  const merged = await mergedPrForBranch(branch, state.lane, io);
493
656
  if (merged) return { satisfied: true, outputs: { branch, pr: merged.number } };
494
657
  }
658
+ // forge#3534: an investigation exit already posted its deliverables marker: build is done.
659
+ if (hasInvestigationMarker(comments)) return { satisfied: true, terminalReason: "investigation", outputs: {} };
495
660
  return { satisfied: false };
496
661
  },
497
662
  async detectOutcome(state, io) {
498
- const { comments } = await issueMarkers(state.issue, io);
663
+ const { comments: allComments } = await issueMarkers(state.issue, io);
664
+ const comments = sliceToCurrentCycle(allComments); // forge#3657
499
665
  const complete = hasMarker(comments, PHASE_MARKERS.build.header, PHASE_MARKERS.build.completionMarker); // #1305: require :COMPLETE …
500
666
  // Resolve the branch the builder actually created from the FORGE:BUILDER:COMPLETE
501
667
  // comment (ground truth), scoped to that specific comment — see
@@ -507,14 +673,28 @@ export const PHASES = [
507
673
  // from a genuine git-confirmed zero and would wrongly trip the
508
674
  // non-retryable guard below on the very first attempt.
509
675
  const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
676
+ // forge#3545: fail closed. COMPLETE is written before the B6.5 acceptance gate, so a
677
+ // newer FAILED/BLOCKED gate (or BUILD_BLOCKED) must not be committed and move on to
678
+ // review. Non-retryable: a retry would only re-read the same append-only markers.
679
+ // A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
680
+ // build invocation does via build.md B0 gate resume (re-enters B6.5; forge#3725).
681
+ if (latestGateState(comments) === "blocked") {
682
+ return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
683
+ }
510
684
  if (complete && ahead > 0) {
511
685
  return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
512
686
  }
687
+ // forge#3534: INVESTIGATION_COMPLETE writes no code and opens no PR, so it never
688
+ // posts FORGE:BUILDER:COMPLETE with commits. The durable deliverables marker is the
689
+ // ground truth. Not retried; review is skipped and close runs `--terminal-state investigation`.
690
+ if (hasInvestigationMarker(comments)) {
691
+ return { status: "committed", terminalReason: "investigation", outputs: {} };
692
+ }
513
693
  // B5.5 NEEDS_DECOMPOSE: the size gate posts no FORGE:BUILDER:COMPLETE and
514
694
  // build.md says the router (not build) dispatches decompose with no
515
695
  // needs-human. Non-retryable by construction: a retry re-measures and
516
696
  // re-emits the same exit.
517
- if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(await trustedCommentBodies(state.issue, io))) {
697
+ if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
518
698
  return { status: "committed", terminalReason: "decomposed", outputs: branch ? { branch } : {} };
519
699
  }
520
700
  const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
@@ -561,7 +741,8 @@ export const PHASES = [
561
741
  id: "review",
562
742
  command: "work-on/review",
563
743
  buildArgs: reviewArgs,
564
- entryCondition: (s) => s.committed.includes("build"),
744
+ // forge#3534: an investigation build has no PR to review; close runs directly.
745
+ entryCondition: (s) => s.committed.includes("build") && s.terminalReason !== "investigation",
565
746
  async reconcile(state, io) {
566
747
  const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
567
748
  return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
@@ -663,8 +844,48 @@ export const PHASES = [
663
844
  if (at >= 0) scoped = comments.slice(at + 1);
664
845
  }
665
846
  const latest = latestMarked(scoped, PHASE_MARKERS.remediate.header, completion);
666
- if (latest === null)
847
+ // forge#3647: ground-truth PR read. An interrupted or rate-limited remediation can leave the
848
+ // PR already merged with no COMPLETE marker (or a stale HELD-AWAITING-MERGE report). The
849
+ // reported outcome is never upgraded to a merge decision; only the live PR state is. Same
850
+ // merged predicate as reconcile (`mergedAt` or state MERGED). Unreadable => null (fail closed).
851
+ const prMerged = async () => {
852
+ if (state.pr == null) return false;
853
+ try {
854
+ const out = await io.gh(["pr", "view", String(state.pr), "--json", "state,mergedAt,baseRefName"]);
855
+ const j = JSON.parse(out || "{}");
856
+ return !!j.mergedAt || j.state === "MERGED";
857
+ } catch { return false; }
858
+ };
859
+ // Shared with the AUTO-LANDED case: CLOSED + workflow:merged means genuinely finished;
860
+ // anything else (including an unreadable snapshot) hands off to idempotent `close`.
861
+ const mergedResult = async (reGateOutcome) => {
862
+ let closed = false;
863
+ try {
864
+ const snap = await issueSnapshot(state.issue, io);
865
+ closed = snap.ok && snap.state === "CLOSED" &&
866
+ snap.labels.includes(PHASE_MARKERS.close.completionLabel);
867
+ } catch { closed = false; }
868
+ return closed
869
+ ? { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } }
870
+ : { status: "committed", terminalReason: "merged", outputs: { reGateOutcome, closeHandoff: true } };
871
+ };
872
+ // forge#3685: a resume that ends HELD posts a non-terminal FORGE:REMEDIATION_HELD record (no COMPLETE).
873
+ // Newest-wins between the held record and COMPLETE, by position in the (trusted, scoped) comment list.
874
+ const isHeld = (b) => startsWithHeader(b, REMEDIATION_HELD_MARKER) && hasSentinelLine(b, REMEDIATION_HELD_MARKER);
875
+ let heldAt = -1, completeAt = -1;
876
+ scoped.forEach((b, i) => {
877
+ if (isHeld(b)) heldAt = i;
878
+ if (startsWithHeader(b, PHASE_MARKERS.remediate.header) && hasSentinelLine(b, completion)) completeAt = i;
879
+ });
880
+ if (heldAt >= 0 && heldAt > completeAt) {
881
+ // Merged PR hands to close; otherwise awaiting-merge (terminal for the engine, re-entered by an operator), never a retry.
882
+ if (await prMerged()) return mergedResult("AUTO-LANDED");
883
+ return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome: "HELD-AWAITING-MERGE" } };
884
+ }
885
+ if (latest === null) {
886
+ if (await prMerged()) return mergedResult("AUTO-LANDED");
667
887
  return { status: "failed", detail: `no ${completion} marker` };
888
+ }
668
889
  // Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
669
890
  // (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
670
891
  // first whitespace-delimited token after the colon).
@@ -672,7 +893,7 @@ export const PHASES = [
672
893
  const match = matches.length ? matches[matches.length - 1] : null;
673
894
  const reGateOutcome = match ? match[1] : null;
674
895
  switch (reGateOutcome) {
675
- case "AUTO-LANDED": {
896
+ case "AUTO-LANDED":
676
897
  // forge#3624: remediate.md M8 drives close itself on this outcome, but an
677
898
  // engine phase session may skip or fail that delegated close and nothing
678
899
  // verified it. Check the issue's real state: CLOSED + workflow:merged means
@@ -680,17 +901,10 @@ export const PHASES = [
680
901
  // an unreadable snapshot, hands off to `close` (idempotent via its reconcile).
681
902
  // The reason stays "merged" either way so the commit replaces the review's
682
903
  // sticky "needs-human" and close.buildArgs accepts --terminal-state merged.
683
- let closed = false;
684
- try {
685
- const snap = await issueSnapshot(state.issue, io);
686
- closed = snap.ok && snap.state === "CLOSED" &&
687
- snap.labels.includes(PHASE_MARKERS.close.completionLabel);
688
- } catch { closed = false; }
689
- return closed
690
- ? { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } }
691
- : { status: "committed", terminalReason: "merged", outputs: { reGateOutcome, closeHandoff: true } };
692
- }
904
+ return mergedResult(reGateOutcome);
693
905
  case "HELD-AWAITING-MERGE":
906
+ // forge#3647: a HELD report for a PR that has since merged must still drive close.
907
+ if (await prMerged()) return mergedResult(reGateOutcome);
694
908
  return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
695
909
  case "RE-ESCALATED":
696
910
  case "UNFIXABLE":
@@ -715,20 +929,23 @@ export const PHASES = [
715
929
  // non-merged handoff reason (needs-human, awaiting-merge, engine-error, ...) must never
716
930
  // be closed as merged.
717
931
  const reason = state.terminalReason ?? null;
718
- if (reason !== null && !["merged", "decomposed", "invalid"].includes(reason))
932
+ if (reason !== null && !["merged", "decomposed", "invalid", "investigation"].includes(reason))
719
933
  throw new PhaseArgsError(`refusing close --terminal-state merged from terminalReason ${JSON.stringify(reason)}`);
720
934
  const terminal = reason === "decomposed" ? "decomposed"
721
- : reason === "invalid" ? "invalid" : "merged";
935
+ : reason === "invalid" ? "invalid"
936
+ : reason === "investigation" ? "investigation" : "merged";
722
937
  const args = [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
723
938
  // forge#3504: a merged close with no PR is fabricated evidence — require it.
724
- if (state.pr != null || terminal === "merged") args.push("--pr", need(state.pr, "pr", /^[0-9]+$/));
939
+ // forge#3534: an investigation close is PR-less by definition; never pass --pr.
940
+ if (terminal !== "investigation" && (state.pr != null || terminal === "merged")) args.push("--pr", need(state.pr, "pr", /^[0-9]+$/));
725
941
  if (state.branch) args.push("--branch", need(state.branch, "branch", BRANCH_RE));
726
942
  const wt = await resolveWorktree(state, io);
727
943
  if (wt && PATH_RE.test(wt)) args.push("--worktree", wt);
728
944
  args.push("--terminal-state", terminal);
729
945
  return args;
730
946
  },
731
- entryCondition: (s) => s.committed.includes("review"),
947
+ entryCondition: (s) => s.committed.includes("review")
948
+ || (s.committed.includes("build") && s.terminalReason === "investigation"), // forge#3534
732
949
  async reconcile(state, io) {
733
950
  // Idempotent resume: issue already CLOSED → skip the LLM re-run. forge#3644: the
734
951
  // workflow:merged label alone is NOT proof close ran — remediate M7 labels the issue
@@ -738,7 +955,25 @@ export const PHASES = [
738
955
  // strict subset of what satisfies this predicate on a CLOSED issue).
739
956
  const snap = await issueSnapshot(state.issue, io);
740
957
  if (!snap.ok) return { satisfied: false };
741
- return snap.state === "CLOSED" ? { satisfied: true } : { satisfied: false };
958
+ if (snap.state === "CLOSED" && state.terminalReason === "investigation") {
959
+ // forge#3534: the implement child closes an investigation issue itself, so CLOSED is no
960
+ // proof close.md ran (board, summary, trajectory). Its own idempotency key is the
961
+ // FORGE:TRAJECTORY comment; only that makes this satisfied.
962
+ try {
963
+ const { comments } = await issueMarkers(state.issue, io);
964
+ return { satisfied: comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY")), terminalReason: "investigation" };
965
+ } catch { return { satisfied: false }; }
966
+ }
967
+ if (snap.state === "CLOSED") return { satisfied: true };
968
+ // forge#3545: a current-cycle FORGE:PHASE:COMPLETE means close already ran to its
969
+ // PHASE_COMPLETE exit; do not re-run it.
970
+ if (snap.state === "OPEN") {
971
+ try {
972
+ const { comments } = await issueMarkers(state.issue, io);
973
+ if (hasPhaseComplete(sliceToCurrentCycle(comments))) return { satisfied: true, terminalReason: "phase-complete" };
974
+ } catch { /* fail closed: not satisfied */ }
975
+ }
976
+ return { satisfied: false };
742
977
  },
743
978
  async detectOutcome(state, io) {
744
979
  const snap = await issueSnapshot(state.issue, io);
@@ -756,8 +991,16 @@ export const PHASES = [
756
991
  // (already in TERMINAL_REASONS) rather than inventing a new one.
757
992
  if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
758
993
  return { status: "committed", terminalReason: "merged", outputs: {} };
994
+ // forge#3534: an investigation exit closes the issue itself; keep that reason honest.
759
995
  if (snap.state === "CLOSED")
760
- return { status: "committed", terminalReason: "invalid", outputs: {} };
996
+ return { status: "committed", terminalReason: state.terminalReason === "investigation" ? "investigation" : "invalid", outputs: {} };
997
+ // forge#3545: PHASE_COMPLETE is a valid close exit (multi-phase issue: PR merged,
998
+ // issue deliberately left OPEN). Only the current cycle's marker counts.
999
+ if (snap.state === "OPEN") {
1000
+ const { comments } = await issueMarkers(state.issue, io);
1001
+ if (hasPhaseComplete(sliceToCurrentCycle(comments)))
1002
+ return { status: "committed", terminalReason: "phase-complete", outputs: {} };
1003
+ }
761
1004
  return { status: "failed", detail: "issue not closed" };
762
1005
  },
763
1006
  isTerminalAfter: () => true,
@@ -0,0 +1,24 @@
1
+ // Spec-contract test (forge#3725): the build.md B0 resume block must re-enter
2
+ // B6.5 when the newest acceptance-gate marker is blocked (see latestGateState
3
+ // in phases.mjs), instead of returning ALREADY_DONE.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import path from "node:path";
9
+
10
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
11
+
12
+ describe("latestGateState / ACCEPTANCE_GATE spec contract (forge#3725)", () => {
13
+ it("build.md B0 gate resume re-enters B6.5 before returning ALREADY_DONE", () => {
14
+ const spec = readFileSync(path.join(ROOT, "commands/work-on/build.md"), "utf8");
15
+ const start = spec.indexOf("**Resume check**");
16
+ const end = spec.indexOf("- If there is no `<!-- FORGE:INVESTIGATOR -->`", start);
17
+ assert.ok(start >= 0 && end > start, "B0 resume block found");
18
+ const b0 = spec.slice(start, end);
19
+ assert.match(b0, /ACCEPTANCE_GATE:(FAILED|BLOCKED)/);
20
+ assert.match(b0, /BUILD_BLOCKED/);
21
+ assert.match(b0, /re-enter at \*\*Phase B6\.5\*\*/);
22
+ assert.ok(b0.indexOf("Gate resume") < b0.indexOf("status: ALREADY_DONE"), "gate resume precedes ALREADY_DONE");
23
+ });
24
+ });
@@ -7,9 +7,9 @@
7
7
  import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
9
  import { readdirSync, statSync } from "node:fs";
10
- import { homedir } from "node:os";
10
+ import { homedir, hostname } from "node:os";
11
11
  import { join } from "node:path";
12
- import { runIssue, DEFAULT_MAX_ATTEMPTS } from "./engine.mjs";
12
+ import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs";
13
13
  import { makeProjector } from "./engine/projector.mjs";
14
14
  import { readLog, deriveState } from "./engine/runlog.mjs";
15
15
  import { assertOpenCodeNativeRuntime } from "./runner.mjs";
@@ -338,6 +338,10 @@ export function lastLocalRun(dir) {
338
338
  * `runCommand()` call; only applies on the "api" backend.
339
339
  * Omit to keep runner.mjs's default (`FORGEDOCK_MODEL` env
340
340
  * or its built-in default).
341
+ * --wait-lease Optional (forge#3574). If another live holder owns the
342
+ * run's lease, wait (bounded by the lease TTL) until it
343
+ * expires or its same-host pid is dead, then continue.
344
+ * Without it, a held lease exits `deferred` (code 75).
341
345
  * --retry Optional (forge#3511). Reopen a run that ended `engine-error`
342
346
  * (e.g. a session-limit hit) and resume from its last committed
343
347
  * phase. A run that ended `phase-complete` (multi-phase issue, phase merged,
@@ -346,7 +350,7 @@ export function lastLocalRun(dir) {
346
350
  */
347
351
  export async function runFromCli(argv, deps = {}) {
348
352
  const issue = parseInt(argv[0], 10);
349
- if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane> [--retry]");
353
+ if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane> [--retry] [--wait-lease]");
350
354
  assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
351
355
  const lane = flag(argv, "--lane");
352
356
  if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
@@ -355,10 +359,13 @@ export async function runFromCli(argv, deps = {}) {
355
359
  const model = flag(argv, "--model");
356
360
  // Boolean flag: only counts at a flag position, never as another flag's value.
357
361
  const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
362
+ // forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
363
+ // holder's lease expires or becomes reclaimable. Same flag-position guard.
364
+ const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
358
365
  const io = deps.io ?? makeIo();
359
366
  await assertRepoMatchesCwd(io, repo);
360
367
  const runIssueFn = deps.runIssue ?? runIssue;
361
- const agentId = `cli_${process.pid}`;
368
+ const agentId = `cli_${sanitizeLeaseHost(hostname())}_${process.pid}`;
362
369
  // Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
363
370
  const dir = deps.dir ?? runDir();
364
371
  // forge#2240: print the run-log path at the very start of the run, not only
@@ -390,8 +397,14 @@ export async function runFromCli(argv, deps = {}) {
390
397
  // runIssue's/runner.mjs's existing defaults (forge#2028).
391
398
  ...(backend ? { backend } : {}),
392
399
  ...(model ? { model } : {}),
393
- ...(retry ? { retry: true } : {}) });
400
+ ...(retry ? { retry: true } : {}),
401
+ ...(waitLease ? { waitLease: true } : {}) });
394
402
  console.log(`issue #${issue} → ${res.terminalReason}`);
403
+ if (res.terminalReason === "deferred") {
404
+ // forge#3574: say WHY (holder + expiry); the empty-log diagnostics add nothing.
405
+ console.log(res.detail ?? "run deferred: another holder owns the lease");
406
+ return res;
407
+ }
395
408
  if (res.terminalReason === "not-retryable") {
396
409
  console.log(res.detail ?? "run is not retryable");
397
410
  return res;