forgedock 1.11.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/engine/phases-args.test.mjs +113 -0
  2. package/bin/engine/phases.mjs +433 -109
  3. package/bin/engine/runlog.mjs +21 -1
  4. package/bin/engine-cli.mjs +14 -2
  5. package/bin/engine.mjs +150 -19
  6. package/bin/forgedock.mjs +2 -2
  7. package/bin/hooks/interactive-engine.mjs +3 -0
  8. package/bin/labels.json +5 -0
  9. package/bin/orchestrate-preflight.mjs +1 -1
  10. package/bin/runner.mjs +111 -12
  11. package/bin/watch-utils.mjs +1 -0
  12. package/commands/cleanup.md +1 -1
  13. package/commands/orchestrate/phase-4-execution.md +58 -24
  14. package/commands/orchestrate/phase-6-report.md +29 -0
  15. package/commands/quality-gate.md +96 -12
  16. package/commands/recover-orphans.md +5 -5
  17. package/commands/review-pr-agents/protocols.md +1 -1
  18. package/commands/review-pr-agents.md +11 -11
  19. package/commands/review-pr.md +227 -33
  20. package/commands/work-on/build/implement.md +1 -1
  21. package/commands/work-on/build/validate.md +58 -7
  22. package/commands/work-on/build.md +11 -4
  23. package/commands/work-on/decompose.md +2 -2
  24. package/commands/work-on/investigate.md +14 -4
  25. package/commands/work-on/remediate.md +109 -19
  26. package/commands/work-on/review.md +79 -25
  27. package/commands/work-on.md +23 -15
  28. package/docs/WORK-ON-RUNTIME.md +2 -2
  29. package/docs/spec/label-state-machine.md +6 -1
  30. package/docs/spec/review-protocol.md +1 -1
  31. package/package.json +1 -1
  32. package/packages/protocol/src/phases.js +27 -1
  33. package/scripts/check-branch-ancestry.sh +82 -0
  34. package/scripts/check-branch-ancestry.test.sh +78 -0
  35. package/scripts/check-spec-markers.sh +2 -0
  36. package/scripts/forge-root.test.sh +36 -15
  37. package/scripts/review-pr-domain-map.test.sh +87 -0
  38. package/scripts/spec-no-worktree-script.test.sh +46 -0
  39. package/scripts/transition-label.sh +3 -1
@@ -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
  }
@@ -221,20 +297,145 @@ function resolveBranch(state, comments) {
221
297
  return parseBranchFromMarkers(comments) || state.branch || null;
222
298
  }
223
299
 
300
+
301
+ /**
302
+ * forge#3499: non-blocking evidence about the context / architect children that
303
+ * `work-on/build` now runs itself. Recorded for visibility only — a missing
304
+ * marker NEVER fails build (context is advisory; the TRIVIAL band legitimately
305
+ * skips both children). A BLOCKED / missing-arg child is not hidden here: it
306
+ * shows up as `absent`, never as a fabricated "skipped" commit.
307
+ */
308
+ function buildChildEvidence(comments) {
309
+ const trivial = complexityBand(comments) === "TRIVIAL";
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") };
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // forge#3499: per-phase argument construction.
317
+ //
318
+ // Every `work-on` phase sub-skill takes ALL of its inputs as arguments (forge#3398
319
+ // forked-phase router) and stops with BLOCKED when one is missing. Each phase's
320
+ // `buildArgs` emits exactly the flags its `commands/<command>.md` `argument-hint`
321
+ // requires; bin/engine/phases-args.test.mjs pins the two together.
322
+ //
323
+ // Values are validated against strict allowlists before use: the runner joins the
324
+ // args array with single spaces into the prompt, so a value with whitespace,
325
+ // quotes or newlines would splice extra arguments (forge#3466 class).
326
+ // ---------------------------------------------------------------------------
327
+
328
+ export class PhaseArgsError extends Error {
329
+ constructor(message) { super(message); this.name = "PhaseArgsError"; this.code = "PHASE_ARGS_INVALID"; }
330
+ }
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
+
346
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
347
+ const BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._\/-]*$/;
348
+ const LANE_RE = BRANCH_RE;
349
+ const PATH_RE = /^[A-Za-z0-9_.\/\\:@+~-]+$/;
350
+
351
+ function need(value, name, re) {
352
+ if (value === null || value === undefined || value === "") throw new PhaseArgsError(`missing ${name}`);
353
+ const v = String(value);
354
+ if (!re.test(v) || v.includes("..")) throw new PhaseArgsError(`invalid ${name}: ${JSON.stringify(v)}`);
355
+ return v;
356
+ }
357
+ /** @returns {string[]} the `--repo`/`--gh-flag` pair (gh-flag is ONE quoted token, as in the argument-hint). */
358
+ function repoArgs(ctx) {
359
+ const repo = need(ctx.repo, "repo", REPO_RE);
360
+ return ["--repo", repo, "--gh-flag", `"-R ${repo}"`];
361
+ }
362
+ function baseArgs(state) { return ["--base", need(state.lane, "base (lane)", LANE_RE)]; }
363
+
364
+ /**
365
+ * Resolve the linked worktree checked out on `branch` from `git worktree list
366
+ * --porcelain` — the only engine-side ground truth (build.md B1C creates it).
367
+ * Returns null when absent/prunable/unresolvable; never invents a path.
368
+ */
369
+ export async function resolveWorktree(state, io) {
370
+ if (state.worktree) return state.worktree;
371
+ if (!state.branch) return null;
372
+ let out;
373
+ try { out = await io.git(["worktree", "list", "--porcelain"]); } catch { return null; }
374
+ for (const block of String(out || "").split(/\n\s*\n/)) {
375
+ const lines = block.split("\n");
376
+ const wt = lines.find((l) => l.startsWith("worktree "));
377
+ const br = lines.find((l) => l.startsWith("branch "));
378
+ if (!wt || !br || lines.some((l) => l.startsWith("prunable"))) continue;
379
+ if (br.slice("branch ".length).trim() === `refs/heads/${state.branch}`) return wt.slice("worktree ".length).trim();
380
+ }
381
+ return null;
382
+ }
383
+
384
+ async function reviewArgs(state, ctx, io) {
385
+ const worktree = need(await resolveWorktree(state, io), "worktree (no linked worktree for branch)", PATH_RE);
386
+ return [String(state.issue), ...repoArgs(ctx), "--worktree", worktree,
387
+ "--branch", need(state.branch, "branch", BRANCH_RE), ...baseArgs(state)];
388
+ }
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
+
224
413
  /** @type {Phase[]} */
225
414
  export const PHASES = [
226
415
  {
227
416
  id: "investigate",
228
417
  command: "work-on/investigate",
418
+ buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx)],
229
419
  entryCondition: () => true,
230
420
  async detectOutcome(state, io) {
231
- const { blob } = await issueMarkers(state.issue, io);
232
- 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)
233
432
  return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
234
- if (has(blob, PHASE_MARKERS.investigate.decomposedMarker))
235
- return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
236
- 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 } };
237
437
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
438
+ }
238
439
  return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
239
440
  },
240
441
  // forge#2379: no longer terminal after "decomposed" — that reason now
@@ -255,10 +456,11 @@ export const PHASES = [
255
456
  // before the run terminates.
256
457
  id: "decompose",
257
458
  command: "work-on/decompose",
459
+ buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx)],
258
460
  entryCondition: (s) => s.terminalReason === "decomposed",
259
461
  async detectOutcome(state, io) {
260
- const { blob } = await issueMarkers(state.issue, io);
261
- 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))
262
464
  return { status: "committed", terminalReason: "decomposed", outputs: {} };
263
465
  return { status: "failed", detail: `no ${PHASE_MARKERS.decompose.completionMarker} marker` };
264
466
  },
@@ -266,64 +468,35 @@ export const PHASES = [
266
468
  // which runs its own /work-on pipeline — nothing more for THIS run to do.
267
469
  isTerminalAfter: () => true,
268
470
  },
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
471
  {
310
472
  id: "build",
311
473
  command: "work-on/build",
312
- entryCondition: (s) => s.committed.includes("architect"),
474
+ buildArgs: async (state, ctx) => [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)],
475
+ // forge#3499: work-on/build owns worktree creation and runs the context /
476
+ // architect / implement / validate children itself (it needs the worktree
477
+ // as `--repo-path`), so the engine no longer schedules context/architect as
478
+ // top-level phases — build is eligible as soon as investigate committed.
479
+ entryCondition: (s) => s.committed.includes("investigate"),
313
480
  async reconcile(state, io) {
314
481
  // Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
315
482
  // comment) rather than trusting a possibly-stale/absent state.branch, then
316
483
  // check it's already ahead of base → treat as done, skip the LLM (forge#2174).
317
- const { blob, comments } = await issueMarkers(state.issue, io);
484
+ const { comments } = await issueMarkers(state.issue, io);
318
485
  const branch = resolveBranch(state, comments);
319
- 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) {
320
487
  return { satisfied: true, outputs: { branch } };
321
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
+ }
322
495
  return { satisfied: false };
323
496
  },
324
497
  async detectOutcome(state, io) {
325
- const { blob, comments } = await issueMarkers(state.issue, io);
326
- 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 …
327
500
  // Resolve the branch the builder actually created from the FORGE:BUILDER:COMPLETE
328
501
  // comment (ground truth), scoped to that specific comment — see
329
502
  // resolveBranch()/parseBranchFromMarkers() above (forge#2174, forge#2184).
@@ -334,7 +507,16 @@ export const PHASES = [
334
507
  // from a genuine git-confirmed zero and would wrongly trip the
335
508
  // non-retryable guard below on the very first attempt.
336
509
  const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
337
- if (complete && ahead > 0) return { status: "committed", outputs: { branch } };
510
+ if (complete && ahead > 0) {
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 } : {} };
519
+ }
338
520
  const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
339
521
  // forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
340
522
  // but the resolved (real, ground-truth) branch has zero commits ahead of
@@ -362,6 +544,15 @@ export const PHASES = [
362
544
  // resolve, so both must stay retryable. Only a successfully-computed
363
545
  // ahead of 0 on a *resolved* branch (a real "nothing new to commit"
364
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
+ }
365
556
  if (complete && ahead !== -1) return { status: "failed", detail, retryable: false };
366
557
  return { status: "failed", detail };
367
558
  },
@@ -369,17 +560,41 @@ export const PHASES = [
369
560
  {
370
561
  id: "review",
371
562
  command: "work-on/review",
563
+ buildArgs: reviewArgs,
372
564
  entryCondition: (s) => s.committed.includes("build"),
373
565
  async reconcile(state, io) {
374
566
  const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
375
567
  return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
376
568
  },
377
- async detectOutcome(state, io) {
569
+ async detectOutcome(state, io, result) {
378
570
  const pr = await prStatusFor(state, io);
379
571
  if (!pr) return { status: "failed", detail: "no PR created" };
380
572
  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 };
573
+ // forge#3521: the spec (commands/work-on/review.md) signals an in-PR fix
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.
578
+ // The PR number from GitHub (openPrFor) always wins over parsed text.
579
+ const outputs = { pr: pr.number };
580
+ const escalated = { status: "blocked", detail: "review escalated", outputs };
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;
589
+ if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
590
+ // An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
591
+ // (phase trail, ci gate, base conflict, merge refusal) also label the issue
592
+ // needs-human but are NOT remediation handoffs, so skip the label fallback.
593
+ if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs };
594
+ let snap = null;
595
+ try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
596
+ if (snap?.ok && (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating"))) return escalated;
597
+ return { status: "failed", detail: "PR open, not merged", retryable: false, outputs };
383
598
  },
384
599
  },
385
600
  {
@@ -398,15 +613,62 @@ export const PHASES = [
398
613
  // handing the PR to remediation in the same run.
399
614
  id: "remediate",
400
615
  command: "work-on/remediate",
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
+ },
401
647
  entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
402
648
  async detectOutcome(state, io) {
403
- const { blob } = await issueMarkers(state.issue, io);
404
- if (!has(blob, PHASE_MARKERS.remediate.completionMarker))
405
- 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` };
406
667
  // Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
407
668
  // (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
408
669
  // first whitespace-delimited token after the colon).
409
- 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;
410
672
  const reGateOutcome = match ? match[1] : null;
411
673
  switch (reGateOutcome) {
412
674
  case "AUTO-LANDED":
@@ -435,6 +697,25 @@ export const PHASES = [
435
697
  {
436
698
  id: "close",
437
699
  command: "work-on/close",
700
+ buildArgs: async (state, ctx, io) => {
701
+ // Only --terminal-state is required by close.md; the rest are optional context.
702
+ // forge#3506: fail closed — `merged` only from a null/merged reason. A residual
703
+ // non-merged handoff reason (needs-human, awaiting-merge, engine-error, ...) must never
704
+ // be closed as merged.
705
+ const reason = state.terminalReason ?? null;
706
+ if (reason !== null && !["merged", "decomposed", "invalid"].includes(reason))
707
+ throw new PhaseArgsError(`refusing close --terminal-state merged from terminalReason ${JSON.stringify(reason)}`);
708
+ const terminal = reason === "decomposed" ? "decomposed"
709
+ : reason === "invalid" ? "invalid" : "merged";
710
+ const args = [String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
711
+ // forge#3504: a merged close with no PR is fabricated evidence — require it.
712
+ if (state.pr != null || terminal === "merged") args.push("--pr", need(state.pr, "pr", /^[0-9]+$/));
713
+ if (state.branch) args.push("--branch", need(state.branch, "branch", BRANCH_RE));
714
+ const wt = await resolveWorktree(state, io);
715
+ if (wt && PATH_RE.test(wt)) args.push("--worktree", wt);
716
+ args.push("--terminal-state", terminal);
717
+ return args;
718
+ },
438
719
  entryCondition: (s) => s.committed.includes("review"),
439
720
  async reconcile(state, io) {
440
721
  // Idempotent resume: issue already closed or workflow:merged label set → skip the LLM re-run.
@@ -468,11 +749,53 @@ export const PHASES = [
468
749
  },
469
750
  ];
470
751
 
752
+ /**
753
+ * Parse the LAST `REVIEW_RESULT:` block from a phase's final reply (forge#3521).
754
+ * Untrusted model output: only the fixed keys below are read, via anchored
755
+ * line matches; `pr_number` must be digits-only. Returns null when absent.
756
+ */
757
+ export function parseReviewResult(text) {
758
+ if (typeof text !== "string" || !text) return null;
759
+ const lines = text.split(/\r?\n/);
760
+ let start = -1;
761
+ for (let i = 0; i < lines.length; i++) if (/^\s*REVIEW_RESULT:\s*$/.test(lines[i])) start = i;
762
+ if (start < 0) return null;
763
+ const out = {};
764
+ for (let i = start + 1; i < lines.length; i++) {
765
+ const m = /^\s+(status|next|remediation|pr_number):[ \t]*(\S*)[ \t]*$/.exec(lines[i]);
766
+ if (m) { if (!(m[1] in out)) out[m[1]] = m[2]; continue; }
767
+ if (/^\s+[A-Za-z_]+:/.test(lines[i]) || !lines[i].trim()) continue;
768
+ break;
769
+ }
770
+ if (out.pr_number !== undefined && !/^[0-9]+$/.test(out.pr_number)) delete out.pr_number;
771
+ return out;
772
+ }
773
+
471
774
  async function openPrFor(state, io) {
472
775
  if (!state.branch) return null;
473
776
  const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
474
777
  try { const a = JSON.parse(out || "[]"); return a[0]?.number ?? null; } catch { return null; }
475
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
+ }
476
799
  async function prStatusFor(state, io) {
477
800
  const n = await openPrFor(state, io);
478
801
  if (!n) return null;
@@ -481,7 +804,8 @@ async function prStatusFor(state, io) {
481
804
  try { j = JSON.parse(out || "{}"); } catch { return null; }
482
805
  const labels = (j.labels || []).map((l) => l.name || l);
483
806
  return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
484
- needsHuman: labels.includes("needs-human") };
807
+ needsHuman: labels.includes("needs-human"),
808
+ remediating: labels.includes("workflow:remediating") };
485
809
  }
486
810
 
487
811
  /** The engine's transition function: first uncommitted phase whose gate holds. */