@clemrig/factory 0.6.0 → 0.7.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 +61 -23
- package/dist/app/define.js +1 -1
- package/dist/app/define.js.map +1 -1
- package/dist/app/init.d.ts +1 -0
- package/dist/app/init.js +1 -0
- package/dist/app/init.js.map +1 -1
- package/dist/app/run.d.ts +2 -1
- package/dist/app/run.js +16 -4
- package/dist/app/run.js.map +1 -1
- package/dist/app/workflows/implement.d.ts +1 -1
- package/dist/app/workflows/implement.js +332 -132
- package/dist/app/workflows/implement.js.map +1 -1
- package/dist/app/workflows/issue-save.js +16 -14
- package/dist/app/workflows/issue-save.js.map +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +4 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/init.d.ts +3 -2
- package/dist/cli/init.js +10 -3
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/run.js +1 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/domain/definition.d.ts +2 -0
- package/dist/domain/definition.js +1 -0
- package/dist/domain/definition.js.map +1 -1
- package/dist/domain/description.d.ts +51 -0
- package/dist/domain/description.js +78 -0
- package/dist/domain/description.js.map +1 -0
- package/dist/domain/plan.d.ts +2 -0
- package/dist/domain/plan.js +15 -0
- package/dist/domain/plan.js.map +1 -1
- package/dist/domain/pull-request.d.ts +42 -8
- package/dist/domain/pull-request.js +287 -24
- package/dist/domain/pull-request.js.map +1 -1
- package/dist/domain/review.d.ts +19 -11
- package/dist/domain/review.js +42 -21
- package/dist/domain/review.js.map +1 -1
- package/dist/domain/spec.js +12 -4
- package/dist/domain/spec.js.map +1 -1
- package/dist/domain/verification.d.ts +0 -2
- package/dist/domain/verification.js.map +1 -1
- package/dist/system/browser.d.ts +29 -0
- package/dist/system/browser.js +84 -0
- package/dist/system/browser.js.map +1 -0
- package/dist/system/checks.d.ts +5 -6
- package/dist/system/checks.js +3 -3
- package/dist/system/checks.js.map +1 -1
- package/dist/system/git.d.ts +9 -15
- package/dist/system/git.js +92 -100
- package/dist/system/git.js.map +1 -1
- package/dist/system/github.d.ts +15 -0
- package/dist/system/github.js +36 -2
- package/dist/system/github.js.map +1 -1
- package/dist/system/issues.d.ts +5 -2
- package/dist/system/issues.js +5 -3
- package/dist/system/issues.js.map +1 -1
- package/dist/system/run-files.d.ts +22 -2
- package/dist/system/run-files.js +78 -4
- package/dist/system/run-files.js.map +1 -1
- package/dist/system/templates.d.ts +3 -2
- package/dist/system/templates.js +8 -3
- package/dist/system/templates.js.map +1 -1
- package/package.json +3 -3
- package/prompts/implement/describe.md +74 -0
- package/prompts/implement/implement.md +25 -8
- package/prompts/implement/plan.md +35 -7
- package/prompts/implement/review.md +43 -16
- package/templates/init/factory.ts +23 -5
|
@@ -2,16 +2,18 @@ import { agent, code, defineWorkflow, END, gate, node, retry, schemaGate, step,
|
|
|
2
2
|
import { isSlug } from "../../domain/issue.js";
|
|
3
3
|
import { planCommitMessage, planOutput, planProblem, renderPlan, } from "../../domain/plan.js";
|
|
4
4
|
import { DELIVERY } from "../../domain/delivery.js";
|
|
5
|
-
import {
|
|
5
|
+
import { descriptionOutput, descriptionProblems, maxScreenshotBytes, } from "../../domain/description.js";
|
|
6
|
+
import { ghVersionProblem, maxBodyLength, mergeDangerLine, pullRequestBody, } from "../../domain/pull-request.js";
|
|
6
7
|
import { implementationOutput, implementationProblem, largeChangeLines, reviewChanges, reviewOutput, reviewProblem, } from "../../domain/review.js";
|
|
7
8
|
import { parseSpec } from "../../domain/spec.js";
|
|
8
|
-
import { failureFeedback, seconds
|
|
9
|
+
import { failureFeedback, seconds } from "../../domain/verification.js";
|
|
10
|
+
import { closeBrowser, runBrowser } from "../../system/browser.js";
|
|
9
11
|
import { runChecks } from "../../system/checks.js";
|
|
10
12
|
import * as git from "../../system/git.js";
|
|
11
13
|
import * as github from "../../system/github.js";
|
|
12
14
|
import { withTreeLock } from "../../system/lock.js";
|
|
13
15
|
import { artifactPath, hasArtifact, isIssue, readArtifact, writeArtifact, } from "../../system/issues.js";
|
|
14
|
-
import {
|
|
16
|
+
import { keepCheckOutputs, keepFailedCheck, keepInRun, screenshotProblems, screenshotsFolder, } from "../../system/run-files.js";
|
|
15
17
|
function facts(state) {
|
|
16
18
|
return state.data;
|
|
17
19
|
}
|
|
@@ -27,6 +29,11 @@ function reviewSession(state) {
|
|
|
27
29
|
const restarts = state.retries?.["review.modified"] ?? 0;
|
|
28
30
|
return `review-${reviewRounds(state)}${restarts > 0 ? `-${restarts}` : ""}`;
|
|
29
31
|
}
|
|
32
|
+
/** One conversation for the description, a new one once it changed the repository. */
|
|
33
|
+
function describeSession(state) {
|
|
34
|
+
const restarts = state.retries?.["describe.modified"] ?? 0;
|
|
35
|
+
return `describe${restarts > 0 ? `-${restarts}` : ""}`;
|
|
36
|
+
}
|
|
30
37
|
function short(commit) {
|
|
31
38
|
return commit.slice(0, 7);
|
|
32
39
|
}
|
|
@@ -42,21 +49,42 @@ function journalChecks(log, where = "") {
|
|
|
42
49
|
log(`${check.name} passed in ${seconds(event.durationMs)}`);
|
|
43
50
|
};
|
|
44
51
|
}
|
|
45
|
-
/** The dependencies
|
|
46
|
-
async function
|
|
52
|
+
/** The dependencies that are not delivered: no plan.md on the main branch, which is checked out. */
|
|
53
|
+
async function dependencyProblems(dir, slug, base, dependencies) {
|
|
47
54
|
const problems = [];
|
|
48
|
-
for (const dependency of
|
|
55
|
+
for (const dependency of dependencies) {
|
|
49
56
|
if (await hasArtifact(dir, dependency, "plan"))
|
|
50
57
|
continue;
|
|
51
|
-
if (
|
|
52
|
-
problems.push(`Issue ${slug} depends on ${dependency}, which is no issue of .factory/issues/: fix spec.md with the issue skill (/issue ${slug})`);
|
|
53
|
-
else
|
|
58
|
+
if (await isIssue(dir, dependency))
|
|
54
59
|
problems.push(`Issue ${slug} depends on ${dependency}, which is not delivered (its plan.md is not on ${base}): deliver it first, or revise the dependency with the issue skill (/issue ${slug})`);
|
|
60
|
+
else
|
|
61
|
+
problems.push(`Issue ${slug} depends on ${dependency}, which is no issue of .factory/issues/: fix spec.md with the issue skill (/issue ${slug})`);
|
|
55
62
|
}
|
|
56
63
|
return problems;
|
|
57
64
|
}
|
|
65
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
66
|
+
/** The repository on GitHub, when gh is recent enough and logged in to open its pull request; otherwise why not. */
|
|
67
|
+
async function gitHubProblems(dir) {
|
|
68
|
+
const problems = [];
|
|
69
|
+
const version = await github
|
|
70
|
+
.version(dir)
|
|
71
|
+
.then(ghVersionProblem, (error) => message(error));
|
|
72
|
+
if (version)
|
|
73
|
+
problems.push(version);
|
|
74
|
+
if (!(await github.isAuthenticated(dir))) {
|
|
75
|
+
problems.push("gh is not logged in: run gh auth login");
|
|
76
|
+
return { problems };
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return { repository: await github.repository(dir), problems };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
problems.push(`gh cannot read the repository on GitHub: ${message(error)}`);
|
|
83
|
+
return { problems };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
58
86
|
/** Every reason not to start, found without changing anything; then the base must pass its checks. */
|
|
59
|
-
async function prepare(definition, { dir, state, log }) {
|
|
87
|
+
async function prepare(definition, { dir, state, log, runDir }) {
|
|
60
88
|
const slug = String(state.input.issue);
|
|
61
89
|
const base = await git.defaultBranch(dir);
|
|
62
90
|
const problems = [];
|
|
@@ -78,18 +106,23 @@ async function prepare(definition, { dir, state, log }) {
|
|
|
78
106
|
else if (!spec.ok)
|
|
79
107
|
problems.push(...spec.problems.map((problem) => `spec.md: ${problem}`), `Fix spec.md with the issue skill (/issue ${slug}), then commit and push it`);
|
|
80
108
|
else
|
|
81
|
-
problems.push(...(await
|
|
109
|
+
problems.push(...(await dependencyProblems(dir, slug, base, spec.value.dependencies)));
|
|
82
110
|
if (await hasArtifact(dir, slug, "plan"))
|
|
83
111
|
problems.push(`Issue ${slug} is already delivered (its plan.md is on ${base}): start a new issue`);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (problems.length > 0 || !spec?.ok || text === undefined)
|
|
112
|
+
const { repository, problems: gitHub } = await gitHubProblems(dir);
|
|
113
|
+
problems.push(...gitHub);
|
|
114
|
+
if (problems.length > 0 || !spec?.ok || text === undefined || !repository)
|
|
87
115
|
throw new Error(["Cannot start:", ...problems.map((problem) => `- ${problem}`)].join("\n"));
|
|
88
116
|
log(`Starting point valid: ${base} matches origin, spec with ${spec.value.criteria.length} criteria, gh logged in`);
|
|
89
117
|
const baseCommit = await git.commitOf(dir, "HEAD");
|
|
90
|
-
|
|
118
|
+
// Nothing changed on the base yet: the checks with `when` wait for a change.
|
|
119
|
+
const checks = await runChecks(dir, definition.verify, [], journalChecks(log, ` on ${base}`));
|
|
91
120
|
if (!checks.ok)
|
|
92
121
|
throw new Error(`${base} does not pass its own checks:\n${failureFeedback(checks.check, checks.cause, checks.output)}`);
|
|
122
|
+
// Kept in the run's folder, outside the repository: made before the run
|
|
123
|
+
// takes the tree, so that a failure leaves it on the main branch.
|
|
124
|
+
const browser = await runBrowser(dir, runDir);
|
|
125
|
+
const screenshotsDir = await screenshotsFolder(runDir);
|
|
93
126
|
// One run at a time per working tree: another one started during the checks
|
|
94
127
|
// may have moved it. Checking and taking the tree happen under one lock, so
|
|
95
128
|
// that two runs never both find it free; the branch starts from the approved
|
|
@@ -106,87 +139,189 @@ async function prepare(definition, { dir, state, log }) {
|
|
|
106
139
|
slug,
|
|
107
140
|
title: spec.value.title,
|
|
108
141
|
spec: text,
|
|
142
|
+
objective: spec.value.objective,
|
|
109
143
|
criteria: spec.value.criteria,
|
|
144
|
+
repository,
|
|
145
|
+
...(browser ? { browser } : {}),
|
|
146
|
+
screenshotsDir,
|
|
110
147
|
base,
|
|
111
148
|
baseCommit,
|
|
112
149
|
specPath: artifactPath(slug, "spec"),
|
|
113
150
|
planPath: artifactPath(slug, "plan"),
|
|
114
151
|
};
|
|
115
152
|
}
|
|
116
|
-
/** The
|
|
117
|
-
async function
|
|
153
|
+
/** The agents work on the plan's commit, on the issue's branch: the run stops if one committed or switched branches. */
|
|
154
|
+
async function ensureOnPlanCommit({ dir, state }, agentName) {
|
|
118
155
|
const { slug, planCommit } = facts(state);
|
|
119
156
|
if ((await git.commitOf(dir, "HEAD")) !== planCommit ||
|
|
120
157
|
(await git.currentBranch(dir)) !== slug)
|
|
121
|
-
throw new Error(
|
|
122
|
-
|
|
158
|
+
throw new Error(`The ${agentName} agent committed or switched branches: the run stops`);
|
|
159
|
+
}
|
|
160
|
+
/** The implementing agent may not commit, switch branches or touch .factory/: the run stops if it did. */
|
|
161
|
+
async function guard(context) {
|
|
162
|
+
await ensureOnPlanCommit(context, "implementing");
|
|
163
|
+
if (await git.hasChanges(context.dir, ".factory"))
|
|
123
164
|
throw new Error("The implementing agent changed .factory/: the run stops");
|
|
124
165
|
}
|
|
125
166
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* what it changed is put back, and the review starts again. Committing or
|
|
130
|
-
* switching branches stops the run.
|
|
167
|
+
* Closes the agents' browser once an agent's call is over, failed or not:
|
|
168
|
+
* Playwright CLI keeps it open after the agent that opened it. Every session
|
|
169
|
+
* of the run's workspace is closed, those a stopped call left included.
|
|
131
170
|
*/
|
|
132
|
-
function
|
|
133
|
-
const call = agent({
|
|
134
|
-
profile,
|
|
135
|
-
schema: reviewOutput,
|
|
136
|
-
session: reviewSession,
|
|
137
|
-
context: ({ state }) => {
|
|
138
|
-
const { slug, spec, changes, checks } = facts(state);
|
|
139
|
-
return { issue: slug, spec, changes, checks };
|
|
140
|
-
},
|
|
141
|
-
});
|
|
171
|
+
function closingBrowser(stage) {
|
|
142
172
|
return {
|
|
143
|
-
...
|
|
144
|
-
outcomes: ["ok", "modified"],
|
|
173
|
+
...stage,
|
|
145
174
|
async run(context) {
|
|
146
|
-
const { dir, state, log } = context;
|
|
147
|
-
const { slug, planCommit, reviewedTree } = facts(state);
|
|
148
|
-
const putBack = async () => {
|
|
149
|
-
if ((await git.commitOf(dir, "HEAD")) !== planCommit ||
|
|
150
|
-
(await git.currentBranch(dir)) !== slug)
|
|
151
|
-
throw new Error("The reviewing agent committed or switched branches: the run stops");
|
|
152
|
-
return git.putBack(dir, reviewedTree);
|
|
153
|
-
};
|
|
154
|
-
const modified = (changed, why) => {
|
|
155
|
-
log(`The review changed ${changed.join(", ")}${why}: put back`);
|
|
156
|
-
return {
|
|
157
|
-
outcome: "modified",
|
|
158
|
-
reason: `The review changed the repository (${changed.join(", ")})${why}: put back, the review starts again`,
|
|
159
|
-
feedback: `A previous review changed ${changed.join(", ")}; the run put them back as they were. Review the change again, without modifying anything in the repository.`,
|
|
160
|
-
};
|
|
161
|
-
};
|
|
162
|
-
// Left by a review that stopped before putting it back: it counts as
|
|
163
|
-
// that review's change, so it starts again in a new conversation.
|
|
164
|
-
const left = await putBack();
|
|
165
|
-
if (left.length > 0)
|
|
166
|
-
return modified(left, " before the run could put it back");
|
|
167
|
-
let result;
|
|
168
|
-
let failure;
|
|
169
175
|
try {
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
catch (error) {
|
|
173
|
-
failure = error;
|
|
176
|
+
return await stage.run(context);
|
|
174
177
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (result)
|
|
181
|
-
return result;
|
|
182
|
-
throw failure;
|
|
178
|
+
finally {
|
|
179
|
+
const { browser } = facts(context.state);
|
|
180
|
+
const problem = browser && (await closeBrowser(browser));
|
|
181
|
+
if (problem)
|
|
182
|
+
context.log(`Could not close the agents' browser: ${problem}`);
|
|
183
183
|
}
|
|
184
|
-
return modified(changed, result
|
|
185
|
-
? ""
|
|
186
|
-
: ` and its call failed (${failure instanceof Error ? failure.message : String(failure)})`);
|
|
187
184
|
},
|
|
188
185
|
};
|
|
189
186
|
}
|
|
187
|
+
/** The agents that read the change without changing the repository: the review, then the description. */
|
|
188
|
+
const readers = {
|
|
189
|
+
review: {
|
|
190
|
+
name: "review",
|
|
191
|
+
agent: "reviewing",
|
|
192
|
+
again: "Review the change again",
|
|
193
|
+
},
|
|
194
|
+
describe: {
|
|
195
|
+
name: "description",
|
|
196
|
+
agent: "describing",
|
|
197
|
+
again: "Describe the change again",
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
/** Puts back the tree the reader reads, as the diff stage recorded it; returns the paths it put back. */
|
|
201
|
+
async function putBackReviewedTree(context, reader) {
|
|
202
|
+
await ensureOnPlanCommit(context, reader.agent);
|
|
203
|
+
return git.putBack(context.dir, facts(context.state).reviewedTree);
|
|
204
|
+
}
|
|
205
|
+
/** The reader changed the repository, which the run put back: it starts again in a new conversation. */
|
|
206
|
+
function readerModified(log, reader, changed, why) {
|
|
207
|
+
const paths = changed.join(", ");
|
|
208
|
+
const after = why ? ` ${why}` : "";
|
|
209
|
+
log(`The ${reader.name} changed ${paths}${after}: put back`);
|
|
210
|
+
return {
|
|
211
|
+
outcome: "modified",
|
|
212
|
+
reason: `The ${reader.name} changed the repository (${paths})${after}: put back, the ${reader.name} starts again`,
|
|
213
|
+
feedback: `A previous ${reader.name} changed ${paths}; the run put them back as they were. ${reader.again}, without modifying anything in the repository.`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* An agent that may run commands but never change the repository. The tree
|
|
218
|
+
* it reads is put back before its call and after it, even when the call
|
|
219
|
+
* fails; what it changed makes it start again.
|
|
220
|
+
*/
|
|
221
|
+
function reading(reader, stage) {
|
|
222
|
+
return closingBrowser({
|
|
223
|
+
...stage,
|
|
224
|
+
outcomes: ["ok", "modified"],
|
|
225
|
+
async run(context) {
|
|
226
|
+
// Left by a call that stopped before the run put it back: it counts
|
|
227
|
+
// as that call's change.
|
|
228
|
+
const left = await putBackReviewedTree(context, reader);
|
|
229
|
+
if (left.length > 0)
|
|
230
|
+
return readerModified(context.log, reader, left, "before the run could put it back");
|
|
231
|
+
const ended = await stage.run(context).then((result) => ({ result }), (error) => ({ error }));
|
|
232
|
+
const changed = await putBackReviewedTree(context, reader);
|
|
233
|
+
if (changed.length > 0)
|
|
234
|
+
return readerModified(context.log, reader, changed, "error" in ended
|
|
235
|
+
? `and its call failed (${message(ended.error)})`
|
|
236
|
+
: undefined);
|
|
237
|
+
if ("error" in ended)
|
|
238
|
+
throw ended.error;
|
|
239
|
+
return ended.result;
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/** The agents' browser, in the request of the agents that may use it. */
|
|
244
|
+
function browserContext(state) {
|
|
245
|
+
const { browser } = facts(state);
|
|
246
|
+
return browser ? { browser } : {};
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The reviewing agent: it judges the change against the spec, from the code
|
|
250
|
+
* and the checks alone. From the second round on, it first reviews the change
|
|
251
|
+
* knowing nothing of the earlier reviews, then, in the same conversation,
|
|
252
|
+
* checks what the previous one found against the code as it is now, and gives
|
|
253
|
+
* its whole review again: nothing found earlier is lost on the way.
|
|
254
|
+
*/
|
|
255
|
+
function review(profile) {
|
|
256
|
+
const call = (context) => agent({ profile, schema: reviewOutput, session: reviewSession, context });
|
|
257
|
+
const fresh = call(({ state }) => {
|
|
258
|
+
const { slug, spec, changes, checks } = facts(state);
|
|
259
|
+
return { issue: slug, spec, changes, checks, ...browserContext(state) };
|
|
260
|
+
});
|
|
261
|
+
const recheck = call(({ state }) => ({
|
|
262
|
+
previous: facts(state).reviews?.at(-1),
|
|
263
|
+
}));
|
|
264
|
+
return reading(readers.review, {
|
|
265
|
+
...fresh,
|
|
266
|
+
async run(context) {
|
|
267
|
+
const first = await fresh.run(context);
|
|
268
|
+
if ((facts(context.state).reviews ?? []).length === 0)
|
|
269
|
+
return first;
|
|
270
|
+
return recheck.run(context);
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The describing agent: once the change is approved, it explains it to a
|
|
276
|
+
* reader who forgot its issue, and takes its screenshots. It never reads the
|
|
277
|
+
* implementing agent's account of its own work.
|
|
278
|
+
*/
|
|
279
|
+
function describe(profile) {
|
|
280
|
+
return reading(readers.describe, agent({
|
|
281
|
+
profile,
|
|
282
|
+
schema: descriptionOutput,
|
|
283
|
+
session: describeSession,
|
|
284
|
+
context: ({ state }) => {
|
|
285
|
+
const f = facts(state);
|
|
286
|
+
return {
|
|
287
|
+
issue: f.slug,
|
|
288
|
+
spec: f.spec,
|
|
289
|
+
plan: f.plan,
|
|
290
|
+
changes: f.changes,
|
|
291
|
+
checks: f.checks,
|
|
292
|
+
review: f.review,
|
|
293
|
+
deviations: f.implementation.deviations,
|
|
294
|
+
repository: { public: f.repository.public },
|
|
295
|
+
screenshots: f.screenshotsDir,
|
|
296
|
+
...browserContext(state),
|
|
297
|
+
};
|
|
298
|
+
},
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
/** What the pull request shows, from the run's data; its screenshots `pending` until attached. */
|
|
302
|
+
function pullRequestFacts(state, description, pending) {
|
|
303
|
+
const f = facts(state);
|
|
304
|
+
return {
|
|
305
|
+
objective: f.objective,
|
|
306
|
+
criteria: f.criteria,
|
|
307
|
+
deviations: f.implementation.deviations,
|
|
308
|
+
assumptions: f.plan.risks,
|
|
309
|
+
review: f.review,
|
|
310
|
+
acceptedReview: f.acceptedReview === true,
|
|
311
|
+
reviewRounds: reviewRounds(state),
|
|
312
|
+
resolved: (f.reviews ?? []).flatMap(({ resolved = [] }) => resolved),
|
|
313
|
+
checks: f.checks,
|
|
314
|
+
skippedChecks: f.skippedChecks,
|
|
315
|
+
files: f.changes.files,
|
|
316
|
+
description,
|
|
317
|
+
screenshotsPending: pending,
|
|
318
|
+
links: {
|
|
319
|
+
spec: `${f.repository.url}/blob/${f.baseCommit}/${f.specPath}`,
|
|
320
|
+
plan: `${f.repository.url}/blob/${f.planCommit}/${f.planPath}`,
|
|
321
|
+
},
|
|
322
|
+
runId: state.id,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
190
325
|
/**
|
|
191
326
|
* A failed run resumes on its branch, at the commit its stage started from,
|
|
192
327
|
* with its changes in progress and none in .factory/ but the plan it may have
|
|
@@ -195,21 +330,25 @@ function review(profile) {
|
|
|
195
330
|
async function resumeProblem({ dir, state, }) {
|
|
196
331
|
if (state.stage === "prepare")
|
|
197
332
|
return undefined;
|
|
333
|
+
// Its pull request is open: only its screenshots remain to attach, from the
|
|
334
|
+
// run's folder, whatever the repository is on.
|
|
335
|
+
if (state.stage === "attach")
|
|
336
|
+
return undefined;
|
|
198
337
|
// Stopped once its last stage was done, such as by Ctrl-C.
|
|
199
338
|
const { pullRequest } = state.data;
|
|
200
339
|
if (typeof pullRequest === "string")
|
|
201
340
|
return `the run already delivered its pull request ${pullRequest}`;
|
|
202
|
-
// An earlier factory
|
|
203
|
-
//
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
341
|
+
// An earlier factory read neither the repository on GitHub nor the spec's
|
|
342
|
+
// objective: the pull request this run would open needs them.
|
|
343
|
+
if (typeof state.data.repository !== "object")
|
|
344
|
+
return "the run started with an earlier version of the factory, whose data this one does not read: start a new run";
|
|
345
|
+
// The review and the description start from the tree the review reads,
|
|
346
|
+
// which Git may have pruned meanwhile.
|
|
208
347
|
const { reviewedTree } = state.data;
|
|
209
|
-
if (state.stage === "review" &&
|
|
348
|
+
if ((state.stage === "review" || state.stage === "describe") &&
|
|
210
349
|
typeof reviewedTree === "string" &&
|
|
211
350
|
!(await git.hasObject(dir, reviewedTree)))
|
|
212
|
-
return
|
|
351
|
+
return `the tree the ${state.stage === "review" ? "review" : "description"} reads is no longer in Git (removed by git gc): start a new run`;
|
|
213
352
|
const { slug, baseCommit, planPath } = facts(state);
|
|
214
353
|
// Until the plan is committed, the branch is at the run's starting commit.
|
|
215
354
|
const { planCommit } = state.data;
|
|
@@ -226,7 +365,7 @@ async function resumeProblem({ dir, state, }) {
|
|
|
226
365
|
problems.push(".factory/ has changes in progress, which a run never delivers: discard them");
|
|
227
366
|
return problems.length ? problems.join("; ") : undefined;
|
|
228
367
|
}
|
|
229
|
-
/** Plans, implements, checks and reviews an approved spec, then
|
|
368
|
+
/** Plans, implements, checks and reviews an approved spec, then describes it in the pull request it opens. */
|
|
230
369
|
export function implement(definition) {
|
|
231
370
|
return defineWorkflow({
|
|
232
371
|
name: DELIVERY,
|
|
@@ -244,7 +383,10 @@ export function implement(definition) {
|
|
|
244
383
|
{ name: "implement", stages: ["implement", "implemented"] },
|
|
245
384
|
{ name: "verify", stages: ["verify"] },
|
|
246
385
|
{ name: "review", stages: ["diff", "review", "reviewed"] },
|
|
247
|
-
{
|
|
386
|
+
{
|
|
387
|
+
name: "deliver",
|
|
388
|
+
stages: ["describe", "described", "deliver", "attach"],
|
|
389
|
+
},
|
|
248
390
|
],
|
|
249
391
|
nodes: {
|
|
250
392
|
prepare: node(step((context) => prepare(definition, context)), { ok: "plan" }),
|
|
@@ -270,14 +412,14 @@ export function implement(definition) {
|
|
|
270
412
|
log(`Committed plan.md (${plan.steps.length} steps) as ${short(planCommit)}`);
|
|
271
413
|
return { planCommit };
|
|
272
414
|
}), { ok: "implement" }),
|
|
273
|
-
implement: node(agent({
|
|
415
|
+
implement: node(closingBrowser(agent({
|
|
274
416
|
profile: definition.profiles.implement,
|
|
275
417
|
schema: implementationOutput,
|
|
276
418
|
context: ({ state }) => {
|
|
277
419
|
const { slug, spec, plan } = facts(state);
|
|
278
|
-
return { issue: slug, spec, plan };
|
|
420
|
+
return { issue: slug, spec, plan, ...browserContext(state) };
|
|
279
421
|
},
|
|
280
|
-
}), { ok: "implemented" }),
|
|
422
|
+
})), { ok: "implemented" }),
|
|
281
423
|
implemented: node(gate(["ok", "invalid"], async (context) => {
|
|
282
424
|
// First what the agent did, whatever it answered: a malformed
|
|
283
425
|
// response must not hide a change it may not make.
|
|
@@ -298,20 +440,20 @@ export function implement(definition) {
|
|
|
298
440
|
};
|
|
299
441
|
}), { ok: "verify", invalid: retry("implement", 2) }),
|
|
300
442
|
verify: node(code(["passed", "failed"], async ({ dir, state, log, runDir }) => {
|
|
301
|
-
const checks = await runChecks(dir, definition.verify,
|
|
443
|
+
const checks = await runChecks(dir, definition.verify, await git.changedFiles(dir, facts(state).planCommit), journalChecks(log));
|
|
302
444
|
if (checks.ok)
|
|
303
445
|
return {
|
|
304
446
|
outcome: "passed",
|
|
305
447
|
reason: "Every check passed",
|
|
306
448
|
data: {
|
|
307
449
|
// What each check wrote stays with the run, for the review.
|
|
308
|
-
checks: await
|
|
309
|
-
...check,
|
|
310
|
-
outputFile: await keepInRun(runDir, `checks/${index + 1}-${fileName(check.name)}.log`, output),
|
|
311
|
-
}))),
|
|
450
|
+
checks: await keepCheckOutputs(runDir, checks.passed),
|
|
312
451
|
skippedChecks: checks.skipped.map(({ name }) => name),
|
|
313
452
|
},
|
|
314
453
|
};
|
|
454
|
+
// All it wrote, the agent receives its end only.
|
|
455
|
+
const kept = await keepFailedCheck(runDir, checks.check.name, checks.output);
|
|
456
|
+
log(`${checks.check.name} failed: ${checks.cause}; its output is in ${kept}`);
|
|
315
457
|
return {
|
|
316
458
|
outcome: "failed",
|
|
317
459
|
reason: `${checks.check.name} failed: ${checks.cause}`,
|
|
@@ -320,31 +462,25 @@ export function implement(definition) {
|
|
|
320
462
|
}), { passed: "diff", failed: retry("implement", 3) }),
|
|
321
463
|
diff: node(step(async ({ dir, state, log, runDir }) => {
|
|
322
464
|
const { planCommit } = facts(state);
|
|
323
|
-
const
|
|
465
|
+
const reviewedTree = await git.snapshot(dir);
|
|
466
|
+
const diff = await git.diff(dir, planCommit, reviewedTree);
|
|
324
467
|
if (diff.truncated)
|
|
325
468
|
throw new Error("The changes are too large to review (over 2 MiB of diff): the run stops");
|
|
326
|
-
const files = await git.
|
|
469
|
+
const files = await git.diffStat(dir, planCommit, reviewedTree);
|
|
327
470
|
const lines = diff.text.split("\n").length;
|
|
328
|
-
// What the review reads, and must leave as it found it.
|
|
329
|
-
const reviewedTree = await git.snapshot(dir);
|
|
330
471
|
log(`Changes since the plan: ${files.length} files, ${lines} diff lines`);
|
|
331
|
-
if (lines <= largeChangeLines)
|
|
332
|
-
return {
|
|
333
|
-
changes: {
|
|
334
|
-
files,
|
|
335
|
-
lines,
|
|
336
|
-
large: false,
|
|
337
|
-
diff: diff.text,
|
|
338
|
-
},
|
|
339
|
-
reviewedTree,
|
|
340
|
-
};
|
|
341
472
|
// A large diff is read from a file, part by part.
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
473
|
+
const changes = lines <= largeChangeLines
|
|
474
|
+
? { files, lines, large: false, diff: diff.text }
|
|
475
|
+
: {
|
|
476
|
+
files,
|
|
477
|
+
lines,
|
|
478
|
+
large: true,
|
|
479
|
+
diffFile: await keepInRun(runDir, "diff.patch", diff.text),
|
|
480
|
+
};
|
|
481
|
+
if (changes.large)
|
|
482
|
+
log(`Large change: the review reads its diff from ${changes.diffFile}`);
|
|
483
|
+
return { changes, reviewedTree };
|
|
348
484
|
}), { ok: "review" }),
|
|
349
485
|
review: node(review(definition.profiles.review), {
|
|
350
486
|
ok: "reviewed",
|
|
@@ -361,63 +497,127 @@ export function implement(definition) {
|
|
|
361
497
|
if (problem)
|
|
362
498
|
return { outcome: "invalid", reason: problem };
|
|
363
499
|
const changes = reviewChanges(parsed.data);
|
|
500
|
+
// Each review judged is kept, the last one apart for the stages after.
|
|
501
|
+
const judged = {
|
|
502
|
+
review: parsed.data,
|
|
503
|
+
reviews: [...(facts(state).reviews ?? []), parsed.data],
|
|
504
|
+
};
|
|
505
|
+
// The operator resumed the run to deliver it as the review left it.
|
|
506
|
+
if (changes && state.data.acceptedReview === true)
|
|
507
|
+
return {
|
|
508
|
+
outcome: "approved",
|
|
509
|
+
reason: "The operator accepted the review's unmet criteria and blocking findings",
|
|
510
|
+
data: judged,
|
|
511
|
+
};
|
|
364
512
|
if (changes)
|
|
365
513
|
return {
|
|
366
514
|
outcome: "changes",
|
|
367
515
|
reason: "The review requires changes",
|
|
368
516
|
feedback: changes,
|
|
369
|
-
data:
|
|
517
|
+
data: judged,
|
|
370
518
|
};
|
|
371
519
|
return {
|
|
372
520
|
outcome: "approved",
|
|
373
521
|
reason: "Every criterion is met and nothing blocks",
|
|
374
|
-
data:
|
|
522
|
+
data: judged,
|
|
375
523
|
};
|
|
376
524
|
}), {
|
|
377
|
-
approved: "
|
|
525
|
+
approved: "describe",
|
|
378
526
|
changes: retry("implement", 2),
|
|
379
527
|
invalid: retry("review", 2),
|
|
380
528
|
}),
|
|
381
|
-
|
|
382
|
-
|
|
529
|
+
describe: node(describe(definition.profiles.describe ?? definition.profiles.review), { ok: "described", modified: retry("describe", 1) }),
|
|
530
|
+
described: node(gate(["ok", "invalid"], async ({ state }) => {
|
|
531
|
+
const parsed = descriptionOutput.safeParse(state.output);
|
|
532
|
+
if (!parsed.success)
|
|
533
|
+
return {
|
|
534
|
+
outcome: "invalid",
|
|
535
|
+
reason: `Invalid structured output:\n${z.prettifyError(parsed.error)}`,
|
|
536
|
+
};
|
|
537
|
+
const description = parsed.data;
|
|
538
|
+
const { changes, screenshotsDir } = facts(state);
|
|
539
|
+
const problems = [
|
|
540
|
+
...descriptionProblems(description, changes.files),
|
|
541
|
+
...(await screenshotProblems(screenshotsDir, description.screenshots.map(({ file }) => file), maxScreenshotBytes)),
|
|
542
|
+
];
|
|
543
|
+
const length = pullRequestBody(pullRequestFacts(state, description, false)).length;
|
|
544
|
+
if (length > maxBodyLength)
|
|
545
|
+
problems.push(`The pull request's body would have ${length} characters, over ${maxBodyLength}: shorten the description`);
|
|
546
|
+
if (problems.length > 0)
|
|
547
|
+
return {
|
|
548
|
+
outcome: "invalid",
|
|
549
|
+
reason: [
|
|
550
|
+
"Invalid description:",
|
|
551
|
+
...problems.map((problem) => `- ${problem}`),
|
|
552
|
+
].join("\n"),
|
|
553
|
+
};
|
|
554
|
+
return {
|
|
555
|
+
outcome: "ok",
|
|
556
|
+
reason: "Structured output and invariants valid: description",
|
|
557
|
+
data: { description },
|
|
558
|
+
};
|
|
559
|
+
}), { ok: "deliver", invalid: retry("describe", 2) }),
|
|
560
|
+
deliver: node(code(["done", "attach"], async ({ dir, state, log }) => {
|
|
561
|
+
const { slug, base, plan, specPath, description } = facts(state);
|
|
383
562
|
const commit = await git.commit(dir, `${plan.commit}\n\nSpec: ${specPath}`);
|
|
384
563
|
log(`Committed "${plan.commit}" as ${short(commit)}`);
|
|
385
564
|
await git.forcePush(dir, slug);
|
|
386
565
|
log(`Pushed ${slug}, replacing any earlier version of the branch`);
|
|
566
|
+
// Its screenshots are attached once it is open: a failed upload
|
|
567
|
+
// leaves the pull request recorded, and resuming attaches them.
|
|
568
|
+
const attach = description.screenshots.length > 0;
|
|
387
569
|
const pullRequest = await github.openPullRequest(dir, {
|
|
388
570
|
base,
|
|
389
571
|
head: slug,
|
|
390
572
|
title: plan.commit,
|
|
391
|
-
body: pullRequestBody(
|
|
392
|
-
criteria,
|
|
393
|
-
implementation,
|
|
394
|
-
review,
|
|
395
|
-
checks,
|
|
396
|
-
skippedChecks,
|
|
397
|
-
reviewRounds: reviewRounds(state),
|
|
398
|
-
specPath,
|
|
399
|
-
planPath,
|
|
400
|
-
baseCommit,
|
|
401
|
-
runId: state.id,
|
|
402
|
-
}),
|
|
573
|
+
body: pullRequestBody(pullRequestFacts(state, description, attach)),
|
|
403
574
|
});
|
|
404
575
|
log(`Pull request: ${pullRequest}`);
|
|
405
576
|
await git.switchTo(dir, base);
|
|
406
577
|
log(`Back on ${base}`);
|
|
407
|
-
return {
|
|
578
|
+
return {
|
|
579
|
+
outcome: attach ? "attach" : "done",
|
|
580
|
+
reason: attach
|
|
581
|
+
? "Pull request open, its screenshots to attach"
|
|
582
|
+
: "Pull request open",
|
|
583
|
+
data: { pullRequest },
|
|
584
|
+
};
|
|
585
|
+
}), { done: END, attach: "attach" }),
|
|
586
|
+
attach: node(step(async ({ dir, state, log }) => {
|
|
587
|
+
const { pullRequest, description, screenshotsDir } = facts(state);
|
|
588
|
+
const files = description.screenshots.map(({ file }) => file);
|
|
589
|
+
try {
|
|
590
|
+
await github.attachImages(screenshotsDir, pullRequest, pullRequestBody(pullRequestFacts(state, description, false)), files);
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
// gh writes the body even when only some uploads succeeded: the
|
|
594
|
+
// pull request says again that its screenshots are coming.
|
|
595
|
+
await github
|
|
596
|
+
.editBody(dir, pullRequest, pullRequestBody(pullRequestFacts(state, description, true)))
|
|
597
|
+
.catch((restore) => log(`Could not put back the pull request's body: ${message(restore)}`));
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
log(`Attached ${files.length} screenshot${files.length === 1 ? "" : "s"} to ${pullRequest}`);
|
|
408
601
|
}), { ok: END }),
|
|
409
602
|
},
|
|
410
603
|
resumeProblem,
|
|
411
604
|
// Also called for a failed run, which opened no pull request.
|
|
412
605
|
presentResult: (state) => {
|
|
413
|
-
const { pullRequest, checks } = facts(state);
|
|
606
|
+
const { pullRequest, checks, description, acceptedReview } = facts(state);
|
|
414
607
|
if (typeof pullRequest !== "string")
|
|
415
608
|
return [];
|
|
416
609
|
const rounds = reviewRounds(state);
|
|
610
|
+
const after = rounds === 0
|
|
611
|
+
? "at the first review"
|
|
612
|
+
: `after ${rounds} round${rounds === 1 ? "" : "s"} of changes`;
|
|
417
613
|
return [
|
|
418
614
|
`Pull request: ${pullRequest}`,
|
|
419
|
-
|
|
615
|
+
acceptedReview
|
|
616
|
+
? `Review: delivered by the operator with open findings ${after}`
|
|
617
|
+
: `Review: approved ${after}`,
|
|
420
618
|
`Checks passed: ${checks.map(({ name }) => name).join(", ")}`,
|
|
619
|
+
// Runs delivered by an earlier factory have no description.
|
|
620
|
+
...(description ? [mergeDangerLine(description)] : []),
|
|
421
621
|
];
|
|
422
622
|
},
|
|
423
623
|
});
|