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.
- package/bin/engine/phases-args.test.mjs +11 -0
- package/bin/engine/phases.mjs +133 -15
- package/bin/engine/phases.test.mjs +24 -0
- package/bin/engine/runlog.mjs +10 -1
- package/bin/engine.mjs +43 -7
- package/bin/forgedock.mjs +1 -1
- package/commands/forge-stats.md +10 -0
- package/commands/issue.md +17 -2
- package/commands/orchestrate/phase-4-execution.md +10 -3
- package/commands/review-pr-staging.md +290 -30
- 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/check-spec-size.sh +102 -0
- package/scripts/check-spec-size.test.sh +85 -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 +127 -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,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);
|
|
469
|
-
const
|
|
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 *
|
|
501
|
-
console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${
|
|
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
|
-
//
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
895
|
-
|
|
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
|
+
});
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
@@ -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
|
-
|
|
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)"],
|
package/commands/forge-stats.md
CHANGED
|
@@ -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
|
-
|
|
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
|