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
@@ -57,7 +57,10 @@ export function deriveState(events) {
57
57
  /** @type {import("./phases.mjs").RunState} */
58
58
  const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
59
59
  phase: null, branch: null, pr: null, terminal: false,
60
- terminalReason: null, lease: null, lastRateLimit: null };
60
+ terminalReason: null, lease: null, lastRateLimit: null,
61
+ remediationKind: null };
62
+ // forge#3528: handoff reason of the LAST PHASE_COMMIT only (replaced, not sticky).
63
+ let lastCommitReason = null;
61
64
  for (const e of events) {
62
65
  switch (e.event) {
63
66
  case "RUN_START":
@@ -71,6 +74,11 @@ export function deriveState(events) {
71
74
  // forge#3504: a blocked phase persists its reason so a resumed run still routes to
72
75
  // remediate instead of defaulting to a "merged" close.
73
76
  if (e.terminalReason) s.terminalReason = e.terminalReason;
77
+ lastCommitReason = e.terminalReason ?? null;
78
+ // forge#3530: the review's remediation kind must survive resume. A later review
79
+ // commit replaces it; a committed remediate consumes it, so it is never reused.
80
+ if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
81
+ else if (e.phase === "remediate") s.remediationKind = null;
74
82
  break;
75
83
  case "RUN_TERMINAL":
76
84
  s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
@@ -78,8 +86,23 @@ export function deriveState(events) {
78
86
  // forge#3511: an explicit `run-issue --retry` reopens an engine-error
79
87
  // terminal. Bumping `v` makes the local state newer than the stale
80
88
  // remote terminal index so reconcile remirrors instead of re-terminating.
89
+ // forge#3528: RUN_TERMINAL overwrote the persisted handoff reason
90
+ // (needs-human / decomposed); restore the last commit's reason so the
91
+ // retry re-enters remediate/decompose instead of close/build.
81
92
  case "RUN_REOPEN":
82
- s.terminal = false; s.terminalReason = null; s.v = e.seq;
93
+ s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
94
+ break;
95
+ // forge#3562: an explicit `run-issue --retry` on a `phase-complete` run
96
+ // (multi-phase issue: this phase merged, more remain) starts a NEW cycle.
97
+ // RUN_REOPEN keeps `committed`, so pickPhase would find nothing to run and
98
+ // re-terminate; a new cycle clears committed/branch/pr so it restarts at
99
+ // investigate with a fresh branch. `v` is bumped like RUN_REOPEN, and
100
+ // lastCommitReason is reset so a stale handoff reason cannot leak into
101
+ // the new cycle through a later RUN_REOPEN.
102
+ case "RUN_NEXT_CYCLE":
103
+ s.terminal = false; s.terminalReason = null; s.committed = [];
104
+ s.branch = null; s.pr = null; s.remediationKind = null;
105
+ lastCommitReason = null; s.v = e.seq;
83
106
  break;
84
107
  // forge#2524: a session-limit pause is purely informational — it does
85
108
  // NOT touch committed/terminal/terminalReason. The phase that hit the
@@ -26,6 +26,7 @@ export const ACTIVE_WORKFLOW_LABELS = [
26
26
  "workflow:ready-to-build",
27
27
  "workflow:building",
28
28
  "workflow:in-review",
29
+ "workflow:remediating",
29
30
  ];
30
31
 
31
32
  const pexec = promisify(execFile);
@@ -339,7 +340,9 @@ export function lastLocalRun(dir) {
339
340
  * or its built-in default).
340
341
  * --retry Optional (forge#3511). Reopen a run that ended `engine-error`
341
342
  * (e.g. a session-limit hit) and resume from its last committed
342
- * phase. Other terminal reasons are not reopened.
343
+ * phase. A run that ended `phase-complete` (multi-phase issue, phase merged,
344
+ * more remain) starts its next cycle from investigate. Other
345
+ * terminal reasons are not reopened.
343
346
  */
344
347
  export async function runFromCli(argv, deps = {}) {
345
348
  const issue = parseInt(argv[0], 10);
package/bin/engine.mjs CHANGED
@@ -225,8 +225,11 @@ function makeProgressEmitter(onProgress) {
225
225
  * `maxSessionLimitPauses`.
226
226
  * @param {boolean} [opts.retry] - forge#3511: explicit opt-in. When the run's
227
227
  * terminal reason is `engine-error`, reopen it (RUN_REOPEN) and resume from
228
- * the last committed phase. Any other terminal reason is left untouched and
229
- * returns `not-retryable`.
228
+ * the last committed phase. forge#3562: when it is `phase-complete` (a
229
+ * multi-phase issue whose phase merged with more remaining), start a new
230
+ * cycle (RUN_NEXT_CYCLE) that resets committed/branch/pr and restarts at
231
+ * investigate. Any other terminal reason is left untouched and returns
232
+ * `not-retryable`.
230
233
  */
231
234
  export async function runIssue(opts) {
232
235
  const { issue, dir, agentId, lane = "staging", io, runner,
@@ -297,15 +300,35 @@ export async function runIssue(opts) {
297
300
  // lease write below, which then publishes `terminal:false` at the reopen's
298
301
  // higher `v`. Only `engine-error` is ever reopened; needs-human / merged /
299
302
  // decomposed / etc. terminals are left untouched.
303
+ // forge#3562: `phase-complete` is also retryable, but as a NEW cycle
304
+ // (RUN_NEXT_CYCLE clears committed) rather than a reopen of the same one.
300
305
  let reopened = false;
301
306
  if (retry && state?.terminal) {
302
- if (state.terminalReason !== "engine-error") {
307
+ if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete") {
303
308
  return {
304
309
  terminalReason: "not-retryable",
305
- detail: `issue ${issue} ended ${state.terminalReason}; --retry only reopens an engine-error run`,
310
+ detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error or phase-complete run`,
306
311
  };
307
312
  }
308
- appendEvent(dir, issue, { event: "RUN_REOPEN", issue });
313
+ // forge#3562 (SEC-3): a new cycle discards `committed`, so check the live
314
+ // issue first. A CLOSED issue has nothing left to run; an unreadable
315
+ // snapshot fails closed rather than resetting a possibly-finished run.
316
+ if (state.terminalReason === "phase-complete") {
317
+ let snap;
318
+ try { snap = await issueSnapshot(issue, io); } catch { snap = { ok: false, state: null }; }
319
+ if (!snap.ok || snap.state === "CLOSED") {
320
+ return {
321
+ terminalReason: "not-retryable",
322
+ detail: snap.ok
323
+ ? `issue ${issue} is CLOSED; --retry does not start a new cycle on a closed issue`
324
+ : `issue ${issue} state could not be read; --retry did not start a new cycle`,
325
+ };
326
+ }
327
+ }
328
+ appendEvent(dir, issue, {
329
+ event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
330
+ issue,
331
+ });
309
332
  state = deriveState(readLog(dir, issue));
310
333
  reopened = true;
311
334
  }
@@ -359,10 +382,13 @@ export async function runIssue(opts) {
359
382
  // decompose) and before RUN_TERMINAL leaves its persisted reason on the commit event. Resume
360
383
  // must honour that reason instead of picking the next phase (close would throw in buildArgs
361
384
  // and end as engine-error; build would run on a decomposed parent). Review's `needs-human`
362
- // is a handoff to remediate, so only these two committed phases trigger the guard.
385
+ // is a handoff to remediate, so only these committed phases trigger the guard.
363
386
  if (state.terminalReason && TERMINAL_REASONS.includes(state.terminalReason) &&
364
387
  ((state.committed.includes("remediate") && !["merged", "decomposed", "invalid"].includes(state.terminalReason)) ||
365
- (state.committed.includes("decompose") && state.terminalReason === "decomposed")))
388
+ (state.committed.includes("decompose") && state.terminalReason === "decomposed") ||
389
+ // forge#3512: investigate persists `invalid` on its own commit. Only when investigate is
390
+ // the LAST committed phase: a later remediate/close commit carrying `invalid` is a handoff.
391
+ (state.committed[state.committed.length - 1] === "investigate" && state.terminalReason === "invalid")))
366
392
  return await terminate(state, state.terminalReason);
367
393
  // forge#2352: state-vs-GitHub divergence guard. Every phase's own
368
394
  // `entryCondition` only ever checked `state.committed` (local run-log
@@ -562,7 +588,7 @@ export async function runIssue(opts) {
562
588
  // "needs-human" — this is the engine/tool breaking, not a genuine
563
589
  // human-judgment block (see #2244/#2261). Any other thrown error is
564
590
  // a true unexpected crash and keeps propagating unchanged.
565
- if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") {
591
+ if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS") {
566
592
  // forge#2241: when the runner attached a session-limit reset time
567
593
  // (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
568
594
  // for a genuine session-limit CLI_BACKEND_FAILED — never
@@ -570,7 +596,9 @@ export async function runIssue(opts) {
570
596
  // reading raw logs. Purely additive: the base detail string is
571
597
  // unchanged, and this appends nothing when e.resetAt is absent.
572
598
  const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
573
- const detail = `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
599
+ const detail = e.code === "CLI_MAX_TURNS"
600
+ ? `phase ${phase.id}: CLI_MAX_TURNS - max-turns exhausted${e.numTurns != null ? ` after ${e.numTurns} turns` : ""}: ${e.message}`
601
+ : `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
574
602
  // forge#2240 (review finding): this fail-fast path previously left
575
603
  // phase_exit unreported — a caller tailing progress output would see
576
604
  // "→ phase X started" and then nothing, dangling exactly on the
@@ -610,8 +638,11 @@ export async function runIssue(opts) {
610
638
  // without ever resolving a PR (e.g. retries exhausted before one existed)
611
639
  // has nothing to remediate and stays a plain needs-human stop — it must not
612
640
  // fall through to `close`, which would otherwise be the next eligible phase.
641
+ // The handoff is opt-in: only an explicit escalation signal (outcome.handoff
642
+ // === true, set by review detectOutcome) permits it. The PR number merely
643
+ // supplies the remediate argument and is never itself a permission.
613
644
  const isRemediationHandoff = outcome.status === "blocked" &&
614
- phase.id === "review" && (outcome.reason || "needs-human") === "needs-human" && outcome.handoff !== false &&
645
+ phase.id === "review" && (outcome.reason || "needs-human") === "needs-human" && outcome.handoff === true &&
615
646
  (outcome.outputs?.pr ?? state.pr) != null;
616
647
  if (outcome.status === "blocked") {
617
648
  emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
@@ -631,7 +662,7 @@ export async function runIssue(opts) {
631
662
  }
632
663
  // forge#2377: `outcome.usage` is populated by runPhaseWithRetry() below
633
664
  // from the injected runner()'s (== bin/runner.mjs's runCommand()) return
634
- // value — null when the backend doesn't report usage (CLI backend today)
665
+ // value — null when the backend doesn't report usage (the CLI backend parses it from its JSON envelope)
635
666
  // or when detectOutcome() never ran (this call site is only reached on
636
667
  // the "committed" path, so that case doesn't apply here). Kept as a
637
668
  // sibling of `outputs` rather than nested inside it, since `outputs` is
@@ -650,6 +681,8 @@ export async function runIssue(opts) {
650
681
  const commitReason = blockedReason ?? outcome.terminalReason ?? null;
651
682
  appendEvent(dir, issue, {
652
683
  event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs, usage: outcome.usage ?? null,
684
+ // forge#3589: model + turn count per phase (null when the backend does not report them).
685
+ model: outcome.model ?? null, turns: outcome.turns ?? null,
653
686
  ...(commitReason ? { terminalReason: commitReason } : {}),
654
687
  });
655
688
  state = deriveState(readLog(dir, issue));
@@ -674,11 +707,16 @@ export async function runIssue(opts) {
674
707
  // is seen), phase.id is "decompose" — not "investigate" — so this
675
708
  // exemption does not apply and the normal terminate() path below fires,
676
709
  // ending the run for real.
677
- const isDecomposeHandoff = phase.id === "investigate" && terminalReason === "decomposed";
710
+ // build also hands off: the B5.5 size gate (NEEDS_DECOMPOSE) reports "decomposed".
711
+ const isDecomposeHandoff = (phase.id === "investigate" || phase.id === "build") && terminalReason === "decomposed";
712
+ // forge#3624: remediate AUTO-LANDED whose issue is not yet CLOSED+workflow:merged hands off
713
+ // to `close` instead of ending the run (close is idempotent). Exempts BOTH terminate paths.
714
+ const isCloseHandoff = phase.id === "remediate" && outcome.status === "committed" &&
715
+ outcome.outputs?.closeHandoff === true && terminalReason === "merged";
678
716
  if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
679
- !isDecomposeHandoff && !isRemediationHandoff)
717
+ !isDecomposeHandoff && !isRemediationHandoff && !isCloseHandoff)
680
718
  return await terminate(state, terminalReason, outcome.detail);
681
- if (phase.isTerminalAfter && phase.isTerminalAfter(state))
719
+ if (phase.isTerminalAfter && !isCloseHandoff && phase.isTerminalAfter(state))
682
720
  return await terminate(state, state.terminalReason || "merged");
683
721
  }
684
722
  return await terminate(state, state.terminalReason || "merged");
@@ -726,7 +764,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
726
764
  // forge#2377: the last successful attempt's `usage` (from runner()'s ==
727
765
  // runCommand()'s resolved value — {input_tokens, output_tokens,
728
766
  // cache_creation_input_tokens, cache_read_input_tokens} on the API
729
- // backend, or null on the CLI backend / when the field is absent). Reset
767
+ // backend or the CLI JSON envelope, null when the field is absent). Reset
730
768
  // is unnecessary since a thrown attempt never reaches the assignment
731
769
  // below — `lastUsage` simply stays whatever the previous successful
732
770
  // attempt (if any) set it to, which is correct: it always reflects the
@@ -761,6 +799,16 @@ async function runPhaseWithRetry(phase, state, ctx) {
761
799
  // *why* the CLI exited 1 (the quota/session-limit theory in #2244 is
762
800
  // unproven) — a deterministic tool crash should not be retried as if
763
801
  // transient regardless of its root cause.
802
+ // forge#3589: CLI_MAX_TURNS (turn bound exhausted) is likewise deterministic and is
803
+ // NOT a commit: record the exhausted attempt, then rethrow so runIssue terminates
804
+ // `engine-error` with no PHASE_COMMIT — `--retry` then re-enters this same phase.
805
+ if (e.code === "CLI_MAX_TURNS") {
806
+ appendEvent(dir, issue, {
807
+ event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts,
808
+ turns: e.numTurns ?? null, usage: e.usage ?? null,
809
+ });
810
+ throw e;
811
+ }
764
812
  if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
765
813
  // forge#2377: no `usage` field here — the runner threw, so no result
766
814
  // (and therefore no usage data) was ever produced for this attempt.
@@ -772,9 +820,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
772
820
  }
773
821
  allAttemptsThrew = false;
774
822
  lastUsage = result?.usage ?? null;
823
+ // forge#3589: model/turns recorded alongside usage (additive; null when absent).
824
+ const lastModel = result?.cliModel ?? result?.model ?? null;
825
+ const lastTurns = result?.turns ?? null;
775
826
  const outcome = await phase.detectOutcome(state, io, result);
776
- if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage };
777
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage });
827
+ if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage, model: lastModel, turns: lastTurns };
828
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
778
829
  // forge#2176: a phase's detectOutcome can mark a failure as a known,
779
830
  // state-derived fixed point — re-running the phase's runner is
780
831
  // guaranteed to reproduce the identical failure (e.g. the build phase's
@@ -786,8 +837,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
786
837
  // for every phase that doesn't opt in — investigate/context/architect/
787
838
  // review/close — is unchanged, preserving transient-failure retries).
788
839
  if (outcome.retryable === false) {
789
- // forge#3521: keep outcome.outputs (e.g. the PR number) so the blocked
790
- // commit persists it and isRemediationHandoff can see it.
840
+ // forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
841
+ // forward `handoff` so only an explicit opt-in escalation is honoured; a
842
+ // no-signal failure carries no handoff and stops at needs-human.
791
843
  return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, usage: lastUsage };
792
844
  }
793
845
  }
@@ -822,6 +874,7 @@ function eventsFromIndex(idx) {
822
874
  const outputs = {};
823
875
  if (phase === "build" && idx.branch) outputs.branch = idx.branch;
824
876
  if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
877
+ if (phase === "review" && idx.remediationKind) outputs.remediation = idx.remediationKind;
825
878
  events.push({ event: "PHASE_COMMIT", phase, outputs });
826
879
  }
827
880
  // forge#3506: a non-terminal reason is a handoff (e.g. review -> remediate). Replay it on