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
@@ -0,0 +1,112 @@
1
+ // Contract test (forge#3499): the arguments each engine phase passes to its
2
+ // work-on sub-skill must satisfy that spec's `argument-hint`. Fails CI when a
3
+ // phase's `buildArgs` and `commands/<command>.md` drift apart — the failure
4
+ // mode that parked every orchestrate run at architect.
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { readFileSync, existsSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import path from "node:path";
10
+ import { PHASES, PhaseArgsError } from "./phases.mjs";
11
+
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
13
+
14
+ function argumentHint(command) {
15
+ const file = path.join(ROOT, "commands", `${command}.md`);
16
+ assert.ok(existsSync(file), `engine phase command has no spec: ${file}`);
17
+ const m = readFileSync(file, "utf-8").match(/^---\n([\s\S]*?)\n---/);
18
+ assert.ok(m, `${command}.md has no frontmatter`);
19
+ const h = m[1].match(/^argument-hint:\s*(.*)$/m);
20
+ assert.ok(h, `${command}.md has no argument-hint`);
21
+ let v = h[1].trim();
22
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
23
+ return v.replace(/\\"/g, '"');
24
+ }
25
+
26
+ /** Required = flags/positional outside [...] groups; optional = inside. */
27
+ function parseHint(hint) {
28
+ const required = hint.replace(/\[[^\]]*\]/g, " ");
29
+ const flagsIn = (t) => [...t.matchAll(/(--[a-z][a-z-]*)/g)].map((x) => x[1]);
30
+ return {
31
+ requiredFlags: flagsIn(required),
32
+ allFlags: flagsIn(hint),
33
+ requiredPositional: /^\s*\{[A-Z_]+\}/.test(required),
34
+ };
35
+ }
36
+
37
+ const state = { v: 0, run: "r", issue: 3499, lane: "staging", committed: ["investigate", "build", "review"],
38
+ phase: null, branch: "fix/example-3499", pr: 77, terminal: false, terminalReason: null, lease: null };
39
+ const ctx = { repo: "acme/widgets" };
40
+ const io = {
41
+ git: async () =>
42
+ "worktree /repo/.claude/worktrees/fix-example-3499\nHEAD 1111111\nbranch refs/heads/fix/example-3499\n",
43
+ };
44
+
45
+ describe("engine phase args satisfy each target spec's argument-hint (forge#3499)", () => {
46
+ it("context/architect are not engine phases (work-on/build owns them)", () => {
47
+ const ids = PHASES.map((p) => p.id);
48
+ assert.ok(!ids.includes("context") && !ids.includes("architect"), ids.join(","));
49
+ });
50
+
51
+ for (const phase of PHASES) {
52
+ it(`${phase.id} (${phase.command})`, async () => {
53
+ assert.equal(typeof phase.buildArgs, "function", `${phase.id} must define buildArgs`);
54
+ const hint = parseHint(argumentHint(phase.command));
55
+ const args = await phase.buildArgs({ ...state, terminalReason: phase.id === "remediate" ? "needs-human" : null }, ctx, io);
56
+
57
+ assert.ok(Array.isArray(args) && args.every((a) => typeof a === "string"), "args must be an array of strings");
58
+ assert.ok(args.length > 1, `${phase.id} must not be invoked with only a positional`);
59
+ for (const a of args) assert.ok(!/[\n\r\t]/.test(a), `control char in arg ${JSON.stringify(a)}`);
60
+
61
+ // positional: issue number (or PR number for remediate) comes first
62
+ const expectedPositional = phase.id === "remediate" ? String(state.pr) : String(state.issue);
63
+ assert.equal(args[0], expectedPositional);
64
+ if (hint.requiredPositional) assert.match(args[0], /^[0-9]+$/);
65
+
66
+ const emitted = args.filter((a) => a.startsWith("--"));
67
+ for (const flag of hint.requiredFlags) {
68
+ const i = args.indexOf(flag);
69
+ assert.ok(i >= 0, `${phase.id}: required ${flag} missing from [${args.join(" ")}]`);
70
+ assert.ok(args[i + 1] !== undefined && args[i + 1] !== "" && !args[i + 1].startsWith("--"),
71
+ `${phase.id}: ${flag} has no value`);
72
+ }
73
+ for (const flag of emitted) {
74
+ assert.ok(hint.allFlags.includes(flag), `${phase.id}: emits ${flag}, which ${phase.command}.md does not declare`);
75
+ }
76
+ });
77
+ }
78
+
79
+ it("--gh-flag is a single quoted token, as in the specs' argument-hint", async () => {
80
+ const args = await PHASES.find((p) => p.id === "investigate").buildArgs(state, ctx, io);
81
+ assert.equal(args[args.indexOf("--gh-flag") + 1], '"-R acme/widgets"');
82
+ });
83
+
84
+ it("fails closed (PhaseArgsError) on a missing or injectable repo/branch/worktree rather than sending a partial arg set", async () => {
85
+ const inv = PHASES.find((p) => p.id === "investigate");
86
+ const review = PHASES.find((p) => p.id === "review");
87
+ for (const repo of [null, "", "no-slash", "a/b --base main", 'a/b"c', "a/b\nc"]) {
88
+ await assert.rejects(() => inv.buildArgs(state, { repo }, io), PhaseArgsError, `repo=${JSON.stringify(repo)}`);
89
+ }
90
+ await assert.rejects(() => review.buildArgs({ ...state, branch: "x y" }, ctx, io), PhaseArgsError);
91
+ await assert.rejects(() => review.buildArgs({ ...state, branch: null }, ctx, io), PhaseArgsError);
92
+ await assert.rejects(() => review.buildArgs(state, ctx, { git: async () => "worktree /repo\nbranch refs/heads/main\n" }), PhaseArgsError);
93
+ await assert.rejects(() => review.buildArgs(state, ctx, { git: async () => { throw new Error("git down"); } }), PhaseArgsError);
94
+ await assert.rejects(() => PHASES.find((p) => p.id === "remediate").buildArgs({ ...state, pr: null }, ctx, io), PhaseArgsError);
95
+ });
96
+
97
+ it("close refuses a merged terminal state without a PR (forge#3504)", async () => {
98
+ const close = PHASES.find((p) => p.id === "close");
99
+ await assert.rejects(() => close.buildArgs({ ...state, pr: null, terminalReason: "merged" }, ctx, io), PhaseArgsError);
100
+ });
101
+
102
+ it("close derives --terminal-state from the run's terminal reason", async () => {
103
+ const close = PHASES.find((p) => p.id === "close");
104
+ for (const [reason, expected] of [[null, "merged"], ["invalid", "invalid"], ["decomposed", "decomposed"]]) {
105
+ const args = await close.buildArgs({ ...state, terminalReason: reason }, ctx, io);
106
+ assert.equal(args[args.indexOf("--terminal-state") + 1], expected);
107
+ }
108
+ // worktree is optional for close: absent worktree must not fail it
109
+ const args = await close.buildArgs(state, ctx, { git: async () => "" });
110
+ assert.ok(!args.includes("--worktree"));
111
+ });
112
+ });
@@ -221,11 +221,90 @@ function resolveBranch(state, comments) {
221
221
  return parseBranchFromMarkers(comments) || state.branch || null;
222
222
  }
223
223
 
224
+
225
+ /**
226
+ * forge#3499: non-blocking evidence about the context / architect children that
227
+ * `work-on/build` now runs itself. Recorded for visibility only — a missing
228
+ * marker NEVER fails build (context is advisory; the TRIVIAL band legitimately
229
+ * skips both children). A BLOCKED / missing-arg child is not hidden here: it
230
+ * shows up as `absent`, never as a fabricated "skipped" commit.
231
+ */
232
+ function buildChildEvidence(blob, comments) {
233
+ const trivial = complexityBand(comments) === "TRIVIAL";
234
+ const state = (marker) => (has(blob, marker) ? "complete" : trivial ? "skipped-trivial" : "absent");
235
+ return {
236
+ context: state(PHASE_MARKERS.context.completionMarker),
237
+ architect: state(PHASE_MARKERS.architect.completionMarker),
238
+ };
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // forge#3499: per-phase argument construction.
243
+ //
244
+ // Every `work-on` phase sub-skill takes ALL of its inputs as arguments (forge#3398
245
+ // forked-phase router) and stops with BLOCKED when one is missing. Each phase's
246
+ // `buildArgs` emits exactly the flags its `commands/<command>.md` `argument-hint`
247
+ // requires; bin/engine/phases-args.test.mjs pins the two together.
248
+ //
249
+ // Values are validated against strict allowlists before use: the runner joins the
250
+ // args array with single spaces into the prompt, so a value with whitespace,
251
+ // quotes or newlines would splice extra arguments (forge#3466 class).
252
+ // ---------------------------------------------------------------------------
253
+
254
+ export class PhaseArgsError extends Error {
255
+ constructor(message) { super(message); this.name = "PhaseArgsError"; this.code = "PHASE_ARGS_INVALID"; }
256
+ }
257
+
258
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
259
+ const BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._\/-]*$/;
260
+ const LANE_RE = BRANCH_RE;
261
+ const PATH_RE = /^[A-Za-z0-9_.\/\\:@+~-]+$/;
262
+
263
+ function need(value, name, re) {
264
+ if (value === null || value === undefined || value === "") throw new PhaseArgsError(`missing ${name}`);
265
+ const v = String(value);
266
+ if (!re.test(v) || v.includes("..")) throw new PhaseArgsError(`invalid ${name}: ${JSON.stringify(v)}`);
267
+ return v;
268
+ }
269
+ /** @returns {string[]} the `--repo`/`--gh-flag` pair (gh-flag is ONE quoted token, as in the argument-hint). */
270
+ function repoArgs(ctx) {
271
+ const repo = need(ctx.repo, "repo", REPO_RE);
272
+ return ["--repo", repo, "--gh-flag", `"-R ${repo}"`];
273
+ }
274
+ function baseArgs(state) { return ["--base", need(state.lane, "base (lane)", LANE_RE)]; }
275
+
276
+ /**
277
+ * Resolve the linked worktree checked out on `branch` from `git worktree list
278
+ * --porcelain` — the only engine-side ground truth (build.md B1C creates it).
279
+ * Returns null when absent/prunable/unresolvable; never invents a path.
280
+ */
281
+ export async function resolveWorktree(state, io) {
282
+ if (state.worktree) return state.worktree;
283
+ if (!state.branch) return null;
284
+ let out;
285
+ try { out = await io.git(["worktree", "list", "--porcelain"]); } catch { return null; }
286
+ for (const block of String(out || "").split(/\n\s*\n/)) {
287
+ const lines = block.split("\n");
288
+ const wt = lines.find((l) => l.startsWith("worktree "));
289
+ const br = lines.find((l) => l.startsWith("branch "));
290
+ if (!wt || !br || lines.some((l) => l.startsWith("prunable"))) continue;
291
+ if (br.slice("branch ".length).trim() === `refs/heads/${state.branch}`) return wt.slice("worktree ".length).trim();
292
+ }
293
+ return null;
294
+ }
295
+
296
+ async function reviewArgs(state, ctx, io) {
297
+ const worktree = need(await resolveWorktree(state, io), "worktree (no linked worktree for branch)", PATH_RE);
298
+ return [String(state.issue), ...repoArgs(ctx), "--worktree", worktree,
299
+ "--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
300
+ }
301
+
224
302
  /** @type {Phase[]} */
225
303
  export const PHASES = [
226
304
  {
227
305
  id: "investigate",
228
306
  command: "work-on/investigate",
307
+ buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx)],
229
308
  entryCondition: () => true,
230
309
  async detectOutcome(state, io) {
231
310
  const { blob } = await issueMarkers(state.issue, io);
@@ -255,6 +334,7 @@ export const PHASES = [
255
334
  // before the run terminates.
256
335
  id: "decompose",
257
336
  command: "work-on/decompose",
337
+ buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx)],
258
338
  entryCondition: (s) => s.terminalReason === "decomposed",
259
339
  async detectOutcome(state, io) {
260
340
  const { blob } = await issueMarkers(state.issue, io);
@@ -266,50 +346,15 @@ export const PHASES = [
266
346
  // which runs its own /work-on pipeline — nothing more for THIS run to do.
267
347
  isTerminalAfter: () => true,
268
348
  },
269
- {
270
- id: "context",
271
- command: "work-on/build/context",
272
- entryCondition: (s) => s.committed.includes("investigate"),
273
- async reconcile(state, io) {
274
- const markers = await issueMarkers(state.issue, io);
275
- if (complexityBand(markers.comments) === "TRIVIAL") {
276
- return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "context" } };
277
- }
278
- // Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
279
- // Bare FORGE:CONTEXT matches a partial/interrupted annotation — require :COMPLETE.
280
- return has(markers.blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
281
- },
282
- async detectOutcome(state, io) {
283
- const { blob } = await issueMarkers(state.issue, io);
284
- // Context is non-critical: a missing marker is a VISIBLE skip, not a hard fail (spec §7).
285
- if (has(blob, PHASE_MARKERS.context.presenceMarker)) return { status: "committed", outputs: {} };
286
- return { status: "committed", outputs: { skipped: true, which: "context" } };
287
- },
288
- },
289
- {
290
- id: "architect",
291
- command: "work-on/build/architect",
292
- entryCondition: (s) => s.committed.includes("context"),
293
- async reconcile(state, io) {
294
- const markers = await issueMarkers(state.issue, io);
295
- if (complexityBand(markers.comments) === "TRIVIAL") {
296
- return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "architect" } };
297
- }
298
- // Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
299
- // Bare FORGE:ARCHITECT matches a partial/interrupted annotation — require :COMPLETE.
300
- return has(markers.blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
301
- },
302
- async detectOutcome(state, io) {
303
- const { blob } = await issueMarkers(state.issue, io);
304
- return has(blob, PHASE_MARKERS.architect.completionMarker)
305
- ? { status: "committed", outputs: {} }
306
- : { status: "failed", detail: `no ${PHASE_MARKERS.architect.completionMarker}` };
307
- },
308
- },
309
349
  {
310
350
  id: "build",
311
351
  command: "work-on/build",
312
- entryCondition: (s) => s.committed.includes("architect"),
352
+ buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)],
353
+ // forge#3499: work-on/build owns worktree creation and runs the context /
354
+ // architect / implement / validate children itself (it needs the worktree
355
+ // as `--repo-path`), so the engine no longer schedules context/architect as
356
+ // top-level phases — build is eligible as soon as investigate committed.
357
+ entryCondition: (s) => s.committed.includes("investigate"),
313
358
  async reconcile(state, io) {
314
359
  // Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
315
360
  // comment) rather than trusting a possibly-stale/absent state.branch, then
@@ -334,7 +379,9 @@ export const PHASES = [
334
379
  // from a genuine git-confirmed zero and would wrongly trip the
335
380
  // non-retryable guard below on the very first attempt.
336
381
  const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
337
- if (complete && ahead > 0) return { status: "committed", outputs: { branch } };
382
+ if (complete && ahead > 0) {
383
+ return { status: "committed", outputs: { branch, ...buildChildEvidence(blob, comments) } };
384
+ }
338
385
  const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
339
386
  // forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
340
387
  // but the resolved (real, ground-truth) branch has zero commits ahead of
@@ -369,17 +416,33 @@ export const PHASES = [
369
416
  {
370
417
  id: "review",
371
418
  command: "work-on/review",
419
+ buildArgs: reviewArgs,
372
420
  entryCondition: (s) => s.committed.includes("build"),
373
421
  async reconcile(state, io) {
374
422
  const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
375
423
  return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
376
424
  },
377
- async detectOutcome(state, io) {
425
+ async detectOutcome(state, io, result) {
378
426
  const pr = await prStatusFor(state, io);
379
427
  if (!pr) return { status: "failed", detail: "no PR created" };
380
428
  if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
381
- if (pr.needsHuman) return { status: "blocked", detail: "review escalated", outputs: { pr: pr.number } };
382
- return { status: "failed", detail: "PR open, not merged", retryable: false };
429
+ // forge#3521: the spec (commands/work-on/review.md) signals an in-PR fix
430
+ // by labelling the ISSUE needs-human and returning REVIEW_RESULT
431
+ // `status: NEXT / next: remediate`; the PR label is back-compat only.
432
+ // The PR number from GitHub (openPrFor) always wins over parsed text.
433
+ const outputs = { pr: pr.number };
434
+ const escalated = { status: "blocked", detail: "review escalated", outputs };
435
+ if (pr.needsHuman) return escalated;
436
+ const rr = parseReviewResult(result?.text);
437
+ if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
438
+ // An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
439
+ // (phase trail, ci gate, base conflict, merge refusal) also label the issue
440
+ // needs-human but are NOT remediation handoffs, so skip the label fallback.
441
+ if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs };
442
+ let snap = null;
443
+ try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
444
+ if (snap?.ok && snap.labels.includes("needs-human")) return escalated;
445
+ return { status: "failed", detail: "PR open, not merged", retryable: false, outputs };
383
446
  },
384
447
  },
385
448
  {
@@ -398,6 +461,9 @@ export const PHASES = [
398
461
  // handing the PR to remediation in the same run.
399
462
  id: "remediate",
400
463
  command: "work-on/remediate",
464
+ buildArgs: async (state, ctx) => [
465
+ need(state.pr, "pr", /^[0-9]+$/), "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state),
466
+ ],
401
467
  entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
402
468
  async detectOutcome(state, io) {
403
469
  const { blob } = await issueMarkers(state.issue, io);
@@ -435,6 +501,25 @@ export const PHASES = [
435
501
  {
436
502
  id: "close",
437
503
  command: "work-on/close",
504
+ buildArgs: async (state, ctx, io) => {
505
+ // Only --terminal-state is required by close.md; the rest are optional context.
506
+ // forge#3506: fail closed — `merged` only from a null/merged reason. A residual
507
+ // non-merged handoff reason (needs-human, awaiting-merge, engine-error, ...) must never
508
+ // be closed as merged.
509
+ const reason = state.terminalReason ?? null;
510
+ if (reason !== null && !["merged", "decomposed", "invalid"].includes(reason))
511
+ throw new PhaseArgsError(`refusing close --terminal-state merged from terminalReason ${JSON.stringify(reason)}`);
512
+ const terminal = reason === "decomposed" ? "decomposed"
513
+ : reason === "invalid" ? "invalid" : "merged";
514
+ const args = [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
515
+ // forge#3504: a merged close with no PR is fabricated evidence — require it.
516
+ if (state.pr != null || terminal === "merged") args.push("--pr", need(state.pr, "pr", /^[0-9]+$/));
517
+ if (state.branch) args.push("--branch", need(state.branch, "branch", BRANCH_RE));
518
+ const wt = await resolveWorktree(state, io);
519
+ if (wt && PATH_RE.test(wt)) args.push("--worktree", wt);
520
+ args.push("--terminal-state", terminal);
521
+ return args;
522
+ },
438
523
  entryCondition: (s) => s.committed.includes("review"),
439
524
  async reconcile(state, io) {
440
525
  // Idempotent resume: issue already closed or workflow:merged label set → skip the LLM re-run.
@@ -468,6 +553,28 @@ export const PHASES = [
468
553
  },
469
554
  ];
470
555
 
556
+ /**
557
+ * Parse the LAST `REVIEW_RESULT:` block from a phase's final reply (forge#3521).
558
+ * Untrusted model output: only the fixed keys below are read, via anchored
559
+ * line matches; `pr_number` must be digits-only. Returns null when absent.
560
+ */
561
+ export function parseReviewResult(text) {
562
+ if (typeof text !== "string" || !text) return null;
563
+ const lines = text.split(/\r?\n/);
564
+ let start = -1;
565
+ for (let i = 0; i < lines.length; i++) if (/^\s*REVIEW_RESULT:\s*$/.test(lines[i])) start = i;
566
+ if (start < 0) return null;
567
+ const out = {};
568
+ for (let i = start + 1; i < lines.length; i++) {
569
+ const m = /^\s+(status|next|remediation|pr_number):[ \t]*(\S*)[ \t]*$/.exec(lines[i]);
570
+ if (m) { if (!(m[1] in out)) out[m[1]] = m[2]; continue; }
571
+ if (/^\s+[A-Za-z_]+:/.test(lines[i]) || !lines[i].trim()) continue;
572
+ break;
573
+ }
574
+ if (out.pr_number !== undefined && !/^[0-9]+$/.test(out.pr_number)) delete out.pr_number;
575
+ return out;
576
+ }
577
+
471
578
  async function openPrFor(state, io) {
472
579
  if (!state.branch) return null;
473
580
  const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
@@ -68,10 +68,19 @@ export function deriveState(events) {
68
68
  s.v = e.seq;
69
69
  if (e.outputs?.branch) s.branch = e.outputs.branch;
70
70
  if (e.outputs?.pr != null) s.pr = e.outputs.pr;
71
+ // forge#3504: a blocked phase persists its reason so a resumed run still routes to
72
+ // remediate instead of defaulting to a "merged" close.
73
+ if (e.terminalReason) s.terminalReason = e.terminalReason;
71
74
  break;
72
75
  case "RUN_TERMINAL":
73
76
  s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
74
77
  break;
78
+ // forge#3511: an explicit `run-issue --retry` reopens an engine-error
79
+ // terminal. Bumping `v` makes the local state newer than the stale
80
+ // remote terminal index so reconcile remirrors instead of re-terminating.
81
+ case "RUN_REOPEN":
82
+ s.terminal = false; s.terminalReason = null; s.v = e.seq;
83
+ break;
75
84
  // forge#2524: a session-limit pause is purely informational — it does
76
85
  // NOT touch committed/terminal/terminalReason. The phase that hit the
77
86
  // pause is, by construction, not yet committed (the engine is about to
@@ -337,16 +337,21 @@ export function lastLocalRun(dir) {
337
337
  * `runCommand()` call; only applies on the "api" backend.
338
338
  * Omit to keep runner.mjs's default (`FORGEDOCK_MODEL` env
339
339
  * or its built-in default).
340
+ * --retry Optional (forge#3511). Reopen a run that ended `engine-error`
341
+ * (e.g. a session-limit hit) and resume from its last committed
342
+ * phase. Other terminal reasons are not reopened.
340
343
  */
341
344
  export async function runFromCli(argv, deps = {}) {
342
345
  const issue = parseInt(argv[0], 10);
343
- if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane>");
346
+ if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane> [--retry]");
344
347
  assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
345
348
  const lane = flag(argv, "--lane");
346
349
  if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
347
350
  const repo = flag(argv, "--repo");
348
351
  const backend = flag(argv, "--backend");
349
352
  const model = flag(argv, "--model");
353
+ // Boolean flag: only counts at a flag position, never as another flag's value.
354
+ const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
350
355
  const io = deps.io ?? makeIo();
351
356
  await assertRepoMatchesCwd(io, repo);
352
357
  const runIssueFn = deps.runIssue ?? runIssue;
@@ -375,13 +380,19 @@ export async function runFromCli(argv, deps = {}) {
375
380
  else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
376
381
  };
377
382
  const res = await runIssueFn({ issue, dir, agentId, lane, io,
383
+ ...(repo ? { repo } : {}),
378
384
  runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
379
385
  onProgress,
380
386
  // Only forwarded when explicitly provided — omitting them preserves
381
387
  // runIssue's/runner.mjs's existing defaults (forge#2028).
382
388
  ...(backend ? { backend } : {}),
383
- ...(model ? { model } : {}) });
389
+ ...(model ? { model } : {}),
390
+ ...(retry ? { retry: true } : {}) });
384
391
  console.log(`issue #${issue} → ${res.terminalReason}`);
392
+ if (res.terminalReason === "not-retryable") {
393
+ console.log(res.detail ?? "run is not retryable");
394
+ return res;
395
+ }
385
396
  // forge#2175: a non-success termination previously printed nothing beyond
386
397
  // the bare reason above — the actual failing phase/attempt/reason was only
387
398
  // recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the