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.
Files changed (64) hide show
  1. package/bin/engine/phases.mjs +54 -12
  2. package/bin/engine/runlog.mjs +12 -0
  3. package/bin/engine-cli.mjs +3 -1
  4. package/bin/engine.mjs +64 -16
  5. package/bin/hooks/interactive-engine.mjs +135 -212
  6. package/bin/hooks/pre-tool-use.mjs +16 -4
  7. package/bin/labels.json +5 -0
  8. package/bin/opencode-adapter.mjs +1 -1
  9. package/bin/runner.mjs +234 -21
  10. package/commands/autopilot.md +6 -6
  11. package/commands/cleanup.md +7 -4
  12. package/commands/orchestrate/phase-1-resolve.md +1 -1
  13. package/commands/orchestrate/phase-3-dependency.md +14 -19
  14. package/commands/orchestrate/phase-4-execution.md +50 -38
  15. package/commands/orchestrate/phase-6-report.md +6 -0
  16. package/commands/orchestrate/safety.md +1 -1
  17. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  18. package/commands/pipeline-health/phase-5-report.md +1 -0
  19. package/commands/pipeline-resume.md +1 -1
  20. package/commands/pipeline-status.md +2 -2
  21. package/commands/review-pr-agents/api.md +1 -1
  22. package/commands/review-pr-agents/auth.md +1 -1
  23. package/commands/review-pr-agents/infra.md +9 -3
  24. package/commands/review-pr-agents/protocols.md +19 -5
  25. package/commands/review-pr-agents/scraper.md +4 -1
  26. package/commands/review-pr-agents/security.md +17 -5
  27. package/commands/review-pr-agents.md +1 -1
  28. package/commands/review-pr-staging.md +302 -8
  29. package/commands/review-pr.md +254 -34
  30. package/commands/work-on/build/architect.md +36 -0
  31. package/commands/work-on/build/validate.md +88 -4
  32. package/commands/work-on/build.md +95 -3
  33. package/commands/work-on/remediate.md +115 -13
  34. package/commands/work-on.md +4 -4
  35. package/docs/CONFIG.md +29 -0
  36. package/docs/RELEASE-NOTES.md +45 -0
  37. package/docs/spec/forge-protocol-v1.md +22 -1
  38. package/docs/spec/review-protocol.md +24 -4
  39. package/package.json +2 -1
  40. package/scripts/check-blast-radius.sh +164 -0
  41. package/scripts/check-blast-radius.test.sh +183 -0
  42. package/scripts/check-contract-scope.sh +137 -0
  43. package/scripts/check-contract-scope.test.sh +83 -0
  44. package/scripts/check-spec-bash.sh +60 -3
  45. package/scripts/check-spec-bash.test.sh +56 -0
  46. package/scripts/check-spec-markers.sh +29 -0
  47. package/scripts/check-test-wiring.sh +57 -0
  48. package/scripts/check-test-wiring.test.sh +70 -0
  49. package/scripts/classify-finding.sh +72 -8
  50. package/scripts/classify-finding.test.sh +77 -8
  51. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  52. package/scripts/diff-size.test.sh +3 -2
  53. package/scripts/doctor-pipeline-state.sh +35 -0
  54. package/scripts/forge-annotation.sh +3 -0
  55. package/scripts/forge-root.test.sh +70 -1
  56. package/scripts/forge-run.sh +2 -2
  57. package/scripts/recovery-claim-live.test.sh +10 -7
  58. package/scripts/remediate-basesync.test.sh +103 -0
  59. package/scripts/review-delta.sh +206 -0
  60. package/scripts/review-delta.test.sh +213 -0
  61. package/scripts/review-pr-gate2.test.sh +97 -0
  62. package/scripts/review-pr-staging-scope.test.sh +99 -0
  63. package/scripts/run-shell-tests.sh +115 -0
  64. 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
- * Prefers the envelope's structured fields (`api_error: "usage_limit_reached"`,
984
- * `api_error_status: 429`), then the envelope's `result` text, then the plain
985
- * combined output. `stdout` is parsed ALONE (forge#2422 invariant); never throws.
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" || env.api_error_status === 429) return true;
995
- if (typeof env.result === "string" && USAGE_LIMIT_RE.test(env.result)) return true;
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 USAGE_LIMIT_RE.test(output || "") && /resets?\s/i.test(output || "");
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
- const resetAt =
1351
- extractSessionLimitResetTime(stdout) ?? extractSessionLimitResetTime(output);
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 (detectUsageLimit(stdout, output)) err.usageLimit = true;
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
- // model/maxIterations only apply to the api backend — the cli backend
2323
- // uses whatever model the `claude` CLI itself is configured for, and has
2324
- // no equivalent iteration-cap concept for a single `claude --print`
2325
- // invocation. Warn (rather than silently drop) when the caller explicitly
2326
- // supplied either, so the behavior is discoverable outside of --dry-run.
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 (backend: cli uses whatever model the ` +
2340
- `claude CLI itself is configured for). Use --backend api to honor ${
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
  }
@@ -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 or workflow:in-review)"
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
@@ -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` or `workflow:in-review` label (already in progress)
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 the runtime installation before falling back to
87
- # the target repository. The orchestrator runs inside the project being worked on,
88
- # so a bare `scripts/extract-affected-files.sh` silently fails when that project
89
- # has not copied ForgeDock's helper scripts into its own repository (observed in
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
- local candidates=()
94
- case '${CLAUDE_PLUGIN_ROOT}' in /*) candidates+=('${CLAUDE_PLUGIN_ROOT}/scripts/extract-affected-files.sh') ;; esac # running plugin first
95
- [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/extract-affected-files.sh")
96
- [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/extract-affected-files.sh")
97
- candidates+=("$PWD/scripts/extract-affected-files.sh")
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
- echo "ERROR: extract-affected-files.sh is not installed in any configured runtime path." >&2
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