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.
Files changed (39) hide show
  1. package/bin/engine/phases.mjs +69 -15
  2. package/bin/engine/runlog.mjs +21 -3
  3. package/bin/engine.mjs +43 -7
  4. package/bin/runner.mjs +21 -2
  5. package/commands/forge-stats.md +2 -2
  6. package/commands/orchestrate/phase-1-resolve.md +6 -40
  7. package/commands/orchestrate/phase-4-execution.md +26 -162
  8. package/commands/quality-gate.md +12 -80
  9. package/commands/review-pr-staging.md +67 -301
  10. package/commands/review-pr.md +150 -752
  11. package/commands/work-on/build/validate.md +6 -40
  12. package/commands/work-on/build.md +82 -40
  13. package/commands/work-on/close.md +7 -22
  14. package/commands/work-on/decompose.md +8 -24
  15. package/commands/work-on/investigate.md +18 -29
  16. package/commands/work-on/remediate.md +38 -147
  17. package/commands/work-on/review.md +25 -42
  18. package/commands/work-on.md +13 -63
  19. package/docs/CONFIG.md +10 -7
  20. package/docs/RELEASE-NOTES.md +6 -0
  21. package/docs/WORK-ON-RUNTIME.md +1 -1
  22. package/docs/spec/forge-protocol-v1.md +1 -1
  23. package/package.json +1 -1
  24. package/scripts/check-spec-markers.sh +2 -0
  25. package/scripts/check-spec-size.sh +47 -27
  26. package/scripts/check-spec-size.test.sh +46 -3
  27. package/scripts/diff-size.sh +36 -3
  28. package/scripts/diff-size.test.sh +47 -0
  29. package/scripts/forge-root.test.sh +218 -91
  30. package/scripts/lib/fence-bootstrap.sh +16 -0
  31. package/scripts/lib/forge-resolve.sh +96 -0
  32. package/scripts/lib/review-delta-mode.sh +41 -0
  33. package/scripts/lint-dispatch-prompt.test.sh +3 -3
  34. package/scripts/remediate-liveness.sh +96 -0
  35. package/scripts/review-acceptance-gate.sh +132 -0
  36. package/scripts/review-acceptance-gate.test.sh +90 -0
  37. package/scripts/review-pr-delta-wiring.test.sh +24 -7
  38. package/scripts/review-pr-staging-scope.test.sh +14 -7
  39. package/scripts/spec-no-worktree-script.test.sh +1 -1
@@ -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
- if ((verdict ?? marker) === "YES") return true;
554
- // forge#3573 safety net: an investigator NO whose own size estimate is clearly over
555
- // the gate (estimate > threshold x factor) fails toward decompose. Own-line,
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
- if (est && gate) {
564
- const estimate = Number(est[1]);
565
- const threshold = Number(gate[1]);
566
- if (threshold > 0 && estimate > threshold * factor) {
567
- console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
568
- return true;
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
- const legacyArgs = () => [pr, "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
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 hasTrajectory(state.issue, io), terminalReason: "investigation" };
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 hasTrajectory(state.issue, io); } catch { ran = false; }
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" };
@@ -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
- if (lastCommitPhase === "remediate" && lastCommitReason === "needs-human") {
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
- s.lastRateLimit = { phase: e.phase, resetAt: e.resetAt ?? null, waitMs: e.waitMs ?? null };
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 { hostname as osHostname } from "node:os";
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
- const rl = local?.lastRateLimit;
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
- if (pendingPhase && rl && rl.phase === pendingPhase.id &&
497
- typeof rl.resetAt === "number" && Number.isFinite(rl.resetAt) && rl.resetAt > now()) {
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
@@ -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. 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.
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 bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
208
- FORGE_ROOT=""
209
- # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
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 bootstrap (canonical; keep byte-identical across specs, guarded by scripts/forge-root.test.sh)
398
- FORGE_ROOT=""
399
- # Windows drive-letter FORGEDOCK_HOME (C:/x or C:\x) is normalized to /c/x (cygpath when present); relative values stay rejected.
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=""