forgedock 1.11.0 → 1.12.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 (39) hide show
  1. package/bin/engine/phases-args.test.mjs +113 -0
  2. package/bin/engine/phases.mjs +433 -109
  3. package/bin/engine/runlog.mjs +21 -1
  4. package/bin/engine-cli.mjs +14 -2
  5. package/bin/engine.mjs +150 -19
  6. package/bin/forgedock.mjs +2 -2
  7. package/bin/hooks/interactive-engine.mjs +3 -0
  8. package/bin/labels.json +5 -0
  9. package/bin/orchestrate-preflight.mjs +1 -1
  10. package/bin/runner.mjs +111 -12
  11. package/bin/watch-utils.mjs +1 -0
  12. package/commands/cleanup.md +1 -1
  13. package/commands/orchestrate/phase-4-execution.md +58 -24
  14. package/commands/orchestrate/phase-6-report.md +29 -0
  15. package/commands/quality-gate.md +96 -12
  16. package/commands/recover-orphans.md +5 -5
  17. package/commands/review-pr-agents/protocols.md +1 -1
  18. package/commands/review-pr-agents.md +11 -11
  19. package/commands/review-pr.md +227 -33
  20. package/commands/work-on/build/implement.md +1 -1
  21. package/commands/work-on/build/validate.md +58 -7
  22. package/commands/work-on/build.md +11 -4
  23. package/commands/work-on/decompose.md +2 -2
  24. package/commands/work-on/investigate.md +14 -4
  25. package/commands/work-on/remediate.md +109 -19
  26. package/commands/work-on/review.md +79 -25
  27. package/commands/work-on.md +23 -15
  28. package/docs/WORK-ON-RUNTIME.md +2 -2
  29. package/docs/spec/label-state-machine.md +6 -1
  30. package/docs/spec/review-protocol.md +1 -1
  31. package/package.json +1 -1
  32. package/packages/protocol/src/phases.js +27 -1
  33. package/scripts/check-branch-ancestry.sh +82 -0
  34. package/scripts/check-branch-ancestry.test.sh +78 -0
  35. package/scripts/check-spec-markers.sh +2 -0
  36. package/scripts/forge-root.test.sh +36 -15
  37. package/scripts/review-pr-domain-map.test.sh +87 -0
  38. package/scripts/spec-no-worktree-script.test.sh +46 -0
  39. 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":
@@ -68,10 +71,27 @@ export function deriveState(events) {
68
71
  s.v = e.seq;
69
72
  if (e.outputs?.branch) s.branch = e.outputs.branch;
70
73
  if (e.outputs?.pr != null) s.pr = e.outputs.pr;
74
+ // forge#3504: a blocked phase persists its reason so a resumed run still routes to
75
+ // remediate instead of defaulting to a "merged" close.
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;
71
82
  break;
72
83
  case "RUN_TERMINAL":
73
84
  s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
74
85
  break;
86
+ // forge#3511: an explicit `run-issue --retry` reopens an engine-error
87
+ // terminal. Bumping `v` makes the local state newer than the stale
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.
92
+ case "RUN_REOPEN":
93
+ s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
94
+ break;
75
95
  // forge#2524: a session-limit pause is purely informational — it does
76
96
  // NOT touch committed/terminal/terminalReason. The phase that hit the
77
97
  // pause is, by construction, not yet committed (the engine is about to
@@ -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);
@@ -337,16 +338,21 @@ export function lastLocalRun(dir) {
337
338
  * `runCommand()` call; only applies on the "api" backend.
338
339
  * Omit to keep runner.mjs's default (`FORGEDOCK_MODEL` env
339
340
  * or its built-in default).
341
+ * --retry Optional (forge#3511). Reopen a run that ended `engine-error`
342
+ * (e.g. a session-limit hit) and resume from its last committed
343
+ * phase. Other terminal reasons are not reopened.
340
344
  */
341
345
  export async function runFromCli(argv, deps = {}) {
342
346
  const issue = parseInt(argv[0], 10);
343
- if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane>");
347
+ if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane> [--retry]");
344
348
  assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
345
349
  const lane = flag(argv, "--lane");
346
350
  if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
347
351
  const repo = flag(argv, "--repo");
348
352
  const backend = flag(argv, "--backend");
349
353
  const model = flag(argv, "--model");
354
+ // Boolean flag: only counts at a flag position, never as another flag's value.
355
+ const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
350
356
  const io = deps.io ?? makeIo();
351
357
  await assertRepoMatchesCwd(io, repo);
352
358
  const runIssueFn = deps.runIssue ?? runIssue;
@@ -375,13 +381,19 @@ export async function runFromCli(argv, deps = {}) {
375
381
  else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
376
382
  };
377
383
  const res = await runIssueFn({ issue, dir, agentId, lane, io,
384
+ ...(repo ? { repo } : {}),
378
385
  runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
379
386
  onProgress,
380
387
  // Only forwarded when explicitly provided — omitting them preserves
381
388
  // runIssue's/runner.mjs's existing defaults (forge#2028).
382
389
  ...(backend ? { backend } : {}),
383
- ...(model ? { model } : {}) });
390
+ ...(model ? { model } : {}),
391
+ ...(retry ? { retry: true } : {}) });
384
392
  console.log(`issue #${issue} → ${res.terminalReason}`);
393
+ if (res.terminalReason === "not-retryable") {
394
+ console.log(res.detail ?? "run is not retryable");
395
+ return res;
396
+ }
385
397
  // forge#2175: a non-success termination previously printed nothing beyond
386
398
  // the bare reason above — the actual failing phase/attempt/reason was only
387
399
  // recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the
package/bin/engine.mjs CHANGED
@@ -29,6 +29,12 @@ export const DEFAULT_LEASE_RENEW_INTERVAL_MS = 240000;
29
29
  // need at most one or two pauses in practice.
30
30
  export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
31
31
 
32
+ // forge#3511: bounded wait applied ONLY when the runner flagged a confirmed
33
+ // usage-limit failure (`err.usageLimit`) but could not produce a parseable
34
+ // reset epoch. The runner never fabricates a reset time; this default lives in
35
+ // the engine and is still capped by `maxSessionLimitPauses`.
36
+ export const DEFAULT_SESSION_LIMIT_WAIT_MS = 30 * 60 * 1000;
37
+
32
38
  /**
33
39
  * Validates the subset of `runIssue()`'s options that must fail fast,
34
40
  * synchronously, before any state I/O or timer creation — `backend` and the
@@ -44,7 +50,7 @@ export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
44
50
  * @param {number} params.maxSessionLimitPauses - forge#2524: bound on consecutive session-limit pauses.
45
51
  * @throws {Error & {code: "INVALID_BACKEND"|"INVALID_LEASE_CONFIG"|"INVALID_SESSION_LIMIT_CONFIG"}}
46
52
  */
47
- function validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses }) {
53
+ function validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses, sessionLimitDefaultWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS }) {
48
54
  // forge#2054: validate `backend` before anything else — before state is
49
55
  // read/written and before the phase/retry loop begins below. An invalid
50
56
  // value must fail fast and non-retryably. Without this check, an invalid
@@ -128,6 +134,15 @@ function validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, ma
128
134
  { code: "INVALID_SESSION_LIMIT_CONFIG" },
129
135
  );
130
136
  }
137
+ // forge#3511: same guard for the unknown-reset default wait.
138
+ if (typeof sessionLimitDefaultWaitMs !== "number" || !Number.isFinite(sessionLimitDefaultWaitMs) || sessionLimitDefaultWaitMs < 0) {
139
+ throw Object.assign(
140
+ new Error(
141
+ `Invalid session-limit config: sessionLimitDefaultWaitMs must be a finite number >= 0, got ${typeof sessionLimitDefaultWaitMs === "number" ? sessionLimitDefaultWaitMs : `${typeof sessionLimitDefaultWaitMs} (${JSON.stringify(sessionLimitDefaultWaitMs)})`}.`,
142
+ ),
143
+ { code: "INVALID_SESSION_LIMIT_CONFIG" },
144
+ );
145
+ }
131
146
  }
132
147
 
133
148
  /**
@@ -204,6 +219,14 @@ function makeProgressEmitter(onProgress) {
204
219
  * Defaults to DEFAULT_MAX_SESSION_LIMIT_PAUSES. Purely a defensive backstop
205
220
  * against a persistently-misreported reset time; a genuine reset is
206
221
  * expected to need at most one or two pauses in practice.
222
+ * @param {number} [opts.sessionLimitDefaultWaitMs] - forge#3511: how long to
223
+ * pause when the failure is a confirmed usage limit but no reset time could
224
+ * be parsed. Defaults to DEFAULT_SESSION_LIMIT_WAIT_MS; counts against
225
+ * `maxSessionLimitPauses`.
226
+ * @param {boolean} [opts.retry] - forge#3511: explicit opt-in. When the run's
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`.
207
230
  */
208
231
  export async function runIssue(opts) {
209
232
  const { issue, dir, agentId, lane = "staging", io, runner,
@@ -214,12 +237,19 @@ export async function runIssue(opts) {
214
237
  // callers keep runner.mjs's own "auto" ladder default unchanged —
215
238
  // this is purely additive pass-through, not a new default.
216
239
  backend, model,
240
+ // forge#3499: "owner/repo" the phase sub-skills must target
241
+ // (`--repo` / `--gh-flag`). Optional override; otherwise resolved
242
+ // lazily — once, only if a phase's runner is actually invoked —
243
+ // from the cwd-resolved repo (`gh repo view`).
244
+ repo,
217
245
  leaseTtlMs = DEFAULT_LEASE_TTL_MS,
218
246
  leaseRenewIntervalMs = DEFAULT_LEASE_RENEW_INTERVAL_MS,
219
247
  // forge#2524: real default is a genuine setTimeout-based wait —
220
248
  // tests inject a fast/no-op replacement (see opts.sleep doc above).
221
249
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
222
250
  maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES,
251
+ sessionLimitDefaultWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS,
252
+ retry = false,
223
253
  onProgress = () => {} } = opts;
224
254
 
225
255
  // forge#2452: extracted into makeProgressEmitter() — see its docstring for
@@ -230,7 +260,7 @@ export async function runIssue(opts) {
230
260
  // for the forge#2054/#2313/#2329 rationale and required call-order (before
231
261
  // any state I/O or timer creation, unchanged from the inline block this
232
262
  // replaces).
233
- validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses });
263
+ validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses, sessionLimitDefaultWaitMs });
234
264
 
235
265
  const projector = makeProjector(io);
236
266
 
@@ -260,6 +290,25 @@ export async function runIssue(opts) {
260
290
  rewriteLog(dir, issue, eventsFromIndex(state));
261
291
  state = deriveState(readLog(dir, issue));
262
292
  }
293
+
294
+ // forge#3511: explicit `--retry` reopens a transient `engine-error` terminal
295
+ // so the run resumes from its last committed phase. Placed AFTER the I3
296
+ // lease guard (never weaken that ordering) and BEFORE the unconditional
297
+ // lease write below, which then publishes `terminal:false` at the reopen's
298
+ // higher `v`. Only `engine-error` is ever reopened; needs-human / merged /
299
+ // decomposed / etc. terminals are left untouched.
300
+ let reopened = false;
301
+ if (retry && state?.terminal) {
302
+ if (state.terminalReason !== "engine-error") {
303
+ return {
304
+ terminalReason: "not-retryable",
305
+ detail: `issue ${issue} ended ${state.terminalReason}; --retry only reopens an engine-error run`,
306
+ };
307
+ }
308
+ appendEvent(dir, issue, { event: "RUN_REOPEN", issue });
309
+ state = deriveState(readLog(dir, issue));
310
+ reopened = true;
311
+ }
263
312
  // forge#2239: claim the lease unconditionally here — before the phase loop
264
313
  // starts, for EVERY reconcile action ("fresh", "remirror", "hydrate", and
265
314
  // "local"). Previously only "fresh"/"remirror"/"hydrate" wrote any state at
@@ -273,6 +322,17 @@ export async function runIssue(opts) {
273
322
  // check above (commit 541a3e5 fixed the reverse ordering as a real bug —
274
323
  // do not reintroduce it).
275
324
  await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
325
+ // forge#3511: clear the stale label only after the reopened state is
326
+ // published, so a crash in between leaves the run still marked engine-error.
327
+ // On `--retry` this runs whether or not THIS invocation did the reopen: a prior
328
+ // retry may have published the reopen and died before removing the label, so the
329
+ // next one sees a non-terminal state (reopened=false) with the label still set.
330
+ // Best-effort — a transient `gh` error must not abort a reopen already published.
331
+ // Removing an absent label is a harmless no-op.
332
+ if (retry) {
333
+ try { await projector.removeLabel(issue, "workflow:engine-error"); }
334
+ catch (err) { console.warn(`forge#3511: could not clear workflow:engine-error on #${issue}: ${err?.message || err}`); }
335
+ }
276
336
 
277
337
  // 2. Drive phases until terminal.
278
338
  //
@@ -284,8 +344,29 @@ export async function runIssue(opts) {
284
344
  // against a nonexistent ref and silently evaluated to 0 (forge#2174). The
285
345
  // build phase's `reconcile`/`detectOutcome` (bin/engine/phases.mjs) resolve
286
346
  // the real branch from the `FORGE:BUILDER` comment instead.
347
+ let repoPromise = null;
348
+ const resolveRepo = () => (repoPromise ??= (async () => {
349
+ if (repo) return String(repo).trim();
350
+ try {
351
+ const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
352
+ return String(out || "").trim() || null;
353
+ } catch { return null; }
354
+ })());
355
+
287
356
  let phase;
288
357
  while ((phase = pickPhase(state))) {
358
+ // forge#3506 (SPEC-1/SPEC-2): a crash after a terminal-after phase committed (remediate or
359
+ // decompose) and before RUN_TERMINAL leaves its persisted reason on the commit event. Resume
360
+ // must honour that reason instead of picking the next phase (close would throw in buildArgs
361
+ // 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 committed phases trigger the guard.
363
+ if (state.terminalReason && TERMINAL_REASONS.includes(state.terminalReason) &&
364
+ ((state.committed.includes("remediate") && !["merged", "decomposed", "invalid"].includes(state.terminalReason)) ||
365
+ (state.committed.includes("decompose") && state.terminalReason === "decomposed") ||
366
+ // forge#3512: investigate persists `invalid` on its own commit. Only when investigate is
367
+ // the LAST committed phase: a later remediate/close commit carrying `invalid` is a handoff.
368
+ (state.committed[state.committed.length - 1] === "investigate" && state.terminalReason === "invalid")))
369
+ return await terminate(state, state.terminalReason);
289
370
  // forge#2352: state-vs-GitHub divergence guard. Every phase's own
290
371
  // `entryCondition` only ever checked `state.committed` (local run-log
291
372
  // progress) — never the issue's live GitHub state/labels — so a phase
@@ -428,18 +509,21 @@ export async function runIssue(opts) {
428
509
  let pauseCount = 0;
429
510
  for (;;) {
430
511
  try {
431
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
512
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo });
432
513
  break;
433
514
  } catch (e) {
434
515
  const resetAtMs = e.resetAtEpochMs;
516
+ const hasResetEpoch =
517
+ typeof resetAtMs === "number" && Number.isFinite(resetAtMs) && resetAtMs > now();
518
+ // forge#3511: a confirmed usage limit whose reset time is
519
+ // missing/unparseable/already past still pauses, by a bounded default.
435
520
  const canPause =
436
521
  e.code === "CLI_BACKEND_FAILED" &&
437
- typeof resetAtMs === "number" && Number.isFinite(resetAtMs) &&
438
- resetAtMs > now() &&
522
+ (hasResetEpoch || e.usageLimit === true) &&
439
523
  pauseCount < maxSessionLimitPauses;
440
524
  if (!canPause) throw e; // falls through to the unchanged outer catch below
441
525
  pauseCount += 1;
442
- const waitMs = Math.max(0, resetAtMs - now());
526
+ const waitMs = hasResetEpoch ? Math.max(0, resetAtMs - now()) : sessionLimitDefaultWaitMs;
443
527
  // forge#2524 AC4: append a run-log event so a crash mid-wait
444
528
  // leaves a durable record of the pause (deriveState() folds this
445
529
  // into RunState.lastRateLimit — see bin/engine/runlog.mjs). This
@@ -449,15 +533,17 @@ export async function runIssue(opts) {
449
533
  // was ever written.
450
534
  appendEvent(dir, issue, {
451
535
  event: "PHASE_RATE_LIMITED", phase: phase.id,
452
- resetAt: resetAtMs, resetAtDisplay: e.resetAt || null, waitMs,
536
+ resetAt: hasResetEpoch ? resetAtMs : null, resetAtDisplay: e.resetAt || null, waitMs,
453
537
  });
454
538
  // forge#2524 AC6: progress observer sees the pause so a caller
455
539
  // (bin/engine-cli.mjs) can render the wait state instead of the
456
540
  // process going silent for the duration.
457
541
  emitProgress({
458
542
  event: "phase_paused", phase: phase.id, reason: "session-limit",
459
- resetAt: resetAtMs,
460
- detail: `session limit hit — pausing until ${e.resetAt || new Date(resetAtMs).toISOString()}`,
543
+ resetAt: hasResetEpoch ? resetAtMs : null,
544
+ detail: hasResetEpoch
545
+ ? `session limit hit — pausing until ${e.resetAt || new Date(resetAtMs).toISOString()}`
546
+ : `session limit hit — reset time unknown, waiting default ${Math.round(waitMs / 60000)} min`,
461
547
  });
462
548
  await sleep(waitMs);
463
549
  // Loop back and retry the same phase with a fresh attempt budget
@@ -522,8 +608,14 @@ export async function runIssue(opts) {
522
608
  }
523
609
  }
524
610
 
611
+ // forge#3499: remediate takes the PR number as its positional argument, so
612
+ // the handoff only applies when review resolved one. A review that blocked
613
+ // without ever resolving a PR (e.g. retries exhausted before one existed)
614
+ // has nothing to remediate and stays a plain needs-human stop — it must not
615
+ // fall through to `close`, which would otherwise be the next eligible phase.
525
616
  const isRemediationHandoff = outcome.status === "blocked" &&
526
- phase.id === "review" && (outcome.reason || "needs-human") === "needs-human";
617
+ phase.id === "review" && (outcome.reason || "needs-human") === "needs-human" && outcome.handoff !== false &&
618
+ (outcome.outputs?.pr ?? state.pr) != null;
527
619
  if (outcome.status === "blocked") {
528
620
  emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
529
621
  if (!isRemediationHandoff)
@@ -547,11 +639,24 @@ export async function runIssue(opts) {
547
639
  // the "committed" path, so that case doesn't apply here). Kept as a
548
640
  // sibling of `outputs` rather than nested inside it, since `outputs` is
549
641
  // owned by phase.detectOutcome() (bin/engine/phases.mjs).
550
- appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {}, usage: outcome.usage ?? null });
642
+ // forge#3503/#3504: a blocked review hands off to remediate, which reads the PR
643
+ // number and the needs-human reason from state re-derived from the run log. Persist
644
+ // both in the PHASE_COMMIT event itself so a rebuild (the next line, or a resume)
645
+ // sees them; in-memory-only values are discarded by deriveState.
646
+ const commitOutputs = { ...(outcome.outputs || {}) };
647
+ if (outcome.status === "blocked" && commitOutputs.pr == null && state.pr != null)
648
+ commitOutputs.pr = state.pr;
649
+ const blockedReason = outcome.status === "blocked" ? (outcome.reason || "needs-human") : null;
650
+ // forge#3506: committed outcomes also carry handoff/terminal reasons (investigate
651
+ // `decomposed`, remediate `merged`/`awaiting-merge`/`needs-human`). Persist them on the
652
+ // commit event too, so a crash before RUN_TERMINAL or a rebuild cannot drop them.
653
+ const commitReason = blockedReason ?? outcome.terminalReason ?? null;
654
+ appendEvent(dir, issue, {
655
+ event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs, usage: outcome.usage ?? null,
656
+ ...(commitReason ? { terminalReason: commitReason } : {}),
657
+ });
551
658
  state = deriveState(readLog(dir, issue));
552
- const terminalReason = outcome.status === "blocked"
553
- ? (outcome.reason || "needs-human")
554
- : outcome.terminalReason;
659
+ const terminalReason = blockedReason ?? outcome.terminalReason;
555
660
  if (terminalReason) state.terminalReason = terminalReason;
556
661
  await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
557
662
 
@@ -572,7 +677,8 @@ export async function runIssue(opts) {
572
677
  // is seen), phase.id is "decompose" — not "investigate" — so this
573
678
  // exemption does not apply and the normal terminate() path below fires,
574
679
  // ending the run for real.
575
- const isDecomposeHandoff = phase.id === "investigate" && terminalReason === "decomposed";
680
+ // build also hands off: the B5.5 size gate (NEEDS_DECOMPOSE) reports "decomposed".
681
+ const isDecomposeHandoff = (phase.id === "investigate" || phase.id === "build") && terminalReason === "decomposed";
576
682
  if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
577
683
  !isDecomposeHandoff && !isRemediationHandoff)
578
684
  return await terminate(state, terminalReason, outcome.detail);
@@ -597,7 +703,21 @@ export async function runIssue(opts) {
597
703
  }
598
704
 
599
705
  async function runPhaseWithRetry(phase, state, ctx) {
600
- const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model } = ctx;
706
+ const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo } = ctx;
707
+ // forge#3499: build the phase's full argument set ONCE, up front. Every
708
+ // work-on phase sub-skill stops with BLOCKED when a required flag is absent,
709
+ // so an incomplete/invalid set is a deterministic engine failure — fail
710
+ // closed here (no runner call, no retries) instead of sending a partial
711
+ // `[issue]`-only invocation that burns the attempt budget.
712
+ let phaseArgs;
713
+ try {
714
+ phaseArgs = await phase.buildArgs(state, { repo: await resolveRepo() }, io);
715
+ } catch (e) {
716
+ if (e?.code !== "PHASE_ARGS_INVALID") throw e;
717
+ const detail = `phase ${phase.id} args unresolved: ${e.message}`;
718
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, maxAttempts });
719
+ return { status: "blocked", detail, reason: "engine-error", usage: null };
720
+ }
601
721
  // forge#2261: true only if EVERY attempt failed by the runner itself
602
722
  // throwing (never once reached phase.detectOutcome()). This is the signal
603
723
  // that distinguishes an engine/tool crash (the tool never even produced a
@@ -621,7 +741,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
621
741
  let result;
622
742
  try {
623
743
  result = await runner({
624
- commandsDir, commandName: phase.command, args: [String(issue)],
744
+ commandsDir, commandName: phase.command, args: phaseArgs,
625
745
  // Only forwarded when explicitly provided — omitting them preserves
626
746
  // runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
627
747
  ...(backend ? { backend } : {}),
@@ -656,7 +776,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
656
776
  }
657
777
  allAttemptsThrew = false;
658
778
  lastUsage = result?.usage ?? null;
659
- const outcome = await phase.detectOutcome(state, io);
779
+ const outcome = await phase.detectOutcome(state, io, result);
660
780
  if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage };
661
781
  appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage });
662
782
  // forge#2176: a phase's detectOutcome can mark a failure as a known,
@@ -670,7 +790,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
670
790
  // for every phase that doesn't opt in — investigate/context/architect/
671
791
  // review/close — is unchanged, preserving transient-failure retries).
672
792
  if (outcome.retryable === false) {
673
- return { status: "blocked", detail: outcome.detail, usage: lastUsage };
793
+ // forge#3521: keep outcome.outputs (e.g. the PR number) so the blocked
794
+ // commit persists it and isRemediationHandoff can see it.
795
+ return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, usage: lastUsage };
674
796
  }
675
797
  }
676
798
  // Exhausted transient retries → escalate (spec §7).
@@ -704,8 +826,17 @@ function eventsFromIndex(idx) {
704
826
  const outputs = {};
705
827
  if (phase === "build" && idx.branch) outputs.branch = idx.branch;
706
828
  if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
829
+ if (phase === "review" && idx.remediationKind) outputs.remediation = idx.remediationKind;
707
830
  events.push({ event: "PHASE_COMMIT", phase, outputs });
708
831
  }
832
+ // forge#3506: a non-terminal reason is a handoff (e.g. review -> remediate). Replay it on
833
+ // the last committed PHASE_COMMIT (deriveState is last-wins), never as RUN_TERMINAL, which
834
+ // would terminate the run. Nothing to attach it to when no phase is committed.
835
+ if (!idx.terminal && idx.terminalReason && idx.committed.length > 0) {
836
+ for (let i = events.length - 1; i >= 0; i--) {
837
+ if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; break; }
838
+ }
839
+ }
709
840
  if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
710
841
  return events;
711
842
  }
package/bin/forgedock.mjs CHANGED
@@ -3207,7 +3207,7 @@ function help() {
3207
3207
  ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
3208
3208
  ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
3209
3209
  ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
3210
- ["npx forgedock run-issue <issue>", "Drive one issue through the durable engine"],
3210
+ ["npx forgedock run-issue <issue> --lane <lane> [--retry]", "Drive one issue through the durable engine (--retry reopens an engine-error run)"],
3211
3211
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
3212
3212
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
3213
3213
  ["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
@@ -3971,7 +3971,7 @@ switch (command) {
3971
3971
  // an uncaught throw, which already set exitCode = 1 below. Now that
3972
3972
  // runIssue() resolves cleanly with a terminalReason instead of throwing,
3973
3973
  // preserve that same non-zero exit code for this reason too.
3974
- if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error")) exitCode = 1;
3974
+ if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable")) exitCode = 1;
3975
3975
  } catch (err) {
3976
3976
  process.stderr.write(`${RED}${err.message}${RESET}\n`);
3977
3977
  exitCode = 1;
@@ -116,6 +116,9 @@ const PHASE_MARKERS = [
116
116
  * @param {string} text
117
117
  * @returns {{ phaseId: string|null, terminalReason: string|null }}
118
118
  */
119
+ // forge#3542: this scans a session TRANSCRIPT (tool results / assistant text), which carries no
120
+ // comment authorship, so it is outside the headless engine's trusted + anchored marker model
121
+ // (bin/engine/phases.mjs). Advisory phase detection only.
119
122
  export function detectPhaseFromText(text) {
120
123
  const str = typeof text === "string" ? text : "";
121
124
  let phaseId = null;
package/bin/labels.json CHANGED
@@ -39,6 +39,11 @@
39
39
  "color": "5319E7",
40
40
  "description": "Pipeline: PR created, under review. Managed by ForgeDock."
41
41
  },
42
+ {
43
+ "name": "workflow:remediating",
44
+ "color": "C5DEF5",
45
+ "description": "Pipeline: autonomous remediation pending (no human action needed). Managed by ForgeDock."
46
+ },
42
47
  {
43
48
  "name": "workflow:awaiting-merge",
44
49
  "color": "FF8C00",
@@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url";
16
16
  import { classifyInputPattern } from "./engine/resolve.mjs";
17
17
 
18
18
  const VERSION = "opencode-preflight-v1";
19
- const IN_FLIGHT_LABELS = new Set(["workflow:building", "workflow:in-review"]);
19
+ const IN_FLIGHT_LABELS = new Set(["workflow:building", "workflow:in-review", "workflow:remediating"]);
20
20
  const EXCLUDED_LABELS = new Set([
21
21
  "needs-human",
22
22
  "workflow:decomposed",
package/bin/runner.mjs CHANGED
@@ -933,6 +933,71 @@ export function sanitizeOutputExcerptForLog(output) {
933
933
  return sanitizeAndCap(String(output), MAX_LOGGED_OUTPUT_EXCERPT_LEN);
934
934
  }
935
935
 
936
+ // Shared usage-limit phrasing: detection and reset extraction must agree on it.
937
+ const USAGE_LIMIT_PHRASE = "(?:session|weekly|usage) limit";
938
+ const USAGE_LIMIT_RE = new RegExp(USAGE_LIMIT_PHRASE, "i");
939
+ // `... limit · resets 3pm (Asia/Kolkata)` — capture stops at the zone's `)`.
940
+ const USAGE_LIMIT_RESET_PAREN_RE = new RegExp(
941
+ `${USAGE_LIMIT_PHRASE}[^\\n"]*?resets?\\s+([^\\n"]+?\\))`, "i");
942
+ // Zone-less fallback: capture to end of line, but never across a quote.
943
+ const USAGE_LIMIT_RESET_LINE_RE = new RegExp(
944
+ `${USAGE_LIMIT_PHRASE}[^\\n"]*?resets?\\s+([^\\n"]+?)\\s*$`, "im");
945
+ // The two regexes above are polynomial on long no-match lines, so they only
946
+ // ever run on a short window per phrase hit (untrusted input can be tens of MB).
947
+ const USAGE_LIMIT_SCAN_WINDOW_CHARS = 512;
948
+ const USAGE_LIMIT_MAX_PHRASE_HITS = 32;
949
+
950
+ /** Linear scan for phrase hits; returns at most MAX_PHRASE_HITS newline-cut windows. */
951
+ function collectUsageLimitWindows(source) {
952
+ const windows = [];
953
+ const phraseRe = new RegExp(USAGE_LIMIT_PHRASE, "gi");
954
+ let hit;
955
+ while (windows.length < USAGE_LIMIT_MAX_PHRASE_HITS && (hit = phraseRe.exec(source)) !== null) {
956
+ let window = source.slice(hit.index, hit.index + USAGE_LIMIT_SCAN_WINDOW_CHARS);
957
+ const nl = window.indexOf("\n");
958
+ if (nl !== -1) window = window.slice(0, nl);
959
+ windows.push(window);
960
+ }
961
+ return windows;
962
+ }
963
+
964
+ /**
965
+ * Parses a `claude --output-format json` single-result envelope from `text`.
966
+ * Returns the parsed object, or `undefined` when `text` is not exactly one
967
+ * JSON object. Never throws.
968
+ */
969
+ function parseCliEnvelope(text) {
970
+ if (typeof text !== "string") return undefined;
971
+ const trimmed = text.trim();
972
+ if (!trimmed.startsWith("{")) return undefined;
973
+ try {
974
+ const parsed = JSON.parse(trimmed);
975
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
976
+ } catch {
977
+ return undefined;
978
+ }
979
+ }
980
+
981
+ /**
982
+ * 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.
986
+ *
987
+ * @param {string} stdout - raw stdout of the CLI run
988
+ * @param {string} [output] - combined stdout+stderr, for the plain-text fallback
989
+ * @returns {boolean}
990
+ */
991
+ export function detectUsageLimit(stdout, output = "") {
992
+ const env = parseCliEnvelope(stdout);
993
+ 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;
997
+ }
998
+ return USAGE_LIMIT_RE.test(output || "") && /resets?\s/i.test(output || "");
999
+ }
1000
+
936
1001
  /**
937
1002
  * Best-effort extraction of a Claude CLI session-limit reset time from
938
1003
  * captured stdout/stderr (forge#2241). The CLI's own session-limit message
@@ -952,7 +1017,19 @@ export function sanitizeOutputExcerptForLog(output) {
952
1017
  */
953
1018
  export function extractSessionLimitResetTime(output) {
954
1019
  if (!output) return undefined;
955
- const match = /session limit[^\n]*?resets?\s+([^\n]+?)\s*$/im.exec(output);
1020
+ // The engine runs phases with `--output-format json`, so the limit message
1021
+ // usually arrives inside a one-line JSON envelope. Read the structured
1022
+ // `result` text when the whole input parses; otherwise fall back to the raw
1023
+ // text. The plain-text captures below never cross a quote or newline, so a
1024
+ // JSON-wrapped message cannot swallow the trailing `","type":...` fields.
1025
+ const text = parseCliEnvelope(output)?.result ?? output;
1026
+ const source = typeof text === "string" ? text : output;
1027
+ const windows = collectUsageLimitWindows(source);
1028
+ let match = null;
1029
+ for (const w of windows) { if ((match = USAGE_LIMIT_RESET_PAREN_RE.exec(w))) break; }
1030
+ if (!match) {
1031
+ for (const w of windows) { if ((match = USAGE_LIMIT_RESET_LINE_RE.exec(w))) break; }
1032
+ }
956
1033
  if (!match) return undefined;
957
1034
  const resetAt = match[1].trim();
958
1035
  if (resetAt.length === 0) return undefined;
@@ -1036,19 +1113,26 @@ function wallTimeInZoneToEpochMs(y, mo, d, h, mi, s, timeZone) {
1036
1113
  */
1037
1114
  export function parseSessionLimitResetEpochMs(resetAtText, nowMs = Date.now()) {
1038
1115
  if (!resetAtText) return undefined;
1039
- const match = /^(\d{1,2}):(\d{2})\s*([ap]m)\s*\(([^)]+)\)\s*$/i.exec(resetAtText.trim());
1116
+ const match = /^(\d{1,2})(?::(\d{2}))?\s*([ap]m)?\s*\(([^)]+)\)\s*$/i.exec(resetAtText.trim());
1040
1117
  if (!match) return undefined;
1041
1118
  const [, hourStr, minuteStr, ampm, timeZone] = match;
1042
1119
  const rawHour = parseInt(hourStr, 10);
1043
- // Reject out-of-range hours BEFORE the `% 12` conversion below — otherwise
1044
- // malformed input like "13:30pm" or "99:30pm" silently aliases to a
1045
- // plausible-but-wrong hour (13 % 12 = 1, 99 % 12 = 3) instead of being
1046
- // rejected, violating this function's own "never fabricate a value"
1047
- // contract. Mirrors the `minute > 59` guard further down.
1048
- if (rawHour < 1 || rawHour > 12) return undefined;
1049
- let hour = rawHour % 12;
1050
- if (/pm/i.test(ampm)) hour += 12;
1051
- const minute = parseInt(minuteStr, 10);
1120
+ let hour;
1121
+ if (ampm) {
1122
+ // Reject out-of-range hours BEFORE the `% 12` conversion below — otherwise
1123
+ // malformed input like "13:30pm" or "99:30pm" silently aliases to a
1124
+ // plausible-but-wrong hour (13 % 12 = 1, 99 % 12 = 3) instead of being
1125
+ // rejected, violating this function's own "never fabricate a value"
1126
+ // contract. Mirrors the `minute > 59` guard further down.
1127
+ if (rawHour < 1 || rawHour > 12) return undefined;
1128
+ hour = rawHour % 12;
1129
+ if (/pm/i.test(ampm)) hour += 12;
1130
+ } else {
1131
+ // 24-hour form: minutes are required (a bare "15" is too ambiguous).
1132
+ if (minuteStr === undefined || rawHour > 23) return undefined;
1133
+ hour = rawHour;
1134
+ }
1135
+ const minute = minuteStr === undefined ? 0 : parseInt(minuteStr, 10);
1052
1136
  if (!Number.isFinite(hour) || !Number.isFinite(minute) || minute > 59) return undefined;
1053
1137
 
1054
1138
  let todayParts;
@@ -1261,7 +1345,14 @@ export function runCliBackend({
1261
1345
  // present) so callers (bin/engine.mjs) can surface *when* a
1262
1346
  // quota-exhaustion failure will clear without reading raw logs. Only
1263
1347
  // ever set when the pattern actually matches — never fabricated.
1264
- const resetAt = extractSessionLimitResetTime(output);
1348
+ // 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);
1352
+ // Structured usage-limit flag: lets the engine pause by a bounded
1353
+ // default when the reset time itself is missing or unparseable. The
1354
+ // engine, not the runner, owns that default — no epoch is fabricated here.
1355
+ if (detectUsageLimit(stdout, output)) err.usageLimit = true;
1265
1356
  if (resetAt) {
1266
1357
  err.resetAt = resetAt;
1267
1358
  // forge#2524: also attach a machine-usable epoch-ms timestamp so
@@ -1392,6 +1483,9 @@ export function runCliBackend({
1392
1483
  usage,
1393
1484
  model: "cli",
1394
1485
  backend: "cli",
1486
+ // forge#3521: the phase's final reply (the CLI envelope's `.result`), so
1487
+ // callers can read its structured `*_RESULT` block. Additive; "" when absent.
1488
+ text: parsedResult ?? "",
1395
1489
  };
1396
1490
  } finally {
1397
1491
  if (tmpDir) {
@@ -2377,6 +2471,11 @@ export async function runCommand(opts = {}) {
2377
2471
  usage,
2378
2472
  model,
2379
2473
  backend: "api",
2474
+ // forge#3521: final assistant text (see the CLI backend's `text`).
2475
+ text: (response.content || [])
2476
+ .filter((blk) => blk.type === "text" && typeof blk.text === "string")
2477
+ .map((blk) => blk.text)
2478
+ .join("\n"),
2380
2479
  };
2381
2480
  }
2382
2481
 
@@ -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