forgedock 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/forgedock.mjs CHANGED
@@ -59,8 +59,8 @@ import {
59
59
  detectClaudeVersion,
60
60
  loadBreakpoints,
61
61
  hasFeature,
62
- getPersistedHomeState,
63
62
  setPersistedHomeState,
63
+ getInstalledCommandsDriftStatus,
64
64
  } from "./registry.mjs";
65
65
  import { renderMark, ember } from "./cinema.mjs";
66
66
  import {
@@ -2740,27 +2740,16 @@ async function doctor(fix = false) {
2740
2740
  `incomplete (missing: ${missingDirs.join(", ")}). Run: npx forgedock install`,
2741
2741
  );
2742
2742
  } else {
2743
- // Prefer the version registry.mjs last recorded for this exact
2744
- // write (getPersistedHomeState()) — the single source of truth
2745
- // update()/install also write through — falling back to reading
2746
- // ~/.forge/version directly on disk when the registry has no entry
2747
- // (e.g. an older ForgeDock version wrote ~/.forge/ before #1943
2748
- // added registry tracking). Reading both keeps doctor() honest
2749
- // even if the registry ever drifts from what's actually on disk.
2750
- const recorded = getPersistedHomeState();
2751
- let persistedVersion = recorded?.version || "";
2752
- if (!persistedVersion) {
2753
- try {
2754
- persistedVersion = readFileSync(versionPath, "utf-8").trim();
2755
- } catch {
2756
- // version file missing/unreadable — reported below as "unknown"
2757
- }
2758
- }
2759
- const sourceVersion = getVersion();
2760
- if (persistedVersion && sourceVersion && compareVersions(persistedVersion, sourceVersion) < 0) {
2743
+ // Package-vs-installed-commands drift comparison is factored into
2744
+ // a single shared helper (bin/registry.mjs's
2745
+ // getInstalledCommandsDriftStatus, forge#2719) so doctor, the
2746
+ // `version` command, and the SessionStart hook all agree on one
2747
+ // source of truth instead of re-deriving this logic independently.
2748
+ const { persistedVersion, isStale, unknown } = getInstalledCommandsDriftStatus(FORGE_HOME);
2749
+ if (!unknown && isStale) {
2761
2750
  warn(
2762
2751
  "Persisted toolset home (~/.forge)",
2763
- `v${persistedVersion} — source is v${sourceVersion}. Run: npx forgedock update`,
2752
+ `v${persistedVersion} — source is v${getVersion()}. Run: npx forgedock update`,
2764
2753
  );
2765
2754
  } else {
2766
2755
  pass("Persisted toolset home (~/.forge)", `v${persistedVersion || "unknown"} at ${persistedHome}`);
@@ -3959,6 +3948,18 @@ switch (command) {
3959
3948
  `Run ${CYAN}npx forgedock update${RESET} to fetch it.`,
3960
3949
  );
3961
3950
  }
3951
+
3952
+ // forge#2719 — surface package-vs-installed-commands drift (previously
3953
+ // only visible via `doctor` Check 5d). Uses the same shared helper doctor
3954
+ // calls, so both surfaces agree. Silent when versions match or when
3955
+ // either version is unknown (e.g. a git-clone dev install, which never
3956
+ // persists to ~/.forge — see registry.mjs's getInstalledCommandsDriftStatus).
3957
+ const driftStatus = getInstalledCommandsDriftStatus(FORGE_HOME);
3958
+ if (!driftStatus.unknown && driftStatus.isStale) {
3959
+ console.log(
3960
+ ` ${YELLOW}Installed commands are stale (v${driftStatus.persistedVersion}) vs package v${driftStatus.sourceVersion}${RESET} — run: ${CYAN}npx forgedock update${RESET}`,
3961
+ );
3962
+ }
3962
3963
  break;
3963
3964
  }
3964
3965
  case "help":
@@ -51,6 +51,8 @@ const FORGE_HOME = resolve(__dirname, "..", "..");
51
51
  let resolveState;
52
52
  let nudgeSeen;
53
53
  let markNudgeSeen;
54
+ // Assigned alongside the other registry.mjs helpers below — see forge#2719.
55
+ let getInstalledCommandsDriftStatus;
54
56
 
55
57
  // ---------------------------------------------------------------------------
56
58
  // forge-utils helpers — populated inside the try block to honour fail-open
@@ -88,7 +90,7 @@ try {
88
90
  // that import() rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME.
89
91
  /** @type {import('../registry.mjs')} */
90
92
  (
91
- { resolveState, nudgeSeen, markNudgeSeen } = await import(
93
+ { resolveState, nudgeSeen, markNudgeSeen, getInstalledCommandsDriftStatus } = await import(
92
94
  pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
93
95
  )
94
96
  );
@@ -126,6 +128,25 @@ try {
126
128
  const cwd = process.cwd();
127
129
  const state = resolveState(cwd);
128
130
 
131
+ // Surface package-vs-installed-commands drift as a single non-fatal
132
+ // advisory line (forge#2719). Uses the same shared helper doctor's
133
+ // Check 5d and the `version` command call, so all three surfaces agree.
134
+ // Best-effort: any failure here must never block the session, and
135
+ // managed-optedout directories stay completely silent per this hook's
136
+ // existing contract for that state (see the switch below).
137
+ if (state !== "managed-optedout") {
138
+ try {
139
+ const driftStatus = getInstalledCommandsDriftStatus(FORGE_HOME);
140
+ if (!driftStatus.unknown && driftStatus.isStale) {
141
+ console.log(
142
+ `ForgeDock: installed commands are stale (v${driftStatus.persistedVersion}) vs package v${driftStatus.sourceVersion} — run: npx forgedock update`,
143
+ );
144
+ }
145
+ } catch {
146
+ // Fail open — never let a drift-check error affect session start.
147
+ }
148
+ }
149
+
129
150
  switch (state) {
130
151
  case "managed-active":
131
152
  await handleManagedActive(cwd);
package/bin/registry.mjs CHANGED
@@ -465,6 +465,92 @@ export async function setPersistedHomeState({ path, version, updatedAt } = {}) {
465
465
  });
466
466
  }
467
467
 
468
+ // ---------------------------------------------------------------------------
469
+ // Package-vs-installed-commands drift detection (issue #2719)
470
+ // ---------------------------------------------------------------------------
471
+ //
472
+ // Single shared source of truth for "is the persisted ~/.forge/ toolset
473
+ // (commands/skills the SessionStart hook and ~/.claude/commands actually
474
+ // load) older than the forgedock package currently running?" — previously
475
+ // this comparison existed only inline inside doctor's Check 5d
476
+ // (bin/forgedock.mjs), so a user who bumped the npm package but never ran
477
+ // `forgedock update` had no way to learn about the drift outside of
478
+ // proactively running `doctor`. This helper is now the one place the
479
+ // comparison lives; doctor, the `version` command, and the SessionStart hook
480
+ // all call it instead of re-deriving the logic (forge#1589 split-brain
481
+ // precedent — see the persistHome() comment above for why that matters).
482
+
483
+ /**
484
+ * Compare the running forgedock package's version against the version last
485
+ * persisted to `~/.forge/` (the copy the SessionStart hook and
486
+ * `~/.claude/commands` actually load from — see persistHome() in
487
+ * bin/journey.mjs).
488
+ *
489
+ * Fail-open: never throws. Any failure to read either version (missing/
490
+ * corrupt package.json, missing/corrupt registry, missing/unreadable
491
+ * `~/.forge/version` file) resolves to `unknown: true` with empty version
492
+ * strings, rather than propagating an error to the caller. This mirrors the
493
+ * fail-open contract required of both doctor (best-effort diagnostics) and
494
+ * the SessionStart hook (must never block a Claude Code session — see
495
+ * forge#383/forge#489).
496
+ *
497
+ * Version comparison delegates to the already-fixed compareVersions() above
498
+ * (forge#1932 — do not reimplement comparison logic here).
499
+ *
500
+ * @param {string} forgeHome - Absolute path to the ForgeDock installation
501
+ * root whose package.json holds the "source" version (the package
502
+ * currently running). Callers pass their own FORGE_HOME.
503
+ * @returns {{
504
+ * persistedVersion: string,
505
+ * sourceVersion: string,
506
+ * isStale: boolean,
507
+ * unknown: boolean
508
+ * }}
509
+ */
510
+ export function getInstalledCommandsDriftStatus(forgeHome) {
511
+ const notFound = { persistedVersion: "", sourceVersion: "", isStale: false, unknown: true };
512
+
513
+ let sourceVersion = "";
514
+ try {
515
+ const pkg = JSON.parse(readFileSync(join(forgeHome, "package.json"), "utf-8"));
516
+ sourceVersion = pkg.version || "";
517
+ } catch {
518
+ // Missing/unreadable/corrupt package.json — cannot determine the source
519
+ // version at all; degrade to unknown rather than guessing.
520
+ return notFound;
521
+ }
522
+
523
+ // Prefer the version registry.mjs last recorded for persistHome()'s write
524
+ // (the single source of truth update()/install also write through),
525
+ // falling back to reading ~/.forge/version directly on disk when the
526
+ // registry has no entry (e.g. an older ForgeDock version wrote ~/.forge/
527
+ // before forge#1943 added registry tracking). Mirrors doctor's Check 5d.
528
+ let persistedVersion = "";
529
+ try {
530
+ const recorded = getPersistedHomeState();
531
+ persistedVersion = recorded?.version || "";
532
+ } catch {
533
+ // getPersistedHomeState() already fails open to null — this catch is
534
+ // defensive only.
535
+ }
536
+ if (!persistedVersion) {
537
+ try {
538
+ const versionPath = join(HOME, ".forge", "version");
539
+ persistedVersion = readFileSync(versionPath, "utf-8").trim();
540
+ } catch {
541
+ // version file missing/unreadable — no persisted-home version is
542
+ // knowable; degrade to unknown.
543
+ }
544
+ }
545
+
546
+ if (!persistedVersion) {
547
+ return notFound;
548
+ }
549
+
550
+ const isStale = compareVersions(persistedVersion, sourceVersion) < 0;
551
+ return { persistedVersion, sourceVersion, isStale, unknown: false };
552
+ }
553
+
468
554
  // ---------------------------------------------------------------------------
469
555
  // Claude Code version detection and breakpoints resolution (issue #1252)
470
556
  // ---------------------------------------------------------------------------
package/bin/runner.mjs CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  rmSync,
48
48
  realpathSync,
49
49
  } from "fs";
50
- import { join, dirname, basename, relative, isAbsolute } from "path";
50
+ import { join, dirname, basename, relative, isAbsolute, extname } from "path";
51
51
  import os from "os";
52
52
  import { execSync, spawnSync } from "child_process";
53
53
  import { parseForgeYaml, resolveModelAlias } from "./forge-utils.mjs";
@@ -143,11 +143,23 @@ export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
143
143
  // evict the oldest (insertion-order, via `Map`'s iteration order and a
144
144
  // delete+re-set on refresh) so a process that probes an unbounded
145
145
  // number of distinct `cwd` values cannot grow this cache without limit.
146
- // Each entry stores `{ available, cachedAt }` instead of a bare boolean so
147
- // `isClaudeCliAvailable()` can evaluate the TTL.
146
+ // Each entry stores `{ available, cliPath, cachedAt }` instead of a bare
147
+ // boolean so `isClaudeCliAvailable()` can evaluate the TTL, and so the
148
+ // *same* resolved absolute executable path the probe validated can be
149
+ // reused by `runCliBackend()`'s production invocation (issue #2741) — see
150
+ // `resolveClaudeCliBinary()` below. `cliPath` is `null` when unavailable or
151
+ // when the probe succeeded but no path-like line could be parsed out of it
152
+ // (e.g. an injected test `execImpl` that returns a bare version string).
148
153
  const CLI_AVAILABILITY_CACHE_TTL_MS = 60_000;
149
154
  const CLI_AVAILABILITY_CACHE_MAX_SIZE = 100;
150
155
  const cliAvailabilityCache = new Map();
156
+ // Windows file extensions `spawnSync(shell:false)` can actually launch:
157
+ // native executables (`.exe`, `.com`) directly, and `.cmd`/`.bat` via Node's
158
+ // own built-in special-cased re-exec through cmd.exe (see the comment on
159
+ // `runCliBackend()` below). An extensionless file — e.g. the POSIX shell
160
+ // shim `npm`/`npx` also drop alongside these on Windows — cannot be
161
+ // launched by `CreateProcess` at all, with or without a full absolute path.
162
+ const WINDOWS_SPAWNABLE_EXTENSIONS = new Set([".exe", ".cmd", ".bat", ".com"]);
151
163
  // Cap tool-result payloads so a large file read or verbose command does not
152
164
  // blow the context window in a single turn.
153
165
  const MAX_TOOL_RESULT_CHARS = 100_000;
@@ -203,21 +215,110 @@ export function resolveConfiguredDefaultModel(cwd) {
203
215
  // Backend selection (issue #2003) — CLI vs API execution backend
204
216
  // ---------------------------------------------------------------------------
205
217
 
218
+ /**
219
+ * Split the combined probe command's stdout (issue #2741 — see
220
+ * `isClaudeCliAvailable()`) into the leading "path-like" lines (from the
221
+ * `where`/`command -v` half of the command) versus everything after them
222
+ * (the `claude --version` output). A path-like line is one containing a
223
+ * path separator (`/` or `\`); `claude --version` output (e.g.
224
+ * `"1.2.3 (Claude Code)"`) never does, so the first non-path-like line
225
+ * marks the boundary.
226
+ *
227
+ * Pure/side-effect-free so it can be unit tested directly against
228
+ * synthetic multi-line `where` output without spawning anything.
229
+ *
230
+ * @param {string} raw
231
+ * @returns {string[]} Leading path-like lines, in original order.
232
+ */
233
+ function parseProbeOutput(raw) {
234
+ const lines = String(raw ?? "")
235
+ .split(/\r?\n/)
236
+ .map((line) => line.trim())
237
+ .filter(Boolean);
238
+ const pathLines = [];
239
+ for (const line of lines) {
240
+ if (line.includes("/") || line.includes("\\")) {
241
+ pathLines.push(line);
242
+ } else {
243
+ break;
244
+ }
245
+ }
246
+ return pathLines;
247
+ }
248
+
249
+ /**
250
+ * Pick the best resolved `claude` executable path out of the candidate
251
+ * path-like lines produced by `parseProbeOutput()` (issue #2741).
252
+ *
253
+ * On Windows, prefers a candidate with a recognized spawnable extension
254
+ * (`WINDOWS_SPAWNABLE_EXTENSIONS`) over an extensionless one — `where`
255
+ * matches bare filenames as well as PATHEXT-suffixed ones, and an
256
+ * npx-transient shim directory can contain both an extensionless POSIX
257
+ * shell script and a `.cmd`/`.ps1` sibling generated by npm's cross-platform
258
+ * shim generator. If every candidate is extensionless, checks the same
259
+ * directory for a `.cmd`/`.exe`/`.bat`/`.com` sibling with the same base
260
+ * name before giving up and returning the bare match as-is.
261
+ *
262
+ * On POSIX, `command -v` returns a single resolvable path directly usable
263
+ * by `spawnSync(shell:false)` (POSIX `execve` honors the shebang line), so
264
+ * no extension preference is needed.
265
+ *
266
+ * @param {string[]} pathLines
267
+ * @returns {string|null}
268
+ */
269
+ function selectResolvedCliPath(pathLines) {
270
+ if (pathLines.length === 0) return null;
271
+ if (process.platform !== "win32") return pathLines[0];
272
+
273
+ const withExt = pathLines.find((line) =>
274
+ WINDOWS_SPAWNABLE_EXTENSIONS.has(extname(line).toLowerCase()),
275
+ );
276
+ if (withExt) return withExt;
277
+
278
+ const bare = pathLines[0];
279
+ const dir = dirname(bare);
280
+ const base = basename(bare);
281
+ for (const ext of [".cmd", ".exe", ".bat", ".com"]) {
282
+ const candidate = join(dir, `${base}${ext}`);
283
+ if (existsSync(candidate)) return candidate;
284
+ }
285
+ return bare;
286
+ }
287
+
206
288
  /**
207
289
  * Probe whether the Claude Code CLI (`claude`) is present and responds on
208
290
  * this machine, so `forgedock run` can drive it directly instead of
209
291
  * requiring a separate, billable ANTHROPIC_API_KEY.
210
292
  *
211
- * Deliberately uses `execSync("claude --version", ...)` — a single command
212
- * *string* executed through a shell — rather than
213
- * `execFileSync("claude", [...])`. On Windows, `claude` is installed as a
214
- * `claude.cmd` shim; `execFileSync` does not resolve `.cmd` shims and throws
215
- * ENOENT even though the CLI is genuinely installed and on PATH (this exact
216
- * regression previously made an unrelated skill-based enrichment backend
217
- * silently dead on Windows — see issue #382). `execSync` always runs its
218
- * command through a shell, which resolves PATHEXT-registered `.cmd`/`.bat`
219
- * shims correctly on Windows and behaves identically on POSIX. This mirrors
220
- * the pre-existing `claude --version` doctor check in bin/forgedock.mjs.
293
+ * Deliberately runs a single command *string* through a shell (via
294
+ * `execSync`) rather than `execFileSync("claude", [...])` or a no-shell
295
+ * `spawnSync`. On Windows, `claude` may be installed as a `claude.cmd`
296
+ * shim, or present only as an npx-transient shim (an extensionless script
297
+ * in a `_npx` cache directory, reachable only via the shell-augmented
298
+ * PATH); `execFileSync`/no-shell `spawnSync` do not resolve either case the
299
+ * same way a shell does and throw ENOENT even though the CLI is genuinely
300
+ * runnable (see issue #382 for the `.cmd`-shim regression this originally
301
+ * guarded against, and issue #2741 for the npx-shim case). `execSync`
302
+ * always runs its command through a shell, which resolves both cases
303
+ * correctly on Windows and behaves identically on POSIX.
304
+ *
305
+ * issue #2741 — probe/invocation asymmetry fix: this probe now does double
306
+ * duty. In a single `execImpl` call (so per-cwd caching below still pays
307
+ * the probe cost exactly once — existing tests assert this), it both
308
+ * verifies `claude --version` responds AND captures the shell-resolved
309
+ * absolute path (`where claude` on Windows / `command -v claude` on POSIX)
310
+ * that made that resolution possible. That resolved path is cached
311
+ * alongside `available` and exposed via `resolveClaudeCliBinary()` below,
312
+ * so `runCliBackend()`'s production call site can spawn the *exact* binary
313
+ * this probe validated — with `shell:false` — instead of a bare `"claude"`
314
+ * name that the no-shell OS exec-path search may fail to resolve the same
315
+ * way. The two commands are chained with `&&` (path-resolution failure
316
+ * short-circuits the whole probe as "unavailable") rather than run
317
+ * independently: an environment where `claude` only resolves as an
318
+ * unlocatable shell alias/function (no path lookup, `--version` succeeds
319
+ * anyway) was never actually spawnable via `spawnSync` in the first place,
320
+ * so treating that as "unavailable" here is consistent with what
321
+ * `runCliBackend()` can actually do with it.
221
322
  *
222
323
  * Bounded by CLI_PROBE_TIMEOUT_MS so a hung or misbehaving `claude` binary
223
324
  * can never make backend resolution (and therefore --dry-run, or the start
@@ -249,15 +350,18 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
249
350
  if (cached && Date.now() - cached.cachedAt < CLI_AVAILABILITY_CACHE_TTL_MS) {
250
351
  return cached.available;
251
352
  }
353
+ const resolveCmd = process.platform === "win32" ? "where claude" : "command -v claude";
252
354
  let available;
355
+ let cliPath = null;
253
356
  try {
254
- execImpl("claude --version", {
357
+ const raw = execImpl(`${resolveCmd} && claude --version`, {
255
358
  cwd,
256
359
  stdio: ["ignore", "pipe", "ignore"],
257
360
  encoding: "utf-8",
258
361
  timeout: CLI_PROBE_TIMEOUT_MS,
259
362
  });
260
363
  available = true;
364
+ cliPath = selectResolvedCliPath(parseProbeOutput(raw));
261
365
  } catch {
262
366
  available = false;
263
367
  }
@@ -266,7 +370,7 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
266
370
  // entry — whether "oldest" means "never refreshed" or "not refreshed in
267
371
  // the longest time."
268
372
  cliAvailabilityCache.delete(cwd);
269
- cliAvailabilityCache.set(cwd, { available, cachedAt: Date.now() });
373
+ cliAvailabilityCache.set(cwd, { available, cliPath, cachedAt: Date.now() });
270
374
  if (cliAvailabilityCache.size > CLI_AVAILABILITY_CACHE_MAX_SIZE) {
271
375
  const oldestKey = cliAvailabilityCache.keys().next().value;
272
376
  cliAvailabilityCache.delete(oldestKey);
@@ -274,6 +378,28 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
274
378
  return available;
275
379
  }
276
380
 
381
+ /**
382
+ * Return the exact absolute `claude` executable path that
383
+ * `isClaudeCliAvailable()` validated for `cwd` (issue #2741), ensuring the
384
+ * probe/cache is populated or refreshed first. This is what closes the
385
+ * probe/invocation asymmetry: `runCommand()`'s production call site passes
386
+ * this resolved path as `runCliBackend()`'s `bin` argument instead of the
387
+ * bare `"claude"` default, so the invocation spawns the identical binary
388
+ * the probe already confirmed responds — with `shell:false` preserved
389
+ * throughout (see the SECURITY note on `runCliBackend()` below).
390
+ *
391
+ * @param {string} [cwd] - Working directory for the probe (default cwd).
392
+ * @param {object} [opts] - Forwarded to `isClaudeCliAvailable()` (e.g.
393
+ * `execImpl` test seam).
394
+ * @returns {string|null} Absolute resolved path, or `null` when the CLI is
395
+ * unavailable or no path-like line could be parsed from the probe output
396
+ * (falls back to the bare `"claude"` name at the call site in that case).
397
+ */
398
+ export function resolveClaudeCliBinary(cwd = process.cwd(), opts = {}) {
399
+ isClaudeCliAvailable(cwd, opts);
400
+ return cliAvailabilityCache.get(cwd)?.cliPath ?? null;
401
+ }
402
+
277
403
  /**
278
404
  * Shared backend-resolution primitive (issue #2026) used by both
279
405
  * `resolveBackend()` (below, `forgedock run`'s engine ladder — issue #2003)
@@ -386,19 +512,32 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
386
512
  * arbitrary shell commands with this process's privileges). Do not
387
513
  * reintroduce `shell: true` or string-command interpolation here.
388
514
  *
389
- * Windows `.cmd` shim resolution works WITHOUT `shell: true`: Node's
390
- * spawn/spawnSync resolve a bare `"claude"` command via PATH+PATHEXT and
391
- * safely re-invoke through cmd.exe internally when the resolved target is a
515
+ * Windows `.cmd`/`.bat` shim resolution works WITHOUT `shell: true` — BUT
516
+ * only when `bin` is already a resolvable target: Node's spawn/spawnSync
517
+ * safely re-invoke through cmd.exe internally when the *given* target is a
392
518
  * `.cmd`/`.bat` file, using a properly escaped mechanism, and argv elements
393
519
  * are never parsed as shell syntax (verified empirically on Windows: a
394
520
  * malicious-looking argv element is delivered to the child process
395
521
  * byte-for-byte, not executed). This is the correct fix for the
396
522
  * `execFileSync("claude", [...])` ENOENT-on-Windows regression from issue
397
- * #382 — that regression was about USING execFileSync with an unresolved
398
- * bare command name in an older/incompatible way, not about needing
399
- * `shell: true`; spawnSync's own PATH/PATHEXT resolution (unlike
400
- * execFileSync's, in the failure mode #382 hit) handles the `.cmd` shim
401
- * transparently here.
523
+ * #382 for a `claude.cmd` sitting directly on a real PATH entry.
524
+ *
525
+ * CORRECTED (issue #2741): the claim that a bare `"claude"` name resolves
526
+ * via "spawnSync's own PATH/PATHEXT resolution" was overconfident and
527
+ * false for an npx-transient shim (an extensionless script living in a
528
+ * `_npx` cache directory rather than a stable PATH entry with a registered
529
+ * PATHEXT extension) — `spawnSync(shell:false)` resolves a bare command
530
+ * name via the OS's own exec-path search, which does not carry the same
531
+ * shell/PATHEXT semantics `isClaudeCliAvailable()`'s shell-based probe
532
+ * uses, and ENOENTs on that shim even though the probe reports the CLI
533
+ * available. The fix is NOT `shell: true` (would reopen the injection hole
534
+ * above); it is resolving the *exact* absolute path the probe validated
535
+ * once, via `resolveClaudeCliBinary()`, and passing that resolved path as
536
+ * `bin` — `spawnSync(shell:false)` launches an already-fully-resolved
537
+ * absolute path (native `.exe`, or `.cmd`/`.bat` via the special-cased
538
+ * re-exec described above) without needing any further PATH/PATHEXT
539
+ * resolution of its own. See `isClaudeCliAvailable()`/
540
+ * `resolveClaudeCliBinary()` above for how that resolution happens.
402
541
  *
403
542
  * `--dangerously-skip-permissions` mirrors the established headless-CI
404
543
  * invocation pattern already documented in docs/CI.md and
@@ -454,14 +593,19 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
454
593
  * @param {string[]} [opts.args]
455
594
  * @param {string} opts.cwd
456
595
  * @param {{log: Function, error?: Function}} [opts.logger]
457
- * @param {string} [opts.bin] - Executable to invoke (default "claude"). Test
458
- * seam only — production callers must never override this; it exists so
459
- * tests can point at a controlled fake binary instead of either invoking
460
- * the real `claude` CLI (slow, non-deterministic, real side effects) or
461
- * fighting platform-specific PATH-resolution semantics to shim "claude"
462
- * itself (Windows resolves a bare executable name via the *calling*
463
- * process's search path at the OS level, which is not reliably
464
- * overridable per-call from within the same process).
596
+ * @param {string} [opts.bin] - Executable to invoke (default "claude").
597
+ * UPDATED (issue #2741): the real production caller (`runCommand()`
598
+ * below) now deliberately passes `resolveClaudeCliBinary(cwd) ?? "claude"`
599
+ * here — a pre-resolved absolute path, not the bare default — to close
600
+ * the probe/invocation asymmetry described on `isClaudeCliAvailable()`
601
+ * above. The bare `"claude"` default remains purely a fallback for that
602
+ * production call site (when resolution genuinely finds nothing) and a
603
+ * test seam: tests still point this at a controlled fake binary instead
604
+ * of either invoking the real `claude` CLI (slow, non-deterministic, real
605
+ * side effects) or fighting platform-specific PATH-resolution semantics
606
+ * to shim "claude" itself (Windows resolves a bare executable name via
607
+ * the *calling* process's search path at the OS level, which is not
608
+ * reliably overridable per-call from within the same process).
465
609
  * @param {Function} [opts.spawnFn] - Injectable replacement for
466
610
  * `child_process.spawnSync` (default `spawnSync`). Test seam only —
467
611
  * production callers must never override this. Mirrors the identical
@@ -825,8 +969,12 @@ export function runCliBackend({
825
969
  delete childEnv.ANTHROPIC_API_KEY;
826
970
 
827
971
  // No `shell` option (defaults to false): argv is passed as discrete,
828
- // unparsed elements — see the SECURITY note above. This also correctly
829
- // resolves the Windows `.cmd` shim without needing shell:true.
972
+ // unparsed elements — see the SECURITY note above. `bin` is expected to
973
+ // already be a fully-resolved absolute path (see `resolveClaudeCliBinary()`
974
+ // and the `opts.bin` doc comment above, issue #2741) — spawnSync launches
975
+ // an already-resolved `.exe`/`.cmd`/`.bat` path directly (with Node's
976
+ // built-in special-cased `.cmd`/`.bat` re-exec) without needing any
977
+ // further shell/PATHEXT resolution of its own.
830
978
  const result = spawnFn(bin, cliArgs, {
831
979
  cwd,
832
980
  encoding: "utf-8",
@@ -1896,7 +2044,22 @@ export async function runCommand(opts = {}) {
1896
2044
  }.`,
1897
2045
  );
1898
2046
  }
1899
- return runCliBackend({ spec, userMessage, systemPrompt: cliSystemPrompt, args, cwd, logger });
2047
+ // Pass the exact absolute path isClaudeCliAvailable() already validated
2048
+ // for this cwd (issue #2741) — resolveClaudeCliBinary() reuses that same
2049
+ // per-cwd cache, so this does not trigger a second probe. Falls back to
2050
+ // the bare "claude" name only if resolution genuinely found nothing
2051
+ // (e.g. an injected-execImpl test scenario with no path-like output),
2052
+ // preserving prior behavior in that edge case.
2053
+ const resolvedBin = resolveClaudeCliBinary(cwd) ?? "claude";
2054
+ return runCliBackend({
2055
+ spec,
2056
+ userMessage,
2057
+ systemPrompt: cliSystemPrompt,
2058
+ args,
2059
+ cwd,
2060
+ logger,
2061
+ bin: resolvedBin,
2062
+ });
1900
2063
  }
1901
2064
 
1902
2065
  if (!apiKey) {
@@ -1061,7 +1061,12 @@ The engine drives every phase transition deterministically, mirrors state to the
1061
1061
 
1062
1062
  **Why engine-first**: The engine's phase table enforces gate semantics in code — not via LLM interpretation of markdown specs. This eliminates the class of bug where the LLM assumes a review approved when the FORGE:REVIEW comment is absent or unreadable (issue #1714). Interactive sessions that run `/work-on` via Skill invocations additionally bridge to the engine run-log via the SubagentStop hook (`bin/hooks/interactive-engine.mjs`) — state is durable across compaction and context resets.
1063
1063
 
1064
- **Fallback (no forgedock CLI)**: If `forgedock` is not in PATH, fall back to spawning Agent sub-agents that run `Skill("work-on", ...)` per issue. The SubagentStop hook still bridges these runs to the engine run-log for state persistence.
1064
+ **Fallback — best-effort, not all-or-nothing (fixed forge#2743)**: Engine-first is guaranteed to have an Agent-spawn fallback under two distinct trigger conditions, not just CLI absence:
1065
+
1066
+ 1. **CLI absent at dispatch time**: If `forgedock` is not in PATH, `phase-4-execution.md` Step 4A falls back to spawning Agent sub-agents that run `Skill("work-on", ...)` per issue for the whole batch, before any dispatch happens.
1067
+ 2. **Backend unavailable despite CLI presence, or a runtime engine-error with empty committed state**: `command -v forgedock` only proves the orchestrator CLI is installed — it says nothing about whether the engine's execution backend (the `claude` CLI spawn) can actually run a phase (forge#2741 is a concrete case: `spawnSync claude` ENOENTs even though a shell `command -v` probe reports the binary present). Step 4A now runs a cheap backend preflight canary before committing the whole ready set to engine-first, downgrading the entire run to Agent-spawn on failure. And per-issue, Step 4B's completion handler auto-falls-back any individual engine-dispatched issue that completes at `workflow:engine-error` with an empty committed state (`committed=[] branch=null pr=null` — see `bin/engine-cli.mjs`'s `formatTerminalDiagnostics()`) to the same Agent-spawn template, rather than leaving it stuck waiting on a resume mechanism that only exists for Agent-spawn-dispatched issues. An engine-error with partial committed state (branch/PR non-null) is NOT auto-fallen-back, to avoid double-work — it surfaces via the existing stall-detection alert instead.
1068
+
1069
+ In both cases the SubagentStop hook still bridges the fallback Agent-spawn runs to the engine run-log for state persistence. Engine-first is therefore best-effort: it is always backed by a working Agent-spawn path, whether the gap is discovered before dispatch (canary) or after (per-issue fallback) — a whole ready set is never permanently lost to an environmental engine failure.
1065
1070
 
1066
1071
  ### Concurrency model: in-process worker pool + worktree-per-issue
1067
1072
 
@@ -528,6 +528,32 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
528
528
  # Uses SORTED_READY_SET[] from Step 3E.5 (descending value/cost) and budget gate from Step 4A-pre.0
529
529
  FORGEDOCK_AVAILABLE=$(command -v forgedock >/dev/null 2>&1 && echo "true" || echo "false")
530
530
 
531
+ # Backend preflight canary (forge#2743): `command -v forgedock` only proves the orchestrator CLI
532
+ # binary is on PATH — it says nothing about whether the engine's execution backend (the `claude`
533
+ # CLI spawn, or a working `ANTHROPIC_API_KEY`) can actually run a phase. Without this probe, an
534
+ # environmental failure (e.g. forge#2741 — `spawnSync claude` ENOENT despite a shell `command -v`
535
+ # probe reporting the binary present) commits the ENTIRE ready set to engine-first dispatch, every
536
+ # `forgedock run-issue` call fails within seconds, and nothing re-routes the batch — a human has
537
+ # to notice and manually re-dispatch via the Agent-spawn path below.
538
+ #
539
+ # CRITICAL — do NOT use a shell-resolved probe here (`command -v claude`, `claude --version` run
540
+ # directly by bash, `which claude`, etc.). forge#2741's entire root cause is that a shell-resolved
541
+ # probe and the engine's actual invocation resolve the `claude` binary differently: bash's builtin
542
+ # PATH lookup (and `execSync("claude --version", ...)`, used deliberately by
543
+ # `bin/runner.mjs`'s `isClaudeCliAvailable()` for this exact reason) can find and run an
544
+ # npx-transient/extensionless shim that `bin/runner.mjs`'s actual backend call —
545
+ # `runCliBackend()`'s `spawnFn(bin, cliArgs, { shell: false, ... })` with bare `bin = "claude"`
546
+ # (no shell resolution) — cannot. A canary that shell-resolves `claude` reproduces the exact
547
+ # blind spot it exists to catch and reports the backend healthy in precisely forge#2741's failure
548
+ # scenario. The canary MUST reproduce `runCliBackend()`'s exact invocation shape — bare binary
549
+ # name, `shell: false` — via a one-off Node `spawnSync` call, not a bash-native probe:
550
+ if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
551
+ if ! node -e "const r = require('node:child_process').spawnSync('claude', ['--version'], { shell: false }); process.exit(r.error ? 1 : 0);" 2>/dev/null; then
552
+ echo "WARNING: forgedock CLI is on PATH, but its execution backend is not invocable via the engine's own spawn shape (bare 'claude', shell:false) in this environment. Downgrading to the Agent-spawn fallback path for this entire run — see forge#2743. (If forge#2741's ENOENT root cause is fixed, this canary will pass again on the next /orchestrate invocation.)"
553
+ FORGEDOCK_AVAILABLE="false"
554
+ fi
555
+ fi
556
+
531
557
  if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
532
558
  # Build the budget-gated dispatch queue first (forge#1743's should_dispatch() still applies
533
559
  # per-issue, independent of the concurrency cap below).
@@ -849,7 +875,7 @@ If `DISPATCH_NOW` is empty (headroom is 0), do not spawn any agents this cycle
849
875
 
850
876
  You will be automatically notified when each background agent completes — or, for an engine-first dispatch (Step 4A, `FORGEDOCK_AVAILABLE=true`), when a backgrounded `Bash(run_in_background=true, command="forgedock run-issue ...")` call completes. Both are the same underlying "background task finished" notification the harness delivers; treat them identically for the purposes of this step. **Do NOT use `sleep` loops to poll for completion.** Instead, wait for the automatic notification. When one arrives, look up which issue it belongs to — `AGENT_ISSUE_MAP` for an `agent_completed` notification, `ENGINE_DISPATCH_MAP` (fixed forge#2466) for a background-Bash notification from the engine-first path — and immediately process it exactly the same way regardless of which map resolved it; every check below (`classify_predecessor_state()`, dependent dispatch, stall/staging checks) keys off GitHub labels/state, not which dispatch mechanism produced them. **Fallback**: if a runtime ever fails to deliver a background-Bash completion notification, poll `gh issue view --json labels` for the in-flight engine-dispatched issue's workflow label instead of blocking — the same degrade-gracefully behavior already used when `BACKGROUND_DISPATCH_ENABLED=false` for the Agent-spawn path (see `phase-3-dependency.md` → Background Dispatch Mode).
851
877
 
852
- **Concurrency slot release (MANDATORY — first action on every completion)** <!-- Added: forge#1912 -->: The instant a completion notification arrives — `agent_completed`, or a background-Bash completion for an engine-dispatched issue — decrement `ACTIVE_DISPATCH_COUNT` by 1 — a worker slot has just freed, regardless of what terminal state the issue ended up in. Do this before any stall-recovery/resume logic below. If that agent is then resumed (item 2 below, or Step 4B.5's stall recovery) because it stalled mid-pipeline rather than truly finishing, re-increment `ACTIVE_DISPATCH_COUNT` when the `Agent(resume=...)` call is issued — a resume re-occupies a worker slot exactly like a fresh dispatch does. (Step 4B.5's stall-resume mechanism is `Agent(resume=...)`-specific and is unchanged by this fix — an engine-dispatched issue that stalls is not currently auto-resumed by Step 4B.5; it surfaces the same way any other stalled issue does, via the standard stall-detection alert, and `forgedock resume-stalled` remains available as a separate manual/scripted recovery path outside this file's scope.)
878
+ **Concurrency slot release (MANDATORY — first action on every completion)** <!-- Added: forge#1912 -->: The instant a completion notification arrives — `agent_completed`, or a background-Bash completion for an engine-dispatched issue — decrement `ACTIVE_DISPATCH_COUNT` by 1 — a worker slot has just freed, regardless of what terminal state the issue ended up in. Do this before any stall-recovery/resume logic below. If that agent is then resumed or fallback-dispatched (item 2 below, or Step 4B.5's stall recovery) because it stalled mid-pipeline rather than truly finishing, re-increment `ACTIVE_DISPATCH_COUNT` when the `Agent(resume=...)` (or, per item 2b below, the fresh `Agent(...)` fallback dispatch) call is issued — either re-occupies a worker slot exactly like a fresh dispatch does. (Step 4B.5's TIME-BASED hang detector — an agent that never completes at all, as opposed to one that completes at `workflow:engine-error` — remains `Agent(resume=...)`-specific and out of scope for this fix: an engine-dispatched issue that silently hangs still surfaces only via the standard stall-detection alert, and `forgedock resume-stalled` remains available as a separate manual/scripted recovery path for that case. The completion-triggered case — an engine-dispatched issue that DOES complete, at `workflow:engine-error` with empty committed state — is now auto-fallen-back to Agent-spawn by item 2b below, fixed forge#2743.)
853
879
 
854
880
  **Ordering requirement (MANDATORY — prevents transient over-cap)**: For every agent that completed in this notification batch, finish its terminal-state check and, if applicable, its resume re-increment (items 1-2 below) BEFORE computing `dispatch_headroom` for item 5's newly-ready-issue dispatch. Computing headroom before a to-be-resumed agent's re-increment would let a genuinely-still-running agent's freed slot be double-booked — once by a fresh dispatch, once by the resume itself — transiently exceeding `MAX_CONCURRENT`. Process every completed agent's items 1-2 to a decision (terminal vs. resumed) first; only then compute headroom once for the batch's item 5 dispatch.
855
881
 
@@ -888,6 +914,10 @@ classify_predecessor_state() {
888
914
  # predecessor were still mid-pipeline — which is accurate, since the
889
915
  # stall-detection step below (item 2) auto-resumes/retries a completed
890
916
  # agent run that ends in workflow:engine-error, same as any other stall.
917
+ # Fixed forge#2743: item 2's sub-case 2b is what actually fulfills this for
918
+ # ENGINE_DISPATCH_MAP-tracked issues (fresh Agent-spawn fallback on empty
919
+ # committed state) — 2a's Agent(resume=...) alone cannot reach them, since
920
+ # they were never in AGENT_ISSUE_MAP.
891
921
  echo "IN_PROGRESS"
892
922
  elif [ "$PRED_STATE" = "CLOSED" ]; then
893
923
  # Closed with no workflow:invalid/merged label (e.g. closed-not-planned) — treat as DONE,
@@ -1086,7 +1116,11 @@ done
1086
1116
  echo "#{NUM}: $FINAL_STATE"
1087
1117
  ```
1088
1118
 
1089
- 2. **If the issue is NOT in a terminal-for-this-agent state** (`workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`), the agent stalled mid-pipeline. **Resume it immediately**. `workflow:engine-error` (forge#2261 — `bin/engine.mjs`'s headless engine terminating on an engine/tool-level failure rather than a genuine human-judgment block) is deliberately excluded from the terminal-for-this-agent set: a completed agent run ending there is treated exactly like any other stall and auto-resumed/retried here, up to the resume-cap in item 3 below — no human decision is pending, so there is nothing to gate on:
1119
+ 2. **If the issue is NOT in a terminal-for-this-agent state** (`workflow:merged`, `workflow:invalid`, `needs-human`, or `workflow:awaiting-merge`), the agent stalled mid-pipeline. **Resume it immediately**. `workflow:engine-error` (forge#2261 — `bin/engine.mjs`'s headless engine terminating on an engine/tool-level failure rather than a genuine human-judgment block) is deliberately excluded from the terminal-for-this-agent set: a completed agent run ending there is treated exactly like any other stall and auto-resumed/retried here, up to the resume-cap in item 3 below — no human decision is pending, so there is nothing to gate on.
1120
+
1121
+ **Two distinct resume mechanisms, dispatched by which map resolved the completion (fixed forge#2743)** — `AGENT_ISSUE_MAP`-resolved completions (Agent-spawn path) and `ENGINE_DISPATCH_MAP`-resolved completions (engine-first path) hit `workflow:engine-error` for structurally different reasons and need different recovery:
1122
+
1123
+ **2a. Agent-spawn-dispatched issue (`AGENT_ISSUE_MAP[{NUMBER}]` set)** — unchanged from prior behavior. Resume the same agent in place:
1090
1124
  ```
1091
1125
  Agent(
1092
1126
  resume=AGENT_ISSUE_MAP[{NUMBER}],
@@ -1095,7 +1129,83 @@ done
1095
1129
  prompt="The previous /work-on invocation stopped before completing the full pipeline. The issue is currently at {CURRENT_WORKFLOW_STATE}. Continue — invoke Skill(skill='work-on', args='{NUMBER} --under-orchestration') to resume the routing loop from the current state. /work-on will re-read GitHub state and pick up where it left off."
1096
1130
  )
1097
1131
  ```
1098
- **Resume ALL stalled agents in a single message** (parallel resume). Do not wait between resumes. Each resume re-occupies a worker slot — increment `ACTIVE_DISPATCH_COUNT` by 1 per agent resumed here (it was already decremented by the "Concurrency slot release" rule above when the stall was first observed as a completion). <!-- Added: forge#1912 -->
1132
+
1133
+ **2b. Engine-first-dispatched issue (`ENGINE_DISPATCH_MAP[{NUMBER}]` set, no `AGENT_ISSUE_MAP` entry)** — `Agent(resume=...)` has nothing to resume here; the completed task was a backgrounded `Bash(command="forgedock run-issue ...")` call, not an `Agent()`. Re-issuing the identical `forgedock run-issue` command would just reproduce the same environmental failure. Instead, fall back to the Agent-spawn path for this ONE issue — but only when it is safe to do so:
1134
+ ```bash
1135
+ # Idempotency guard: only fall back once per issue per engine-error occurrence.
1136
+ ALREADY_FALLEN_BACK=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
1137
+ --jq '[.[] | select(.body | contains("FORGE:ENGINE_FALLBACK"))] | length' 2>/dev/null || echo "0")
1138
+
1139
+ # Safety gate: only fall back when the engine run committed NOTHING — no branch, no PR, no
1140
+ # committed phases. Primary source: the `FORGE:STATE` HTML-comment block the engine already
1141
+ # mirrors onto the issue BODY on every phase transition (`bin/engine/state.mjs`/`projector.mjs`
1142
+ # — see phase-3-dependency.md "Engine mode (default)"), which carries `committed`/`branch`/`pr`
1143
+ # as real JSON — read it directly rather than screen-scraping terminal text:
1144
+ # NOTE (forge#2750 review fix): `bin/engine/state.mjs`'s `serializeState()` writes the block
1145
+ # as THREE separate lines — `<!-- FORGE:STATE`, the JSON payload, and `-->` — never all on one
1146
+ # line. A single-line `grep -oP '(?<=<!-- FORGE:STATE)[\s\S]*?(?=-->)'` NEVER matches this <!-- allowlist:portability — cited non-portable anti-pattern being replaced, not a command this spec runs -->
1147
+ # (GNU `grep -P` without `-z` operates per-line, not across newlines, regardless of `[\s\S]`)
1148
+ # — it always returns empty. Extract the block with a `sed` line-range instead, which handles
1149
+ # the real multi-line shape correctly:
1150
+ STATE_BLOCK=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body' \
1151
+ | sed -n '/<!-- FORGE:STATE/,/-->/p')
1152
+ STATE_JSON=$(echo "$STATE_BLOCK" | sed '1d;$d')
1153
+ if [ -n "$STATE_JSON" ]; then
1154
+ COMMITTED_COUNT=$(echo "$STATE_JSON" | jq -r '.committed | length' 2>/dev/null)
1155
+ STATE_BRANCH=$(echo "$STATE_JSON" | jq -r '.branch' 2>/dev/null)
1156
+ STATE_PR=$(echo "$STATE_JSON" | jq -r '.pr' 2>/dev/null)
1157
+ [ "$COMMITTED_COUNT" = "0" ] && [ "$STATE_BRANCH" = "null" ] && [ "$STATE_PR" = "null" ] \
1158
+ && EMPTY_COMMITTED_STATE="true" || EMPTY_COMMITTED_STATE="false"
1159
+ else
1160
+ # No FORGE:STATE block was ever written to the issue body (e.g. the run failed before
1161
+ # phase 1 committed anything). There is no reliable structured signal available in this
1162
+ # case — fail SAFE: do NOT auto-fallback on an unverifiable state. This surfaces via the
1163
+ # existing stall-detection alert / needs-human path instead, exactly as before this fix.
1164
+ # (An earlier draft of this fix attempted a second fallback source — a
1165
+ # `committed=[] branch=null pr=null` diagnostic line assumed to be present in the
1166
+ # completed background Bash call's captured stdout — but no code in this file or
1167
+ # phase-3-dependency.md actually captures/populates that stdout into a named variable,
1168
+ # so that fallback was dead code reading an unset variable. Removed rather than wired up:
1169
+ # the FORGE:STATE block above is the engine's own durable, already-documented mirror of
1170
+ # this exact data and should always be present by the time a phase transition occurs;
1171
+ # treating its absence as "cannot verify, don't risk it" is the correct fail-safe.)
1172
+ EMPTY_COMMITTED_STATE="false"
1173
+ fi
1174
+ # A non-empty committed/branch/pr means real per-issue work exists that a fresh Agent-spawn
1175
+ # dispatch could collide with or duplicate — that case is NOT auto-fallen-back; it surfaces
1176
+ # via the existing stall-detection alert / needs-human path instead, exactly as before this fix.
1177
+
1178
+ if [ "$ALREADY_FALLEN_BACK" -eq 0 ] && [ "$EMPTY_COMMITTED_STATE" = "true" ]; then
1179
+ ENGINE_FALLBACK_BODY="<!-- FORGE:ENGINE_FALLBACK -->
1180
+ ## Engine-First Dispatch Failed — Falling Back to Agent-Spawn
1181
+
1182
+ \`forgedock run-issue\` ended at \`workflow:engine-error\` with no committed phases, branch, or PR
1183
+ (\`committed=[] branch=null pr=null\`) — this is an environmental/tool failure (forge#2261), not a
1184
+ per-issue content failure, and nothing was committed that a fresh dispatch could collide with.
1185
+ Auto-falling back to the Agent-spawn \`/work-on\` path for this issue. See forge#2743."
1186
+ gh issue comment {NUMBER} {GH_FLAG} --body "$ENGINE_FALLBACK_BODY" # <!-- allowlist:check-command-side-effects — intentional pipeline status annotation, mirrors work-on/build/architect.md:700 -->
1187
+
1188
+ # Reuse the SAME Agent-spawn template as Step 4A's "Agent-spawn path (fallback when forgedock
1189
+ # CLI unavailable)" section above (the `Agent(subagent_type="general-purpose", ...)` block
1190
+ # under "Copy this template. Fill in variables. Do not modify the structure:") verbatim —
1191
+ # HARD RULE 1 preserved. This is a fresh dispatch, not a resume: capture its returned agent
1192
+ # ID into AGENT_ISSUE_MAP so subsequent completions for this issue route through 2a above.
1193
+ Agent(
1194
+ subagent_type="general-purpose",
1195
+ model="{SUBAGENT_MODEL}",
1196
+ description="Work on {PROJECT_PREFIX}#{NUMBER} (engine-error fallback)",
1197
+ run_in_background=true,
1198
+ prompt="<same template body as Step 4A's Agent-spawn path — see the 'Agent-spawn path (fallback when forgedock CLI unavailable)' section above; fill {NUMBER}/{GH_REPO}/{REPO_PATH}/{LANE}/{PR_BASE}/{ISSUE_TITLE} exactly as that template does>"
1199
+ )
1200
+ AGENT_ISSUE_MAP[{NUMBER}] = <agent_id returned by the Agent() call above>
1201
+ # Re-occupies the worker slot this completion just freed (see "Concurrency slot release" above).
1202
+ ACTIVE_DISPATCH_COUNT=$((ACTIVE_DISPATCH_COUNT + 1))
1203
+ elif [ "$ALREADY_FALLEN_BACK" -eq 0 ]; then
1204
+ echo "#{NUMBER}: engine-error with non-empty committed state (partial work exists) — NOT auto-falling back. Surfaces via standard stall-detection alert; forgedock resume-stalled remains available for manual/scripted recovery."
1205
+ fi
1206
+ ```
1207
+
1208
+ **Resume ALL stalled agents in a single message** (parallel resume/fallback across 2a and 2b together). Do not wait between resumes. Each resume or fallback dispatch re-occupies a worker slot — increment `ACTIVE_DISPATCH_COUNT` by 1 per agent resumed/dispatched here (it was already decremented by the "Concurrency slot release" rule above when the stall was first observed as a completion). <!-- Added: forge#1912 -->
1099
1209
 
1100
1210
  3. **Track resume cycles per agent.** If an agent has been resumed 2+ times and still hasn't reached a terminal state, report it as a failure — do not resume again.
1101
1211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Autonomous dev pipeline for Claude Code. GitHub as institutional memory — every issue, PR, and annotation survives context resets and makes the next agent smarter.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -102,6 +102,7 @@ MARKER_REGISTRY="
102
102
  DESIGN
103
103
  DISPATCHER
104
104
  DOSSIER_UPDATED
105
+ ENGINE_FALLBACK
105
106
  FAST_PATH
106
107
  FINDING_SOURCE
107
108
  FIX_CI