forgedock 1.15.0 → 1.16.1
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 +66 -15
- package/bin/engine/runlog.mjs +29 -2
- package/bin/engine.mjs +35 -4
- package/bin/runner.mjs +21 -2
- package/commands/forge-stats.md +10 -0
- 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 +79 -303
- package/commands/review-pr.md +150 -752
- package/commands/work-on/build/validate.md +6 -40
- package/commands/work-on/build.md +34 -24
- package/commands/work-on/close.md +6 -21
- package/commands/work-on/decompose.md +5 -22
- package/commands/work-on/investigate.md +10 -24
- package/commands/work-on/remediate.md +25 -144
- package/commands/work-on/review.md +7 -41
- package/commands/work-on.md +10 -61
- package/docs/CONFIG.md +6 -5
- package/docs/RELEASE-NOTES.md +6 -0
- package/package.json +1 -1
- package/scripts/check-spec-size.sh +122 -0
- package/scripts/check-spec-size.test.sh +128 -0
- package/scripts/forge-root.test.sh +210 -91
- package/scripts/lib/fence-bootstrap.sh +16 -0
- package/scripts/lib/forge-resolve.sh +87 -0
- package/scripts/lib/review-delta-mode.sh +41 -0
- package/scripts/lint-dispatch-prompt.test.sh +3 -3
- package/scripts/review-pr-delta-wiring.test.sh +24 -7
- package/scripts/review-pr-staging-scope.test.sh +41 -6
- package/scripts/spec-no-worktree-script.test.sh +1 -1
package/bin/engine/phases.mjs
CHANGED
|
@@ -218,6 +218,26 @@ function hasPhaseComplete(comments) {
|
|
|
218
218
|
return hasMarker(comments, "FORGE:PHASE:COMPLETE", "FORGE:PHASE:COMPLETE");
|
|
219
219
|
}
|
|
220
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
|
+
|
|
230
|
+
/**
|
|
231
|
+
* forge#3772: investigation close evidence, shared by close.reconcile and close.detectOutcome.
|
|
232
|
+
* A trusted FORGE:TRAJECTORY, or a trusted current-cycle FORGE:PHASE:COMPLETE (multi-phase body).
|
|
233
|
+
* Throws on fetch error; callers fail closed.
|
|
234
|
+
*/
|
|
235
|
+
async function hasInvestigationCloseEvidence(issue, io) {
|
|
236
|
+
const { comments } = await issueMarkers(issue, io);
|
|
237
|
+
return comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY"))
|
|
238
|
+
|| hasPhaseComplete(sliceToCurrentCycle(comments));
|
|
239
|
+
}
|
|
240
|
+
|
|
221
241
|
/**
|
|
222
242
|
* Trusted comment bodies, chronological — the same single fetch + trust predicate
|
|
223
243
|
* as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
|
|
@@ -520,6 +540,8 @@ async function reviewArgs(state, ctx, io) {
|
|
|
520
540
|
|
|
521
541
|
/** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
|
|
522
542
|
const DEFAULT_ESTIMATE_FACTOR = 1.2;
|
|
543
|
+
/** Default `build.diff_size.batch_seam_min` (docs/CONFIG.md); the engine cannot read forge.yaml here. */
|
|
544
|
+
const DEFAULT_BATCH_SEAM_MIN = 3;
|
|
523
545
|
|
|
524
546
|
/**
|
|
525
547
|
* Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
|
|
@@ -541,23 +563,41 @@ function isDecomposed(report) {
|
|
|
541
563
|
console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
|
|
542
564
|
return false;
|
|
543
565
|
}
|
|
544
|
-
|
|
545
|
-
//
|
|
546
|
-
//
|
|
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.
|
|
566
|
+
// forge#3573 safety net inputs: own-line, whole-number fields only; absent/0/non-numeric
|
|
567
|
+
// fields have no effect. The factor is the investigator's effective `ESTIMATE_FACTOR:`
|
|
568
|
+
// (positive decimal); absent/invalid -> 1.2.
|
|
549
569
|
const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
550
570
|
const gate = /^GATE_THRESHOLD:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
551
571
|
const fac = /^ESTIMATE_FACTOR:[ \t]*(\d+(?:\.\d+)?)[ \t]*$/m.exec(text);
|
|
572
|
+
const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
552
573
|
const parsedFactor = fac ? Number(fac[1]) : NaN;
|
|
553
574
|
const factor = Number.isFinite(parsedFactor) && parsedFactor > 0 ? parsedFactor : DEFAULT_ESTIMATE_FACTOR;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
575
|
+
const sized = est && gate && Number(gate[1]) > 0;
|
|
576
|
+
const estimate = sized ? Number(est[1]) : 0;
|
|
577
|
+
const threshold = sized ? Number(gate[1]) : 0;
|
|
578
|
+
if ((verdict ?? marker) === "YES") {
|
|
579
|
+
// forge#3786: a YES the numbers do not require is downgraded to NO unless the report
|
|
580
|
+
// carries an own-line `DECOMPOSE_JUSTIFICATION:` line. Required = estimate over
|
|
581
|
+
// threshold x factor, or the seam clause (seams >= batch_seam_min and estimate over
|
|
582
|
+
// threshold / 2). The engine cannot see the `batch` label or the configured
|
|
583
|
+
// `batch_seam_min`, so it uses the default 3 and stays lenient for batches. Absent
|
|
584
|
+
// numeric fields keep the YES (no silent downgrade of a malformed report).
|
|
585
|
+
if (sized) {
|
|
586
|
+
const seams = seamsM ? Number(seamsM[1]) : 0;
|
|
587
|
+
const required = estimate > threshold * factor || (seams >= DEFAULT_BATCH_SEAM_MIN && estimate > threshold / 2);
|
|
588
|
+
const justified = /^DECOMPOSE_JUSTIFICATION:[ \t]*\S/m.test(text);
|
|
589
|
+
if (!required && !justified) {
|
|
590
|
+
console.error(`[engine] unsupported DECOMPOSE:YES (estimate ${estimate}, seams ${seams}, gate ${threshold} x ${factor}, no DECOMPOSE_JUSTIFICATION); treating as not decomposed`);
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
560
593
|
}
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
// forge#3573 safety net: an investigator NO whose own size estimate is clearly over
|
|
597
|
+
// the gate (estimate > threshold x factor) fails toward decompose.
|
|
598
|
+
if (sized && estimate > threshold * factor) {
|
|
599
|
+
console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
|
|
600
|
+
return true;
|
|
561
601
|
}
|
|
562
602
|
return false;
|
|
563
603
|
}
|
|
@@ -959,9 +999,11 @@ export const PHASES = [
|
|
|
959
999
|
// forge#3534: the implement child closes an investigation issue itself, so CLOSED is no
|
|
960
1000
|
// proof close.md ran (board, summary, trajectory). Its own idempotency key is the
|
|
961
1001
|
// FORGE:TRAJECTORY comment; only that makes this satisfied.
|
|
1002
|
+
// forge#3772: investigation exits are normally single-phase (close.md C1 skips the
|
|
1003
|
+
// multi-phase guard for them); a trusted current-cycle PHASE:COMPLETE is accepted as a
|
|
1004
|
+
// safety net so a multi-phase body cannot make every retry fail.
|
|
962
1005
|
try {
|
|
963
|
-
|
|
964
|
-
return { satisfied: comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY")), terminalReason: "investigation" };
|
|
1006
|
+
return { satisfied: await hasInvestigationCloseEvidence(state.issue, io), terminalReason: "investigation" };
|
|
965
1007
|
} catch { return { satisfied: false }; }
|
|
966
1008
|
}
|
|
967
1009
|
if (snap.state === "CLOSED") return { satisfied: true };
|
|
@@ -992,8 +1034,17 @@ export const PHASES = [
|
|
|
992
1034
|
if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
|
|
993
1035
|
return { status: "committed", terminalReason: "merged", outputs: {} };
|
|
994
1036
|
// forge#3534: an investigation exit closes the issue itself; keep that reason honest.
|
|
995
|
-
if (snap.state === "CLOSED")
|
|
996
|
-
|
|
1037
|
+
if (snap.state === "CLOSED") {
|
|
1038
|
+
if (state.terminalReason !== "investigation")
|
|
1039
|
+
return { status: "committed", terminalReason: "invalid", outputs: {} };
|
|
1040
|
+
// The issue is always already CLOSED for an investigation run, so require the same
|
|
1041
|
+
// positive evidence as reconcile (a trusted FORGE:TRAJECTORY); fail closed on error.
|
|
1042
|
+
let ran = false;
|
|
1043
|
+
try { ran = await hasInvestigationCloseEvidence(state.issue, io); } catch { ran = false; }
|
|
1044
|
+
return ran
|
|
1045
|
+
? { status: "committed", terminalReason: "investigation", outputs: {} }
|
|
1046
|
+
: { status: "failed", detail: "investigation close: no FORGE:TRAJECTORY" };
|
|
1047
|
+
}
|
|
997
1048
|
// forge#3545: PHASE_COMPLETE is a valid close exit (multi-phase issue: PR merged,
|
|
998
1049
|
// issue deliberately left OPEN). Only the current cycle's marker counts.
|
|
999
1050
|
if (snap.state === "OPEN") {
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -61,6 +61,11 @@ 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;
|
|
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;
|
|
64
69
|
for (const e of events) {
|
|
65
70
|
switch (e.event) {
|
|
66
71
|
case "RUN_START":
|
|
@@ -75,6 +80,7 @@ export function deriveState(events) {
|
|
|
75
80
|
// remediate instead of defaulting to a "merged" close.
|
|
76
81
|
if (e.terminalReason) s.terminalReason = e.terminalReason;
|
|
77
82
|
lastCommitReason = e.terminalReason ?? null;
|
|
83
|
+
lastCommitPhase = e.phase ?? null;
|
|
78
84
|
// forge#3530: the review's remediation kind must survive resume. A later review
|
|
79
85
|
// commit replaces it; a committed remediate consumes it, so it is never reused.
|
|
80
86
|
if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
|
|
@@ -82,6 +88,7 @@ export function deriveState(events) {
|
|
|
82
88
|
break;
|
|
83
89
|
case "RUN_TERMINAL":
|
|
84
90
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
91
|
+
lastTerminalReason = s.terminalReason;
|
|
85
92
|
break;
|
|
86
93
|
// forge#3511: an explicit `run-issue --retry` reopens an engine-error
|
|
87
94
|
// terminal. Bumping `v` makes the local state newer than the stale
|
|
@@ -91,6 +98,18 @@ export function deriveState(events) {
|
|
|
91
98
|
// retry re-enters remediate/decompose instead of close/build.
|
|
92
99
|
case "RUN_REOPEN":
|
|
93
100
|
s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
|
|
101
|
+
// forge#3754: a remediate that ended needs-human is re-run after a human clears the
|
|
102
|
+
// label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
|
|
103
|
+
// does not re-terminate and remediate's entryCondition re-selects it.
|
|
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") {
|
|
109
|
+
s.committed = s.committed.filter((p) => p !== "remediate");
|
|
110
|
+
}
|
|
111
|
+
lastTerminalReason = null;
|
|
112
|
+
s.lastRateLimit = null; // forge#3791: an explicit reopen never re-enters a stale pause
|
|
94
113
|
break;
|
|
95
114
|
// forge#3562: an explicit `run-issue --retry` on a `phase-complete` run
|
|
96
115
|
// (multi-phase issue: this phase merged, more remain) starts a NEW cycle.
|
|
@@ -102,7 +121,8 @@ export function deriveState(events) {
|
|
|
102
121
|
case "RUN_NEXT_CYCLE":
|
|
103
122
|
s.terminal = false; s.terminalReason = null; s.committed = [];
|
|
104
123
|
s.branch = null; s.pr = null; s.remediationKind = null;
|
|
105
|
-
lastCommitReason = null; s.v = e.seq;
|
|
124
|
+
lastCommitReason = null; lastCommitPhase = null; lastTerminalReason = null; s.v = e.seq;
|
|
125
|
+
s.lastRateLimit = null; // forge#3791
|
|
106
126
|
break;
|
|
107
127
|
// forge#2524: a session-limit pause is purely informational — it does
|
|
108
128
|
// NOT touch committed/terminal/terminalReason. The phase that hit the
|
|
@@ -111,9 +131,16 @@ export function deriveState(events) {
|
|
|
111
131
|
// run correctly re-enters pickPhase at the same phase with no special
|
|
112
132
|
// casing needed here beyond recording the diagnostic below.
|
|
113
133
|
case "PHASE_RATE_LIMITED":
|
|
114
|
-
|
|
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 };
|
|
115
136
|
s.v = e.seq;
|
|
116
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;
|
|
117
144
|
// PHASE_START / PHASE_FAILED do not change committed state
|
|
118
145
|
}
|
|
119
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,
|
|
@@ -514,6 +542,8 @@ export async function runIssue(opts) {
|
|
|
514
542
|
// must honour that reason instead of picking the next phase (close would throw in buildArgs
|
|
515
543
|
// and end as engine-error; build would run on a decomposed parent). Review's `needs-human`
|
|
516
544
|
// is a handoff to remediate, so only these committed phases trigger the guard.
|
|
545
|
+
// forge#3754: a reopened (--retry) remediate-terminal run has `remediate` un-committed by the
|
|
546
|
+
// run-log reducer, so this guard does not fire and remediate re-runs.
|
|
517
547
|
if (state.terminalReason && TERMINAL_REASONS.includes(state.terminalReason) &&
|
|
518
548
|
((state.committed.includes("remediate") && !["merged", "decomposed", "invalid"].includes(state.terminalReason)) ||
|
|
519
549
|
(state.committed.includes("decompose") && state.terminalReason === "decomposed") ||
|
|
@@ -694,6 +724,7 @@ export async function runIssue(opts) {
|
|
|
694
724
|
appendEvent(dir, issue, {
|
|
695
725
|
event: "PHASE_RATE_LIMITED", phase: phase.id,
|
|
696
726
|
resetAt: hasResetEpoch ? resetAtMs : null, resetAtDisplay: e.resetAt || null, waitMs,
|
|
727
|
+
backendId: backendIdentity({ backend, env: process.env }),
|
|
697
728
|
});
|
|
698
729
|
// forge#2524 AC6: progress observer sees the pause so a caller
|
|
699
730
|
// (bin/engine-cli.mjs) can render the wait state instead of the
|
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
|
@@ -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. 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
|
+
|
|
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.
|
|
@@ -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
|
-
FORGE_ROOT
|
|
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; 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
|
-
FORGE_ROOT
|
|
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; 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=""
|