forgedock 1.12.0 → 1.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/phases-args.test.mjs +2 -1
- package/bin/engine/phases.mjs +295 -78
- package/bin/engine/runlog.mjs +13 -2
- package/bin/engine-cli.mjs +1 -0
- package/bin/engine.mjs +8 -3
- 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 +24 -2
- package/bin/watch-utils.mjs +1 -0
- package/commands/cleanup.md +1 -1
- package/commands/orchestrate/phase-4-execution.md +32 -17
- 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 +155 -24
- package/commands/work-on/build/implement.md +1 -1
- 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 +22 -12
- package/commands/work-on/review.md +25 -9
- package/commands/work-on.md +13 -13
- 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 +21 -0
- package/scripts/forge-root.test.sh +35 -14
- package/scripts/review-pr-domain-map.test.sh +87 -0
- 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,21 @@ export const PHASES = [
|
|
|
427
571
|
if (!pr) return { status: "failed", detail: "no PR created" };
|
|
428
572
|
if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
|
|
429
573
|
// forge#3521: the spec (commands/work-on/review.md) signals an in-PR fix
|
|
430
|
-
// by
|
|
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
580
|
const escalated = { status: "blocked", detail: "review escalated", outputs };
|
|
435
|
-
if (pr.needsHuman) return escalated;
|
|
436
581
|
const rr = parseReviewResult(result?.text);
|
|
582
|
+
// forge#3530: carry the review's remediation kind (router Phase 4R parity) so the
|
|
583
|
+
// remediate phase can post the matching bound marker. Untrusted model output: only
|
|
584
|
+
// the three known kinds are carried verbatim; any other non-empty value is recorded as
|
|
585
|
+
// the "unknown" sentinel (never the raw text) so remediate's buildArgs fails closed.
|
|
586
|
+
if (rr && rr.status === "NEXT" && rr.next === "remediate" && rr.remediation)
|
|
587
|
+
outputs.remediation = isRemediationKind(rr.remediation) ? rr.remediation : "unknown";
|
|
588
|
+
if (pr.needsHuman || pr.remediating) return escalated;
|
|
437
589
|
if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
|
|
438
590
|
// An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
|
|
439
591
|
// (phase trail, ci gate, base conflict, merge refusal) also label the issue
|
|
@@ -441,7 +593,7 @@ export const PHASES = [
|
|
|
441
593
|
if (rr && rr.status) return { status: "failed", detail: "PR open, not merged", retryable: false, handoff: false, outputs };
|
|
442
594
|
let snap = null;
|
|
443
595
|
try { snap = await issueSnapshot(state.issue, io); } catch { snap = null; }
|
|
444
|
-
if (snap?.ok && snap.labels.includes("needs-human")) return escalated;
|
|
596
|
+
if (snap?.ok && (snap.labels.includes("needs-human") || snap.labels.includes("workflow:remediating"))) return escalated;
|
|
445
597
|
return { status: "failed", detail: "PR open, not merged", retryable: false, outputs };
|
|
446
598
|
},
|
|
447
599
|
},
|
|
@@ -461,18 +613,62 @@ export const PHASES = [
|
|
|
461
613
|
// handing the PR to remediation in the same run.
|
|
462
614
|
id: "remediate",
|
|
463
615
|
command: "work-on/remediate",
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
616
|
+
// forge#3530: this is the engine's second implementation of router Phase 4R
|
|
617
|
+
// (commands/work-on.md): post the per-kind bound marker immediately BEFORE
|
|
618
|
+
// dispatching remediation, so remediate.md M0's per-kind guard and review.md R4's
|
|
619
|
+
// loop bounds see it. Fail closed (PhaseArgsError -> engine-error, no runner call)
|
|
620
|
+
// on a missing/unknown kind or a marker that cannot be posted: an unrecorded bound
|
|
621
|
+
// must not run. Keep in sync with Phase 4R.
|
|
622
|
+
buildArgs: async (state, ctx, io) => {
|
|
623
|
+
const pr = need(state.pr, "pr", /^[0-9]+$/);
|
|
624
|
+
const kind = state.remediationKind ?? null;
|
|
625
|
+
const legacyArgs = () => [pr, "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state)];
|
|
626
|
+
// Legacy handoff with no kind (needs-human label fallback, or no REVIEW_RESULT): there
|
|
627
|
+
// is nothing to bind, so dispatch as before. remediate.md M0 then applies its default
|
|
628
|
+
// single-attempt guard. A present-but-unknown kind is never dispatched.
|
|
629
|
+
if (kind === null) return legacyArgs();
|
|
630
|
+
if (!isRemediationKind(kind))
|
|
631
|
+
throw new PhaseArgsError(`unknown remediation kind: ${JSON.stringify(kind)}`);
|
|
632
|
+
const boundMarker = REMEDIATION_BOUND_MARKERS[kind];
|
|
633
|
+
const marker = `<!-- FORGE:${boundMarker}: pr=${pr} -->`;
|
|
634
|
+
try {
|
|
635
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
636
|
+
// Resume idempotency: a handoff interrupted after the marker post must not double-count.
|
|
637
|
+
// forge#3542: only a full-line marker in a trusted comment counts.
|
|
638
|
+
if (!comments.some((b) => hasSentinelLine(b, `FORGE:${boundMarker}: pr=${pr}`))) {
|
|
639
|
+
await io.gh(["issue", "comment", String(state.issue), "--body",
|
|
640
|
+
`${marker}\nReview handed PR #${pr} to remediation (${kind}); the engine is dispatching it once.`]);
|
|
641
|
+
}
|
|
642
|
+
} catch (e) {
|
|
643
|
+
throw new PhaseArgsError(`github-unavailable: could not post FORGE:${boundMarker}; remediation not invoked (${e?.message || e})`);
|
|
644
|
+
}
|
|
645
|
+
return legacyArgs();
|
|
646
|
+
},
|
|
467
647
|
entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
|
|
468
648
|
async detectOutcome(state, io) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
649
|
+
// forge#3530: read the NEWEST completed trail only. A concatenated oldest-first blob
|
|
650
|
+
// returned a stale outcome from an earlier remediation when a later one posted nothing.
|
|
651
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
652
|
+
const completion = PHASE_MARKERS.remediate.completionMarker;
|
|
653
|
+
// forge#3542: trusted, header-led comment with the sentinel as its own line.
|
|
654
|
+
// The trail must also belong to THIS run: when a per-kind bound marker was posted for
|
|
655
|
+
// this PR (buildArgs above), only trails posted AFTER the newest such trusted marker
|
|
656
|
+
// count — a stale earlier trail (or a forged one that predates dispatch) never decides.
|
|
657
|
+
let scoped = comments;
|
|
658
|
+
if (isRemediationKind(state.remediationKind ?? null) && state.pr != null) {
|
|
659
|
+
const bound = `FORGE:${REMEDIATION_BOUND_MARKERS[state.remediationKind]}: pr=${state.pr}`;
|
|
660
|
+
let at = -1;
|
|
661
|
+
comments.forEach((b, i) => { if (hasSentinelLine(b, bound)) at = i; });
|
|
662
|
+
if (at >= 0) scoped = comments.slice(at + 1);
|
|
663
|
+
}
|
|
664
|
+
const latest = latestMarked(scoped, PHASE_MARKERS.remediate.header, completion);
|
|
665
|
+
if (latest === null)
|
|
666
|
+
return { status: "failed", detail: `no ${completion} marker` };
|
|
472
667
|
// Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
|
|
473
668
|
// (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
|
|
474
669
|
// first whitespace-delimited token after the colon).
|
|
475
|
-
const
|
|
670
|
+
const matches = [...latest.matchAll(/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/g)];
|
|
671
|
+
const match = matches.length ? matches[matches.length - 1] : null;
|
|
476
672
|
const reGateOutcome = match ? match[1] : null;
|
|
477
673
|
switch (reGateOutcome) {
|
|
478
674
|
case "AUTO-LANDED":
|
|
@@ -580,6 +776,26 @@ async function openPrFor(state, io) {
|
|
|
580
776
|
const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
|
|
581
777
|
try { const a = JSON.parse(out || "[]"); return a[0]?.number ?? null; } catch { return null; }
|
|
582
778
|
}
|
|
779
|
+
/**
|
|
780
|
+
* forge#3535: the merged PR (if any) that shipped `branch` into `lane`.
|
|
781
|
+
* Build keys off the branch resolved from FORGE:BUILDER:COMPLETE, not
|
|
782
|
+
* `state.branch` (null on a fresh re-run), so this takes the branch explicitly.
|
|
783
|
+
* A PR merged into a different base, a closed-unmerged PR, no PR, or any
|
|
784
|
+
* gh/JSON error yields null: unknown is never "merged" (forge#3504/#3506).
|
|
785
|
+
* Returns `{ number }` with a real integer PR number, else null.
|
|
786
|
+
*/
|
|
787
|
+
async function mergedPrForBranch(branch, lane, io) {
|
|
788
|
+
if (!branch || !lane) return null;
|
|
789
|
+
try {
|
|
790
|
+
const out = await io.gh(["pr", "list", "--head", branch, "--state", "all",
|
|
791
|
+
"--json", "number,state,mergedAt,baseRefName"]);
|
|
792
|
+
const a = JSON.parse(out || "[]");
|
|
793
|
+
if (!Array.isArray(a)) return null;
|
|
794
|
+
const hit = a.find((p) => p && typeof p === "object" && Number.isInteger(p.number)
|
|
795
|
+
&& (!!p.mergedAt || p.state === "MERGED") && p.baseRefName === lane);
|
|
796
|
+
return hit ? { number: hit.number } : null;
|
|
797
|
+
} catch { return null; }
|
|
798
|
+
}
|
|
583
799
|
async function prStatusFor(state, io) {
|
|
584
800
|
const n = await openPrFor(state, io);
|
|
585
801
|
if (!n) return null;
|
|
@@ -588,7 +804,8 @@ async function prStatusFor(state, io) {
|
|
|
588
804
|
try { j = JSON.parse(out || "{}"); } catch { return null; }
|
|
589
805
|
const labels = (j.labels || []).map((l) => l.name || l);
|
|
590
806
|
return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
|
|
591
|
-
needsHuman: labels.includes("needs-human")
|
|
807
|
+
needsHuman: labels.includes("needs-human"),
|
|
808
|
+
remediating: labels.includes("workflow:remediating") };
|
|
592
809
|
}
|
|
593
810
|
|
|
594
811
|
/** The engine's transition function: first uncommitted phase whose gate holds. */
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -57,7 +57,10 @@ export function deriveState(events) {
|
|
|
57
57
|
/** @type {import("./phases.mjs").RunState} */
|
|
58
58
|
const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
|
|
59
59
|
phase: null, branch: null, pr: null, terminal: false,
|
|
60
|
-
terminalReason: null, lease: null, lastRateLimit: null
|
|
60
|
+
terminalReason: null, lease: null, lastRateLimit: null,
|
|
61
|
+
remediationKind: null };
|
|
62
|
+
// forge#3528: handoff reason of the LAST PHASE_COMMIT only (replaced, not sticky).
|
|
63
|
+
let lastCommitReason = null;
|
|
61
64
|
for (const e of events) {
|
|
62
65
|
switch (e.event) {
|
|
63
66
|
case "RUN_START":
|
|
@@ -71,6 +74,11 @@ export function deriveState(events) {
|
|
|
71
74
|
// forge#3504: a blocked phase persists its reason so a resumed run still routes to
|
|
72
75
|
// remediate instead of defaulting to a "merged" close.
|
|
73
76
|
if (e.terminalReason) s.terminalReason = e.terminalReason;
|
|
77
|
+
lastCommitReason = e.terminalReason ?? null;
|
|
78
|
+
// forge#3530: the review's remediation kind must survive resume. A later review
|
|
79
|
+
// commit replaces it; a committed remediate consumes it, so it is never reused.
|
|
80
|
+
if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
|
|
81
|
+
else if (e.phase === "remediate") s.remediationKind = null;
|
|
74
82
|
break;
|
|
75
83
|
case "RUN_TERMINAL":
|
|
76
84
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
@@ -78,8 +86,11 @@ export function deriveState(events) {
|
|
|
78
86
|
// forge#3511: an explicit `run-issue --retry` reopens an engine-error
|
|
79
87
|
// terminal. Bumping `v` makes the local state newer than the stale
|
|
80
88
|
// remote terminal index so reconcile remirrors instead of re-terminating.
|
|
89
|
+
// forge#3528: RUN_TERMINAL overwrote the persisted handoff reason
|
|
90
|
+
// (needs-human / decomposed); restore the last commit's reason so the
|
|
91
|
+
// retry re-enters remediate/decompose instead of close/build.
|
|
81
92
|
case "RUN_REOPEN":
|
|
82
|
-
s.terminal = false; s.terminalReason =
|
|
93
|
+
s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
|
|
83
94
|
break;
|
|
84
95
|
// forge#2524: a session-limit pause is purely informational — it does
|
|
85
96
|
// NOT touch committed/terminal/terminalReason. The phase that hit the
|
package/bin/engine-cli.mjs
CHANGED
package/bin/engine.mjs
CHANGED
|
@@ -359,10 +359,13 @@ export async function runIssue(opts) {
|
|
|
359
359
|
// decompose) and before RUN_TERMINAL leaves its persisted reason on the commit event. Resume
|
|
360
360
|
// must honour that reason instead of picking the next phase (close would throw in buildArgs
|
|
361
361
|
// and end as engine-error; build would run on a decomposed parent). Review's `needs-human`
|
|
362
|
-
// is a handoff to remediate, so only these
|
|
362
|
+
// is a handoff to remediate, so only these committed phases trigger the guard.
|
|
363
363
|
if (state.terminalReason && TERMINAL_REASONS.includes(state.terminalReason) &&
|
|
364
364
|
((state.committed.includes("remediate") && !["merged", "decomposed", "invalid"].includes(state.terminalReason)) ||
|
|
365
|
-
(state.committed.includes("decompose") && state.terminalReason === "decomposed")
|
|
365
|
+
(state.committed.includes("decompose") && state.terminalReason === "decomposed") ||
|
|
366
|
+
// forge#3512: investigate persists `invalid` on its own commit. Only when investigate is
|
|
367
|
+
// the LAST committed phase: a later remediate/close commit carrying `invalid` is a handoff.
|
|
368
|
+
(state.committed[state.committed.length - 1] === "investigate" && state.terminalReason === "invalid")))
|
|
366
369
|
return await terminate(state, state.terminalReason);
|
|
367
370
|
// forge#2352: state-vs-GitHub divergence guard. Every phase's own
|
|
368
371
|
// `entryCondition` only ever checked `state.committed` (local run-log
|
|
@@ -674,7 +677,8 @@ export async function runIssue(opts) {
|
|
|
674
677
|
// is seen), phase.id is "decompose" — not "investigate" — so this
|
|
675
678
|
// exemption does not apply and the normal terminate() path below fires,
|
|
676
679
|
// ending the run for real.
|
|
677
|
-
|
|
680
|
+
// build also hands off: the B5.5 size gate (NEEDS_DECOMPOSE) reports "decomposed".
|
|
681
|
+
const isDecomposeHandoff = (phase.id === "investigate" || phase.id === "build") && terminalReason === "decomposed";
|
|
678
682
|
if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
|
|
679
683
|
!isDecomposeHandoff && !isRemediationHandoff)
|
|
680
684
|
return await terminate(state, terminalReason, outcome.detail);
|
|
@@ -822,6 +826,7 @@ function eventsFromIndex(idx) {
|
|
|
822
826
|
const outputs = {};
|
|
823
827
|
if (phase === "build" && idx.branch) outputs.branch = idx.branch;
|
|
824
828
|
if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
|
|
829
|
+
if (phase === "review" && idx.remediationKind) outputs.remediation = idx.remediationKind;
|
|
825
830
|
events.push({ event: "PHASE_COMMIT", phase, outputs });
|
|
826
831
|
}
|
|
827
832
|
// forge#3506: a non-terminal reason is a handoff (e.g. review -> remediate). Replay it on
|
|
@@ -116,6 +116,9 @@ const PHASE_MARKERS = [
|
|
|
116
116
|
* @param {string} text
|
|
117
117
|
* @returns {{ phaseId: string|null, terminalReason: string|null }}
|
|
118
118
|
*/
|
|
119
|
+
// forge#3542: this scans a session TRANSCRIPT (tool results / assistant text), which carries no
|
|
120
|
+
// comment authorship, so it is outside the headless engine's trusted + anchored marker model
|
|
121
|
+
// (bin/engine/phases.mjs). Advisory phase detection only.
|
|
119
122
|
export function detectPhaseFromText(text) {
|
|
120
123
|
const str = typeof text === "string" ? text : "";
|
|
121
124
|
let phaseId = null;
|