forgedock 1.0.21 → 1.1.1

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 +227 -174
  2. package/bin/cinema.mjs +320 -0
  3. package/bin/demo.mjs +386 -0
  4. package/bin/engine/phases.mjs +148 -0
  5. package/bin/engine/projector.mjs +32 -0
  6. package/bin/engine/reconcile.mjs +11 -0
  7. package/bin/engine/runlog.mjs +79 -0
  8. package/bin/engine/state.mjs +27 -0
  9. package/bin/engine-cli.mjs +50 -0
  10. package/bin/engine.mjs +138 -0
  11. package/bin/forgedock.mjs +693 -1393
  12. package/bin/init-detect.mjs +95 -0
  13. package/bin/journey.mjs +783 -0
  14. package/bin/runner.mjs +847 -0
  15. package/bin/settings-hook.mjs +112 -0
  16. package/bin/tui.mjs +6 -3
  17. package/commands/autopilot.md +4 -4
  18. package/commands/cleanup.md +85 -39
  19. package/commands/deploy-info.md +11 -5
  20. package/commands/design-bench.md +1 -0
  21. package/commands/design-render-critique-loop.md +1 -0
  22. package/commands/design.md +1 -0
  23. package/commands/forge-stats.md +2 -1
  24. package/commands/issue.md +1 -1
  25. package/commands/optimize.md +1 -1
  26. package/commands/orchestrate.md +346 -34
  27. package/commands/quality-gate.md +168 -80
  28. package/commands/review-pr-agents.md +69 -45
  29. package/commands/review-pr.md +54 -6
  30. package/commands/scope.md +11 -3
  31. package/commands/security-audit.md +24 -16
  32. package/commands/sync-ecosystem.md +2 -2
  33. package/commands/work-on/build/architect.md +47 -3
  34. package/commands/work-on/build/context.md +36 -5
  35. package/commands/work-on/build/implement.md +16 -4
  36. package/commands/work-on/build/validate.md +54 -8
  37. package/commands/work-on/build.md +27 -9
  38. package/commands/work-on/close.md +131 -3
  39. package/commands/work-on/investigate.md +24 -1
  40. package/commands/work-on/review.md +24 -0
  41. package/commands/work-on-monolithic.md +1 -0
  42. package/commands/work-on.md +271 -11
  43. package/docs/BRAND.md +6 -2
  44. package/docs/CONFIG.md +39 -34
  45. package/docs/site/.vitepress/config.mjs +85 -0
  46. package/docs/site/annotations-explained.md +55 -0
  47. package/docs/site/command-learning-path.md +104 -0
  48. package/docs/site/command-reference.md +3 -0
  49. package/docs/site/getting-started.md +42 -13
  50. package/docs/site/index.md +1 -1
  51. package/docs/site/troubleshooting.md +208 -0
  52. package/docs/spec/forge-protocol-v1.md +768 -0
  53. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  54. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  55. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  56. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  57. package/examples/forgedock-demo/README.md +113 -0
  58. package/examples/forgedock-demo/bootstrap.sh +125 -0
  59. package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
  60. package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
  61. package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
  62. package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
  63. package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
  64. package/examples/forgedock-demo/labels.json +21 -0
  65. package/examples/forgedock-demo/package.json +16 -0
  66. package/examples/forgedock-demo/scripts/smoke.js +61 -0
  67. package/examples/forgedock-demo/src/auth.js +28 -0
  68. package/examples/forgedock-demo/src/db.js +66 -0
  69. package/examples/forgedock-demo/src/routes/notes.js +64 -0
  70. package/examples/forgedock-demo/src/server.js +79 -0
  71. package/package.json +13 -3
  72. package/scripts/verify-env-vars.sh +18 -2
  73. package/scripts/verify-host-headers.sh +4 -4
  74. package/scripts/verify-route-registration.sh +2 -2
  75. package/scripts/worktree-lifecycle.sh +202 -0
  76. package/templates/forge.yaml.minimal +44 -0
package/bin/runner.mjs ADDED
@@ -0,0 +1,847 @@
1
+ /**
2
+ * bin/runner.mjs — Standalone command runner for ForgeDock.
3
+ *
4
+ * Decouples ForgeDock from the Claude Code agent loop. Instead of relying on
5
+ * Claude Code to read a `commands/*.md` spec as a slash command, this runner
6
+ * loads the same spec, assembles a system prompt, and drives an Anthropic
7
+ * tool-use loop directly via the Claude SDK — enabling CI/CD runs, headless
8
+ * batch processing, and non-Claude-Code users.
9
+ *
10
+ * This is the foundational increment of the standalone runtime (issue #1151):
11
+ * a generic spec-driven `run` path. Broader per-command parity, subagent
12
+ * spawning, and streaming UI are tracked as follow-ups.
13
+ *
14
+ * Exports:
15
+ * resolveSpecPath(commandsDir, name) → string|null (spec file path)
16
+ * listCommands(commandsDir) → string[] (available command names)
17
+ * loadCommandSpec(commandsDir, name) → {path,name,content}
18
+ * buildSystemPrompt(spec, opts) → string
19
+ * buildUserMessage(name, args) → string
20
+ * TOOL_DEFINITIONS → object[] (Anthropic tool schemas)
21
+ * truncateToolResult(content) → string (cap + truncation marker)
22
+ * resolveBashShell() → string|undefined (explicit shell for run_bash)
23
+ * getToolHandlers(cwd) → Record<string, fn>
24
+ * renderDryRun(ctx) → string
25
+ * renderSummaryCard(ctx) → string
26
+ * runCommand(opts) → Promise<{status, ...}>
27
+ *
28
+ * Design notes:
29
+ * - The Anthropic SDK is a LAZY/optional dependency: it is imported only when
30
+ * a live run is requested (`--dry-run` and all pure helpers need no SDK and
31
+ * no network), keeping `npm install`/`npm test` dependency-free.
32
+ * - The API key is read from ANTHROPIC_API_KEY only — never written to disk
33
+ * or logged.
34
+ * - Path resolution is Windows-safe: command names use `/` separators and are
35
+ * joined with `path` segments. Path traversal (`..`) is rejected.
36
+ */
37
+
38
+ import {
39
+ readFileSync,
40
+ existsSync,
41
+ writeFileSync,
42
+ readdirSync,
43
+ mkdirSync,
44
+ realpathSync,
45
+ } from "fs";
46
+ import { join, dirname, basename, relative, isAbsolute } from "path";
47
+ import { execSync, spawnSync } from "child_process";
48
+
49
+ const DEFAULT_MODEL = "claude-sonnet-4-5";
50
+ const DEFAULT_MAX_ITERATIONS = 50;
51
+ const DEFAULT_MAX_TOKENS = 8192;
52
+ // Default wall-clock limit for a single run_bash command. Chosen to be
53
+ // generous enough for CI steps (git clones, test suites) while bounding
54
+ // the worst-case hang to 5 minutes. Override via FORGEDOCK_BASH_TIMEOUT (ms).
55
+ const DEFAULT_BASH_TIMEOUT_MS = 5 * 60 * 1000;
56
+ // Cap tool-result payloads so a large file read or verbose command does not
57
+ // blow the context window in a single turn.
58
+ const MAX_TOOL_RESULT_CHARS = 100_000;
59
+ // Sentinel appended to a tool result that was sliced to MAX_TOOL_RESULT_CHARS,
60
+ // so the model can tell its input was cut rather than treating it as complete.
61
+ const TRUNCATION_MARKER = "\n…[truncated]";
62
+
63
+ /**
64
+ * Cap a tool-result string to MAX_TOOL_RESULT_CHARS, appending a visible
65
+ * truncation marker when (and only when) the content was actually sliced.
66
+ * Short results are returned unchanged.
67
+ *
68
+ * @param {string} content
69
+ * @returns {string}
70
+ */
71
+ export function truncateToolResult(content) {
72
+ const str = String(content ?? "");
73
+ if (str.length <= MAX_TOOL_RESULT_CHARS) return str;
74
+ return str.slice(0, MAX_TOOL_RESULT_CHARS) + TRUNCATION_MARKER;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Command spec resolution
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Resolve a command name to its spec file under commandsDir.
83
+ *
84
+ * Supports flat (`work-on`) and nested (`work-on/build`,
85
+ * `work-on/build/architect`) command names. A leading slash and a trailing
86
+ * `.md` are tolerated. Returns null if the spec does not exist or the name is
87
+ * empty / contains a path-traversal segment.
88
+ *
89
+ * @param {string} commandsDir - Absolute path to the commands/ directory.
90
+ * @param {string} commandName - e.g. "work-on", "/review-pr", "work-on/build".
91
+ * @returns {string|null} Absolute path to the spec file, or null.
92
+ */
93
+ export function resolveSpecPath(commandsDir, commandName) {
94
+ if (typeof commandName !== "string" || commandName.trim() === "") return null;
95
+ const clean = commandName
96
+ .trim()
97
+ .replace(/^[\\/]+/, "")
98
+ .replace(/\.md$/i, "");
99
+ // Split on BOTH separators — on Windows a backslash in the name would
100
+ // otherwise survive as a single segment and be re-normalized by path.join,
101
+ // bypassing the traversal check below.
102
+ const segments = clean.split(/[\\/]/).filter(Boolean);
103
+ if (segments.length === 0) return null;
104
+ // Reject path traversal — command names must stay within commands/.
105
+ if (segments.some((s) => s === ".." || s === ".")) return null;
106
+ const candidate = join(commandsDir, ...segments) + ".md";
107
+ // Defense-in-depth: the resolved path must remain inside commandsDir even
108
+ // if a segment slipped through (belt-and-suspenders with the check above).
109
+ const rel = relative(commandsDir, candidate);
110
+ if (rel.startsWith("..") || isAbsolute(rel)) return null;
111
+ return existsSync(candidate) ? candidate : null;
112
+ }
113
+
114
+ /**
115
+ * List all available command names (relative to commandsDir, without .md),
116
+ * recursing into nested directories. Names always use `/` separators
117
+ * regardless of platform.
118
+ *
119
+ * @param {string} commandsDir
120
+ * @returns {string[]} Sorted command names.
121
+ */
122
+ export function listCommands(commandsDir) {
123
+ const out = [];
124
+ function walk(dir) {
125
+ let entries;
126
+ try {
127
+ entries = readdirSync(dir, { withFileTypes: true });
128
+ } catch {
129
+ return;
130
+ }
131
+ for (const entry of entries) {
132
+ const full = join(dir, entry.name);
133
+ if (entry.isDirectory()) {
134
+ walk(full);
135
+ } else if (entry.name.endsWith(".md")) {
136
+ const rel = relative(commandsDir, full)
137
+ .split(/[\\/]/)
138
+ .join("/")
139
+ .replace(/\.md$/, "");
140
+ out.push(rel);
141
+ }
142
+ }
143
+ }
144
+ walk(commandsDir);
145
+ return out.sort();
146
+ }
147
+
148
+ /**
149
+ * Load a command spec. Throws a descriptive error (code UNKNOWN_COMMAND) listing
150
+ * available commands if the name does not resolve.
151
+ *
152
+ * @param {string} commandsDir
153
+ * @param {string} commandName
154
+ * @returns {{path: string, name: string, content: string}}
155
+ */
156
+ export function loadCommandSpec(commandsDir, commandName) {
157
+ const path = resolveSpecPath(commandsDir, commandName);
158
+ if (!path) {
159
+ const available = listCommands(commandsDir);
160
+ const err = new Error(
161
+ `Unknown command: "${commandName}"\n\nAvailable commands:\n ${available.join("\n ")}`,
162
+ );
163
+ err.code = "UNKNOWN_COMMAND";
164
+ err.available = available;
165
+ throw err;
166
+ }
167
+ const name = String(commandName).trim().replace(/^\/+/, "").replace(/\.md$/i, "");
168
+ return { path, name, content: readFileSync(path, "utf-8") };
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Prompt assembly
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /**
176
+ * Assemble the system prompt from a loaded spec.
177
+ *
178
+ * @param {{name: string, content: string}} spec
179
+ * @param {{repoRoot?: string}} [opts]
180
+ * @returns {string}
181
+ */
182
+ export function buildSystemPrompt(spec, opts = {}) {
183
+ const { repoRoot } = opts;
184
+ return [
185
+ `You are ForgeDock's standalone command runner. You are executing the "/${spec.name}" command directly via the Anthropic API — NOT inside Claude Code.`,
186
+ ``,
187
+ `Follow the command specification below exactly. You have three tools to do real work:`,
188
+ ` - read_file: read a file from disk`,
189
+ ` - write_file: create or overwrite a file`,
190
+ ` - run_bash: run a shell command (git, gh, scripts/, build/test commands, etc.)`,
191
+ ``,
192
+ `Use run_bash for all git/gh operations and for running scripts from scripts/. 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.`,
193
+ repoRoot ? `\nWorking directory / repo root: ${repoRoot}` : "",
194
+ ``,
195
+ `=== COMMAND SPECIFICATION (commands/${spec.name}.md) ===`,
196
+ spec.content,
197
+ ]
198
+ .filter((line) => line !== false && line !== undefined && line !== null)
199
+ .join("\n");
200
+ }
201
+
202
+ /**
203
+ * Build the initial user message equivalent to the Claude Code slash invocation.
204
+ *
205
+ * @param {string} commandName
206
+ * @param {string[]|string} args
207
+ * @returns {string}
208
+ */
209
+ export function buildUserMessage(commandName, args) {
210
+ const name = String(commandName).trim().replace(/^\/+/, "");
211
+ const argStr = Array.isArray(args) ? args.join(" ") : String(args ?? "");
212
+ return `Execute: /${name} ${argStr}`.trim();
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Tool registry
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /** Anthropic tool-use schemas for the runtime's tool loop. */
220
+ export const TOOL_DEFINITIONS = [
221
+ {
222
+ name: "read_file",
223
+ description:
224
+ "Read the contents of a file. Path must be relative to the working directory. Absolute paths and paths that escape the working directory (e.g. '../') are rejected.",
225
+ input_schema: {
226
+ type: "object",
227
+ properties: {
228
+ path: { type: "string", description: "File path to read (relative to working directory)." },
229
+ },
230
+ required: ["path"],
231
+ },
232
+ },
233
+ {
234
+ name: "write_file",
235
+ description:
236
+ "Create or overwrite a file with the given content. Parent directories are created as needed. Path must be relative to the working directory. Absolute paths and paths that escape the working directory (e.g. '../') are rejected.",
237
+ input_schema: {
238
+ type: "object",
239
+ properties: {
240
+ path: { type: "string", description: "File path to write (relative to working directory)." },
241
+ content: { type: "string", description: "Full file content." },
242
+ },
243
+ required: ["path", "content"],
244
+ },
245
+ },
246
+ {
247
+ name: "run_bash",
248
+ description:
249
+ "Run a shell command in the working directory and return combined stdout/stderr. Use for git, gh, scripts/, build, and test commands.",
250
+ input_schema: {
251
+ type: "object",
252
+ properties: {
253
+ command: { type: "string", description: "Shell command to execute." },
254
+ },
255
+ required: ["command"],
256
+ },
257
+ },
258
+ ];
259
+
260
+ /**
261
+ * Resolve a tool-supplied path against cwd unless it is already absolute.
262
+ * @param {string} cwd
263
+ * @param {string} p
264
+ */
265
+ function resolvePath(cwd, p) {
266
+ return isAbsolute(p) ? p : join(cwd, p);
267
+ }
268
+
269
+ /**
270
+ * Resolve a model-controlled path, confining it to cwd.
271
+ *
272
+ * Absolute paths are rejected outright — the runner is designed to feed
273
+ * untrusted third-party content into the model loop (prompt injection is
274
+ * explicitly in scope), so model-controlled paths must never escape the working
275
+ * directory. A prompt-injection payload could otherwise call
276
+ * read_file("/proc/self/environ") to leak ANTHROPIC_API_KEY into model context,
277
+ * or write_file("/etc/cron.d/evil") for privilege escalation.
278
+ *
279
+ * Mirrors the guard used by resolveSpecPath (the positive example already in
280
+ * this file): reject when relative(cwd, resolved) starts with ".." or is
281
+ * absolute.
282
+ *
283
+ * The lexical checks above only inspect the path *string* — they do not
284
+ * protect against a symlink pre-existing inside cwd (planted by an earlier
285
+ * tool call, or already present on disk) that lexically resolves inside cwd
286
+ * but dereferences at the OS level to a location outside it. To close that
287
+ * gap, the resolved path's *real* (symlink-free) location is also checked
288
+ * against the *real* cwd before returning. write_file targets frequently
289
+ * don't exist yet (its mkdirSync call runs after this function returns), so
290
+ * realpathSync would throw ENOENT if run on the leaf directly — this walks
291
+ * up to the nearest existing ancestor, resolves *that*, and rejoins the
292
+ * non-existent remainder. Unlike bin/registry.mjs's normalizeDir() (a
293
+ * non-security path-normalization helper), there is no fail-open fallback
294
+ * here: this is a security confinement check, so a resolution failure must
295
+ * never be treated as "no symlink present".
296
+ *
297
+ * @param {string} cwd - Absolute path to the allowed root directory.
298
+ * @param {string} p - Model-supplied path (must be relative).
299
+ * @returns {string} - Absolute resolved path (guaranteed inside cwd).
300
+ * @throws {Error} - If p is absolute or resolves outside cwd (lexically
301
+ * or after symlink resolution).
302
+ */
303
+ function resolveConfinedPath(cwd, p) {
304
+ if (isAbsolute(p)) {
305
+ throw new Error(
306
+ `Absolute paths are not permitted. Use a path relative to the working directory. Got: ${p}`,
307
+ );
308
+ }
309
+ const resolved = join(cwd, p);
310
+ const rel = relative(cwd, resolved);
311
+ if (rel.startsWith("..") || isAbsolute(rel)) {
312
+ throw new Error(
313
+ `Path escape detected: "${p}" resolves outside the working directory. Use a path relative to the working directory.`,
314
+ );
315
+ }
316
+
317
+ // Symlink-resolution check: walk up from `resolved` to the nearest
318
+ // existing ancestor (this may be `cwd` itself, if none of the
319
+ // intermediate directories exist yet — the common case for write_file),
320
+ // realpath that ancestor, then rejoin the not-yet-existing remainder.
321
+ const realCwd = realpathSync.native(cwd);
322
+ let existingAncestor = resolved;
323
+ const remainder = [];
324
+ while (!existsSync(existingAncestor)) {
325
+ const parent = dirname(existingAncestor);
326
+ if (parent === existingAncestor) break; // reached filesystem root — give up walking
327
+ remainder.unshift(basename(existingAncestor));
328
+ existingAncestor = parent;
329
+ }
330
+ const realAncestor = realpathSync.native(existingAncestor);
331
+ const realResolved =
332
+ remainder.length > 0 ? join(realAncestor, ...remainder) : realAncestor;
333
+
334
+ const realRel = relative(realCwd, realResolved);
335
+ if (realRel.startsWith("..") || isAbsolute(realRel)) {
336
+ throw new Error(
337
+ `Path escape detected: "${p}" resolves outside the working directory. Use a path relative to the working directory.`,
338
+ );
339
+ }
340
+
341
+ // Residual TOCTOU assumption: we return the lexical `resolved` path rather
342
+ // than `realResolved` so that callers (readFileSync / writeFileSync) work
343
+ // correctly with not-yet-existing paths (where realResolved === resolved
344
+ // anyway, since there is nothing to dereference). The check above verified
345
+ // confinement on the realpath of the nearest *existing* ancestor, but a
346
+ // concurrent actor that mutates the filesystem between this check and the
347
+ // caller's open() call could in theory introduce a symlink that evades the
348
+ // guard — a classic time-of-check / time-of-use (TOCTOU) window.
349
+ //
350
+ // This is acceptable under the current threat model because:
351
+ // 1. Tool calls are executed in a strictly sequential, synchronous
352
+ // `for...of` loop (see the runLive tool-use loop). There is no async
353
+ // gap and no concurrent tool execution within the runner itself.
354
+ // 2. A prompt-injected model therefore cannot create this race — it would
355
+ // need to schedule two tool calls simultaneously, which the loop
356
+ // prevents.
357
+ // 3. Only an independent, external OS process running concurrently could
358
+ // exploit the window, which is outside the stated single-shot
359
+ // prompt-injection threat model.
360
+ //
361
+ // If the runner is ever refactored to execute tool calls concurrently or
362
+ // asynchronously, this assumption MUST be revisited. Mitigations to
363
+ // consider at that point include: operating on `realResolved` directly
364
+ // (eliminating the lexical/realpath split), or opening the file with an
365
+ // O_NOFOLLOW-equivalent flag so the kernel rejects a symlink at open time.
366
+ return resolved;
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Windows bash shim detection
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * Path fragments that identify Windows bash shims — executables that exist on
375
+ * disk but are launchers or redirectors rather than real bash installations.
376
+ * On systems without WSL these shims either fail or open the Windows Store UI.
377
+ *
378
+ * Used by resolveBashShell() to skip shim paths returned by `where bash.exe`,
379
+ * so that a real bash installation (e.g. Git for Windows) is preferred over a
380
+ * shim that happens to appear earlier on PATH.
381
+ *
382
+ * Fragments are matched case-insensitively against the normalised (backslash)
383
+ * form of each candidate path.
384
+ */
385
+ const WINDOWS_BASH_SHIM_FRAGMENTS = [
386
+ "\\WindowsApps\\", // Windows Store app shim tree (opens Store if WSL absent)
387
+ "\\System32\\bash.exe", // WSL inbox launcher (fails without WSL)
388
+ ];
389
+
390
+ /**
391
+ * Return true when a Windows path points to a known bash shim rather than a
392
+ * real bash installation. Shims pass existsSync() but will fail or open the
393
+ * Windows Store UI when WSL is not installed.
394
+ *
395
+ * Matching is case-insensitive and tolerates forward-slash separators so the
396
+ * helper works correctly regardless of how the path was produced.
397
+ *
398
+ * @param {string} p - Candidate path to test.
399
+ * @returns {boolean}
400
+ */
401
+ export function isWindowsBashShim(p) {
402
+ if (typeof p !== "string") return false;
403
+ const normalised = p.replace(/\//g, "\\").toLowerCase();
404
+ return WINDOWS_BASH_SHIM_FRAGMENTS.some((frag) =>
405
+ normalised.includes(frag.toLowerCase()),
406
+ );
407
+ }
408
+
409
+ // Guard so the "no bash found" warning fires at most once per process, even
410
+ // when run_bash is invoked many times in a single runner session.
411
+ let _bashWarningEmitted = false;
412
+
413
+ /**
414
+ * Resolve an explicit bash shell for run_bash, or undefined to fall back to the
415
+ * platform default shell. The system prompt instructs the model to use
416
+ * bash-style git/gh/scripts invocations, so executing under bash keeps behavior
417
+ * consistent across platforms rather than following the host default (cmd.exe
418
+ * on Windows). A FORGEDOCK_SHELL override always wins. When no bash is found we
419
+ * return undefined so execSync falls back to the platform default and non-bash
420
+ * hosts still work.
421
+ *
422
+ * On Windows, discovery order is:
423
+ * 1. FORGEDOCK_SHELL env var (override — wins unconditionally)
424
+ * 2. PATH lookup via `where bash.exe` (catches Scoop, winget, custom prefixes)
425
+ * Known shim paths (WindowsApps, System32\bash.exe) are filtered out so
426
+ * that a real bash installation is preferred over a stub that may fail or
427
+ * open the Windows Store UI if WSL is not installed.
428
+ * 3. Hardcoded candidates: standard Git for Windows (x64 + x86) + Scoop + WSL
429
+ * 4. undefined → platform default shell (cmd.exe); emits a one-time stderr warning
430
+ *
431
+ * @returns {string|undefined} Absolute path to a bash shell, or undefined.
432
+ */
433
+ export function resolveBashShell() {
434
+ // Explicit override always wins (even cmd.exe, if the operator insists).
435
+ const override = process.env.FORGEDOCK_SHELL;
436
+ if (override && override.trim()) return override.trim();
437
+
438
+ if (process.platform === "win32") {
439
+ // 1. PATH-based discovery — covers Scoop, winget, and custom install
440
+ // prefixes that place bash.exe on PATH but not under Program Files.
441
+ // `where.exe` is a standalone tool in System32; run it via the default
442
+ // shell so restricted environments that lack `where` degrade gracefully.
443
+ // All results are filtered to skip known shim paths before the first
444
+ // real bash candidate is accepted (see isWindowsBashShim).
445
+ try {
446
+ const fromPath = execSync("where bash.exe", {
447
+ encoding: "utf-8",
448
+ stdio: ["pipe", "pipe", "pipe"],
449
+ timeout: 3000,
450
+ shell: true,
451
+ })
452
+ .split(/\r?\n/)
453
+ .map((l) => l.trim())
454
+ .filter((l) => l.length > 0 && !isWindowsBashShim(l))
455
+ .find((l) => existsSync(l));
456
+ if (fromPath) return fromPath;
457
+ } catch {
458
+ // `where` failed or returned no results — fall through to hardcoded list.
459
+ }
460
+
461
+ // 2. Hardcoded candidates: standard Git for Windows (machine-wide x64 + x86),
462
+ // Scoop convention (%USERPROFILE%\scoop\apps\git\current\bin\bash.exe),
463
+ // and WSL bash (%SystemRoot%\System32\bash.exe).
464
+ // Note: System32\bash.exe is the WSL inbox launcher — it works when WSL is
465
+ // installed but is a shim that fails without WSL. It is listed last so
466
+ // real Git-for-Windows paths take priority.
467
+ const candidates = [
468
+ join(process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe"),
469
+ join(
470
+ process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)",
471
+ "Git",
472
+ "bin",
473
+ "bash.exe",
474
+ ),
475
+ // Scoop installs Git under %USERPROFILE%\scoop\apps\git\current\.
476
+ join(
477
+ process.env.USERPROFILE || "",
478
+ "scoop",
479
+ "apps",
480
+ "git",
481
+ "current",
482
+ "bin",
483
+ "bash.exe",
484
+ ),
485
+ join(process.env.SystemRoot || "C:\\Windows", "System32", "bash.exe"),
486
+ ];
487
+ const found = candidates.find((c) => c && existsSync(c));
488
+ if (found) return found;
489
+
490
+ // 3. No bash found anywhere — warn the operator once so the silent cmd.exe
491
+ // fallback does not go unnoticed. Writing to stderr keeps stdout clean
492
+ // for callers that pipe or parse runner output.
493
+ if (!_bashWarningEmitted) {
494
+ _bashWarningEmitted = true;
495
+ process.stderr.write(
496
+ "[ForgeDock] Warning: bash not found on this system. run_bash commands " +
497
+ "will execute under cmd.exe, which may not support bash idioms " +
498
+ "(heredocs, scripts/*.sh, single-quote semantics). Install Git for " +
499
+ "Windows (https://gitforwindows.org) or Scoop (`scoop install git`), " +
500
+ "or set FORGEDOCK_SHELL to the absolute path of bash.exe to suppress " +
501
+ "this warning.\n",
502
+ );
503
+ }
504
+ return undefined;
505
+ }
506
+ // POSIX: prefer bash, fall back to /bin/sh (bash-compatible enough).
507
+ return ["/bin/bash", "/usr/bin/bash", "/bin/sh"].find((c) => existsSync(c));
508
+ }
509
+
510
+ /**
511
+ * Build the concrete tool handlers bound to a working directory.
512
+ *
513
+ * Each handler returns a string (the tool_result content). Handlers may throw;
514
+ * the loop catches and reports the error back to the model as an error result.
515
+ *
516
+ * @param {string} cwd
517
+ * @returns {Record<string, (input: object) => string>}
518
+ */
519
+ export function getToolHandlers(cwd) {
520
+ return {
521
+ read_file: ({ path }) => {
522
+ if (!path) throw new Error("read_file requires a 'path'");
523
+ return readFileSync(resolveConfinedPath(cwd, path), "utf-8");
524
+ },
525
+ write_file: ({ path, content }) => {
526
+ if (!path) throw new Error("write_file requires a 'path'");
527
+ const target = resolveConfinedPath(cwd, path);
528
+ mkdirSync(dirname(target), { recursive: true });
529
+ writeFileSync(target, content ?? "", "utf-8");
530
+ return `Wrote ${Buffer.byteLength(content ?? "", "utf-8")} bytes to ${path}`;
531
+ },
532
+ run_bash: ({ command }) => {
533
+ if (!command) throw new Error("run_bash requires a 'command'");
534
+ // Scrub the Anthropic API key from the child environment. The runner is
535
+ // designed to feed untrusted third-party content (issue/PR bodies via gh)
536
+ // into the model loop, so a prompt-injection payload could otherwise issue
537
+ // a run_bash command that exfiltrates the key. gh/git auth tokens
538
+ // (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
539
+ const childEnv = { ...process.env };
540
+ delete childEnv.ANTHROPIC_API_KEY;
541
+ // Run under bash when available so bash-style commands the model emits
542
+ // (heredocs, scripts/*.sh, single-quote semantics) behave consistently
543
+ // across platforms. `undefined` lets spawnSync fall back to the platform
544
+ // default shell (see `shell: shell || true` below).
545
+ const shell = resolveBashShell();
546
+ // Resolve wall-clock timeout. FORGEDOCK_BASH_TIMEOUT overrides the
547
+ // module default so operators can tune per-repo or per-CI-job without
548
+ // touching source. NaN / non-positive values fall back to the default.
549
+ const rawTimeout = parseInt(process.env.FORGEDOCK_BASH_TIMEOUT, 10);
550
+ const timeoutMs =
551
+ Number.isFinite(rawTimeout) && rawTimeout > 0
552
+ ? rawTimeout
553
+ : DEFAULT_BASH_TIMEOUT_MS;
554
+ const startMs = Date.now();
555
+ // Use spawnSync (not execSync) so stderr is captured on BOTH the success
556
+ // and failure paths. execSync only ever *returns* stdout on success —
557
+ // stderr is only exposed via the thrown error's `.stderr` on a nonzero
558
+ // exit — even though the tool schema above promises combined
559
+ // stdout/stderr and the failure path below already concatenates both.
560
+ // That meant diagnostics a command writes to stderr while still exiting
561
+ // 0 (git/npm/linter warnings) silently vanished from what the agent
562
+ // loop sees. spawnSync returns { stdout, stderr, status, signal, error }
563
+ // uniformly regardless of exit status, so both streams are always
564
+ // available. `shell: shell || true` preserves execSync's implicit
565
+ // "always run via a shell" behavior for compound commands (&&, pipes,
566
+ // heredocs) when resolveBashShell() returns undefined.
567
+ // maxBuffer defaults to 50 MB. FORGEDOCK_MAX_BUFFER_BYTES overrides it so
568
+ // tests can trigger ENOBUFS with a small value without generating 50 MB of
569
+ // output. Non-positive or non-finite values fall back to the default.
570
+ const rawMaxBuffer = parseInt(process.env.FORGEDOCK_MAX_BUFFER_BYTES, 10);
571
+ const maxBuffer =
572
+ Number.isFinite(rawMaxBuffer) && rawMaxBuffer > 0
573
+ ? rawMaxBuffer
574
+ : 50 * 1024 * 1024;
575
+ const result = spawnSync(command, {
576
+ cwd,
577
+ encoding: "utf-8",
578
+ maxBuffer,
579
+ timeout: timeoutMs,
580
+ env: childEnv,
581
+ shell: shell || true,
582
+ });
583
+ const stdout = result.stdout ? String(result.stdout) : "";
584
+ const stderr = result.stderr ? String(result.stderr) : "";
585
+ // Detect timeout. Two reliable indicators:
586
+ // 1. result.error.code === "ETIMEDOUT" — Node sets this on the error
587
+ // object specifically when spawnSync's own `timeout` option fires
588
+ // and it kills the child. This is the authoritative Node-initiated
589
+ // kill signal.
590
+ // 2. elapsedMs >= timeoutMs — elapsed-wall-time fallback for platforms
591
+ // (primarily Windows) where the ETIMEDOUT error code is unreliable.
592
+ // If we spent at least as long as the timeout, the timer must have
593
+ // fired, since a voluntarily-exiting process would have returned
594
+ // before then.
595
+ //
596
+ // NOTE: `result.signal === "SIGTERM"` is intentionally NOT included.
597
+ // spawnSync sets result.signal for ANY signal that terminated the child —
598
+ // including SIGTERM from external sources (supervisors, orchestrators,
599
+ // `timeout(1)` wrappers, or the child self-signaling) that are entirely
600
+ // unrelated to this runner's timeout. Including it caused any externally-
601
+ // sent SIGTERM to be misreported as a ForgeDock timeout (issue #1240).
602
+ //
603
+ // Gate all of this on `result.status === null` so a legitimately-
604
+ // completed process near the timeout boundary is never misreported.
605
+ const elapsedMs = Date.now() - startMs;
606
+ const timedOut =
607
+ result.status === null &&
608
+ (result.error?.code === "ETIMEDOUT" || elapsedMs >= timeoutMs);
609
+ if (timedOut) {
610
+ const timeoutSecs = Math.round(timeoutMs / 1000);
611
+ const partial = (stdout + stderr).trim();
612
+ throw new Error(
613
+ `Command timed out after ${timeoutSecs}s and was killed. ` +
614
+ `Set FORGEDOCK_BASH_TIMEOUT (ms) to adjust.` +
615
+ (partial ? `\nPartial output:\n${partial}` : ""),
616
+ );
617
+ }
618
+ if (result.error?.code === "ENOBUFS") {
619
+ // The process ran but produced more output than maxBuffer allows.
620
+ // spawnSync populates result.stdout/stderr up to the limit before
621
+ // setting result.error — surface what was captured so the agent has
622
+ // actionable context rather than a misleading "failed to start" error.
623
+ const partial = (stdout + stderr).trim();
624
+ const bufMB = Math.round(maxBuffer / (1024 * 1024));
625
+ throw new Error(
626
+ `Command output exceeded ${bufMB}MB buffer limit (output truncated).` +
627
+ (partial ? `\nPartial output:\n${partial}` : ""),
628
+ );
629
+ }
630
+ if (result.error) {
631
+ // spawnSync-level failure to even launch the process (e.g. ENOENT
632
+ // for a missing shell binary) — distinct from a nonzero exit status
633
+ // below.
634
+ throw new Error(`Command failed to start: ${result.error.message}`);
635
+ }
636
+ if (result.status !== 0) {
637
+ // Surface the command's output AND exit status to the model so it
638
+ // can react to failures rather than silently swallowing them.
639
+ throw new Error(
640
+ `Command failed (exit ${result.status ?? "?"}):\n${stdout}${stderr}`.trim(),
641
+ );
642
+ }
643
+ // Combined stdout/stderr on success — matches the tool schema's
644
+ // documented contract.
645
+ return stdout + stderr;
646
+ },
647
+ };
648
+ }
649
+
650
+ // ---------------------------------------------------------------------------
651
+ // Rendering
652
+ // ---------------------------------------------------------------------------
653
+
654
+ /**
655
+ * Render the dry-run preview: what would be sent to the API, no network.
656
+ * @param {{spec: object, systemPrompt: string, userMessage: string, model: string, maxIterations: number}} ctx
657
+ * @returns {string}
658
+ */
659
+ export function renderDryRun(ctx) {
660
+ const { spec, systemPrompt, userMessage, model, maxIterations } = ctx;
661
+ return [
662
+ `┌─ ForgeDock run (dry-run) ───────────────────────────────`,
663
+ `│ command: /${spec.name}`,
664
+ `│ spec: ${spec.path}`,
665
+ `│ model: ${model}`,
666
+ `│ max iterations: ${maxIterations}`,
667
+ `│ tools: ${TOOL_DEFINITIONS.map((t) => t.name).join(", ")}`,
668
+ `│ system prompt: ${systemPrompt.length} chars`,
669
+ `│ user message: ${userMessage}`,
670
+ `└─────────────────────────────────────────────────────────`,
671
+ ``,
672
+ `(dry-run) No API call made. Set ANTHROPIC_API_KEY and install`,
673
+ `@anthropic-ai/sdk, then drop --dry-run to execute the pipeline.`,
674
+ ].join("\n");
675
+ }
676
+
677
+ /**
678
+ * Render the pipeline summary card emitted on completion.
679
+ * @param {{command: string, args: string[], iterations: number, stopReason: string}} ctx
680
+ * @returns {string}
681
+ */
682
+ export function renderSummaryCard(ctx) {
683
+ const { command, args, iterations, stopReason } = ctx;
684
+ const argStr = Array.isArray(args) ? args.join(" ") : String(args ?? "");
685
+ return [
686
+ ``,
687
+ `┌─ ForgeDock pipeline summary ────────────────────────────`,
688
+ `│ command: /${command} ${argStr}`.trimEnd(),
689
+ `│ iterations: ${iterations}`,
690
+ `│ stop: ${stopReason}`,
691
+ `└─────────────────────────────────────────────────────────`,
692
+ ].join("\n");
693
+ }
694
+
695
+ // ---------------------------------------------------------------------------
696
+ // Orchestrator
697
+ // ---------------------------------------------------------------------------
698
+
699
+ /**
700
+ * Run a ForgeDock command outside Claude Code.
701
+ *
702
+ * @param {object} opts
703
+ * @param {string} opts.commandsDir - Absolute path to commands/.
704
+ * @param {string} opts.commandName - Command to run (e.g. "work-on").
705
+ * @param {string[]} [opts.args] - Command arguments.
706
+ * @param {string} [opts.cwd] - Working directory (default cwd).
707
+ * @param {string} [opts.apiKey] - Anthropic API key (default env).
708
+ * @param {string} [opts.model] - Model id.
709
+ * @param {number} [opts.maxIterations] - Tool-loop bound.
710
+ * @param {boolean} [opts.dryRun] - Preview without an API call.
711
+ * @param {{log: Function, error?: Function}} [opts.logger] - Output sink.
712
+ * @returns {Promise<{status: string, command: string, [k: string]: any}>}
713
+ */
714
+ export async function runCommand(opts = {}) {
715
+ const {
716
+ commandsDir,
717
+ commandName,
718
+ args = [],
719
+ cwd = process.cwd(),
720
+ apiKey = process.env.ANTHROPIC_API_KEY,
721
+ model = process.env.FORGEDOCK_MODEL || DEFAULT_MODEL,
722
+ maxIterations = DEFAULT_MAX_ITERATIONS,
723
+ dryRun = false,
724
+ logger = console,
725
+ } = opts;
726
+
727
+ const spec = loadCommandSpec(commandsDir, commandName);
728
+ const systemPrompt = buildSystemPrompt(spec, { repoRoot: cwd });
729
+ const userMessage = buildUserMessage(commandName, args);
730
+
731
+ if (dryRun) {
732
+ logger.log(renderDryRun({ spec, systemPrompt, userMessage, model, maxIterations }));
733
+ return { status: "dry-run", command: spec.name, args, specPath: spec.path };
734
+ }
735
+
736
+ if (!apiKey) {
737
+ const err = new Error(
738
+ "ANTHROPIC_API_KEY is not set. Export your Anthropic API key to run the live pipeline, or pass --dry-run to preview.",
739
+ );
740
+ err.code = "NO_API_KEY";
741
+ throw err;
742
+ }
743
+
744
+ // Lazy/optional SDK import — keeps the package dependency-free until a live
745
+ // run is actually requested.
746
+ let Anthropic;
747
+ try {
748
+ ({ default: Anthropic } = await import("@anthropic-ai/sdk"));
749
+ } catch {
750
+ const err = new Error(
751
+ "@anthropic-ai/sdk is not installed. Install it with:\n npm install @anthropic-ai/sdk\nThen re-run, or use --dry-run to preview without the SDK.",
752
+ );
753
+ err.code = "NO_SDK";
754
+ throw err;
755
+ }
756
+
757
+ const client = new Anthropic({ apiKey });
758
+ // NOTE on /proc/$PPID/environ (Linux): deleting from process.env calls
759
+ // unsetenv() at the C level, which does NOT update the kernel's
760
+ // /proc/<pid>/environ snapshot (that is fixed at execve() time). A
761
+ // run_bash payload can still read /proc/$PPID/environ and recover the
762
+ // key if it was set in the shell environment before the node process
763
+ // started. The only effective JavaScript-layer mitigation is the
764
+ // child-env scrub in run_bash: `childEnv = {...process.env}; delete
765
+ // childEnv.ANTHROPIC_API_KEY` — this prevents the child from inheriting
766
+ // the key in its own process.env, which is sufficient for
767
+ // `process.env.ANTHROPIC_API_KEY` access. The /proc/$PPID/environ vector
768
+ // is a known Linux limitation at the OS level; closing it would require
769
+ // a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
770
+ // See: issue #1370 for tracking this known limitation.
771
+ const handlers = getToolHandlers(cwd);
772
+ const messages = [{ role: "user", content: userMessage }];
773
+
774
+ let iterations = 0;
775
+ while (iterations < maxIterations) {
776
+ iterations++;
777
+
778
+ const response = await client.messages.create({
779
+ model,
780
+ max_tokens: DEFAULT_MAX_TOKENS,
781
+ system: systemPrompt,
782
+ tools: TOOL_DEFINITIONS,
783
+ messages,
784
+ });
785
+
786
+ messages.push({ role: "assistant", content: response.content });
787
+
788
+ for (const block of response.content) {
789
+ if (block.type === "text" && block.text.trim()) {
790
+ logger.log(block.text);
791
+ }
792
+ }
793
+
794
+ if (response.stop_reason !== "tool_use") {
795
+ // `max_tokens` is a TRUNCATED assistant turn, not a clean finish — report
796
+ // it distinctly so callers (and CI) don't treat a cut-off run as success.
797
+ const status =
798
+ response.stop_reason === "max_tokens" ? "incomplete" : "complete";
799
+ logger.log(
800
+ renderSummaryCard({
801
+ command: spec.name,
802
+ args,
803
+ iterations,
804
+ stopReason: response.stop_reason,
805
+ }),
806
+ );
807
+ return {
808
+ status,
809
+ command: spec.name,
810
+ iterations,
811
+ stopReason: response.stop_reason,
812
+ };
813
+ }
814
+
815
+ const toolResults = [];
816
+ for (const block of response.content) {
817
+ if (block.type !== "tool_use") continue;
818
+ const handler = handlers[block.name];
819
+ let content;
820
+ let isError = false;
821
+ try {
822
+ if (!handler) throw new Error(`Unknown tool: ${block.name}`);
823
+ content = String(handler(block.input ?? {}) ?? "");
824
+ } catch (e) {
825
+ content = `Error: ${e.message}`;
826
+ isError = true;
827
+ }
828
+ toolResults.push({
829
+ type: "tool_result",
830
+ tool_use_id: block.id,
831
+ content: truncateToolResult(content),
832
+ is_error: isError,
833
+ });
834
+ }
835
+ messages.push({ role: "user", content: toolResults });
836
+ }
837
+
838
+ logger.log(
839
+ renderSummaryCard({
840
+ command: spec.name,
841
+ args,
842
+ iterations,
843
+ stopReason: "max_iterations",
844
+ }),
845
+ );
846
+ return { status: "max-iterations", command: spec.name, iterations };
847
+ }