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.
Files changed (78) hide show
  1. package/README.md +8 -2
  2. package/bin/batch-runner.mjs +31 -2
  3. package/bin/engine/admission.mjs +294 -0
  4. package/bin/engine/admission.test.mjs +265 -0
  5. package/bin/engine/invariants.mjs +17 -4
  6. package/bin/engine/phases.mjs +358 -42
  7. package/bin/engine/resolve.mjs +256 -0
  8. package/bin/engine/resolve.test.mjs +149 -0
  9. package/bin/engine/state.mjs +31 -7
  10. package/bin/engine-cli.mjs +138 -2
  11. package/bin/engine.mjs +414 -21
  12. package/bin/forgedock.mjs +552 -44
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +189 -19
  15. package/bin/journey.mjs +109 -11
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +321 -10
  18. package/bin/watch-utils.mjs +255 -0
  19. package/commands/cleanup.md +7 -3
  20. package/commands/forge-stats.md +5 -4
  21. package/commands/issue.md +5 -1
  22. package/commands/orchestrate/config.md +41 -1
  23. package/commands/orchestrate/phase-1-resolve.md +234 -8
  24. package/commands/orchestrate/phase-3-dependency.md +27 -18
  25. package/commands/orchestrate/phase-4-execution.md +223 -22
  26. package/commands/qa-sweep.md +17 -0
  27. package/commands/quality-gate.md +6 -6
  28. package/commands/replay.md +16 -2
  29. package/commands/review-pr-agents/spec-cli.md +179 -0
  30. package/commands/review-pr.md +65 -0
  31. package/commands/upgrade-deps.md +523 -0
  32. package/commands/work-on/build/validate.md +61 -1
  33. package/commands/work-on/build.md +9 -1
  34. package/commands/work-on/decompose.md +10 -1
  35. package/commands/work-on/investigate.md +22 -4
  36. package/commands/work-on/remediate.md +2 -0
  37. package/commands/work-on/review.md +40 -1
  38. package/commands/work-on.md +19 -4
  39. package/docs/AIDER.md +264 -0
  40. package/docs/AUTOPILOT-CRON.md +130 -0
  41. package/docs/CONFIG.md +156 -0
  42. package/docs/CURSOR.md +135 -0
  43. package/docs/OPENCODE.md +312 -0
  44. package/docs/demo.gif +0 -0
  45. package/docs/eval/README.md +4 -4
  46. package/docs/launch/show-hn.md +3 -3
  47. package/docs/site/architecture.md +180 -0
  48. package/docs/site/command-reference.md +17 -0
  49. package/docs/site/configuration.md +243 -0
  50. package/docs/site/for-companies.md +4 -4
  51. package/docs/site/forge-annotation-protocol.md +1 -1
  52. package/docs/site/github-is-the-memory.md +2 -2
  53. package/docs/site/how-it-works.md +1 -1
  54. package/docs/site/public/llms-full.txt +380 -0
  55. package/docs/site/public/llms.txt +35 -0
  56. package/docs/spec/eval-run-result.md +8 -4
  57. package/docs/spec/label-state-machine.md +9 -0
  58. package/docs/spec-graph-schema.md +3 -3
  59. package/package.json +3 -3
  60. package/packages/protocol/README.md +75 -0
  61. package/packages/protocol/src/card.js +114 -0
  62. package/packages/protocol/src/cli.js +4 -77
  63. package/packages/protocol/src/emit.js +5 -11
  64. package/packages/protocol/src/html-comment-escape.js +68 -0
  65. package/packages/protocol/src/index.js +3 -1
  66. package/packages/protocol/src/parse.js +19 -11
  67. package/packages/protocol/src/phases.js +105 -0
  68. package/packages/protocol/src/types.js +18 -1
  69. package/packages/protocol/src/validate.js +11 -0
  70. package/scripts/check-admission-parity.mjs +167 -0
  71. package/scripts/check-command-side-effects.sh +376 -0
  72. package/scripts/check-phase-registry-drift.mjs +96 -0
  73. package/scripts/check-spec-markers.sh +274 -0
  74. package/scripts/classify-lane.sh +29 -7
  75. package/scripts/forge-run.sh +353 -0
  76. package/templates/cross-repo-trigger.yml +282 -0
  77. package/templates/cursor/forge.mdc +148 -0
  78. package/templates/github-actions/forgedock-autopilot.yml +113 -0
@@ -5,29 +5,205 @@
5
5
  * @typedef ... (see plan "Shared types")
6
6
  */
7
7
 
8
- export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed"];
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
- /** Fetch the issue's comment bodies as one blob for marker checks. */
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
- return out || "";
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 the lane base. On the first build the
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(state, io) {
89
+ async function commitsAhead(lane, branch, io) {
22
90
  try {
23
- const n = await io.git(["rev-list", "--count", `origin/${state.lane}..${state.branch}`]);
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 0;
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 m = await issueMarkers(state.issue, io);
39
- if (has(m, "INVESTIGATION:INVALID"))
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(m, "DECOMPOSE:YES"))
217
+ if (has(blob, PHASE_MARKERS.investigate.decomposedMarker))
42
218
  return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
43
- if (has(m, "INVESTIGATION:COMPLETE"))
219
+ if (has(blob, PHASE_MARKERS.investigate.completionMarker))
44
220
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
45
- return { status: "failed", detail: "no INVESTIGATION:COMPLETE marker" };
221
+ return { status: "failed", detail: `no ${PHASE_MARKERS.investigate.completionMarker} marker` };
46
222
  },
47
- isTerminalAfter: (s) => s.terminalReason === "invalid" || s.terminalReason === "decomposed",
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 m = await issueMarkers(state.issue, io);
57
- return has(m, "FORGE:CONTEXT:COMPLETE") ? { satisfied: true } : { satisfied: false };
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 m = await issueMarkers(state.issue, io);
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(m, "FORGE:CONTEXT")) return { status: "committed", outputs: {} };
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 m = await issueMarkers(state.issue, io);
74
- return has(m, "FORGE:ARCHITECT:COMPLETE") ? { satisfied: true } : { satisfied: false };
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 m = await issueMarkers(state.issue, io);
78
- return has(m, "FORGE:ARCHITECT:COMPLETE")
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: "no FORGE:ARCHITECT:COMPLETE" };
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: branch already ahead of base → treat as done, skip the LLM.
89
- if (state.branch && (await commitsAhead(state, io)) > 0) {
90
- const m = await issueMarkers(state.issue, io);
91
- if (has(m, "FORGE:BUILDER:COMPLETE")) return { satisfied: true, outputs: { branch: state.branch } };
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 m = await issueMarkers(state.issue, io);
97
- const complete = has(m, "FORGE:BUILDER:COMPLETE"); // #1305: require :COMPLETE …
98
- const ahead = state.branch ? await commitsAhead(state, io) : 0; // … AND real commits
99
- if (complete && ahead > 0) return { status: "committed", outputs: { branch: state.branch } };
100
- return { status: "failed", detail: `builder complete=${complete} commitsAhead=${ahead}` };
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 out = await io.gh(["issue", "view", String(state.issue), "--json", "state,labels"]);
126
- let j;
127
- try { j = JSON.parse(out || "{}"); } catch { return { satisfied: false }; }
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 out = await io.gh(["issue", "view", String(state.issue), "--json", "state,labels"]);
135
- let j;
136
- try { j = JSON.parse(out || "{}"); } catch { return { status: "failed", detail: "malformed gh response" }; }
137
- const labels = (j.labels || []).map((l) => l.name || l);
138
- if (j.state === "CLOSED" || labels.includes("workflow:merged"))
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,