forgedock 1.12.1 → 1.13.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/bin/engine/phases.mjs +54 -12
- package/bin/engine/runlog.mjs +12 -0
- package/bin/engine-cli.mjs +3 -1
- package/bin/engine.mjs +64 -16
- package/bin/hooks/interactive-engine.mjs +135 -212
- package/bin/hooks/pre-tool-use.mjs +16 -4
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +1 -1
- package/bin/runner.mjs +234 -21
- package/commands/autopilot.md +6 -6
- package/commands/cleanup.md +7 -4
- package/commands/orchestrate/phase-1-resolve.md +1 -1
- package/commands/orchestrate/phase-3-dependency.md +14 -19
- package/commands/orchestrate/phase-4-execution.md +50 -38
- package/commands/orchestrate/phase-6-report.md +6 -0
- package/commands/orchestrate/safety.md +1 -1
- package/commands/pipeline-health/phase-2-metrics.md +39 -0
- package/commands/pipeline-health/phase-5-report.md +1 -0
- package/commands/pipeline-resume.md +1 -1
- package/commands/pipeline-status.md +2 -2
- package/commands/review-pr-agents/api.md +1 -1
- package/commands/review-pr-agents/auth.md +1 -1
- package/commands/review-pr-agents/infra.md +9 -3
- package/commands/review-pr-agents/protocols.md +19 -5
- package/commands/review-pr-agents/scraper.md +4 -1
- package/commands/review-pr-agents/security.md +17 -5
- package/commands/review-pr-agents.md +1 -1
- package/commands/review-pr-staging.md +302 -8
- package/commands/review-pr.md +254 -34
- package/commands/work-on/build/architect.md +36 -0
- package/commands/work-on/build/validate.md +88 -4
- package/commands/work-on/build.md +95 -3
- package/commands/work-on/remediate.md +115 -13
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +29 -0
- package/docs/RELEASE-NOTES.md +45 -0
- package/docs/spec/forge-protocol-v1.md +22 -1
- package/docs/spec/review-protocol.md +24 -4
- package/package.json +2 -1
- package/scripts/check-blast-radius.sh +164 -0
- package/scripts/check-blast-radius.test.sh +183 -0
- package/scripts/check-contract-scope.sh +137 -0
- package/scripts/check-contract-scope.test.sh +83 -0
- package/scripts/check-spec-bash.sh +60 -3
- package/scripts/check-spec-bash.test.sh +56 -0
- package/scripts/check-spec-markers.sh +29 -0
- package/scripts/check-test-wiring.sh +57 -0
- package/scripts/check-test-wiring.test.sh +70 -0
- package/scripts/classify-finding.sh +72 -8
- package/scripts/classify-finding.test.sh +77 -8
- package/scripts/close-dossier-no-main-commit.test.sh +12 -12
- package/scripts/diff-size.test.sh +3 -2
- package/scripts/doctor-pipeline-state.sh +35 -0
- package/scripts/forge-annotation.sh +3 -0
- package/scripts/forge-root.test.sh +70 -1
- package/scripts/forge-run.sh +2 -2
- package/scripts/recovery-claim-live.test.sh +10 -7
- package/scripts/remediate-basesync.test.sh +103 -0
- package/scripts/review-delta.sh +206 -0
- package/scripts/review-delta.test.sh +213 -0
- package/scripts/review-pr-gate2.test.sh +97 -0
- package/scripts/review-pr-staging-scope.test.sh +99 -0
- package/scripts/run-shell-tests.sh +115 -0
- package/scripts/select-fix-targets.sh +17 -5
package/bin/runner.mjs
CHANGED
|
@@ -263,6 +263,83 @@ export function resolveConfiguredCliTimeout(cwd) {
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Derive the flat forge.yaml key stem for a runner command name (forge#3589).
|
|
268
|
+
* `parseForgeYaml` only supports `section:` + one nested `key: scalar` level,
|
|
269
|
+
* so per-command settings live flat under `engine:` as `<stem>_model` /
|
|
270
|
+
* `<stem>_max_turns`, where the stem is the command name with every
|
|
271
|
+
* non-alphanumeric character replaced by `_`
|
|
272
|
+
* (`review-pr-staging` -> `review_pr_staging`, `work-on/review` -> `work_on_review`).
|
|
273
|
+
* The single derivation shared by the resolver, docs, and tests.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} commandName
|
|
276
|
+
* @returns {string}
|
|
277
|
+
*/
|
|
278
|
+
export function engineConfigKey(commandName) {
|
|
279
|
+
return String(commandName ?? "").replace(/[^A-Za-z0-9]/g, "_");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// A model value reaches argv as a discrete element (no shell), but is still
|
|
283
|
+
// restricted to the characters real model ids/aliases use.
|
|
284
|
+
const CLI_MODEL_PATTERN = /^[A-Za-z0-9._:[\]-]+$/;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Validate a model string destined for `--model`. Returns the trimmed value,
|
|
288
|
+
* or `null` when absent/invalid (never forwarded).
|
|
289
|
+
*
|
|
290
|
+
* @param {unknown} value
|
|
291
|
+
* @returns {string|null}
|
|
292
|
+
*/
|
|
293
|
+
export function sanitizeCliModel(value) {
|
|
294
|
+
if (typeof value !== "string") return null;
|
|
295
|
+
const trimmed = value.trim();
|
|
296
|
+
return trimmed.length > 0 && trimmed.length <= 128 && CLI_MODEL_PATTERN.test(trimmed)
|
|
297
|
+
? trimmed
|
|
298
|
+
: null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Validate a turn bound destined for `--max-turns`: a positive integer (number
|
|
303
|
+
* or all-digit string), else `null`.
|
|
304
|
+
*
|
|
305
|
+
* @param {unknown} value
|
|
306
|
+
* @returns {number|null}
|
|
307
|
+
*/
|
|
308
|
+
export function sanitizeMaxTurns(value) {
|
|
309
|
+
if (typeof value === "string" && !/^\d{1,6}$/.test(value.trim())) return null;
|
|
310
|
+
const n = typeof value === "string" ? Number(value.trim()) : value;
|
|
311
|
+
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Resolve per-command CLI settings from forge.yaml's flat
|
|
316
|
+
* `engine.<stem>_model` / `engine.<stem>_max_turns` keys (forge#3589), where
|
|
317
|
+
* `<stem>` comes from `engineConfigKey(commandName)`. Opt-in: with no config
|
|
318
|
+
* both fields are `null`. Fail-soft on any read/parse error. Invalid values
|
|
319
|
+
* are dropped (never forwarded to argv).
|
|
320
|
+
*
|
|
321
|
+
* @param {string} cwd
|
|
322
|
+
* @param {string} commandName
|
|
323
|
+
* @returns {{model: string|null, maxTurns: number|null}}
|
|
324
|
+
*/
|
|
325
|
+
export function resolveCommandEngineSettings(cwd, commandName) {
|
|
326
|
+
const none = { model: null, maxTurns: null };
|
|
327
|
+
try {
|
|
328
|
+
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
329
|
+
if (!existsSync(forgeYamlPath)) return none;
|
|
330
|
+
const engine = parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.engine;
|
|
331
|
+
if (!engine || typeof engine !== "object") return none;
|
|
332
|
+
const stem = engineConfigKey(commandName);
|
|
333
|
+
if (!stem) return none;
|
|
334
|
+
return {
|
|
335
|
+
model: sanitizeCliModel(resolveModelAlias(engine[`${stem}_model`])),
|
|
336
|
+
maxTurns: sanitizeMaxTurns(engine[`${stem}_max_turns`]),
|
|
337
|
+
};
|
|
338
|
+
} catch {
|
|
339
|
+
return none;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
266
343
|
// ---------------------------------------------------------------------------
|
|
267
344
|
// Backend selection (issue #2003) — CLI vs API execution backend
|
|
268
345
|
// ---------------------------------------------------------------------------
|
|
@@ -442,6 +519,7 @@ export function isClaudeCliAvailable(
|
|
|
442
519
|
const resolveCmd = process.platform === "win32" ? "where claude" : "command -v claude";
|
|
443
520
|
let available;
|
|
444
521
|
let cliPath = null;
|
|
522
|
+
let version = null;
|
|
445
523
|
try {
|
|
446
524
|
const raw = execImpl(
|
|
447
525
|
`${resolveCmd} && echo ${CLI_PROBE_OUTPUT_SENTINEL} && claude --version`,
|
|
@@ -454,6 +532,7 @@ export function isClaudeCliAvailable(
|
|
|
454
532
|
);
|
|
455
533
|
available = true;
|
|
456
534
|
cliPath = selectResolvedCliPath(parseProbeOutput(raw), { platform, existsImpl });
|
|
535
|
+
version = parseCliVersion(raw);
|
|
457
536
|
} catch {
|
|
458
537
|
available = false;
|
|
459
538
|
}
|
|
@@ -462,7 +541,7 @@ export function isClaudeCliAvailable(
|
|
|
462
541
|
// entry — whether "oldest" means "never refreshed" or "not refreshed in
|
|
463
542
|
// the longest time."
|
|
464
543
|
cliAvailabilityCache.delete(cwd);
|
|
465
|
-
cliAvailabilityCache.set(cwd, { available, cliPath, cachedAt: Date.now() });
|
|
544
|
+
cliAvailabilityCache.set(cwd, { available, cliPath, version, cachedAt: Date.now() });
|
|
466
545
|
if (cliAvailabilityCache.size > CLI_AVAILABILITY_CACHE_MAX_SIZE) {
|
|
467
546
|
const oldestKey = cliAvailabilityCache.keys().next().value;
|
|
468
547
|
cliAvailabilityCache.delete(oldestKey);
|
|
@@ -470,6 +549,53 @@ export function isClaudeCliAvailable(
|
|
|
470
549
|
return available;
|
|
471
550
|
}
|
|
472
551
|
|
|
552
|
+
// Minimum `claude` CLI versions that accept the print-mode flags (forge#3589).
|
|
553
|
+
// `--max-turns` is intentionally absent from `claude --help`, so support is
|
|
554
|
+
// gated on the cached `claude --version` probe, never on help-text scraping.
|
|
555
|
+
const CLI_MIN_VERSION_MODEL_FLAG = [1, 0, 0];
|
|
556
|
+
const CLI_MIN_VERSION_MAX_TURNS_FLAG = [1, 0, 0];
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Extract a `major.minor.patch` version from probe output (the text after the
|
|
560
|
+
* probe sentinel when present, else the whole string). Bounded, linear regex.
|
|
561
|
+
*
|
|
562
|
+
* @param {string} raw
|
|
563
|
+
* @returns {number[]|null}
|
|
564
|
+
*/
|
|
565
|
+
export function parseCliVersion(raw) {
|
|
566
|
+
const text = String(raw ?? "");
|
|
567
|
+
const idx = text.indexOf(CLI_PROBE_OUTPUT_SENTINEL);
|
|
568
|
+
const tail = idx >= 0 ? text.slice(idx + CLI_PROBE_OUTPUT_SENTINEL.length) : text;
|
|
569
|
+
const m = /(\d{1,4})\.(\d{1,4})\.(\d{1,4})/.exec(tail.slice(0, 512));
|
|
570
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function versionAtLeast(version, min) {
|
|
574
|
+
if (!Array.isArray(version)) return false;
|
|
575
|
+
for (let i = 0; i < 3; i++) {
|
|
576
|
+
if (version[i] !== min[i]) return version[i] > min[i];
|
|
577
|
+
}
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Which print-mode flags the installed `claude` CLI supports, derived from the
|
|
583
|
+
* cached `--version` probe. Unparseable version or unavailable CLI means
|
|
584
|
+
* unsupported (flags omitted).
|
|
585
|
+
*
|
|
586
|
+
* @param {string} [cwd]
|
|
587
|
+
* @param {object} [opts] - Forwarded to `isClaudeCliAvailable()` (test seams).
|
|
588
|
+
* @returns {{model: boolean, maxTurns: boolean}}
|
|
589
|
+
*/
|
|
590
|
+
export function getClaudeCliCapabilities(cwd = process.cwd(), opts = {}) {
|
|
591
|
+
isClaudeCliAvailable(cwd, opts);
|
|
592
|
+
const version = cliAvailabilityCache.get(cwd)?.version ?? null;
|
|
593
|
+
return {
|
|
594
|
+
model: versionAtLeast(version, CLI_MIN_VERSION_MODEL_FLAG),
|
|
595
|
+
maxTurns: versionAtLeast(version, CLI_MIN_VERSION_MAX_TURNS_FLAG),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
473
599
|
/**
|
|
474
600
|
* Return the exact absolute `claude` executable path that
|
|
475
601
|
* `isClaudeCliAvailable()` validated for `cwd` (issue #2741), ensuring the
|
|
@@ -935,7 +1061,6 @@ export function sanitizeOutputExcerptForLog(output) {
|
|
|
935
1061
|
|
|
936
1062
|
// Shared usage-limit phrasing: detection and reset extraction must agree on it.
|
|
937
1063
|
const USAGE_LIMIT_PHRASE = "(?:session|weekly|usage) limit";
|
|
938
|
-
const USAGE_LIMIT_RE = new RegExp(USAGE_LIMIT_PHRASE, "i");
|
|
939
1064
|
// `... limit · resets 3pm (Asia/Kolkata)` — capture stops at the zone's `)`.
|
|
940
1065
|
const USAGE_LIMIT_RESET_PAREN_RE = new RegExp(
|
|
941
1066
|
`${USAGE_LIMIT_PHRASE}[^\\n"]*?resets?\\s+([^\\n"]+?\\))`, "i");
|
|
@@ -961,6 +1086,16 @@ function collectUsageLimitWindows(source) {
|
|
|
961
1086
|
return windows;
|
|
962
1087
|
}
|
|
963
1088
|
|
|
1089
|
+
/**
|
|
1090
|
+
* True when `text` holds a `limit ... resets ...` clause (the shape the reset
|
|
1091
|
+
* extractor needs). Only bounded windows reach the polynomial regexes (#3522).
|
|
1092
|
+
*/
|
|
1093
|
+
function hasUsageLimitResetClause(text) {
|
|
1094
|
+
if (typeof text !== "string") return false;
|
|
1095
|
+
return collectUsageLimitWindows(text).some(
|
|
1096
|
+
(w) => USAGE_LIMIT_RESET_PAREN_RE.test(w) || USAGE_LIMIT_RESET_LINE_RE.test(w));
|
|
1097
|
+
}
|
|
1098
|
+
|
|
964
1099
|
/**
|
|
965
1100
|
* Parses a `claude --output-format json` single-result envelope from `text`.
|
|
966
1101
|
* Returns the parsed object, or `undefined` when `text` is not exactly one
|
|
@@ -980,9 +1115,13 @@ function parseCliEnvelope(text) {
|
|
|
980
1115
|
|
|
981
1116
|
/**
|
|
982
1117
|
* Detects whether a failed CLI run was a Claude usage/session/weekly limit.
|
|
983
|
-
*
|
|
984
|
-
* `
|
|
985
|
-
*
|
|
1118
|
+
* Deliberately narrow (forge#3523): the envelope's `api_error` must be
|
|
1119
|
+
* `"usage_limit_reached"`, or the envelope must be `is_error: true` with a
|
|
1120
|
+
* `result` carrying a full `limit ... resets ...` clause, or (no envelope) the
|
|
1121
|
+
* plain combined output must carry that same clause. A bare HTTP 429 (a
|
|
1122
|
+
* transient rate limit) or a keyword-only mention is NOT a usage limit; a limit
|
|
1123
|
+
* with neither signal is treated as an ordinary failure. `stdout` is parsed
|
|
1124
|
+
* ALONE (forge#2422 invariant); never throws.
|
|
986
1125
|
*
|
|
987
1126
|
* @param {string} stdout - raw stdout of the CLI run
|
|
988
1127
|
* @param {string} [output] - combined stdout+stderr, for the plain-text fallback
|
|
@@ -991,11 +1130,10 @@ function parseCliEnvelope(text) {
|
|
|
991
1130
|
export function detectUsageLimit(stdout, output = "") {
|
|
992
1131
|
const env = parseCliEnvelope(stdout);
|
|
993
1132
|
if (env) {
|
|
994
|
-
if (env.api_error === "usage_limit_reached"
|
|
995
|
-
|
|
996
|
-
return false;
|
|
1133
|
+
if (env.api_error === "usage_limit_reached") return true;
|
|
1134
|
+
return env.is_error === true && hasUsageLimitResetClause(env.result);
|
|
997
1135
|
}
|
|
998
|
-
return
|
|
1136
|
+
return hasUsageLimitResetClause(output || "");
|
|
999
1137
|
}
|
|
1000
1138
|
|
|
1001
1139
|
/**
|
|
@@ -1185,7 +1323,17 @@ export function runCliBackend({
|
|
|
1185
1323
|
logger = console,
|
|
1186
1324
|
bin = "claude",
|
|
1187
1325
|
spawnFn = spawnSync,
|
|
1326
|
+
model = null,
|
|
1327
|
+
maxTurns = null,
|
|
1188
1328
|
}) {
|
|
1329
|
+
// forge#3589: opt-in flags, validated again here as defence in depth. Callers
|
|
1330
|
+
// (runCommand) are responsible for the version gate. Unset -> argv unchanged.
|
|
1331
|
+
const modelFlag = sanitizeCliModel(model);
|
|
1332
|
+
const maxTurnsFlag = sanitizeMaxTurns(maxTurns);
|
|
1333
|
+
const optionalFlags = [
|
|
1334
|
+
...(modelFlag ? ["--model", modelFlag] : []),
|
|
1335
|
+
...(maxTurnsFlag ? ["--max-turns", String(maxTurnsFlag)] : []),
|
|
1336
|
+
];
|
|
1189
1337
|
const rawTimeout = parseInt(process.env.FORGEDOCK_CLI_TIMEOUT_MS, 10);
|
|
1190
1338
|
const configuredTimeout = resolveConfiguredCliTimeout(cwd);
|
|
1191
1339
|
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0
|
|
@@ -1212,6 +1360,7 @@ export function runCliBackend({
|
|
|
1212
1360
|
userMessage,
|
|
1213
1361
|
"--output-format",
|
|
1214
1362
|
"json",
|
|
1363
|
+
...optionalFlags,
|
|
1215
1364
|
"--dangerously-skip-permissions",
|
|
1216
1365
|
];
|
|
1217
1366
|
|
|
@@ -1226,6 +1375,7 @@ export function runCliBackend({
|
|
|
1226
1375
|
userMessage,
|
|
1227
1376
|
"--output-format",
|
|
1228
1377
|
"json",
|
|
1378
|
+
...optionalFlags,
|
|
1229
1379
|
"--append-system-prompt-file",
|
|
1230
1380
|
systemPromptPath,
|
|
1231
1381
|
"--dangerously-skip-permissions",
|
|
@@ -1297,6 +1447,41 @@ export function runCliBackend({
|
|
|
1297
1447
|
|
|
1298
1448
|
const output = (stdout + stderr).trim();
|
|
1299
1449
|
|
|
1450
|
+
// forge#3589: turn-limit exhaustion arrives as a JSON envelope with
|
|
1451
|
+
// `subtype: "error_max_turns"`, possibly on a non-zero exit. Detect it from
|
|
1452
|
+
// the parsed stdout alone (no regex over output, #3522/#2422) on BOTH exit
|
|
1453
|
+
// paths, before the generic non-zero classification below, so callers can
|
|
1454
|
+
// tell it apart from a crash and resume the same phase.
|
|
1455
|
+
let maxTurnsEnvelope = null;
|
|
1456
|
+
try {
|
|
1457
|
+
const parsedEnvelope = JSON.parse(stdout.trim());
|
|
1458
|
+
if (parsedEnvelope && typeof parsedEnvelope === "object") maxTurnsEnvelope = parsedEnvelope;
|
|
1459
|
+
} catch {
|
|
1460
|
+
// Non-JSON stdout: not a max-turns envelope.
|
|
1461
|
+
}
|
|
1462
|
+
if (maxTurnsEnvelope?.subtype === "error_max_turns") {
|
|
1463
|
+
const numTurns = Number.isFinite(maxTurnsEnvelope.num_turns) ? maxTurnsEnvelope.num_turns : null;
|
|
1464
|
+
const err = new Error(
|
|
1465
|
+
`claude CLI stopped at the --max-turns limit` +
|
|
1466
|
+
(maxTurnsFlag ? ` (${maxTurnsFlag})` : "") +
|
|
1467
|
+
(numTurns !== null ? ` after ${numTurns} turns` : "") +
|
|
1468
|
+
`. Raise the engine max_turns setting and re-run (--retry resumes this phase).`,
|
|
1469
|
+
);
|
|
1470
|
+
err.code = "CLI_MAX_TURNS";
|
|
1471
|
+
err.numTurns = numTurns;
|
|
1472
|
+
const u = maxTurnsEnvelope.usage;
|
|
1473
|
+
err.usage =
|
|
1474
|
+
u && typeof u === "object"
|
|
1475
|
+
? {
|
|
1476
|
+
input_tokens: toFiniteUsageNumber(u.input_tokens),
|
|
1477
|
+
output_tokens: toFiniteUsageNumber(u.output_tokens),
|
|
1478
|
+
cache_creation_input_tokens: toFiniteUsageNumber(u.cache_creation_input_tokens),
|
|
1479
|
+
cache_read_input_tokens: toFiniteUsageNumber(u.cache_read_input_tokens),
|
|
1480
|
+
}
|
|
1481
|
+
: null;
|
|
1482
|
+
throw err;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1300
1485
|
if (result.status !== 0) {
|
|
1301
1486
|
// Non-zero exit: always emit a self-contained diagnostic, regardless of
|
|
1302
1487
|
// whether stdout/stderr captured anything. Previously this branch threw
|
|
@@ -1346,13 +1531,18 @@ export function runCliBackend({
|
|
|
1346
1531
|
// quota-exhaustion failure will clear without reading raw logs. Only
|
|
1347
1532
|
// ever set when the pattern actually matches — never fabricated.
|
|
1348
1533
|
// The reset text is read from stdout's parsed envelope first (stdout
|
|
1349
|
-
// ALONE, forge#2422), then from the combined output.
|
|
1350
|
-
|
|
1351
|
-
|
|
1534
|
+
// ALONE, forge#2422), then from the combined output. Gated on the same
|
|
1535
|
+
// narrowed predicate as `usageLimit` (forge#3523): the engine pauses on
|
|
1536
|
+
// `resetAtEpochMs` alone, so a bare 429 or keyword-only failure whose
|
|
1537
|
+
// output happens to contain a reset clause must not carry one either.
|
|
1538
|
+
const isUsageLimit = detectUsageLimit(stdout, output);
|
|
1539
|
+
const resetAt = isUsageLimit
|
|
1540
|
+
? extractSessionLimitResetTime(stdout) ?? extractSessionLimitResetTime(output)
|
|
1541
|
+
: undefined;
|
|
1352
1542
|
// Structured usage-limit flag: lets the engine pause by a bounded
|
|
1353
1543
|
// default when the reset time itself is missing or unparseable. The
|
|
1354
1544
|
// engine, not the runner, owns that default — no epoch is fabricated here.
|
|
1355
|
-
if (
|
|
1545
|
+
if (isUsageLimit) err.usageLimit = true;
|
|
1356
1546
|
if (resetAt) {
|
|
1357
1547
|
err.resetAt = resetAt;
|
|
1358
1548
|
// forge#2524: also attach a machine-usable epoch-ms timestamp so
|
|
@@ -1399,9 +1589,11 @@ export function runCliBackend({
|
|
|
1399
1589
|
const stdoutTrimmed = stdout.trim();
|
|
1400
1590
|
let parsedResult = null;
|
|
1401
1591
|
let usage = null;
|
|
1592
|
+
let turns = null;
|
|
1402
1593
|
try {
|
|
1403
1594
|
const parsed = JSON.parse(stdoutTrimmed);
|
|
1404
1595
|
if (parsed && typeof parsed === "object") {
|
|
1596
|
+
if (Number.isFinite(parsed.num_turns)) turns = parsed.num_turns;
|
|
1405
1597
|
parsedResult = typeof parsed.result === "string" ? parsed.result : null;
|
|
1406
1598
|
if (parsed.usage && typeof parsed.usage === "object") {
|
|
1407
1599
|
usage = {
|
|
@@ -1482,6 +1674,10 @@ export function runCliBackend({
|
|
|
1482
1674
|
stopReason: "cli_exit_0",
|
|
1483
1675
|
usage,
|
|
1484
1676
|
model: "cli",
|
|
1677
|
+
// forge#3589: additive — the model forced via --model (null = CLI default)
|
|
1678
|
+
// and the envelope's turn count (null when absent), for runlog recording.
|
|
1679
|
+
cliModel: modelFlag,
|
|
1680
|
+
turns,
|
|
1485
1681
|
backend: "cli",
|
|
1486
1682
|
// forge#3521: the phase's final reply (the CLI envelope's `.result`), so
|
|
1487
1683
|
// callers can read its structured `*_RESULT` block. Additive; "" when absent.
|
|
@@ -2319,25 +2515,24 @@ export async function runCommand(opts = {}) {
|
|
|
2319
2515
|
);
|
|
2320
2516
|
}
|
|
2321
2517
|
|
|
2322
|
-
//
|
|
2323
|
-
//
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
2518
|
+
// maxIterations only applies to the api backend — a single `claude --print`
|
|
2519
|
+
// invocation has no iteration-cap concept (it is NOT `--max-turns`; see the
|
|
2520
|
+
// per-command settings below). `model` is forwarded as `--model` (forge#3589).
|
|
2521
|
+
// Warn (rather than silently drop) when the caller explicitly supplied
|
|
2522
|
+
// maxIterations, so the behavior is discoverable outside of --dry-run.
|
|
2327
2523
|
// hasOwnProperty on the raw opts (not the destructured, default-applied
|
|
2328
2524
|
// values above) is required here: model/maxIterations always resolve to
|
|
2329
2525
|
// a computed default, so checking their truthiness would fire this
|
|
2330
2526
|
// warning on every single cli-backend run.
|
|
2331
2527
|
const ignoredOptions = [];
|
|
2332
|
-
if (Object.prototype.hasOwnProperty.call(opts, "model")) ignoredOptions.push("model");
|
|
2333
2528
|
if (Object.prototype.hasOwnProperty.call(opts, "maxIterations"))
|
|
2334
2529
|
ignoredOptions.push("maxIterations");
|
|
2335
2530
|
if (ignoredOptions.length > 0) {
|
|
2336
2531
|
logger.log(
|
|
2337
2532
|
`Warning: --${ignoredOptions.join(" and --")} ${
|
|
2338
2533
|
ignoredOptions.length > 1 ? "are" : "is"
|
|
2339
|
-
} ignored on the cli backend (
|
|
2340
|
-
`
|
|
2534
|
+
} ignored on the cli backend (a single claude --print has no iteration cap). ` +
|
|
2535
|
+
`Use --backend api to honor ${
|
|
2341
2536
|
ignoredOptions.length > 1 ? "these options" : "this option"
|
|
2342
2537
|
}.`,
|
|
2343
2538
|
);
|
|
@@ -2349,6 +2544,22 @@ export async function runCommand(opts = {}) {
|
|
|
2349
2544
|
// (e.g. an injected-execImpl test scenario with no path-like output),
|
|
2350
2545
|
// preserving prior behavior in that edge case.
|
|
2351
2546
|
const resolvedBin = resolveClaudeCliBinary(cwd);
|
|
2547
|
+
// forge#3589: per-command CLI settings. Precedence: forge.yaml
|
|
2548
|
+
// engine.<stem>_model (most specific), then the run-level model the caller
|
|
2549
|
+
// explicitly supplied (opts.model, e.g. the engine's --model), else omit (the CLI's own default; no behaviour change).
|
|
2550
|
+
// max_turns comes from config only. Flags are forwarded only when the
|
|
2551
|
+
// installed CLI version supports them.
|
|
2552
|
+
const commandSettings = resolveCommandEngineSettings(cwd, commandName);
|
|
2553
|
+
const runLevelModel = Object.prototype.hasOwnProperty.call(opts, "model")
|
|
2554
|
+
? sanitizeCliModel(resolveModelAlias(opts.model))
|
|
2555
|
+
: null;
|
|
2556
|
+
let cliModel = commandSettings.model ?? runLevelModel;
|
|
2557
|
+
let cliMaxTurns = commandSettings.maxTurns;
|
|
2558
|
+
if (resolvedBin && (cliModel || cliMaxTurns)) {
|
|
2559
|
+
const caps = getClaudeCliCapabilities(cwd);
|
|
2560
|
+
if (cliModel && !caps.model) cliModel = null;
|
|
2561
|
+
if (cliMaxTurns && !caps.maxTurns) cliMaxTurns = null;
|
|
2562
|
+
}
|
|
2352
2563
|
if (!resolvedBin && backend === "auto") {
|
|
2353
2564
|
resolvedBackend = "api";
|
|
2354
2565
|
} else if (!resolvedBin) {
|
|
@@ -2367,6 +2578,8 @@ export async function runCommand(opts = {}) {
|
|
|
2367
2578
|
args,
|
|
2368
2579
|
cwd,
|
|
2369
2580
|
logger,
|
|
2581
|
+
model: cliModel,
|
|
2582
|
+
maxTurns: cliMaxTurns,
|
|
2370
2583
|
},
|
|
2371
2584
|
});
|
|
2372
2585
|
}
|
package/commands/autopilot.md
CHANGED
|
@@ -228,9 +228,9 @@ INFLIGHT_ISSUES=$(gh issue list $GH_FLAG \
|
|
|
228
228
|
--state open \
|
|
229
229
|
--limit 200 \
|
|
230
230
|
--json number,title,labels \
|
|
231
|
-
--jq '[.[] | select(.labels | map(.name) | any(. == "workflow:building" or . == "workflow:in-review"))] | length' \
|
|
231
|
+
--jq '[.[] | select(.labels | map(.name) | any(. == "workflow:building" or . == "workflow:in-review" or . == "workflow:remediating"))] | length' \
|
|
232
232
|
2>/dev/null || echo '0')
|
|
233
|
-
echo "STATE: $INFLIGHT_ISSUES in-flight issue(s) (workflow:building
|
|
233
|
+
echo "STATE: $INFLIGHT_ISSUES in-flight issue(s) (workflow:building, workflow:in-review, or workflow:remediating)"
|
|
234
234
|
```
|
|
235
235
|
|
|
236
236
|
### 0D: Detect staging vs main delta
|
|
@@ -725,7 +725,7 @@ while true; do
|
|
|
725
725
|
--json number,milestone,labels \
|
|
726
726
|
--jq '[.[] | select(
|
|
727
727
|
.milestone == null and
|
|
728
|
-
(.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "needs-human") | not)
|
|
728
|
+
(.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "workflow:remediating" or . == "needs-human") | not)
|
|
729
729
|
)] | .[].number' \
|
|
730
730
|
2>/dev/null || echo '')
|
|
731
731
|
|
|
@@ -855,7 +855,7 @@ To approve: remove \`needs-human\` label from #${ISSUE_NUM} and re-run \`/autopi
|
|
|
855
855
|
--json number,milestone,labels \
|
|
856
856
|
--jq '[.[] | select(
|
|
857
857
|
.milestone == null and
|
|
858
|
-
(.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "needs-human") | not)
|
|
858
|
+
(.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "workflow:remediating" or . == "needs-human") | not)
|
|
859
859
|
)] | .[].number' \
|
|
860
860
|
2>/dev/null || echo '')
|
|
861
861
|
for N in $PRE_DISPATCH_ISSUES; do DISPATCHED_ISSUES+=("$N"); done
|
|
@@ -1005,7 +1005,7 @@ while IFS= read -r milestone; do
|
|
|
1005
1005
|
'[.[] | select(
|
|
1006
1006
|
.milestone != null and
|
|
1007
1007
|
(.milestone.title | ascii_downcase | gsub("[^a-z0-9]+"; "-") | ltrimstr("-") | rtrimstr("-")) == $slug and
|
|
1008
|
-
(.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "needs-human") | not)
|
|
1008
|
+
(.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "workflow:remediating" or . == "needs-human") | not)
|
|
1009
1009
|
)] | .[].number' \
|
|
1010
1010
|
2>/dev/null || echo '')
|
|
1011
1011
|
|
|
@@ -1109,7 +1109,7 @@ To approve: remove \`needs-human\` label from #${ISSUE_NUM} and re-run \`/autopi
|
|
|
1109
1109
|
'[.[] | select(
|
|
1110
1110
|
.milestone != null and
|
|
1111
1111
|
(.milestone.title | ascii_downcase | gsub("[^a-z0-9]+"; "-") | ltrimstr("-") | rtrimstr("-")) == $slug and
|
|
1112
|
-
(.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "needs-human") | not)
|
|
1112
|
+
(.labels | map(.name) | any(. == "workflow:merged" or . == "workflow:invalid" or . == "workflow:decomposed" or . == "workflow:remediating" or . == "needs-human") | not)
|
|
1113
1113
|
)] | .[].number' \
|
|
1114
1114
|
2>/dev/null || echo '')
|
|
1115
1115
|
for N in $MS_PRE_DISPATCH; do DISPATCHED_ISSUES+=("$N"); done
|
package/commands/cleanup.md
CHANGED
|
@@ -71,6 +71,9 @@ gh issue list {GH_FLAG} --state closed --label "workflow:building" --limit 100 -
|
|
|
71
71
|
echo "=== Stale workflow:awaiting-merge ==="
|
|
72
72
|
gh issue list {GH_FLAG} --state closed --label "workflow:awaiting-merge" --limit 100 --json number,title --jq '.[] | "#\(.number) — \(.title)"'
|
|
73
73
|
|
|
74
|
+
echo "=== Stale workflow:remediating ==="
|
|
75
|
+
gh issue list {GH_FLAG} --state closed --label "workflow:remediating" --limit 100 --json number,title --jq '.[] | "#\(.number) — \(.title)"'
|
|
76
|
+
|
|
74
77
|
echo "=== Stale workflow:investigating ==="
|
|
75
78
|
gh issue list {GH_FLAG} --state closed --label "workflow:investigating" --limit 100 --json number,title --jq '.[] | "#\(.number) — \(.title)"'
|
|
76
79
|
|
|
@@ -82,11 +85,11 @@ gh issue list {GH_FLAG} --state closed --label "needs-validation" --limit 100 --
|
|
|
82
85
|
|
|
83
86
|
For each closed issue with a stale intermediate label:
|
|
84
87
|
|
|
85
|
-
**Stale `workflow:in-review`, `workflow:building`, `workflow:awaiting-merge`** — these were merged but label wasn't updated:
|
|
88
|
+
**Stale `workflow:in-review`, `workflow:building`, `workflow:awaiting-merge`, `workflow:remediating`** — these were merged but label wasn't updated:
|
|
86
89
|
```bash
|
|
87
90
|
for NUM in {stale_issue_numbers}; do
|
|
88
91
|
gh issue edit $NUM {GH_FLAG} --add-label "workflow:merged"
|
|
89
|
-
gh issue edit $NUM {GH_FLAG} --remove-label "workflow:in-review,workflow:building,workflow:awaiting-merge,needs-validation" 2>/dev/null || true
|
|
92
|
+
gh issue edit $NUM {GH_FLAG} --remove-label "workflow:in-review,workflow:building,workflow:awaiting-merge,workflow:remediating,needs-validation" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
90
93
|
done
|
|
91
94
|
```
|
|
92
95
|
|
|
@@ -159,7 +162,7 @@ These were closed outside the pipeline. Report count but don't fix (not necessar
|
|
|
159
162
|
|
|
160
163
|
### 1D: Detect stale OPEN issues in intermediate workflow states
|
|
161
164
|
|
|
162
|
-
Open issues stuck in `workflow:building`, `workflow:in-review`, or `workflow:investigating` beyond a configurable threshold indicate a stalled agent. These are invisible to Phase 1A–1C (which only query closed issues).
|
|
165
|
+
Open issues stuck in `workflow:building`, `workflow:in-review`, `workflow:remediating`, or `workflow:investigating` beyond a configurable threshold indicate a stalled agent. These are invisible to Phase 1A–1C (which only query closed issues).
|
|
163
166
|
|
|
164
167
|
```bash
|
|
165
168
|
# Configurable threshold — override by setting STALE_THRESHOLD_HOURS before invoking /cleanup
|
|
@@ -174,7 +177,7 @@ echo "=== Stale-Open Intermediate Issues (threshold: ${STALE_THRESHOLD_HOURS}h)
|
|
|
174
177
|
|
|
175
178
|
STALE_OPEN_COUNT=0
|
|
176
179
|
|
|
177
|
-
for LABEL in "workflow:building" "workflow:in-review" "workflow:investigating"; do
|
|
180
|
+
for LABEL in "workflow:building" "workflow:in-review" "workflow:remediating" "workflow:investigating"; do
|
|
178
181
|
ISSUES=$(gh issue list {GH_FLAG} \
|
|
179
182
|
--state open \
|
|
180
183
|
--label "$LABEL" \
|
|
@@ -281,7 +281,7 @@ Remove issues that should NOT be worked on:
|
|
|
281
281
|
- Has `workflow:invalid` label
|
|
282
282
|
- Has `needs-human` label (requires manual action)
|
|
283
283
|
- Has `workflow:decomposed` label (parent tracker — its sub-issues should be picked up instead)
|
|
284
|
-
- Has `workflow:building
|
|
284
|
+
- Has `workflow:building`, `workflow:in-review`, or `workflow:remediating` label (already in progress; `workflow:remediating` is a review-handoff state awaiting a remediation worker)
|
|
285
285
|
- Is an epic (`epic` label) — these are planning containers, not buildable
|
|
286
286
|
|
|
287
287
|
If a `workflow:decomposed` issue is found, automatically expand it to its open sub-issues instead.
|
|
@@ -83,27 +83,22 @@ declare -A EDGE_FILES # "{PRED}:{SUCCESSOR}" → the specific file(s) that tri
|
|
|
83
83
|
declare -A FILE_SOURCE # {NUM} → contract-deliverables | affected-files-section | body-fallback | none (forge#2436, forge#2848)
|
|
84
84
|
declare -A ISSUE_FILES # {NUM} → newline-separated declared file set (forge#2844)
|
|
85
85
|
|
|
86
|
-
# Resolve ForgeDock's helper from
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
# OpenCode runs against installed ForgeDock). Keep the precedence aligned with
|
|
91
|
-
# phase-4-execution.md's classify-lane resolver.
|
|
86
|
+
# Resolve ForgeDock's helper from a trusted ForgeDock install only (same tiers as the
|
|
87
|
+
# TRUSTED_SCRIPT resolver). The orchestrator runs inside the project being worked on, so
|
|
88
|
+
# neither the working directory nor the target repo may supply the script (#3400/#3483),
|
|
89
|
+
# and a bare `scripts/extract-affected-files.sh` would silently fail on consumer repos.
|
|
92
90
|
resolve_extract_affected_files() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
local candidate
|
|
100
|
-
for candidate in "${candidates[@]}"; do
|
|
101
|
-
if [ -f "$candidate" ]; then
|
|
102
|
-
printf '%s\n' "$candidate"
|
|
103
|
-
return 0
|
|
104
|
-
fi
|
|
91
|
+
# HELPER_SCRIPT resolver (EXTRACT_SCRIPT; trusted-install tiers only, never the working directory or the target repo, #3400/#3483): plugin root, FORGE_ROOT, FORGEDOCK_HOME, FORGE_HOME, install symlink, marketplaces, newest pinned plugin cache under CLAUDE_CONFIG_DIR then ~/.claude.
|
|
92
|
+
local _l _tc _cfg _c
|
|
93
|
+
_l="$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null || true)"; _l="${_l%/commands/work-on.md}"
|
|
94
|
+
_tc="$(printf '%s\n' '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGEDOCK_HOME:-}" "${FORGE_HOME:-}" "$_l" "$HOME/.claude/plugins/marketplaces/forgedock")"
|
|
95
|
+
for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
|
|
96
|
+
[ -n "$_cfg" ] && _tc="$_tc"$'\n'"$(find -L "$_cfg/plugins/cache/forgedock/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' | sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true)"
|
|
105
97
|
done
|
|
106
|
-
|
|
98
|
+
while IFS= read -r _c; do
|
|
99
|
+
case "$_c" in /*) if [ -f "$_c/scripts/extract-affected-files.sh" ]; then printf '%s\n' "$_c/scripts/extract-affected-files.sh"; return 0; fi ;; esac
|
|
100
|
+
done <<< "$_tc"
|
|
101
|
+
echo "ERROR: extract-affected-files.sh is not installed in any trusted ForgeDock install path." >&2
|
|
107
102
|
return 1
|
|
108
103
|
}
|
|
109
104
|
|