forgedock 1.16.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.
@@ -227,6 +227,17 @@ async function hasTrajectory(issue, io) {
227
227
  return comments.some((b) => startsWithHeader(b, "FORGE:TRAJECTORY"));
228
228
  }
229
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
+
230
241
  /**
231
242
  * Trusted comment bodies, chronological — the same single fetch + trust predicate
232
243
  * as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
@@ -529,6 +540,8 @@ async function reviewArgs(state, ctx, io) {
529
540
 
530
541
  /** Default `build.diff_size.estimate_factor` (docs/CONFIG.md); used when the report carries no valid `ESTIMATE_FACTOR:` field. */
531
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;
532
545
 
533
546
  /**
534
547
  * Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
@@ -550,23 +563,41 @@ function isDecomposed(report) {
550
563
  console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
551
564
  return false;
552
565
  }
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.
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.
558
569
  const est = /^ESTIMATED_DIFF_LINES:[ \t]*(\d+)[ \t]*$/m.exec(text);
559
570
  const gate = /^GATE_THRESHOLD:[ \t]*(\d+)[ \t]*$/m.exec(text);
560
571
  const fac = /^ESTIMATE_FACTOR:[ \t]*(\d+(?:\.\d+)?)[ \t]*$/m.exec(text);
572
+ const seamsM = /^INDEPENDENT_SEAMS:[ \t]*(\d+)[ \t]*$/m.exec(text);
561
573
  const parsedFactor = fac ? Number(fac[1]) : NaN;
562
574
  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;
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
+ }
569
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;
570
601
  }
571
602
  return false;
572
603
  }
@@ -968,8 +999,11 @@ export const PHASES = [
968
999
  // forge#3534: the implement child closes an investigation issue itself, so CLOSED is no
969
1000
  // proof close.md ran (board, summary, trajectory). Its own idempotency key is the
970
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.
971
1005
  try {
972
- return { satisfied: await hasTrajectory(state.issue, io), terminalReason: "investigation" };
1006
+ return { satisfied: await hasInvestigationCloseEvidence(state.issue, io), terminalReason: "investigation" };
973
1007
  } catch { return { satisfied: false }; }
974
1008
  }
975
1009
  if (snap.state === "CLOSED") return { satisfied: true };
@@ -1006,7 +1040,7 @@ export const PHASES = [
1006
1040
  // The issue is always already CLOSED for an investigation run, so require the same
1007
1041
  // positive evidence as reconcile (a trusted FORGE:TRAJECTORY); fail closed on error.
1008
1042
  let ran = false;
1009
- try { ran = await hasTrajectory(state.issue, io); } catch { ran = false; }
1043
+ try { ran = await hasInvestigationCloseEvidence(state.issue, io); } catch { ran = false; }
1010
1044
  return ran
1011
1045
  ? { status: "committed", terminalReason: "investigation", outputs: {} }
1012
1046
  : { 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,
@@ -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
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; 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=""