forgedock 1.10.3 → 1.12.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 (58) hide show
  1. package/bin/engine/phases-args.test.mjs +112 -0
  2. package/bin/engine/phases.mjs +152 -45
  3. package/bin/engine/runlog.mjs +9 -0
  4. package/bin/engine-cli.mjs +13 -2
  5. package/bin/engine.mjs +144 -18
  6. package/bin/forgedock.mjs +2 -2
  7. package/bin/runner.mjs +89 -12
  8. package/commands/ci-audit.md +3 -3
  9. package/commands/compat-audit.md +1 -1
  10. package/commands/deploy-pr.md +6 -6
  11. package/commands/explain.md +2 -2
  12. package/commands/fix-ci.md +2 -2
  13. package/commands/forge-stats.md +1 -1
  14. package/commands/forgedock-init.md +3 -3
  15. package/commands/optimize.md +5 -5
  16. package/commands/orchestrate/phase-1-resolve.md +2 -2
  17. package/commands/orchestrate/phase-4-execution.md +208 -48
  18. package/commands/orchestrate/phase-6-report.md +29 -0
  19. package/commands/quality-gate.md +97 -13
  20. package/commands/recover-orphans.md +6 -6
  21. package/commands/review-pr-agents/protocols.md +1 -0
  22. package/commands/review-pr-staging.md +28 -6
  23. package/commands/review-pr.md +509 -132
  24. package/commands/security-audit.md +1 -1
  25. package/commands/spec-doctor.md +2 -2
  26. package/commands/test-gate.md +7 -7
  27. package/commands/work-on/build/architect.md +21 -0
  28. package/commands/work-on/build/context.md +1 -1
  29. package/commands/work-on/build/implement.md +17 -0
  30. package/commands/work-on/build/validate.md +60 -9
  31. package/commands/work-on/build.md +98 -9
  32. package/commands/work-on/close.md +33 -11
  33. package/commands/work-on/decompose.md +19 -5
  34. package/commands/work-on/investigate.md +34 -4
  35. package/commands/work-on/remediate.md +117 -13
  36. package/commands/work-on/review.md +66 -26
  37. package/commands/work-on.md +32 -15
  38. package/docs/CONFIG.md +30 -0
  39. package/docs/RELEASE-NOTES.md +9 -0
  40. package/docs/WORK-ON-RUNTIME.md +24 -4
  41. package/docs/site/architecture.md +1 -1
  42. package/docs/spec/forge-protocol-v1.md +10 -0
  43. package/docs/spec/review-protocol.md +1 -0
  44. package/package.json +1 -1
  45. package/packages/protocol/src/phases.js +6 -1
  46. package/packages/protocol/src/types.js +17 -0
  47. package/scripts/check-branch-ancestry.sh +82 -0
  48. package/scripts/check-branch-ancestry.test.sh +78 -0
  49. package/scripts/check-spec-bash.sh +45 -2
  50. package/scripts/check-spec-bash.test.sh +24 -1
  51. package/scripts/check-spec-markers.sh +55 -0
  52. package/scripts/diff-size.sh +84 -0
  53. package/scripts/diff-size.test.sh +117 -0
  54. package/scripts/forge-root.test.sh +16 -1
  55. package/scripts/remediation-marker-match.test.sh +51 -0
  56. package/scripts/review-pr-arguments.test.sh +180 -0
  57. package/scripts/spec-no-worktree-script.test.sh +46 -0
  58. package/scripts/trusted-comments.test.sh +23 -0
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,26 @@ 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 two 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
+ return await terminate(state, state.terminalReason);
289
367
  // forge#2352: state-vs-GitHub divergence guard. Every phase's own
290
368
  // `entryCondition` only ever checked `state.committed` (local run-log
291
369
  // progress) — never the issue's live GitHub state/labels — so a phase
@@ -428,18 +506,21 @@ export async function runIssue(opts) {
428
506
  let pauseCount = 0;
429
507
  for (;;) {
430
508
  try {
431
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
509
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo });
432
510
  break;
433
511
  } catch (e) {
434
512
  const resetAtMs = e.resetAtEpochMs;
513
+ const hasResetEpoch =
514
+ typeof resetAtMs === "number" && Number.isFinite(resetAtMs) && resetAtMs > now();
515
+ // forge#3511: a confirmed usage limit whose reset time is
516
+ // missing/unparseable/already past still pauses, by a bounded default.
435
517
  const canPause =
436
518
  e.code === "CLI_BACKEND_FAILED" &&
437
- typeof resetAtMs === "number" && Number.isFinite(resetAtMs) &&
438
- resetAtMs > now() &&
519
+ (hasResetEpoch || e.usageLimit === true) &&
439
520
  pauseCount < maxSessionLimitPauses;
440
521
  if (!canPause) throw e; // falls through to the unchanged outer catch below
441
522
  pauseCount += 1;
442
- const waitMs = Math.max(0, resetAtMs - now());
523
+ const waitMs = hasResetEpoch ? Math.max(0, resetAtMs - now()) : sessionLimitDefaultWaitMs;
443
524
  // forge#2524 AC4: append a run-log event so a crash mid-wait
444
525
  // leaves a durable record of the pause (deriveState() folds this
445
526
  // into RunState.lastRateLimit — see bin/engine/runlog.mjs). This
@@ -449,15 +530,17 @@ export async function runIssue(opts) {
449
530
  // was ever written.
450
531
  appendEvent(dir, issue, {
451
532
  event: "PHASE_RATE_LIMITED", phase: phase.id,
452
- resetAt: resetAtMs, resetAtDisplay: e.resetAt || null, waitMs,
533
+ resetAt: hasResetEpoch ? resetAtMs : null, resetAtDisplay: e.resetAt || null, waitMs,
453
534
  });
454
535
  // forge#2524 AC6: progress observer sees the pause so a caller
455
536
  // (bin/engine-cli.mjs) can render the wait state instead of the
456
537
  // process going silent for the duration.
457
538
  emitProgress({
458
539
  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()}`,
540
+ resetAt: hasResetEpoch ? resetAtMs : null,
541
+ detail: hasResetEpoch
542
+ ? `session limit hit — pausing until ${e.resetAt || new Date(resetAtMs).toISOString()}`
543
+ : `session limit hit — reset time unknown, waiting default ${Math.round(waitMs / 60000)} min`,
461
544
  });
462
545
  await sleep(waitMs);
463
546
  // Loop back and retry the same phase with a fresh attempt budget
@@ -522,8 +605,14 @@ export async function runIssue(opts) {
522
605
  }
523
606
  }
524
607
 
608
+ // forge#3499: remediate takes the PR number as its positional argument, so
609
+ // the handoff only applies when review resolved one. A review that blocked
610
+ // without ever resolving a PR (e.g. retries exhausted before one existed)
611
+ // has nothing to remediate and stays a plain needs-human stop — it must not
612
+ // fall through to `close`, which would otherwise be the next eligible phase.
525
613
  const isRemediationHandoff = outcome.status === "blocked" &&
526
- phase.id === "review" && (outcome.reason || "needs-human") === "needs-human";
614
+ phase.id === "review" && (outcome.reason || "needs-human") === "needs-human" && outcome.handoff !== false &&
615
+ (outcome.outputs?.pr ?? state.pr) != null;
527
616
  if (outcome.status === "blocked") {
528
617
  emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
529
618
  if (!isRemediationHandoff)
@@ -547,11 +636,24 @@ export async function runIssue(opts) {
547
636
  // the "committed" path, so that case doesn't apply here). Kept as a
548
637
  // sibling of `outputs` rather than nested inside it, since `outputs` is
549
638
  // 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 });
639
+ // forge#3503/#3504: a blocked review hands off to remediate, which reads the PR
640
+ // number and the needs-human reason from state re-derived from the run log. Persist
641
+ // both in the PHASE_COMMIT event itself so a rebuild (the next line, or a resume)
642
+ // sees them; in-memory-only values are discarded by deriveState.
643
+ const commitOutputs = { ...(outcome.outputs || {}) };
644
+ if (outcome.status === "blocked" && commitOutputs.pr == null && state.pr != null)
645
+ commitOutputs.pr = state.pr;
646
+ const blockedReason = outcome.status === "blocked" ? (outcome.reason || "needs-human") : null;
647
+ // forge#3506: committed outcomes also carry handoff/terminal reasons (investigate
648
+ // `decomposed`, remediate `merged`/`awaiting-merge`/`needs-human`). Persist them on the
649
+ // commit event too, so a crash before RUN_TERMINAL or a rebuild cannot drop them.
650
+ const commitReason = blockedReason ?? outcome.terminalReason ?? null;
651
+ appendEvent(dir, issue, {
652
+ event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs, usage: outcome.usage ?? null,
653
+ ...(commitReason ? { terminalReason: commitReason } : {}),
654
+ });
551
655
  state = deriveState(readLog(dir, issue));
552
- const terminalReason = outcome.status === "blocked"
553
- ? (outcome.reason || "needs-human")
554
- : outcome.terminalReason;
656
+ const terminalReason = blockedReason ?? outcome.terminalReason;
555
657
  if (terminalReason) state.terminalReason = terminalReason;
556
658
  await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
557
659
 
@@ -597,7 +699,21 @@ export async function runIssue(opts) {
597
699
  }
598
700
 
599
701
  async function runPhaseWithRetry(phase, state, ctx) {
600
- const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model } = ctx;
702
+ const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model, resolveRepo } = ctx;
703
+ // forge#3499: build the phase's full argument set ONCE, up front. Every
704
+ // work-on phase sub-skill stops with BLOCKED when a required flag is absent,
705
+ // so an incomplete/invalid set is a deterministic engine failure — fail
706
+ // closed here (no runner call, no retries) instead of sending a partial
707
+ // `[issue]`-only invocation that burns the attempt budget.
708
+ let phaseArgs;
709
+ try {
710
+ phaseArgs = await phase.buildArgs(state, { repo: await resolveRepo() }, io);
711
+ } catch (e) {
712
+ if (e?.code !== "PHASE_ARGS_INVALID") throw e;
713
+ const detail = `phase ${phase.id} args unresolved: ${e.message}`;
714
+ appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt: 0, reason: detail, maxAttempts });
715
+ return { status: "blocked", detail, reason: "engine-error", usage: null };
716
+ }
601
717
  // forge#2261: true only if EVERY attempt failed by the runner itself
602
718
  // throwing (never once reached phase.detectOutcome()). This is the signal
603
719
  // that distinguishes an engine/tool crash (the tool never even produced a
@@ -621,7 +737,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
621
737
  let result;
622
738
  try {
623
739
  result = await runner({
624
- commandsDir, commandName: phase.command, args: [String(issue)],
740
+ commandsDir, commandName: phase.command, args: phaseArgs,
625
741
  // Only forwarded when explicitly provided — omitting them preserves
626
742
  // runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
627
743
  ...(backend ? { backend } : {}),
@@ -656,7 +772,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
656
772
  }
657
773
  allAttemptsThrew = false;
658
774
  lastUsage = result?.usage ?? null;
659
- const outcome = await phase.detectOutcome(state, io);
775
+ const outcome = await phase.detectOutcome(state, io, result);
660
776
  if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage };
661
777
  appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage });
662
778
  // forge#2176: a phase's detectOutcome can mark a failure as a known,
@@ -670,7 +786,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
670
786
  // for every phase that doesn't opt in — investigate/context/architect/
671
787
  // review/close — is unchanged, preserving transient-failure retries).
672
788
  if (outcome.retryable === false) {
673
- return { status: "blocked", detail: outcome.detail, usage: lastUsage };
789
+ // forge#3521: keep outcome.outputs (e.g. the PR number) so the blocked
790
+ // commit persists it and isRemediationHandoff can see it.
791
+ return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, usage: lastUsage };
674
792
  }
675
793
  }
676
794
  // Exhausted transient retries → escalate (spec §7).
@@ -706,6 +824,14 @@ function eventsFromIndex(idx) {
706
824
  if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
707
825
  events.push({ event: "PHASE_COMMIT", phase, outputs });
708
826
  }
827
+ // forge#3506: a non-terminal reason is a handoff (e.g. review -> remediate). Replay it on
828
+ // the last committed PHASE_COMMIT (deriveState is last-wins), never as RUN_TERMINAL, which
829
+ // would terminate the run. Nothing to attach it to when no phase is committed.
830
+ if (!idx.terminal && idx.terminalReason && idx.committed.length > 0) {
831
+ for (let i = events.length - 1; i >= 0; i--) {
832
+ if (events[i].event === "PHASE_COMMIT") { events[i].terminalReason = idx.terminalReason; break; }
833
+ }
834
+ }
709
835
  if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
710
836
  return events;
711
837
  }
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;
package/bin/runner.mjs CHANGED
@@ -933,6 +933,53 @@ 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
+
946
+ /**
947
+ * Parses a `claude --output-format json` single-result envelope from `text`.
948
+ * Returns the parsed object, or `undefined` when `text` is not exactly one
949
+ * JSON object. Never throws.
950
+ */
951
+ function parseCliEnvelope(text) {
952
+ if (typeof text !== "string") return undefined;
953
+ const trimmed = text.trim();
954
+ if (!trimmed.startsWith("{")) return undefined;
955
+ try {
956
+ const parsed = JSON.parse(trimmed);
957
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
958
+ } catch {
959
+ return undefined;
960
+ }
961
+ }
962
+
963
+ /**
964
+ * 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.
968
+ *
969
+ * @param {string} stdout - raw stdout of the CLI run
970
+ * @param {string} [output] - combined stdout+stderr, for the plain-text fallback
971
+ * @returns {boolean}
972
+ */
973
+ export function detectUsageLimit(stdout, output = "") {
974
+ const env = parseCliEnvelope(stdout);
975
+ 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;
979
+ }
980
+ return USAGE_LIMIT_RE.test(output || "") && /resets?\s/i.test(output || "");
981
+ }
982
+
936
983
  /**
937
984
  * Best-effort extraction of a Claude CLI session-limit reset time from
938
985
  * captured stdout/stderr (forge#2241). The CLI's own session-limit message
@@ -952,7 +999,15 @@ export function sanitizeOutputExcerptForLog(output) {
952
999
  */
953
1000
  export function extractSessionLimitResetTime(output) {
954
1001
  if (!output) return undefined;
955
- const match = /session limit[^\n]*?resets?\s+([^\n]+?)\s*$/im.exec(output);
1002
+ // The engine runs phases with `--output-format json`, so the limit message
1003
+ // usually arrives inside a one-line JSON envelope. Read the structured
1004
+ // `result` text when the whole input parses; otherwise fall back to the raw
1005
+ // text. The plain-text captures below never cross a quote or newline, so a
1006
+ // JSON-wrapped message cannot swallow the trailing `","type":...` fields.
1007
+ const text = parseCliEnvelope(output)?.result ?? output;
1008
+ 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);
956
1011
  if (!match) return undefined;
957
1012
  const resetAt = match[1].trim();
958
1013
  if (resetAt.length === 0) return undefined;
@@ -1036,19 +1091,26 @@ function wallTimeInZoneToEpochMs(y, mo, d, h, mi, s, timeZone) {
1036
1091
  */
1037
1092
  export function parseSessionLimitResetEpochMs(resetAtText, nowMs = Date.now()) {
1038
1093
  if (!resetAtText) return undefined;
1039
- const match = /^(\d{1,2}):(\d{2})\s*([ap]m)\s*\(([^)]+)\)\s*$/i.exec(resetAtText.trim());
1094
+ const match = /^(\d{1,2})(?::(\d{2}))?\s*([ap]m)?\s*\(([^)]+)\)\s*$/i.exec(resetAtText.trim());
1040
1095
  if (!match) return undefined;
1041
1096
  const [, hourStr, minuteStr, ampm, timeZone] = match;
1042
1097
  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);
1098
+ let hour;
1099
+ if (ampm) {
1100
+ // Reject out-of-range hours BEFORE the `% 12` conversion below — otherwise
1101
+ // malformed input like "13:30pm" or "99:30pm" silently aliases to a
1102
+ // plausible-but-wrong hour (13 % 12 = 1, 99 % 12 = 3) instead of being
1103
+ // rejected, violating this function's own "never fabricate a value"
1104
+ // contract. Mirrors the `minute > 59` guard further down.
1105
+ if (rawHour < 1 || rawHour > 12) return undefined;
1106
+ hour = rawHour % 12;
1107
+ if (/pm/i.test(ampm)) hour += 12;
1108
+ } else {
1109
+ // 24-hour form: minutes are required (a bare "15" is too ambiguous).
1110
+ if (minuteStr === undefined || rawHour > 23) return undefined;
1111
+ hour = rawHour;
1112
+ }
1113
+ const minute = minuteStr === undefined ? 0 : parseInt(minuteStr, 10);
1052
1114
  if (!Number.isFinite(hour) || !Number.isFinite(minute) || minute > 59) return undefined;
1053
1115
 
1054
1116
  let todayParts;
@@ -1261,7 +1323,14 @@ export function runCliBackend({
1261
1323
  // present) so callers (bin/engine.mjs) can surface *when* a
1262
1324
  // quota-exhaustion failure will clear without reading raw logs. Only
1263
1325
  // ever set when the pattern actually matches — never fabricated.
1264
- const resetAt = extractSessionLimitResetTime(output);
1326
+ // 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);
1330
+ // Structured usage-limit flag: lets the engine pause by a bounded
1331
+ // default when the reset time itself is missing or unparseable. The
1332
+ // engine, not the runner, owns that default — no epoch is fabricated here.
1333
+ if (detectUsageLimit(stdout, output)) err.usageLimit = true;
1265
1334
  if (resetAt) {
1266
1335
  err.resetAt = resetAt;
1267
1336
  // forge#2524: also attach a machine-usable epoch-ms timestamp so
@@ -1392,6 +1461,9 @@ export function runCliBackend({
1392
1461
  usage,
1393
1462
  model: "cli",
1394
1463
  backend: "cli",
1464
+ // forge#3521: the phase's final reply (the CLI envelope's `.result`), so
1465
+ // callers can read its structured `*_RESULT` block. Additive; "" when absent.
1466
+ text: parsedResult ?? "",
1395
1467
  };
1396
1468
  } finally {
1397
1469
  if (tmpDir) {
@@ -2377,6 +2449,11 @@ export async function runCommand(opts = {}) {
2377
2449
  usage,
2378
2450
  model,
2379
2451
  backend: "api",
2452
+ // forge#3521: final assistant text (see the CLI backend's `text`).
2453
+ text: (response.content || [])
2454
+ .filter((blk) => blk.type === "text" && typeof blk.text === "string")
2455
+ .map((blk) => blk.text)
2456
+ .join("\n"),
2380
2457
  };
2381
2458
  }
2382
2459
 
@@ -253,7 +253,7 @@ For each gap detected (tool present in repo but no CI validation step found), fi
253
253
  **Skip issue creation if `--dry-run` flag is passed** (or `forge.yaml → ci_audit.dry_run: true`):
254
254
  ```bash
255
255
  DRY_RUN=$(yq '.ci_audit.dry_run // "false"' forge.yaml 2>/dev/null || echo "false")
256
- [ "$1" = "--dry-run" ] || [ "$2" = "--dry-run" ] && DRY_RUN="true"
256
+ [ "${1}" = "--dry-run" ] || [ "${2}" = "--dry-run" ] && DRY_RUN="true"
257
257
  ```
258
258
 
259
259
  ### 3A: Determine validator recommendation
@@ -262,8 +262,8 @@ For each gap, compose a concrete, version-aware recommendation:
262
262
 
263
263
  ```bash
264
264
  get_validator_recommendation() {
265
- local tool="$1"
266
- local version="$2" # may be empty if unknown
265
+ local tool="${1}"
266
+ local version="${2}" # may be empty if unknown
267
267
 
268
268
  case "$tool" in
269
269
  traefik)
@@ -28,7 +28,7 @@ fi
28
28
  # Fallback: resolve from this command file's own location if yq is unavailable
29
29
  # or forge.yaml is absent. The commands/ directory is always inside FORGE_HOME.
30
30
  if [ -z "$FORGE_HOME_PATH" ] || [ ! -d "$FORGE_HOME_PATH" ]; then
31
- FORGE_HOME_PATH="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." 2>/dev/null && pwd)"
31
+ FORGE_HOME_PATH="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")/.." 2>/dev/null && pwd)"
32
32
  fi
33
33
  export FORGE_HOME_PATH
34
34
  ```
@@ -93,12 +93,12 @@ DRY_RUN=false
93
93
  # Parse optional flags
94
94
  shift 2>/dev/null || true
95
95
  while [[ $# -gt 0 ]]; do
96
- case "$1" in
97
- --target) TARGET="$2"; shift 2 ;;
98
- --issue) ISSUE_NUMBER="$2"; shift 2 ;;
99
- --repo) GH_REPO="$2"; GH_FLAG="-R $GH_REPO"; shift 2 ;;
100
- --max-ci-iterations) MAX_CI_ITER="$2"; shift 2 ;;
101
- --max-review-iterations) MAX_REVIEW_ITER="$2"; shift 2 ;;
96
+ case "${1}" in
97
+ --target) TARGET="${2}"; shift 2 ;;
98
+ --issue) ISSUE_NUMBER="${2}"; shift 2 ;;
99
+ --repo) GH_REPO="${2}"; GH_FLAG="-R $GH_REPO"; shift 2 ;;
100
+ --max-ci-iterations) MAX_CI_ITER="${2}"; shift 2 ;;
101
+ --max-review-iterations) MAX_REVIEW_ITER="${2}"; shift 2 ;;
102
102
  --dry-run) DRY_RUN=true; shift ;;
103
103
  *) shift ;;
104
104
  esac
@@ -140,10 +140,10 @@ Extract each annotation block from the comments. Each `<!-- FORGE:TYPE -->` bloc
140
140
  ```bash
141
141
  # Helper: extract comment body containing a FORGE marker
142
142
  extract_forge() {
143
- local marker="$1"
143
+ local marker="${1}"
144
144
  echo "$ALL_COMMENTS" | awk "
145
145
  /<!-- ${marker}(:[A-Z]*)? -->/{found=1; buffer=\"\"}
146
- found {buffer = buffer \$0 ORS}
146
+ found {buffer = buffer \$(0) ORS}
147
147
  /<!-- ${marker}[^>]*:COMPLETE -->/{if(found){print buffer; found=0; buffer=\"\"}}
148
148
  END {if(found && length(buffer)>0) print buffer}
149
149
  "
@@ -49,7 +49,7 @@ Parse `$ARGUMENTS`:
49
49
 
50
50
  ```bash
51
51
  # Extract PR number (accept raw number or full GitHub URL)
52
- PR_INPUT=$(echo "$ARGUMENTS" | awk '{print $1}')
52
+ PR_INPUT=$(echo "$ARGUMENTS" | awk '{print $(1)}')
53
53
  if echo "$PR_INPUT" | grep -qE '^https?://'; then
54
54
  PR_NUMBER=$(echo "$PR_INPUT" | grep -oE '[0-9]+$')
55
55
  else
@@ -58,7 +58,7 @@ fi
58
58
 
59
59
  MAX_ATTEMPTS=5
60
60
  if echo "$ARGUMENTS" | grep -q -- "--max-attempts"; then
61
- MAX_ATTEMPTS=$(echo "$ARGUMENTS" | grep -oE -- '--max-attempts [0-9]+' | awk '{print $2}')
61
+ MAX_ATTEMPTS=$(echo "$ARGUMENTS" | grep -oE -- '--max-attempts [0-9]+' | awk '{print $(2)}')
62
62
  fi
63
63
 
64
64
  if [ -z "$PR_NUMBER" ]; then
@@ -104,7 +104,7 @@ for cmd in commands/*.md; do
104
104
  done
105
105
 
106
106
  echo ""
107
- echo "Total: $(wc -l commands/*.md | tail -1 | awk '{print $1}') lines across $(ls commands/*.md | wc -l) commands"
107
+ echo "Total: $(wc -l commands/*.md | tail -1 | awk '{print $(1)}') lines across $(ls commands/*.md | wc -l) commands"
108
108
  ```
109
109
 
110
110
  **After running, flag any command where:**
@@ -576,7 +576,7 @@ gh repo view "${OWNER}/${REPO}" --json name,url 2>/dev/null \
576
576
  ### 6B: Path existence
577
577
 
578
578
  ```bash
579
- [ -d "$(grep 'root:' "$FORGE_YAML" | head -1 | awk '{print $2}' | tr -d '"')" ] \
579
+ [ -d "$(grep 'root:' "$FORGE_YAML" | head -1 | awk '{print $(2)}' | tr -d '"')" ] \
580
580
  && echo "✓ paths.root exists" \
581
581
  || echo "WARNING: paths.root does not exist on this machine"
582
582
  ```
@@ -588,7 +588,7 @@ gh repo view "${OWNER}/${REPO}" --json name,url 2>/dev/null \
588
588
  ```bash
589
589
  # Only run if project_board section was written
590
590
  if grep -q "^project_board:" "$FORGE_YAML"; then
591
- PROJECT_ID=$(grep "project_id:" "$FORGE_YAML" | head -1 | awk '{print $2}' | tr -d '"')
591
+ PROJECT_ID=$(grep "project_id:" "$FORGE_YAML" | head -1 | awk '{print $(2)}' | tr -d '"')
592
592
  # Validate PVT_ prefix before GraphQL call — any other format will produce a confusing API error
593
593
  if [[ "$PROJECT_ID" != PVT_* ]]; then
594
594
  echo "ERROR: project_board.project_id must start with 'PVT_' (got: '$PROJECT_ID')"
@@ -610,7 +610,7 @@ fi
610
610
  if grep -q "^repos:" "$FORGE_YAML"; then
611
611
  # Extract satellite repo names and verify each is accessible
612
612
  grep -A 20 "^repos:" "$FORGE_YAML" | grep "repo:" | tail -n +2 | while read -r line; do
613
- SAT_REPO=$(echo "$line" | awk '{print $2}' | tr -d '"')
613
+ SAT_REPO=$(echo "$line" | awk '{print $(2)}' | tr -d '"')
614
614
  gh repo view "$SAT_REPO" --json name 2>/dev/null \
615
615
  && echo "✓ satellite repo ${SAT_REPO} accessible" \
616
616
  || echo "WARNING: satellite repo ${SAT_REPO} not accessible — check prefix and repo name"
@@ -250,7 +250,7 @@ declare -A LABEL_MAP=(
250
250
  )
251
251
 
252
252
  map_label() {
253
- local canonical="$1"
253
+ local canonical="${1}"
254
254
  echo "${LABEL_MAP[$canonical]:-$canonical}"
255
255
  }
256
256
  SCRIPT_EOF
@@ -514,8 +514,8 @@ for candidate in "${TIER_A_PROMOTIONS[@]}"; do
514
514
  # Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)
515
515
  #
516
516
  # CONTRACT:
517
- # \$1 = absolute path to unified diff (git diff HEAD)
518
- # \$2 = absolute path to worktree root
517
+ # \${1} = absolute path to unified diff (git diff HEAD)
518
+ # \${2} = absolute path to worktree root
519
519
  # stdout = findings lines: SLUG | SEVERITY | FILE | MESSAGE
520
520
  # exit 0 = pass, exit 1 = findings, exit 2 = inapplicable
521
521
  # 30s timeout enforced by quality-gate (fail-closed)
@@ -612,8 +612,8 @@ ${CITATIONS_BODY}
612
612
  ## gate.d contract
613
613
 
614
614
  Each script receives:
615
- - \$1 = path to unified diff
616
- - \$2 = path to worktree root
615
+ - \${1} = path to unified diff
616
+ - \${2} = path to worktree root
617
617
  - exits 0 (pass), 1 (findings on stdout), or 2 (inapplicable)
618
618
  - 30-second timeout enforced by quality-gate (fail-closed on timeout)
619
619