forgedock 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/bin/engine/phases-args.test.mjs +2 -1
  2. package/bin/engine/phases.mjs +349 -90
  3. package/bin/engine/runlog.mjs +25 -2
  4. package/bin/engine-cli.mjs +4 -1
  5. package/bin/engine.mjs +72 -19
  6. package/bin/hooks/interactive-engine.mjs +135 -209
  7. package/bin/hooks/pre-tool-use.mjs +16 -4
  8. package/bin/labels.json +10 -0
  9. package/bin/opencode-adapter.mjs +1 -1
  10. package/bin/orchestrate-preflight.mjs +1 -1
  11. package/bin/runner.mjs +258 -23
  12. package/bin/watch-utils.mjs +1 -0
  13. package/commands/autopilot.md +6 -6
  14. package/commands/cleanup.md +8 -5
  15. package/commands/orchestrate/phase-1-resolve.md +1 -1
  16. package/commands/orchestrate/phase-3-dependency.md +14 -19
  17. package/commands/orchestrate/phase-4-execution.md +80 -53
  18. package/commands/orchestrate/phase-6-report.md +6 -0
  19. package/commands/orchestrate/safety.md +1 -1
  20. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  21. package/commands/pipeline-health/phase-5-report.md +1 -0
  22. package/commands/pipeline-resume.md +1 -1
  23. package/commands/pipeline-status.md +2 -2
  24. package/commands/recover-orphans.md +5 -5
  25. package/commands/review-pr-agents/api.md +1 -1
  26. package/commands/review-pr-agents/auth.md +1 -1
  27. package/commands/review-pr-agents/infra.md +3 -3
  28. package/commands/review-pr-agents/protocols.md +15 -6
  29. package/commands/review-pr-agents/scraper.md +1 -1
  30. package/commands/review-pr-agents/security.md +5 -5
  31. package/commands/review-pr-agents.md +12 -12
  32. package/commands/review-pr-staging.md +226 -8
  33. package/commands/review-pr.md +394 -53
  34. package/commands/work-on/build/architect.md +36 -0
  35. package/commands/work-on/build/implement.md +1 -1
  36. package/commands/work-on/build/validate.md +88 -4
  37. package/commands/work-on/build.md +106 -7
  38. package/commands/work-on/decompose.md +2 -2
  39. package/commands/work-on/investigate.md +14 -4
  40. package/commands/work-on/remediate.md +137 -25
  41. package/commands/work-on/review.md +25 -9
  42. package/commands/work-on.md +15 -15
  43. package/docs/CONFIG.md +29 -0
  44. package/docs/RELEASE-NOTES.md +45 -0
  45. package/docs/spec/forge-protocol-v1.md +22 -1
  46. package/docs/spec/label-state-machine.md +6 -1
  47. package/docs/spec/review-protocol.md +20 -5
  48. package/package.json +2 -1
  49. package/packages/protocol/src/phases.js +21 -0
  50. package/scripts/check-blast-radius.sh +164 -0
  51. package/scripts/check-blast-radius.test.sh +183 -0
  52. package/scripts/check-contract-scope.sh +137 -0
  53. package/scripts/check-contract-scope.test.sh +83 -0
  54. package/scripts/check-spec-bash.sh +60 -3
  55. package/scripts/check-spec-bash.test.sh +56 -0
  56. package/scripts/check-spec-markers.sh +29 -0
  57. package/scripts/check-test-wiring.sh +57 -0
  58. package/scripts/check-test-wiring.test.sh +70 -0
  59. package/scripts/classify-finding.sh +72 -8
  60. package/scripts/classify-finding.test.sh +77 -8
  61. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  62. package/scripts/diff-size.test.sh +3 -2
  63. package/scripts/doctor-pipeline-state.sh +35 -0
  64. package/scripts/forge-annotation.sh +3 -0
  65. package/scripts/forge-root.test.sh +103 -13
  66. package/scripts/forge-run.sh +2 -2
  67. package/scripts/recovery-claim-live.test.sh +10 -7
  68. package/scripts/remediate-basesync.test.sh +103 -0
  69. package/scripts/review-delta.sh +206 -0
  70. package/scripts/review-delta.test.sh +213 -0
  71. package/scripts/review-pr-domain-map.test.sh +87 -0
  72. package/scripts/review-pr-gate2.test.sh +84 -0
  73. package/scripts/review-pr-staging-scope.test.sh +99 -0
  74. package/scripts/run-shell-tests.sh +115 -0
  75. package/scripts/select-fix-targets.sh +17 -5
  76. package/scripts/transition-label.sh +3 -1
@@ -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,22 @@ 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
- const escalated = { status: "blocked", detail: "review escalated", outputs };
435
- if (pr.needsHuman) return escalated;
580
+ // Opt-in: only an explicit escalation signal sets handoff:true. A PR number alone never hands off.
581
+ const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true };
436
582
  const rr = parseReviewResult(result?.text);
583
+ // forge#3530: carry the review's remediation kind (router Phase 4R parity) so the
584
+ // remediate phase can post the matching bound marker. Untrusted model output: only
585
+ // the three known kinds are carried verbatim; any other non-empty value is recorded as
586
+ // the "unknown" sentinel (never the raw text) so remediate's buildArgs fails closed.
587
+ if (rr && rr.status === "NEXT" && rr.next === "remediate" && rr.remediation)
588
+ outputs.remediation = isRemediationKind(rr.remediation) ? rr.remediation : "unknown";
589
+ if (pr.needsHuman || pr.remediating) return escalated;
437
590
  if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
438
591
  // An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
439
592
  // (phase trail, ci gate, base conflict, merge refusal) also label the issue
@@ -441,7 +594,7 @@ export const PHASES = [
441
594
  if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs };
442
595
  let snap = null;
443
596
  try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
444
- if (snap?.ok && snap.labels.includes("needs-human")) return escalated;
597
+ if (snap?.ok && (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating"))) return escalated;
445
598
  return { status: "failed", detail: "PR open, not merged", retryable: false, outputs };
446
599
  },
447
600
  },
@@ -461,27 +614,82 @@ export const PHASES = [
461
614
  // handing the PR to remediation in the same run.
462
615
  id: "remediate",
463
616
  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
- ],
617
+ // forge#3530: this is the engine's second implementation of router Phase 4R
618
+ // (commands/work-on.md): post the per-kind bound marker immediately BEFORE
619
+ // dispatching remediation, so remediate.md M0's per-kind guard and review.md R4's
620
+ // loop bounds see it. Fail closed (PhaseArgsError -> engine-error, no runner call)
621
+ // on a missing/unknown kind or a marker that cannot be posted: an unrecorded bound
622
+ // must not run. Keep in sync with Phase 4R.
623
+ buildArgs: async (state, ctx, io) => {
624
+ const pr = need(state.pr, "pr", /^[0-9]+$/);
625
+ const kind = state.remediationKind ?? null;
626
+ const legacyArgs = () => [pr, "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
627
+ // Legacy handoff with no kind (needs-human label fallback, or no REVIEW_RESULT): there
628
+ // is nothing to bind, so dispatch as before. remediate.md M0 then applies its default
629
+ // single-attempt guard. A present-but-unknown kind is never dispatched.
630
+ if (kind === null) return legacyArgs();
631
+ if (!isRemediationKind(kind))
632
+ throw new PhaseArgsError(`unknown remediation kind: ${JSON.stringify(kind)}`);
633
+ const boundMarker = REMEDIATION_BOUND_MARKERS[kind];
634
+ const marker = `<!-- FORGE:${boundMarker}: pr=${pr} -->`;
635
+ try {
636
+ const { comments } = await issueMarkers(state.issue, io);
637
+ // Resume idempotency: a handoff interrupted after the marker post must not double-count.
638
+ // forge#3542: only a full-line marker in a trusted comment counts.
639
+ if (!comments.some((b) => hasSentinelLine(b, `FORGE:${boundMarker}: pr=${pr}`))) {
640
+ await io.gh(["issue", "comment", String(state.issue), "--body",
641
+ `${marker}\nReview handed PR #${pr} to remediation (${kind}); the engine is dispatching it once.`]);
642
+ }
643
+ } catch (e) {
644
+ throw new PhaseArgsError(`github-unavailable: could not post FORGE:${boundMarker}; remediation not invoked (${e?.message || e})`);
645
+ }
646
+ return legacyArgs();
647
+ },
467
648
  entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
468
649
  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` };
650
+ // forge#3530: read the NEWEST completed trail only. A concatenated oldest-first blob
651
+ // returned a stale outcome from an earlier remediation when a later one posted nothing.
652
+ const { comments } = await issueMarkers(state.issue, io);
653
+ const completion = PHASE_MARKERS.remediate.completionMarker;
654
+ // forge#3542: trusted, header-led comment with the sentinel as its own line.
655
+ // The trail must also belong to THIS run: when a per-kind bound marker was posted for
656
+ // this PR (buildArgs above), only trails posted AFTER the newest such trusted marker
657
+ // count — a stale earlier trail (or a forged one that predates dispatch) never decides.
658
+ let scoped = comments;
659
+ if (isRemediationKind(state.remediationKind ?? null) && state.pr != null) {
660
+ const bound = `FORGE:${REMEDIATION_BOUND_MARKERS[state.remediationKind]}: pr=${state.pr}`;
661
+ let at = -1;
662
+ comments.forEach((b, i) => { if (hasSentinelLine(b, bound)) at = i; });
663
+ if (at >= 0) scoped = comments.slice(at + 1);
664
+ }
665
+ const latest = latestMarked(scoped, PHASE_MARKERS.remediate.header, completion);
666
+ if (latest === null)
667
+ return { status: "failed", detail: `no ${completion} marker` };
472
668
  // Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
473
669
  // (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
474
670
  // first whitespace-delimited token after the colon).
475
- const match = blob.match(/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/);
671
+ const matches = [...latest.matchAll(/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/g)];
672
+ const match = matches.length ? matches[matches.length - 1] : null;
476
673
  const reGateOutcome = match ? match[1] : null;
477
674
  switch (reGateOutcome) {
478
- case "AUTO-LANDED":
479
- // remediate.md's own Phase M8 already drove close in this case
480
- // (see that file's "If the outcome was AUTO-LANDED" branch) — the
481
- // issue should already carry workflow:merged by the time this
482
- // reads, but the terminal reason here is what THIS phase reports,
483
- // independent of close's own idempotent detectOutcome re-check.
484
- return { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } };
675
+ case "AUTO-LANDED": {
676
+ // forge#3624: remediate.md M8 drives close itself on this outcome, but an
677
+ // engine phase session may skip or fail that delegated close and nothing
678
+ // verified it. Check the issue's real state: CLOSED + workflow:merged means
679
+ // the run is genuinely finished (terminal merged). Anything else, including
680
+ // an unreadable snapshot, hands off to `close` (idempotent via its reconcile).
681
+ // The reason stays "merged" either way so the commit replaces the review's
682
+ // sticky "needs-human" and close.buildArgs accepts --terminal-state merged.
683
+ let closed = false;
684
+ try {
685
+ const snap = await issueSnapshot(state.issue, io);
686
+ closed = snap.ok && snap.state === "CLOSED" &&
687
+ snap.labels.includes(PHASE_MARKERS.close.completionLabel);
688
+ } catch { closed = false; }
689
+ return closed
690
+ ? { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } }
691
+ : { status: "committed", terminalReason: "merged", outputs: { reGateOutcome, closeHandoff: true } };
692
+ }
485
693
  case "HELD-AWAITING-MERGE":
486
694
  return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
487
695
  case "RE-ESCALATED":
@@ -522,12 +730,15 @@ export const PHASES = [
522
730
  },
523
731
  entryCondition: (s) => s.committed.includes("review"),
524
732
  async reconcile(state, io) {
525
- // Idempotent resume: issue already closed or workflow:merged label set → skip the LLM re-run.
733
+ // Idempotent resume: issue already CLOSED → skip the LLM re-run. forge#3644: the
734
+ // workflow:merged label alone is NOT proof close ran — remediate M7 labels the issue
735
+ // merged right after `gh pr merge`, while a staging-base PR leaves it OPEN. Treating
736
+ // OPEN + workflow:merged as done made close.reconcile swallow remediate's closeHandoff
737
+ // (remediate's own check, in its AUTO-LANDED branch, requires CLOSED + the label, a
738
+ // strict subset of what satisfies this predicate on a CLOSED issue).
526
739
  const snap = await issueSnapshot(state.issue, io);
527
740
  if (!snap.ok) return { satisfied: false };
528
- return (snap.state === "CLOSED" || snap.labels.includes(PHASE_MARKERS.close.completionLabel))
529
- ? { satisfied: true }
530
- : { satisfied: false };
741
+ return snap.state === "CLOSED" ? { satisfied: true } : { satisfied: false };
531
742
  },
532
743
  async detectOutcome(state, io) {
533
744
  const snap = await issueSnapshot(state.issue, io);
@@ -553,6 +764,33 @@ export const PHASES = [
553
764
  },
554
765
  ];
555
766
 
767
+ /**
768
+ * forge#3570: confirm a phase's outcome from GitHub ground truth, through the
769
+ * same trusted (`isTrustedComment`) + anchored (header-led, full-line sentinel)
770
+ * marker model the headless engine uses. For callers that only know a phase id
771
+ * (the interactive SubagentStop hook) and must never trust transcript text.
772
+ *
773
+ * `context` / `architect` are not engine phases (forge#3499) but the interactive
774
+ * path still tracks them: committed iff a trusted, anchored `:COMPLETE` comment
775
+ * exists. Unknown phase ids fail. Fetch errors propagate (the caller fails open).
776
+ *
777
+ * @param {string} phaseId
778
+ * @param {object} state
779
+ * @param {object} io
780
+ * @returns {Promise<{status: string, detail?: string, terminalReason?: string, outputs?: object}>}
781
+ */
782
+ export async function detectTrustedOutcome(phaseId, state, io) {
783
+ const phase = PHASES.find((p) => p.id === phaseId);
784
+ if (phase) return phase.detectOutcome(state, io);
785
+ if (phaseId === "context" || phaseId === "architect") {
786
+ const m = PHASE_MARKERS[phaseId];
787
+ const { comments } = await issueMarkers(state.issue, io);
788
+ if (hasMarker(comments, m.header, m.completionMarker)) return { status: "committed", outputs: {} };
789
+ return { status: "failed", detail: `no ${m.completionMarker} marker` };
790
+ }
791
+ return { status: "failed", detail: `unknown phase: ${phaseId}` };
792
+ }
793
+
556
794
  /**
557
795
  * Parse the LAST `REVIEW_RESULT:` block from a phase's final reply (forge#3521).
558
796
  * Untrusted model output: only the fixed keys below are read, via anchored
@@ -580,6 +818,26 @@ async function openPrFor(state, io) {
580
818
  const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
581
819
  try { const a = JSON.parse(out || "[]"); return a[0]?.number ?? null; } catch { return null; }
582
820
  }
821
+ /**
822
+ * forge#3535: the merged PR (if any) that shipped `branch` into `lane`.
823
+ * Build keys off the branch resolved from FORGE:BUILDER:COMPLETE, not
824
+ * `state.branch` (null on a fresh re-run), so this takes the branch explicitly.
825
+ * A PR merged into a different base, a closed-unmerged PR, no PR, or any
826
+ * gh/JSON error yields null: unknown is never "merged" (forge#3504/#3506).
827
+ * Returns `{ number }` with a real integer PR number, else null.
828
+ */
829
+ async function mergedPrForBranch(branch, lane, io) {
830
+ if (!branch || !lane) return null;
831
+ try {
832
+ const out = await io.gh(["pr", "list", "--head", branch, "--state", "all",
833
+ "--json", "number,state,mergedAt,baseRefName"]);
834
+ const a = JSON.parse(out || "[]");
835
+ if (!Array.isArray(a)) return null;
836
+ const hit = a.find((p) => p && typeof p === "object" && Number.isInteger(p.number)
837
+ && (!!p.mergedAt || p.state === "MERGED") && p.baseRefName === lane);
838
+ return hit ? { number: hit.number } : null;
839
+ } catch { return null; }
840
+ }
583
841
  async function prStatusFor(state, io) {
584
842
  const n = await openPrFor(state, io);
585
843
  if (!n) return null;
@@ -588,7 +846,8 @@ async function prStatusFor(state, io) {
588
846
  try { j = JSON.parse(out || "{}"); } catch { return null; }
589
847
  const labels = (j.labels || []).map((l) => l.name || l);
590
848
  return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
591
- needsHuman: labels.includes("needs-human") };
849
+ needsHuman: labels.includes("needs-human"),
850
+ remediating: labels.includes("workflow:remediating") };
592
851
  }
593
852
 
594
853
  /** The engine's transition function: first uncommitted phase whose gate holds. */