forgedock 1.1.10 → 1.2.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 (76) hide show
  1. package/README.md +1 -1
  2. package/bin/cli-spawn-shared.mjs +25 -0
  3. package/bin/engine-cli.mjs +22 -1
  4. package/bin/engine.mjs +46 -4
  5. package/bin/forgedock.mjs +166 -20
  6. package/bin/hooks/pre-tool-use.mjs +331 -10
  7. package/bin/init-detect.mjs +1 -1
  8. package/bin/init-enrich-api.mjs +12 -4
  9. package/bin/init-enrich-cli.mjs +328 -0
  10. package/bin/init-enrich.mjs +137 -0
  11. package/bin/journey.mjs +328 -50
  12. package/bin/runner.mjs +653 -10
  13. package/commands/adopt.md +1 -1
  14. package/commands/analytics.md +10 -7
  15. package/commands/audit-agents.md +29 -6
  16. package/commands/audit.md +9 -20
  17. package/commands/autopilot.md +52 -78
  18. package/commands/changelog.md +1 -1
  19. package/commands/ci-audit.md +19 -10
  20. package/commands/cleanup.md +1 -1
  21. package/commands/compat-audit.md +1 -1
  22. package/commands/deploy-info.md +1 -1
  23. package/commands/deploy-pr.md +1 -1
  24. package/commands/design-bench.md +1 -1
  25. package/commands/design-render-critique-loop.md +1 -1
  26. package/commands/diagnose.md +1 -1
  27. package/commands/forge-stats.md +1 -1
  28. package/commands/forgedock-init.md +1 -1
  29. package/commands/geo-audit.md +9 -6
  30. package/commands/incident-response.md +15 -5
  31. package/commands/issue.md +298 -15
  32. package/commands/milestone.md +45 -18
  33. package/commands/optimize.md +1 -1
  34. package/commands/orchestrate/phase-1-resolve.md +20 -5
  35. package/commands/orchestrate/phase-4-execution.md +6 -1
  36. package/commands/orchestrate/phase-5-cleanup.md +43 -2
  37. package/commands/orchestrate/phase-6-report.md +1 -0
  38. package/commands/orchestrate.md +1 -1
  39. package/commands/pipeline-health/phase-4-proposals.md +40 -10
  40. package/commands/pipeline-health/phase-5-report.md +140 -13
  41. package/commands/pipeline-health.md +1 -1
  42. package/commands/pipeline-resume.md +1 -1
  43. package/commands/pipeline-status.md +1 -1
  44. package/commands/qa-sweep.md +10 -4
  45. package/commands/recover-orphans.md +1 -1
  46. package/commands/review-pr-agents/protocols.md +4 -0
  47. package/commands/review-pr-agents.md +7 -4
  48. package/commands/review-pr-staging.md +44 -15
  49. package/commands/review-pr.md +73 -28
  50. package/commands/rollback.md +15 -5
  51. package/commands/security-audit.md +14 -11
  52. package/commands/signal-planner.md +32 -12
  53. package/commands/spec-doctor.md +1 -1
  54. package/commands/sync-ecosystem.md +54 -11
  55. package/commands/test-gate.md +49 -11
  56. package/commands/validate.md +28 -9
  57. package/commands/work-on/build/architect.md +1 -1
  58. package/commands/work-on/build/context.md +1 -1
  59. package/commands/work-on/build/implement.md +2 -2
  60. package/commands/work-on/build/validate.md +3 -1
  61. package/commands/work-on/build.md +1 -1
  62. package/commands/work-on/close.md +1 -1
  63. package/commands/work-on/decompose.md +27 -24
  64. package/commands/work-on/investigate.md +2 -2
  65. package/commands/work-on/remediate.md +11 -3
  66. package/commands/work-on/review.md +5 -3
  67. package/commands/work-on-monolithic.md +1 -1
  68. package/commands/work-on.md +38 -8
  69. package/docs/CI.md +18 -0
  70. package/docs/CONFIG.md +5 -2
  71. package/docs/site/annotations-explained.md +2 -2
  72. package/docs/site/command-reference.md +21 -0
  73. package/docs/spec/review-protocol.md +6 -0
  74. package/package.json +1 -1
  75. package/scripts/danger-zones.mjs +3 -1
  76. package/scripts/transition-label.sh +80 -28
package/README.md CHANGED
@@ -250,7 +250,7 @@ Then just open Claude Code and run `/work-on <issue>`.
250
250
 
251
251
  Commands then appear as `/forgedock:work-on`, etc. You still run `npx forgedock init` to generate `forge.yaml`.
252
252
 
253
- **Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan; with an `ANTHROPIC_API_KEY`, `npx forgedock run` drives the same command specs through a hardened tool-use loop, and `npx forgedock run-issue <issue>` executes them on the durable engine (event-sourced run log, leases, crash-safe resume).
253
+ **Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan. `npx forgedock run` picks an execution backend automatically (`--backend auto`, the default): if the Claude Code CLI (`claude`) is installed and already authenticated (Pro/Max subscription or a CLI-managed key), it drives the command through that — **no separate `ANTHROPIC_API_KEY` required**. Otherwise it falls back to the Anthropic API directly (`ANTHROPIC_API_KEY` required). Force either path explicitly with `--backend cli` / `--backend api` (or `FORGEDOCK_BACKEND=cli|api`) — the API backend is what CI environments without an interactive `claude` login should use. `npx forgedock run-issue <issue>` executes the same command specs on the durable engine (event-sourced run log, leases, crash-safe resume).
254
254
 
255
255
  **Explicit install command:**
256
256
 
@@ -0,0 +1,25 @@
1
+ /**
2
+ * cli-spawn-shared.mjs — shared constants for `claude` CLI `spawnSync`
3
+ * invocations across the codebase.
4
+ *
5
+ * Extracted (issue #2032) to stop `maxBuffer: 50 * 1024 * 1024` from
6
+ * drifting independently at each spawn call site (bin/runner.mjs's
7
+ * `runCliBackend` and `run_bash`, bin/init-enrich-cli.mjs's `enrich()`) —
8
+ * all three invoke the same local `claude` CLI and should share one
9
+ * default rather than three copies of the same magic number.
10
+ *
11
+ * Isolated: imports no external modules, so it is safe to import from any
12
+ * module without creating a cycle.
13
+ */
14
+
15
+ /**
16
+ * Default `maxBuffer` (bytes) for a `spawnSync` call against the local
17
+ * `claude` CLI. 50 MB comfortably covers a full headless response (stdout)
18
+ * plus any diagnostic stderr without risking silent truncation.
19
+ *
20
+ * `bin/runner.mjs`'s `run_bash` tool handler additionally allows this
21
+ * default to be overridden per-call via `FORGEDOCK_MAX_BUFFER_BYTES` — that
22
+ * override logic lives in `runner.mjs` itself; this constant is only the
23
+ * shared *default*.
24
+ */
25
+ export const DEFAULT_SPAWN_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
@@ -224,6 +224,21 @@ export function lastLocalRun(dir) {
224
224
  * @param {{io?: {gh: Function}, runIssue?: Function}} [deps]
225
225
  * Injectable for tests — defaults to real `gh`/`git` (makeIo()) and the real
226
226
  * `runIssue` engine driver.
227
+ *
228
+ * Flags:
229
+ * --lane <lane> Required — e.g. `main` or `staging`.
230
+ * --repo <owner/repo> Optional — must match the cwd-resolved repo (forge#1593).
231
+ * --backend <cli|api|auto> Optional (forge#2028). Forwarded to every phase's
232
+ * `runCommand()` call. Omit to keep runner.mjs's own
233
+ * default ("auto" ladder — probes the `claude` CLI, falls
234
+ * back to the API). Mirrors `bin/forgedock.mjs`'s `run()`
235
+ * `--backend` semantics — validation (rejecting invalid
236
+ * values) is left to `runCommand()`'s own `resolveBackend()`
237
+ * rather than duplicated here.
238
+ * --model <id> Optional (forge#2028). Forwarded to every phase's
239
+ * `runCommand()` call; only applies on the "api" backend.
240
+ * Omit to keep runner.mjs's default (`FORGEDOCK_MODEL` env
241
+ * or its built-in default).
227
242
  */
228
243
  export async function runFromCli(argv, deps = {}) {
229
244
  const issue = parseInt(argv[0], 10);
@@ -231,12 +246,18 @@ export async function runFromCli(argv, deps = {}) {
231
246
  const lane = flag(argv, "--lane");
232
247
  if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
233
248
  const repo = flag(argv, "--repo");
249
+ const backend = flag(argv, "--backend");
250
+ const model = flag(argv, "--model");
234
251
  const io = deps.io ?? makeIo();
235
252
  await assertRepoMatchesCwd(io, repo);
236
253
  const runIssueFn = deps.runIssue ?? runIssue;
237
254
  const agentId = `cli_${process.pid}`;
238
255
  const res = await runIssueFn({ issue, dir: runDir(), agentId, lane, io,
239
- runner: (await import("./runner.mjs")).runCommand, now: () => Date.now() });
256
+ runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
257
+ // Only forwarded when explicitly provided — omitting them preserves
258
+ // runIssue's/runner.mjs's existing defaults (forge#2028).
259
+ ...(backend ? { backend } : {}),
260
+ ...(model ? { model } : {}) });
240
261
  console.log(`issue #${issue} → ${res.terminalReason}`);
241
262
  return res;
242
263
  }
package/bin/engine.mjs CHANGED
@@ -8,13 +8,49 @@ import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.m
8
8
  import { pickPhase, TERMINAL_REASONS } from "./engine/phases.mjs";
9
9
  import { reconcileState } from "./engine/reconcile.mjs";
10
10
  import { makeProjector } from "./engine/projector.mjs";
11
+ import { VALID_BACKENDS } from "./runner.mjs";
11
12
 
12
13
  const DEFAULT_MAX_ATTEMPTS = 3;
13
14
 
15
+ /**
16
+ * @param {object} opts
17
+ * @param {string} [opts.backend] - "cli" | "api" | "auto" (forge#2028). Forwarded
18
+ * to every phase's `runner()` call when supplied. Omit to keep runner.mjs's own
19
+ * default ("auto" ladder — probes the `claude` CLI, falls back to the API).
20
+ * An invalid value throws synchronously (see below) rather than being forwarded.
21
+ * @param {string} [opts.model] - Model id (forge#2028). Forwarded to every phase's
22
+ * `runner()` call when supplied; only applies on the "api" backend. Omit to keep
23
+ * runner.mjs's default (`FORGEDOCK_MODEL` env or its built-in default).
24
+ */
14
25
  export async function runIssue(opts) {
15
26
  const { issue, dir, agentId, lane = "staging", io, runner,
16
27
  now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
17
- commandsDir = fileURLToPath(new URL("../commands", import.meta.url)) } = opts;
28
+ commandsDir = fileURLToPath(new URL("../commands", import.meta.url)),
29
+ // Optional execution-backend override for every phase's `runner()`
30
+ // call (forge#2028 / MAT-3). Left undefined by default so existing
31
+ // callers keep runner.mjs's own "auto" ladder default unchanged —
32
+ // this is purely additive pass-through, not a new default.
33
+ backend, model } = opts;
34
+
35
+ // forge#2054: validate `backend` before anything else — before state is
36
+ // read/written and before the phase/retry loop begins below. An invalid
37
+ // value must fail fast and non-retryably. Without this check, an invalid
38
+ // backend instead reaches runner.mjs's resolveBackend() deep inside
39
+ // runPhaseWithRetry()'s per-attempt try/catch, throws an uncoded Error, and
40
+ // is silently retried up to `maxAttempts` times (the catch there only
41
+ // fast-fails on `.code === "NO_API_KEY"/"NO_SDK"`) before escalating to
42
+ // needs-human — burning retries on what is actually a config error, not a
43
+ // transient phase failure. runIssue() is the single production choke point
44
+ // (its only caller is bin/engine-cli.mjs's runFromCli()), so validating
45
+ // here protects every current and future caller without requiring each one
46
+ // to remember to validate independently.
47
+ if (backend !== undefined && !VALID_BACKENDS.has(backend)) {
48
+ throw Object.assign(
49
+ new Error(`Invalid backend "${backend}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`),
50
+ { code: "INVALID_BACKEND" },
51
+ );
52
+ }
53
+
18
54
  const projector = makeProjector(io);
19
55
 
20
56
  // 1. Load + reconcile (GitHub wins).
@@ -67,7 +103,7 @@ export async function runIssue(opts) {
67
103
  outcome = { status: "committed", outputs: reconciled.outputs || {} };
68
104
  } else {
69
105
  if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
70
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts });
106
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
71
107
  }
72
108
 
73
109
  if (outcome.status === "blocked") return await terminate(state, "needs-human", outcome.detail);
@@ -95,11 +131,17 @@ export async function runIssue(opts) {
95
131
  }
96
132
 
97
133
  async function runPhaseWithRetry(phase, state, ctx) {
98
- const { io, runner, dir, issue, commandsDir, maxAttempts } = ctx;
134
+ const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model } = ctx;
99
135
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
100
136
  appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
101
137
  try {
102
- await runner({ commandsDir, commandName: phase.command, args: [String(issue)] });
138
+ await runner({
139
+ commandsDir, commandName: phase.command, args: [String(issue)],
140
+ // Only forwarded when explicitly provided — omitting them preserves
141
+ // runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
142
+ ...(backend ? { backend } : {}),
143
+ ...(model ? { model } : {}),
144
+ });
103
145
  } catch (e) {
104
146
  // A missing API key / SDK is a config error, not a transient phase
105
147
  // failure — surface it distinctly instead of burning retries and
package/bin/forgedock.mjs CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  parseInstallTier,
29
29
  writeForgeYaml,
30
30
  backupExisting,
31
+ backfillForgeYaml,
31
32
  manualLowConfidenceKeys,
32
33
  isEphemeralCachePath,
33
34
  writeInstallReceipt,
@@ -950,6 +951,21 @@ async function statusScreen(c) {
950
951
  c.stdout.write("\n");
951
952
  }
952
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
+ */
953
969
  async function initFlow(c) {
954
970
  const outputPath = join(c.cwd, "forge.yaml");
955
971
  const hasExisting = existsSync(outputPath);
@@ -1874,6 +1890,16 @@ async function doctor(fix = false) {
1874
1890
  * Reuses forge() (bin/journey.mjs) verbatim — the same repair path already
1875
1891
  * used by `npx forgedock update` (see relinkAndHint()) — rather than
1876
1892
  * duplicating symlink/copy-mode or hook-registration logic here.
1893
+ *
1894
+ * NOTE: `installRepairRan` only memoizes whether the repair primitive
1895
+ * itself has executed during this invocation — it is a cross-check flag
1896
+ * by necessity (any of the three checks below can trigger it, and it must
1897
+ * not re-run for the other two). It must NOT be read by an individual
1898
+ * check to decide whether THAT check was fixed — a check that was already
1899
+ * healthy before a sibling check triggered repair would incorrectly
1900
+ * report "repaired" too. Each check below tracks its own pre-repair
1901
+ * broken state in a local `*WasBroken` variable for that decision instead.
1902
+ * (forge#1975)
1877
1903
  */
1878
1904
  let installRepairRan = false;
1879
1905
  async function runInstallRepairOnce() {
@@ -1954,7 +1980,9 @@ async function doctor(fix = false) {
1954
1980
  }
1955
1981
 
1956
1982
  let cmdFiles = await detectCommandFiles();
1983
+ let cmdFilesWasBroken = false;
1957
1984
  if (!cmdFiles.error && !cmdFiles.ok && fix) {
1985
+ cmdFilesWasBroken = true;
1958
1986
  await runInstallRepairOnce();
1959
1987
  cmdFiles = await detectCommandFiles();
1960
1988
  }
@@ -1962,7 +1990,7 @@ async function doctor(fix = false) {
1962
1990
  if (cmdFiles.error) {
1963
1991
  fail("Command files", `Could not read commands directory: ${cmdFiles.error}`);
1964
1992
  } else if (cmdFiles.ok) {
1965
- if (fix && installRepairRan) {
1993
+ if (fix && cmdFilesWasBroken) {
1966
1994
  fixed("Command files", `${cmdFiles.checked} files installed (repaired)`);
1967
1995
  } else {
1968
1996
  pass("Command files", `${cmdFiles.checked} files installed`);
@@ -2153,7 +2181,9 @@ async function doctor(fix = false) {
2153
2181
  }
2154
2182
 
2155
2183
  let hookReg = detectSessionStartHookRegistered();
2184
+ let hookRegWasBroken = false;
2156
2185
  if (!hookReg.error && !hookReg.ok && fix) {
2186
+ hookRegWasBroken = true;
2157
2187
  await runInstallRepairOnce();
2158
2188
  hookReg = detectSessionStartHookRegistered();
2159
2189
  }
@@ -2161,7 +2191,7 @@ async function doctor(fix = false) {
2161
2191
  if (hookReg.error) {
2162
2192
  fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${hookReg.error}. Run: npx forgedock install`);
2163
2193
  } else if (hookReg.ok) {
2164
- if (fix && installRepairRan) {
2194
+ if (fix && hookRegWasBroken) {
2165
2195
  fixed("SessionStart hook", "registered in ~/.claude/settings.json (repaired)");
2166
2196
  } else {
2167
2197
  pass("SessionStart hook", "registered in ~/.claude/settings.json");
@@ -2221,7 +2251,9 @@ async function doctor(fix = false) {
2221
2251
  // ever surfaced. This check catches that silent-failure case. (forge#1895)
2222
2252
  {
2223
2253
  let hookPathResult = detectSessionStartHookPath();
2254
+ let hookPathWasBroken = false;
2224
2255
  if (hookPathResult.state === "dangling" && fix) {
2256
+ hookPathWasBroken = true;
2225
2257
  await runInstallRepairOnce();
2226
2258
  hookPathResult = detectSessionStartHookPath();
2227
2259
  }
@@ -2237,7 +2269,7 @@ async function doctor(fix = false) {
2237
2269
  `Could not parse a script path out of the registered hook command ("${hookPathResult.command}"). Run: npx forgedock install`,
2238
2270
  );
2239
2271
  } else if (hookPathResult.state === "ok") {
2240
- if (fix && installRepairRan) {
2272
+ if (fix && hookPathWasBroken) {
2241
2273
  fixed("SessionStart hook script path", `refreshed to a valid path (${hookPathResult.path})`);
2242
2274
  } else {
2243
2275
  pass("SessionStart hook script path", `exists on disk (${hookPathResult.path})`);
@@ -2704,17 +2736,19 @@ function help() {
2704
2736
  ["npx forgedock enable [dir]", "Activate ForgeDock in a directory"],
2705
2737
  ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
2706
2738
  ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
2707
- ["npx forgedock run <cmd> [args]", "Run a command headlessly via the Anthropic API"],
2739
+ ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
2708
2740
  ["npx forgedock run-issue <issue>", "Drive one issue through the durable engine"],
2709
2741
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
2710
2742
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
2711
2743
  ["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
2744
+ ["npx forgedock config migrate [dir]", "Backfill missing optional sections into an existing forge.yaml (idempotent)"],
2712
2745
  ["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
2713
2746
  ["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
2714
2747
  ["npx forgedock doctor", "Check installation health"],
2715
2748
  ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
2716
2749
  ["npx forgedock update", "Pull latest & reinstall"],
2717
2750
  ["npx forgedock uninstall", "Remove commands"],
2751
+ ["npx forgedock version", "Print the installed version and check for updates"],
2718
2752
  ["npx forgedock help", "Show this help"],
2719
2753
  ];
2720
2754
  const flagRows = [
@@ -2723,6 +2757,7 @@ function help() {
2723
2757
  ["--manual", "Plain text prompts instead of the review screen (init)"],
2724
2758
  ["--verbose", "Show detection sources for every field (init)"],
2725
2759
  ["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
2760
+ ["--version, -v", "Print the installed version and check for updates"],
2726
2761
  ];
2727
2762
 
2728
2763
  process.stdout.write(
@@ -2735,46 +2770,88 @@ function help() {
2735
2770
 
2736
2771
  /**
2737
2772
  * `forgedock run <command> [args...]` — execute a ForgeDock command spec
2738
- * directly via the Anthropic API, outside of Claude Code (headless / CI).
2773
+ * headlessly, outside of an interactive Claude Code session (CI or local).
2739
2774
  *
2740
2775
  * Flags:
2741
- * --dry-run Preview the assembled prompt + tool plan; no API call.
2742
- * --model <id> Override the model (see resolution order below).
2743
- * --max-iterations <n> Bound the tool-use loop (default: 50).
2776
+ * --dry-run Preview the assembled prompt + tool plan; no call made.
2777
+ * --model <id> Override the model (see resolution order below; api backend only).
2778
+ * --max-iterations <n> Bound the tool-use loop (default: 50; api backend only).
2779
+ * --backend <cli|api|auto> Execution backend (see below; default: auto).
2744
2780
  *
2745
- * The live loop requires ANTHROPIC_API_KEY and the optional @anthropic-ai/sdk
2746
- * dependency. The runtime itself lives in bin/runner.mjs.
2781
+ * Two execution backends (issue #2003):
2782
+ * - "cli": shells out to the local Claude Code CLI (`claude --print ...`),
2783
+ * reusing whatever credentials it already has (Pro/Max OAuth or a
2784
+ * CLI-managed key). No ANTHROPIC_API_KEY needed. Requires `claude` on PATH.
2785
+ * - "api": drives the Anthropic SDK directly. Requires ANTHROPIC_API_KEY and
2786
+ * the optional @anthropic-ai/sdk dependency. Needed for CI/headless
2787
+ * environments without an interactively-authenticated `claude` CLI.
2788
+ * - "auto" (default): prefers "cli" when a working `claude` install is
2789
+ * detected, otherwise falls back to "api" unchanged — existing callers
2790
+ * that never pass --backend see no behavior change when `claude` isn't
2791
+ * installed.
2747
2792
  *
2748
- * Model resolution order (highest precedence first):
2793
+ * The runtime itself lives in bin/runner.mjs.
2794
+ *
2795
+ * Model resolution order (highest precedence first; api backend only — the
2796
+ * cli backend uses whatever model the `claude` CLI itself is configured for):
2749
2797
  * 1. --model <id> This flag.
2750
2798
  * 2. $FORGEDOCK_MODEL Env var, below.
2751
2799
  * 3. forge.yaml `agents.default_model` Read from the run's cwd, if present.
2752
2800
  * 4. Hardcoded default "claude-sonnet-5" (bin/runner.mjs DEFAULT_MODEL).
2753
2801
  *
2754
2802
  * Env:
2755
- * FORGEDOCK_MODEL Default model id when --model is omitted.
2756
- * FORGEDOCK_SHELL Override the shell used by run_bash. Defaults to bash when
2757
- * found (Git Bash / WSL on Windows, /bin/bash on POSIX),
2758
- * falling back to the platform default shell otherwise.
2803
+ * FORGEDOCK_BACKEND Default backend ("cli"|"api"|"auto") when --backend is omitted.
2804
+ * FORGEDOCK_MODEL Default model id when --model is omitted.
2805
+ * FORGEDOCK_CLI_TIMEOUT_MS Wall-clock timeout (ms) for the cli backend invocation.
2806
+ * FORGEDOCK_SHELL Override the shell used by run_bash / the cli backend.
2807
+ * Defaults to bash when found (Git Bash / WSL on
2808
+ * Windows, /bin/bash on POSIX), falling back to the
2809
+ * platform default shell otherwise.
2759
2810
  */
2760
2811
  async function run() {
2761
2812
  const runArgs = restArgs;
2762
2813
  let dryRun = false;
2763
2814
  let model;
2764
2815
  let maxIterations;
2816
+ let backend;
2765
2817
  const positional = [];
2818
+ // Consume the value for a `--flag <value>` pair at index `idx` (the flag
2819
+ // itself). Errors loudly instead of silently returning `undefined` when
2820
+ // the flag is the last token or is immediately followed by another flag —
2821
+ // both cases previously left the corresponding variable `undefined`,
2822
+ // which downstream validation treats identically to "flag omitted",
2823
+ // silently falling back to the default instead of erroring. Returns the
2824
+ // consumed value; the caller is responsible for advancing its own loop
2825
+ // index past the consumed token.
2826
+ const requireFlagValue = (flagName, idx) => {
2827
+ const value = runArgs[idx + 1];
2828
+ if (value === undefined || value.startsWith("--")) {
2829
+ process.stderr.write(
2830
+ `${RED}Missing value for ${flagName}. Usage: ${flagName} <value>${RESET}\n`,
2831
+ );
2832
+ process.exit(1);
2833
+ }
2834
+ return value;
2835
+ };
2766
2836
  for (let i = 0; i < runArgs.length; i++) {
2767
2837
  const a = runArgs[i];
2768
2838
  if (a === "--dry-run") {
2769
2839
  dryRun = true;
2770
2840
  } else if (a === "--model") {
2771
- model = runArgs[++i];
2841
+ model = requireFlagValue("--model", i);
2842
+ i++;
2772
2843
  } else if (a.startsWith("--model=")) {
2773
2844
  model = a.slice("--model=".length);
2774
2845
  } else if (a === "--max-iterations") {
2775
- maxIterations = parseInt(runArgs[++i], 10);
2846
+ maxIterations = parseInt(requireFlagValue("--max-iterations", i), 10);
2847
+ i++;
2776
2848
  } else if (a.startsWith("--max-iterations=")) {
2777
2849
  maxIterations = parseInt(a.slice("--max-iterations=".length), 10);
2850
+ } else if (a === "--backend") {
2851
+ backend = requireFlagValue("--backend", i);
2852
+ i++;
2853
+ } else if (a.startsWith("--backend=")) {
2854
+ backend = a.slice("--backend=".length);
2778
2855
  } else {
2779
2856
  positional.push(a);
2780
2857
  }
@@ -2785,12 +2862,25 @@ async function run() {
2785
2862
 
2786
2863
  if (!commandName) {
2787
2864
  process.stderr.write(
2788
- `${RED}Usage: forgedock run <command> [args...] [--dry-run] [--model <id>] [--max-iterations <n>]${RESET}\n`,
2865
+ `${RED}Usage: forgedock run <command> [args...] [--dry-run] [--model <id>] [--max-iterations <n>] [--backend <cli|api|auto>]${RESET}\n`,
2866
+ );
2867
+ process.exit(1);
2868
+ }
2869
+
2870
+ // VALID_BACKENDS is imported from bin/runner.mjs (not re-hardcoded here) so
2871
+ // this CLI-layer flag check and runner.mjs's own library-layer
2872
+ // resolveBackend() validation can never structurally diverge on the set of
2873
+ // accepted values or the wording of the resulting error message (issue
2874
+ // #2013 — the two were previously independently-hardcoded Sets with
2875
+ // near-identical but not-quite-matching error text).
2876
+ const { runCommand, VALID_BACKENDS } = await import("./runner.mjs");
2877
+ if (backend !== undefined && !VALID_BACKENDS.has(backend)) {
2878
+ process.stderr.write(
2879
+ `${RED}Invalid --backend "${backend}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.${RESET}\n`,
2789
2880
  );
2790
2881
  process.exit(1);
2791
2882
  }
2792
2883
 
2793
- const { runCommand } = await import("./runner.mjs");
2794
2884
  try {
2795
2885
  const result = await runCommand({
2796
2886
  commandsDir: COMMANDS_DIR,
@@ -2802,6 +2892,7 @@ async function run() {
2802
2892
  ...(Number.isInteger(maxIterations) && maxIterations > 0
2803
2893
  ? { maxIterations }
2804
2894
  : {}),
2895
+ ...(backend ? { backend } : {}),
2805
2896
  });
2806
2897
  // Treat a non-clean stop (iteration cap hit, or a max_tokens-truncated
2807
2898
  // turn) as a failed run so CI/headless callers notice.
@@ -3051,7 +3142,8 @@ async function watch() {
3051
3142
  const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3052
3143
  const KNOWN_COMMANDS = new Set([
3053
3144
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3054
- "run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "help", "--help", "-h",
3145
+ "run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
3146
+ "version", "--version", "-v",
3055
3147
  ]);
3056
3148
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
3057
3149
 
@@ -3200,6 +3292,60 @@ switch (command) {
3200
3292
  }
3201
3293
  break;
3202
3294
  }
3295
+ case "config": {
3296
+ const subcommand = positional[1];
3297
+ if (subcommand === "migrate") {
3298
+ // Directory override mirrors `status [dir]` — defaults to cwd.
3299
+ const dir = positional[2] ? resolve(positional[2]) : process.cwd();
3300
+ const result = backfillForgeYaml(dir);
3301
+ if (!result.present) {
3302
+ console.log(
3303
+ `${RED}No forge.yaml found in ${dir}.${RESET}\n` +
3304
+ ` Run ${cyan("npx forgedock init")} to generate one.`,
3305
+ );
3306
+ exitCode = 1;
3307
+ } else if (result.added.length === 0) {
3308
+ console.log(
3309
+ `${GREEN}forge.yaml already has all ${result.alreadyPresent.length} optional sections.${RESET} Nothing to migrate.`,
3310
+ );
3311
+ } else {
3312
+ console.log(`${GREEN}Backfilled ${result.added.length} missing optional section(s) into forge.yaml:${RESET}`);
3313
+ for (const key of result.added) console.log(` ${GREEN}+${RESET} ${key}`);
3314
+ console.log(`\n Sections already present (unchanged): ${result.alreadyPresent.join(", ") || "none"}`);
3315
+ console.log(`\n All added sections are commented out — edit forge.yaml to enable the ones you need.`);
3316
+ }
3317
+ } else {
3318
+ console.log(`${RED}Unknown config subcommand: ${subcommand ?? "(none)"}${RESET}`);
3319
+ console.log(
3320
+ `Usage: ${CYAN}npx forgedock config migrate [dir]${RESET}\n` +
3321
+ ` Backfills any of the 16 optional forge.yaml sections missing from an existing config (idempotent).`,
3322
+ );
3323
+ exitCode = 1;
3324
+ }
3325
+ break;
3326
+ }
3327
+ case "version":
3328
+ case "--version":
3329
+ case "-v": {
3330
+ // Print the local version immediately — must work offline/instantly,
3331
+ // no network dependency for the primary output (getVersion() reads
3332
+ // package.json off disk only). The latest-version check below is
3333
+ // best-effort: fetchLatestVersion() has its own 5s timeout and never
3334
+ // rejects, so a slow/offline network never blocks or fails this
3335
+ // command. Mirrors the version-check message pattern already used by
3336
+ // update() (see the npm/npx branch above).
3337
+ const localVersion = getVersion();
3338
+ console.log(`forgedock ${localVersion ? `v${localVersion}` : `${YELLOW}version unknown${RESET}`}`);
3339
+
3340
+ const latestVersion = await fetchLatestVersion();
3341
+ if (latestVersion && localVersion && compareVersions(latestVersion, localVersion) > 0) {
3342
+ console.log(
3343
+ ` ${GREEN}New version available: v${latestVersion}${RESET} (you have v${localVersion}). ` +
3344
+ `Run ${CYAN}npx forgedock update${RESET} to fetch it.`,
3345
+ );
3346
+ }
3347
+ break;
3348
+ }
3203
3349
  case "help":
3204
3350
  case "--help":
3205
3351
  case "-h":