forgedock 1.14.0 → 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.
@@ -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"]]) {
@@ -95,6 +95,8 @@ async function issueMarkers(issue, io) {
95
95
  return { comments };
96
96
  }
97
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";
98
100
  const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
101
  /** True when `body` STARTS with the typed header `<!-- {header}` (e.g. `FORGE:BUILDER`). */
100
102
  function startsWithHeader(body, header) {
@@ -175,6 +177,9 @@ export async function postCycleMarker(issue, n, io) {
175
177
  * `FORGE:ACCEPTANCE_GATE:PASSED` => "pass"; `:FAILED`, `:BLOCKED` or a
176
178
  * `FORGE:BUILD_BLOCKED` comment => "blocked"; none => "none" (legacy / crash
177
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).
178
183
  */
179
184
  function latestGateState(comments) {
180
185
  const gate = "FORGE:ACCEPTANCE_GATE";
@@ -187,6 +192,27 @@ function latestGateState(comments) {
187
192
  }
188
193
  return "none";
189
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
+ }
190
216
  /** close.md C2 PHASE_COMPLETE evidence in the current cycle (forge#3545). */
191
217
  function hasPhaseComplete(comments) {
192
218
  return hasMarker(comments, "FORGE:PHASE:COMPLETE", "FORGE:PHASE:COMPLETE");
@@ -228,6 +254,33 @@ function sizeGateRoutesToDecompose(comments) {
228
254
  if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return false;
229
255
  return !comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c));
230
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
+
231
284
  /** Cheap untrusted pre-check: does ANY comment look like an OVER gate? Only then is the trusted fetch worth making. */
232
285
  function mayRouteToDecompose(comments) {
233
286
  const gate = PHASE_MARKERS.build.sizeGateMarker;
@@ -465,8 +518,8 @@ async function reviewArgs(state, ctx, io) {
465
518
  "--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
466
519
  }
467
520
 
468
- /** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); mirrored here for the isDecomposed safety net. */
469
- const ESTIMATE_FACTOR = 1.2;
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;
470
523
 
471
524
  /**
472
525
  * Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
@@ -491,14 +544,18 @@ function isDecomposed(report) {
491
544
  if ((verdict ?? marker) === "YES") return true;
492
545
  // forge#3573 safety net: an investigator NO whose own size estimate is clearly over
493
546
  // the gate (estimate > threshold x factor) fails toward decompose. Own-line,
494
- // whole-number fields only; absent/0/non-numeric fields have no effect.
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.
495
549
  const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
496
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;
497
554
  if (est && gate) {
498
555
  const estimate = Number(est[1]);
499
556
  const threshold = Number(gate[1]);
500
- if (threshold > 0 && estimate > threshold * ESTIMATE_FACTOR) {
501
- console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${ESTIMATE_FACTOR}; routing to decompose`);
557
+ if (threshold > 0 && estimate > threshold * factor) {
558
+ console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
502
559
  return true;
503
560
  }
504
561
  }
@@ -527,8 +584,16 @@ export const PHASES = [
527
584
  return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
528
585
  if (closing === complete) {
529
586
  // investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
530
- 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
+ }
531
595
  return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
596
+ }
532
597
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
533
598
  }
534
599
  return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
@@ -590,6 +655,8 @@ export const PHASES = [
590
655
  const merged = await mergedPrForBranch(branch, state.lane, io);
591
656
  if (merged) return { satisfied: true, outputs: { branch, pr: merged.number } };
592
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: {} };
593
660
  return { satisfied: false };
594
661
  },
595
662
  async detectOutcome(state, io) {
@@ -608,14 +675,21 @@ export const PHASES = [
608
675
  const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
609
676
  // forge#3545: fail closed. COMPLETE is written before the B6.5 acceptance gate, so a
610
677
  // newer FAILED/BLOCKED gate (or BUILD_BLOCKED) must not be committed and move on to
611
- // review. Non-retryable: a retry would only re-read the same append-only markers;
612
- // a repaired build posts a newer PASSED, which clears this.
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).
613
681
  if (latestGateState(comments) === "blocked") {
614
682
  return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
615
683
  }
616
684
  if (complete && ahead > 0) {
617
685
  return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
618
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
+ }
619
693
  // B5.5 NEEDS_DECOMPOSE: the size gate posts no FORGE:BUILDER:COMPLETE and
620
694
  // build.md says the router (not build) dispatches decompose with no
621
695
  // needs-human. Non-retryable by construction: a retry re-measures and
@@ -667,7 +741,8 @@ export const PHASES = [
667
741
  id: "review",
668
742
  command: "work-on/review",
669
743
  buildArgs: reviewArgs,
670
- 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",
671
746
  async reconcile(state, io) {
672
747
  const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
673
748
  return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
@@ -794,6 +869,19 @@ export const PHASES = [
794
869
  ? { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } }
795
870
  : { status: "committed", terminalReason: "merged", outputs: { reGateOutcome, closeHandoff: true } };
796
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
+ }
797
885
  if (latest === null) {
798
886
  if (await prMerged()) return mergedResult("AUTO-LANDED");
799
887
  return { status: "failed", detail: `no ${completion} marker` };
@@ -841,20 +929,23 @@ export const PHASES = [
841
929
  // non-merged handoff reason (needs-human, awaiting-merge, engine-error, ...) must never
842
930
  // be closed as merged.
843
931
  const reason = state.terminalReason ?? null;
844
- if (reason !== null && !["merged", "decomposed", "invalid"].includes(reason))
932
+ if (reason !== null && !["merged", "decomposed", "invalid", "investigation"].includes(reason))
845
933
  throw new PhaseArgsError(`refusing close --terminal-state merged from terminalReason ${JSON.stringify(reason)}`);
846
934
  const terminal = reason === "decomposed" ? "decomposed"
847
- : reason === "invalid" ? "invalid" : "merged";
935
+ : reason === "invalid" ? "invalid"
936
+ : reason === "investigation" ? "investigation" : "merged";
848
937
  const args = [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
849
938
  // forge#3504: a merged close with no PR is fabricated evidence — require it.
850
- 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]+$/));
851
941
  if (state.branch) args.push("--branch", need(state.branch, "branch", BRANCH_RE));
852
942
  const wt = await resolveWorktree(state, io);
853
943
  if (wt && PATH_RE.test(wt)) args.push("--worktree", wt);
854
944
  args.push("--terminal-state", terminal);
855
945
  return args;
856
946
  },
857
- entryCondition: (s) => s.committed.includes("review"),
947
+ entryCondition: (s) => s.committed.includes("review")
948
+ || (s.committed.includes("build") && s.terminalReason === "investigation"), // forge#3534
858
949
  async reconcile(state, io) {
859
950
  // Idempotent resume: issue already CLOSED → skip the LLM re-run. forge#3644: the
860
951
  // workflow:merged label alone is NOT proof close ran — remediate M7 labels the issue
@@ -864,6 +955,15 @@ export const PHASES = [
864
955
  // strict subset of what satisfies this predicate on a CLOSED issue).
865
956
  const snap = await issueSnapshot(state.issue, io);
866
957
  if (!snap.ok) return { 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
+ }
867
967
  if (snap.state === "CLOSED") return { satisfied: true };
868
968
  // forge#3545: a current-cycle FORGE:PHASE:COMPLETE means close already ran to its
869
969
  // PHASE_COMPLETE exit; do not re-run it.
@@ -891,8 +991,9 @@ export const PHASES = [
891
991
  // (already in TERMINAL_REASONS) rather than inventing a new one.
892
992
  if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
893
993
  return { status: "committed", terminalReason: "merged", outputs: {} };
994
+ // forge#3534: an investigation exit closes the issue itself; keep that reason honest.
894
995
  if (snap.state === "CLOSED")
895
- return { status: "committed", terminalReason: "invalid", outputs: {} };
996
+ return { status: "committed", terminalReason: state.terminalReason === "investigation" ? "investigation" : "invalid", outputs: {} };
896
997
  // forge#3545: PHASE_COMPLETE is a valid close exit (multi-phase issue: PR merged,
897
998
  // issue deliberately left OPEN). Only the current cycle's marker counts.
898
999
  if (snap.state === "OPEN") {
@@ -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
+ });
package/bin/engine.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { hostname as osHostname } from "node:os";
8
8
  import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
9
- import { pickPhase, TERMINAL_REASONS, issueSnapshot, postCycleMarker, nextCycleNumber } from "./engine/phases.mjs";
9
+ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber } from "./engine/phases.mjs";
10
10
  import { reconcileState } from "./engine/reconcile.mjs";
11
11
  import { makeProjector } from "./engine/projector.mjs";
12
12
  import { VALID_BACKENDS } from "./runner.mjs";
@@ -271,7 +271,9 @@ export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
271
271
  * the last committed phase. forge#3562: when it is `phase-complete` (a
272
272
  * multi-phase issue whose phase merged with more remaining), start a new
273
273
  * cycle (RUN_NEXT_CYCLE) that resets committed/branch/pr and restarts at
274
- * investigate. Any other terminal reason is left untouched and returns
274
+ * investigate. forge#3571: `needs-human` is reopened (RUN_REOPEN) only when
275
+ * a live read shows the issue OPEN with the label cleared. Any other terminal
276
+ * reason (or an unreadable snapshot) is left untouched and returns
275
277
  * `not-retryable`.
276
278
  * @param {boolean} [opts.waitLease] - forge#3574: when another live holder owns
277
279
  * the lease, poll (via `sleep`, bounded by `leaseTtlMs`) until it expires or
@@ -369,18 +371,45 @@ export async function runIssue(opts) {
369
371
  // so the run resumes from its last committed phase. Placed AFTER the I3
370
372
  // lease guard (never weaken that ordering) and BEFORE the unconditional
371
373
  // lease write below, which then publishes `terminal:false` at the reopen's
372
- // higher `v`. Only `engine-error` is ever reopened; needs-human / merged /
373
- // decomposed / etc. terminals are left untouched.
374
+ // higher `v`. `engine-error` is reopened unconditionally; forge#3571:
375
+ // `needs-human` only once the label is cleared. merged / decomposed / etc.
376
+ // terminals are left untouched.
374
377
  // forge#3562: `phase-complete` is also retryable, but as a NEW cycle
375
378
  // (RUN_NEXT_CYCLE clears committed) rather than a reopen of the same one.
376
379
  let reopened = false;
377
380
  if (retry && state?.terminal) {
378
- if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete") {
381
+ if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
382
+ state.terminalReason !== "needs-human") {
379
383
  return {
380
384
  terminalReason: "not-retryable",
381
- detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error or phase-complete run`,
385
+ detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error, phase-complete or needs-human run`,
382
386
  };
383
387
  }
388
+ // forge#3571: a human clearing `needs-human` is the hand-back signal. Reopen
389
+ // only on a live read showing the issue OPEN and the label gone; a label
390
+ // still present keeps today's refusal, and an unreadable snapshot fails closed.
391
+ if (state.terminalReason === "needs-human") {
392
+ let snap;
393
+ try { snap = await issueSnapshot(issue, io); } catch { snap = { ok: false, state: null }; }
394
+ if (!snap.ok) {
395
+ return {
396
+ terminalReason: "not-retryable",
397
+ detail: `issue ${issue} state could not be read; --retry did not reopen the needs-human run`,
398
+ };
399
+ }
400
+ if (snap.state === "CLOSED") {
401
+ return {
402
+ terminalReason: "not-retryable",
403
+ detail: `issue ${issue} is CLOSED; --retry does not reopen a needs-human run on a closed issue`,
404
+ };
405
+ }
406
+ if ((snap.labels || []).includes("needs-human")) {
407
+ return {
408
+ terminalReason: "not-retryable",
409
+ detail: `issue ${issue} still carries needs-human; resolve it and remove the label before --retry`,
410
+ };
411
+ }
412
+ }
384
413
  // forge#3562 (SEC-3): a new cycle discards `committed`, so check the live
385
414
  // issue first. A CLOSED issue has nothing left to run; an unreadable
386
415
  // snapshot fails closed rather than resetting a possibly-finished run.
@@ -524,8 +553,13 @@ export async function runIssue(opts) {
524
553
  // Reuses the existing "invalid" terminal reason (TERMINAL_REASONS)
525
554
  // rather than inventing a new one; #2353 makes the same choice for
526
555
  // `close`'s own CLOSED-not-merged case.
527
- const isDead = snap.labels.includes("workflow:invalid") ||
556
+ let isDead = snap.labels.includes("workflow:invalid") ||
528
557
  (snap.state === "CLOSED" && !snap.labels.includes("workflow:merged"));
558
+ // forge#3534: an investigation exit closes the issue itself without `workflow:merged`.
559
+ // With the trusted deliverables marker present, a CLOSED (not invalid) issue is not
560
+ // divergent: build.reconcile must run so the run reaches close --terminal-state investigation.
561
+ if (isDead && phase.id === "build" && snap.state === "CLOSED" && !snap.labels.includes("workflow:invalid") &&
562
+ await hasInvestigationExit(issue, io)) isDead = false;
529
563
  if (isDead) {
530
564
  const detail = `issue ${issue} is ${snap.state === "CLOSED" ? "closed" : "open"} ` +
531
565
  `with divergent state before phase ${phase.id} (labels: ${snap.labels.join(", ") || "none"})`;
package/bin/forgedock.mjs CHANGED
@@ -3207,7 +3207,7 @@ function help() {
3207
3207
  ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
3208
3208
  ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
3209
3209
  ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
3210
- ["npx forgedock run-issue <issue> --lane <lane> [--retry] [--wait-lease]", "Drive one issue through the durable engine (--retry reopens an engine-error run; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
3210
+ ["npx forgedock run-issue <issue> --lane <lane> [--retry] [--wait-lease]", "Drive one issue through the durable engine (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
3211
3211
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
3212
3212
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
3213
3213
  ["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
package/commands/issue.md CHANGED
@@ -367,8 +367,23 @@ Run the deterministic dedup script first (authoritative check), then fall back t
367
367
  # empty, so this is a no-op for that path.
368
368
  DEDUP_EXCLUDE_ARGS=()
369
369
  [ -n "$PROGRAMMATIC_EXCLUDE" ] && DEDUP_EXCLUDE_ARGS=(--exclude "$PROGRAMMATIC_EXCLUDE")
370
- DEDUP_RESULT=$(scripts/issue-dedup.sh "{PROPOSED_TITLE}" {GH_FLAG} "${DEDUP_EXCLUDE_ARGS[@]}" 2>&1)
371
- DEDUP_EXIT=$?
370
+ # HELPER_SCRIPT resolver (DEDUP_SCRIPT; trusted-install tiers only, never the working directory or the target repo, #3400/#3483): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude. The cache scan matters because the plugin-root placeholder is not always substituted in forked runs.
371
+ _l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
372
+ _tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
373
+ for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
374
+ [ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
375
+ done
376
+ DEDUP_SCRIPT=""
377
+ while IFS= read -r _c; do
378
+ case "$_c" in /*) [ -z "$DEDUP_SCRIPT" ] && [ -f "$_c/scripts/issue-dedup.sh" ] && DEDUP_SCRIPT="$_c/scripts/issue-dedup.sh" ;; esac
379
+ done <<< "$_tc"
380
+ # Unresolved helper: no deterministic result (exit 0, empty) so the LLM fallback below still runs; never exit 1 (duplicate STOP).
381
+ if [ -n "$DEDUP_SCRIPT" ]; then
382
+ DEDUP_RESULT=$(bash "$DEDUP_SCRIPT" "{PROPOSED_TITLE}" {GH_FLAG} "${DEDUP_EXCLUDE_ARGS[@]}" 2>&1)
383
+ DEDUP_EXIT=$?
384
+ else
385
+ DEDUP_RESULT=""; DEDUP_EXIT=0
386
+ fi
372
387
 
373
388
  if [ "$DEDUP_EXIT" -eq 1 ]; then
374
389
  echo "Near-duplicate detected: $DEDUP_RESULT"
@@ -3806,15 +3806,22 @@ for FILE in "${!SURFACE_FILE_MEMBERS[@]}"; do
3806
3806
  # Dedup-check with member exclusion (MANDATORY — forge#2432, identical mechanism to
3807
3807
  # phase-1-resolve.md's mirror block; this site keeps its existing direct-to-gh issue
3808
3808
  # creation below rather than migrating to /issue's Skill routing, since this loop runs
3809
- # per-completion-cycle and a direct scripts/issue-dedup.sh call is cheaper here). A
3809
+ # per-completion-cycle and a direct issue-dedup.sh call is cheaper here). A
3810
3810
  # batch title necessarily restates its own members' subject matter by construction —
3811
3811
  # excluding the cluster's own member numbers means Phase-2D-style dedup only fires on
3812
3812
  # a GENUINE non-member duplicate. --exclude narrows the candidate set; it is NOT a
3813
3813
  # --force equivalent.
3814
3814
  CHUNK_LIST=$(IFS=,; echo "${CHUNK[*]}")
3815
3815
  PROPOSED_BATCH_TITLE="fix(batch): P3 review findings — ${SAFE_SURFACE_AREA} (same-run batch)"
3816
- DEDUP_RESULT=$(scripts/issue-dedup.sh "$PROPOSED_BATCH_TITLE" {GH_FLAG} --exclude "$CHUNK_LIST" 2>&1)
3817
- DEDUP_EXIT=$?
3816
+ # Resolve the helper from the FORGE_ROOT bootstrap above (trusted install root, never the cwd/target repo, #3679). Unresolved: skip the dedup check (exit 0), never read as a duplicate.
3817
+ DEDUP_SCRIPT=""
3818
+ [ -n "$FORGE_ROOT" ] && [ -f "$FORGE_ROOT/scripts/issue-dedup.sh" ] && DEDUP_SCRIPT="$FORGE_ROOT/scripts/issue-dedup.sh"
3819
+ if [ -n "$DEDUP_SCRIPT" ]; then
3820
+ DEDUP_RESULT=$(bash "$DEDUP_SCRIPT" "$PROPOSED_BATCH_TITLE" {GH_FLAG} --exclude "$CHUNK_LIST" 2>&1)
3821
+ DEDUP_EXIT=$?
3822
+ else
3823
+ DEDUP_RESULT=""; DEDUP_EXIT=0
3824
+ fi
3818
3825
 
3819
3826
  if [ "$DEDUP_EXIT" -eq 1 ]; then
3820
3827
  # Genuine non-member duplicate — some other open issue already covers this exact