forgedock 1.1.11 → 1.3.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.
Files changed (109) hide show
  1. package/README.md +9 -3
  2. package/bin/cli-spawn-shared.mjs +25 -0
  3. package/bin/engine/phases.mjs +178 -28
  4. package/bin/engine/state.mjs +39 -7
  5. package/bin/engine-cli.mjs +85 -3
  6. package/bin/engine.mjs +71 -8
  7. package/bin/forgedock.mjs +322 -20
  8. package/bin/hooks/pre-tool-use.mjs +345 -10
  9. package/bin/init-enrich-api.mjs +12 -4
  10. package/bin/init-enrich-cli.mjs +328 -0
  11. package/bin/init-enrich.mjs +137 -0
  12. package/bin/journey.mjs +116 -15
  13. package/bin/runner.mjs +653 -10
  14. package/commands/adopt.md +1 -1
  15. package/commands/analytics.md +10 -7
  16. package/commands/audit-agents.md +29 -6
  17. package/commands/audit.md +9 -20
  18. package/commands/autopilot.md +52 -78
  19. package/commands/changelog.md +1 -1
  20. package/commands/ci-audit.md +19 -10
  21. package/commands/cleanup.md +1 -1
  22. package/commands/compat-audit.md +1 -1
  23. package/commands/deploy-info.md +1 -1
  24. package/commands/deploy-pr.md +1 -1
  25. package/commands/design-bench.md +1 -1
  26. package/commands/design-render-critique-loop.md +1 -1
  27. package/commands/diagnose.md +1 -1
  28. package/commands/forge-stats.md +6 -5
  29. package/commands/forgedock-init.md +1 -1
  30. package/commands/geo-audit.md +9 -6
  31. package/commands/incident-response.md +15 -5
  32. package/commands/issue.md +302 -15
  33. package/commands/milestone.md +45 -18
  34. package/commands/optimize.md +1 -1
  35. package/commands/orchestrate/phase-1-resolve.md +20 -5
  36. package/commands/orchestrate/phase-4-execution.md +8 -1
  37. package/commands/orchestrate/phase-5-cleanup.md +43 -2
  38. package/commands/orchestrate/phase-6-report.md +1 -0
  39. package/commands/orchestrate.md +1 -1
  40. package/commands/pipeline-health/phase-4-proposals.md +40 -10
  41. package/commands/pipeline-health/phase-5-report.md +140 -13
  42. package/commands/pipeline-health.md +1 -1
  43. package/commands/pipeline-resume.md +1 -1
  44. package/commands/pipeline-status.md +1 -1
  45. package/commands/qa-sweep.md +27 -4
  46. package/commands/recover-orphans.md +1 -1
  47. package/commands/replay.md +16 -2
  48. package/commands/review-pr-agents/protocols.md +4 -0
  49. package/commands/review-pr-agents/spec-cli.md +179 -0
  50. package/commands/review-pr-agents.md +7 -4
  51. package/commands/review-pr-staging.md +44 -15
  52. package/commands/review-pr.md +122 -28
  53. package/commands/rollback.md +15 -5
  54. package/commands/security-audit.md +14 -11
  55. package/commands/signal-planner.md +32 -12
  56. package/commands/spec-doctor.md +1 -1
  57. package/commands/sync-ecosystem.md +54 -11
  58. package/commands/test-gate.md +49 -11
  59. package/commands/upgrade-deps.md +518 -0
  60. package/commands/validate.md +16 -5
  61. package/commands/work-on/build/architect.md +1 -1
  62. package/commands/work-on/build/context.md +1 -1
  63. package/commands/work-on/build/implement.md +2 -2
  64. package/commands/work-on/build/validate.md +64 -2
  65. package/commands/work-on/build.md +10 -2
  66. package/commands/work-on/close.md +1 -1
  67. package/commands/work-on/decompose.md +35 -25
  68. package/commands/work-on/investigate.md +2 -2
  69. package/commands/work-on/remediate.md +1 -1
  70. package/commands/work-on/review.md +5 -3
  71. package/commands/work-on-monolithic.md +1 -1
  72. package/commands/work-on.md +28 -7
  73. package/docs/AIDER.md +264 -0
  74. package/docs/AUTOPILOT-CRON.md +130 -0
  75. package/docs/CI.md +18 -0
  76. package/docs/CONFIG.md +41 -2
  77. package/docs/CURSOR.md +135 -0
  78. package/docs/OPENCODE.md +312 -0
  79. package/docs/demo.gif +0 -0
  80. package/docs/eval/README.md +4 -4
  81. package/docs/launch/show-hn.md +3 -3
  82. package/docs/site/annotations-explained.md +2 -2
  83. package/docs/site/architecture.md +180 -0
  84. package/docs/site/command-reference.md +38 -0
  85. package/docs/site/configuration.md +243 -0
  86. package/docs/site/for-companies.md +4 -4
  87. package/docs/site/forge-annotation-protocol.md +1 -1
  88. package/docs/site/github-is-the-memory.md +2 -2
  89. package/docs/site/how-it-works.md +1 -1
  90. package/docs/site/public/llms-full.txt +380 -0
  91. package/docs/site/public/llms.txt +35 -0
  92. package/docs/spec/review-protocol.md +6 -0
  93. package/docs/spec-graph-schema.md +3 -3
  94. package/package.json +2 -2
  95. package/packages/protocol/README.md +75 -0
  96. package/packages/protocol/src/card.js +114 -0
  97. package/packages/protocol/src/cli.js +4 -77
  98. package/packages/protocol/src/emit.js +14 -3
  99. package/packages/protocol/src/index.js +2 -1
  100. package/packages/protocol/src/parse.js +17 -8
  101. package/packages/protocol/src/types.js +1 -1
  102. package/packages/protocol/src/validate.js +11 -0
  103. package/scripts/check-command-side-effects.sh +261 -0
  104. package/scripts/check-spec-markers.sh +273 -0
  105. package/scripts/classify-lane.sh +29 -7
  106. package/scripts/forge-run.sh +322 -0
  107. package/templates/cross-repo-trigger.yml +282 -0
  108. package/templates/cursor/forge.mdc +148 -0
  109. package/templates/github-actions/forgedock-autopilot.yml +113 -0
package/bin/forgedock.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
5
  import { fileURLToPath } from "url";
6
- import { dirname, join, relative, resolve } from "path";
6
+ import { dirname, join, relative, resolve, sep } from "path";
7
7
  import { mkdir, lstat, readlink, readdir, unlink, readFile, writeFile } from "fs/promises";
8
8
  import {
9
9
  existsSync,
@@ -13,7 +13,7 @@ import {
13
13
  renameSync,
14
14
  unlinkSync,
15
15
  } from "fs";
16
- import { execSync, execFileSync } from "child_process";
16
+ import { execSync, execFileSync, spawnSync } from "child_process";
17
17
  import { homedir } from "os";
18
18
  import https from "https";
19
19
  import {
@@ -951,6 +951,21 @@ async function statusScreen(c) {
951
951
  c.stdout.write("\n");
952
952
  }
953
953
 
954
+ /**
955
+ * `npx forgedock init` — detect + AI-enrich + review `forge.yaml`.
956
+ *
957
+ * AI enrichment backend selection (bin/init-enrich.mjs `resolveEnrichBackend`,
958
+ * issue #2004): prefers a local, authenticated `claude` CLI when present,
959
+ * otherwise falls back to the Anthropic API when ANTHROPIC_API_KEY is set,
960
+ * otherwise skips enrichment.
961
+ *
962
+ * Env:
963
+ * FORGEDOCK_INIT_BACKEND Override ("cli"|"api"|"none"|"auto"; default
964
+ * "auto") — pins the enrichment backend instead of
965
+ * the auto-detect ladder above (issue #2023).
966
+ * Independent of FORGEDOCK_BACKEND, which controls
967
+ * the separate `forgedock run` engine backend.
968
+ */
954
969
  async function initFlow(c) {
955
970
  const outputPath = join(c.cwd, "forge.yaml");
956
971
  const hasExisting = existsSync(outputPath);
@@ -1460,6 +1475,216 @@ async function printVersionAvailableChangelog(localVersion, latestVersion) {
1460
1475
  }
1461
1476
  }
1462
1477
 
1478
+ /**
1479
+ * Detect whether FORGE_HOME resolves inside npm's global install tree, i.e.
1480
+ * this process is running as a globally-installed `forgedock` package (not
1481
+ * an ephemeral npx/dlx cache extraction). Used by update()'s npm-mode branch
1482
+ * (forge#2133) to decide whether a newer published version can be installed
1483
+ * in place with `npm install -g forgedock@latest`, versus the npx-cache case
1484
+ * where there is nothing durable to reinstall over.
1485
+ *
1486
+ * Resolution: `npm root -g` reports the global node_modules directory; if
1487
+ * FORGE_HOME sits under `{globalRoot}/forgedock`, this is a global install.
1488
+ * Fails closed (returns false) on any error — the pre-existing advisory-only
1489
+ * behavior is always a safe fallback when detection is inconclusive.
1490
+ *
1491
+ * @returns {boolean}
1492
+ */
1493
+ function isGlobalNpmInstall() {
1494
+ try {
1495
+ const globalRoot = execSync("npm root -g", {
1496
+ encoding: "utf-8",
1497
+ stdio: ["pipe", "pipe", "pipe"],
1498
+ timeout: 5000,
1499
+ }).trim();
1500
+ if (!globalRoot) return false;
1501
+ const globalPkgDir = resolve(join(globalRoot, "forgedock"));
1502
+ const home = resolve(FORGE_HOME);
1503
+ return home === globalPkgDir || home.startsWith(globalPkgDir + sep);
1504
+ } catch {
1505
+ return false;
1506
+ }
1507
+ }
1508
+
1509
+ /**
1510
+ * Maximum number of additional self-update re-exec attempts allowed after the
1511
+ * first (i.e. up to MAX_SELF_UPDATE_ATTEMPTS + 1 total `npm install -g`
1512
+ * attempts across the parent process and its re-exec'd children). Guards
1513
+ * against unbounded recursion when `npm install -g` reports success but the
1514
+ * resolved version never actually advances (stale registry mirror/proxy,
1515
+ * cache issue, mismatched FORGE_HOME resolution) — see forge#2158.
1516
+ */
1517
+ const MAX_SELF_UPDATE_ATTEMPTS = 1;
1518
+
1519
+ /**
1520
+ * Strict semver-shape check for a version string before it is interpolated
1521
+ * into a shell-invoked command (see forge#2180). Intentionally narrow —
1522
+ * digits-dot-digits-dot-digits with an optional `-prerelease`/`+build`
1523
+ * suffix restricted to `[0-9A-Za-z.-]` — so it accepts every real value
1524
+ * `fetchLatestVersion()` can return from the npm registry's `version` field,
1525
+ * while rejecting anything containing shell metacharacters
1526
+ * (`; | & $ \` ( ) < > " ' \n` etc.) that `shell: true` would otherwise
1527
+ * hand straight to cmd.exe/`/bin/sh`.
1528
+ *
1529
+ * The regex above is fully anchored (`^...$`) but its quantifiers (`\d+`,
1530
+ * `[0-9A-Za-z.-]+`) are unbounded, so shape validity alone does not bound
1531
+ * string *length* — a pathologically long value could still match. This
1532
+ * is defense-in-depth only (see forge#2195): the character-class
1533
+ * restriction already rules out shell metacharacters at any length, and
1534
+ * `fetchLatestVersion()`'s 65536-byte HTTP response cap already bounds
1535
+ * `version` in practice. The explicit length cap below rejects oversized
1536
+ * input before the regex ever runs, closing the gap outright rather than
1537
+ * relying on an upstream cap this function has no visibility into.
1538
+ *
1539
+ * @param {string} version
1540
+ * @returns {boolean}
1541
+ */
1542
+ function isValidSemverShape(version) {
1543
+ const MAX_VERSION_LENGTH = 100;
1544
+ return (
1545
+ typeof version === "string" &&
1546
+ version.length <= MAX_VERSION_LENGTH &&
1547
+ /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)
1548
+ );
1549
+ }
1550
+
1551
+ /**
1552
+ * Install the newer published version globally via `npm install -g
1553
+ * forgedock@{version}`, then re-exec this same file path (which npm has just
1554
+ * overwritten with the new package's contents) so the rest of update()'s
1555
+ * persist/relink phase runs against the *new* payload instead of the stale
1556
+ * in-memory one (forge#2133 — previously the advisory-only branch would
1557
+ * persist/relink from whatever was already resolved, silently downgrading
1558
+ * a newer persisted ~/.forge/ or leaving the global install stuck on the
1559
+ * old version indefinitely).
1560
+ *
1561
+ * Depth guard (forge#2158): the re-exec'd child process could, in principle,
1562
+ * observe the same "newer version available" condition again (e.g. a stale
1563
+ * registry mirror/proxy reports success without the resolved version ever
1564
+ * advancing) and recurse indefinitely, each cycle blocking up to ~125s. The
1565
+ * attempt count is propagated across the re-exec via the
1566
+ * `FORGEDOCK_SELF_UPDATE_ATTEMPT` environment variable (parent process env is
1567
+ * never mutated — the counter is only passed to the spawned child's own
1568
+ * environment). Once `MAX_SELF_UPDATE_ATTEMPTS` is reached, this function
1569
+ * fails loudly with an actionable message and returns `false` instead of
1570
+ * attempting another install/re-exec cycle.
1571
+ *
1572
+ * Fail-open: any error during install or re-exec falls back to the
1573
+ * pre-existing advisory message — the caller must treat a `false` return as
1574
+ * "could not self-update, printed manual instructions instead."
1575
+ *
1576
+ * Windows note (forge#2180): `npm` is a `.cmd` batch launcher on Windows, and
1577
+ * Node's `execFileSync`/`spawnSync` cannot invoke `.cmd`/`.bat` files without
1578
+ * `shell: true` (nodejs/node#3675) — confirmed empirically on a real Windows
1579
+ * 11 machine: `execFileSync("npm", [...])` ENOENTs, and even the exact
1580
+ * `npm.cmd` filename EINVALs, without a shell. `shell: true` is required
1581
+ * here so the install actually runs on Windows. Because that widens the
1582
+ * command line to shell interpretation, `version` — sourced from an
1583
+ * unauthenticated `https://registry.npmjs.org` response in
1584
+ * `fetchLatestVersion()` — is validated against a strict semver shape
1585
+ * (`isValidSemverShape`) before it is ever interpolated into the command,
1586
+ * closing the injection surface `shell: true` would otherwise open (see the
1587
+ * repo-wide execSync→execFileSync shell-injection hardening precedent in
1588
+ * forge#1703/forge#413).
1589
+ *
1590
+ * @param {string} version - target version to install (e.g. "1.2.0")
1591
+ * @returns {boolean} true if re-exec was launched (process will exit before
1592
+ * returning in the success path); false if self-update failed, was capped
1593
+ * by the depth guard, and the caller should fall back to advisory-only
1594
+ * behavior.
1595
+ */
1596
+ function selfUpdateGlobalInstall(version) {
1597
+ // Math.max(0, ...) clamps out negative values: `Number.parseInt(x, 10) || 0`
1598
+ // only substitutes 0 for NaN/0/"" — a negative numeric string (e.g. a
1599
+ // corrupted or manually-set FORGEDOCK_SELF_UPDATE_ATTEMPT="-1") is truthy
1600
+ // and passes through unclamped, which would let a negative attempt count
1601
+ // stay perpetually below MAX_SELF_UPDATE_ATTEMPTS. Defense-in-depth only —
1602
+ // no known path sets this env var to a negative value today. (forge#2168)
1603
+ const attempt = Math.max(
1604
+ 0,
1605
+ Number.parseInt(process.env.FORGEDOCK_SELF_UPDATE_ATTEMPT, 10) || 0,
1606
+ );
1607
+
1608
+ // `attempt` counts completed installs so far (0 = no installs yet). The
1609
+ // guard must allow MAX_SELF_UPDATE_ATTEMPTS + 1 total installs (see the
1610
+ // doc comment above this function) — i.e. it should only trip once
1611
+ // `attempt` has already reached that total, not one call early. Using
1612
+ // `>=` here tripped after only 1 install instead of the documented 2
1613
+ // (forge#2203 — the retry this guard exists to allow never actually ran).
1614
+ if (attempt > MAX_SELF_UPDATE_ATTEMPTS) {
1615
+ console.log(
1616
+ ` ${YELLOW}Self-update did not converge after ${attempt} attempt(s) — the installed version may not be advancing (stale registry mirror/cache?).${RESET}`,
1617
+ );
1618
+ console.log(
1619
+ ` Run ${CYAN}npm install -g forgedock@latest${RESET} manually, then ${CYAN}npx forgedock update${RESET} again.`,
1620
+ );
1621
+ return false;
1622
+ }
1623
+
1624
+ // Validate before this value ever reaches the shell-invoked install below
1625
+ // (forge#2180) — fail open to the same advisory message a failed install
1626
+ // would print, rather than throwing or passing an unvalidated string to
1627
+ // `shell: true`.
1628
+ if (!isValidSemverShape(version)) {
1629
+ console.log(
1630
+ ` ${YELLOW}Self-update aborted: received an unexpected version string ("${version}").${RESET}`,
1631
+ );
1632
+ console.log(
1633
+ ` Run ${CYAN}npm install -g forgedock@latest${RESET} manually, then ${CYAN}npx forgedock update${RESET} again.`,
1634
+ );
1635
+ return false;
1636
+ }
1637
+
1638
+ try {
1639
+ console.log(` Installing ${CYAN}forgedock@${version}${RESET} globally...`);
1640
+ // shell: true is required to resolve npm.cmd on Windows (see the
1641
+ // Windows note in this function's doc comment, forge#2180). `version`
1642
+ // is validated as strict semver above before this point.
1643
+ execFileSync("npm", ["install", "-g", `forgedock@${version}`], {
1644
+ stdio: "inherit",
1645
+ timeout: 120000,
1646
+ shell: true,
1647
+ });
1648
+ } catch (err) {
1649
+ console.log(
1650
+ ` ${YELLOW}Global self-update failed: ${err && err.message ? err.message : String(err)}${RESET}`,
1651
+ );
1652
+ console.log(
1653
+ ` Run ${CYAN}npm install -g forgedock@latest${RESET} manually, then ${CYAN}npx forgedock update${RESET} again.`,
1654
+ );
1655
+ return false;
1656
+ }
1657
+
1658
+ console.log(
1659
+ ` ${GREEN}Installed v${version}.${RESET} Re-running update to finish persist/relink...`,
1660
+ );
1661
+ try {
1662
+ const result = spawnSync(process.execPath, [__filename, "update"], {
1663
+ stdio: "inherit",
1664
+ env: {
1665
+ ...process.env,
1666
+ FORGEDOCK_SELF_UPDATE_ATTEMPT: String(attempt + 1),
1667
+ },
1668
+ });
1669
+ // `result.signal` is set (non-null) when the re-exec'd child was
1670
+ // terminated by a signal (e.g. SIGTERM/SIGKILL) rather than exiting
1671
+ // cleanly — in that case `result.status` is `null`, and
1672
+ // `result.status ?? 0` would silently collapse a killed child into a
1673
+ // reported "success" (exit 0). Exit non-zero explicitly for the
1674
+ // signal-killed case; the non-signaled path is unchanged. (forge#2159)
1675
+ process.exit(result.signal ? 1 : (result.status ?? 0));
1676
+ // Unreachable — process.exit() above terminates the process. Present
1677
+ // only to satisfy linters expecting a return on every path.
1678
+ return true;
1679
+ } catch (err) {
1680
+ console.log(
1681
+ ` ${YELLOW}Could not re-run update after install: ${err && err.message ? err.message : String(err)}${RESET}`,
1682
+ );
1683
+ console.log(` Run ${CYAN}npx forgedock update${RESET} again manually.`);
1684
+ return false;
1685
+ }
1686
+ }
1687
+
1463
1688
  async function update() {
1464
1689
  console.log("");
1465
1690
  console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
@@ -1554,8 +1779,25 @@ async function update() {
1554
1779
  console.log(
1555
1780
  ` ${GREEN}New version available: v${latestVersion}${RESET} (you have v${localVersion}).`,
1556
1781
  );
1557
- console.log(` Run ${CYAN}npx forgedock@latest${RESET} to fetch it.`);
1558
1782
  await printVersionAvailableChangelog(localVersion, latestVersion);
1783
+
1784
+ // forge#2133 — a global npm install can actually be upgraded in place;
1785
+ // do that instead of only printing an advisory (which previously left
1786
+ // global installs stuck on whatever version was first installed,
1787
+ // since every subsequent `npx forgedock update` re-resolves the same
1788
+ // stale global binary). selfUpdateGlobalInstall() re-execs and exits
1789
+ // the process on success, so anything after this block only runs when
1790
+ // self-update wasn't attempted or failed.
1791
+ if (isGlobalNpmInstall()) {
1792
+ selfUpdateGlobalInstall(latestVersion);
1793
+ // selfUpdateGlobalInstall() only returns (rather than exiting the
1794
+ // process) when the install or re-exec failed — it already printed
1795
+ // manual-recovery instructions, so fall through to the persist
1796
+ // refresh below using the still-stale local payload (fail-open,
1797
+ // matches pre-existing behavior for this failure case).
1798
+ } else {
1799
+ console.log(` Run ${CYAN}npx forgedock@latest${RESET} to fetch it.`);
1800
+ }
1559
1801
  } else {
1560
1802
  console.log(` Already up to date (v${localVersion}).`);
1561
1803
  }
@@ -2721,7 +2963,7 @@ function help() {
2721
2963
  ["npx forgedock enable [dir]", "Activate ForgeDock in a directory"],
2722
2964
  ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
2723
2965
  ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
2724
- ["npx forgedock run <cmd> [args]", "Run a command headlessly via the Anthropic API"],
2966
+ ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
2725
2967
  ["npx forgedock run-issue <issue>", "Drive one issue through the durable engine"],
2726
2968
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
2727
2969
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
@@ -2755,46 +2997,88 @@ function help() {
2755
2997
 
2756
2998
  /**
2757
2999
  * `forgedock run <command> [args...]` — execute a ForgeDock command spec
2758
- * directly via the Anthropic API, outside of Claude Code (headless / CI).
3000
+ * headlessly, outside of an interactive Claude Code session (CI or local).
2759
3001
  *
2760
3002
  * Flags:
2761
- * --dry-run Preview the assembled prompt + tool plan; no API call.
2762
- * --model <id> Override the model (see resolution order below).
2763
- * --max-iterations <n> Bound the tool-use loop (default: 50).
3003
+ * --dry-run Preview the assembled prompt + tool plan; no call made.
3004
+ * --model <id> Override the model (see resolution order below; api backend only).
3005
+ * --max-iterations <n> Bound the tool-use loop (default: 50; api backend only).
3006
+ * --backend <cli|api|auto> Execution backend (see below; default: auto).
2764
3007
  *
2765
- * The live loop requires ANTHROPIC_API_KEY and the optional @anthropic-ai/sdk
2766
- * dependency. The runtime itself lives in bin/runner.mjs.
3008
+ * Two execution backends (issue #2003):
3009
+ * - "cli": shells out to the local Claude Code CLI (`claude --print ...`),
3010
+ * reusing whatever credentials it already has (Pro/Max OAuth or a
3011
+ * CLI-managed key). No ANTHROPIC_API_KEY needed. Requires `claude` on PATH.
3012
+ * - "api": drives the Anthropic SDK directly. Requires ANTHROPIC_API_KEY and
3013
+ * the optional @anthropic-ai/sdk dependency. Needed for CI/headless
3014
+ * environments without an interactively-authenticated `claude` CLI.
3015
+ * - "auto" (default): prefers "cli" when a working `claude` install is
3016
+ * detected, otherwise falls back to "api" unchanged — existing callers
3017
+ * that never pass --backend see no behavior change when `claude` isn't
3018
+ * installed.
2767
3019
  *
2768
- * Model resolution order (highest precedence first):
3020
+ * The runtime itself lives in bin/runner.mjs.
3021
+ *
3022
+ * Model resolution order (highest precedence first; api backend only — the
3023
+ * cli backend uses whatever model the `claude` CLI itself is configured for):
2769
3024
  * 1. --model <id> This flag.
2770
3025
  * 2. $FORGEDOCK_MODEL Env var, below.
2771
3026
  * 3. forge.yaml `agents.default_model` Read from the run's cwd, if present.
2772
3027
  * 4. Hardcoded default "claude-sonnet-5" (bin/runner.mjs DEFAULT_MODEL).
2773
3028
  *
2774
3029
  * Env:
2775
- * FORGEDOCK_MODEL Default model id when --model is omitted.
2776
- * FORGEDOCK_SHELL Override the shell used by run_bash. Defaults to bash when
2777
- * found (Git Bash / WSL on Windows, /bin/bash on POSIX),
2778
- * falling back to the platform default shell otherwise.
3030
+ * FORGEDOCK_BACKEND Default backend ("cli"|"api"|"auto") when --backend is omitted.
3031
+ * FORGEDOCK_MODEL Default model id when --model is omitted.
3032
+ * FORGEDOCK_CLI_TIMEOUT_MS Wall-clock timeout (ms) for the cli backend invocation.
3033
+ * FORGEDOCK_SHELL Override the shell used by run_bash / the cli backend.
3034
+ * Defaults to bash when found (Git Bash / WSL on
3035
+ * Windows, /bin/bash on POSIX), falling back to the
3036
+ * platform default shell otherwise.
2779
3037
  */
2780
3038
  async function run() {
2781
3039
  const runArgs = restArgs;
2782
3040
  let dryRun = false;
2783
3041
  let model;
2784
3042
  let maxIterations;
3043
+ let backend;
2785
3044
  const positional = [];
3045
+ // Consume the value for a `--flag <value>` pair at index `idx` (the flag
3046
+ // itself). Errors loudly instead of silently returning `undefined` when
3047
+ // the flag is the last token or is immediately followed by another flag —
3048
+ // both cases previously left the corresponding variable `undefined`,
3049
+ // which downstream validation treats identically to "flag omitted",
3050
+ // silently falling back to the default instead of erroring. Returns the
3051
+ // consumed value; the caller is responsible for advancing its own loop
3052
+ // index past the consumed token.
3053
+ const requireFlagValue = (flagName, idx) => {
3054
+ const value = runArgs[idx + 1];
3055
+ if (value === undefined || value.startsWith("--")) {
3056
+ process.stderr.write(
3057
+ `${RED}Missing value for ${flagName}. Usage: ${flagName} <value>${RESET}\n`,
3058
+ );
3059
+ process.exit(1);
3060
+ }
3061
+ return value;
3062
+ };
2786
3063
  for (let i = 0; i < runArgs.length; i++) {
2787
3064
  const a = runArgs[i];
2788
3065
  if (a === "--dry-run") {
2789
3066
  dryRun = true;
2790
3067
  } else if (a === "--model") {
2791
- model = runArgs[++i];
3068
+ model = requireFlagValue("--model", i);
3069
+ i++;
2792
3070
  } else if (a.startsWith("--model=")) {
2793
3071
  model = a.slice("--model=".length);
2794
3072
  } else if (a === "--max-iterations") {
2795
- maxIterations = parseInt(runArgs[++i], 10);
3073
+ maxIterations = parseInt(requireFlagValue("--max-iterations", i), 10);
3074
+ i++;
2796
3075
  } else if (a.startsWith("--max-iterations=")) {
2797
3076
  maxIterations = parseInt(a.slice("--max-iterations=".length), 10);
3077
+ } else if (a === "--backend") {
3078
+ backend = requireFlagValue("--backend", i);
3079
+ i++;
3080
+ } else if (a.startsWith("--backend=")) {
3081
+ backend = a.slice("--backend=".length);
2798
3082
  } else {
2799
3083
  positional.push(a);
2800
3084
  }
@@ -2805,12 +3089,25 @@ async function run() {
2805
3089
 
2806
3090
  if (!commandName) {
2807
3091
  process.stderr.write(
2808
- `${RED}Usage: forgedock run <command> [args...] [--dry-run] [--model <id>] [--max-iterations <n>]${RESET}\n`,
3092
+ `${RED}Usage: forgedock run <command> [args...] [--dry-run] [--model <id>] [--max-iterations <n>] [--backend <cli|api|auto>]${RESET}\n`,
3093
+ );
3094
+ process.exit(1);
3095
+ }
3096
+
3097
+ // VALID_BACKENDS is imported from bin/runner.mjs (not re-hardcoded here) so
3098
+ // this CLI-layer flag check and runner.mjs's own library-layer
3099
+ // resolveBackend() validation can never structurally diverge on the set of
3100
+ // accepted values or the wording of the resulting error message (issue
3101
+ // #2013 — the two were previously independently-hardcoded Sets with
3102
+ // near-identical but not-quite-matching error text).
3103
+ const { runCommand, VALID_BACKENDS } = await import("./runner.mjs");
3104
+ if (backend !== undefined && !VALID_BACKENDS.has(backend)) {
3105
+ process.stderr.write(
3106
+ `${RED}Invalid --backend "${backend}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.${RESET}\n`,
2809
3107
  );
2810
3108
  process.exit(1);
2811
3109
  }
2812
3110
 
2813
- const { runCommand } = await import("./runner.mjs");
2814
3111
  try {
2815
3112
  const result = await runCommand({
2816
3113
  commandsDir: COMMANDS_DIR,
@@ -2822,6 +3119,7 @@ async function run() {
2822
3119
  ...(Number.isInteger(maxIterations) && maxIterations > 0
2823
3120
  ? { maxIterations }
2824
3121
  : {}),
3122
+ ...(backend ? { backend } : {}),
2825
3123
  });
2826
3124
  // Treat a non-clean stop (iteration cap hit, or a max_tokens-truncated
2827
3125
  // turn) as a failed run so CI/headless callers notice.
@@ -3167,7 +3465,11 @@ switch (command) {
3167
3465
  case "run-issue": {
3168
3466
  const { runFromCli } = await import("./engine-cli.mjs");
3169
3467
  try {
3170
- await runFromCli(restArgs);
3468
+ const result = await runFromCli(restArgs);
3469
+ // forge#2175: make a needs-human escalation distinguishable from a
3470
+ // successful run by exit code, mirroring the resume-stalled case's
3471
+ // existing convention below (result.failed.length > 0 -> exitCode 1).
3472
+ if (result && result.terminalReason === "needs-human") exitCode = 1;
3171
3473
  } catch (err) {
3172
3474
  process.stderr.write(`${RED}${err.message}${RESET}\n`);
3173
3475
  exitCode = 1;