forgedock 1.14.0 → 1.16.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,11 +192,41 @@ 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");
193
219
  }
194
220
 
221
+ /**
222
+ * close.md's own idempotency key: a trusted FORGE:TRAJECTORY comment. Shared by close.reconcile
223
+ * and close.detectOutcome so the two cannot drift. Throws on fetch error; callers fail closed.
224
+ */
225
+ async function hasTrajectory(issue, io) {
226
+ const { comments } = await issueMarkers(issue, io);
227
+ return comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY"));
228
+ }
229
+
195
230
  /**
196
231
  * Trusted comment bodies, chronological — the same single fetch + trust predicate
197
232
  * as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
@@ -228,6 +263,33 @@ function sizeGateRoutesToDecompose(comments) {
228
263
  if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return false;
229
264
  return !comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c));
230
265
  }
266
+ /**
267
+ * Decompose loop guard (mirrors build.md B5.5 and decompose.md Guard 1). Returns a
268
+ * short reason string when the issue must NOT be routed to decompose, else null:
269
+ * a `**Parent**: #N` body line (a sub-issue; decompose Guard 1 would BLOCK it), a
270
+ * TRUSTED `FORGE:DECOMPOSED` comment, or a TRUSTED justified `FORGE:SIZE_OVERRIDE`.
271
+ * `comments` MUST be trusted bodies. A body-fetch failure fails toward the guard
272
+ * (not decomposed) so an error can never route into a refusing decompose phase.
273
+ */
274
+ async function decomposeLoopGuard(issue, comments, io) {
275
+ const starts = (c, m) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${m}`);
276
+ if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return "already decomposed (FORGE:DECOMPOSED)";
277
+ if (comments.some((c) => starts(c, PHASE_MARKERS.build.sizeOverrideMarker) && overrideHasJustification(c)))
278
+ return "justified FORGE:SIZE_OVERRIDE";
279
+ let body;
280
+ try {
281
+ const out = String((await io.gh(["issue", "view", String(issue), "--json", "body", "--jq", ".body"])) ?? "");
282
+ body = out;
283
+ if (out.trimStart().startsWith("{")) {
284
+ try { const j = JSON.parse(out); if (j && typeof j.body === "string") body = j.body; } catch { /* raw text */ }
285
+ }
286
+ } catch {
287
+ return "issue body unavailable (failing toward not decomposed)";
288
+ }
289
+ if (/^\*\*Parent\*\*:[ \t]*#\d+/m.test(body.replace(/\r\n/g, "\n"))) return "sub-issue (**Parent**: #N)";
290
+ return null;
291
+ }
292
+
231
293
  /** Cheap untrusted pre-check: does ANY comment look like an OVER gate? Only then is the trusted fetch worth making. */
232
294
  function mayRouteToDecompose(comments) {
233
295
  const gate = PHASE_MARKERS.build.sizeGateMarker;
@@ -465,8 +527,8 @@ async function reviewArgs(state, ctx, io) {
465
527
  "--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
466
528
  }
467
529
 
468
- /** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); mirrored here for the isDecomposed safety net. */
469
- const ESTIMATE_FACTOR = 1.2;
530
+ /** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
531
+ const DEFAULT_ESTIMATE_FACTOR = 1.2;
470
532
 
471
533
  /**
472
534
  * Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
@@ -491,14 +553,18 @@ function isDecomposed(report) {
491
553
  if ((verdict ?? marker) === "YES") return true;
492
554
  // forge#3573 safety net: an investigator NO whose own size estimate is clearly over
493
555
  // the gate (estimate > threshold x factor) fails toward decompose. Own-line,
494
- // whole-number fields only; absent/0/non-numeric fields have no effect.
556
+ // whole-number fields only; absent/0/non-numeric fields have no effect. The factor is
557
+ // the investigator's effective `ESTIMATE_FACTOR:` (positive decimal); absent/invalid -> 1.2.
495
558
  const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
496
559
  const gate = /^GATE_THRESHOLD:[ \t]*(\d+)[ \t]*$/m.exec(text);
560
+ const fac = /^ESTIMATE_FACTOR:[ \t]*(\d+(?:\.\d+)?)[ \t]*$/m.exec(text);
561
+ const parsedFactor = fac ? Number(fac[1]) : NaN;
562
+ const factor = Number.isFinite(parsedFactor) && parsedFactor > 0 ? parsedFactor : DEFAULT_ESTIMATE_FACTOR;
497
563
  if (est && gate) {
498
564
  const estimate = Number(est[1]);
499
565
  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`);
566
+ if (threshold > 0 && estimate > threshold * factor) {
567
+ console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
502
568
  return true;
503
569
  }
504
570
  }
@@ -527,8 +593,16 @@ export const PHASES = [
527
593
  return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
528
594
  if (closing === complete) {
529
595
  // investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
530
- if (isDecomposed(report))
596
+ if (isDecomposed(report)) {
597
+ // Lazy: the body fetch only happens on the decompose path. A child / already
598
+ // decomposed / overridden issue cannot be decomposed (decompose Guard 1 BLOCKs).
599
+ const guard = await decomposeLoopGuard(state.issue, comments, io);
600
+ if (guard) {
601
+ console.error(`[engine] decompose suppressed for #${state.issue}: ${guard}; treating as not decomposed`);
602
+ return { status: "committed", outputs: { verdict: "CONFIRMED" } };
603
+ }
531
604
  return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
605
+ }
532
606
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
533
607
  }
534
608
  return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
@@ -590,6 +664,8 @@ export const PHASES = [
590
664
  const merged = await mergedPrForBranch(branch, state.lane, io);
591
665
  if (merged) return { satisfied: true, outputs: { branch, pr: merged.number } };
592
666
  }
667
+ // forge#3534: an investigation exit already posted its deliverables marker: build is done.
668
+ if (hasInvestigationMarker(comments)) return { satisfied: true, terminalReason: "investigation", outputs: {} };
593
669
  return { satisfied: false };
594
670
  },
595
671
  async detectOutcome(state, io) {
@@ -608,14 +684,21 @@ export const PHASES = [
608
684
  const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
609
685
  // forge#3545: fail closed. COMPLETE is written before the B6.5 acceptance gate, so a
610
686
  // 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.
687
+ // review. Non-retryable: a retry would only re-read the same append-only markers.
688
+ // A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
689
+ // build invocation does via build.md B0 gate resume (re-enters B6.5; forge#3725).
613
690
  if (latestGateState(comments) === "blocked") {
614
691
  return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
615
692
  }
616
693
  if (complete && ahead > 0) {
617
694
  return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
618
695
  }
696
+ // forge#3534: INVESTIGATION_COMPLETE writes no code and opens no PR, so it never
697
+ // posts FORGE:BUILDER:COMPLETE with commits. The durable deliverables marker is the
698
+ // ground truth. Not retried; review is skipped and close runs `--terminal-state investigation`.
699
+ if (hasInvestigationMarker(comments)) {
700
+ return { status: "committed", terminalReason: "investigation", outputs: {} };
701
+ }
619
702
  // B5.5 NEEDS_DECOMPOSE: the size gate posts no FORGE:BUILDER:COMPLETE and
620
703
  // build.md says the router (not build) dispatches decompose with no
621
704
  // needs-human. Non-retryable by construction: a retry re-measures and
@@ -667,7 +750,8 @@ export const PHASES = [
667
750
  id: "review",
668
751
  command: "work-on/review",
669
752
  buildArgs: reviewArgs,
670
- entryCondition: (s) => s.committed.includes("build"),
753
+ // forge#3534: an investigation build has no PR to review; close runs directly.
754
+ entryCondition: (s) => s.committed.includes("build") && s.terminalReason !== "investigation",
671
755
  async reconcile(state, io) {
672
756
  const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
673
757
  return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
@@ -794,6 +878,19 @@ export const PHASES = [
794
878
  ? { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } }
795
879
  : { status: "committed", terminalReason: "merged", outputs: { reGateOutcome, closeHandoff: true } };
796
880
  };
881
+ // forge#3685: a resume that ends HELD posts a non-terminal FORGE:REMEDIATION_HELD record (no COMPLETE).
882
+ // Newest-wins between the held record and COMPLETE, by position in the (trusted, scoped) comment list.
883
+ const isHeld = (b) => startsWithHeader(b, REMEDIATION_HELD_MARKER) && hasSentinelLine(b, REMEDIATION_HELD_MARKER);
884
+ let heldAt = -1, completeAt = -1;
885
+ scoped.forEach((b, i) => {
886
+ if (isHeld(b)) heldAt = i;
887
+ if (startsWithHeader(b, PHASE_MARKERS.remediate.header) && hasSentinelLine(b, completion)) completeAt = i;
888
+ });
889
+ if (heldAt >= 0 && heldAt > completeAt) {
890
+ // Merged PR hands to close; otherwise awaiting-merge (terminal for the engine, re-entered by an operator), never a retry.
891
+ if (await prMerged()) return mergedResult("AUTO-LANDED");
892
+ return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome: "HELD-AWAITING-MERGE" } };
893
+ }
797
894
  if (latest === null) {
798
895
  if (await prMerged()) return mergedResult("AUTO-LANDED");
799
896
  return { status: "failed", detail: `no ${completion} marker` };
@@ -841,20 +938,23 @@ export const PHASES = [
841
938
  // non-merged handoff reason (needs-human, awaiting-merge, engine-error, ...) must never
842
939
  // be closed as merged.
843
940
  const reason = state.terminalReason ?? null;
844
- if (reason !== null && !["merged", "decomposed", "invalid"].includes(reason))
941
+ if (reason !== null && !["merged", "decomposed", "invalid", "investigation"].includes(reason))
845
942
  throw new PhaseArgsError(`refusing close --terminal-state merged from terminalReason ${JSON.stringify(reason)}`);
846
943
  const terminal = reason === "decomposed" ? "decomposed"
847
- : reason === "invalid" ? "invalid" : "merged";
944
+ : reason === "invalid" ? "invalid"
945
+ : reason === "investigation" ? "investigation" : "merged";
848
946
  const args = [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
849
947
  // 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]+$/));
948
+ // forge#3534: an investigation close is PR-less by definition; never pass --pr.
949
+ if (terminal !== "investigation" && (state.pr != null || terminal === "merged")) args.push("--pr", need(state.pr, "pr", /^[0-9]+$/));
851
950
  if (state.branch) args.push("--branch", need(state.branch, "branch", BRANCH_RE));
852
951
  const wt = await resolveWorktree(state, io);
853
952
  if (wt && PATH_RE.test(wt)) args.push("--worktree", wt);
854
953
  args.push("--terminal-state", terminal);
855
954
  return args;
856
955
  },
857
- entryCondition: (s) => s.committed.includes("review"),
956
+ entryCondition: (s) => s.committed.includes("review")
957
+ || (s.committed.includes("build") && s.terminalReason === "investigation"), // forge#3534
858
958
  async reconcile(state, io) {
859
959
  // Idempotent resume: issue already CLOSED → skip the LLM re-run. forge#3644: the
860
960
  // workflow:merged label alone is NOT proof close ran — remediate M7 labels the issue
@@ -864,6 +964,14 @@ export const PHASES = [
864
964
  // strict subset of what satisfies this predicate on a CLOSED issue).
865
965
  const snap = await issueSnapshot(state.issue, io);
866
966
  if (!snap.ok) return { satisfied: false };
967
+ if (snap.state === "CLOSED" && state.terminalReason === "investigation") {
968
+ // forge#3534: the implement child closes an investigation issue itself, so CLOSED is no
969
+ // proof close.md ran (board, summary, trajectory). Its own idempotency key is the
970
+ // FORGE:TRAJECTORY comment; only that makes this satisfied.
971
+ try {
972
+ return { satisfied: await hasTrajectory(state.issue, io), terminalReason: "investigation" };
973
+ } catch { return { satisfied: false }; }
974
+ }
867
975
  if (snap.state === "CLOSED") return { satisfied: true };
868
976
  // forge#3545: a current-cycle FORGE:PHASE:COMPLETE means close already ran to its
869
977
  // PHASE_COMPLETE exit; do not re-run it.
@@ -891,8 +999,18 @@ export const PHASES = [
891
999
  // (already in TERMINAL_REASONS) rather than inventing a new one.
892
1000
  if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
893
1001
  return { status: "committed", terminalReason: "merged", outputs: {} };
894
- if (snap.state === "CLOSED")
895
- return { status: "committed", terminalReason: "invalid", outputs: {} };
1002
+ // forge#3534: an investigation exit closes the issue itself; keep that reason honest.
1003
+ if (snap.state === "CLOSED") {
1004
+ if (state.terminalReason !== "investigation")
1005
+ return { status: "committed", terminalReason: "invalid", outputs: {} };
1006
+ // The issue is always already CLOSED for an investigation run, so require the same
1007
+ // positive evidence as reconcile (a trusted FORGE:TRAJECTORY); fail closed on error.
1008
+ let ran = false;
1009
+ try { ran = await hasTrajectory(state.issue, io); } catch { ran = false; }
1010
+ return ran
1011
+ ? { status: "committed", terminalReason: "investigation", outputs: {} }
1012
+ : { status: "failed", detail: "investigation close: no FORGE:TRAJECTORY" };
1013
+ }
896
1014
  // forge#3545: PHASE_COMPLETE is a valid close exit (multi-phase issue: PR merged,
897
1015
  // issue deliberately left OPEN). Only the current cycle's marker counts.
898
1016
  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
+ });
@@ -61,6 +61,8 @@ export function deriveState(events) {
61
61
  remediationKind: null };
62
62
  // forge#3528: handoff reason of the LAST PHASE_COMMIT only (replaced, not sticky).
63
63
  let lastCommitReason = null;
64
+ // forge#3754: phase of the LAST PHASE_COMMIT, so a reopen can tell a remediate-terminal run.
65
+ let lastCommitPhase = null;
64
66
  for (const e of events) {
65
67
  switch (e.event) {
66
68
  case "RUN_START":
@@ -75,6 +77,7 @@ export function deriveState(events) {
75
77
  // remediate instead of defaulting to a "merged" close.
76
78
  if (e.terminalReason) s.terminalReason = e.terminalReason;
77
79
  lastCommitReason = e.terminalReason ?? null;
80
+ lastCommitPhase = e.phase ?? null;
78
81
  // forge#3530: the review's remediation kind must survive resume. A later review
79
82
  // commit replaces it; a committed remediate consumes it, so it is never reused.
80
83
  if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
@@ -91,6 +94,12 @@ export function deriveState(events) {
91
94
  // retry re-enters remediate/decompose instead of close/build.
92
95
  case "RUN_REOPEN":
93
96
  s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
97
+ // forge#3754: a remediate that ended needs-human is re-run after a human clears the
98
+ // label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
99
+ // does not re-terminate and remediate's entryCondition re-selects it.
100
+ if (lastCommitPhase === "remediate" && lastCommitReason === "needs-human") {
101
+ s.committed = s.committed.filter((p) => p !== "remediate");
102
+ }
94
103
  break;
95
104
  // forge#3562: an explicit `run-issue --retry` on a `phase-complete` run
96
105
  // (multi-phase issue: this phase merged, more remain) starts a NEW cycle.
@@ -102,7 +111,7 @@ export function deriveState(events) {
102
111
  case "RUN_NEXT_CYCLE":
103
112
  s.terminal = false; s.terminalReason = null; s.committed = [];
104
113
  s.branch = null; s.pr = null; s.remediationKind = null;
105
- lastCommitReason = null; s.v = e.seq;
114
+ lastCommitReason = null; lastCommitPhase = null; s.v = e.seq;
106
115
  break;
107
116
  // forge#2524: a session-limit pause is purely informational — it does
108
117
  // NOT touch committed/terminal/terminalReason. The phase that hit the
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.
@@ -485,6 +514,8 @@ export async function runIssue(opts) {
485
514
  // must honour that reason instead of picking the next phase (close would throw in buildArgs
486
515
  // and end as engine-error; build would run on a decomposed parent). Review's `needs-human`
487
516
  // is a handoff to remediate, so only these committed phases trigger the guard.
517
+ // forge#3754: a reopened (--retry) remediate-terminal run has `remediate` un-committed by the
518
+ // run-log reducer, so this guard does not fire and remediate re-runs.
488
519
  if (state.terminalReason && TERMINAL_REASONS.includes(state.terminalReason) &&
489
520
  ((state.committed.includes("remediate") && !["merged", "decomposed", "invalid"].includes(state.terminalReason)) ||
490
521
  (state.committed.includes("decompose") && state.terminalReason === "decomposed") ||
@@ -524,8 +555,13 @@ export async function runIssue(opts) {
524
555
  // Reuses the existing "invalid" terminal reason (TERMINAL_REASONS)
525
556
  // rather than inventing a new one; #2353 makes the same choice for
526
557
  // `close`'s own CLOSED-not-merged case.
527
- const isDead = snap.labels.includes("workflow:invalid") ||
558
+ let isDead = snap.labels.includes("workflow:invalid") ||
528
559
  (snap.state === "CLOSED" && !snap.labels.includes("workflow:merged"));
560
+ // forge#3534: an investigation exit closes the issue itself without `workflow:merged`.
561
+ // With the trusted deliverables marker present, a CLOSED (not invalid) issue is not
562
+ // divergent: build.reconcile must run so the run reaches close --terminal-state investigation.
563
+ if (isDead && phase.id === "build" && snap.state === "CLOSED" && !snap.labels.includes("workflow:invalid") &&
564
+ await hasInvestigationExit(issue, io)) isDead = false;
529
565
  if (isDead) {
530
566
  const detail = `issue ${issue} is ${snap.state === "CLOSED" ? "closed" : "open"} ` +
531
567
  `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)"],
@@ -52,6 +52,16 @@ Commands are classified by role. Each role has a max line count — beyond this,
52
52
 
53
53
  ---
54
54
 
55
+ ## CI Spec-Size Budget
56
+
57
+ `scripts/check-spec-size.sh` (job `spec-size-budget` in `check-commands.yml`, PRs only) enforces a budget that is independent of the line-count `.baselines`; the baseline is refreshed on release via `/forge-stats baseline`. It compares bytes against the PR base:
58
+
59
+ - `review-pr.md`, `review-pr-staging.md`, `work-on/remediate.md`, `work-on/review.md`, `work-on.md` may not grow by more than 2% or 4 KB. Shrinking always passes.
60
+ - No `commands/**` file may gain a copy of a canonical block (FORGE_ROOT bootstrap, TRUSTED_SCRIPT/HELPER_SCRIPT resolver, FENCE_BOOTSTRAP anchor); the per-file marker count may not rise. Tighten to an outright ban once the shared helper (#3493) lands.
61
+ - Exemption: the `spec-size-exempt` PR label, or a `<!-- spec-size-exempt: <reason> -->` line in the PR body (non-empty reason). The job summary lists the per-file delta and any waiver.
62
+
63
+ ---
64
+
55
65
  ## Mode: `diff` (default)
56
66
 
57
67
  Compare current line counts against the last git commit. Shows what grew and what shrank.
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