forgedock 1.2.0 → 1.3.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 (53) hide show
  1. package/README.md +8 -2
  2. package/bin/engine/phases.mjs +178 -28
  3. package/bin/engine/state.mjs +39 -7
  4. package/bin/engine-cli.mjs +63 -2
  5. package/bin/engine.mjs +25 -4
  6. package/bin/forgedock.mjs +235 -4
  7. package/bin/hooks/pre-tool-use.mjs +26 -12
  8. package/bin/journey.mjs +103 -11
  9. package/commands/forge-stats.md +5 -4
  10. package/commands/issue.md +5 -1
  11. package/commands/orchestrate/phase-4-execution.md +2 -0
  12. package/commands/qa-sweep.md +17 -0
  13. package/commands/replay.md +16 -2
  14. package/commands/review-pr-agents/spec-cli.md +179 -0
  15. package/commands/review-pr.md +49 -0
  16. package/commands/upgrade-deps.md +518 -0
  17. package/commands/work-on/build/validate.md +61 -1
  18. package/commands/work-on/build.md +9 -1
  19. package/commands/work-on/decompose.md +8 -1
  20. package/docs/AIDER.md +264 -0
  21. package/docs/AUTOPILOT-CRON.md +130 -0
  22. package/docs/CONFIG.md +36 -0
  23. package/docs/CURSOR.md +135 -0
  24. package/docs/OPENCODE.md +312 -0
  25. package/docs/demo.gif +0 -0
  26. package/docs/eval/README.md +4 -4
  27. package/docs/launch/show-hn.md +3 -3
  28. package/docs/site/architecture.md +180 -0
  29. package/docs/site/command-reference.md +17 -0
  30. package/docs/site/configuration.md +243 -0
  31. package/docs/site/for-companies.md +4 -4
  32. package/docs/site/forge-annotation-protocol.md +1 -1
  33. package/docs/site/github-is-the-memory.md +2 -2
  34. package/docs/site/how-it-works.md +1 -1
  35. package/docs/site/public/llms-full.txt +380 -0
  36. package/docs/site/public/llms.txt +35 -0
  37. package/docs/spec-graph-schema.md +3 -3
  38. package/package.json +2 -2
  39. package/packages/protocol/README.md +75 -0
  40. package/packages/protocol/src/card.js +114 -0
  41. package/packages/protocol/src/cli.js +4 -77
  42. package/packages/protocol/src/emit.js +14 -3
  43. package/packages/protocol/src/index.js +2 -1
  44. package/packages/protocol/src/parse.js +17 -8
  45. package/packages/protocol/src/types.js +1 -1
  46. package/packages/protocol/src/validate.js +11 -0
  47. package/scripts/check-command-side-effects.sh +261 -0
  48. package/scripts/check-spec-markers.sh +273 -0
  49. package/scripts/classify-lane.sh +29 -7
  50. package/scripts/forge-run.sh +322 -0
  51. package/templates/cross-repo-trigger.yml +282 -0
  52. package/templates/cursor/forge.mdc +148 -0
  53. package/templates/github-actions/forgedock-autopilot.yml +113 -0
package/README.md CHANGED
@@ -4,9 +4,13 @@
4
4
 
5
5
  <h1>ForgeDock</h1>
6
6
 
7
- <p><strong>Deterministic orchestration for autonomous software engineering.</strong></p>
7
+ <p><strong>Point it at an issue and get a merged, reviewed PR; point it at a <strong>milestone</strong> and get parallel pipelines with conflict-aware scheduling.</strong></p>
8
8
 
9
- <p>LLMs generate the code. ForgeDock owns everything else — <strong>state, scheduling, recovery, review, and memory</strong> — as durable, inspectable structure on the GitHub you already have. Issues are the queue. PRs are the ledger. Annotations are the memory. Point it at an issue and get a merged, reviewed PR; point it at a <strong>milestone</strong> and get parallel pipelines with conflict-aware scheduling.</p>
9
+ <p>LLMs generate the code. ForgeDock owns everything else — <strong>state, scheduling, recovery, review, and memory</strong> — as durable, inspectable structure on the GitHub you already have. Issues are the queue. PRs are the ledger. Annotations are the memory. Deterministic orchestration for autonomous software engineering.</p>
10
+
11
+ <p><em>ForgeDock (not ForgeRock) — this project is unrelated to ForgeRock's identity and access management platform.</em></p>
12
+
13
+ <p>📖 <a href="https://rapiercraftstudios.github.io/ForgeDock/">Full documentation site</a></p>
10
14
 
11
15
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg" alt="License: AGPL-3.0" /></a>
12
16
  <a href="https://github.com/RapierCraftStudios/ForgeDock/stargazers"><img src="https://img.shields.io/github/stars/RapierCraftStudios/ForgeDock?style=social" alt="GitHub Stars" /></a>
@@ -52,6 +56,8 @@ $ /work-on #1230 "orchestrate: Layer 5 co-change signal is dead code"
52
56
 
53
57
  ### Try it in 30 seconds — on a throwaway repo, nothing to lose
54
58
 
59
+ **Requires:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (subscription or `ANTHROPIC_API_KEY`) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
60
+
55
61
  ```bash
56
62
  npx forgedock demo # spins up a risk-free demo repo and shows you the pipeline end to end
57
63
  ```
@@ -7,27 +7,146 @@
7
7
 
8
8
  export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed"];
9
9
 
10
- /** Fetch the issue's comment bodies as one blob for marker checks. */
10
+ /**
11
+ * Fetch the issue's comments. Returns both:
12
+ * - `blob`: all bodies joined into one string, for simple marker-presence checks
13
+ * (`has(blob, marker)`) where it doesn't matter which comment posted the marker.
14
+ * - `comments`: an array of individual comment bodies, preserving per-comment
15
+ * boundaries, for extraction that MUST be scoped to a specific comment (see
16
+ * `parseBranchFromMarkers()` below — forge#2184).
17
+ *
18
+ * The `--jq '[.[].body]'` query asks `gh` for a JSON array of bodies. If the
19
+ * response isn't valid JSON (a non-JSON gh error string, or a test mock that
20
+ * supplies a raw marker string instead of the real API shape), fall back to
21
+ * treating the whole blob as a single pseudo-comment — `has()` checks are
22
+ * unaffected either way, and comment-scoped extraction simply won't match,
23
+ * which is the safe, conservative behavior.
24
+ */
11
25
  async function issueMarkers(issue, io) {
12
- const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", ".[].body"]);
13
- return out || "";
26
+ const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", "[.[].body]"]);
27
+ const blob = out || "";
28
+ let comments = [];
29
+ try {
30
+ const parsed = JSON.parse(out);
31
+ if (Array.isArray(parsed)) {
32
+ comments = parsed.map((c) => (typeof c === "string" ? c : (c && c.body) || ""));
33
+ }
34
+ } catch {
35
+ comments = blob ? [blob] : [];
36
+ }
37
+ return { blob, comments };
14
38
  }
15
39
  /**
16
- * Count commits on branch ahead of the lane base. On the first build the
40
+ * Count commits on `branch` ahead of `lane`'s base. On the first build the
17
41
  * branch does not exist yet, so real git rejects the ref range — swallow
18
42
  * that (and any other git failure) as "0 ahead" rather than letting it
19
43
  * propagate and crash runIssue (C1).
44
+ *
45
+ * Takes explicit `lane`/`branch` args (rather than reading them off `state`)
46
+ * so every call site is forced to resolve the branch it means to check —
47
+ * see `resolveBranch()` below (forge#2174: the previous `state.branch`-only
48
+ * signature let the build phase evaluate this against a guessed branch name
49
+ * that never matched the branch the builder actually created).
50
+ *
51
+ * Returns -1 (rather than 0) when the underlying `git` call itself failed
52
+ * (lock contention, transient I/O error, ref not yet fetched, etc.) — distinct
53
+ * from a genuine, successfully-computed 0. This distinction matters to the
54
+ * build phase's `detectOutcome` (forge#2176): a *genuine* 0 ahead (git ran
55
+ * cleanly and reported no new commits) is a stable fixed point safe to mark
56
+ * non-retryable, but a transient git error folded into the same 0 would not
57
+ * be — the very next attempt could see a different, non-erroring result with
58
+ * no external input having changed, so it must remain retryable. Callers that
59
+ * only compare `> 0` (reconcile()'s satisfied check) are unaffected: -1 is
60
+ * still not `> 0`, so existing behavior there is unchanged.
20
61
  */
21
- async function commitsAhead(state, io) {
62
+ async function commitsAhead(lane, branch, io) {
22
63
  try {
23
- const n = await io.git(["rev-list", "--count", `origin/${state.lane}..${state.branch}`]);
64
+ const n = await io.git(["rev-list", "--count", `origin/${lane}..${branch}`]);
24
65
  return parseInt(String(n).trim(), 10) || 0;
25
66
  } catch {
26
- return 0;
67
+ return -1;
27
68
  }
28
69
  }
70
+ /**
71
+ * Marker-presence check used throughout this file — including
72
+ * `FORGE:BUILDER:COMPLETE` eligibility gates in the "build" phase's
73
+ * `reconcile`/`detectOutcome` below (forge#2194 — investigated, no change).
74
+ *
75
+ * This is a plain substring test, deliberately, for consistency: every other
76
+ * marker gate in this file (`INVESTIGATION:INVALID`, `DECOMPOSE:YES`,
77
+ * `INVESTIGATION:COMPLETE`, `FORGE:CONTEXT:COMPLETE`,
78
+ * `FORGE:ARCHITECT:COMPLETE`, `workflow:merged`) uses the identical
79
+ * substring/membership technique — singling out `FORGE:BUILDER:COMPLETE`
80
+ * alone for a "structured" parse would be inconsistent and would not close
81
+ * any real gap: the actual trust boundary for issue-comment content is
82
+ * *authorship* (can an untrusted actor post a comment on this issue at all),
83
+ * not *format*. Nothing in this engine validates comment authorship for any
84
+ * marker today, so an actor able to post an arbitrary comment could just as
85
+ * easily post whatever "structured" shape a parser would accept — format
86
+ * hardening alone buys nothing here. If comment-spoofing is ever a concern
87
+ * worth addressing, the fix is an author allowlist applied uniformly to all
88
+ * markers, not a bespoke parser for this one field.
89
+ */
29
90
  function has(blob, marker) { return blob.includes(marker); }
30
91
 
92
+ /**
93
+ * Parse the real branch name out of the `FORGE:BUILDER` comment's
94
+ * `**Branch**: \`{BRANCH}\`` field (see `commands/work-on/build/implement.md`
95
+ * Phase I6 — this is the exact format the builder posts). Ground truth for
96
+ * "what branch did the builder actually create" — the engine has no other
97
+ * reliable source, since the branch name is slug-derived from the issue
98
+ * title and cannot be guessed or precomputed (forge#2174).
99
+ *
100
+ * SCOPING (forge#2184): only comments whose body contains `FORGE:BUILDER:COMPLETE`
101
+ * — the same completion marker the build phase already gates on — are eligible
102
+ * to supply the branch. A `**Branch**:` field inside any other comment (a
103
+ * FORGE:CONTRACT, FORGE:ARCHITECT, FORGE:CONTEXT, reviewer, or remediation
104
+ * comment) is never considered, even if it happens to match the same regex
105
+ * shape. If more than one FORGE:BUILDER:COMPLETE comment exists (e.g. a
106
+ * resumed/retried build re-posting a fresh completion comment), the LAST one
107
+ * — by array/chronological order — wins, so the most recent build attempt's
108
+ * branch is used. Returns null (never invents a value) if no eligible comment
109
+ * contains the field.
110
+ *
111
+ * WITHIN-COMMENT FIELD ORDER (forge#2193 — investigated, no change): once the
112
+ * winning comment is selected (comment-level last-match, above — settled by
113
+ * forge#2184, do not conflate with this paragraph), `body.match(re)` returns
114
+ * the FIRST `**Branch**:` occurrence in that comment, because `re` has no
115
+ * `/g` flag. This is intentional, not an oversight: there is exactly one
116
+ * producer of this field — `commands/work-on/build/implement.md` Phase I6 —
117
+ * which posts `**Branch**: \`{BRANCH}\`` exactly once per FORGE:BUILDER
118
+ * comment. `FORGE:BUILDER:COMPLETE` is appended IN PLACE to that same
119
+ * existing comment by `commands/work-on/build/validate.md` Phase V5 (an edit,
120
+ * not a new comment), so no code path in this pipeline ever produces two
121
+ * `**Branch**:` fields inside one FORGE:BUILDER:COMPLETE-eligible comment.
122
+ * First-match and last-match are therefore equivalent for every real input;
123
+ * first-match is kept because it's the simpler default. If a future producer
124
+ * ever posts more than one `**Branch**:` field in a single eligible comment,
125
+ * this will silently keep returning the first one — revisit this comment
126
+ * before changing that invariant.
127
+ */
128
+ function parseBranchFromMarkers(comments) {
129
+ const re = /\*\*Branch\*\*:\s*`([^`]+)`/;
130
+ for (let i = comments.length - 1; i >= 0; i--) {
131
+ const body = comments[i];
132
+ if (!body || !body.includes("FORGE:BUILDER:COMPLETE")) continue;
133
+ const match = body.match(re);
134
+ if (match) return match[1];
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Resolve the branch to evaluate the build phase against: ground truth from
141
+ * the FORGE:BUILDER:COMPLETE comment if present (see `parseBranchFromMarkers()`
142
+ * for the exact scoping rule), else whatever `state.branch` already holds
143
+ * (e.g. a real branch carried forward from a prior PHASE_COMMIT — see
144
+ * `runlog.mjs:deriveState`). Never invents a value.
145
+ */
146
+ function resolveBranch(state, comments) {
147
+ return parseBranchFromMarkers(comments) || state.branch || null;
148
+ }
149
+
31
150
  /** @type {Phase[]} */
32
151
  export const PHASES = [
33
152
  {
@@ -35,12 +154,12 @@ export const PHASES = [
35
154
  command: "work-on/investigate",
36
155
  entryCondition: () => true,
37
156
  async detectOutcome(state, io) {
38
- const m = await issueMarkers(state.issue, io);
39
- if (has(m, "INVESTIGATION:INVALID"))
157
+ const { blob } = await issueMarkers(state.issue, io);
158
+ if (has(blob, "INVESTIGATION:INVALID"))
40
159
  return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
41
- if (has(m, "DECOMPOSE:YES"))
160
+ if (has(blob, "DECOMPOSE:YES"))
42
161
  return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
43
- if (has(m, "INVESTIGATION:COMPLETE"))
162
+ if (has(blob, "INVESTIGATION:COMPLETE"))
44
163
  return { status: "committed", outputs: { verdict: "CONFIRMED" } };
45
164
  return { status: "failed", detail: "no INVESTIGATION:COMPLETE marker" };
46
165
  },
@@ -53,13 +172,13 @@ export const PHASES = [
53
172
  async reconcile(state, io) {
54
173
  // Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
55
174
  // 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 };
175
+ const { blob } = await issueMarkers(state.issue, io);
176
+ return has(blob, "FORGE:CONTEXT:COMPLETE") ? { satisfied: true } : { satisfied: false };
58
177
  },
59
178
  async detectOutcome(state, io) {
60
- const m = await issueMarkers(state.issue, io);
179
+ const { blob } = await issueMarkers(state.issue, io);
61
180
  // 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: {} };
181
+ if (has(blob, "FORGE:CONTEXT")) return { status: "committed", outputs: {} };
63
182
  return { status: "committed", outputs: { skipped: true, which: "context" } };
64
183
  },
65
184
  },
@@ -70,12 +189,12 @@ export const PHASES = [
70
189
  async reconcile(state, io) {
71
190
  // Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
72
191
  // 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 };
192
+ const { blob } = await issueMarkers(state.issue, io);
193
+ return has(blob, "FORGE:ARCHITECT:COMPLETE") ? { satisfied: true } : { satisfied: false };
75
194
  },
76
195
  async detectOutcome(state, io) {
77
- const m = await issueMarkers(state.issue, io);
78
- return has(m, "FORGE:ARCHITECT:COMPLETE")
196
+ const { blob } = await issueMarkers(state.issue, io);
197
+ return has(blob, "FORGE:ARCHITECT:COMPLETE")
79
198
  ? { status: "committed", outputs: {} }
80
199
  : { status: "failed", detail: "no FORGE:ARCHITECT:COMPLETE" };
81
200
  },
@@ -85,19 +204,50 @@ export const PHASES = [
85
204
  command: "work-on/build",
86
205
  entryCondition: (s) => s.committed.includes("architect"),
87
206
  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 } };
207
+ // Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
208
+ // comment) rather than trusting a possibly-stale/absent state.branch, then
209
+ // check it's already ahead of base → treat as done, skip the LLM (forge#2174).
210
+ const { blob, comments } = await issueMarkers(state.issue, io);
211
+ const branch = resolveBranch(state, comments);
212
+ if (branch && has(blob, "FORGE:BUILDER:COMPLETE") && (await commitsAhead(state.lane, branch, io)) > 0) {
213
+ return { satisfied: true, outputs: { branch } };
92
214
  }
93
215
  return { satisfied: false };
94
216
  },
95
217
  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}` };
218
+ const { blob, comments } = await issueMarkers(state.issue, io);
219
+ const complete = has(blob, "FORGE:BUILDER:COMPLETE"); // #1305: require :COMPLETE …
220
+ // Resolve the branch the builder actually created from the FORGE:BUILDER:COMPLETE
221
+ // comment (ground truth), scoped to that specific comment — see
222
+ // resolveBranch()/parseBranchFromMarkers() above (forge#2174, forge#2184).
223
+ const branch = resolveBranch(state, comments);
224
+ const ahead = branch ? await commitsAhead(state.lane, branch, io) : 0; // … AND real commits
225
+ if (complete && ahead > 0) return { status: "committed", outputs: { branch } };
226
+ const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
227
+ // forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
228
+ // but the resolved (real, ground-truth) branch has zero commits ahead of
229
+ // the lane base, this is a stable fixed point, not a transient failure.
230
+ // commands/work-on/build.md's own early-exit (Phase B0) means any
231
+ // subsequent re-invocation of this phase's runner will see
232
+ // FORGE:BUILDER:COMPLETE already present and immediately no-op with
233
+ // `BUILD_RESULT: status: ALREADY_DONE` — it will never touch git again,
234
+ // so `ahead` cannot change without new, out-of-band input (e.g. a human
235
+ // pushing a commit). Retrying is therefore guaranteed to reproduce this
236
+ // exact result; mark it non-retryable so the engine escalates after a
237
+ // single attempt instead of burning the full attempt budget.
238
+ //
239
+ // When `complete` is false, the builder never finished at all (crashed,
240
+ // ran out of iterations, or was interrupted) — that IS worth a fresh
241
+ // retry, so this branch intentionally leaves `retryable` unset
242
+ // (defaults to retryable in bin/engine.mjs's runPhaseWithRetry).
243
+ //
244
+ // `ahead === -1` means commitsAhead() itself failed (transient git
245
+ // error), NOT a confirmed zero — that is exactly the kind of failure
246
+ // a retry might resolve, so it must stay retryable too. Only a
247
+ // successfully-computed ahead of 0 (a real "nothing new to commit"
248
+ // result) is the true fixed point this non-retryable signal targets.
249
+ if (complete && ahead !== -1) return { status: "failed", detail, retryable: false };
250
+ return { status: "failed", detail };
101
251
  },
102
252
  },
103
253
  {
@@ -8,11 +8,29 @@ const BLOCK_RE = /<!-- FORGE:STATE\s*([\s\S]*?)-->/;
8
8
 
9
9
  /** @param {import("./phases.mjs").RunState} index */
10
10
  export function serializeState(index) {
11
- // Escape both HTML comment-close forms: the standard "-->" and the "--!>"
12
- // variant that browsers/HTML parsers also honour. JSON.stringify does not
13
- // escape either, so a payload value containing them could otherwise close
14
- // the <!-- FORGE:STATE ... --> block early (CodeQL js/bad-tag-filter).
15
- const payload = JSON.stringify(index).replace(/--(!?)>/g, "--$1&gt;");
11
+ // Escape all three HTML comment delimiter forms so a payload value can never
12
+ // open or close an HTML comment in GitHub's renderer:
13
+ // - "<!--" the comment opener — an unescaped opener would start a new
14
+ // (nested-looking) comment, visually swallowing subsequent content
15
+ // (mirrors packages/protocol/src/emit.js's sanitizeFieldValue, forge#1638/#2119)
16
+ // - "-->" and "--!>" the two comment-close forms browsers/HTML parsers
17
+ // honour. JSON.stringify does not escape any of these, so a payload
18
+ // value containing them could otherwise open/close the
19
+ // <!-- FORGE:STATE ... --> block early (CodeQL js/bad-tag-filter).
20
+ //
21
+ // The "&" escape below MUST run first, before the delimiter escapes. Without
22
+ // it, a payload value that already contains literal entity-like text (e.g.
23
+ // "<!--&gt;" or "&lt;!--") collides with the encoded form of an unrelated
24
+ // real delimiter — the encoder is not injective and the collision is
25
+ // unrecoverable on decode (forge#2137). Escaping "&" first guarantees every
26
+ // "&lt;!--"/"--&gt;"/"--!&gt;" sequence present in the output was produced
27
+ // by *this* encode pass, never by pre-existing entity text (which is now
28
+ // doubly-escaped, e.g. "&" -> "&amp;" -> untouched by the delimiter pass
29
+ // that only matches literal "<!--"/"--"+">" character sequences).
30
+ const payload = JSON.stringify(index)
31
+ .replace(/&/g, "&amp;")
32
+ .replace(/<!--/g, "&lt;!--")
33
+ .replace(/--(!?)>/g, "--$1&gt;");
16
34
  return `${OPEN}\n${payload}\n${CLOSE}`;
17
35
  }
18
36
 
@@ -20,8 +38,22 @@ export function serializeState(index) {
20
38
  export function parseState(issueBody) {
21
39
  const m = BLOCK_RE.exec(issueBody || "");
22
40
  if (!m) return null;
23
- // Inverse of serializeState: restore both "--&gt;" and "--!&gt;".
24
- try { return JSON.parse(m[1].trim().replace(/--(!?)&gt;/g, "--$1>")); } catch { return null; }
41
+ // Inverse of serializeState: restore "--&gt;", "--!&gt;", "&lt;!--", then
42
+ // "&amp;" — in that order. Unescape the closer forms first (most-specific
43
+ // "--!&gt;" before "--&gt;") so a dangling "!" is never left behind, then
44
+ // restore the opener — same ordering rationale as
45
+ // packages/protocol/src/parse.js's unescapeFieldValue. The "&amp;" -> "&"
46
+ // unescape MUST run LAST (mirroring the encode side running it FIRST) so it
47
+ // never touches the "&" that is part of a just-restored "<!--"/"-->"/"--!>"
48
+ // sequence (forge#2137).
49
+ try {
50
+ return JSON.parse(
51
+ m[1].trim()
52
+ .replace(/--(!?)&gt;/g, "--$1>")
53
+ .replace(/&lt;!--/g, "<!--")
54
+ .replace(/&amp;/g, "&"),
55
+ );
56
+ } catch { return null; }
25
57
  }
26
58
 
27
59
  /** Replace the FORGE:STATE block in place, or append one if absent. */
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
9
9
  import { readdirSync, statSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { join } from "node:path";
12
- import { runIssue } from "./engine.mjs";
12
+ import { runIssue, DEFAULT_MAX_ATTEMPTS } from "./engine.mjs";
13
13
  import { makeProjector } from "./engine/projector.mjs";
14
14
  import { readLog, deriveState } from "./engine/runlog.mjs";
15
15
 
@@ -55,6 +55,56 @@ export function makeIo() {
55
55
 
56
56
  export function runDir() { return join(homedir(), ".forge", "runs"); }
57
57
 
58
+ /**
59
+ * Renders the diagnostic block printed below the bare terminal line whenever
60
+ * a run does not terminate `merged` (forge#2175). Reconstructs the failing
61
+ * phase, attempt count, and `PHASE_FAILED.reason` from the durable run-log
62
+ * (the same data `bin/engine.mjs`'s `runPhaseWithRetry()` already appends via
63
+ * `appendEvent()`), plus the final committed/branch/pr state and the run-log
64
+ * path — closing the gap where an operator previously had to manually open
65
+ * `~/.forge/runs/{issue}.jsonl` and read engine source to interpret a bare
66
+ * `issue #N -> needs-human` line.
67
+ *
68
+ * Best-effort: if the run-log is empty/unreadable (e.g. a `deferred` early
69
+ * return before any event was appended), only the run-log path line is
70
+ * printed — never throws.
71
+ *
72
+ * Validates `issue` internally (mirrors the `Number.isInteger` guard in
73
+ * `lastLocalRun()` below) rather than relying solely on the caller —
74
+ * `runFromCli()` already validates before calling this, but this is an
75
+ * exported helper and a future caller could forget to (forge#2190).
76
+ *
77
+ * @param {string} dir - runDir() (or an injected override for tests)
78
+ * @param {number} issue
79
+ * @returns {string} multi-line diagnostic block (no trailing newline)
80
+ */
81
+ export function formatTerminalDiagnostics(dir, issue) {
82
+ if (!Number.isInteger(issue)) {
83
+ return ` run-log: <invalid issue: ${JSON.stringify(issue)}>`;
84
+ }
85
+ const runLogPath = join(dir, `${issue}.jsonl`);
86
+ let events = [];
87
+ try {
88
+ events = readLog(dir, issue);
89
+ } catch {
90
+ // Corrupt/unreadable log — degrade to just the run-log path line below.
91
+ }
92
+
93
+ const lines = [];
94
+ if (events.length > 0) {
95
+ const state = deriveState(events);
96
+ const lastFailure = [...events].reverse().find((e) => e.event === "PHASE_FAILED");
97
+ if (lastFailure) {
98
+ lines.push(` phase: ${lastFailure.phase} (failed ${lastFailure.attempt}/${DEFAULT_MAX_ATTEMPTS} attempts)`);
99
+ lines.push(` reason: ${lastFailure.reason}`);
100
+ }
101
+ const committed = state.committed.length ? state.committed.join(",") : "";
102
+ lines.push(` state: committed=[${committed}] branch=${state.branch ?? "null"} pr=${state.pr ?? "null"}`);
103
+ }
104
+ lines.push(` run-log: ${runLogPath}`);
105
+ return lines.join("\n");
106
+ }
107
+
58
108
  /**
59
109
  * Resolves the repo `gh` would target by default (i.e. resolved from the cwd
60
110
  * git remote), via `gh repo view`. Used to validate an explicit `--repo` flag
@@ -252,13 +302,24 @@ export async function runFromCli(argv, deps = {}) {
252
302
  await assertRepoMatchesCwd(io, repo);
253
303
  const runIssueFn = deps.runIssue ?? runIssue;
254
304
  const agentId = `cli_${process.pid}`;
255
- const res = await runIssueFn({ issue, dir: runDir(), agentId, lane, io,
305
+ // Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
306
+ const dir = deps.dir ?? runDir();
307
+ const res = await runIssueFn({ issue, dir, agentId, lane, io,
256
308
  runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
257
309
  // Only forwarded when explicitly provided — omitting them preserves
258
310
  // runIssue's/runner.mjs's existing defaults (forge#2028).
259
311
  ...(backend ? { backend } : {}),
260
312
  ...(model ? { model } : {}) });
261
313
  console.log(`issue #${issue} → ${res.terminalReason}`);
314
+ // forge#2175: a non-success termination previously printed nothing beyond
315
+ // the bare reason above — the actual failing phase/attempt/reason was only
316
+ // recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the
317
+ // engine source. Print it here instead, reconstructed from the same
318
+ // run-log runIssue() just wrote. Skipped for "merged" (the success case)
319
+ // to avoid regressing/cluttering the existing happy-path output.
320
+ if (res.terminalReason !== "merged") {
321
+ console.log(formatTerminalDiagnostics(dir, issue));
322
+ }
262
323
  return res;
263
324
  }
264
325
 
package/bin/engine.mjs CHANGED
@@ -10,7 +10,9 @@ import { reconcileState } from "./engine/reconcile.mjs";
10
10
  import { makeProjector } from "./engine/projector.mjs";
11
11
  import { VALID_BACKENDS } from "./runner.mjs";
12
12
 
13
- const DEFAULT_MAX_ATTEMPTS = 3;
13
+ // Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
14
+ // diagnostics without duplicating the retry budget constant.
15
+ export const DEFAULT_MAX_ATTEMPTS = 3;
14
16
 
15
17
  /**
16
18
  * @param {object} opts
@@ -85,11 +87,17 @@ export async function runIssue(opts) {
85
87
  }
86
88
 
87
89
  // 2. Drive phases until terminal.
90
+ //
91
+ // Note: the build phase's branch is NOT precomputed here. The real branch
92
+ // name is slug-derived from the issue title by `commands/work-on/build.md`
93
+ // (Phase B1A) and cannot be guessed ahead of time — a prior guessed default
94
+ // (`fix/pipeline-{issue}`) was never communicated to the builder and never
95
+ // matched the branch it actually created, so `commitsAhead()` always ran
96
+ // against a nonexistent ref and silently evaluated to 0 (forge#2174). The
97
+ // build phase's `reconcile`/`detectOutcome` (bin/engine/phases.mjs) resolve
98
+ // the real branch from the `FORGE:BUILDER` comment instead.
88
99
  let phase;
89
100
  while ((phase = pickPhase(state))) {
90
- // Every issue's build works on a deterministic branch; set it before build runs.
91
- if (phase.id === "build" && !state.branch) state.branch = `fix/pipeline-${issue}`;
92
-
93
101
  let reconciled;
94
102
  try {
95
103
  reconciled = phase.reconcile ? await phase.reconcile(state, io) : { satisfied: false };
@@ -153,6 +161,19 @@ async function runPhaseWithRetry(phase, state, ctx) {
153
161
  const outcome = await phase.detectOutcome(state, io);
154
162
  if (outcome.status === "committed" || outcome.status === "blocked") return outcome;
155
163
  appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail });
164
+ // forge#2176: a phase's detectOutcome can mark a failure as a known,
165
+ // state-derived fixed point — re-running the phase's runner is
166
+ // guaranteed to reproduce the identical failure (e.g. the build phase's
167
+ // builder already completed and will no-op on any re-invocation, per
168
+ // commands/work-on/build.md's own `BUILD_RESULT: status: ALREADY_DONE`
169
+ // early-exit). Honor that signal by stopping immediately rather than
170
+ // burning the remaining attempt budget on a guaranteed-identical result.
171
+ // Absent/undefined `retryable` defaults to retryable (existing behavior
172
+ // for every phase that doesn't opt in — investigate/context/architect/
173
+ // review/close — is unchanged, preserving transient-failure retries).
174
+ if (outcome.retryable === false) {
175
+ return { status: "blocked", detail: outcome.detail };
176
+ }
156
177
  }
157
178
  // Exhausted transient retries → escalate (spec §7).
158
179
  return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts` };