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/runner.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  * listCommands(commandsDir) → string[] (available command names)
17
17
  * loadCommandSpec(commandsDir, name) → {path,name,content}
18
18
  * buildSystemPrompt(spec, opts) → string
19
+ * buildCliSystemPrompt(spec) → string (cli-backend system prompt)
19
20
  * buildUserMessage(name, args) → string
20
21
  * TOOL_DEFINITIONS → object[] (Anthropic tool schemas)
21
22
  * truncateToolResult(content) → string (cap + truncation marker)
@@ -42,11 +43,15 @@ import {
42
43
  writeFileSync,
43
44
  readdirSync,
44
45
  mkdirSync,
46
+ mkdtempSync,
47
+ rmSync,
45
48
  realpathSync,
46
49
  } from "fs";
47
50
  import { join, dirname, basename, relative, isAbsolute } from "path";
51
+ import os from "os";
48
52
  import { execSync, spawnSync } from "child_process";
49
53
  import { parseForgeYaml, resolveModelAlias } from "./forge-utils.mjs";
54
+ import { DEFAULT_SPAWN_MAX_BUFFER_BYTES } from "./cli-spawn-shared.mjs";
50
55
 
51
56
  const DEFAULT_MODEL = "claude-sonnet-5";
52
57
  const DEFAULT_MAX_ITERATIONS = 50;
@@ -55,6 +60,94 @@ const DEFAULT_MAX_TOKENS = 16384;
55
60
  // generous enough for CI steps (git clones, test suites) while bounding
56
61
  // the worst-case hang to 5 minutes. Override via FORGEDOCK_BASH_TIMEOUT (ms).
57
62
  const DEFAULT_BASH_TIMEOUT_MS = 5 * 60 * 1000;
63
+ // Default wall-clock limit for a single `claude --print` CLI-backend
64
+ // invocation (issue #2003). This bounds an entire command run (the CLI's own
65
+ // internal tool-use loop), not one bash step, so it is deliberately larger
66
+ // than DEFAULT_BASH_TIMEOUT_MS. Override via FORGEDOCK_CLI_TIMEOUT_MS (ms).
67
+ const DEFAULT_CLI_TIMEOUT_MS = 15 * 60 * 1000;
68
+ // Short bound for the `claude --version` presence probe used by backend
69
+ // auto-detection — this must never make --dry-run (or a live run) hang, so
70
+ // it is far shorter than DEFAULT_CLI_TIMEOUT_MS. Mirrors the timeout already
71
+ // used by the `claude --version` doctor check in bin/forgedock.mjs.
72
+ const CLI_PROBE_TIMEOUT_MS = 5000;
73
+ /**
74
+ * Wraps a Set in a Proxy that blocks add()/delete()/clear(), so the
75
+ * returned collection is genuinely read-only — not just Object.frozen.
76
+ *
77
+ * Note: `Object.freeze(new Set(...))` does NOT prevent mutation. Set's
78
+ * mutator methods (add/delete/clear) operate on an internal [[SetData]]
79
+ * slot, not on the object's own enumerable properties, so Object.freeze()
80
+ * — which only locks down property descriptors — has no effect on them;
81
+ * `.add()` on a frozen Set still silently succeeds. A Proxy trapping the
82
+ * mutator method names is the only reliable way to make a Set read-only.
83
+ *
84
+ * Two known, intentionally-accepted deviations from a plain Set (neither
85
+ * is exercised by any current caller — grep confirms only `.has()` and
86
+ * spread/iteration are used):
87
+ * - `structuredClone()` throws on a Proxy (no native [[SetData]] slot to
88
+ * brand-check), where it would succeed on a plain Set.
89
+ * - Without the `constructor` passthrough below, `.constructor` would
90
+ * resolve to a bound wrapper rather than `Set` itself. Special-cased
91
+ * here so `VALID_BACKENDS.constructor === Set` still holds.
92
+ */
93
+ function readOnlySet(values) {
94
+ const target = new Set(values);
95
+ return new Proxy(target, {
96
+ get(t, prop, receiver) {
97
+ if (prop === "add" || prop === "delete" || prop === "clear") {
98
+ return () => {
99
+ throw new TypeError(
100
+ "VALID_BACKENDS is read-only and must not be mutated.",
101
+ );
102
+ };
103
+ }
104
+ // Return the real Set constructor, not a bound wrapper — keeps
105
+ // `VALID_BACKENDS.constructor === Set` true, matching a plain Set.
106
+ if (prop === "constructor") return Set;
107
+ const value = Reflect.get(t, prop, t);
108
+ return typeof value === "function" ? value.bind(t) : value;
109
+ },
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Valid values for the `backend` option / --backend flag / FORGEDOCK_BACKEND
115
+ * env. Exported (issue #2013) so bin/forgedock.mjs's CLI-layer `--backend`
116
+ * flag validation can reuse this exact Set instead of maintaining its own
117
+ * independently-hardcoded copy — keeps both layers' "Must be one of: ..."
118
+ * error messages structurally unable to drift apart.
119
+ *
120
+ * Read-only (issue #2075): every importer (bin/engine.mjs, bin/forgedock.mjs)
121
+ * holds a reference to this exact instance, not a copy. Without the
122
+ * read-only wrapper above, any consumer calling .add()/.delete()/.clear()
123
+ * on it would silently change the accepted backend values — and the "Must
124
+ * be one of: ..." error-message wording — for every other consumer
125
+ * simultaneously. Treat it as immutable; mutation attempts throw.
126
+ */
127
+ export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
128
+ // Per-process memoization for isClaudeCliAvailable(), keyed by `cwd` (issue
129
+ // #2011). `runCommand()` calls resolveBackend() — and therefore, for the
130
+ // default "auto" backend, isClaudeCliAvailable() — unconditionally on every
131
+ // invocation, including every `--dry-run`. Without caching, a process that
132
+ // calls runCommand() repeatedly (e.g. bin/batch-runner.mjs driving many
133
+ // commands, or an orchestration loop) re-spawns `claude --version` on every
134
+ // single call.
135
+ //
136
+ // Bounded on two axes (issues #2057, #2058 — review findings on PR #2056):
137
+ // 1. TTL (`CLI_AVAILABILITY_CACHE_TTL_MS`): an entry older than the TTL is
138
+ // treated as absent and re-probed, so a `claude` install/uninstall that
139
+ // happens mid-run of a long-lived process (e.g. an orchestration loop)
140
+ // is picked up within one TTL window instead of staying stale for the
141
+ // rest of the process's lifetime.
142
+ // 2. Max size (`CLI_AVAILABILITY_CACHE_MAX_SIZE`): entries beyond the cap
143
+ // evict the oldest (insertion-order, via `Map`'s iteration order and a
144
+ // delete+re-set on refresh) so a process that probes an unbounded
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.
148
+ const CLI_AVAILABILITY_CACHE_TTL_MS = 60_000;
149
+ const CLI_AVAILABILITY_CACHE_MAX_SIZE = 100;
150
+ const cliAvailabilityCache = new Map();
58
151
  // Cap tool-result payloads so a large file read or verbose command does not
59
152
  // blow the context window in a single turn.
60
153
  const MAX_TOOL_RESULT_CHARS = 100_000;
@@ -106,6 +199,398 @@ export function resolveConfiguredDefaultModel(cwd) {
106
199
  }
107
200
  }
108
201
 
202
+ // ---------------------------------------------------------------------------
203
+ // Backend selection (issue #2003) — CLI vs API execution backend
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * Probe whether the Claude Code CLI (`claude`) is present and responds on
208
+ * this machine, so `forgedock run` can drive it directly instead of
209
+ * requiring a separate, billable ANTHROPIC_API_KEY.
210
+ *
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.
221
+ *
222
+ * Bounded by CLI_PROBE_TIMEOUT_MS so a hung or misbehaving `claude` binary
223
+ * can never make backend resolution (and therefore --dry-run, or the start
224
+ * of a live run) hang indefinitely. Any failure — not installed, not on
225
+ * PATH, times out, non-zero exit — is treated as "not available" and never
226
+ * throws; the caller (resolveBackend) falls back to the API backend.
227
+ *
228
+ * Memoized per `cwd` for up to `CLI_AVAILABILITY_CACHE_TTL_MS` (see
229
+ * `cliAvailabilityCache` above, issues #2011, #2057, #2058) — the first call
230
+ * for a given `cwd` pays the probe cost; every subsequent call for that same
231
+ * `cwd` within the TTL window returns the cached result instantly, with no
232
+ * new child process spawned. Once the TTL elapses the entry is treated as
233
+ * absent and re-probed, and the cache never grows past
234
+ * `CLI_AVAILABILITY_CACHE_MAX_SIZE` distinct `cwd` entries (oldest evicted
235
+ * first).
236
+ *
237
+ * @param {string} [cwd] - Working directory for the probe (default cwd).
238
+ * @param {object} [opts]
239
+ * @param {typeof execSync} [opts.execImpl] - Test seam only — production
240
+ * callers must never override this; built-in ESM modules like
241
+ * `node:child_process` export non-configurable bindings that `node:test`'s
242
+ * `mock.method` cannot redefine, so tests inject a stub here directly
243
+ * instead. Mirrors the existing `bin` test-seam parameter on
244
+ * `runCliBackend()`.
245
+ * @returns {boolean}
246
+ */
247
+ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync } = {}) {
248
+ const cached = cliAvailabilityCache.get(cwd);
249
+ if (cached && Date.now() - cached.cachedAt < CLI_AVAILABILITY_CACHE_TTL_MS) {
250
+ return cached.available;
251
+ }
252
+ let available;
253
+ try {
254
+ execImpl("claude --version", {
255
+ cwd,
256
+ stdio: ["ignore", "pipe", "ignore"],
257
+ encoding: "utf-8",
258
+ timeout: CLI_PROBE_TIMEOUT_MS,
259
+ });
260
+ available = true;
261
+ } catch {
262
+ available = false;
263
+ }
264
+ // Re-inserting an existing key moves it to the end of Map's iteration
265
+ // order (delete+set), so eviction below always drops the true oldest
266
+ // entry — whether "oldest" means "never refreshed" or "not refreshed in
267
+ // the longest time."
268
+ cliAvailabilityCache.delete(cwd);
269
+ cliAvailabilityCache.set(cwd, { available, cachedAt: Date.now() });
270
+ if (cliAvailabilityCache.size > CLI_AVAILABILITY_CACHE_MAX_SIZE) {
271
+ const oldestKey = cliAvailabilityCache.keys().next().value;
272
+ cliAvailabilityCache.delete(oldestKey);
273
+ }
274
+ return available;
275
+ }
276
+
277
+ /**
278
+ * Shared backend-resolution primitive (issue #2026) used by both
279
+ * `resolveBackend()` (below, `forgedock run`'s engine ladder — issue #2003)
280
+ * and `resolveEnrichBackend()` (bin/init-enrich.mjs's init-enrichment
281
+ * ladder — issue #2004). Both ladders share the exact same two steps — an
282
+ * explicit override wins outright, otherwise probe the local Claude Code
283
+ * CLI and prefer it when present — but diverge on (a) which override
284
+ * values are valid and whether an invalid one throws or is silently
285
+ * ignored, and (b) what to return when the CLI probe fails (`resolveBackend`
286
+ * always falls back to `"api"`; `resolveEnrichBackend` falls back to `"api"`
287
+ * only if `ANTHROPIC_API_KEY` is set, else `"none"`). Rather than force
288
+ * those into one shape, this shared helper implements exactly the common
289
+ * two steps and defers to a `cliFallback` callback for the diverging tail,
290
+ * and defers override *validation* (throw vs. fall-through) entirely to
291
+ * the caller — this function only performs a membership check, it never
292
+ * throws. This removes the duplicated CLI-probe-and-branch logic while
293
+ * preserving each caller's distinct, already-shipped behavior byte-for-byte.
294
+ *
295
+ * @param {object} opts
296
+ * @param {string} [opts.override] - Pre-validated override value. Returned
297
+ * as-is when it is a member of `validOverrides`; otherwise ignored (the
298
+ * caller is responsible for deciding whether an unrecognized value should
299
+ * throw or fall through — this helper never throws).
300
+ * @param {Set<string>} opts.validOverrides - The set of override values this
301
+ * caller accepts outright.
302
+ * @param {string} [opts.cwd] - Working directory for the CLI probe.
303
+ * @param {Function} [opts.isCliAvailableFn] - Injectable CLI-presence probe.
304
+ * Defaults to `isClaudeCliAvailable` (which is itself memoized per `cwd` —
305
+ * issue #2011). Callers MUST pass this through rather than reimplementing
306
+ * probing, to preserve that cache.
307
+ * @param {Function} opts.cliFallback - Called (no args) when no valid
308
+ * override was given AND the CLI probe failed. Must return the caller's
309
+ * fallback backend value (e.g. `"api"`, or `"api"|"none"` depending on an
310
+ * API key check).
311
+ * @returns {string} The resolved backend value.
312
+ */
313
+ export function resolveBackendLadder({
314
+ override,
315
+ validOverrides,
316
+ cwd = process.cwd(),
317
+ isCliAvailableFn = isClaudeCliAvailable,
318
+ cliFallback,
319
+ }) {
320
+ if (override !== undefined && validOverrides.has(override)) return override;
321
+ if (isCliAvailableFn(cwd)) return "cli";
322
+ return cliFallback();
323
+ }
324
+
325
+ /**
326
+ * Resolve which execution backend `runCommand()` should use.
327
+ *
328
+ * Ladder (issue #2003):
329
+ * 1. An explicit `"cli"` or `"api"` request always wins outright — no
330
+ * probing, no fallback. This is what `--backend cli|api` and
331
+ * `FORGEDOCK_BACKEND=cli|api` produce.
332
+ * 2. `"auto"` (the default) probes `isClaudeCliAvailable()`: prefers the
333
+ * CLI backend when a working `claude` install is detected (reuses
334
+ * whatever credentials the CLI already has — Pro/Max OAuth or a
335
+ * CLI-managed key — with no ANTHROPIC_API_KEY required), otherwise
336
+ * falls back to the API backend unchanged (existing behavior for every
337
+ * caller that never opts in to this feature).
338
+ *
339
+ * Delegates its shared "override-or-probe-CLI" shape to
340
+ * `resolveBackendLadder()` (issue #2026) — this function retains its own
341
+ * `VALID_BACKENDS` throw-on-invalid check (NOT shared; see that helper's
342
+ * doc comment) and its own no-API-key-check fallback (always `"api"`,
343
+ * unconditionally — the API key itself is validated lazily downstream, only
344
+ * if/when the api backend is actually selected).
345
+ *
346
+ * @param {object} [opts]
347
+ * @param {string} [opts.requested] - "cli" | "api" | "auto" (default "auto").
348
+ * @param {string} [opts.cwd] - Working directory for the CLI probe.
349
+ * @returns {"cli"|"api"}
350
+ */
351
+ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {}) {
352
+ if (!VALID_BACKENDS.has(requested)) {
353
+ throw new Error(
354
+ `Invalid backend "${requested}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`,
355
+ );
356
+ }
357
+ return resolveBackendLadder({
358
+ override: requested === "auto" ? undefined : requested,
359
+ validOverrides: new Set(["cli", "api"]),
360
+ cwd,
361
+ cliFallback: () => "api",
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Run a command via the local Claude Code CLI instead of the Anthropic SDK
367
+ * (issue #2003's "cli" backend). Shells out to headless print mode
368
+ * (`claude --print "<message>" --dangerously-skip-permissions`), reusing
369
+ * whatever the CLI is already authenticated with — no ANTHROPIC_API_KEY is
370
+ * read or required anywhere on this path.
371
+ *
372
+ * SECURITY — argv array, NEVER `shell: true`: `userMessage` is built from
373
+ * `buildUserMessage(commandName, args)`, and `args` originates from
374
+ * user-supplied CLI arguments to `forgedock run <command> <args...>` — it is
375
+ * untrusted input from this function's point of view. `spawnSync("claude",
376
+ * [...argv], { shell: false (default) })` passes each argument as a discrete,
377
+ * unparsed argv element: the target process receives `userMessage` as one
378
+ * literal string, with no shell ever tokenizing or expanding it, so shell
379
+ * metacharacters (`$(...)`, backticks, `;`, `&&`, `|`, `!`) inside it cannot
380
+ * trigger command injection. An earlier version of this function built a
381
+ * shell command *string* (`claude --print "<quoted message>" ...`) run via
382
+ * `spawnSync(command, { shell: true })` with hand-rolled quoting
383
+ * (backslash/double-quote escaping only) — that does NOT neutralize
384
+ * `$(...)`/backtick/`!` expansion inside POSIX double quotes and was an
385
+ * exploitable injection (verified: a crafted `userMessage` could execute
386
+ * arbitrary shell commands with this process's privileges). Do not
387
+ * reintroduce `shell: true` or string-command interpolation here.
388
+ *
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
392
+ * `.cmd`/`.bat` file, using a properly escaped mechanism, and argv elements
393
+ * are never parsed as shell syntax (verified empirically on Windows: a
394
+ * malicious-looking argv element is delivered to the child process
395
+ * byte-for-byte, not executed). This is the correct fix for the
396
+ * `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.
402
+ *
403
+ * `--dangerously-skip-permissions` mirrors the established headless-CI
404
+ * invocation pattern already documented in docs/CI.md and
405
+ * templates/workflows/forgedock-review.yml: without it, the CLI would block
406
+ * on an interactive permission prompt that a headless caller can never
407
+ * answer, hanging until FORGEDOCK_CLI_TIMEOUT_MS/DEFAULT_CLI_TIMEOUT_MS
408
+ * kills it.
409
+ *
410
+ * SYSTEM PROMPT / COMMAND SPEC DELIVERY (issue #2019): `systemPrompt` (built
411
+ * by `buildCliSystemPrompt(spec)` — see below) is written to a private
412
+ * temp file and forwarded via `--append-system-prompt-file <path>`, NOT
413
+ * passed inline as a `--system-prompt`/`--append-system-prompt` argv string.
414
+ * Two reasons this matters:
415
+ * 1. Size: a command spec like `commands/work-on.md` is 100+ KB, and the
416
+ * full `commands/*.md` corpus is over 1MB. Windows' CreateProcess has a
417
+ * ~32K character command-line limit; spawnSync here uses `shell: false`
418
+ * so it hits that OS-level argv limit directly. A file path is always
419
+ * short regardless of spec size.
420
+ * 2. Semantics: `--append-system-prompt-file` *appends* to the CLI's own
421
+ * default system prompt (tool descriptions, environment info, etc.),
422
+ * matching how a real `/work-on` slash-command invocation inside Claude
423
+ * Code behaves — the ForgeDock spec augments the CLI's native
424
+ * capabilities rather than replacing them. `--system-prompt-file` would
425
+ * instead *replace* the CLI's default prompt entirely, which would
426
+ * strip the CLI of its own operating instructions.
427
+ * The temp file lives in a per-call `mkdtempSync` directory and is always
428
+ * removed in a `finally` block, covering the timeout/spawn-error/non-zero-exit
429
+ * paths as well as the success path.
430
+ *
431
+ * Model selection is intentionally NOT forwarded to the CLI in this first
432
+ * increment — the CLI backend uses whatever model the `claude` CLI itself is
433
+ * configured for. `opts.model`/FORGEDOCK_MODEL only affects the API backend.
434
+ * Likewise, structured token usage is not available the same way the
435
+ * Anthropic SDK exposes it; `usage` is reported as `null` (an already
436
+ * fully-supported value throughout renderSummaryCard/renderDryRun).
437
+ *
438
+ * @param {object} opts
439
+ * @param {{path: string, name: string, content: string}} opts.spec
440
+ * @param {string} opts.userMessage
441
+ * @param {string} [opts.systemPrompt] - CLI-appropriate system prompt (see
442
+ * `buildCliSystemPrompt`), forwarded via `--append-system-prompt-file`.
443
+ * Omitted/empty is tolerated (no flag is added) so existing callers that
444
+ * have not been updated yet do not break, but production callers should
445
+ * always supply it — see issue #2019.
446
+ * @param {string[]} [opts.args]
447
+ * @param {string} opts.cwd
448
+ * @param {{log: Function, error?: Function}} [opts.logger]
449
+ * @param {string} [opts.bin] - Executable to invoke (default "claude"). Test
450
+ * seam only — production callers must never override this; it exists so
451
+ * tests can point at a controlled fake binary instead of either invoking
452
+ * the real `claude` CLI (slow, non-deterministic, real side effects) or
453
+ * fighting platform-specific PATH-resolution semantics to shim "claude"
454
+ * itself (Windows resolves a bare executable name via the *calling*
455
+ * process's search path at the OS level, which is not reliably
456
+ * overridable per-call from within the same process).
457
+ * @param {Function} [opts.spawnFn] - Injectable replacement for
458
+ * `child_process.spawnSync` (default `spawnSync`). Test seam only —
459
+ * production callers must never override this. Mirrors the identical
460
+ * `spawnFn` seam on `bin/init-enrich-cli.mjs`'s `enrich()` (issue #2033):
461
+ * both functions spawn the local `claude` CLI in headless print mode, so
462
+ * sharing the same seam shape lets tests exercise both with equivalent
463
+ * fixtures and makes future divergence between the two easier to catch.
464
+ * This is additive to the existing `bin` override seam above — tests may
465
+ * still use a real fake-binary-on-disk + real `spawnSync` (the original
466
+ * seam), or fully mock the call via `spawnFn` (no fake binary needed).
467
+ * @returns {{status: string, command: string, iterations: number, stopReason: string, usage: null, model: string, backend: "cli"}}
468
+ */
469
+ export function runCliBackend({
470
+ spec,
471
+ userMessage,
472
+ systemPrompt = "",
473
+ args = [],
474
+ cwd,
475
+ logger = console,
476
+ bin = "claude",
477
+ spawnFn = spawnSync,
478
+ }) {
479
+ const rawTimeout = parseInt(process.env.FORGEDOCK_CLI_TIMEOUT_MS, 10);
480
+ const timeoutMs =
481
+ Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : DEFAULT_CLI_TIMEOUT_MS;
482
+
483
+ // Write the system prompt (command spec + framing) to a private temp file
484
+ // rather than passing it inline as an argv string — see the SYSTEM PROMPT
485
+ // block comment above for why. The directory is created fresh per call and
486
+ // always removed in `finally`, regardless of how spawnSync exits below.
487
+ //
488
+ // `tmpDir` is declared here (outside `try`) so `finally` below can see it,
489
+ // but the `mkdtempSync`/`writeFileSync` calls themselves now run *inside*
490
+ // the `try` block (issue #2061 — review finding on PR #2060) rather than
491
+ // before it: if either call throws (disk full, EACCES on a locked-down
492
+ // temp dir, etc.), that throw must still be covered by the same `finally`
493
+ // cleanup path, not bypass it. `tmpDir` is assigned immediately after
494
+ // `mkdtempSync` succeeds — before `writeFileSync` runs — so a failure in
495
+ // `writeFileSync` still leaves `tmpDir` set and the already-created
496
+ // directory gets cleaned up.
497
+ let tmpDir = null;
498
+ let cliArgs = ["--print", userMessage, "--dangerously-skip-permissions"];
499
+
500
+ try {
501
+ if (systemPrompt) {
502
+ const createdDir = mkdtempSync(join(os.tmpdir(), "forgedock-cli-system-prompt-"));
503
+ tmpDir = createdDir;
504
+ const systemPromptPath = join(createdDir, "system-prompt.txt");
505
+ writeFileSync(systemPromptPath, systemPrompt, "utf-8");
506
+ cliArgs = [
507
+ "--print",
508
+ userMessage,
509
+ "--append-system-prompt-file",
510
+ systemPromptPath,
511
+ "--dangerously-skip-permissions",
512
+ ];
513
+ }
514
+
515
+ // Scrub the Anthropic API key from the child environment. This backend
516
+ // runs with `--dangerously-skip-permissions` (no confirmation gate on any
517
+ // tool call, including bash), and the runner is designed to feed
518
+ // untrusted third-party content (issue/PR bodies via gh) into the model
519
+ // loop — so a prompt-injection payload could otherwise issue a bash
520
+ // command that exfiltrates the key straight out of its own environment.
521
+ // Mirrors the existing scrub in the `run_bash` tool handler below.
522
+ // GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
523
+ // intact — only the Anthropic key is removed.
524
+ const childEnv = { ...process.env };
525
+ delete childEnv.ANTHROPIC_API_KEY;
526
+
527
+ // No `shell` option (defaults to false): argv is passed as discrete,
528
+ // unparsed elements — see the SECURITY note above. This also correctly
529
+ // resolves the Windows `.cmd` shim without needing shell:true.
530
+ const result = spawnFn(bin, cliArgs, {
531
+ cwd,
532
+ encoding: "utf-8",
533
+ maxBuffer: DEFAULT_SPAWN_MAX_BUFFER_BYTES,
534
+ timeout: timeoutMs,
535
+ env: childEnv,
536
+ });
537
+
538
+ const stdout = result.stdout ? String(result.stdout) : "";
539
+ const stderr = result.stderr ? String(result.stderr) : "";
540
+
541
+ // Same timeout-detection shape as run_bash below: ETIMEDOUT is the
542
+ // authoritative Node-initiated kill signal; the elapsed-time fallback is
543
+ // intentionally omitted here since spawnSync's own `timeout` + ETIMEDOUT is
544
+ // reliable for this single, non-looped invocation.
545
+ const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
546
+ if (timedOut) {
547
+ const timeoutSecs = Math.round(timeoutMs / 1000);
548
+ throw new Error(
549
+ `claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
550
+ `Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.`,
551
+ );
552
+ }
553
+ if (result.error) {
554
+ throw new Error(`Failed to invoke claude CLI: ${result.error.message}`);
555
+ }
556
+
557
+ const output = (stdout + stderr).trim();
558
+ if (output) logger.log(output);
559
+
560
+ if (result.status !== 0) {
561
+ const err = new Error(
562
+ `claude CLI exited with status ${result.status ?? "?"}. See output above for details.`,
563
+ );
564
+ err.code = "CLI_BACKEND_FAILED";
565
+ throw err;
566
+ }
567
+
568
+ logger.log(
569
+ renderSummaryCard({
570
+ command: spec.name,
571
+ args,
572
+ iterations: 1,
573
+ stopReason: "cli_exit_0",
574
+ usage: null,
575
+ }),
576
+ );
577
+
578
+ return {
579
+ status: "complete",
580
+ command: spec.name,
581
+ iterations: 1,
582
+ stopReason: "cli_exit_0",
583
+ usage: null,
584
+ model: "cli",
585
+ backend: "cli",
586
+ };
587
+ } finally {
588
+ if (tmpDir) {
589
+ rmSync(tmpDir, { recursive: true, force: true });
590
+ }
591
+ }
592
+ }
593
+
109
594
  // ---------------------------------------------------------------------------
110
595
  // Command spec resolution
111
596
  // ---------------------------------------------------------------------------
@@ -231,6 +716,36 @@ export function buildSystemPrompt(spec, opts = {}) {
231
716
  .join("\n");
232
717
  }
233
718
 
719
+ /**
720
+ * Assemble the system prompt to forward to the CLI backend (issue #2019).
721
+ *
722
+ * This is deliberately NOT `buildSystemPrompt()` reused verbatim:
723
+ * `buildSystemPrompt()` was written for the API backend's custom, minimal
724
+ * 3-tool loop (`read_file`/`write_file`/`run_bash`) and explicitly tells the
725
+ * model "You have three tools to do real work" — that claim is false for the
726
+ * CLI backend, which runs inside the full `claude` CLI with its own native
727
+ * tool set (Read/Write/Edit/Bash/etc.) and its own default system prompt.
728
+ * Passing the API-flavored prompt to the CLI via `--append-system-prompt-file`
729
+ * would misinform the model about which tools it actually has.
730
+ *
731
+ * Forwarded via `--append-system-prompt-file` (see `runCliBackend`), which
732
+ * *appends* to the CLI's own default system prompt rather than replacing it
733
+ * — the CLI keeps its normal tool descriptions/environment info, with the
734
+ * ForgeDock command specification layered on top, mirroring how invoking
735
+ * `/${spec.name}` as a real Claude Code slash command would behave.
736
+ *
737
+ * @param {{name: string, content: string}} spec
738
+ * @returns {string}
739
+ */
740
+ export function buildCliSystemPrompt(spec) {
741
+ return [
742
+ `You are executing the ForgeDock "/${spec.name}" command. Follow the command specification below exactly, using your normal available tools to do the work (file edits, git, gh, running scripts/build/test commands, etc.). Post FORGE annotations to GitHub via the gh CLI exactly as the spec instructs. Do not ask the user questions — this is a headless run. When the command is fully complete, stop and emit a concise final summary of what was accomplished.`,
743
+ ``,
744
+ `=== COMMAND SPECIFICATION (commands/${spec.name}.md) ===`,
745
+ spec.content,
746
+ ].join("\n");
747
+ }
748
+
234
749
  /**
235
750
  * Build the initial user message equivalent to the Claude Code slash invocation.
236
751
  *
@@ -603,7 +1118,7 @@ export function getToolHandlers(cwd) {
603
1118
  const maxBuffer =
604
1119
  Number.isFinite(rawMaxBuffer) && rawMaxBuffer > 0
605
1120
  ? rawMaxBuffer
606
- : 50 * 1024 * 1024;
1121
+ : DEFAULT_SPAWN_MAX_BUFFER_BYTES;
607
1122
  const result = spawnSync(command, {
608
1123
  cwd,
609
1124
  encoding: "utf-8",
@@ -698,25 +1213,53 @@ export function getToolHandlers(cwd) {
698
1213
 
699
1214
  /**
700
1215
  * Render the dry-run preview: what would be sent to the API, no network.
701
- * @param {{spec: object, systemPrompt: string, userMessage: string, model: string, maxIterations: number}} ctx
1216
+ *
1217
+ * `systemPrompt` in `ctx` must already be the backend-appropriate prompt —
1218
+ * callers resolve `backend === "cli" ? cliSystemPrompt : systemPrompt` before
1219
+ * calling this (see `runCommand`). This function branches the `tools:` line
1220
+ * (and the on-disk-vs-inline framing of the system-prompt line) on `backend`
1221
+ * so the preview accurately reflects what each backend actually sends: the
1222
+ * `api` backend sends `TOOL_DEFINITIONS` (its custom read_file/write_file/
1223
+ * run_bash loop) inline as `system`; the `cli` backend uses the `claude`
1224
+ * CLI's own native tool set and receives the prompt via
1225
+ * `--append-system-prompt-file`, not inline (issue #2019).
1226
+ *
1227
+ * @param {{spec: object, systemPrompt: string, userMessage: string, model: string, maxIterations: number, backend?: "cli"|"api"}} ctx
702
1228
  * @returns {string}
703
1229
  */
704
1230
  export function renderDryRun(ctx) {
705
- const { spec, systemPrompt, userMessage, model, maxIterations } = ctx;
1231
+ const { spec, systemPrompt, userMessage, model, maxIterations, backend } = ctx;
1232
+ const backendLine =
1233
+ backend === "cli"
1234
+ ? `│ backend: cli (claude CLI detected — no ANTHROPIC_API_KEY needed)`
1235
+ : backend === "api"
1236
+ ? `│ backend: api (ANTHROPIC_API_KEY required)`
1237
+ : null;
1238
+ const toolsLine =
1239
+ backend === "cli"
1240
+ ? `│ tools: claude CLI's native tools (Read/Write/Edit/Bash/etc. — not TOOL_DEFINITIONS below)`
1241
+ : `│ tools: ${TOOL_DEFINITIONS.map((t) => t.name).join(", ")}`;
1242
+ const systemPromptLine =
1243
+ backend === "cli"
1244
+ ? `│ system prompt: ${systemPrompt.length} chars (appended to CLI's default via --append-system-prompt-file)`
1245
+ : `│ system prompt: ${systemPrompt.length} chars`;
706
1246
  return [
707
1247
  `┌─ ForgeDock run (dry-run) ───────────────────────────────`,
708
1248
  `│ command: /${spec.name}`,
709
1249
  `│ spec: ${spec.path}`,
1250
+ backendLine,
710
1251
  `│ model: ${model}`,
711
1252
  `│ max iterations: ${maxIterations}`,
712
- `│ tools: ${TOOL_DEFINITIONS.map((t) => t.name).join(", ")}`,
713
- `│ system prompt: ${systemPrompt.length} chars`,
1253
+ toolsLine,
1254
+ systemPromptLine,
714
1255
  `│ user message: ${userMessage}`,
715
1256
  `└─────────────────────────────────────────────────────────`,
716
1257
  ``,
717
1258
  `(dry-run) No API call made. Set ANTHROPIC_API_KEY and install`,
718
1259
  `@anthropic-ai/sdk, then drop --dry-run to execute the pipeline.`,
719
- ].join("\n");
1260
+ ]
1261
+ .filter((line) => line !== null && line !== undefined)
1262
+ .join("\n");
720
1263
  }
721
1264
 
722
1265
  /**
@@ -771,6 +1314,15 @@ export function renderSummaryCard(ctx) {
771
1314
  * `cwd`) > hardcoded DEFAULT_MODEL.
772
1315
  * @param {number} [opts.maxIterations] - Tool-loop bound.
773
1316
  * @param {boolean} [opts.dryRun] - Preview without an API call.
1317
+ * @param {string} [opts.backend] - "cli" | "api" | "auto" (default
1318
+ * "auto"). Resolution order when omitted: $FORGEDOCK_BACKEND env > "auto".
1319
+ * "auto" prefers the local Claude Code CLI when detected (no
1320
+ * ANTHROPIC_API_KEY needed), else falls back to the API backend. This
1321
+ * precedence is intentional/shipped (issue #2003's accepted acceptance
1322
+ * criteria) and is NOT changed by issue #2020 — that issue only adds a
1323
+ * discoverability notice (below) for the case where the ladder silently
1324
+ * preferred `cli` while an `ANTHROPIC_API_KEY` was also available, so
1325
+ * users know the `--backend api`/`FORGEDOCK_BACKEND=api` override exists.
774
1326
  * @param {{log: Function, error?: Function}} [opts.logger] - Output sink.
775
1327
  * @returns {Promise<{status: string, command: string, [k: string]: any}>}
776
1328
  */
@@ -786,21 +1338,104 @@ export async function runCommand(opts = {}) {
786
1338
  DEFAULT_MODEL,
787
1339
  maxIterations = DEFAULT_MAX_ITERATIONS,
788
1340
  dryRun = false,
1341
+ backend = process.env.FORGEDOCK_BACKEND || "auto",
789
1342
  logger = console,
790
1343
  } = opts;
791
1344
 
792
1345
  const spec = loadCommandSpec(commandsDir, commandName);
793
1346
  const systemPrompt = buildSystemPrompt(spec, { repoRoot: cwd });
1347
+ // CLI-backend-specific prompt (issue #2019) — see buildCliSystemPrompt's
1348
+ // doc comment for why this must NOT be the same string as `systemPrompt`
1349
+ // above (that one is written for the API backend's custom 3-tool loop).
1350
+ const cliSystemPrompt = buildCliSystemPrompt(spec);
794
1351
  const userMessage = buildUserMessage(commandName, args);
795
1352
 
1353
+ // Resolve the backend before dry-run so the preview reports what would
1354
+ // actually run. isClaudeCliAvailable() is bounded by CLI_PROBE_TIMEOUT_MS
1355
+ // and never throws on detection failure, so this cannot make --dry-run (or
1356
+ // the start of a live run) hang or fail even when `claude` is absent or
1357
+ // misbehaving. An explicitly invalid `backend` value DOES throw here —
1358
+ // surfacing a bad --backend/FORGEDOCK_BACKEND value immediately, before any
1359
+ // work is attempted, rather than silently ignoring it.
1360
+ const resolvedBackend = resolveBackend({ requested: backend, cwd });
1361
+
796
1362
  if (dryRun) {
797
- logger.log(renderDryRun({ spec, systemPrompt, userMessage, model, maxIterations }));
798
- return { status: "dry-run", command: spec.name, args, specPath: spec.path, model };
1363
+ logger.log(
1364
+ renderDryRun({
1365
+ spec,
1366
+ systemPrompt: resolvedBackend === "cli" ? cliSystemPrompt : systemPrompt,
1367
+ userMessage,
1368
+ model,
1369
+ maxIterations,
1370
+ backend: resolvedBackend,
1371
+ }),
1372
+ );
1373
+ return {
1374
+ status: "dry-run",
1375
+ command: spec.name,
1376
+ args,
1377
+ specPath: spec.path,
1378
+ model,
1379
+ backend: resolvedBackend,
1380
+ };
1381
+ }
1382
+
1383
+ if (resolvedBackend === "cli") {
1384
+ // Discoverability notice (issue #2020): the "auto" ladder's CLI-first
1385
+ // precedence is intentional, shipped behavior (issue #2003's accepted
1386
+ // acceptance criteria: "prefers CLI when present, falls back to SDK")
1387
+ // and is NOT changed here — reverting it would contradict that accepted
1388
+ // design, exactly as sibling issue #2023 found for the analogous
1389
+ // init-enrich ladder. The actual gap #2020 identified is that this
1390
+ // choice is invisible on a live run: --dry-run reports the resolved
1391
+ // backend via renderDryRun() above, but a live run previously printed
1392
+ // nothing, so a user who has both `claude` on PATH and
1393
+ // ANTHROPIC_API_KEY set has no signal that the ladder picked "cli" over
1394
+ // their key, nor that `--backend api`/`FORGEDOCK_BACKEND=api` exists to
1395
+ // pin the previous (API-only) behavior. Gate strictly on:
1396
+ // - `backend` being the raw, pre-resolution "auto"/default value (an
1397
+ // explicit `--backend cli`/`FORGEDOCK_BACKEND=cli` request needs no
1398
+ // "did you mean to override" hint — the user already chose).
1399
+ // - `apiKey` being truthy (silent for the common CLI-only case with no
1400
+ // key at all — nothing to "switch away from").
1401
+ if (backend === "auto" && apiKey) {
1402
+ logger.log(
1403
+ "Using the claude CLI backend (found on PATH). ANTHROPIC_API_KEY is also set — " +
1404
+ "pass --backend api or set FORGEDOCK_BACKEND=api to use the API backend instead.",
1405
+ );
1406
+ }
1407
+
1408
+ // model/maxIterations only apply to the api backend — the cli backend
1409
+ // uses whatever model the `claude` CLI itself is configured for, and has
1410
+ // no equivalent iteration-cap concept for a single `claude --print`
1411
+ // invocation. Warn (rather than silently drop) when the caller explicitly
1412
+ // supplied either, so the behavior is discoverable outside of --dry-run.
1413
+ // hasOwnProperty on the raw opts (not the destructured, default-applied
1414
+ // values above) is required here: model/maxIterations always resolve to
1415
+ // a computed default, so checking their truthiness would fire this
1416
+ // warning on every single cli-backend run.
1417
+ const ignoredOptions = [];
1418
+ if (Object.prototype.hasOwnProperty.call(opts, "model")) ignoredOptions.push("model");
1419
+ if (Object.prototype.hasOwnProperty.call(opts, "maxIterations"))
1420
+ ignoredOptions.push("maxIterations");
1421
+ if (ignoredOptions.length > 0) {
1422
+ logger.log(
1423
+ `Warning: --${ignoredOptions.join(" and --")} ${
1424
+ ignoredOptions.length > 1 ? "are" : "is"
1425
+ } ignored on the cli backend (backend: cli uses whatever model the ` +
1426
+ `claude CLI itself is configured for). Use --backend api to honor ${
1427
+ ignoredOptions.length > 1 ? "these options" : "this option"
1428
+ }.`,
1429
+ );
1430
+ }
1431
+ return runCliBackend({ spec, userMessage, systemPrompt: cliSystemPrompt, args, cwd, logger });
799
1432
  }
800
1433
 
801
1434
  if (!apiKey) {
802
1435
  const err = new Error(
803
- "ANTHROPIC_API_KEY is not set. Export your Anthropic API key to run the live pipeline, or pass --dry-run to preview.",
1436
+ "ANTHROPIC_API_KEY is not set. Export your Anthropic API key to run the live pipeline, " +
1437
+ "pass --dry-run to preview, or use --backend cli (requires the `claude` CLI on PATH, " +
1438
+ "already authenticated) to run without an API key.",
804
1439
  );
805
1440
  err.code = "NO_API_KEY";
806
1441
  throw err;
@@ -894,6 +1529,7 @@ export async function runCommand(opts = {}) {
894
1529
  stopReason: response.stop_reason,
895
1530
  usage,
896
1531
  model,
1532
+ backend: "api",
897
1533
  };
898
1534
  }
899
1535
 
@@ -929,5 +1565,12 @@ export async function runCommand(opts = {}) {
929
1565
  usage,
930
1566
  }),
931
1567
  );
932
- return { status: "max-iterations", command: spec.name, iterations, usage, model };
1568
+ return {
1569
+ status: "max-iterations",
1570
+ command: spec.name,
1571
+ iterations,
1572
+ usage,
1573
+ model,
1574
+ backend: "api",
1575
+ };
933
1576
  }