forgedock 1.2.0 → 1.4.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/README.md +8 -2
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +358 -42
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +31 -7
- package/bin/engine-cli.mjs +138 -2
- package/bin/engine.mjs +414 -21
- package/bin/forgedock.mjs +552 -44
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +189 -19
- package/bin/journey.mjs +109 -11
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +223 -22
- package/commands/qa-sweep.md +17 -0
- package/commands/quality-gate.md +6 -6
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +65 -0
- package/commands/upgrade-deps.md +523 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +10 -1
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +156 -0
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +17 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +3 -3
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +5 -11
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +3 -1
- package/packages/protocol/src/parse.js +19 -11
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +18 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +376 -0
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +274 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +353 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- package/templates/github-actions/forgedock-autopilot.yml +113 -0
package/bin/engine/phases.mjs
CHANGED
|
@@ -5,29 +5,205 @@
|
|
|
5
5
|
* @typedef ... (see plan "Shared types")
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// forge#2378: marker/label strings are single-sourced from packages/protocol's
|
|
9
|
+
// phase registry (itself derived from RESERVED_TYPES' completionSentinel fields
|
|
10
|
+
// where available) — do NOT reintroduce inline "FORGE:..."/"INVESTIGATION:..."/
|
|
11
|
+
// "workflow:merged" literals in this file. bin/hooks/interactive-engine.mjs
|
|
12
|
+
// imports the identical registry, so the two can no longer drift apart the way
|
|
13
|
+
// they did in forge#2375/PR#2395.
|
|
14
|
+
import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
|
|
9
15
|
|
|
10
|
-
|
|
16
|
+
// forge#2261: "engine-error" is a distinct terminal reason for engine/tool-level
|
|
17
|
+
// failures (e.g. an exhausted retry loop where the runner itself never once
|
|
18
|
+
// succeeded, or a fail-fast CLI_BACKEND_FAILED/NO_API_KEY/NO_SDK throw) — kept
|
|
19
|
+
// separate from "needs-human" so it is never misclassified as a genuine
|
|
20
|
+
// human-judgment block by /orchestrate's classify_predecessor_state().
|
|
21
|
+
//
|
|
22
|
+
// forge#2379: "awaiting-merge" mirrors the `workflow:awaiting-merge` label
|
|
23
|
+
// commands/work-on/remediate.md's Phase M8 sets on a HELD-AWAITING-MERGE
|
|
24
|
+
// re-gate outcome (a clean re-review that didn't clear the #1809 Q1 auto-land
|
|
25
|
+
// bar) — already recognized as a terminal state by work-on.md's Universal
|
|
26
|
+
// Phase Dispatcher; this just gives the `remediate` phase's detectOutcome a
|
|
27
|
+
// matching engine-level terminal reason to report instead of overloading
|
|
28
|
+
// "needs-human" (which would misrepresent a clean-but-unmet-bar re-review as
|
|
29
|
+
// a fresh human-judgment escalation).
|
|
30
|
+
export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed", "engine-error", "awaiting-merge"];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fetch the issue's comments. Returns both:
|
|
34
|
+
* - `blob`: all bodies joined into one string, for simple marker-presence checks
|
|
35
|
+
* (`has(blob, marker)`) where it doesn't matter which comment posted the marker.
|
|
36
|
+
* - `comments`: an array of individual comment bodies, preserving per-comment
|
|
37
|
+
* boundaries, for extraction that MUST be scoped to a specific comment (see
|
|
38
|
+
* `parseBranchFromMarkers()` below — forge#2184).
|
|
39
|
+
*
|
|
40
|
+
* The `--jq '[.[].body]'` query asks `gh` for a JSON array of bodies. If the
|
|
41
|
+
* response isn't valid JSON (a non-JSON gh error string, or a test mock that
|
|
42
|
+
* supplies a raw marker string instead of the real API shape), fall back to
|
|
43
|
+
* treating the whole blob as a single pseudo-comment — `has()` checks are
|
|
44
|
+
* unaffected either way, and comment-scoped extraction simply won't match,
|
|
45
|
+
* which is the safe, conservative behavior.
|
|
46
|
+
*/
|
|
11
47
|
async function issueMarkers(issue, io) {
|
|
12
|
-
const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", ".[].body"]);
|
|
13
|
-
|
|
48
|
+
const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", "[.[].body]"]);
|
|
49
|
+
const blob = out || "";
|
|
50
|
+
let comments = [];
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(out);
|
|
53
|
+
if (Array.isArray(parsed)) {
|
|
54
|
+
comments = parsed.map((c) => (typeof c === "string" ? c : (c && c.body) || ""));
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
comments = blob ? [blob] : [];
|
|
58
|
+
}
|
|
59
|
+
return { blob, comments };
|
|
14
60
|
}
|
|
15
61
|
/**
|
|
16
|
-
* Count commits on branch ahead of
|
|
62
|
+
* Count commits on `branch` ahead of `lane`'s base. On the first build the
|
|
17
63
|
* branch does not exist yet, so real git rejects the ref range — swallow
|
|
18
64
|
* that (and any other git failure) as "0 ahead" rather than letting it
|
|
19
65
|
* propagate and crash runIssue (C1).
|
|
66
|
+
*
|
|
67
|
+
* Takes explicit `lane`/`branch` args (rather than reading them off `state`)
|
|
68
|
+
* so every call site is forced to resolve the branch it means to check —
|
|
69
|
+
* see `resolveBranch()` below (forge#2174: the previous `state.branch`-only
|
|
70
|
+
* signature let the build phase evaluate this against a guessed branch name
|
|
71
|
+
* that never matched the branch the builder actually created).
|
|
72
|
+
*
|
|
73
|
+
* Returns -1 (rather than 0) when the underlying `git` call itself failed
|
|
74
|
+
* (lock contention, transient I/O error, ref not yet fetched, etc.) — distinct
|
|
75
|
+
* from a genuine, successfully-computed 0. More generally, -1 means "this
|
|
76
|
+
* count was not computed" — that includes both a git failure here AND a
|
|
77
|
+
* caller that had no resolvable branch to check in the first place (forge#2211:
|
|
78
|
+
* `detectOutcome` mirrors this same -1 sentinel for its unresolved-branch case
|
|
79
|
+
* rather than synthesizing its own 0). This distinction matters to the
|
|
80
|
+
* build phase's `detectOutcome` (forge#2176): a *genuine* 0 ahead (git ran
|
|
81
|
+
* cleanly and reported no new commits) is a stable fixed point safe to mark
|
|
82
|
+
* non-retryable, but a transient git error — or a not-yet-resolved branch —
|
|
83
|
+
* folded into the same 0 would not be — the very next attempt could see a
|
|
84
|
+
* different, computed result with no external input having changed, so it
|
|
85
|
+
* must remain retryable. Callers that only compare `> 0` (reconcile()'s
|
|
86
|
+
* satisfied check) are unaffected: -1 is still not `> 0`, so existing
|
|
87
|
+
* behavior there is unchanged.
|
|
20
88
|
*/
|
|
21
|
-
async function commitsAhead(
|
|
89
|
+
async function commitsAhead(lane, branch, io) {
|
|
22
90
|
try {
|
|
23
|
-
const n = await io.git(["rev-list", "--count", `origin/${
|
|
91
|
+
const n = await io.git(["rev-list", "--count", `origin/${lane}..${branch}`]);
|
|
24
92
|
return parseInt(String(n).trim(), 10) || 0;
|
|
25
93
|
} catch {
|
|
26
|
-
return
|
|
94
|
+
return -1;
|
|
27
95
|
}
|
|
28
96
|
}
|
|
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
|
+
*/
|
|
29
117
|
function has(blob, marker) { return blob.includes(marker); }
|
|
30
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Fetch the issue's live `state` (OPEN/CLOSED) and `labels` in one call.
|
|
121
|
+
*
|
|
122
|
+
* This is the single data source for two consumers (forge#2352):
|
|
123
|
+
* - the `close` phase's `reconcile`/`detectOutcome` below (which already made
|
|
124
|
+
* this exact call inline before this helper existed — factored out here so
|
|
125
|
+
* both call sites share one shape instead of drifting independently);
|
|
126
|
+
* - the divergence guard in `bin/engine.mjs`'s `runIssue()` phase loop, which
|
|
127
|
+
* calls this once per loop iteration (before running any phase other than
|
|
128
|
+
* `close`) to detect an issue that was closed / labeled `workflow:invalid`
|
|
129
|
+
* / labeled `needs-human` out from under an in-flight run.
|
|
130
|
+
*
|
|
131
|
+
* Returns `{ ok: false, state: null, labels: [] }` on any fetch/parse failure
|
|
132
|
+
* — callers must treat `ok: false` as "could not determine, do not act on
|
|
133
|
+
* this" rather than "issue has no labels/is not closed". This mirrors the
|
|
134
|
+
* existing fail-open behavior `close`'s `reconcile` already had (a `gh`
|
|
135
|
+
* failure there degrades to "not satisfied", never to a false positive).
|
|
136
|
+
*/
|
|
137
|
+
export async function issueSnapshot(issue, io) {
|
|
138
|
+
const out = await io.gh(["issue", "view", String(issue), "--json", "state,labels"]);
|
|
139
|
+
let j;
|
|
140
|
+
try {
|
|
141
|
+
j = JSON.parse(out || "{}");
|
|
142
|
+
} catch {
|
|
143
|
+
return { ok: false, state: null, labels: [] };
|
|
144
|
+
}
|
|
145
|
+
const labels = (j.labels || []).map((l) => l.name || l);
|
|
146
|
+
return { ok: true, state: j.state || null, labels };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse the real branch name out of the `FORGE:BUILDER` comment's
|
|
151
|
+
* `**Branch**: \`{BRANCH}\`` field (see `commands/work-on/build/implement.md`
|
|
152
|
+
* Phase I6 — this is the exact format the builder posts). Ground truth for
|
|
153
|
+
* "what branch did the builder actually create" — the engine has no other
|
|
154
|
+
* reliable source, since the branch name is slug-derived from the issue
|
|
155
|
+
* title and cannot be guessed or precomputed (forge#2174).
|
|
156
|
+
*
|
|
157
|
+
* SCOPING (forge#2184): only comments whose body contains `FORGE:BUILDER:COMPLETE`
|
|
158
|
+
* — the same completion marker the build phase already gates on — are eligible
|
|
159
|
+
* to supply the branch. A `**Branch**:` field inside any other comment (a
|
|
160
|
+
* FORGE:CONTRACT, FORGE:ARCHITECT, FORGE:CONTEXT, reviewer, or remediation
|
|
161
|
+
* comment) is never considered, even if it happens to match the same regex
|
|
162
|
+
* shape. If more than one FORGE:BUILDER:COMPLETE comment exists (e.g. a
|
|
163
|
+
* resumed/retried build re-posting a fresh completion comment), the LAST one
|
|
164
|
+
* — by array/chronological order — wins, so the most recent build attempt's
|
|
165
|
+
* branch is used. Returns null (never invents a value) if no eligible comment
|
|
166
|
+
* contains the field.
|
|
167
|
+
*
|
|
168
|
+
* WITHIN-COMMENT FIELD ORDER (forge#2193 — investigated, no change): once the
|
|
169
|
+
* winning comment is selected (comment-level last-match, above — settled by
|
|
170
|
+
* forge#2184, do not conflate with this paragraph), `body.match(re)` returns
|
|
171
|
+
* the FIRST `**Branch**:` occurrence in that comment, because `re` has no
|
|
172
|
+
* `/g` flag. This is intentional, not an oversight: there is exactly one
|
|
173
|
+
* producer of this field — `commands/work-on/build/implement.md` Phase I6 —
|
|
174
|
+
* which posts `**Branch**: \`{BRANCH}\`` exactly once per FORGE:BUILDER
|
|
175
|
+
* comment. `FORGE:BUILDER:COMPLETE` is appended IN PLACE to that same
|
|
176
|
+
* existing comment by `commands/work-on/build/validate.md` Phase V5 (an edit,
|
|
177
|
+
* not a new comment), so no code path in this pipeline ever produces two
|
|
178
|
+
* `**Branch**:` fields inside one FORGE:BUILDER:COMPLETE-eligible comment.
|
|
179
|
+
* First-match and last-match are therefore equivalent for every real input;
|
|
180
|
+
* first-match is kept because it's the simpler default. If a future producer
|
|
181
|
+
* ever posts more than one `**Branch**:` field in a single eligible comment,
|
|
182
|
+
* this will silently keep returning the first one — revisit this comment
|
|
183
|
+
* before changing that invariant.
|
|
184
|
+
*/
|
|
185
|
+
function parseBranchFromMarkers(comments) {
|
|
186
|
+
const re = /\*\*Branch\*\*:\s*`([^`]+)`/;
|
|
187
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
188
|
+
const body = comments[i];
|
|
189
|
+
if (!body || !body.includes(PHASE_MARKERS.build.completionMarker)) continue;
|
|
190
|
+
const match = body.match(re);
|
|
191
|
+
if (match) return match[1];
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve the branch to evaluate the build phase against: ground truth from
|
|
198
|
+
* the FORGE:BUILDER:COMPLETE comment if present (see `parseBranchFromMarkers()`
|
|
199
|
+
* for the exact scoping rule), else whatever `state.branch` already holds
|
|
200
|
+
* (e.g. a real branch carried forward from a prior PHASE_COMMIT — see
|
|
201
|
+
* `runlog.mjs:deriveState`). Never invents a value.
|
|
202
|
+
*/
|
|
203
|
+
function resolveBranch(state, comments) {
|
|
204
|
+
return parseBranchFromMarkers(comments) || state.branch || null;
|
|
205
|
+
}
|
|
206
|
+
|
|
31
207
|
/** @type {Phase[]} */
|
|
32
208
|
export const PHASES = [
|
|
33
209
|
{
|
|
@@ -35,16 +211,43 @@ export const PHASES = [
|
|
|
35
211
|
command: "work-on/investigate",
|
|
36
212
|
entryCondition: () => true,
|
|
37
213
|
async detectOutcome(state, io) {
|
|
38
|
-
const
|
|
39
|
-
if (has(
|
|
214
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
215
|
+
if (has(blob, PHASE_MARKERS.investigate.invalidMarker))
|
|
40
216
|
return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
|
|
41
|
-
if (has(
|
|
217
|
+
if (has(blob, PHASE_MARKERS.investigate.decomposedMarker))
|
|
42
218
|
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
43
|
-
if (has(
|
|
219
|
+
if (has(blob, PHASE_MARKERS.investigate.completionMarker))
|
|
44
220
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
45
|
-
return { status: "failed", detail:
|
|
221
|
+
return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
|
|
46
222
|
},
|
|
47
|
-
|
|
223
|
+
// forge#2379: no longer terminal after "decomposed" — that reason now
|
|
224
|
+
// hands off to the "decompose" phase below (see bin/engine.mjs's
|
|
225
|
+
// runIssue(), which special-cases exactly this phase/reason combination
|
|
226
|
+
// to skip its own immediate-terminate check and let pickPhase run again).
|
|
227
|
+
// "invalid" is unaffected — investigate.md never posts anything further
|
|
228
|
+
// after INVESTIGATION:INVALID, so that path still terminates in place.
|
|
229
|
+
isTerminalAfter: (s) => s.terminalReason === "invalid",
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
// forge#2379: "decompose" was previously only a terminal reason on
|
|
233
|
+
// investigate (the run stopped the instant DECOMPOSE:YES was seen) —
|
|
234
|
+
// work-on/decompose (sub-issue fan-out, FORGE:DECOMPOSED posting) was
|
|
235
|
+
// never actually dispatched by the engine. This phase closes that gap:
|
|
236
|
+
// entryCondition fires exactly when investigate's own outcome signaled
|
|
237
|
+
// decompose, so pickPhase now genuinely dispatches work-on/decompose
|
|
238
|
+
// before the run terminates.
|
|
239
|
+
id: "decompose",
|
|
240
|
+
command: "work-on/decompose",
|
|
241
|
+
entryCondition: (s) => s.terminalReason === "decomposed",
|
|
242
|
+
async detectOutcome(state, io) {
|
|
243
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
244
|
+
if (has(blob, PHASE_MARKERS.decompose.completionMarker))
|
|
245
|
+
return { status: "committed", terminalReason: "decomposed", outputs: {} };
|
|
246
|
+
return { status: "failed", detail: `no ${PHASE_MARKERS.decompose.completionMarker} marker` };
|
|
247
|
+
},
|
|
248
|
+
// Always terminal: decomposition spawns independent sub-issues, each of
|
|
249
|
+
// which runs its own /work-on pipeline — nothing more for THIS run to do.
|
|
250
|
+
isTerminalAfter: () => true,
|
|
48
251
|
},
|
|
49
252
|
{
|
|
50
253
|
id: "context",
|
|
@@ -53,13 +256,13 @@ export const PHASES = [
|
|
|
53
256
|
async reconcile(state, io) {
|
|
54
257
|
// Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
|
|
55
258
|
// Bare FORGE:CONTEXT matches a partial/interrupted annotation — require :COMPLETE.
|
|
56
|
-
const
|
|
57
|
-
return has(
|
|
259
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
260
|
+
return has(blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
58
261
|
},
|
|
59
262
|
async detectOutcome(state, io) {
|
|
60
|
-
const
|
|
263
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
61
264
|
// Context is non-critical: a missing marker is a VISIBLE skip, not a hard fail (spec §7).
|
|
62
|
-
if (has(
|
|
265
|
+
if (has(blob, PHASE_MARKERS.context.presenceMarker)) return { status: "committed", outputs: {} };
|
|
63
266
|
return { status: "committed", outputs: { skipped: true, which: "context" } };
|
|
64
267
|
},
|
|
65
268
|
},
|
|
@@ -70,14 +273,14 @@ export const PHASES = [
|
|
|
70
273
|
async reconcile(state, io) {
|
|
71
274
|
// Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
|
|
72
275
|
// Bare FORGE:ARCHITECT matches a partial/interrupted annotation — require :COMPLETE.
|
|
73
|
-
const
|
|
74
|
-
return has(
|
|
276
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
277
|
+
return has(blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
75
278
|
},
|
|
76
279
|
async detectOutcome(state, io) {
|
|
77
|
-
const
|
|
78
|
-
return has(
|
|
280
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
281
|
+
return has(blob, PHASE_MARKERS.architect.completionMarker)
|
|
79
282
|
? { status: "committed", outputs: {} }
|
|
80
|
-
: { status: "failed", detail:
|
|
283
|
+
: { status: "failed", detail: `no ${PHASE_MARKERS.architect.completionMarker}` };
|
|
81
284
|
},
|
|
82
285
|
},
|
|
83
286
|
{
|
|
@@ -85,19 +288,59 @@ export const PHASES = [
|
|
|
85
288
|
command: "work-on/build",
|
|
86
289
|
entryCondition: (s) => s.committed.includes("architect"),
|
|
87
290
|
async reconcile(state, io) {
|
|
88
|
-
// Idempotent resume:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
291
|
+
// Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
|
|
292
|
+
// comment) rather than trusting a possibly-stale/absent state.branch, then
|
|
293
|
+
// check it's already ahead of base → treat as done, skip the LLM (forge#2174).
|
|
294
|
+
const { blob, comments } = await issueMarkers(state.issue, io);
|
|
295
|
+
const branch = resolveBranch(state, comments);
|
|
296
|
+
if (branch && has(blob, PHASE_MARKERS.build.completionMarker) && (await commitsAhead(state.lane, branch, io)) > 0) {
|
|
297
|
+
return { satisfied: true, outputs: { branch } };
|
|
92
298
|
}
|
|
93
299
|
return { satisfied: false };
|
|
94
300
|
},
|
|
95
301
|
async detectOutcome(state, io) {
|
|
96
|
-
const
|
|
97
|
-
const complete = has(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
302
|
+
const { blob, comments } = await issueMarkers(state.issue, io);
|
|
303
|
+
const complete = has(blob, PHASE_MARKERS.build.completionMarker); // #1305: require :COMPLETE …
|
|
304
|
+
// Resolve the branch the builder actually created from the FORGE:BUILDER:COMPLETE
|
|
305
|
+
// comment (ground truth), scoped to that specific comment — see
|
|
306
|
+
// resolveBranch()/parseBranchFromMarkers() above (forge#2174, forge#2184).
|
|
307
|
+
const branch = resolveBranch(state, comments);
|
|
308
|
+
// forge#2211: an unresolved branch means the commit count was never
|
|
309
|
+
// computed at all — mirror commitsAhead()'s own "-1 = not computed"
|
|
310
|
+
// sentinel here instead of synthesizing a `0`, which is indistinguishable
|
|
311
|
+
// from a genuine git-confirmed zero and would wrongly trip the
|
|
312
|
+
// non-retryable guard below on the very first attempt.
|
|
313
|
+
const ahead = branch ? await commitsAhead(state.lane, branch, io) : -1; // … AND real commits
|
|
314
|
+
if (complete && ahead > 0) return { status: "committed", outputs: { branch } };
|
|
315
|
+
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
316
|
+
// forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
|
|
317
|
+
// but the resolved (real, ground-truth) branch has zero commits ahead of
|
|
318
|
+
// the lane base, this is a stable fixed point, not a transient failure.
|
|
319
|
+
// commands/work-on/build.md's own early-exit (Phase B0) means any
|
|
320
|
+
// subsequent re-invocation of this phase's runner will see
|
|
321
|
+
// FORGE:BUILDER:COMPLETE already present and immediately no-op with
|
|
322
|
+
// `BUILD_RESULT: status: ALREADY_DONE` — it will never touch git again,
|
|
323
|
+
// so `ahead` cannot change without new, out-of-band input (e.g. a human
|
|
324
|
+
// pushing a commit). Retrying is therefore guaranteed to reproduce this
|
|
325
|
+
// exact result; mark it non-retryable so the engine escalates after a
|
|
326
|
+
// single attempt instead of burning the full attempt budget.
|
|
327
|
+
//
|
|
328
|
+
// When `complete` is false, the builder never finished at all (crashed,
|
|
329
|
+
// ran out of iterations, or was interrupted) — that IS worth a fresh
|
|
330
|
+
// retry, so this branch intentionally leaves `retryable` unset
|
|
331
|
+
// (defaults to retryable in bin/engine.mjs's runPhaseWithRetry).
|
|
332
|
+
//
|
|
333
|
+
// `ahead === -1` means the count was never computed — either
|
|
334
|
+
// commitsAhead() itself failed (transient git error) or the branch
|
|
335
|
+
// could not be resolved at all (forge#2211: `resolveBranch()` returned
|
|
336
|
+
// null, e.g. on the very first build attempt before any
|
|
337
|
+
// FORGE:BUILDER:COMPLETE comment names a branch). Neither is a
|
|
338
|
+
// confirmed zero — both are exactly the kind of failure a retry might
|
|
339
|
+
// resolve, so both must stay retryable. Only a successfully-computed
|
|
340
|
+
// ahead of 0 on a *resolved* branch (a real "nothing new to commit"
|
|
341
|
+
// result) is the true fixed point this non-retryable signal targets.
|
|
342
|
+
if (complete && ahead !== -1) return { status: "failed", detail, retryable: false };
|
|
343
|
+
return { status: "failed", detail };
|
|
101
344
|
},
|
|
102
345
|
},
|
|
103
346
|
{
|
|
@@ -116,27 +359,100 @@ export const PHASES = [
|
|
|
116
359
|
return { status: "failed", detail: "PR open, not merged" };
|
|
117
360
|
},
|
|
118
361
|
},
|
|
362
|
+
{
|
|
363
|
+
// forge#2379: `remediate` re-drives a needs-human PR via
|
|
364
|
+
// commands/work-on/remediate.md (checkout → classify FIXABLE/UNFIXABLE →
|
|
365
|
+
// fix → quality-gate → re-review → #1809 auto-land bar → merge-or-hold),
|
|
366
|
+
// finishing with a `FORGE:REMEDIATION`/`FORGE:REMEDIATION:COMPLETE`
|
|
367
|
+
// marker posted to BOTH the PR and this issue (Phase M8), carrying a
|
|
368
|
+
// `**Re-gate outcome**: AUTO-LANDED | HELD-AWAITING-MERGE | RE-ESCALATED
|
|
369
|
+
// | UNFIXABLE` field. This entry registers that outcome vocabulary in the
|
|
370
|
+
// phase table (closing the literal "remediate appears nowhere in the
|
|
371
|
+
// engine" gap) and is fully unit-tested via `pickPhase`/`detectOutcome`.
|
|
372
|
+
//
|
|
373
|
+
// KNOWN LIMITATION (documented, not fixed here — see forge#2379
|
|
374
|
+
// investigation "What We Found"): `review`'s `"blocked"` outcome (the
|
|
375
|
+
// needs-human escalation) causes `bin/engine.mjs`'s `runIssue()` to
|
|
376
|
+
// `terminate()` immediately, before `review` is ever added to
|
|
377
|
+
// `state.committed` — and the divergence guard just above that also
|
|
378
|
+
// pauses before any non-`close` phase once the issue carries
|
|
379
|
+
// `needs-human`. So a single continuous `runIssue()` walk cannot reach
|
|
380
|
+
// this phase's `entryCondition` today. In practice `remediate.md` is
|
|
381
|
+
// (correctly, per `work-on.md` Phase 0A.1) invoked as its own separate
|
|
382
|
+
// top-level entry point (`/work-on <pr> --remediate`), not as a
|
|
383
|
+
// continuation of the original run — this phase entry documents and
|
|
384
|
+
// tests the target state shape for a future run that reconstructs
|
|
385
|
+
// `committed`/`terminalReason` from live GitHub state (e.g. a dedicated
|
|
386
|
+
// remediation-run entry point) rather than from a fresh local run-log.
|
|
387
|
+
// Making that live wiring real is out of this issue's scope — it needs
|
|
388
|
+
// changes to `review`'s `"blocked"` contract and to `bin/tests/engine.test.mjs`,
|
|
389
|
+
// both outside this issue's declared file set.
|
|
390
|
+
id: "remediate",
|
|
391
|
+
command: "work-on/remediate",
|
|
392
|
+
entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
|
|
393
|
+
async detectOutcome(state, io) {
|
|
394
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
395
|
+
if (!has(blob, PHASE_MARKERS.remediate.completionMarker))
|
|
396
|
+
return { status: "failed", detail: `no ${PHASE_MARKERS.remediate.completionMarker} marker` };
|
|
397
|
+
// Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
|
|
398
|
+
// (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
|
|
399
|
+
// first whitespace-delimited token after the colon).
|
|
400
|
+
const match = blob.match(/\*\*Re-gate outcome\*\*:\s*([A-Z-]+)/);
|
|
401
|
+
const reGateOutcome = match ? match[1] : null;
|
|
402
|
+
switch (reGateOutcome) {
|
|
403
|
+
case "AUTO-LANDED":
|
|
404
|
+
// remediate.md's own Phase M8 already drove close in this case
|
|
405
|
+
// (see that file's "If the outcome was AUTO-LANDED" branch) — the
|
|
406
|
+
// issue should already carry workflow:merged by the time this
|
|
407
|
+
// reads, but the terminal reason here is what THIS phase reports,
|
|
408
|
+
// independent of close's own idempotent detectOutcome re-check.
|
|
409
|
+
return { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } };
|
|
410
|
+
case "HELD-AWAITING-MERGE":
|
|
411
|
+
return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
|
|
412
|
+
case "RE-ESCALATED":
|
|
413
|
+
case "UNFIXABLE":
|
|
414
|
+
// Both leave the issue at needs-human (a fresh escalation, or a
|
|
415
|
+
// policy judgment call respectively) — reuse the existing
|
|
416
|
+
// "needs-human" terminal reason rather than inventing two more,
|
|
417
|
+
// matching the #2352/#2353 precedent of reusing an existing
|
|
418
|
+
// TERMINAL_REASONS value where semantically equivalent.
|
|
419
|
+
return { status: "committed", terminalReason: "needs-human", outputs: { reGateOutcome } };
|
|
420
|
+
default:
|
|
421
|
+
return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
isTerminalAfter: () => true,
|
|
425
|
+
},
|
|
119
426
|
{
|
|
120
427
|
id: "close",
|
|
121
428
|
command: "work-on/close",
|
|
122
429
|
entryCondition: (s) => s.committed.includes("review"),
|
|
123
430
|
async reconcile(state, io) {
|
|
124
431
|
// Idempotent resume: issue already closed or workflow:merged label set → skip the LLM re-run.
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const labels = (j.labels || []).map((l) => l.name || l);
|
|
129
|
-
return (j.state === "CLOSED" || labels.includes("workflow:merged"))
|
|
432
|
+
const snap = await issueSnapshot(state.issue, io);
|
|
433
|
+
if (!snap.ok) return { satisfied: false };
|
|
434
|
+
return (snap.state === "CLOSED" || snap.labels.includes(PHASE_MARKERS.close.completionLabel))
|
|
130
435
|
? { satisfied: true }
|
|
131
436
|
: { satisfied: false };
|
|
132
437
|
},
|
|
133
438
|
async detectOutcome(state, io) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
439
|
+
const snap = await issueSnapshot(state.issue, io);
|
|
440
|
+
if (!snap.ok) return { status: "failed", detail: "malformed gh response" };
|
|
441
|
+
// forge#2353: a bare `state === "CLOSED"` is NOT sufficient evidence that
|
|
442
|
+
// a PR actually merged — the divergence guard in bin/engine.mjs (forge#2352)
|
|
443
|
+
// can now route a closed-as-invalid or otherwise closed-not-merged issue
|
|
444
|
+
// into this phase (see that guard's own comment for why `close` is exempt
|
|
445
|
+
// from it), and reporting `terminalReason: "merged"` for that case would
|
|
446
|
+
// inflate run-log/telemetry merge-rate consumers with runs that never
|
|
447
|
+
// shipped a PR. Only `workflow:merged` — the label the review phase's own
|
|
448
|
+
// merge flow sets — is proof of an actual merge. A CLOSED issue without
|
|
449
|
+
// that label is still a real terminal state (nothing left for this phase
|
|
450
|
+
// to do), just not a "merged" one — reuse the existing "invalid" reason
|
|
451
|
+
// (already in TERMINAL_REASONS) rather than inventing a new one.
|
|
452
|
+
if (snap.labels.includes(PHASE_MARKERS.close.completionLabel))
|
|
139
453
|
return { status: "committed", terminalReason: "merged", outputs: {} };
|
|
454
|
+
if (snap.state === "CLOSED")
|
|
455
|
+
return { status: "committed", terminalReason: "invalid", outputs: {} };
|
|
140
456
|
return { status: "failed", detail: "issue not closed" };
|
|
141
457
|
},
|
|
142
458
|
isTerminalAfter: () => true,
|