forgedock 1.1.11 → 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 (73) 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 +87 -16
  6. package/bin/hooks/pre-tool-use.mjs +331 -10
  7. package/bin/init-enrich-api.mjs +12 -4
  8. package/bin/init-enrich-cli.mjs +328 -0
  9. package/bin/init-enrich.mjs +137 -0
  10. package/bin/journey.mjs +13 -4
  11. package/bin/runner.mjs +653 -10
  12. package/commands/adopt.md +1 -1
  13. package/commands/analytics.md +10 -7
  14. package/commands/audit-agents.md +29 -6
  15. package/commands/audit.md +9 -20
  16. package/commands/autopilot.md +52 -78
  17. package/commands/changelog.md +1 -1
  18. package/commands/ci-audit.md +19 -10
  19. package/commands/cleanup.md +1 -1
  20. package/commands/compat-audit.md +1 -1
  21. package/commands/deploy-info.md +1 -1
  22. package/commands/deploy-pr.md +1 -1
  23. package/commands/design-bench.md +1 -1
  24. package/commands/design-render-critique-loop.md +1 -1
  25. package/commands/diagnose.md +1 -1
  26. package/commands/forge-stats.md +1 -1
  27. package/commands/forgedock-init.md +1 -1
  28. package/commands/geo-audit.md +9 -6
  29. package/commands/incident-response.md +15 -5
  30. package/commands/issue.md +298 -15
  31. package/commands/milestone.md +45 -18
  32. package/commands/optimize.md +1 -1
  33. package/commands/orchestrate/phase-1-resolve.md +20 -5
  34. package/commands/orchestrate/phase-4-execution.md +6 -1
  35. package/commands/orchestrate/phase-5-cleanup.md +43 -2
  36. package/commands/orchestrate/phase-6-report.md +1 -0
  37. package/commands/orchestrate.md +1 -1
  38. package/commands/pipeline-health/phase-4-proposals.md +40 -10
  39. package/commands/pipeline-health/phase-5-report.md +140 -13
  40. package/commands/pipeline-health.md +1 -1
  41. package/commands/pipeline-resume.md +1 -1
  42. package/commands/pipeline-status.md +1 -1
  43. package/commands/qa-sweep.md +10 -4
  44. package/commands/recover-orphans.md +1 -1
  45. package/commands/review-pr-agents/protocols.md +4 -0
  46. package/commands/review-pr-agents.md +7 -4
  47. package/commands/review-pr-staging.md +44 -15
  48. package/commands/review-pr.md +73 -28
  49. package/commands/rollback.md +15 -5
  50. package/commands/security-audit.md +14 -11
  51. package/commands/signal-planner.md +32 -12
  52. package/commands/spec-doctor.md +1 -1
  53. package/commands/sync-ecosystem.md +54 -11
  54. package/commands/test-gate.md +49 -11
  55. package/commands/validate.md +16 -5
  56. package/commands/work-on/build/architect.md +1 -1
  57. package/commands/work-on/build/context.md +1 -1
  58. package/commands/work-on/build/implement.md +2 -2
  59. package/commands/work-on/build/validate.md +3 -1
  60. package/commands/work-on/build.md +1 -1
  61. package/commands/work-on/close.md +1 -1
  62. package/commands/work-on/decompose.md +27 -24
  63. package/commands/work-on/investigate.md +2 -2
  64. package/commands/work-on/remediate.md +1 -1
  65. package/commands/work-on/review.md +5 -3
  66. package/commands/work-on-monolithic.md +1 -1
  67. package/commands/work-on.md +28 -7
  68. package/docs/CI.md +18 -0
  69. package/docs/CONFIG.md +5 -2
  70. package/docs/site/annotations-explained.md +2 -2
  71. package/docs/site/command-reference.md +21 -0
  72. package/docs/spec/review-protocol.md +6 -0
  73. package/package.json +1 -1
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
@@ -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);
@@ -2721,7 +2736,7 @@ function help() {
2721
2736
  ["npx forgedock enable [dir]", "Activate ForgeDock in a directory"],
2722
2737
  ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
2723
2738
  ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
2724
- ["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)"],
2725
2740
  ["npx forgedock run-issue <issue>", "Drive one issue through the durable engine"],
2726
2741
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
2727
2742
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
@@ -2755,46 +2770,88 @@ function help() {
2755
2770
 
2756
2771
  /**
2757
2772
  * `forgedock run <command> [args...]` — execute a ForgeDock command spec
2758
- * directly via the Anthropic API, outside of Claude Code (headless / CI).
2773
+ * headlessly, outside of an interactive Claude Code session (CI or local).
2759
2774
  *
2760
2775
  * 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).
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).
2764
2780
  *
2765
- * The live loop requires ANTHROPIC_API_KEY and the optional @anthropic-ai/sdk
2766
- * 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.
2767
2792
  *
2768
- * 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):
2769
2797
  * 1. --model <id> This flag.
2770
2798
  * 2. $FORGEDOCK_MODEL Env var, below.
2771
2799
  * 3. forge.yaml `agents.default_model` Read from the run's cwd, if present.
2772
2800
  * 4. Hardcoded default "claude-sonnet-5" (bin/runner.mjs DEFAULT_MODEL).
2773
2801
  *
2774
2802
  * 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.
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.
2779
2810
  */
2780
2811
  async function run() {
2781
2812
  const runArgs = restArgs;
2782
2813
  let dryRun = false;
2783
2814
  let model;
2784
2815
  let maxIterations;
2816
+ let backend;
2785
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
+ };
2786
2836
  for (let i = 0; i < runArgs.length; i++) {
2787
2837
  const a = runArgs[i];
2788
2838
  if (a === "--dry-run") {
2789
2839
  dryRun = true;
2790
2840
  } else if (a === "--model") {
2791
- model = runArgs[++i];
2841
+ model = requireFlagValue("--model", i);
2842
+ i++;
2792
2843
  } else if (a.startsWith("--model=")) {
2793
2844
  model = a.slice("--model=".length);
2794
2845
  } else if (a === "--max-iterations") {
2795
- maxIterations = parseInt(runArgs[++i], 10);
2846
+ maxIterations = parseInt(requireFlagValue("--max-iterations", i), 10);
2847
+ i++;
2796
2848
  } else if (a.startsWith("--max-iterations=")) {
2797
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);
2798
2855
  } else {
2799
2856
  positional.push(a);
2800
2857
  }
@@ -2805,12 +2862,25 @@ async function run() {
2805
2862
 
2806
2863
  if (!commandName) {
2807
2864
  process.stderr.write(
2808
- `${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`,
2809
2880
  );
2810
2881
  process.exit(1);
2811
2882
  }
2812
2883
 
2813
- const { runCommand } = await import("./runner.mjs");
2814
2884
  try {
2815
2885
  const result = await runCommand({
2816
2886
  commandsDir: COMMANDS_DIR,
@@ -2822,6 +2892,7 @@ async function run() {
2822
2892
  ...(Number.isInteger(maxIterations) && maxIterations > 0
2823
2893
  ? { maxIterations }
2824
2894
  : {}),
2895
+ ...(backend ? { backend } : {}),
2825
2896
  });
2826
2897
  // Treat a non-clean stop (iteration cap hit, or a max_tokens-truncated
2827
2898
  // turn) as a failed run so CI/headless callers notice.