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.
Files changed (76) hide show
  1. package/bin/engine/phases-args.test.mjs +2 -1
  2. package/bin/engine/phases.mjs +349 -90
  3. package/bin/engine/runlog.mjs +25 -2
  4. package/bin/engine-cli.mjs +4 -1
  5. package/bin/engine.mjs +72 -19
  6. package/bin/hooks/interactive-engine.mjs +135 -209
  7. package/bin/hooks/pre-tool-use.mjs +16 -4
  8. package/bin/labels.json +10 -0
  9. package/bin/opencode-adapter.mjs +1 -1
  10. package/bin/orchestrate-preflight.mjs +1 -1
  11. package/bin/runner.mjs +258 -23
  12. package/bin/watch-utils.mjs +1 -0
  13. package/commands/autopilot.md +6 -6
  14. package/commands/cleanup.md +8 -5
  15. package/commands/orchestrate/phase-1-resolve.md +1 -1
  16. package/commands/orchestrate/phase-3-dependency.md +14 -19
  17. package/commands/orchestrate/phase-4-execution.md +80 -53
  18. package/commands/orchestrate/phase-6-report.md +6 -0
  19. package/commands/orchestrate/safety.md +1 -1
  20. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  21. package/commands/pipeline-health/phase-5-report.md +1 -0
  22. package/commands/pipeline-resume.md +1 -1
  23. package/commands/pipeline-status.md +2 -2
  24. package/commands/recover-orphans.md +5 -5
  25. package/commands/review-pr-agents/api.md +1 -1
  26. package/commands/review-pr-agents/auth.md +1 -1
  27. package/commands/review-pr-agents/infra.md +3 -3
  28. package/commands/review-pr-agents/protocols.md +15 -6
  29. package/commands/review-pr-agents/scraper.md +1 -1
  30. package/commands/review-pr-agents/security.md +5 -5
  31. package/commands/review-pr-agents.md +12 -12
  32. package/commands/review-pr-staging.md +226 -8
  33. package/commands/review-pr.md +394 -53
  34. package/commands/work-on/build/architect.md +36 -0
  35. package/commands/work-on/build/implement.md +1 -1
  36. package/commands/work-on/build/validate.md +88 -4
  37. package/commands/work-on/build.md +106 -7
  38. package/commands/work-on/decompose.md +2 -2
  39. package/commands/work-on/investigate.md +14 -4
  40. package/commands/work-on/remediate.md +137 -25
  41. package/commands/work-on/review.md +25 -9
  42. package/commands/work-on.md +15 -15
  43. package/docs/CONFIG.md +29 -0
  44. package/docs/RELEASE-NOTES.md +45 -0
  45. package/docs/spec/forge-protocol-v1.md +22 -1
  46. package/docs/spec/label-state-machine.md +6 -1
  47. package/docs/spec/review-protocol.md +20 -5
  48. package/package.json +2 -1
  49. package/packages/protocol/src/phases.js +21 -0
  50. package/scripts/check-blast-radius.sh +164 -0
  51. package/scripts/check-blast-radius.test.sh +183 -0
  52. package/scripts/check-contract-scope.sh +137 -0
  53. package/scripts/check-contract-scope.test.sh +83 -0
  54. package/scripts/check-spec-bash.sh +60 -3
  55. package/scripts/check-spec-bash.test.sh +56 -0
  56. package/scripts/check-spec-markers.sh +29 -0
  57. package/scripts/check-test-wiring.sh +57 -0
  58. package/scripts/check-test-wiring.test.sh +70 -0
  59. package/scripts/classify-finding.sh +72 -8
  60. package/scripts/classify-finding.test.sh +77 -8
  61. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  62. package/scripts/diff-size.test.sh +3 -2
  63. package/scripts/doctor-pipeline-state.sh +35 -0
  64. package/scripts/forge-annotation.sh +3 -0
  65. package/scripts/forge-root.test.sh +103 -13
  66. package/scripts/forge-run.sh +2 -2
  67. package/scripts/recovery-claim-live.test.sh +10 -7
  68. package/scripts/remediate-basesync.test.sh +103 -0
  69. package/scripts/review-delta.sh +206 -0
  70. package/scripts/review-delta.test.sh +213 -0
  71. package/scripts/review-pr-domain-map.test.sh +87 -0
  72. package/scripts/review-pr-gate2.test.sh +84 -0
  73. package/scripts/review-pr-staging-scope.test.sh +99 -0
  74. package/scripts/run-shell-tests.sh +115 -0
  75. package/scripts/select-fix-targets.sh +17 -5
  76. 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
- * Prefers the envelope's structured fields (`api_error: "usage_limit_reached"`,
966
- * `api_error_status: 429`), then the envelope's `result` text, then the plain
967
- * 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.
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" || env.api_error_status === 429) return true;
977
- if (typeof env.result === "string" && USAGE_LIMIT_RE.test(env.result)) return true;
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 USAGE_LIMIT_RE.test(output || "") && /resets?\s/i.test(output || "");
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 match =
1010
- USAGE_LIMIT_RESET_PAREN_RE.exec(source) ?? USAGE_LIMIT_RESET_LINE_RE.exec(source);
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
- const resetAt =
1329
- 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;
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 (detectUsageLimit(stdout, output)) err.usageLimit = true;
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
- // model/maxIterations only apply to the api backend — the cli backend
2301
- // uses whatever model the `claude` CLI itself is configured for, and has
2302
- // no equivalent iteration-cap concept for a single `claude --print`
2303
- // invocation. Warn (rather than silently drop) when the caller explicitly
2304
- // 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.
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 (backend: cli uses whatever model the ` +
2318
- `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 ${
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
  }
@@ -25,6 +25,7 @@ export const FALLBACK_WORKFLOW_LABELS = [
25
25
  "workflow:ready-to-build",
26
26
  "workflow:building",
27
27
  "workflow:in-review",
28
+ "workflow:remediating",
28
29
  "needs-human",
29
30
  ];
30
31
 
@@ -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" \
@@ -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` 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