forgedock 1.16.0 → 1.16.2
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.mjs +69 -15
- package/bin/engine/runlog.mjs +21 -3
- package/bin/engine.mjs +43 -7
- package/bin/runner.mjs +21 -2
- package/commands/forge-stats.md +2 -2
- package/commands/orchestrate/phase-1-resolve.md +6 -40
- package/commands/orchestrate/phase-4-execution.md +26 -162
- package/commands/quality-gate.md +12 -80
- package/commands/review-pr-staging.md +67 -301
- package/commands/review-pr.md +150 -752
- package/commands/work-on/build/validate.md +6 -40
- package/commands/work-on/build.md +82 -40
- package/commands/work-on/close.md +7 -22
- package/commands/work-on/decompose.md +8 -24
- package/commands/work-on/investigate.md +18 -29
- package/commands/work-on/remediate.md +38 -147
- package/commands/work-on/review.md +25 -42
- package/commands/work-on.md +13 -63
- package/docs/CONFIG.md +10 -7
- package/docs/RELEASE-NOTES.md +6 -0
- package/docs/WORK-ON-RUNTIME.md +1 -1
- package/docs/spec/forge-protocol-v1.md +1 -1
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-spec-size.sh +47 -27
- package/scripts/check-spec-size.test.sh +46 -3
- package/scripts/diff-size.sh +36 -3
- package/scripts/diff-size.test.sh +47 -0
- package/scripts/forge-root.test.sh +218 -91
- package/scripts/lib/fence-bootstrap.sh +16 -0
- package/scripts/lib/forge-resolve.sh +96 -0
- package/scripts/lib/review-delta-mode.sh +41 -0
- package/scripts/lint-dispatch-prompt.test.sh +3 -3
- package/scripts/remediate-liveness.sh +96 -0
- package/scripts/review-acceptance-gate.sh +132 -0
- package/scripts/review-acceptance-gate.test.sh +90 -0
- package/scripts/review-pr-delta-wiring.test.sh +24 -7
- package/scripts/review-pr-staging-scope.test.sh +14 -7
- package/scripts/spec-no-worktree-script.test.sh +1 -1
package/bin/engine/phases.mjs
CHANGED
|
@@ -97,6 +97,10 @@ async function issueMarkers(issue, io) {
|
|
|
97
97
|
|
|
98
98
|
/** Non-terminal held-resume record (remediate.md Phase M8, forge#3685): header and sentinel are the same line; never a COMPLETE marker. */
|
|
99
99
|
const REMEDIATION_HELD_MARKER = "FORGE:REMEDIATION_HELD";
|
|
100
|
+
/** Non-terminal deferral record (remediate.md Phase M0 liveness step): a live earlier run holds the lease. Never a COMPLETE/trail marker. */
|
|
101
|
+
const REMEDIATION_DEFERRED_MARKER = "FORGE:REMEDIATION_DEFERRED";
|
|
102
|
+
/** Lease holder ids the engine mints (`cli_<host>_<pid>`) are passed to remediate only when they match this shape (same as remediate-liveness.sh). */
|
|
103
|
+
const LEASE_HOLDER_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
100
104
|
const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
101
105
|
/** True when `body` STARTS with the typed header `<!-- {header}` (e.g. `FORGE:BUILDER`). */
|
|
102
106
|
function startsWithHeader(body, header) {
|
|
@@ -227,6 +231,17 @@ async function hasTrajectory(issue, io) {
|
|
|
227
231
|
return comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY"));
|
|
228
232
|
}
|
|
229
233
|
|
|
234
|
+
/**
|
|
235
|
+
* forge#3772: investigation close evidence, shared by close.reconcile and close.detectOutcome.
|
|
236
|
+
* A trusted FORGE:TRAJECTORY, or a trusted current-cycle FORGE:PHASE:COMPLETE (multi-phase body).
|
|
237
|
+
* Throws on fetch error; callers fail closed.
|
|
238
|
+
*/
|
|
239
|
+
async function hasInvestigationCloseEvidence(issue, io) {
|
|
240
|
+
const { comments } = await issueMarkers(issue, io);
|
|
241
|
+
return comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY"))
|
|
242
|
+
|| hasPhaseComplete(sliceToCurrentCycle(comments));
|
|
243
|
+
}
|
|
244
|
+
|
|
230
245
|
/**
|
|
231
246
|
* Trusted comment bodies, chronological — the same single fetch + trust predicate
|
|
232
247
|
* as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
|
|
@@ -259,7 +274,7 @@ function sizeGateRoutesToDecompose(comments) {
|
|
|
259
274
|
for (let i = comments.length - 1; i >= 0; i--) if (starts(comments[i], gate)) { idx = i; break; }
|
|
260
275
|
if (idx < 0) return false;
|
|
261
276
|
const body = comments[idx].replace(/\r\n/g, "\n");
|
|
262
|
-
const m = /^result:[ \t]*(OK|OVERRIDDEN|OVER)[ \t]*$/m.exec(body);
|
|
277
|
+
const m = /^result:[ \t]*(OK|OVERRIDDEN|COHESIVE|OVER)[ \t]*$/m.exec(body);
|
|
263
278
|
if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return false;
|
|
264
279
|
return !comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c));
|
|
265
280
|
}
|
|
@@ -529,6 +544,8 @@ async function reviewArgs(state, ctx, io) {
|
|
|
529
544
|
|
|
530
545
|
/** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
|
|
531
546
|
const DEFAULT_ESTIMATE_FACTOR = 1.2;
|
|
547
|
+
/** Default `build.diff_size.batch_seam_min` (docs/CONFIG.md); the engine cannot read forge.yaml here. */
|
|
548
|
+
const DEFAULT_BATCH_SEAM_MIN = 3;
|
|
532
549
|
|
|
533
550
|
/**
|
|
534
551
|
* Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
|
|
@@ -550,23 +567,41 @@ function isDecomposed(report) {
|
|
|
550
567
|
console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
|
|
551
568
|
return false;
|
|
552
569
|
}
|
|
553
|
-
|
|
554
|
-
//
|
|
555
|
-
//
|
|
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.
|
|
570
|
+
// forge#3573 safety net inputs: own-line, whole-number fields only; absent/0/non-numeric
|
|
571
|
+
// fields have no effect. The factor is the investigator's effective `ESTIMATE_FACTOR:`
|
|
572
|
+
// (positive decimal); absent/invalid -> 1.2.
|
|
558
573
|
const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
559
574
|
const gate = /^GATE_THRESHOLD:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
560
575
|
const fac = /^ESTIMATE_FACTOR:[ \t]*(\d+(?:\.\d+)?)[ \t]*$/m.exec(text);
|
|
576
|
+
const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
561
577
|
const parsedFactor = fac ? Number(fac[1]) : NaN;
|
|
562
578
|
const factor = Number.isFinite(parsedFactor) && parsedFactor > 0 ? parsedFactor : DEFAULT_ESTIMATE_FACTOR;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
579
|
+
const sized = est && gate && Number(gate[1]) > 0;
|
|
580
|
+
const estimate = sized ? Number(est[1]) : 0;
|
|
581
|
+
const threshold = sized ? Number(gate[1]) : 0;
|
|
582
|
+
if ((verdict ?? marker) === "YES") {
|
|
583
|
+
// forge#3786: a YES the numbers do not require is downgraded to NO unless the report
|
|
584
|
+
// carries an own-line `DECOMPOSE_JUSTIFICATION:` line. Required = estimate over
|
|
585
|
+
// threshold x factor, or the seam clause (seams >= batch_seam_min and estimate over
|
|
586
|
+
// threshold / 2). The engine cannot see the `batch` label or the configured
|
|
587
|
+
// `batch_seam_min`, so it uses the default 3 and stays lenient for batches. Absent
|
|
588
|
+
// numeric fields keep the YES (no silent downgrade of a malformed report).
|
|
589
|
+
if (sized) {
|
|
590
|
+
const seams = seamsM ? Number(seamsM[1]) : 0;
|
|
591
|
+
const required = estimate > threshold * factor || (seams >= DEFAULT_BATCH_SEAM_MIN && estimate > threshold / 2);
|
|
592
|
+
const justified = /^DECOMPOSE_JUSTIFICATION:[ \t]*\S/m.test(text);
|
|
593
|
+
if (!required && !justified) {
|
|
594
|
+
console.error(`[engine] unsupported DECOMPOSE:YES (estimate ${estimate}, seams ${seams}, gate ${threshold} x ${factor}, no DECOMPOSE_JUSTIFICATION); treating as not decomposed`);
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
569
597
|
}
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
// forge#3573 safety net: an investigator NO whose own size estimate is clearly over
|
|
601
|
+
// the gate (estimate > threshold x factor) fails toward decompose.
|
|
602
|
+
if (sized && estimate > threshold * factor) {
|
|
603
|
+
console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
|
|
604
|
+
return true;
|
|
570
605
|
}
|
|
571
606
|
return false;
|
|
572
607
|
}
|
|
@@ -813,7 +848,9 @@ export const PHASES = [
|
|
|
813
848
|
buildArgs: async (state, ctx, io) => {
|
|
814
849
|
const pr = need(state.pr, "pr", /^[0-9]+$/);
|
|
815
850
|
const kind = state.remediationKind ?? null;
|
|
816
|
-
|
|
851
|
+
// Pass the dispatching lease holder so remediate.md M0 can tell a live OTHER run from this engine's own lease.
|
|
852
|
+
const holderArgs = () => (typeof ctx.agentId === "string" && LEASE_HOLDER_RE.test(ctx.agentId) ? ["--lease-holder", ctx.agentId] : []);
|
|
853
|
+
const legacyArgs = () => [pr, "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state), ...holderArgs()];
|
|
817
854
|
// Legacy handoff with no kind (needs-human label fallback, or no REVIEW_RESULT): there
|
|
818
855
|
// is nothing to bind, so dispatch as before. remediate.md M0 then applies its default
|
|
819
856
|
// single-attempt guard. A present-but-unknown kind is never dispatched.
|
|
@@ -893,6 +930,20 @@ export const PHASES = [
|
|
|
893
930
|
}
|
|
894
931
|
if (latest === null) {
|
|
895
932
|
if (await prMerged()) return mergedResult("AUTO-LANDED");
|
|
933
|
+
// A deferral to a live earlier run (remediate.md M0 liveness step) is not a failed attempt. The
|
|
934
|
+
// decision was made by the script at M0 time, so the record is not re-judged against the clock (a
|
|
935
|
+
// lease that expired since is the NEXT dispatch's business). Only a trusted, full-line record for THIS
|
|
936
|
+
// PR counts, and only while it is newer than every `FORGE:REMEDIATION` trail comment: a later run
|
|
937
|
+
// that proceeded posts one, which retires the deferral.
|
|
938
|
+
let deferredAt = -1, trailAt = -1, deferred = null;
|
|
939
|
+
scoped.forEach((b, i) => {
|
|
940
|
+
if (startsWithHeader(b, REMEDIATION_DEFERRED_MARKER) && hasSentinelLine(b, REMEDIATION_DEFERRED_MARKER)) { deferredAt = i; deferred = b; }
|
|
941
|
+
else if (startsWithHeader(b, PHASE_MARKERS.remediate.header)) trailAt = i;
|
|
942
|
+
});
|
|
943
|
+
if (deferred !== null && deferredAt > trailAt && (state.pr == null || /^pr=(\d+)\s*$/m.exec(deferred)?.[1] === String(state.pr))) {
|
|
944
|
+
const kind = /^kind=(\w+)\s*$/m.exec(deferred)?.[1] ?? "lease";
|
|
945
|
+
return { status: "blocked", reason: "deferred", retryable: false, detail: `deferred-live-run: ${kind}` };
|
|
946
|
+
}
|
|
896
947
|
return { status: "failed", detail: `no ${completion} marker` };
|
|
897
948
|
}
|
|
898
949
|
// Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
|
|
@@ -968,8 +1019,11 @@ export const PHASES = [
|
|
|
968
1019
|
// forge#3534: the implement child closes an investigation issue itself, so CLOSED is no
|
|
969
1020
|
// proof close.md ran (board, summary, trajectory). Its own idempotency key is the
|
|
970
1021
|
// FORGE:TRAJECTORY comment; only that makes this satisfied.
|
|
1022
|
+
// forge#3772: investigation exits are normally single-phase (close.md C1 skips the
|
|
1023
|
+
// multi-phase guard for them); a trusted current-cycle PHASE:COMPLETE is accepted as a
|
|
1024
|
+
// safety net so a multi-phase body cannot make every retry fail.
|
|
971
1025
|
try {
|
|
972
|
-
return { satisfied: await
|
|
1026
|
+
return { satisfied: await hasInvestigationCloseEvidence(state.issue, io), terminalReason: "investigation" };
|
|
973
1027
|
} catch { return { satisfied: false }; }
|
|
974
1028
|
}
|
|
975
1029
|
if (snap.state === "CLOSED") return { satisfied: true };
|
|
@@ -1006,7 +1060,7 @@ export const PHASES = [
|
|
|
1006
1060
|
// The issue is always already CLOSED for an investigation run, so require the same
|
|
1007
1061
|
// positive evidence as reconcile (a trusted FORGE:TRAJECTORY); fail closed on error.
|
|
1008
1062
|
let ran = false;
|
|
1009
|
-
try { ran = await
|
|
1063
|
+
try { ran = await hasInvestigationCloseEvidence(state.issue, io); } catch { ran = false; }
|
|
1010
1064
|
return ran
|
|
1011
1065
|
? { status: "committed", terminalReason: "investigation", outputs: {} }
|
|
1012
1066
|
: { status: "failed", detail: "investigation close: no FORGE:TRAJECTORY" };
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -63,6 +63,9 @@ export function deriveState(events) {
|
|
|
63
63
|
let lastCommitReason = null;
|
|
64
64
|
// forge#3754: phase of the LAST PHASE_COMMIT, so a reopen can tell a remediate-terminal run.
|
|
65
65
|
let lastCommitPhase = null;
|
|
66
|
+
// forge#3772: reason of the LAST RUN_TERMINAL, so a reopen can tell a needs-human terminal
|
|
67
|
+
// (label cleared by a human) from an engine-error retry.
|
|
68
|
+
let lastTerminalReason = null;
|
|
66
69
|
for (const e of events) {
|
|
67
70
|
switch (e.event) {
|
|
68
71
|
case "RUN_START":
|
|
@@ -85,6 +88,7 @@ export function deriveState(events) {
|
|
|
85
88
|
break;
|
|
86
89
|
case "RUN_TERMINAL":
|
|
87
90
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
91
|
+
lastTerminalReason = s.terminalReason;
|
|
88
92
|
break;
|
|
89
93
|
// forge#3511: an explicit `run-issue --retry` reopens an engine-error
|
|
90
94
|
// terminal. Bumping `v` makes the local state newer than the stale
|
|
@@ -97,9 +101,15 @@ export function deriveState(events) {
|
|
|
97
101
|
// forge#3754: a remediate that ended needs-human is re-run after a human clears the
|
|
98
102
|
// label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
|
|
99
103
|
// does not re-terminate and remediate's entryCondition re-selects it.
|
|
100
|
-
|
|
104
|
+
// forge#3772: only when the terminal being reopened was itself needs-human (the engine
|
|
105
|
+
// reopens that only after a live read shows the label cleared). An engine-error retry
|
|
106
|
+
// keeps remediate committed so the resume guard is not bypassed.
|
|
107
|
+
if (lastCommitPhase === "remediate" && lastCommitReason === "needs-human"
|
|
108
|
+
&& lastTerminalReason === "needs-human") {
|
|
101
109
|
s.committed = s.committed.filter((p) => p !== "remediate");
|
|
102
110
|
}
|
|
111
|
+
lastTerminalReason = null;
|
|
112
|
+
s.lastRateLimit = null; // forge#3791: an explicit reopen never re-enters a stale pause
|
|
103
113
|
break;
|
|
104
114
|
// forge#3562: an explicit `run-issue --retry` on a `phase-complete` run
|
|
105
115
|
// (multi-phase issue: this phase merged, more remain) starts a NEW cycle.
|
|
@@ -111,7 +121,8 @@ export function deriveState(events) {
|
|
|
111
121
|
case "RUN_NEXT_CYCLE":
|
|
112
122
|
s.terminal = false; s.terminalReason = null; s.committed = [];
|
|
113
123
|
s.branch = null; s.pr = null; s.remediationKind = null;
|
|
114
|
-
lastCommitReason = null; lastCommitPhase = null; s.v = e.seq;
|
|
124
|
+
lastCommitReason = null; lastCommitPhase = null; lastTerminalReason = null; s.v = e.seq;
|
|
125
|
+
s.lastRateLimit = null; // forge#3791
|
|
115
126
|
break;
|
|
116
127
|
// forge#2524: a session-limit pause is purely informational — it does
|
|
117
128
|
// NOT touch committed/terminal/terminalReason. The phase that hit the
|
|
@@ -120,9 +131,16 @@ export function deriveState(events) {
|
|
|
120
131
|
// run correctly re-enters pickPhase at the same phase with no special
|
|
121
132
|
// casing needed here beyond recording the diagnostic below.
|
|
122
133
|
case "PHASE_RATE_LIMITED":
|
|
123
|
-
|
|
134
|
+
// forge#3791: backendId is the non-secret identity of the backend/account that hit the limit.
|
|
135
|
+
s.lastRateLimit = { phase: e.phase, resetAt: e.resetAt ?? null, waitMs: e.waitMs ?? null, backendId: e.backendId ?? null };
|
|
124
136
|
s.v = e.seq;
|
|
125
137
|
break;
|
|
138
|
+
// forge#3791: the paused phase attempting again means the pause is over. A crash
|
|
139
|
+
// mid-pause appends no PHASE_START, so a same-backend relaunch still resumes the wait.
|
|
140
|
+
// `v` is intentionally not bumped.
|
|
141
|
+
case "PHASE_START":
|
|
142
|
+
if (s.lastRateLimit && e.phase === s.lastRateLimit.phase) s.lastRateLimit = null;
|
|
143
|
+
break;
|
|
126
144
|
// PHASE_START / PHASE_FAILED do not change committed state
|
|
127
145
|
}
|
|
128
146
|
}
|
package/bin/engine.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* GitHub state (phase.detectOutcome). All effects are injected → fully testable.
|
|
5
5
|
*/
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import {
|
|
7
|
+
import { createHash, scryptSync } from "node:crypto";
|
|
8
|
+
import { hostname as osHostname, homedir } from "node:os";
|
|
8
9
|
import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
|
|
9
10
|
import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber } from "./engine/phases.mjs";
|
|
10
11
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
@@ -36,6 +37,21 @@ export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
|
|
|
36
37
|
// the engine and is still capped by `maxSessionLimitPauses`.
|
|
37
38
|
export const DEFAULT_SESSION_LIMIT_WAIT_MS = 30 * 60 * 1000;
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* forge#3791: non-secret identity of the backend/account a usage-limit pause belongs to.
|
|
42
|
+
* Truncated sha256 over the resolved backend, the Claude config dir, and a scrypt fingerprint of the
|
|
43
|
+
* API key (when set; a password KDF, not a bare hash, so the secret is never hashed cheaply). The raw key and path are never emitted.
|
|
44
|
+
* @param {{backend?: string, env?: object}} [p]
|
|
45
|
+
* @returns {string} 16 hex chars
|
|
46
|
+
*/
|
|
47
|
+
export function backendIdentity({ backend, env = process.env } = {}) {
|
|
48
|
+
const sha = (s) => createHash("sha256").update(String(s)).digest("hex");
|
|
49
|
+
const cfg = env.CLAUDE_CONFIG_DIR || `${homedir()}/.claude`;
|
|
50
|
+
const key = env.ANTHROPIC_API_KEY
|
|
51
|
+
? scryptSync(String(env.ANTHROPIC_API_KEY), "forgedock-backend-identity", 16).toString("hex") : "";
|
|
52
|
+
return sha(JSON.stringify([backend ?? "auto", cfg, key])).slice(0, 16);
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
/**
|
|
40
56
|
* Validates the subset of `runIssue()`'s options that must fail fast,
|
|
41
57
|
* synchronously, before any state I/O or timer creation — `backend` and the
|
|
@@ -491,10 +507,22 @@ export async function runIssue(opts) {
|
|
|
491
507
|
// the phase early. Same-host resume reads the LOCAL run-log's lastRateLimit
|
|
492
508
|
// (the remote FORGE:STATE does not carry it). The lease is renewed while we
|
|
493
509
|
// wait so a second relaunch still defers.
|
|
494
|
-
|
|
510
|
+
// forge#3791: after a --retry reopen/next-cycle the reducer cleared the pause, so read the
|
|
511
|
+
// post-reopen state, not the pre-reopen `local` derive.
|
|
512
|
+
const rl = (reopened ? state : local)?.lastRateLimit;
|
|
495
513
|
const pendingPhase = pickPhase(state);
|
|
496
|
-
|
|
497
|
-
|
|
514
|
+
const currentBackendId = backendIdentity({ backend, env: process.env });
|
|
515
|
+
const rlApplies = !!(pendingPhase && rl && rl.phase === pendingPhase.id &&
|
|
516
|
+
typeof rl.resetAt === "number" && Number.isFinite(rl.resetAt) && rl.resetAt > now());
|
|
517
|
+
// forge#3791: only wait when the pause was recorded by this same backend/account. A record
|
|
518
|
+
// without backendId (pre-fix) cannot be attributed, so it does not wait.
|
|
519
|
+
if (rlApplies && rl.backendId !== currentBackendId) {
|
|
520
|
+
emitProgress({
|
|
521
|
+
event: "phase_resumed", phase: pendingPhase.id, reason: "session-limit",
|
|
522
|
+
detail: "rate-limit pause belongs to another backend; not waiting",
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (rlApplies && rl.backendId === currentBackendId) {
|
|
498
526
|
const waitMs = Math.min(rl.resetAt - now(), MAX_RESUME_RATE_LIMIT_WAIT_MS);
|
|
499
527
|
emitProgress({
|
|
500
528
|
event: "phase_paused", phase: pendingPhase.id, reason: "session-limit", resetAt: rl.resetAt,
|
|
@@ -671,7 +699,7 @@ export async function runIssue(opts) {
|
|
|
671
699
|
let pauseCount = 0;
|
|
672
700
|
for (;;) {
|
|
673
701
|
try {
|
|
674
|
-
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo });
|
|
702
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId });
|
|
675
703
|
break;
|
|
676
704
|
} catch (e) {
|
|
677
705
|
const resetAtMs = e.resetAtEpochMs;
|
|
@@ -696,6 +724,7 @@ export async function runIssue(opts) {
|
|
|
696
724
|
appendEvent(dir, issue, {
|
|
697
725
|
event: "PHASE_RATE_LIMITED", phase: phase.id,
|
|
698
726
|
resetAt: hasResetEpoch ? resetAtMs : null, resetAtDisplay: e.resetAt || null, waitMs,
|
|
727
|
+
backendId: backendIdentity({ backend, env: process.env }),
|
|
699
728
|
});
|
|
700
729
|
// forge#2524 AC6: progress observer sees the pause so a caller
|
|
701
730
|
// (bin/engine-cli.mjs) can render the wait state instead of the
|
|
@@ -785,6 +814,13 @@ export async function runIssue(opts) {
|
|
|
785
814
|
(outcome.outputs?.pr ?? state.pr) != null;
|
|
786
815
|
if (outcome.status === "blocked") {
|
|
787
816
|
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
|
|
817
|
+
// forge#3792: remediate deferring to a live earlier run is neither a commit nor a terminal: a RUN_TERMINAL or
|
|
818
|
+
// PHASE_COMMIT would strand the PR (`deferred` is not --retry-able, and a committed remediate lets `close` run on
|
|
819
|
+
// an unmerged PR). Release our lease and exit with nothing written; the next dispatch re-picks remediate.
|
|
820
|
+
if (phase.id === "remediate" && outcome.reason === "deferred") {
|
|
821
|
+
await projector.writeState(issue, { ...state, lease: null });
|
|
822
|
+
return { terminalReason: "deferred", detail: outcome.detail };
|
|
823
|
+
}
|
|
788
824
|
if (!isRemediationHandoff)
|
|
789
825
|
return await terminate(state, outcome.reason || "needs-human", outcome.detail);
|
|
790
826
|
} else {
|
|
@@ -876,7 +912,7 @@ export async function runIssue(opts) {
|
|
|
876
912
|
}
|
|
877
913
|
|
|
878
914
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
879
|
-
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo } = ctx;
|
|
915
|
+
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo, agentId } = ctx;
|
|
880
916
|
// forge#3499: build the phase's full argument set ONCE, up front. Every
|
|
881
917
|
// work-on phase sub-skill stops with BLOCKED when a required flag is absent,
|
|
882
918
|
// so an incomplete/invalid set is a deterministic engine failure — fail
|
|
@@ -884,7 +920,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
884
920
|
// `[issue]`-only invocation that burns the attempt budget.
|
|
885
921
|
let phaseArgs;
|
|
886
922
|
try {
|
|
887
|
-
phaseArgs = await phase.buildArgs(state, { repo: await resolveRepo() }, io);
|
|
923
|
+
phaseArgs = await phase.buildArgs(state, { repo: await resolveRepo(), agentId }, io);
|
|
888
924
|
} catch (e) {
|
|
889
925
|
if (e?.code !== "PHASE_ARGS_INVALID") throw e;
|
|
890
926
|
const detail = `phase ${phase.id} args unresolved: ${e.message}`;
|
package/bin/runner.mjs
CHANGED
|
@@ -52,9 +52,28 @@ import { join, dirname, basename, relative, isAbsolute, extname, win32 as win32P
|
|
|
52
52
|
import os from "os";
|
|
53
53
|
import { execSync, spawnSync } from "child_process";
|
|
54
54
|
import { createRequire } from "module";
|
|
55
|
+
import { fileURLToPath } from "url";
|
|
55
56
|
import { parseForgeYaml, resolveModelAlias } from "./forge-utils.mjs";
|
|
56
57
|
import { DEFAULT_SPAWN_MAX_BUFFER_BYTES } from "./cli-spawn-shared.mjs";
|
|
57
58
|
|
|
59
|
+
// Package root (the directory that holds bin/, commands/ and scripts/). Exported to every
|
|
60
|
+
// phase child as FORGEDOCK_HOME so spec fences can source scripts/lib/*.sh without a
|
|
61
|
+
// cache scan. A value the operator already set is never overridden.
|
|
62
|
+
/** Windows drive-letter path (C:\x or C:/x) -> /c/x, the form the bash helpers accept; other paths are returned unchanged. */
|
|
63
|
+
export function toPosixRoot(p, platform = process.platform) {
|
|
64
|
+
if (platform !== "win32") return p;
|
|
65
|
+
const m = /^([A-Za-z]):[\\/](.*)$/.exec(p);
|
|
66
|
+
return m ? `/${m[1].toLowerCase()}/${m[2].replace(/\\/g, "/")}` : p.replace(/\\/g, "/");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const FORGEDOCK_PACKAGE_ROOT = toPosixRoot(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
70
|
+
|
|
71
|
+
/** Returns `env` with FORGEDOCK_HOME defaulted to the package root (never overriding a set value). */
|
|
72
|
+
export function withForgedockHome(env) {
|
|
73
|
+
if (!env.FORGEDOCK_HOME) env.FORGEDOCK_HOME = FORGEDOCK_PACKAGE_ROOT;
|
|
74
|
+
return env;
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
const DEFAULT_MODEL = "claude-sonnet-5";
|
|
59
78
|
const DEFAULT_MAX_ITERATIONS = 50;
|
|
60
79
|
const DEFAULT_MAX_TOKENS = 16384;
|
|
@@ -1391,7 +1410,7 @@ export function runCliBackend({
|
|
|
1391
1410
|
// Mirrors the existing scrub in the `run_bash` tool handler below.
|
|
1392
1411
|
// GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
|
|
1393
1412
|
// intact — only the Anthropic key is removed.
|
|
1394
|
-
const childEnv = { ...process.env };
|
|
1413
|
+
const childEnv = withForgedockHome({ ...process.env });
|
|
1395
1414
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
1396
1415
|
|
|
1397
1416
|
// No `shell` option (defaults to false): argv is passed as discrete,
|
|
@@ -2182,7 +2201,7 @@ export function getToolHandlers(cwd) {
|
|
|
2182
2201
|
// into the model loop, so a prompt-injection payload could otherwise issue
|
|
2183
2202
|
// a run_bash command that exfiltrates the key. gh/git auth tokens
|
|
2184
2203
|
// (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
|
|
2185
|
-
const childEnv = { ...process.env };
|
|
2204
|
+
const childEnv = withForgedockHome({ ...process.env });
|
|
2186
2205
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
2187
2206
|
// Run under bash when available so bash-style commands the model emits
|
|
2188
2207
|
// (heredocs, scripts/*.sh, single-quote semantics) behave consistently
|
package/commands/forge-stats.md
CHANGED
|
@@ -57,8 +57,8 @@ Commands are classified by role. Each role has a max line count — beyond this,
|
|
|
57
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
58
|
|
|
59
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.
|
|
61
|
-
- Exemption: the `spec-size-exempt` PR label
|
|
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. The shared helpers (`scripts/lib/forge-resolve.sh`, `scripts/lib/fence-bootstrap.sh`) are sourced through marker-free stubs, so a conversion reads as a net reduction.
|
|
61
|
+
- Exemption: the `spec-size-exempt` PR label only (needs triage rights; a PR-body marker is not honoured). The job summary lists the per-file delta and any waiver.
|
|
62
62
|
|
|
63
63
|
---
|
|
64
64
|
|
|
@@ -204,26 +204,9 @@ Phase 1 resolves `$ARGUMENTS` to a concrete issue-number list exactly once, at T
|
|
|
204
204
|
Classify `$ARGUMENTS` and persist that classification alongside the resolved issue-number list, so Phase 4 (`phase-4-execution.md` Step 4B) can decide whether to re-run the query later in the batch:
|
|
205
205
|
|
|
206
206
|
```bash
|
|
207
|
-
# FORGE_ROOT
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
211
|
-
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
212
|
-
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
213
|
-
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
214
|
-
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
215
|
-
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
216
|
-
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
217
|
-
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
218
|
-
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
219
|
-
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
220
|
-
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
221
|
-
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
222
|
-
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
223
|
-
while IFS= read -r _c; do
|
|
224
|
-
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
225
|
-
done <<< "$_k"
|
|
226
|
-
fi
|
|
207
|
+
# FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
|
|
208
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
209
|
+
FORGE_ROOT="$(forge_root 2>/dev/null || true)"
|
|
227
210
|
# Import ForgeDock's OWN resolve.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}
|
|
228
211
|
# (inert on non-ForgeDock repos, and would execute repo-controlled JS). Unresolved/missing => no output,
|
|
229
212
|
# which Phase 4 treats as "literal" (never re-resolve) — fail closed, loudly.
|
|
@@ -394,26 +377,9 @@ After executing the plan, retain every ungrouped finding in the registry for the
|
|
|
394
377
|
# batch metadata as `{number, affectedFile, members, memberCount}`.
|
|
395
378
|
# Run this after initial resolution and every later admission event; execute the
|
|
396
379
|
# returned `groups`/`extensions` member numbers against the repo each candidate came from.
|
|
397
|
-
# FORGE_ROOT
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
_h="${FORGEDOCK_HOME:-}"; case "$_h" in [A-Za-z]:[/\\]*) _w="$_h"; _h="$(cygpath -u "$_w" 2>/dev/null || true)"; [ -n "$_h" ] || _h="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')" ;; esac
|
|
401
|
-
# Only the official marketplace is trusted (name pinned; override only via the trusted FORGEDOCK_MARKETPLACE env, never repo files).
|
|
402
|
-
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
403
|
-
if [ -n "${FORGEDOCK_HOME:-}" ]; then case "$_h" in /*) FORGE_ROOT="$_h" ;; esac; else
|
|
404
|
-
# Portable to bash 3.2 (macOS), BSD/GNU coreutils and zsh: no mapfile, no sort -V, no bare globs (zsh aborts on no match). Every assignment ends in || true so the block survives set -e / pipefail.
|
|
405
|
-
_l="$HOME/.claude/commands/work-on.md"; _l="$(readlink -f "$_l" 2>/dev/null || readlink "$_l" 2>/dev/null || true)"; [ -n "$_l" ] && _l="$(dirname "$(dirname "$_l")")"
|
|
406
|
-
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path); skills are generated files, not symlinks.
|
|
407
|
-
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) _x="$(head -n 1 "$_cx/forge-home" 2>/dev/null || true)" ;; *) _x="" ;; esac
|
|
408
|
-
# newest cached version first: numeric major.minor.patch of the version dir name only (non-semver names such as commit SHAs are skipped); a release outranks its pre-release (1.10.0 > 1.9.0 > 1.9.0-rc1)
|
|
409
|
-
_v="$(find -L "$HOME/.claude/plugins/cache" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | awk -F/ -v mk="$_mk" '$(NF-2)==mk && $(NF-1)=="forgedock" && $NF ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/{v=$NF;p=index(v,"-");r=1;if(p){v=substr(v,1,p-1);r=0};split(v,a,".");printf "%d %d %d %d %s\n",a[1],a[2],a[3],r,$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr | cut -d' ' -f5- || true)"
|
|
410
|
-
_m="$HOME/.claude/plugins/marketplaces/$_mk"
|
|
411
|
-
# '${CLAUDE_PLUGIN_ROOT}' is substituted by Claude Code when it loads a plugin spec (the exact spelling only, never as an env var), so a running plugin resolves to its own root first; unsubstituted (other runtimes) it stays a literal that the /* check rejects.
|
|
412
|
-
_k="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_HOME:-}" "$_l" "$_x" "$_v" "$_m")"
|
|
413
|
-
while IFS= read -r _c; do
|
|
414
|
-
case "$_c" in /*) [ -z "$FORGE_ROOT" ] && [ -f "$_c/scripts/verify-phase-trail.sh" ] && [ -f "$_c/scripts/lint-dispatch-prompt.sh" ] && [ -f "$_c/scripts/is-docs-only.sh" ] && [ -f "$_c/bin/engine/resolve.mjs" ] && [ -f "$_c/bin/engine/orchestrate-canary.mjs" ] && [ -f "$_c/bin/engine/admission.mjs" ] && FORGE_ROOT="$_c" ;; esac
|
|
415
|
-
done <<< "$_k"
|
|
416
|
-
fi
|
|
380
|
+
# FORGE_ROOT: shared resolver scripts/lib/forge-resolve.sh (empty when unresolved => callers fail closed)
|
|
381
|
+
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
382
|
+
FORGE_ROOT="$(forge_root 2>/dev/null || true)"
|
|
417
383
|
# Import ForgeDock's OWN admission.mjs from FORGE_ROOT, never from the consumer {REPO_PATH}.
|
|
418
384
|
# Unresolved/missing => no plan: every candidate stays an ungrouped singleton (fail closed, loudly).
|
|
419
385
|
BATCH_PLAN=""
|