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.
@@ -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
- if ((verdict ?? marker) === "YES") return true;
545
- // forge#3573 safety net: an investigator NO whose own size estimate is clearly over
546
- // the gate (estimate > threshold x factor) fails toward decompose. Own-line,
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
- if (est && gate) {
555
- const estimate = Number(est[1]);
556
- const threshold = Number(gate[1]);
557
- if (threshold > 0 && estimate > threshold * factor) {
558
- console.error(`[engine] investigator estimate ${estimate} exceeds gate ${threshold} x ${factor}; routing to decompose`);
559
- return true;
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
- const { comments } = await issueMarkers(state.issue, io);
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
- return { status: "committed", terminalReason: state.terminalReason === "investigation" ? "investigation" : "invalid", outputs: {} };
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") {
@@ -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
- 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 };
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 { 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,
@@ -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
@@ -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 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; 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; 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=""