forgedock 1.6.0 → 1.7.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.
- package/README.md +14 -2
- package/bin/engine/orchestrate-canary.mjs +15 -0
- package/bin/engine/orchestrate-canary.test.mjs +28 -0
- package/bin/engine-cli.mjs +2 -0
- package/bin/forgedock.mjs +176 -44
- package/bin/opencode-adapter.mjs +961 -0
- package/bin/registry.mjs +2 -1
- package/bin/runner.mjs +250 -60
- package/commands/orchestrate/phase-3-dependency.md +43 -6
- package/commands/orchestrate/phase-4-execution.md +158 -58
- package/commands/review-pr-agents.md +8 -0
- package/commands/review-pr-staging.md +18 -0
- package/commands/review-pr.md +20 -2
- package/commands/work-on/build.md +10 -1
- package/commands/work-on.md +10 -1
- package/docs/CONFIG.md +6 -5
- package/docs/OPENCODE.md +238 -270
- package/package.json +3 -2
package/bin/registry.mjs
CHANGED
|
@@ -513,12 +513,13 @@ export function getInstalledCommandsDriftStatus(forgeHome) {
|
|
|
513
513
|
let sourceVersion = "";
|
|
514
514
|
try {
|
|
515
515
|
const pkg = JSON.parse(readFileSync(join(forgeHome, "package.json"), "utf-8"));
|
|
516
|
-
sourceVersion = pkg.version
|
|
516
|
+
sourceVersion = typeof pkg.version === "string" ? pkg.version.trim() : "";
|
|
517
517
|
} catch {
|
|
518
518
|
// Missing/unreadable/corrupt package.json — cannot determine the source
|
|
519
519
|
// version at all; degrade to unknown rather than guessing.
|
|
520
520
|
return notFound;
|
|
521
521
|
}
|
|
522
|
+
if (!sourceVersion) return notFound;
|
|
522
523
|
|
|
523
524
|
// Prefer the version registry.mjs last recorded for persistHome()'s write
|
|
524
525
|
// (the single source of truth update()/install also write through),
|
package/bin/runner.mjs
CHANGED
|
@@ -47,9 +47,10 @@ import {
|
|
|
47
47
|
rmSync,
|
|
48
48
|
realpathSync,
|
|
49
49
|
} from "fs";
|
|
50
|
-
import { join, dirname, basename, relative, isAbsolute, extname } from "path";
|
|
50
|
+
import { join, dirname, basename, relative, isAbsolute, extname, win32 as win32Path } from "path";
|
|
51
51
|
import os from "os";
|
|
52
52
|
import { execSync, spawnSync } from "child_process";
|
|
53
|
+
import { createRequire } from "module";
|
|
53
54
|
import { parseForgeYaml, resolveModelAlias } from "./forge-utils.mjs";
|
|
54
55
|
import { DEFAULT_SPAWN_MAX_BUFFER_BYTES } from "./cli-spawn-shared.mjs";
|
|
55
56
|
|
|
@@ -70,6 +71,7 @@ const DEFAULT_CLI_TIMEOUT_MS = 15 * 60 * 1000;
|
|
|
70
71
|
// it is far shorter than DEFAULT_CLI_TIMEOUT_MS. Mirrors the timeout already
|
|
71
72
|
// used by the `claude --version` doctor check in bin/forgedock.mjs.
|
|
72
73
|
const CLI_PROBE_TIMEOUT_MS = 5000;
|
|
74
|
+
export const CLI_PROBE_OUTPUT_SENTINEL = "__FORGEDOCK_CLI_PATHS_END__";
|
|
73
75
|
/**
|
|
74
76
|
* Wraps a Set in a Proxy that blocks add()/delete()/clear(), so the
|
|
75
77
|
* returned collection is genuinely read-only — not just Object.frozen.
|
|
@@ -125,6 +127,35 @@ function readOnlySet(values) {
|
|
|
125
127
|
* simultaneously. Treat it as immutable; mutation attempts throw.
|
|
126
128
|
*/
|
|
127
129
|
export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
|
|
130
|
+
|
|
131
|
+
export const FORGE_OPENCODE_CAPABILITY_ERROR = "FORGE_OPENCODE_CAPABILITY_ERROR";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether the caller is an OpenCode session. The adapter sets this marker on
|
|
135
|
+
* every shell environment; keeping the predicate small makes all Claude
|
|
136
|
+
* backend entrypoints agree on the same runtime boundary.
|
|
137
|
+
*/
|
|
138
|
+
export function isOpenCodeRuntime(env = process.env) {
|
|
139
|
+
return String(env?.FORGE_RUNTIME || "").toLowerCase() === "opencode";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Refuse Claude-backed execution from an OpenCode workflow. OpenCode has its
|
|
144
|
+
* own Skill/Task execution path; falling through to this runner would create a
|
|
145
|
+
* competing controller and may consume Claude credentials or quota.
|
|
146
|
+
*/
|
|
147
|
+
export function assertOpenCodeNativeRuntime({
|
|
148
|
+
env = process.env,
|
|
149
|
+
operation = "Claude-backed ForgeDock execution",
|
|
150
|
+
} = {}) {
|
|
151
|
+
if (!isOpenCodeRuntime(env)) return;
|
|
152
|
+
const error = new Error(
|
|
153
|
+
`${FORGE_OPENCODE_CAPABILITY_ERROR}: ${operation} is unavailable when FORGE_RUNTIME=opencode. Use native ForgeDock Skill/Task dispatch instead.`,
|
|
154
|
+
);
|
|
155
|
+
error.code = FORGE_OPENCODE_CAPABILITY_ERROR;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
|
|
128
159
|
// Per-process memoization for isClaudeCliAvailable(), keyed by `cwd` (issue
|
|
129
160
|
// #2011). `runCommand()` calls resolveBackend() — and therefore, for the
|
|
130
161
|
// default "auto" backend, isClaudeCliAvailable() — unconditionally on every
|
|
@@ -153,12 +184,9 @@ export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
|
|
|
153
184
|
const CLI_AVAILABILITY_CACHE_TTL_MS = 60_000;
|
|
154
185
|
const CLI_AVAILABILITY_CACHE_MAX_SIZE = 100;
|
|
155
186
|
const cliAvailabilityCache = new Map();
|
|
156
|
-
// Windows
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// `runCliBackend()` below). An extensionless file — e.g. the POSIX shell
|
|
160
|
-
// shim `npm`/`npx` also drop alongside these on Windows — cannot be
|
|
161
|
-
// launched by `CreateProcess` at all, with or without a full absolute path.
|
|
187
|
+
// Windows path types emitted by npm's shim generator. Native executables are
|
|
188
|
+
// directly spawnable; .cmd/.bat files must be resolved to their trusted native
|
|
189
|
+
// target before spawning because Node cannot exec them with shell:false.
|
|
162
190
|
const WINDOWS_SPAWNABLE_EXTENSIONS = new Set([".exe", ".cmd", ".bat", ".com"]);
|
|
163
191
|
// Cap tool-result payloads so a large file read or verbose command does not
|
|
164
192
|
// blow the context window in a single turn.
|
|
@@ -216,13 +244,10 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
216
244
|
// ---------------------------------------------------------------------------
|
|
217
245
|
|
|
218
246
|
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* path separator (`/` or `\`); `claude --version` output (e.g.
|
|
224
|
-
* `"1.2.3 (Claude Code)"`) never does, so the first non-path-like line
|
|
225
|
-
* marks the boundary.
|
|
247
|
+
* Read path candidates from the explicitly delimited resolver section of the
|
|
248
|
+
* combined CLI probe. Version output is untrusted for parsing purposes: update
|
|
249
|
+
* notices can contain path-like URLs or filenames, so shape-based inference
|
|
250
|
+
* must never allow lines after the sentinel to become executable candidates.
|
|
226
251
|
*
|
|
227
252
|
* Pure/side-effect-free so it can be unit tested directly against
|
|
228
253
|
* synthetic multi-line `where` output without spawning anything.
|
|
@@ -230,20 +255,16 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
230
255
|
* @param {string} raw
|
|
231
256
|
* @returns {string[]} Leading path-like lines, in original order.
|
|
232
257
|
*/
|
|
233
|
-
function parseProbeOutput(raw) {
|
|
258
|
+
export function parseProbeOutput(raw) {
|
|
234
259
|
const lines = String(raw ?? "")
|
|
235
260
|
.split(/\r?\n/)
|
|
236
261
|
.map((line) => line.trim())
|
|
237
262
|
.filter(Boolean);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
break;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
return pathLines;
|
|
263
|
+
const boundary = lines.indexOf(CLI_PROBE_OUTPUT_SENTINEL);
|
|
264
|
+
if (boundary < 0) return [];
|
|
265
|
+
return lines
|
|
266
|
+
.slice(0, boundary)
|
|
267
|
+
.filter((line) => line.includes("/") || line.includes("\\"));
|
|
247
268
|
}
|
|
248
269
|
|
|
249
270
|
/**
|
|
@@ -266,9 +287,12 @@ function parseProbeOutput(raw) {
|
|
|
266
287
|
* @param {string[]} pathLines
|
|
267
288
|
* @returns {string|null}
|
|
268
289
|
*/
|
|
269
|
-
function selectResolvedCliPath(
|
|
290
|
+
export function selectResolvedCliPath(
|
|
291
|
+
pathLines,
|
|
292
|
+
{ platform = process.platform, existsImpl = existsSync } = {},
|
|
293
|
+
) {
|
|
270
294
|
if (pathLines.length === 0) return null;
|
|
271
|
-
if (
|
|
295
|
+
if (platform !== "win32") return pathLines[0];
|
|
272
296
|
|
|
273
297
|
const withExt = pathLines.find((line) =>
|
|
274
298
|
WINDOWS_SPAWNABLE_EXTENSIONS.has(extname(line).toLowerCase()),
|
|
@@ -280,11 +304,49 @@ function selectResolvedCliPath(pathLines) {
|
|
|
280
304
|
const base = basename(bare);
|
|
281
305
|
for (const ext of [".cmd", ".exe", ".bat", ".com"]) {
|
|
282
306
|
const candidate = join(dir, `${base}${ext}`);
|
|
283
|
-
if (
|
|
307
|
+
if (existsImpl(candidate)) return candidate;
|
|
284
308
|
}
|
|
285
309
|
return bare;
|
|
286
310
|
}
|
|
287
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Resolve an npm-generated Windows command shim to the native executable it
|
|
314
|
+
* delegates to. This keeps shell:false and argv-array isolation intact instead
|
|
315
|
+
* of interpolating untrusted command arguments into cmd.exe.
|
|
316
|
+
*/
|
|
317
|
+
export function resolveDirectCliExecutable(
|
|
318
|
+
cliPath,
|
|
319
|
+
{
|
|
320
|
+
platform = process.platform,
|
|
321
|
+
existsImpl = existsSync,
|
|
322
|
+
readImpl = readFileSync,
|
|
323
|
+
} = {},
|
|
324
|
+
) {
|
|
325
|
+
if (!cliPath || platform !== "win32") return cliPath || null;
|
|
326
|
+
const extension = extname(cliPath).toLowerCase();
|
|
327
|
+
if (extension === ".exe" || extension === ".com") return cliPath;
|
|
328
|
+
if (extension !== ".cmd" && extension !== ".bat") return null;
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const shim = readImpl(cliPath, "utf-8");
|
|
332
|
+
// npm emits several equivalent native-target wrappers. Only accept a
|
|
333
|
+
// bounded %dp0%-relative .exe/.com target; never evaluate batch syntax.
|
|
334
|
+
const directMatch = shim.match(
|
|
335
|
+
/(?:^|\r?\n)\s*"%(?:~)?dp0%?[\\/]([^"\r\n]+\.(?:exe|com))"\s+%\*\s*(?:\r?\n|$)/im,
|
|
336
|
+
);
|
|
337
|
+
const variableMatch = shim.match(
|
|
338
|
+
/(?:^|\r?\n)\s*set\s+"[^"=]+=%(?:~)?dp0%?[\\/]([^"\r\n]+\.(?:exe|com))"[\s\S]*?\r?\n\s*"%[^"\r\n]+%"\s+%\*\s*(?:\r?\n|$)/im,
|
|
339
|
+
);
|
|
340
|
+
const match = directMatch || variableMatch;
|
|
341
|
+
if (!match) return null;
|
|
342
|
+
const pathApi = platform === "win32" ? win32Path : { join, dirname };
|
|
343
|
+
const target = pathApi.join(pathApi.dirname(cliPath), ...match[1].split(/[\\/]+/));
|
|
344
|
+
return existsImpl(target) ? target : null;
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
288
350
|
/**
|
|
289
351
|
* Probe whether the Claude Code CLI (`claude`) is present and responds on
|
|
290
352
|
* this machine, so `forgedock run` can drive it directly instead of
|
|
@@ -345,7 +407,10 @@ function selectResolvedCliPath(pathLines) {
|
|
|
345
407
|
* `runCliBackend()`.
|
|
346
408
|
* @returns {boolean}
|
|
347
409
|
*/
|
|
348
|
-
export function isClaudeCliAvailable(
|
|
410
|
+
export function isClaudeCliAvailable(
|
|
411
|
+
cwd = process.cwd(),
|
|
412
|
+
{ execImpl = execSync, platform = process.platform, existsImpl = existsSync } = {},
|
|
413
|
+
) {
|
|
349
414
|
const cached = cliAvailabilityCache.get(cwd);
|
|
350
415
|
if (cached && Date.now() - cached.cachedAt < CLI_AVAILABILITY_CACHE_TTL_MS) {
|
|
351
416
|
return cached.available;
|
|
@@ -354,14 +419,17 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
|
|
|
354
419
|
let available;
|
|
355
420
|
let cliPath = null;
|
|
356
421
|
try {
|
|
357
|
-
const raw = execImpl(
|
|
422
|
+
const raw = execImpl(
|
|
423
|
+
`${resolveCmd} && echo ${CLI_PROBE_OUTPUT_SENTINEL} && claude --version`,
|
|
424
|
+
{
|
|
358
425
|
cwd,
|
|
359
426
|
stdio: ["ignore", "pipe", "ignore"],
|
|
360
427
|
encoding: "utf-8",
|
|
361
428
|
timeout: CLI_PROBE_TIMEOUT_MS,
|
|
362
|
-
|
|
429
|
+
},
|
|
430
|
+
);
|
|
363
431
|
available = true;
|
|
364
|
-
cliPath = selectResolvedCliPath(parseProbeOutput(raw));
|
|
432
|
+
cliPath = selectResolvedCliPath(parseProbeOutput(raw), { platform, existsImpl });
|
|
365
433
|
} catch {
|
|
366
434
|
available = false;
|
|
367
435
|
}
|
|
@@ -397,7 +465,107 @@ export function isClaudeCliAvailable(cwd = process.cwd(), { execImpl = execSync
|
|
|
397
465
|
*/
|
|
398
466
|
export function resolveClaudeCliBinary(cwd = process.cwd(), opts = {}) {
|
|
399
467
|
isClaudeCliAvailable(cwd, opts);
|
|
400
|
-
|
|
468
|
+
let cliPath = cliAvailabilityCache.get(cwd)?.cliPath ?? null;
|
|
469
|
+
if (!cliPath) return null;
|
|
470
|
+
|
|
471
|
+
const existsImpl = opts.existsImpl ?? existsSync;
|
|
472
|
+
try {
|
|
473
|
+
if (existsImpl(cliPath)) {
|
|
474
|
+
const executable = resolveDirectCliExecutable(cliPath, { ...opts, existsImpl });
|
|
475
|
+
if (executable) return executable;
|
|
476
|
+
}
|
|
477
|
+
} catch {
|
|
478
|
+
// Treat an unverifiable cached path as stale and make one bounded refresh.
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
cliAvailabilityCache.delete(cwd);
|
|
482
|
+
isClaudeCliAvailable(cwd, opts);
|
|
483
|
+
cliPath = cliAvailabilityCache.get(cwd)?.cliPath ?? null;
|
|
484
|
+
if (!cliPath) return null;
|
|
485
|
+
try {
|
|
486
|
+
return existsImpl(cliPath)
|
|
487
|
+
? resolveDirectCliExecutable(cliPath, { ...opts, existsImpl })
|
|
488
|
+
: null;
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Invoke the CLI with one recovery attempt for a path that disappeared after
|
|
496
|
+
* resolution. The availability cache is intentionally retained, so only an
|
|
497
|
+
* observed ENOENT triggers a fresh resolution.
|
|
498
|
+
*/
|
|
499
|
+
export function runCliWithStalePathRecovery({
|
|
500
|
+
backend,
|
|
501
|
+
cwd,
|
|
502
|
+
bin,
|
|
503
|
+
cliOptions,
|
|
504
|
+
runCliFn = runCliBackend,
|
|
505
|
+
resolveCliFn = resolveClaudeCliBinary,
|
|
506
|
+
}) {
|
|
507
|
+
try {
|
|
508
|
+
return runCliFn({ ...cliOptions, bin });
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (backend !== "auto" || error?.code !== "ENOENT") throw error;
|
|
511
|
+
const freshBin = resolveCliFn(cwd);
|
|
512
|
+
if (!freshBin) throw error;
|
|
513
|
+
return runCliFn({ ...cliOptions, bin: freshBin });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Check whether the configured execution backend is locally usable without
|
|
519
|
+
* making a paid model request. CLI checks reuse the production resolver;
|
|
520
|
+
* API checks verify that a key is configured, not that the key is accepted.
|
|
521
|
+
*/
|
|
522
|
+
export function checkExecutionBackend({
|
|
523
|
+
env = process.env,
|
|
524
|
+
requested = env.FORGEDOCK_BACKEND || "auto",
|
|
525
|
+
cwd = process.cwd(),
|
|
526
|
+
apiKey = env.ANTHROPIC_API_KEY,
|
|
527
|
+
resolveCliFn = resolveClaudeCliBinary,
|
|
528
|
+
spawnImpl = spawnSync,
|
|
529
|
+
sdkAvailableFn = () => {
|
|
530
|
+
try {
|
|
531
|
+
createRequire(import.meta.url).resolve("@anthropic-ai/sdk");
|
|
532
|
+
return true;
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
} = {}) {
|
|
538
|
+
if (!VALID_BACKENDS.has(requested)) {
|
|
539
|
+
return { ready: false, backend: requested, reason: "invalid-backend" };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (isOpenCodeRuntime(env)) {
|
|
543
|
+
return { ready: false, backend: "opencode", reason: "native-opencode-required" };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (requested !== "api") {
|
|
547
|
+
const cliPath = resolveCliFn(cwd);
|
|
548
|
+
if (cliPath) {
|
|
549
|
+
const result = spawnImpl(cliPath, ["--version"], {
|
|
550
|
+
cwd,
|
|
551
|
+
shell: false,
|
|
552
|
+
encoding: "utf-8",
|
|
553
|
+
timeout: CLI_PROBE_TIMEOUT_MS,
|
|
554
|
+
});
|
|
555
|
+
if (!result?.error && result?.status === 0) {
|
|
556
|
+
return { ready: true, backend: "cli", reason: "resolved-cli" };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (requested === "cli") {
|
|
560
|
+
return { ready: false, backend: "cli", reason: "cli-unavailable" };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (apiKey && sdkAvailableFn()) {
|
|
565
|
+
return { ready: true, backend: "api", reason: "api-key-and-sdk-configured" };
|
|
566
|
+
}
|
|
567
|
+
if (apiKey) return { ready: false, backend: "api", reason: "api-sdk-missing" };
|
|
568
|
+
return { ready: false, backend: "api", reason: "api-key-missing" };
|
|
401
569
|
}
|
|
402
570
|
|
|
403
571
|
/**
|
|
@@ -472,14 +640,16 @@ export function resolveBackendLadder({
|
|
|
472
640
|
* @param {object} [opts]
|
|
473
641
|
* @param {string} [opts.requested] - "cli" | "api" | "auto" (default "auto").
|
|
474
642
|
* @param {string} [opts.cwd] - Working directory for the CLI probe.
|
|
643
|
+
* @param {object} [opts.env] - Runtime environment used for capability checks.
|
|
475
644
|
* @returns {"cli"|"api"}
|
|
476
645
|
*/
|
|
477
|
-
export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {}) {
|
|
646
|
+
export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env } = {}) {
|
|
478
647
|
if (!VALID_BACKENDS.has(requested)) {
|
|
479
648
|
throw new Error(
|
|
480
649
|
`Invalid backend "${requested}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`,
|
|
481
650
|
);
|
|
482
651
|
}
|
|
652
|
+
assertOpenCodeNativeRuntime({ env, operation: "Claude-backed backend resolution" });
|
|
483
653
|
return resolveBackendLadder({
|
|
484
654
|
override: requested === "auto" ? undefined : requested,
|
|
485
655
|
validOverrides: new Set(["cli", "api"]),
|
|
@@ -512,15 +682,11 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
|
|
|
512
682
|
* arbitrary shell commands with this process's privileges). Do not
|
|
513
683
|
* reintroduce `shell: true` or string-command interpolation here.
|
|
514
684
|
*
|
|
515
|
-
* Windows `.cmd`/`.bat`
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
* malicious-looking argv element is delivered to the child process
|
|
521
|
-
* byte-for-byte, not executed). This is the correct fix for the
|
|
522
|
-
* `execFileSync("claude", [...])` ENOENT-on-Windows regression from issue
|
|
523
|
-
* #382 for a `claude.cmd` sitting directly on a real PATH entry.
|
|
685
|
+
* Windows npm `.cmd`/`.bat` shims cannot be launched by Node with
|
|
686
|
+
* `shell:false`. `resolveDirectCliExecutable()` therefore parses only the
|
|
687
|
+
* strict npm-shim form that delegates directly to a native `.exe`/`.com` and
|
|
688
|
+
* returns that trusted target. Unknown batch scripts fail closed instead of
|
|
689
|
+
* requiring cmd.exe interpolation of the untrusted user message.
|
|
524
690
|
*
|
|
525
691
|
* CORRECTED (issue #2741): the claim that a bare `"claude"` name resolves
|
|
526
692
|
* via "spawnSync's own PATH/PATHEXT resolution" was overconfident and
|
|
@@ -534,8 +700,8 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
|
|
|
534
700
|
* above); it is resolving the *exact* absolute path the probe validated
|
|
535
701
|
* once, via `resolveClaudeCliBinary()`, and passing that resolved path as
|
|
536
702
|
* `bin` — `spawnSync(shell:false)` launches an already-fully-resolved
|
|
537
|
-
*
|
|
538
|
-
*
|
|
703
|
+
* native executable path (including the target extracted from a recognized
|
|
704
|
+
* npm shim) without needing any further PATH/PATHEXT
|
|
539
705
|
* resolution of its own. See `isClaudeCliAvailable()`/
|
|
540
706
|
* `resolveClaudeCliBinary()` above for how that resolution happens.
|
|
541
707
|
*
|
|
@@ -971,10 +1137,9 @@ export function runCliBackend({
|
|
|
971
1137
|
// No `shell` option (defaults to false): argv is passed as discrete,
|
|
972
1138
|
// unparsed elements — see the SECURITY note above. `bin` is expected to
|
|
973
1139
|
// already be a fully-resolved absolute path (see `resolveClaudeCliBinary()`
|
|
974
|
-
// and the `opts.bin` doc comment above, issue #2741) —
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
// further shell/PATHEXT resolution of its own.
|
|
1140
|
+
// and the `opts.bin` doc comment above, issue #2741) — Windows npm shims
|
|
1141
|
+
// have already been reduced to their native target, so spawnSync never
|
|
1142
|
+
// needs shell/PATHEXT resolution.
|
|
978
1143
|
const result = spawnFn(bin, cliArgs, {
|
|
979
1144
|
cwd,
|
|
980
1145
|
encoding: "utf-8",
|
|
@@ -1015,7 +1180,9 @@ export function runCliBackend({
|
|
|
1015
1180
|
);
|
|
1016
1181
|
}
|
|
1017
1182
|
if (result.error) {
|
|
1018
|
-
|
|
1183
|
+
const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
|
|
1184
|
+
error.code = result.error.code;
|
|
1185
|
+
throw error;
|
|
1019
1186
|
}
|
|
1020
1187
|
|
|
1021
1188
|
const output = (stdout + stderr).trim();
|
|
@@ -1955,9 +2122,15 @@ export async function runCommand(opts = {}) {
|
|
|
1955
2122
|
maxIterations = DEFAULT_MAX_ITERATIONS,
|
|
1956
2123
|
dryRun = false,
|
|
1957
2124
|
backend = process.env.FORGEDOCK_BACKEND || "auto",
|
|
2125
|
+
runtimeEnv = process.env,
|
|
1958
2126
|
logger = console,
|
|
1959
2127
|
} = opts;
|
|
1960
2128
|
|
|
2129
|
+
assertOpenCodeNativeRuntime({
|
|
2130
|
+
env: runtimeEnv,
|
|
2131
|
+
operation: "Claude-backed ForgeDock runner",
|
|
2132
|
+
});
|
|
2133
|
+
|
|
1961
2134
|
const spec = loadCommandSpec(commandsDir, commandName);
|
|
1962
2135
|
const systemPrompt = buildSystemPrompt(spec, { repoRoot: cwd });
|
|
1963
2136
|
// CLI-backend-specific prompt (issue #2019) — see buildCliSystemPrompt's
|
|
@@ -1973,7 +2146,12 @@ export async function runCommand(opts = {}) {
|
|
|
1973
2146
|
// misbehaving. An explicitly invalid `backend` value DOES throw here —
|
|
1974
2147
|
// surfacing a bad --backend/FORGEDOCK_BACKEND value immediately, before any
|
|
1975
2148
|
// work is attempted, rather than silently ignoring it.
|
|
1976
|
-
|
|
2149
|
+
let resolvedBackend = resolveBackend({ requested: backend, cwd, env: runtimeEnv });
|
|
2150
|
+
|
|
2151
|
+
if (resolvedBackend === "cli" && backend === "auto") {
|
|
2152
|
+
const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey, env: runtimeEnv });
|
|
2153
|
+
if (readiness.backend === "api") resolvedBackend = "api";
|
|
2154
|
+
}
|
|
1977
2155
|
|
|
1978
2156
|
if (dryRun) {
|
|
1979
2157
|
logger.log(
|
|
@@ -2050,16 +2228,28 @@ export async function runCommand(opts = {}) {
|
|
|
2050
2228
|
// the bare "claude" name only if resolution genuinely found nothing
|
|
2051
2229
|
// (e.g. an injected-execImpl test scenario with no path-like output),
|
|
2052
2230
|
// preserving prior behavior in that edge case.
|
|
2053
|
-
const resolvedBin = resolveClaudeCliBinary(cwd)
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2231
|
+
const resolvedBin = resolveClaudeCliBinary(cwd);
|
|
2232
|
+
if (!resolvedBin && backend === "auto") {
|
|
2233
|
+
resolvedBackend = "api";
|
|
2234
|
+
} else if (!resolvedBin) {
|
|
2235
|
+
const err = new Error("The configured claude CLI path disappeared before invocation.");
|
|
2236
|
+
err.code = "CLI_BACKEND_UNAVAILABLE";
|
|
2237
|
+
throw err;
|
|
2238
|
+
} else {
|
|
2239
|
+
return runCliWithStalePathRecovery({
|
|
2240
|
+
backend,
|
|
2241
|
+
cwd,
|
|
2242
|
+
bin: resolvedBin,
|
|
2243
|
+
cliOptions: {
|
|
2244
|
+
spec,
|
|
2245
|
+
userMessage,
|
|
2246
|
+
systemPrompt: cliSystemPrompt,
|
|
2247
|
+
args,
|
|
2248
|
+
cwd,
|
|
2249
|
+
logger,
|
|
2250
|
+
},
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2063
2253
|
}
|
|
2064
2254
|
|
|
2065
2255
|
if (!apiKey) {
|
|
@@ -1136,8 +1136,30 @@ Background dispatch (via `run_in_background=true` on each `Agent()` call) is the
|
|
|
1136
1136
|
Check both at the start of Phase 4, before the first dispatch:
|
|
1137
1137
|
|
|
1138
1138
|
```bash
|
|
1139
|
+
# Runtime selection is explicit for non-Claude adapters. OpenCode runs nested
|
|
1140
|
+
# work-on phases through its native task path, so it must not be treated as a
|
|
1141
|
+
# failed Claude probe.
|
|
1142
|
+
FORGE_RUNTIME="${FORGE_RUNTIME:-}"
|
|
1143
|
+
if [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_PID:-}" ] || [ -n "${OPENCODE:-}" ]; then
|
|
1144
|
+
FORGE_RUNTIME="opencode"
|
|
1145
|
+
fi
|
|
1146
|
+
|
|
1147
|
+
if [ "$FORGE_RUNTIME" = "opencode" ]; then
|
|
1148
|
+
echo "OpenCode runtime detected: native task dispatch selected."
|
|
1149
|
+
# ForgeDock's OpenCode plugin opts into background task events by default so
|
|
1150
|
+
# each completed DAG node can wake this parent without a wave barrier.
|
|
1151
|
+
if [ "${OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS:-true}" = "false" ]; then
|
|
1152
|
+
echo "OpenCode background subagents explicitly disabled: orchestration is degraded to foreground task dispatch."
|
|
1153
|
+
BACKGROUND_DISPATCH_ENABLED=false
|
|
1154
|
+
else
|
|
1155
|
+
BACKGROUND_DISPATCH_ENABLED=true
|
|
1156
|
+
fi
|
|
1157
|
+
fi
|
|
1158
|
+
|
|
1139
1159
|
# Feature gate check — run once before Phase 4 dispatch begins
|
|
1140
|
-
BACKGROUND_DISPATCH_ENABLED
|
|
1160
|
+
if [ "${BACKGROUND_DISPATCH_ENABLED:-}" != "false" ]; then
|
|
1161
|
+
BACKGROUND_DISPATCH_ENABLED=true
|
|
1162
|
+
fi
|
|
1141
1163
|
|
|
1142
1164
|
if [ -n "${CLAUDE_CODE_DISABLE_BACKGROUND_TASKS:-}" ]; then
|
|
1143
1165
|
echo "Background dispatch disabled: CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set."
|
|
@@ -1147,7 +1169,7 @@ fi
|
|
|
1147
1169
|
# Version check: if the Claude Code version can be read, compare it.
|
|
1148
1170
|
# If the version cannot be determined, default to ENABLED (optimistic).
|
|
1149
1171
|
CC_VERSION=$(claude --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "")
|
|
1150
|
-
if [ -n "$CC_VERSION" ]; then
|
|
1172
|
+
if [ "$FORGE_RUNTIME" != "opencode" ] && [ -n "$CC_VERSION" ]; then
|
|
1151
1173
|
# Compare major.minor.patch numerically
|
|
1152
1174
|
IFS='.' read -r CC_MAJOR CC_MINOR CC_PATCH <<< "$CC_VERSION"
|
|
1153
1175
|
if [ "$CC_MAJOR" -lt 2 ] || \
|
|
@@ -1159,9 +1181,25 @@ if [ -n "$CC_VERSION" ]; then
|
|
|
1159
1181
|
fi
|
|
1160
1182
|
```
|
|
1161
1183
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1184
|
+
When `FORGE_RUNTIME=opencode` (or an OpenCode runtime marker is present), do not
|
|
1185
|
+
interpret a missing `claude` executable as an environment failure. The Phase 4
|
|
1186
|
+
dispatcher must use OpenCode's native `task` contract instead of the Claude
|
|
1187
|
+
`Agent(...)` fallback. With background subagents enabled, each task is launched
|
|
1188
|
+
with `background=true` and its injected task-result event immediately drives
|
|
1189
|
+
predecessor classification and newly-ready dispatch. Claude remains the default
|
|
1190
|
+
when no runtime marker is present, preserving the existing Claude behavior.
|
|
1191
|
+
|
|
1192
|
+
**When `BACKGROUND_DISPATCH_ENABLED=false`**: Claude falls back to its existing
|
|
1193
|
+
synchronous/polling behavior. OpenCode reaches this branch only after an
|
|
1194
|
+
explicit `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false`; use independent
|
|
1195
|
+
foreground `task` calls where the host can execute them concurrently, and
|
|
1196
|
+
report that per-completion streaming is unavailable. Do not use Claude's
|
|
1197
|
+
`Agent(...)` fallback and do not claim the event-driven DAG guarantee.
|
|
1198
|
+
|
|
1199
|
+
**When `BACKGROUND_DISPATCH_ENABLED=true`**: Claude uses
|
|
1200
|
+
`run_in_background=true` and reacts to `agent_completed` notifications. OpenCode
|
|
1201
|
+
uses `task(..., background=true)` and reacts to each injected task-result event.
|
|
1202
|
+
Do NOT poll or wait for a whole wave in either enabled path.
|
|
1165
1203
|
|
|
1166
1204
|
### Orchestrator state reconstruction on wake / after compaction
|
|
1167
1205
|
|
|
@@ -1359,4 +1397,3 @@ done
|
|
|
1359
1397
|
**Why this keeps context small**: Each `Agent()` call returns an agent ID stored only in `AGENT_ISSUE_MAP`, which is rebuilt per Step 4A.pre dispatch batch. After compaction, the map is gone — but the DAG state, including `blocked-on-human-merge` tracking (a durable `FORGE:BLOCKED_ON_HUMAN_MERGE` comment plus label, not an in-context variable), is fully on GitHub. The reconstruction above re-derives the ready set, the gated set, and the blocked-on-human-merge set from labels and comments alone, so the orchestrator context never needs to hold cumulative dispatch history.
|
|
1360
1398
|
|
|
1361
1399
|
---
|
|
1362
|
-
|