forgedock 1.0.21 → 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.
- package/README.md +134 -80
- package/bin/demo.mjs +386 -0
- package/bin/forgedock.mjs +357 -30
- package/bin/init-detect.mjs +95 -0
- package/bin/runner.mjs +780 -0
- package/commands/cleanup.md +85 -39
- package/commands/deploy-info.md +6 -2
- package/commands/forge-stats.md +1 -1
- package/commands/optimize.md +1 -1
- package/commands/orchestrate.md +320 -26
- package/commands/quality-gate.md +91 -49
- package/commands/review-pr-agents.md +62 -38
- package/commands/review-pr.md +33 -2
- package/commands/scope.md +11 -3
- package/commands/security-audit.md +24 -16
- package/commands/sync-ecosystem.md +2 -2
- package/commands/work-on/build/architect.md +47 -3
- package/commands/work-on/build/context.md +36 -5
- package/commands/work-on/build/validate.md +16 -7
- package/commands/work-on/close.md +129 -1
- package/commands/work-on/investigate.md +24 -1
- package/commands/work-on.md +226 -8
- package/docs/site/annotations-explained.md +55 -0
- package/docs/site/command-learning-path.md +104 -0
- package/docs/site/command-reference.md +3 -0
- package/docs/site/getting-started.md +36 -14
- package/docs/site/troubleshooting.md +208 -0
- package/docs/spec/forge-protocol-v1.md +768 -0
- package/examples/forgedock-demo/README.md +113 -0
- package/examples/forgedock-demo/bootstrap.sh +125 -0
- package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
- package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
- package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
- package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
- package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
- package/examples/forgedock-demo/labels.json +21 -0
- package/examples/forgedock-demo/package.json +16 -0
- package/examples/forgedock-demo/scripts/smoke.js +61 -0
- package/examples/forgedock-demo/src/auth.js +28 -0
- package/examples/forgedock-demo/src/db.js +66 -0
- package/examples/forgedock-demo/src/routes/notes.js +64 -0
- package/examples/forgedock-demo/src/server.js +79 -0
- package/package.json +5 -1
- 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
|
+
}
|