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.
- package/bin/engine/phases-args.test.mjs +2 -1
- package/bin/engine/phases.mjs +349 -90
- package/bin/engine/runlog.mjs +25 -2
- package/bin/engine-cli.mjs +4 -1
- package/bin/engine.mjs +72 -19
- package/bin/hooks/interactive-engine.mjs +135 -209
- package/bin/hooks/pre-tool-use.mjs +16 -4
- package/bin/labels.json +10 -0
- package/bin/opencode-adapter.mjs +1 -1
- package/bin/orchestrate-preflight.mjs +1 -1
- package/bin/runner.mjs +258 -23
- package/bin/watch-utils.mjs +1 -0
- package/commands/autopilot.md +6 -6
- package/commands/cleanup.md +8 -5
- package/commands/orchestrate/phase-1-resolve.md +1 -1
- package/commands/orchestrate/phase-3-dependency.md +14 -19
- package/commands/orchestrate/phase-4-execution.md +80 -53
- package/commands/orchestrate/phase-6-report.md +6 -0
- package/commands/orchestrate/safety.md +1 -1
- package/commands/pipeline-health/phase-2-metrics.md +39 -0
- package/commands/pipeline-health/phase-5-report.md +1 -0
- package/commands/pipeline-resume.md +1 -1
- package/commands/pipeline-status.md +2 -2
- package/commands/recover-orphans.md +5 -5
- package/commands/review-pr-agents/api.md +1 -1
- package/commands/review-pr-agents/auth.md +1 -1
- package/commands/review-pr-agents/infra.md +3 -3
- package/commands/review-pr-agents/protocols.md +15 -6
- package/commands/review-pr-agents/scraper.md +1 -1
- package/commands/review-pr-agents/security.md +5 -5
- package/commands/review-pr-agents.md +12 -12
- package/commands/review-pr-staging.md +226 -8
- package/commands/review-pr.md +394 -53
- package/commands/work-on/build/architect.md +36 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build/validate.md +88 -4
- package/commands/work-on/build.md +106 -7
- package/commands/work-on/decompose.md +2 -2
- package/commands/work-on/investigate.md +14 -4
- package/commands/work-on/remediate.md +137 -25
- package/commands/work-on/review.md +25 -9
- package/commands/work-on.md +15 -15
- package/docs/CONFIG.md +29 -0
- package/docs/RELEASE-NOTES.md +45 -0
- package/docs/spec/forge-protocol-v1.md +22 -1
- package/docs/spec/label-state-machine.md +6 -1
- package/docs/spec/review-protocol.md +20 -5
- package/package.json +2 -1
- package/packages/protocol/src/phases.js +21 -0
- package/scripts/check-blast-radius.sh +164 -0
- package/scripts/check-blast-radius.test.sh +183 -0
- package/scripts/check-contract-scope.sh +137 -0
- package/scripts/check-contract-scope.test.sh +83 -0
- package/scripts/check-spec-bash.sh +60 -3
- package/scripts/check-spec-bash.test.sh +56 -0
- package/scripts/check-spec-markers.sh +29 -0
- package/scripts/check-test-wiring.sh +57 -0
- package/scripts/check-test-wiring.test.sh +70 -0
- package/scripts/classify-finding.sh +72 -8
- package/scripts/classify-finding.test.sh +77 -8
- package/scripts/close-dossier-no-main-commit.test.sh +12 -12
- package/scripts/diff-size.test.sh +3 -2
- package/scripts/doctor-pipeline-state.sh +35 -0
- package/scripts/forge-annotation.sh +3 -0
- package/scripts/forge-root.test.sh +103 -13
- package/scripts/forge-run.sh +2 -2
- package/scripts/recovery-claim-live.test.sh +10 -7
- package/scripts/remediate-basesync.test.sh +103 -0
- package/scripts/review-delta.sh +206 -0
- package/scripts/review-delta.test.sh +213 -0
- package/scripts/review-pr-domain-map.test.sh +87 -0
- package/scripts/review-pr-gate2.test.sh +84 -0
- package/scripts/review-pr-staging-scope.test.sh +99 -0
- package/scripts/run-shell-tests.sh +115 -0
- package/scripts/select-fix-targets.sh +17 -5
- 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`);
|
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
|
}
|
|
@@ -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(
|
|
308
|
+
function buildChildEvidence(comments) {
|
|
233
309
|
const trivial = complexityBand(comments) === "TRIVIAL";
|
|
234
|
-
const state = (
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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 (
|
|
314
|
-
|
|
315
|
-
|
|
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 {
|
|
341
|
-
if (
|
|
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 {
|
|
484
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
363
485
|
const branch = resolveBranch(state, comments);
|
|
364
|
-
if (branch &&
|
|
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 {
|
|
371
|
-
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 …
|
|
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(
|
|
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
|
|
431
|
-
//
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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
|
|
480
|
-
//
|
|
481
|
-
// issue
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
|
|
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
|
|
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
|
|
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. */
|