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.
- package/bin/engine/phases-args.test.mjs +113 -0
- package/bin/engine/phases.mjs +433 -109
- package/bin/engine/runlog.mjs +21 -1
- package/bin/engine-cli.mjs +14 -2
- package/bin/engine.mjs +150 -19
- package/bin/forgedock.mjs +2 -2
- package/bin/hooks/interactive-engine.mjs +3 -0
- package/bin/labels.json +5 -0
- package/bin/orchestrate-preflight.mjs +1 -1
- package/bin/runner.mjs +111 -12
- package/bin/watch-utils.mjs +1 -0
- package/commands/cleanup.md +1 -1
- package/commands/orchestrate/phase-4-execution.md +58 -24
- package/commands/orchestrate/phase-6-report.md +29 -0
- package/commands/quality-gate.md +96 -12
- package/commands/recover-orphans.md +5 -5
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-agents.md +11 -11
- package/commands/review-pr.md +227 -33
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build/validate.md +58 -7
- package/commands/work-on/build.md +11 -4
- package/commands/work-on/decompose.md +2 -2
- package/commands/work-on/investigate.md +14 -4
- package/commands/work-on/remediate.md +109 -19
- package/commands/work-on/review.md +79 -25
- package/commands/work-on.md +23 -15
- package/docs/WORK-ON-RUNTIME.md +2 -2
- package/docs/spec/label-state-machine.md +6 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/packages/protocol/src/phases.js +27 -1
- package/scripts/check-branch-ancestry.sh +82 -0
- package/scripts/check-branch-ancestry.test.sh +78 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/forge-root.test.sh +36 -15
- package/scripts/review-pr-domain-map.test.sh +87 -0
- package/scripts/spec-no-worktree-script.test.sh +46 -0
- package/scripts/transition-label.sh +3 -1
package/bin/engine/phases.mjs
CHANGED
|
@@ -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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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(
|
|
53
|
-
if (Array.isArray(parsed))
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
175
|
-
*
|
|
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.
|
|
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
|
-
|
|
232
|
-
|
|
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 (
|
|
235
|
-
|
|
236
|
-
|
|
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 {
|
|
261
|
-
if (
|
|
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
|
-
|
|
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 {
|
|
484
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
318
485
|
const branch = resolveBranch(state, comments);
|
|
319
|
-
if (branch &&
|
|
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 {
|
|
326
|
-
const 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)
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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. */
|