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.
- package/README.md +8 -2
- package/bin/engine/phases.mjs +178 -28
- package/bin/engine/state.mjs +39 -7
- package/bin/engine-cli.mjs +63 -2
- package/bin/engine.mjs +25 -4
- package/bin/forgedock.mjs +235 -4
- package/bin/hooks/pre-tool-use.mjs +26 -12
- package/bin/journey.mjs +103 -11
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/qa-sweep.md +17 -0
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +49 -0
- package/commands/upgrade-deps.md +518 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +8 -1
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +36 -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-graph-schema.md +3 -3
- package/package.json +2 -2
- 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 +14 -3
- package/packages/protocol/src/index.js +2 -1
- package/packages/protocol/src/parse.js +17 -8
- package/packages/protocol/src/types.js +1 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-command-side-effects.sh +261 -0
- package/scripts/check-spec-markers.sh +273 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +322 -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/README.md
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
<h1>ForgeDock</h1>
|
|
6
6
|
|
|
7
|
-
<p><strong>
|
|
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.
|
|
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
|
```
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -7,27 +7,146 @@
|
|
|
7
7
|
|
|
8
8
|
export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed"];
|
|
9
9
|
|
|
10
|
-
/**
|
|
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
|
-
|
|
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
|
|
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(
|
|
62
|
+
async function commitsAhead(lane, branch, io) {
|
|
22
63
|
try {
|
|
23
|
-
const n = await io.git(["rev-list", "--count", `origin/${
|
|
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
|
|
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
|
|
39
|
-
if (has(
|
|
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(
|
|
160
|
+
if (has(blob, "DECOMPOSE:YES"))
|
|
42
161
|
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
43
|
-
if (has(
|
|
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
|
|
57
|
-
return has(
|
|
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
|
|
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(
|
|
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
|
|
74
|
-
return has(
|
|
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
|
|
78
|
-
return has(
|
|
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:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
97
|
-
const complete = has(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
{
|
package/bin/engine/state.mjs
CHANGED
|
@@ -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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
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
|
+
// "<!-->" or "<!--") 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
|
+
// "<!--"/"-->"/"--!>" 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. "&" -> "&" -> untouched by the delimiter pass
|
|
29
|
+
// that only matches literal "<!--"/"--"+">" character sequences).
|
|
30
|
+
const payload = JSON.stringify(index)
|
|
31
|
+
.replace(/&/g, "&")
|
|
32
|
+
.replace(/<!--/g, "<!--")
|
|
33
|
+
.replace(/--(!?)>/g, "--$1>");
|
|
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
|
|
24
|
-
|
|
41
|
+
// Inverse of serializeState: restore "-->", "--!>", "<!--", then
|
|
42
|
+
// "&" — in that order. Unescape the closer forms first (most-specific
|
|
43
|
+
// "--!>" before "-->") 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 "&" -> "&"
|
|
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(/--(!?)>/g, "--$1>")
|
|
53
|
+
.replace(/<!--/g, "<!--")
|
|
54
|
+
.replace(/&/g, "&"),
|
|
55
|
+
);
|
|
56
|
+
} catch { return null; }
|
|
25
57
|
}
|
|
26
58
|
|
|
27
59
|
/** Replace the FORGE:STATE block in place, or append one if absent. */
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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` };
|