forgedock 1.12.0 → 1.13.0
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-args.test.mjs +2 -1
- package/bin/engine/phases.mjs +349 -90
- package/bin/engine/runlog.mjs +25 -2
- package/bin/engine-cli.mjs +4 -1
- package/bin/engine.mjs +72 -19
- package/bin/hooks/interactive-engine.mjs +135 -209
- package/bin/hooks/pre-tool-use.mjs +16 -4
- package/bin/labels.json +10 -0
- package/bin/opencode-adapter.mjs +1 -1
- package/bin/orchestrate-preflight.mjs +1 -1
- package/bin/runner.mjs +258 -23
- package/bin/watch-utils.mjs +1 -0
- package/commands/autopilot.md +6 -6
- package/commands/cleanup.md +8 -5
- 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 +80 -53
- 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/recover-orphans.md +5 -5
- 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 +3 -3
- package/commands/review-pr-agents/protocols.md +15 -6
- package/commands/review-pr-agents/scraper.md +1 -1
- package/commands/review-pr-agents/security.md +5 -5
- package/commands/review-pr-agents.md +12 -12
- package/commands/review-pr-staging.md +226 -8
- package/commands/review-pr.md +394 -53
- package/commands/work-on/build/architect.md +36 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build/validate.md +88 -4
- package/commands/work-on/build.md +106 -7
- package/commands/work-on/decompose.md +2 -2
- package/commands/work-on/investigate.md +14 -4
- package/commands/work-on/remediate.md +137 -25
- package/commands/work-on/review.md +25 -9
- package/commands/work-on.md +15 -15
- 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/label-state-machine.md +6 -1
- package/docs/spec/review-protocol.md +20 -5
- package/package.json +2 -1
- package/packages/protocol/src/phases.js +21 -0
- 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 +103 -13
- 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-domain-map.test.sh +87 -0
- package/scripts/review-pr-gate2.test.sh +84 -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/scripts/transition-label.sh +3 -1
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,13 +1061,40 @@ 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");
|
|
942
1067
|
// Zone-less fallback: capture to end of line, but never across a quote.
|
|
943
1068
|
const USAGE_LIMIT_RESET_LINE_RE = new RegExp(
|
|
944
1069
|
`${USAGE_LIMIT_PHRASE}[^\\n"]*?resets?\\s+([^\\n"]+?)\\s*$`, "im");
|
|
1070
|
+
// The two regexes above are polynomial on long no-match lines, so they only
|
|
1071
|
+
// ever run on a short window per phrase hit (untrusted input can be tens of MB).
|
|
1072
|
+
const USAGE_LIMIT_SCAN_WINDOW_CHARS = 512;
|
|
1073
|
+
const USAGE_LIMIT_MAX_PHRASE_HITS = 32;
|
|
1074
|
+
|
|
1075
|
+
/** Linear scan for phrase hits; returns at most MAX_PHRASE_HITS newline-cut windows. */
|
|
1076
|
+
function collectUsageLimitWindows(source) {
|
|
1077
|
+
const windows = [];
|
|
1078
|
+
const phraseRe = new RegExp(USAGE_LIMIT_PHRASE, "gi");
|
|
1079
|
+
let hit;
|
|
1080
|
+
while (windows.length < USAGE_LIMIT_MAX_PHRASE_HITS && (hit = phraseRe.exec(source)) !== null) {
|
|
1081
|
+
let window = source.slice(hit.index, hit.index + USAGE_LIMIT_SCAN_WINDOW_CHARS);
|
|
1082
|
+
const nl = window.indexOf("\n");
|
|
1083
|
+
if (nl !== -1) window = window.slice(0, nl);
|
|
1084
|
+
windows.push(window);
|
|
1085
|
+
}
|
|
1086
|
+
return windows;
|
|
1087
|
+
}
|
|
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
|
+
}
|
|
945
1098
|
|
|
946
1099
|
/**
|
|
947
1100
|
* Parses a `claude --output-format json` single-result envelope from `text`.
|
|
@@ -962,9 +1115,13 @@ function parseCliEnvelope(text) {
|
|
|
962
1115
|
|
|
963
1116
|
/**
|
|
964
1117
|
* Detects whether a failed CLI run was a Claude usage/session/weekly limit.
|
|
965
|
-
*
|
|
966
|
-
* `
|
|
967
|
-
*
|
|
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.
|
|
968
1125
|
*
|
|
969
1126
|
* @param {string} stdout - raw stdout of the CLI run
|
|
970
1127
|
* @param {string} [output] - combined stdout+stderr, for the plain-text fallback
|
|
@@ -973,11 +1130,10 @@ function parseCliEnvelope(text) {
|
|
|
973
1130
|
export function detectUsageLimit(stdout, output = "") {
|
|
974
1131
|
const env = parseCliEnvelope(stdout);
|
|
975
1132
|
if (env) {
|
|
976
|
-
if (env.api_error === "usage_limit_reached"
|
|
977
|
-
|
|
978
|
-
return false;
|
|
1133
|
+
if (env.api_error === "usage_limit_reached") return true;
|
|
1134
|
+
return env.is_error === true && hasUsageLimitResetClause(env.result);
|
|
979
1135
|
}
|
|
980
|
-
return
|
|
1136
|
+
return hasUsageLimitResetClause(output || "");
|
|
981
1137
|
}
|
|
982
1138
|
|
|
983
1139
|
/**
|
|
@@ -1006,8 +1162,12 @@ export function extractSessionLimitResetTime(output) {
|
|
|
1006
1162
|
// JSON-wrapped message cannot swallow the trailing `","type":...` fields.
|
|
1007
1163
|
const text = parseCliEnvelope(output)?.result ?? output;
|
|
1008
1164
|
const source = typeof text === "string" ? text : output;
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1165
|
+
const windows = collectUsageLimitWindows(source);
|
|
1166
|
+
let match = null;
|
|
1167
|
+
for (const w of windows) { if ((match = USAGE_LIMIT_RESET_PAREN_RE.exec(w))) break; }
|
|
1168
|
+
if (!match) {
|
|
1169
|
+
for (const w of windows) { if ((match = USAGE_LIMIT_RESET_LINE_RE.exec(w))) break; }
|
|
1170
|
+
}
|
|
1011
1171
|
if (!match) return undefined;
|
|
1012
1172
|
const resetAt = match[1].trim();
|
|
1013
1173
|
if (resetAt.length === 0) return undefined;
|
|
@@ -1163,7 +1323,17 @@ export function runCliBackend({
|
|
|
1163
1323
|
logger = console,
|
|
1164
1324
|
bin = "claude",
|
|
1165
1325
|
spawnFn = spawnSync,
|
|
1326
|
+
model = null,
|
|
1327
|
+
maxTurns = null,
|
|
1166
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
|
+
];
|
|
1167
1337
|
const rawTimeout = parseInt(process.env.FORGEDOCK_CLI_TIMEOUT_MS, 10);
|
|
1168
1338
|
const configuredTimeout = resolveConfiguredCliTimeout(cwd);
|
|
1169
1339
|
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0
|
|
@@ -1190,6 +1360,7 @@ export function runCliBackend({
|
|
|
1190
1360
|
userMessage,
|
|
1191
1361
|
"--output-format",
|
|
1192
1362
|
"json",
|
|
1363
|
+
...optionalFlags,
|
|
1193
1364
|
"--dangerously-skip-permissions",
|
|
1194
1365
|
];
|
|
1195
1366
|
|
|
@@ -1204,6 +1375,7 @@ export function runCliBackend({
|
|
|
1204
1375
|
userMessage,
|
|
1205
1376
|
"--output-format",
|
|
1206
1377
|
"json",
|
|
1378
|
+
...optionalFlags,
|
|
1207
1379
|
"--append-system-prompt-file",
|
|
1208
1380
|
systemPromptPath,
|
|
1209
1381
|
"--dangerously-skip-permissions",
|
|
@@ -1275,6 +1447,41 @@ export function runCliBackend({
|
|
|
1275
1447
|
|
|
1276
1448
|
const output = (stdout + stderr).trim();
|
|
1277
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
|
+
|
|
1278
1485
|
if (result.status !== 0) {
|
|
1279
1486
|
// Non-zero exit: always emit a self-contained diagnostic, regardless of
|
|
1280
1487
|
// whether stdout/stderr captured anything. Previously this branch threw
|
|
@@ -1324,13 +1531,18 @@ export function runCliBackend({
|
|
|
1324
1531
|
// quota-exhaustion failure will clear without reading raw logs. Only
|
|
1325
1532
|
// ever set when the pattern actually matches — never fabricated.
|
|
1326
1533
|
// The reset text is read from stdout's parsed envelope first (stdout
|
|
1327
|
-
// ALONE, forge#2422), then from the combined output.
|
|
1328
|
-
|
|
1329
|
-
|
|
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;
|
|
1330
1542
|
// Structured usage-limit flag: lets the engine pause by a bounded
|
|
1331
1543
|
// default when the reset time itself is missing or unparseable. The
|
|
1332
1544
|
// engine, not the runner, owns that default — no epoch is fabricated here.
|
|
1333
|
-
if (
|
|
1545
|
+
if (isUsageLimit) err.usageLimit = true;
|
|
1334
1546
|
if (resetAt) {
|
|
1335
1547
|
err.resetAt = resetAt;
|
|
1336
1548
|
// forge#2524: also attach a machine-usable epoch-ms timestamp so
|
|
@@ -1377,9 +1589,11 @@ export function runCliBackend({
|
|
|
1377
1589
|
const stdoutTrimmed = stdout.trim();
|
|
1378
1590
|
let parsedResult = null;
|
|
1379
1591
|
let usage = null;
|
|
1592
|
+
let turns = null;
|
|
1380
1593
|
try {
|
|
1381
1594
|
const parsed = JSON.parse(stdoutTrimmed);
|
|
1382
1595
|
if (parsed && typeof parsed === "object") {
|
|
1596
|
+
if (Number.isFinite(parsed.num_turns)) turns = parsed.num_turns;
|
|
1383
1597
|
parsedResult = typeof parsed.result === "string" ? parsed.result : null;
|
|
1384
1598
|
if (parsed.usage && typeof parsed.usage === "object") {
|
|
1385
1599
|
usage = {
|
|
@@ -1460,6 +1674,10 @@ export function runCliBackend({
|
|
|
1460
1674
|
stopReason: "cli_exit_0",
|
|
1461
1675
|
usage,
|
|
1462
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,
|
|
1463
1681
|
backend: "cli",
|
|
1464
1682
|
// forge#3521: the phase's final reply (the CLI envelope's `.result`), so
|
|
1465
1683
|
// callers can read its structured `*_RESULT` block. Additive; "" when absent.
|
|
@@ -2297,25 +2515,24 @@ export async function runCommand(opts = {}) {
|
|
|
2297
2515
|
);
|
|
2298
2516
|
}
|
|
2299
2517
|
|
|
2300
|
-
//
|
|
2301
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
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.
|
|
2305
2523
|
// hasOwnProperty on the raw opts (not the destructured, default-applied
|
|
2306
2524
|
// values above) is required here: model/maxIterations always resolve to
|
|
2307
2525
|
// a computed default, so checking their truthiness would fire this
|
|
2308
2526
|
// warning on every single cli-backend run.
|
|
2309
2527
|
const ignoredOptions = [];
|
|
2310
|
-
if (Object.prototype.hasOwnProperty.call(opts, "model")) ignoredOptions.push("model");
|
|
2311
2528
|
if (Object.prototype.hasOwnProperty.call(opts, "maxIterations"))
|
|
2312
2529
|
ignoredOptions.push("maxIterations");
|
|
2313
2530
|
if (ignoredOptions.length > 0) {
|
|
2314
2531
|
logger.log(
|
|
2315
2532
|
`Warning: --${ignoredOptions.join(" and --")} ${
|
|
2316
2533
|
ignoredOptions.length > 1 ? "are" : "is"
|
|
2317
|
-
} ignored on the cli backend (
|
|
2318
|
-
`
|
|
2534
|
+
} ignored on the cli backend (a single claude --print has no iteration cap). ` +
|
|
2535
|
+
`Use --backend api to honor ${
|
|
2319
2536
|
ignoredOptions.length > 1 ? "these options" : "this option"
|
|
2320
2537
|
}.`,
|
|
2321
2538
|
);
|
|
@@ -2327,6 +2544,22 @@ export async function runCommand(opts = {}) {
|
|
|
2327
2544
|
// (e.g. an injected-execImpl test scenario with no path-like output),
|
|
2328
2545
|
// preserving prior behavior in that edge case.
|
|
2329
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
|
+
}
|
|
2330
2563
|
if (!resolvedBin && backend === "auto") {
|
|
2331
2564
|
resolvedBackend = "api";
|
|
2332
2565
|
} else if (!resolvedBin) {
|
|
@@ -2345,6 +2578,8 @@ export async function runCommand(opts = {}) {
|
|
|
2345
2578
|
args,
|
|
2346
2579
|
cwd,
|
|
2347
2580
|
logger,
|
|
2581
|
+
model: cliModel,
|
|
2582
|
+
maxTurns: cliMaxTurns,
|
|
2348
2583
|
},
|
|
2349
2584
|
});
|
|
2350
2585
|
}
|
package/bin/watch-utils.mjs
CHANGED
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" \
|
|
@@ -226,7 +229,7 @@ For each orphaned issue found:
|
|
|
226
229
|
```bash
|
|
227
230
|
gh issue close $NUM {GH_FLAG} --comment "Closed by cleanup — PR #$MERGED_PR was already merged."
|
|
228
231
|
gh issue edit $NUM {GH_FLAG} --add-label "workflow:merged"
|
|
229
|
-
gh issue edit $NUM {GH_FLAG} --remove-label "workflow:in-review,workflow:awaiting-merge" 2>/dev/null || true
|
|
232
|
+
gh issue edit $NUM {GH_FLAG} --remove-label "workflow:in-review,workflow:remediating,workflow:awaiting-merge" 2>/dev/null || true # allowlist:check-command-side-effects
|
|
230
233
|
```
|
|
231
234
|
|
|
232
235
|
Also check open issues with `workflow:building` — same pattern (search for merged PRs referencing them).
|
|
@@ -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
|
|