forgedock 1.12.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.
@@ -38,6 +38,7 @@ const state = { v: 0, run: "r", issue: 3499, lane: "staging", committed: ["inves
38
38
  phase: null, branch: "fix/example-3499", pr: 77, terminal: false, terminalReason: null, lease: null };
39
39
  const ctx = { repo: "acme/widgets" };
40
40
  const io = {
41
+ gh: async () => "",
41
42
  git: async () =>
42
43
  "worktree /repo/.claude/worktrees/fix-example-3499\nHEAD 1111111\nbranch refs/heads/fix/example-3499\n",
43
44
  };
@@ -52,7 +53,7 @@ describe("engine phase args satisfy each target spec's argument-hint (forge#3499
52
53
  it(`${phase.id} (${phase.command})`, async () => {
53
54
  assert.equal(typeof phase.buildArgs, "function", `${phase.id} must define buildArgs`);
54
55
  const hint = parseHint(argumentHint(phase.command));
55
- const args = await phase.buildArgs({ ...state, terminalReason: phase.id === "remediate" ? "needs-human" : null }, ctx, io);
56
+ const args = await phase.buildArgs({ ...state, terminalReason: phase.id === "remediate" ? "needs-human" : null, remediationKind: phase.id === "remediate" ? "inpr-fix" : null }, ctx, io);
56
57
 
57
58
  assert.ok(Array.isArray(args) && args.every((a) => typeof a === "string"), "args must be an array of strings");
58
59
  assert.ok(args.length > 1, `${phase.id} must not be invoked with only a positional`);
@@ -30,33 +30,130 @@ import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
30
30
  export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed", "engine-error", "awaiting-merge"];
31
31
 
32
32
  /**
33
- * Fetch the issue's comments. Returns both:
34
- * - `blob`: all bodies joined into one string, for simple marker-presence checks
35
- * (`has(blob, marker)`) where it doesn't matter which comment posted the marker.
36
- * - `comments`: an array of individual comment bodies, preserving per-comment
37
- * boundaries, for extraction that MUST be scoped to a specific comment (see
38
- * `parseBranchFromMarkers()` below — forge#2184).
33
+ * Trust predicate for FORGE marker comments (forge#3542) — a JS port of
34
+ * `scripts/trusted-comments.sh`, the ONE predicate the specs use. A comment is
35
+ * trusted when
36
+ * - its `author_association` is in FORGE_TRAIL_TRUSTED_ASSOCIATIONS
37
+ * (default OWNER,MEMBER,COLLABORATOR; an explicitly EMPTY value trusts no
38
+ * association — `??`, not `||`), OR
39
+ * - its `user.type` is "Bot" (a GitHub App installation always reports
40
+ * association NONE), OR
41
+ * - its `user.login` is in FORGE_TRAIL_TRUSTED_LOGINS (default empty).
42
+ * A comment carrying no author data at all (a bare string, `{body}` only) is
43
+ * UNTRUSTED: fail closed.
44
+ */
45
+ export function isTrustedComment(c) {
46
+ if (!c || typeof c !== "object") return false;
47
+ const list = (v) => String(v).split(",").map((x) => x.trim()).filter(Boolean);
48
+ const assoc = list(process.env.FORGE_TRAIL_TRUSTED_ASSOCIATIONS ?? "OWNER,MEMBER,COLLABORATOR");
49
+ const logins = list(process.env.FORGE_TRAIL_TRUSTED_LOGINS ?? "");
50
+ return assoc.includes(c.author_association ?? "")
51
+ || (c.user?.type ?? "") === "Bot"
52
+ || logins.includes(c.user?.login ?? "");
53
+ }
54
+
55
+ /**
56
+ * Fetch the issue's comments, keeping ONLY trusted ones (forge#3542). Returns
57
+ * `{ comments }`: an array of trusted comment bodies, preserving per-comment
58
+ * boundaries and chronological order. Every marker gate below goes through
59
+ * `markedComments()`/`hasMarker()`/`latestMarked()`, which additionally require
60
+ * the marker to be ANCHORED (typed header leading, sentinel on its own line) —
61
+ * prose that merely quotes a marker never matches.
39
62
  *
40
- * The `--jq '[.[].body]'` query asks `gh` for a JSON array of bodies. If the
41
- * response isn't valid JSON (a non-JSON gh error string, or a test mock that
42
- * supplies a raw marker string instead of the real API shape), fall back to
43
- * treating the whole blob as a single pseudo-comment — `has()` checks are
44
- * unaffected either way, and comment-scoped extraction simply won't match,
45
- * which is the safe, conservative behavior.
63
+ * The `--jq` query asks `gh` for one JSON object per comment
64
+ * (`{body, author_association, user:{type,login}}`, paginated, one per line); a
65
+ * single JSON array of such objects is also accepted. Anything unparsable, or a
66
+ * comment without author data, yields no trusted comment (fail closed) — there is
67
+ * deliberately no "trust unauthored" escape hatch; tests supply authored comments.
46
68
  */
47
69
  async function issueMarkers(issue, io) {
48
- const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", "[.[].body]"]);
49
- const blob = out || "";
50
- let comments = [];
70
+ // `--paginate` so an issue with >30 comments still exposes its newest trail;
71
+ // `{...} | @json` emits one JSON document per line per page, which (unlike
72
+ // `[.[]]`, one array per page) concatenates safely across pages.
73
+ const out = await io.gh(["api", "--paginate", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq",
74
+ ".[] | {body, author_association, user: {type: .user.type, login: .user.login}} | @json"]);
75
+ const text = out || "";
76
+ let raw = null;
51
77
  try {
52
- const parsed = JSON.parse(out);
53
- if (Array.isArray(parsed)) {
54
- comments = parsed.map((c) => (typeof c === "string" ? c : (c && c.body) || ""));
55
- }
56
- } catch {
57
- comments = blob ? [blob] : [];
78
+ const parsed = JSON.parse(text);
79
+ if (Array.isArray(parsed)) raw = parsed;
80
+ } catch { /* not a single JSON document — try one JSON value per line */ }
81
+ if (!raw) {
82
+ try {
83
+ const vals = text.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
84
+ // Tolerate a line that is itself an array (concatenated pages).
85
+ if (vals.length > 0) raw = vals.flatMap((v) => (Array.isArray(v) ? v : [v]));
86
+ } catch { /* fall through */ }
58
87
  }
59
- return { blob, comments };
88
+ const comments = (raw || [])
89
+ .filter((c) => c && typeof c === "object" && typeof c.body === "string" && isTrustedComment(c))
90
+ .map((c) => c.body);
91
+ return { comments };
92
+ }
93
+
94
+ const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95
+ /** True when `body` STARTS with the typed header `<!-- {header}` (e.g. `FORGE:BUILDER`). */
96
+ function startsWithHeader(body, header) {
97
+ return typeof body === "string" && new RegExp(`^\\s*<!--\\s*${reEscape(header)}(?![A-Za-z0-9_])`).test(body);
98
+ }
99
+ /** True when `body` carries `<!-- {sentinel} -->` as a full line of its own. */
100
+ function hasSentinelLine(body, sentinel) {
101
+ return new RegExp(`^[ \\t]*<!--[ \\t]*${reEscape(sentinel)}[ \\t]*-->[ \\t]*$`, "m").test(body);
102
+ }
103
+ /**
104
+ * Trusted comments that START with `header` AND carry `sentinel` as a full line.
105
+ * The sentinel is not required to be the LAST line: `FORGE:BUILDER:COMPLETE` is
106
+ * appended in place to an existing builder comment (validate.md Phase V5).
107
+ */
108
+ function markedComments(comments, header, sentinel) {
109
+ return comments.filter((b) => startsWithHeader(b, header) && hasSentinelLine(b, sentinel));
110
+ }
111
+ function hasMarker(comments, header, sentinel) { return markedComments(comments, header, sentinel).length > 0; }
112
+ /** Newest marked comment, or null. */
113
+ function latestMarked(comments, header, sentinel) {
114
+ const all = markedComments(comments, header, sentinel);
115
+ return all.length ? all[all.length - 1] : null;
116
+ }
117
+ /**
118
+ * Trusted comment bodies, chronological — the same single fetch + trust predicate
119
+ * as issueMarkers() (one path, forge#3542). Fail closed: a fetch error yields `[]` here (issueMarkers itself propagates it).
120
+ */
121
+ async function trustedCommentBodies(issue, io) {
122
+ try { return (await issueMarkers(issue, io)).comments; } catch { return []; }
123
+ }
124
+
125
+ /** build.md B5.5 Step 2: a SIZE_OVERRIDE needs a non-empty justification — the first non-blank, non-HTML-comment line after the marker line. */
126
+ function overrideHasJustification(body) {
127
+ const lines = String(body).replace(/\r\n/g, "\n").split("\n");
128
+ return lines.slice(1).some((l) => l.trim() !== "" && !l.trimStart().startsWith("<!--"));
129
+ }
130
+
131
+ /**
132
+ * Size-gate routing (build.md B5.5). True only when the latest `FORGE:DIFF_SIZE`
133
+ * says `result: OVER`, carries a `### Split Proposal` (the discriminator against
134
+ * the decompose-loop-guard Blocked exit, which also posts OVER but must not
135
+ * re-enter decompose), and no justified `FORGE:SIZE_OVERRIDE` comment follows it,
136
+ * and the issue is not already decomposed. `comments` MUST be TRUSTED bodies in
137
+ * chronological order (see trustedCommentBodies); an untrusted comment never
138
+ * reaches this function.
139
+ */
140
+ function sizeGateRoutesToDecompose(comments) {
141
+ const gate = PHASE_MARKERS.build.sizeGateMarker;
142
+ const override = PHASE_MARKERS.build.sizeOverrideMarker;
143
+ const starts = (c, m) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${m}`);
144
+ if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return false;
145
+ let idx = -1;
146
+ for (let i = comments.length - 1; i >= 0; i--) if (starts(comments[i], gate)) { idx = i; break; }
147
+ if (idx < 0) return false;
148
+ const body = comments[idx].replace(/\r\n/g, "\n");
149
+ const m = /^result:[ \t]*(OK|OVERRIDDEN|OVER)[ \t]*$/m.exec(body);
150
+ if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return false;
151
+ return !comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c));
152
+ }
153
+ /** Cheap untrusted pre-check: does ANY comment look like an OVER gate? Only then is the trusted fetch worth making. */
154
+ function mayRouteToDecompose(comments) {
155
+ const gate = PHASE_MARKERS.build.sizeGateMarker;
156
+ return comments.some((c) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${gate}`) && /^result:[ \t]*OVER[ \t]*\r?$/m.test(c));
60
157
  }
61
158
  /**
62
159
  * Count commits on `branch` ahead of `lane`'s base. On the first build the
@@ -94,28 +191,6 @@ async function commitsAhead(lane, branch, io) {
94
191
  return -1;
95
192
  }
96
193
  }
97
- /**
98
- * Marker-presence check used throughout this file — including
99
- * `FORGE:BUILDER:COMPLETE` eligibility gates in the "build" phase's
100
- * `reconcile`/`detectOutcome` below (forge#2194 — investigated, no change).
101
- *
102
- * This is a plain substring test, deliberately, for consistency: every other
103
- * marker gate in this file (`INVESTIGATION:INVALID`, `DECOMPOSE:YES`,
104
- * `INVESTIGATION:COMPLETE`, `FORGE:CONTEXT:COMPLETE`,
105
- * `FORGE:ARCHITECT:COMPLETE`, `workflow:merged`) uses the identical
106
- * substring/membership technique — singling out `FORGE:BUILDER:COMPLETE`
107
- * alone for a "structured" parse would be inconsistent and would not close
108
- * any real gap: the actual trust boundary for issue-comment content is
109
- * *authorship* (can an untrusted actor post a comment on this issue at all),
110
- * not *format*. Nothing in this engine validates comment authorship for any
111
- * marker today, so an actor able to post an arbitrary comment could just as
112
- * easily post whatever "structured" shape a parser would accept — format
113
- * hardening alone buys nothing here. If comment-spoofing is ever a concern
114
- * worth addressing, the fix is an author allowlist applied uniformly to all
115
- * markers, not a bespoke parser for this one field.
116
- */
117
- function has(blob, marker) { return blob.includes(marker); }
118
-
119
194
  /**
120
195
  * The interactive workflow persists its conservative complexity decision in a
121
196
  * FORGE:FAST_PATH comment. The engine must consume that decision too; otherwise
@@ -126,7 +201,7 @@ function has(blob, marker) { return blob.includes(marker); }
126
201
  function complexityBand(comments) {
127
202
  for (let i = comments.length - 1; i >= 0; i--) {
128
203
  const body = comments[i];
129
- if (!body?.includes("FORGE:FAST_PATH")) continue;
204
+ if (!startsWithHeader(body, "FORGE:FAST_PATH")) continue;
130
205
  const match = body.match(/\*\*COMPLEXITY_BAND\*\*:\s*([A-Z_]+)/);
131
206
  if (match) return match[1];
132
207
  }
@@ -171,8 +246,9 @@ export async function issueSnapshot(issue, io) {
171
246
  * reliable source, since the branch name is slug-derived from the issue
172
247
  * title and cannot be guessed or precomputed (forge#2174).
173
248
  *
174
- * SCOPING (forge#2184): only comments whose body contains `FORGE:BUILDER:COMPLETE`
175
- * — the same completion marker the build phase already gates on — are eligible
249
+ * SCOPING (forge#2184, forge#3542): only TRUSTED comments that start with the
250
+ * `<!-- FORGE:BUILDER` header and carry `<!-- FORGE:BUILDER:COMPLETE -->` as a full
251
+ * line — the same anchored completion check the build phase gates on — are eligible
176
252
  * to supply the branch. A `**Branch**:` field inside any other comment (a
177
253
  * FORGE:CONTRACT, FORGE:ARCHITECT, FORGE:CONTEXT, reviewer, or remediation
178
254
  * comment) is never considered, even if it happens to match the same regex
@@ -203,7 +279,7 @@ function parseBranchFromMarkers(comments) {
203
279
  const re = /\*\*Branch\*\*:\s*`([^`]+)`/;
204
280
  for (let i = comments.length - 1; i >= 0; i--) {
205
281
  const body = comments[i];
206
- if (!body || !body.includes(PHASE_MARKERS.build.completionMarker)) continue;
282
+ if (!body || !startsWithHeader(body, PHASE_MARKERS.build.header) || !hasSentinelLine(body, PHASE_MARKERS.build.completionMarker)) continue;
207
283
  const match = body.match(re);
208
284
  if (match) return match[1];
209
285
  }
@@ -229,13 +305,11 @@ function resolveBranch(state, comments) {
229
305
  * skips both children). A BLOCKED / missing-arg child is not hidden here: it
230
306
  * shows up as `absent`, never as a fabricated "skipped" commit.
231
307
  */
232
- function buildChildEvidence(blob, comments) {
308
+ function buildChildEvidence(comments) {
233
309
  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
- };
310
+ const state = (phase) => (hasMarker(comments, PHASE_MARKERS[phase].header, PHASE_MARKERS[phase].completionMarker)
311
+ ? "complete" : trivial ? "skipped-trivial" : "absent");
312
+ return { context: state("context"), architect: state("architect") };
239
313
  }
240
314
 
241
315
  // ---------------------------------------------------------------------------
@@ -255,6 +329,20 @@ export class PhaseArgsError extends Error {
255
329
  constructor(message) { super(message); this.name = "PhaseArgsError"; this.code = "PHASE_ARGS_INVALID"; }
256
330
  }
257
331
 
332
+ /**
333
+ * forge#3530: review `remediation` kind -> the bound marker router Phase 4R posts
334
+ * (commands/work-on.md). review.md R4 counts these; remediate.md M0 keys its per-kind
335
+ * guard on them.
336
+ */
337
+ export const REMEDIATION_BOUND_MARKERS = Object.freeze({
338
+ "ci-gate": "CI_REMEDIATION",
339
+ "inpr-fix": "INPR_REMEDIATION",
340
+ "base-sync": "BASESYNC_REMEDIATION",
341
+ });
342
+ function isRemediationKind(k) {
343
+ return typeof k === "string" && Object.hasOwn(REMEDIATION_BOUND_MARKERS, k);
344
+ }
345
+
258
346
  const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
259
347
  const BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._\/-]*$/;
260
348
  const LANE_RE = BRANCH_RE;
@@ -299,6 +387,29 @@ async function reviewArgs(state, ctx, io) {
299
387
  "--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
300
388
  }
301
389
 
390
+ /**
391
+ * Is the issue assessed for decomposition? Evaluated on ONE comment: the newest
392
+ * trusted `<!-- FORGE:INVESTIGATOR` report (forge#3542 selects it; forge#3543's
393
+ * parsing applies within it). Within the comment the strict structured signals
394
+ * decide, in order: the `**YES**`/`**NO**` verdict under `### Decomposition
395
+ * Assessment`, then a marker alone on its own line (`<!-- DECOMPOSE:YES|NO -->`).
396
+ * Quoted markers mid-line are ignored. A heading/marker conflict fails safe (not
397
+ * decomposed) and is logged.
398
+ */
399
+ function isDecomposed(report) {
400
+ const text = String(report || "").replace(/\r\n/g, "\n");
401
+ const section = text.match(/^### Decomposition Assessment[^\n]*\n([\s\S]*?)(?=^###[^#]|(?![\s\S]))/m);
402
+ const verdict = section ? (/^\s*\*\*(YES|NO)\*\*/m.exec(section[1]) || [])[1] : undefined;
403
+ const markers = new Set();
404
+ for (const m of text.matchAll(/^<!-- DECOMPOSE:(YES|NO) -->[ \t]*$/gm)) markers.add(m[1]);
405
+ const marker = markers.size === 1 ? [...markers][0] : undefined;
406
+ if (markers.size > 1 || (verdict && marker && verdict !== marker)) {
407
+ console.error(`[engine] conflicting decomposition signals (heading=${verdict ?? "none"}, markers=${[...markers].join("+") || "none"}); treating as not decomposed`);
408
+ return false;
409
+ }
410
+ return (verdict ?? marker) === "YES";
411
+ }
412
+
302
413
  /** @type {Phase[]} */
303
414
  export const PHASES = [
304
415
  {
@@ -307,13 +418,24 @@ export const PHASES = [
307
418
  buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx)],
308
419
  entryCondition: () => true,
309
420
  async detectOutcome(state, io) {
310
- const { blob } = await issueMarkers(state.issue, io);
311
- if (has(blob, PHASE_MARKERS.investigate.invalidMarker))
421
+ // forge#3542: evaluate ONE comment — the newest trusted comment that starts with
422
+ // the `<!-- FORGE:INVESTIGATOR` header — and read its verdict from its LAST
423
+ // non-empty line (the sentinel investigate.md Phase 1C closes with). A report that
424
+ // merely quotes `INVESTIGATION:INVALID` in prose, or any untrusted comment, can
425
+ // never flip the verdict.
426
+ const { comments } = await issueMarkers(state.issue, io);
427
+ const report = [...comments].reverse().find((b) => startsWithHeader(b, PHASE_MARKERS.investigate.header));
428
+ const closing = report ? report.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop() : "";
429
+ const invalid = `<!-- ${PHASE_MARKERS.investigate.invalidMarker} -->`;
430
+ const complete = `<!-- ${PHASE_MARKERS.investigate.completionMarker} -->`;
431
+ if (closing === invalid)
312
432
  return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
313
- if (has(blob, PHASE_MARKERS.investigate.decomposedMarker))
314
- return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
315
- if (has(blob, PHASE_MARKERS.investigate.completionMarker))
433
+ if (closing === complete) {
434
+ // investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
435
+ if (isDecomposed(report))
436
+ return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
316
437
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
438
+ }
317
439
  return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
318
440
  },
319
441
  // forge#2379: no longer terminal after "decomposed" — that reason now
@@ -337,8 +459,8 @@ export const PHASES = [
337
459
  buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx)],
338
460
  entryCondition: (s) => s.terminalReason === "decomposed",
339
461
  async detectOutcome(state, io) {
340
- const { blob } = await issueMarkers(state.issue, io);
341
- if (has(blob, PHASE_MARKERS.decompose.completionMarker))
462
+ const { comments } = await issueMarkers(state.issue, io);
463
+ if (hasMarker(comments, PHASE_MARKERS.decompose.header, PHASE_MARKERS.decompose.completionMarker))
342
464
  return { status: "committed", terminalReason: "decomposed", outputs: {} };
343
465
  return { status: "failed", detail: `no ${PHASE_MARKERS.decompose.completionMarker} marker` };
344
466
  },
@@ -359,16 +481,22 @@ export const PHASES = [
359
481
  // Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
360
482
  // comment) rather than trusting a possibly-stale/absent state.branch, then
361
483
  // check it's already ahead of base → treat as done, skip the LLM (forge#2174).
362
- const { blob, comments } = await issueMarkers(state.issue, io);
484
+ const { comments } = await issueMarkers(state.issue, io);
363
485
  const branch = resolveBranch(state, comments);
364
- if (branch && has(blob, PHASE_MARKERS.build.completionMarker) && (await commitsAhead(state.lane, branch, io)) > 0) {
486
+ if (branch && hasMarker(comments, PHASE_MARKERS.build.header, PHASE_MARKERS.build.completionMarker) && (await commitsAhead(state.lane, branch, io)) > 0) {
365
487
  return { satisfied: true, outputs: { branch } };
366
488
  }
489
+ // forge#3535: work already merged into the lane has ahead=0 by definition;
490
+ // positive merged-PR evidence (base === lane) means build is satisfied.
491
+ if (branch && hasMarker(comments, PHASE_MARKERS.build.header, PHASE_MARKERS.build.completionMarker)) {
492
+ const merged = await mergedPrForBranch(branch, state.lane, io);
493
+ if (merged) return { satisfied: true, outputs: { branch, pr: merged.number } };
494
+ }
367
495
  return { satisfied: false };
368
496
  },
369
497
  async detectOutcome(state, io) {
370
- const { blob, comments } = await issueMarkers(state.issue, io);
371
- const complete = has(blob, PHASE_MARKERS.build.completionMarker); // #1305: require :COMPLETE …
498
+ const { comments } = await issueMarkers(state.issue, io);
499
+ const complete = hasMarker(comments, PHASE_MARKERS.build.header, PHASE_MARKERS.build.completionMarker); // #1305: require :COMPLETE …
372
500
  // Resolve the branch the builder actually created from the FORGE:BUILDER:COMPLETE
373
501
  // comment (ground truth), scoped to that specific comment — see
374
502
  // resolveBranch()/parseBranchFromMarkers() above (forge#2174, forge#2184).
@@ -380,7 +508,14 @@ export const PHASES = [
380
508
  // non-retryable guard below on the very first attempt.
381
509
  const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
382
510
  if (complete && ahead > 0) {
383
- return { status: "committed", outputs: { branch, ...buildChildEvidence(blob, comments) } };
511
+ return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
512
+ }
513
+ // B5.5 NEEDS_DECOMPOSE: the size gate posts no FORGE:BUILDER:COMPLETE and
514
+ // build.md says the router (not build) dispatches decompose with no
515
+ // needs-human. Non-retryable by construction: a retry re-measures and
516
+ // re-emits the same exit.
517
+ if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(await trustedCommentBodies(state.issue, io))) {
518
+ return { status: "committed", terminalReason: "decomposed", outputs: branch ? { branch } : {} };
384
519
  }
385
520
  const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
386
521
  // forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
@@ -409,6 +544,15 @@ export const PHASES = [
409
544
  // resolve, so both must stay retryable. Only a successfully-computed
410
545
  // ahead of 0 on a *resolved* branch (a real "nothing new to commit"
411
546
  // result) is the true fixed point this non-retryable signal targets.
547
+ // forge#3535: before the fixed point, check whether the branch already
548
+ // merged into the lane — then ahead=0 is the success state, not a missing
549
+ // build. Only positive evidence converts; everything else falls through.
550
+ if (complete && branch) {
551
+ const merged = await mergedPrForBranch(branch, state.lane, io);
552
+ if (merged) {
553
+ return { status: "committed", outputs: { branch, pr: merged.number, ...buildChildEvidence(comments) } };
554
+ }
555
+ }
412
556
  if (complete && ahead !== -1) return { status: "failed", detail, retryable: false };
413
557
  return { status: "failed", detail };
414
558
  },
@@ -427,13 +571,21 @@ export const PHASES = [
427
571
  if (!pr) return { status: "failed", detail: "no PR created" };
428
572
  if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
429
573
  // 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.
574
+ // by returning REVIEW_RESULT `status: NEXT / next: remediate`. forge#3541: the
575
+ // issue is labelled `workflow:remediating` (non-human "autonomous remediation
576
+ // pending"), no longer `needs-human`; both labels still trigger the handoff below
577
+ // (needs-human = back-compat for older runs). The PR label is back-compat only.
432
578
  // The PR number from GitHub (openPrFor) always wins over parsed text.
433
579
  const outputs = { pr: pr.number };
434
580
  const escalated = { status: "blocked", detail: "review escalated", outputs };
435
- if (pr.needsHuman) return escalated;
436
581
  const rr = parseReviewResult(result?.text);
582
+ // forge#3530: carry the review's remediation kind (router Phase 4R parity) so the
583
+ // remediate phase can post the matching bound marker. Untrusted model output: only
584
+ // the three known kinds are carried verbatim; any other non-empty value is recorded as
585
+ // the "unknown" sentinel (never the raw text) so remediate's buildArgs fails closed.
586
+ if (rr && rr.status === "NEXT" && rr.next === "remediate" && rr.remediation)
587
+ outputs.remediation = isRemediationKind(rr.remediation) ? rr.remediation : "unknown";
588
+ if (pr.needsHuman || pr.remediating) return escalated;
437
589
  if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
438
590
  // An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
439
591
  // (phase trail, ci gate, base conflict, merge refusal) also label the issue
@@ -441,7 +593,7 @@ export const PHASES = [
441
593
  if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs };
442
594
  let snap = null;
443
595
  try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
444
- if (snap?.ok && snap.labels.includes("needs-human")) return escalated;
596
+ if (snap?.ok && (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating"))) return escalated;
445
597
  return { status: "failed", detail: "PR open, not merged", retryable: false, outputs };
446
598
  },
447
599
  },
@@ -461,18 +613,62 @@ export const PHASES = [
461
613
  // handing the PR to remediation in the same run.
462
614
  id: "remediate",
463
615
  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
- ],
616
+ // forge#3530: this is the engine's second implementation of router Phase 4R
617
+ // (commands/work-on.md): post the per-kind bound marker immediately BEFORE
618
+ // dispatching remediation, so remediate.md M0's per-kind guard and review.md R4's
619
+ // loop bounds see it. Fail closed (PhaseArgsError -> engine-error, no runner call)
620
+ // on a missing/unknown kind or a marker that cannot be posted: an unrecorded bound
621
+ // must not run. Keep in sync with Phase 4R.
622
+ buildArgs: async (state, ctx, io) => {
623
+ const pr = need(state.pr, "pr", /^[0-9]+$/);
624
+ const kind = state.remediationKind ?? null;
625
+ const legacyArgs = () => [pr, "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
626
+ // Legacy handoff with no kind (needs-human label fallback, or no REVIEW_RESULT): there
627
+ // is nothing to bind, so dispatch as before. remediate.md M0 then applies its default
628
+ // single-attempt guard. A present-but-unknown kind is never dispatched.
629
+ if (kind === null) return legacyArgs();
630
+ if (!isRemediationKind(kind))
631
+ throw new PhaseArgsError(`unknown remediation kind: ${JSON.stringify(kind)}`);
632
+ const boundMarker = REMEDIATION_BOUND_MARKERS[kind];
633
+ const marker = `<!-- FORGE:${boundMarker}: pr=${pr} -->`;
634
+ try {
635
+ const { comments } = await issueMarkers(state.issue, io);
636
+ // Resume idempotency: a handoff interrupted after the marker post must not double-count.
637
+ // forge#3542: only a full-line marker in a trusted comment counts.
638
+ if (!comments.some((b) => hasSentinelLine(b, `FORGE:${boundMarker}: pr=${pr}`))) {
639
+ await io.gh(["issue", "comment", String(state.issue), "--body",
640
+ `${marker}\nReview handed PR #${pr} to remediation (${kind}); the engine is dispatching it once.`]);
641
+ }
642
+ } catch (e) {
643
+ throw new PhaseArgsError(`github-unavailable: could not post FORGE:${boundMarker}; remediation not invoked (${e?.message || e})`);
644
+ }
645
+ return legacyArgs();
646
+ },
467
647
  entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
468
648
  async detectOutcome(state, io) {
469
- const { blob } = await issueMarkers(state.issue, io);
470
- if (!has(blob, PHASE_MARKERS.remediate.completionMarker))
471
- return { status: "failed", detail: `no ${PHASE_MARKERS.remediate.completionMarker} marker` };
649
+ // forge#3530: read the NEWEST completed trail only. A concatenated oldest-first blob
650
+ // returned a stale outcome from an earlier remediation when a later one posted nothing.
651
+ const { comments } = await issueMarkers(state.issue, io);
652
+ const completion = PHASE_MARKERS.remediate.completionMarker;
653
+ // forge#3542: trusted, header-led comment with the sentinel as its own line.
654
+ // The trail must also belong to THIS run: when a per-kind bound marker was posted for
655
+ // this PR (buildArgs above), only trails posted AFTER the newest such trusted marker
656
+ // count — a stale earlier trail (or a forged one that predates dispatch) never decides.
657
+ let scoped = comments;
658
+ if (isRemediationKind(state.remediationKind ?? null) && state.pr != null) {
659
+ const bound = `FORGE:${REMEDIATION_BOUND_MARKERS[state.remediationKind]}: pr=${state.pr}`;
660
+ let at = -1;
661
+ comments.forEach((b, i) => { if (hasSentinelLine(b, bound)) at = i; });
662
+ if (at >= 0) scoped = comments.slice(at + 1);
663
+ }
664
+ const latest = latestMarked(scoped, PHASE_MARKERS.remediate.header, completion);
665
+ if (latest === null)
666
+ return { status: "failed", detail: `no ${completion} marker` };
472
667
  // Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
473
668
  // (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
474
669
  // first whitespace-delimited token after the colon).
475
- const match = blob.match(/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/);
670
+ const matches = [...latest.matchAll(/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/g)];
671
+ const match = matches.length ? matches[matches.length - 1] : null;
476
672
  const reGateOutcome = match ? match[1] : null;
477
673
  switch (reGateOutcome) {
478
674
  case "AUTO-LANDED":
@@ -580,6 +776,26 @@ async function openPrFor(state, io) {
580
776
  const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
581
777
  try { const a = JSON.parse(out || "[]"); return a[0]?.number ?? null; } catch { return null; }
582
778
  }
779
+ /**
780
+ * forge#3535: the merged PR (if any) that shipped `branch` into `lane`.
781
+ * Build keys off the branch resolved from FORGE:BUILDER:COMPLETE, not
782
+ * `state.branch` (null on a fresh re-run), so this takes the branch explicitly.
783
+ * A PR merged into a different base, a closed-unmerged PR, no PR, or any
784
+ * gh/JSON error yields null: unknown is never "merged" (forge#3504/#3506).
785
+ * Returns `{ number }` with a real integer PR number, else null.
786
+ */
787
+ async function mergedPrForBranch(branch, lane, io) {
788
+ if (!branch || !lane) return null;
789
+ try {
790
+ const out = await io.gh(["pr", "list", "--head", branch, "--state", "all",
791
+ "--json", "number,state,mergedAt,baseRefName"]);
792
+ const a = JSON.parse(out || "[]");
793
+ if (!Array.isArray(a)) return null;
794
+ const hit = a.find((p) => p && typeof p === "object" && Number.isInteger(p.number)
795
+ && (!!p.mergedAt || p.state === "MERGED") && p.baseRefName === lane);
796
+ return hit ? { number: hit.number } : null;
797
+ } catch { return null; }
798
+ }
583
799
  async function prStatusFor(state, io) {
584
800
  const n = await openPrFor(state, io);
585
801
  if (!n) return null;
@@ -588,7 +804,8 @@ async function prStatusFor(state, io) {
588
804
  try { j = JSON.parse(out || "{}"); } catch { return null; }
589
805
  const labels = (j.labels || []).map((l) => l.name || l);
590
806
  return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
591
- needsHuman: labels.includes("needs-human") };
807
+ needsHuman: labels.includes("needs-human"),
808
+ remediating: labels.includes("workflow:remediating") };
592
809
  }
593
810
 
594
811
  /** The engine's transition function: first uncommitted phase whose gate holds. */
@@ -57,7 +57,10 @@ export function deriveState(events) {
57
57
  /** @type {import("./phases.mjs").RunState} */
58
58
  const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
59
59
  phase: null, branch: null, pr: null, terminal: false,
60
- terminalReason: null, lease: null, lastRateLimit: null };
60
+ terminalReason: null, lease: null, lastRateLimit: null,
61
+ remediationKind: null };
62
+ // forge#3528: handoff reason of the LAST PHASE_COMMIT only (replaced, not sticky).
63
+ let lastCommitReason = null;
61
64
  for (const e of events) {
62
65
  switch (e.event) {
63
66
  case "RUN_START":
@@ -71,6 +74,11 @@ export function deriveState(events) {
71
74
  // forge#3504: a blocked phase persists its reason so a resumed run still routes to
72
75
  // remediate instead of defaulting to a "merged" close.
73
76
  if (e.terminalReason) s.terminalReason = e.terminalReason;
77
+ lastCommitReason = e.terminalReason ?? null;
78
+ // forge#3530: the review's remediation kind must survive resume. A later review
79
+ // commit replaces it; a committed remediate consumes it, so it is never reused.
80
+ if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
81
+ else if (e.phase === "remediate") s.remediationKind = null;
74
82
  break;
75
83
  case "RUN_TERMINAL":
76
84
  s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
@@ -78,8 +86,11 @@ export function deriveState(events) {
78
86
  // forge#3511: an explicit `run-issue --retry` reopens an engine-error
79
87
  // terminal. Bumping `v` makes the local state newer than the stale
80
88
  // remote terminal index so reconcile remirrors instead of re-terminating.
89
+ // forge#3528: RUN_TERMINAL overwrote the persisted handoff reason
90
+ // (needs-human / decomposed); restore the last commit's reason so the
91
+ // retry re-enters remediate/decompose instead of close/build.
81
92
  case "RUN_REOPEN":
82
- s.terminal = false; s.terminalReason = null; s.v = e.seq;
93
+ s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
83
94
  break;
84
95
  // forge#2524: a session-limit pause is purely informational — it does
85
96
  // NOT touch committed/terminal/terminalReason. The phase that hit the
@@ -26,6 +26,7 @@ export const ACTIVE_WORKFLOW_LABELS = [
26
26
  "workflow:ready-to-build",
27
27
  "workflow:building",
28
28
  "workflow:in-review",
29
+ "workflow:remediating",
29
30
  ];
30
31
 
31
32
  const pexec = promisify(execFile);
package/bin/engine.mjs CHANGED
@@ -359,10 +359,13 @@ export async function runIssue(opts) {
359
359
  // decompose) and before RUN_TERMINAL leaves its persisted reason on the commit event. Resume
360
360
  // must honour that reason instead of picking the next phase (close would throw in buildArgs
361
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.
362
+ // is a handoff to remediate, so only these committed phases trigger the guard.
363
363
  if (state.terminalReason && TERMINAL_REASONS.includes(state.terminalReason) &&
364
364
  ((state.committed.includes("remediate") && !["merged", "decomposed", "invalid"].includes(state.terminalReason)) ||
365
- (state.committed.includes("decompose") && state.terminalReason === "decomposed")))
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")))
366
369
  return await terminate(state, state.terminalReason);
367
370
  // forge#2352: state-vs-GitHub divergence guard. Every phase's own
368
371
  // `entryCondition` only ever checked `state.committed` (local run-log
@@ -674,7 +677,8 @@ export async function runIssue(opts) {
674
677
  // is seen), phase.id is "decompose" — not "investigate" — so this
675
678
  // exemption does not apply and the normal terminate() path below fires,
676
679
  // ending the run for real.
677
- 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";
678
682
  if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
679
683
  !isDecomposeHandoff && !isRemediationHandoff)
680
684
  return await terminate(state, terminalReason, outcome.detail);
@@ -822,6 +826,7 @@ function eventsFromIndex(idx) {
822
826
  const outputs = {};
823
827
  if (phase === "build" && idx.branch) outputs.branch = idx.branch;
824
828
  if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
829
+ if (phase === "review" && idx.remediationKind) outputs.remediation = idx.remediationKind;
825
830
  events.push({ event: "PHASE_COMMIT", phase, outputs });
826
831
  }
827
832
  // forge#3506: a non-terminal reason is a handoff (e.g. review -> remediate). Replay it on
@@ -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;