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.
- package/bin/engine/phases-args.test.mjs +11 -0
- package/bin/engine/phases.mjs +115 -14
- package/bin/engine/phases.test.mjs +24 -0
- package/bin/engine.mjs +41 -7
- package/bin/forgedock.mjs +1 -1
- package/commands/issue.md +17 -2
- package/commands/orchestrate/phase-4-execution.md +10 -3
- package/commands/review-pr-staging.md +278 -28
- package/commands/review-pr.md +71 -22
- package/commands/work-on/build/implement.md +15 -1
- package/commands/work-on/build.md +7 -4
- package/commands/work-on/investigate.md +5 -3
- package/commands/work-on/remediate.md +215 -35
- package/docs/CONFIG.md +2 -2
- package/docs/RELEASE-NOTES.md +12 -0
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +6 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +14 -7
- package/scripts/classify-finding.test.sh +9 -0
- package/scripts/forge-root.test.sh +41 -4
- package/scripts/remediate-approved-head.test.sh +43 -0
- package/scripts/remediation-marker-match.test.sh +6 -0
- package/scripts/resolve-deploy-gate.sh +51 -0
- package/scripts/resolve-deploy-gate.test.sh +59 -0
- package/scripts/review-pr-delta-wiring.test.sh +8 -0
- package/scripts/review-pr-domain-map.test.sh +10 -0
- package/scripts/review-pr-staging-scope.test.sh +99 -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"]]) {
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -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);
|
|
469
|
-
const
|
|
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 *
|
|
501
|
-
console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${
|
|
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
|
-
//
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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.
|
|
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`.
|
|
373
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|
|
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
|
-
|
|
3817
|
-
|
|
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
|