forgedock 1.0.20 → 1.0.22

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 (49) hide show
  1. package/README.md +146 -77
  2. package/bin/demo.mjs +386 -0
  3. package/bin/forgedock.mjs +357 -30
  4. package/bin/init-detect.mjs +95 -0
  5. package/bin/runner.mjs +780 -0
  6. package/commands/ci-audit.md +469 -0
  7. package/commands/cleanup.md +85 -39
  8. package/commands/deploy-info.md +6 -2
  9. package/commands/forge-stats.md +1 -1
  10. package/commands/optimize.md +1 -1
  11. package/commands/orchestrate.md +684 -33
  12. package/commands/quality-gate.md +166 -46
  13. package/commands/review-pr-agents.md +106 -34
  14. package/commands/review-pr.md +33 -2
  15. package/commands/scope.md +11 -3
  16. package/commands/security-audit.md +24 -16
  17. package/commands/sync-ecosystem.md +2 -2
  18. package/commands/work-on/build/architect.md +47 -3
  19. package/commands/work-on/build/context.md +36 -5
  20. package/commands/work-on/build/validate.md +16 -7
  21. package/commands/work-on/close.md +129 -1
  22. package/commands/work-on/investigate.md +24 -1
  23. package/commands/work-on.md +226 -8
  24. package/docs/comparison.md +135 -0
  25. package/docs/design/developer-objections-ai-context-tools.md +244 -0
  26. package/docs/design/messaging-angles-research.md +179 -0
  27. package/docs/design/show-hn-draft.md +86 -0
  28. package/docs/site/annotations-explained.md +55 -0
  29. package/docs/site/command-learning-path.md +104 -0
  30. package/docs/site/command-reference.md +3 -0
  31. package/docs/site/getting-started.md +36 -14
  32. package/docs/site/troubleshooting.md +208 -0
  33. package/docs/spec/forge-protocol-v1.md +768 -0
  34. package/examples/forgedock-demo/README.md +113 -0
  35. package/examples/forgedock-demo/bootstrap.sh +125 -0
  36. package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
  37. package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
  38. package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
  39. package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
  40. package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
  41. package/examples/forgedock-demo/labels.json +21 -0
  42. package/examples/forgedock-demo/package.json +16 -0
  43. package/examples/forgedock-demo/scripts/smoke.js +61 -0
  44. package/examples/forgedock-demo/src/auth.js +28 -0
  45. package/examples/forgedock-demo/src/db.js +66 -0
  46. package/examples/forgedock-demo/src/routes/notes.js +64 -0
  47. package/examples/forgedock-demo/src/server.js +79 -0
  48. package/package.json +6 -2
  49. package/templates/forge.yaml.minimal +44 -0
package/bin/runner.mjs ADDED
@@ -0,0 +1,780 @@
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
+ return resolved;
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Windows bash shim detection
346
+ // ---------------------------------------------------------------------------
347
+
348
+ /**
349
+ * Path fragments that identify Windows bash shims — executables that exist on
350
+ * disk but are launchers or redirectors rather than real bash installations.
351
+ * On systems without WSL these shims either fail or open the Windows Store UI.
352
+ *
353
+ * Used by resolveBashShell() to skip shim paths returned by `where bash.exe`,
354
+ * so that a real bash installation (e.g. Git for Windows) is preferred over a
355
+ * shim that happens to appear earlier on PATH.
356
+ *
357
+ * Fragments are matched case-insensitively against the normalised (backslash)
358
+ * form of each candidate path.
359
+ */
360
+ const WINDOWS_BASH_SHIM_FRAGMENTS = [
361
+ "\\WindowsApps\\", // Windows Store app shim tree (opens Store if WSL absent)
362
+ "\\System32\\bash.exe", // WSL inbox launcher (fails without WSL)
363
+ ];
364
+
365
+ /**
366
+ * Return true when a Windows path points to a known bash shim rather than a
367
+ * real bash installation. Shims pass existsSync() but will fail or open the
368
+ * Windows Store UI when WSL is not installed.
369
+ *
370
+ * Matching is case-insensitive and tolerates forward-slash separators so the
371
+ * helper works correctly regardless of how the path was produced.
372
+ *
373
+ * @param {string} p - Candidate path to test.
374
+ * @returns {boolean}
375
+ */
376
+ export function isWindowsBashShim(p) {
377
+ if (typeof p !== "string") return false;
378
+ const normalised = p.replace(/\//g, "\\").toLowerCase();
379
+ return WINDOWS_BASH_SHIM_FRAGMENTS.some((frag) =>
380
+ normalised.includes(frag.toLowerCase()),
381
+ );
382
+ }
383
+
384
+ // Guard so the "no bash found" warning fires at most once per process, even
385
+ // when run_bash is invoked many times in a single runner session.
386
+ let _bashWarningEmitted = false;
387
+
388
+ /**
389
+ * Resolve an explicit bash shell for run_bash, or undefined to fall back to the
390
+ * platform default shell. The system prompt instructs the model to use
391
+ * bash-style git/gh/scripts invocations, so executing under bash keeps behavior
392
+ * consistent across platforms rather than following the host default (cmd.exe
393
+ * on Windows). A FORGEDOCK_SHELL override always wins. When no bash is found we
394
+ * return undefined so execSync falls back to the platform default and non-bash
395
+ * hosts still work.
396
+ *
397
+ * On Windows, discovery order is:
398
+ * 1. FORGEDOCK_SHELL env var (override — wins unconditionally)
399
+ * 2. PATH lookup via `where bash.exe` (catches Scoop, winget, custom prefixes)
400
+ * Known shim paths (WindowsApps, System32\bash.exe) are filtered out so
401
+ * that a real bash installation is preferred over a stub that may fail or
402
+ * open the Windows Store UI if WSL is not installed.
403
+ * 3. Hardcoded candidates: standard Git for Windows (x64 + x86) + Scoop + WSL
404
+ * 4. undefined → platform default shell (cmd.exe); emits a one-time stderr warning
405
+ *
406
+ * @returns {string|undefined} Absolute path to a bash shell, or undefined.
407
+ */
408
+ export function resolveBashShell() {
409
+ // Explicit override always wins (even cmd.exe, if the operator insists).
410
+ const override = process.env.FORGEDOCK_SHELL;
411
+ if (override && override.trim()) return override.trim();
412
+
413
+ if (process.platform === "win32") {
414
+ // 1. PATH-based discovery — covers Scoop, winget, and custom install
415
+ // prefixes that place bash.exe on PATH but not under Program Files.
416
+ // `where.exe` is a standalone tool in System32; run it via the default
417
+ // shell so restricted environments that lack `where` degrade gracefully.
418
+ // All results are filtered to skip known shim paths before the first
419
+ // real bash candidate is accepted (see isWindowsBashShim).
420
+ try {
421
+ const fromPath = execSync("where bash.exe", {
422
+ encoding: "utf-8",
423
+ stdio: ["pipe", "pipe", "pipe"],
424
+ timeout: 3000,
425
+ shell: true,
426
+ })
427
+ .split(/\r?\n/)
428
+ .map((l) => l.trim())
429
+ .filter((l) => l.length > 0 && !isWindowsBashShim(l))
430
+ .find((l) => existsSync(l));
431
+ if (fromPath) return fromPath;
432
+ } catch {
433
+ // `where` failed or returned no results — fall through to hardcoded list.
434
+ }
435
+
436
+ // 2. Hardcoded candidates: standard Git for Windows (machine-wide x64 + x86),
437
+ // Scoop convention (%USERPROFILE%\scoop\apps\git\current\bin\bash.exe),
438
+ // and WSL bash (%SystemRoot%\System32\bash.exe).
439
+ // Note: System32\bash.exe is the WSL inbox launcher — it works when WSL is
440
+ // installed but is a shim that fails without WSL. It is listed last so
441
+ // real Git-for-Windows paths take priority.
442
+ const candidates = [
443
+ join(process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe"),
444
+ join(
445
+ process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)",
446
+ "Git",
447
+ "bin",
448
+ "bash.exe",
449
+ ),
450
+ // Scoop installs Git under %USERPROFILE%\scoop\apps\git\current\.
451
+ join(
452
+ process.env.USERPROFILE || "",
453
+ "scoop",
454
+ "apps",
455
+ "git",
456
+ "current",
457
+ "bin",
458
+ "bash.exe",
459
+ ),
460
+ join(process.env.SystemRoot || "C:\\Windows", "System32", "bash.exe"),
461
+ ];
462
+ const found = candidates.find((c) => c && existsSync(c));
463
+ if (found) return found;
464
+
465
+ // 3. No bash found anywhere — warn the operator once so the silent cmd.exe
466
+ // fallback does not go unnoticed. Writing to stderr keeps stdout clean
467
+ // for callers that pipe or parse runner output.
468
+ if (!_bashWarningEmitted) {
469
+ _bashWarningEmitted = true;
470
+ process.stderr.write(
471
+ "[ForgeDock] Warning: bash not found on this system. run_bash commands " +
472
+ "will execute under cmd.exe, which may not support bash idioms " +
473
+ "(heredocs, scripts/*.sh, single-quote semantics). Install Git for " +
474
+ "Windows (https://gitforwindows.org) or Scoop (`scoop install git`), " +
475
+ "or set FORGEDOCK_SHELL to the absolute path of bash.exe to suppress " +
476
+ "this warning.\n",
477
+ );
478
+ }
479
+ return undefined;
480
+ }
481
+ // POSIX: prefer bash, fall back to /bin/sh (bash-compatible enough).
482
+ return ["/bin/bash", "/usr/bin/bash", "/bin/sh"].find((c) => existsSync(c));
483
+ }
484
+
485
+ /**
486
+ * Build the concrete tool handlers bound to a working directory.
487
+ *
488
+ * Each handler returns a string (the tool_result content). Handlers may throw;
489
+ * the loop catches and reports the error back to the model as an error result.
490
+ *
491
+ * @param {string} cwd
492
+ * @returns {Record<string, (input: object) => string>}
493
+ */
494
+ export function getToolHandlers(cwd) {
495
+ return {
496
+ read_file: ({ path }) => {
497
+ if (!path) throw new Error("read_file requires a 'path'");
498
+ return readFileSync(resolveConfinedPath(cwd, path), "utf-8");
499
+ },
500
+ write_file: ({ path, content }) => {
501
+ if (!path) throw new Error("write_file requires a 'path'");
502
+ const target = resolveConfinedPath(cwd, path);
503
+ mkdirSync(dirname(target), { recursive: true });
504
+ writeFileSync(target, content ?? "", "utf-8");
505
+ return `Wrote ${Buffer.byteLength(content ?? "", "utf-8")} bytes to ${path}`;
506
+ },
507
+ run_bash: ({ command }) => {
508
+ if (!command) throw new Error("run_bash requires a 'command'");
509
+ // Scrub the Anthropic API key from the child environment. The runner is
510
+ // designed to feed untrusted third-party content (issue/PR bodies via gh)
511
+ // into the model loop, so a prompt-injection payload could otherwise issue
512
+ // a run_bash command that exfiltrates the key. gh/git auth tokens
513
+ // (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
514
+ const childEnv = { ...process.env };
515
+ delete childEnv.ANTHROPIC_API_KEY;
516
+ // Run under bash when available so bash-style commands the model emits
517
+ // (heredocs, scripts/*.sh, single-quote semantics) behave consistently
518
+ // across platforms. `undefined` lets spawnSync fall back to the platform
519
+ // default shell (see `shell: shell || true` below).
520
+ const shell = resolveBashShell();
521
+ // Resolve wall-clock timeout. FORGEDOCK_BASH_TIMEOUT overrides the
522
+ // module default so operators can tune per-repo or per-CI-job without
523
+ // touching source. NaN / non-positive values fall back to the default.
524
+ const rawTimeout = parseInt(process.env.FORGEDOCK_BASH_TIMEOUT, 10);
525
+ const timeoutMs =
526
+ Number.isFinite(rawTimeout) && rawTimeout > 0
527
+ ? rawTimeout
528
+ : DEFAULT_BASH_TIMEOUT_MS;
529
+ const startMs = Date.now();
530
+ // Use spawnSync (not execSync) so stderr is captured on BOTH the success
531
+ // and failure paths. execSync only ever *returns* stdout on success —
532
+ // stderr is only exposed via the thrown error's `.stderr` on a nonzero
533
+ // exit — even though the tool schema above promises combined
534
+ // stdout/stderr and the failure path below already concatenates both.
535
+ // That meant diagnostics a command writes to stderr while still exiting
536
+ // 0 (git/npm/linter warnings) silently vanished from what the agent
537
+ // loop sees. spawnSync returns { stdout, stderr, status, signal, error }
538
+ // uniformly regardless of exit status, so both streams are always
539
+ // available. `shell: shell || true` preserves execSync's implicit
540
+ // "always run via a shell" behavior for compound commands (&&, pipes,
541
+ // heredocs) when resolveBashShell() returns undefined.
542
+ const result = spawnSync(command, {
543
+ cwd,
544
+ encoding: "utf-8",
545
+ maxBuffer: 50 * 1024 * 1024,
546
+ timeout: timeoutMs,
547
+ env: childEnv,
548
+ shell: shell || true,
549
+ });
550
+ const stdout = result.stdout ? String(result.stdout) : "";
551
+ const stderr = result.stderr ? String(result.stderr) : "";
552
+ // Detect timeout: result.error.code === "ETIMEDOUT" and/or
553
+ // result.signal === "SIGTERM" are the primary indicators (set by
554
+ // Node.js when the timeout fires and the process is killed), but on
555
+ // Windows signal reporting may not be reliable. Fall back to elapsed
556
+ // wall time — if we spent at least as long as the timeout, the timer
557
+ // must have fired, since a voluntarily-exiting process would have
558
+ // returned before then. Gate all of this on `result.status === null`
559
+ // so a legitimately-completed process near the timeout boundary is
560
+ // never misreported as timed out.
561
+ const elapsedMs = Date.now() - startMs;
562
+ const timedOut =
563
+ result.status === null &&
564
+ (result.error?.code === "ETIMEDOUT" ||
565
+ result.signal === "SIGTERM" ||
566
+ elapsedMs >= timeoutMs);
567
+ if (timedOut) {
568
+ const timeoutSecs = Math.round(timeoutMs / 1000);
569
+ const partial = (stdout + stderr).trim();
570
+ throw new Error(
571
+ `Command timed out after ${timeoutSecs}s and was killed. ` +
572
+ `Set FORGEDOCK_BASH_TIMEOUT (ms) to adjust.` +
573
+ (partial ? `\nPartial output:\n${partial}` : ""),
574
+ );
575
+ }
576
+ if (result.error) {
577
+ // spawnSync-level failure to even launch the process (e.g. ENOENT
578
+ // for a missing shell binary) — distinct from a nonzero exit status
579
+ // below.
580
+ throw new Error(`Command failed to start: ${result.error.message}`);
581
+ }
582
+ if (result.status !== 0) {
583
+ // Surface the command's output AND exit status to the model so it
584
+ // can react to failures rather than silently swallowing them.
585
+ throw new Error(
586
+ `Command failed (exit ${result.status ?? "?"}):\n${stdout}${stderr}`.trim(),
587
+ );
588
+ }
589
+ // Combined stdout/stderr on success — matches the tool schema's
590
+ // documented contract.
591
+ return stdout + stderr;
592
+ },
593
+ };
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Rendering
598
+ // ---------------------------------------------------------------------------
599
+
600
+ /**
601
+ * Render the dry-run preview: what would be sent to the API, no network.
602
+ * @param {{spec: object, systemPrompt: string, userMessage: string, model: string, maxIterations: number}} ctx
603
+ * @returns {string}
604
+ */
605
+ export function renderDryRun(ctx) {
606
+ const { spec, systemPrompt, userMessage, model, maxIterations } = ctx;
607
+ return [
608
+ `┌─ ForgeDock run (dry-run) ───────────────────────────────`,
609
+ `│ command: /${spec.name}`,
610
+ `│ spec: ${spec.path}`,
611
+ `│ model: ${model}`,
612
+ `│ max iterations: ${maxIterations}`,
613
+ `│ tools: ${TOOL_DEFINITIONS.map((t) => t.name).join(", ")}`,
614
+ `│ system prompt: ${systemPrompt.length} chars`,
615
+ `│ user message: ${userMessage}`,
616
+ `└─────────────────────────────────────────────────────────`,
617
+ ``,
618
+ `(dry-run) No API call made. Set ANTHROPIC_API_KEY and install`,
619
+ `@anthropic-ai/sdk, then drop --dry-run to execute the pipeline.`,
620
+ ].join("\n");
621
+ }
622
+
623
+ /**
624
+ * Render the pipeline summary card emitted on completion.
625
+ * @param {{command: string, args: string[], iterations: number, stopReason: string}} ctx
626
+ * @returns {string}
627
+ */
628
+ export function renderSummaryCard(ctx) {
629
+ const { command, args, iterations, stopReason } = ctx;
630
+ const argStr = Array.isArray(args) ? args.join(" ") : String(args ?? "");
631
+ return [
632
+ ``,
633
+ `┌─ ForgeDock pipeline summary ────────────────────────────`,
634
+ `│ command: /${command} ${argStr}`.trimEnd(),
635
+ `│ iterations: ${iterations}`,
636
+ `│ stop: ${stopReason}`,
637
+ `└─────────────────────────────────────────────────────────`,
638
+ ].join("\n");
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // Orchestrator
643
+ // ---------------------------------------------------------------------------
644
+
645
+ /**
646
+ * Run a ForgeDock command outside Claude Code.
647
+ *
648
+ * @param {object} opts
649
+ * @param {string} opts.commandsDir - Absolute path to commands/.
650
+ * @param {string} opts.commandName - Command to run (e.g. "work-on").
651
+ * @param {string[]} [opts.args] - Command arguments.
652
+ * @param {string} [opts.cwd] - Working directory (default cwd).
653
+ * @param {string} [opts.apiKey] - Anthropic API key (default env).
654
+ * @param {string} [opts.model] - Model id.
655
+ * @param {number} [opts.maxIterations] - Tool-loop bound.
656
+ * @param {boolean} [opts.dryRun] - Preview without an API call.
657
+ * @param {{log: Function, error?: Function}} [opts.logger] - Output sink.
658
+ * @returns {Promise<{status: string, command: string, [k: string]: any}>}
659
+ */
660
+ export async function runCommand(opts = {}) {
661
+ const {
662
+ commandsDir,
663
+ commandName,
664
+ args = [],
665
+ cwd = process.cwd(),
666
+ apiKey = process.env.ANTHROPIC_API_KEY,
667
+ model = process.env.FORGEDOCK_MODEL || DEFAULT_MODEL,
668
+ maxIterations = DEFAULT_MAX_ITERATIONS,
669
+ dryRun = false,
670
+ logger = console,
671
+ } = opts;
672
+
673
+ const spec = loadCommandSpec(commandsDir, commandName);
674
+ const systemPrompt = buildSystemPrompt(spec, { repoRoot: cwd });
675
+ const userMessage = buildUserMessage(commandName, args);
676
+
677
+ if (dryRun) {
678
+ logger.log(renderDryRun({ spec, systemPrompt, userMessage, model, maxIterations }));
679
+ return { status: "dry-run", command: spec.name, args, specPath: spec.path };
680
+ }
681
+
682
+ if (!apiKey) {
683
+ const err = new Error(
684
+ "ANTHROPIC_API_KEY is not set. Export your Anthropic API key to run the live pipeline, or pass --dry-run to preview.",
685
+ );
686
+ err.code = "NO_API_KEY";
687
+ throw err;
688
+ }
689
+
690
+ // Lazy/optional SDK import — keeps the package dependency-free until a live
691
+ // run is actually requested.
692
+ let Anthropic;
693
+ try {
694
+ ({ default: Anthropic } = await import("@anthropic-ai/sdk"));
695
+ } catch {
696
+ const err = new Error(
697
+ "@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.",
698
+ );
699
+ err.code = "NO_SDK";
700
+ throw err;
701
+ }
702
+
703
+ const client = new Anthropic({ apiKey });
704
+ const handlers = getToolHandlers(cwd);
705
+ const messages = [{ role: "user", content: userMessage }];
706
+
707
+ let iterations = 0;
708
+ while (iterations < maxIterations) {
709
+ iterations++;
710
+
711
+ const response = await client.messages.create({
712
+ model,
713
+ max_tokens: DEFAULT_MAX_TOKENS,
714
+ system: systemPrompt,
715
+ tools: TOOL_DEFINITIONS,
716
+ messages,
717
+ });
718
+
719
+ messages.push({ role: "assistant", content: response.content });
720
+
721
+ for (const block of response.content) {
722
+ if (block.type === "text" && block.text.trim()) {
723
+ logger.log(block.text);
724
+ }
725
+ }
726
+
727
+ if (response.stop_reason !== "tool_use") {
728
+ // `max_tokens` is a TRUNCATED assistant turn, not a clean finish — report
729
+ // it distinctly so callers (and CI) don't treat a cut-off run as success.
730
+ const status =
731
+ response.stop_reason === "max_tokens" ? "incomplete" : "complete";
732
+ logger.log(
733
+ renderSummaryCard({
734
+ command: spec.name,
735
+ args,
736
+ iterations,
737
+ stopReason: response.stop_reason,
738
+ }),
739
+ );
740
+ return {
741
+ status,
742
+ command: spec.name,
743
+ iterations,
744
+ stopReason: response.stop_reason,
745
+ };
746
+ }
747
+
748
+ const toolResults = [];
749
+ for (const block of response.content) {
750
+ if (block.type !== "tool_use") continue;
751
+ const handler = handlers[block.name];
752
+ let content;
753
+ let isError = false;
754
+ try {
755
+ if (!handler) throw new Error(`Unknown tool: ${block.name}`);
756
+ content = String(handler(block.input ?? {}) ?? "");
757
+ } catch (e) {
758
+ content = `Error: ${e.message}`;
759
+ isError = true;
760
+ }
761
+ toolResults.push({
762
+ type: "tool_result",
763
+ tool_use_id: block.id,
764
+ content: truncateToolResult(content),
765
+ is_error: isError,
766
+ });
767
+ }
768
+ messages.push({ role: "user", content: toolResults });
769
+ }
770
+
771
+ logger.log(
772
+ renderSummaryCard({
773
+ command: spec.name,
774
+ args,
775
+ iterations,
776
+ stopReason: "max_iterations",
777
+ }),
778
+ );
779
+ return { status: "max-iterations", command: spec.name, iterations };
780
+ }