@flumecode/runner 0.15.0 → 0.17.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/dist/cli.js +730 -760
- package/package.json +1 -1
- package/skills-plugin/skills/create-release/SKILL.md +8 -4
- package/skills-plugin/skills/request-to-plan/SKILL.md +3 -0
- package/skills-plugin/skills/resolve-merge-conflict/SKILL.md +13 -5
- package/skills-plugin/skills/revise-implementation/SKILL.md +8 -8
- package/skills-plugin/skills/format-code-plugin-generator/SKILL.md +0 -64
- package/skills-plugin/skills/lint-plugin-generator/SKILL.md +0 -66
package/dist/cli.js
CHANGED
|
@@ -26,8 +26,8 @@ function writeConfig(config) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// src/run.ts
|
|
29
|
-
import { existsSync as
|
|
30
|
-
import { join as
|
|
29
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
30
|
+
import { join as join4 } from "node:path";
|
|
31
31
|
|
|
32
32
|
// src/version.ts
|
|
33
33
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
@@ -180,607 +180,168 @@ async function safeText(res) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
// src/
|
|
184
|
-
import {
|
|
185
|
-
import {
|
|
186
|
-
|
|
187
|
-
// src/workspace.ts
|
|
188
|
-
import { execFile } from "node:child_process";
|
|
189
|
-
import { existsSync as existsSync2 } from "node:fs";
|
|
190
|
-
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
191
|
-
import { tmpdir } from "node:os";
|
|
192
|
-
import { join as join2 } from "node:path";
|
|
193
|
-
import { promisify } from "node:util";
|
|
194
|
-
|
|
195
|
-
// src/types.ts
|
|
196
|
-
function jobTitle(ctx) {
|
|
197
|
-
return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
|
|
198
|
-
}
|
|
183
|
+
// src/executor.ts
|
|
184
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
185
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
199
186
|
|
|
200
|
-
// src/
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
187
|
+
// src/widgets.ts
|
|
188
|
+
import { randomUUID } from "node:crypto";
|
|
189
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
190
|
+
import { z } from "zod";
|
|
191
|
+
var SERVER_NAME = "flume_widgets";
|
|
192
|
+
var SINGLE_SELECT = "single_select";
|
|
193
|
+
var MULTI_SELECT = "multi_select";
|
|
194
|
+
var WIDGET_TOOL_NAMES = [
|
|
195
|
+
`mcp__${SERVER_NAME}__${SINGLE_SELECT}`,
|
|
196
|
+
`mcp__${SERVER_NAME}__${MULTI_SELECT}`
|
|
197
|
+
];
|
|
198
|
+
var optionsSchema = z.array(z.string().min(1)).min(2).max(8).describe("2\u20138 short, distinct choices for the user to pick from.");
|
|
199
|
+
var TAIL = "Do NOT add an 'Other' or 'None of these' catch-all \u2014 the UI always offers an 'Other' free-text option automatically. After calling this, END YOUR TURN and wait: the user's answer arrives as their next message and starts a fresh run.";
|
|
200
|
+
function createWidgetTooling() {
|
|
201
|
+
const collected = [];
|
|
202
|
+
const singleSelect = tool(
|
|
203
|
+
SINGLE_SELECT,
|
|
204
|
+
"Ask the user a single-select (radio-button) question \u2014 exactly one answer. Use this for a genuine either/or choice (competing approaches, scope decisions, yes/no) instead of writing the options as prose. " + TAIL,
|
|
205
|
+
{
|
|
206
|
+
question: z.string().min(1).describe("The question to ask the user."),
|
|
207
|
+
body: z.string().optional().describe(
|
|
208
|
+
"Optional markdown shown above the question so the user can read the context they're confirming (e.g. the drafted release notes). Omit for plain questions."
|
|
209
|
+
),
|
|
210
|
+
options: optionsSchema
|
|
211
|
+
},
|
|
212
|
+
async (args) => {
|
|
213
|
+
collected.push({
|
|
214
|
+
id: randomUUID(),
|
|
215
|
+
type: "single_select",
|
|
216
|
+
question: args.question,
|
|
217
|
+
body: args.body,
|
|
218
|
+
options: args.options.map((label) => ({ id: randomUUID(), label })),
|
|
219
|
+
selectedOptionId: null,
|
|
220
|
+
customAnswer: null
|
|
221
|
+
});
|
|
222
|
+
return widgetPosted("single-select");
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
const multiSelect = tool(
|
|
226
|
+
MULTI_SELECT,
|
|
227
|
+
"Ask the user a multi-select (checkbox) question \u2014 they may pick any number of options, including none of the presets if they use 'Other'. Use this for 'select all that apply' questions (which features to include, which files to touch). " + TAIL,
|
|
228
|
+
{
|
|
229
|
+
question: z.string().min(1).describe("The question to ask the user."),
|
|
230
|
+
body: z.string().optional().describe(
|
|
231
|
+
"Optional markdown shown above the question so the user can read the context they're confirming (e.g. the drafted release notes). Omit for plain questions."
|
|
232
|
+
),
|
|
233
|
+
options: optionsSchema
|
|
234
|
+
},
|
|
235
|
+
async (args) => {
|
|
236
|
+
collected.push({
|
|
237
|
+
id: randomUUID(),
|
|
238
|
+
type: "multi_select",
|
|
239
|
+
question: args.question,
|
|
240
|
+
body: args.body,
|
|
241
|
+
options: args.options.map((label) => ({ id: randomUUID(), label })),
|
|
242
|
+
selectedOptionIds: null,
|
|
243
|
+
customAnswer: null
|
|
244
|
+
});
|
|
245
|
+
return widgetPosted("multi-select");
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
const mcpServer = createSdkMcpServer({
|
|
249
|
+
name: SERVER_NAME,
|
|
250
|
+
tools: [singleSelect, multiSelect]
|
|
251
|
+
});
|
|
252
|
+
return { mcpServer, collected };
|
|
214
253
|
}
|
|
215
|
-
function
|
|
216
|
-
|
|
254
|
+
function widgetPosted(kind) {
|
|
255
|
+
return {
|
|
256
|
+
content: [
|
|
257
|
+
{
|
|
258
|
+
type: "text",
|
|
259
|
+
text: `Question posted to the user as a ${kind} widget. End your turn now and wait \u2014 their answer will arrive as their next message.`
|
|
260
|
+
}
|
|
261
|
+
]
|
|
262
|
+
};
|
|
217
263
|
}
|
|
218
|
-
function getJobLog() {
|
|
219
|
-
const full = lines.join("\n");
|
|
220
|
-
if (full.length <= MAX_BYTES) return full;
|
|
221
|
-
const half = Math.floor(MAX_BYTES / 2);
|
|
222
|
-
return full.slice(0, half) + `
|
|
223
264
|
|
|
224
|
-
|
|
265
|
+
// src/plan.ts
|
|
266
|
+
import { createSdkMcpServer as createSdkMcpServer2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
|
|
267
|
+
import { z as z2 } from "zod";
|
|
225
268
|
|
|
226
|
-
|
|
227
|
-
|
|
269
|
+
// src/schema-hints.ts
|
|
270
|
+
var INLINE_CODE_HINT = "Wrap code identifiers (function, variable, type, and file names, commands, and flags) in inline backticks, e.g. `getCodingSessionsForRequest`.";
|
|
228
271
|
|
|
229
|
-
// src/
|
|
230
|
-
var
|
|
231
|
-
var
|
|
232
|
-
var
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
const e = err;
|
|
278
|
-
const detail = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
|
|
279
|
-
logEvent("install:err", detail || (err instanceof Error ? err.message : String(err)));
|
|
280
|
-
return { status: "failed", manager, error: err instanceof Error ? err.message : String(err) };
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
async function makeWorkspace() {
|
|
284
|
-
return mkdtemp(join2(tmpdir(), WORKSPACE_PREFIX));
|
|
272
|
+
// src/plan.ts
|
|
273
|
+
var SERVER_NAME2 = "flume_plan";
|
|
274
|
+
var SUBMIT_PLAN = "submit_plan";
|
|
275
|
+
var PLAN_TOOL_NAME = `mcp__${SERVER_NAME2}__${SUBMIT_PLAN}`;
|
|
276
|
+
var PLAN_MARKER = "<!-- flumecode:end-of-plan -->";
|
|
277
|
+
var pseudoCodeEntrySchema = z2.object({
|
|
278
|
+
file: z2.string().min(1),
|
|
279
|
+
pseudoCode: z2.string().min(1)
|
|
280
|
+
});
|
|
281
|
+
var stepSchema = z2.object({
|
|
282
|
+
title: z2.string().min(1).describe("A concise imperative title for this step."),
|
|
283
|
+
description: z2.array(z2.string().min(1)).min(1).describe(
|
|
284
|
+
"Bullet points that explain this step's change so a reviewer can judge whether the design is correct. Each array item is one short, self-contained bullet \u2014 not a single paragraph, and not a restatement of the pseudo code. " + INLINE_CODE_HINT
|
|
285
|
+
),
|
|
286
|
+
pseudoCode: z2.array(pseudoCodeEntrySchema).optional().describe(
|
|
287
|
+
"Per-file pseudo code. Provide an entry for every non-documentation file this step touches. Each entry contains the file path and pseudo code describing the changes to that file."
|
|
288
|
+
)
|
|
289
|
+
});
|
|
290
|
+
var planInputSchema = {
|
|
291
|
+
title: z2.string().min(1).max(120).describe(
|
|
292
|
+
"A concise, descriptive name for THIS plan. Must be distinct from the request title and from any sibling plans on the same request. Keep it under 120 characters."
|
|
293
|
+
),
|
|
294
|
+
scope: z2.enum(["feat", "fix", "chore", "docs", "test", "refactor"]).describe("The primary intent of the change."),
|
|
295
|
+
goal: z2.string().min(1).describe("One or two sentences stating the outcome. " + INLINE_CODE_HINT),
|
|
296
|
+
rootCause: z2.string().optional().describe(
|
|
297
|
+
'For bug fixes (scope === "fix"): the underlying cause of the bug \u2014 the specific code, logic, or condition that produces the incorrect behavior, not just the symptom. Required when scope is "fix"; omit for all other scopes. ' + INLINE_CODE_HINT
|
|
298
|
+
),
|
|
299
|
+
assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
|
|
300
|
+
requirements: z2.array(z2.string().min(1)).min(1).describe(
|
|
301
|
+
"Required, human-readable statements of what this change must accomplish and why, in plain language a non-technical reader can follow. Distinct from acceptanceCriteria: requirements explain intent/rationale; acceptance criteria are the machine-checkable proof. At least 1 required. " + INLINE_CODE_HINT
|
|
302
|
+
),
|
|
303
|
+
steps: z2.array(stepSchema).min(1).describe("Ordered list of changes. Each step says what and why, with file references."),
|
|
304
|
+
acceptanceCriteria: z2.array(z2.string().min(1)).min(2).describe(
|
|
305
|
+
"Concrete, deterministically-checkable conditions that together define done. Each names a trigger/precondition and the exact observable result (run X -> output Y; file Z contains W; f(a) returns b) \u2014 no vague adjectives, not a restatement of a step. The set must collectively cover every step's change. At least 2 required. " + INLINE_CODE_HINT
|
|
306
|
+
),
|
|
307
|
+
risks: z2.array(z2.string()).describe("Anything that could change the approach."),
|
|
308
|
+
outOfScope: z2.array(z2.string()).describe("What is deliberately not being done.")
|
|
309
|
+
};
|
|
310
|
+
function requireRootCauseForFix(schema) {
|
|
311
|
+
return schema.superRefine((plan, ctx) => {
|
|
312
|
+
if (plan.scope === "fix" && (plan.rootCause === void 0 || plan.rootCause.trim() === "")) {
|
|
313
|
+
ctx.addIssue({
|
|
314
|
+
code: z2.ZodIssueCode.custom,
|
|
315
|
+
path: ["rootCause"],
|
|
316
|
+
message: 'rootCause is required and must be non-empty when scope is "fix".'
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
});
|
|
285
320
|
}
|
|
286
|
-
var
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
workspaceRegistry.delete(oldest);
|
|
301
|
-
rm(oldDir, { recursive: true, force: true }).catch(() => {
|
|
302
|
-
});
|
|
321
|
+
var planSchema = requireRootCauseForFix(z2.object(planInputSchema));
|
|
322
|
+
function renderPlan(plan) {
|
|
323
|
+
const lines2 = [];
|
|
324
|
+
lines2.push(`# ${plan.title}`);
|
|
325
|
+
lines2.push("");
|
|
326
|
+
lines2.push(`**Scope** \u2014 \`${plan.scope}\``);
|
|
327
|
+
lines2.push("");
|
|
328
|
+
lines2.push(`**Goal** \u2014 ${plan.goal}`);
|
|
329
|
+
if (plan.assumptions.length > 0) {
|
|
330
|
+
lines2.push("");
|
|
331
|
+
lines2.push("**Assumptions**");
|
|
332
|
+
for (const assumption of plan.assumptions) {
|
|
333
|
+
lines2.push(`- ${assumption}`);
|
|
334
|
+
}
|
|
303
335
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
workspaceRegistry.delete(key);
|
|
309
|
-
if (dir !== void 0) {
|
|
310
|
-
await cleanup(dir).catch(() => {
|
|
311
|
-
});
|
|
336
|
+
if (plan.rootCause && plan.rootCause.trim().length > 0) {
|
|
337
|
+
lines2.push("");
|
|
338
|
+
lines2.push("## Root cause");
|
|
339
|
+
lines2.push(plan.rootCause);
|
|
312
340
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
await git(["-C", dir, "clean", "-fd"]).catch(() => {
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
async function prepareAtSha(ctx, dir, reused) {
|
|
321
|
-
const identity = { name: ctx.agentName, email: ctx.agentEmail };
|
|
322
|
-
if (!reused) {
|
|
323
|
-
await cloneAtSha(ctx, dir);
|
|
324
|
-
await ensureGitIdentity(dir, identity);
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
328
|
-
await ensureGitIdentity(dir, identity);
|
|
329
|
-
}
|
|
330
|
-
async function prepareResumingBranch(ctx, dir, reused) {
|
|
331
|
-
const identity = { name: ctx.agentName, email: ctx.agentEmail };
|
|
332
|
-
if (!reused) {
|
|
333
|
-
const result = await cloneResumingBranch(ctx, dir);
|
|
334
|
-
await ensureGitIdentity(dir, identity);
|
|
335
|
-
return result;
|
|
336
|
-
}
|
|
337
|
-
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
338
|
-
await ensureGitIdentity(dir, identity);
|
|
339
|
-
return { resumed: true };
|
|
340
|
-
}
|
|
341
|
-
async function sweepWorkspaces() {
|
|
342
|
-
const base = tmpdir();
|
|
343
|
-
let entries;
|
|
344
|
-
try {
|
|
345
|
-
entries = await readdir(base);
|
|
346
|
-
} catch {
|
|
347
|
-
return 0;
|
|
348
|
-
}
|
|
349
|
-
let removed = 0;
|
|
350
|
-
for (const entry of entries) {
|
|
351
|
-
if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
|
|
352
|
-
try {
|
|
353
|
-
await rm(join2(base, entry), { recursive: true, force: true });
|
|
354
|
-
removed++;
|
|
355
|
-
} catch {
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
return removed;
|
|
359
|
-
}
|
|
360
|
-
async function cloneAtSha(ctx, dir) {
|
|
361
|
-
await git(["clone", "--quiet", cloneUrl(ctx), dir]);
|
|
362
|
-
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
|
|
363
|
-
}
|
|
364
|
-
async function cloneResumingBranch(ctx, dir) {
|
|
365
|
-
await git(["clone", "--quiet", cloneUrl(ctx), dir]);
|
|
366
|
-
try {
|
|
367
|
-
await git(["-C", dir, "fetch", "--quiet", "origin", ctx.repo.checkoutBranch]);
|
|
368
|
-
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, "FETCH_HEAD"]);
|
|
369
|
-
return { resumed: true };
|
|
370
|
-
} catch {
|
|
371
|
-
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
|
|
372
|
-
return { resumed: false };
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
async function hasChanges(dir) {
|
|
376
|
-
await git(["-C", dir, "add", "-A"]);
|
|
377
|
-
const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
|
|
378
|
-
return stdout2.trim().length > 0;
|
|
379
|
-
}
|
|
380
|
-
async function gitDiffStat(dir) {
|
|
381
|
-
const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "--stat"]);
|
|
382
|
-
return stdout2;
|
|
383
|
-
}
|
|
384
|
-
var PreCommitError = class extends Error {
|
|
385
|
-
constructor(log) {
|
|
386
|
-
super("pre-commit checks failed");
|
|
387
|
-
this.log = log;
|
|
388
|
-
this.name = "PreCommitError";
|
|
389
|
-
}
|
|
390
|
-
};
|
|
391
|
-
function commitFailureLog(err) {
|
|
392
|
-
const e = err;
|
|
393
|
-
const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
|
|
394
|
-
return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
|
|
395
|
-
}
|
|
396
|
-
function isUnsupportedGitSubcommand(err) {
|
|
397
|
-
const e = err;
|
|
398
|
-
const text = `${typeof e.stderr === "string" ? e.stderr : ""}
|
|
399
|
-
${e.message ?? ""}`;
|
|
400
|
-
return /is not a git command|unknown subcommand|usage: git hook/i.test(text);
|
|
401
|
-
}
|
|
402
|
-
async function runRepoChecks(dir) {
|
|
403
|
-
try {
|
|
404
|
-
await git(["-C", dir, "hook", "run", "pre-commit"]);
|
|
405
|
-
logEvent("checks", "pre-commit hook passed");
|
|
406
|
-
return { ok: true, log: "", skipped: false };
|
|
407
|
-
} catch (err) {
|
|
408
|
-
if (isUnsupportedGitSubcommand(err)) {
|
|
409
|
-
logEvent("checks", "pre-commit hook skipped (git too old)");
|
|
410
|
-
return { ok: true, log: "", skipped: true };
|
|
411
|
-
}
|
|
412
|
-
const log = commitFailureLog(err);
|
|
413
|
-
logEvent("checks:err", log);
|
|
414
|
-
return { ok: false, log, skipped: false };
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
async function commitChanges(ctx, dir) {
|
|
418
|
-
if (!await hasChanges(dir)) return false;
|
|
419
|
-
try {
|
|
420
|
-
await git(["-C", dir, "commit", "--quiet", "-m", `FlumeCode: ${jobTitle(ctx)}`]);
|
|
421
|
-
} catch (err) {
|
|
422
|
-
throw new PreCommitError(commitFailureLog(err));
|
|
423
|
-
}
|
|
424
|
-
return true;
|
|
425
|
-
}
|
|
426
|
-
async function pushBranch(ctx, dir) {
|
|
427
|
-
await git(["-C", dir, "push", "--quiet", "-u", "origin", ctx.repo.checkoutBranch]);
|
|
428
|
-
}
|
|
429
|
-
var RebaseConflictError = class extends Error {
|
|
430
|
-
constructor(mergeBranch, files) {
|
|
431
|
-
const list = files.length ? `: ${files.join(", ")}` : "";
|
|
432
|
-
super(`Rebase onto ${mergeBranch} hit conflicts in ${files.length} file(s)${list}`);
|
|
433
|
-
this.mergeBranch = mergeBranch;
|
|
434
|
-
this.files = files;
|
|
435
|
-
this.name = "RebaseConflictError";
|
|
436
|
-
}
|
|
437
|
-
};
|
|
438
|
-
async function rebaseOntoMergeBranch(ctx, dir) {
|
|
439
|
-
const { mergeBranch } = ctx.repo;
|
|
440
|
-
if (!mergeBranch) return;
|
|
441
|
-
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
442
|
-
try {
|
|
443
|
-
await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
|
|
444
|
-
} catch (err) {
|
|
445
|
-
const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
|
|
446
|
-
() => ({ stdout: "" })
|
|
447
|
-
);
|
|
448
|
-
const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
449
|
-
await git(["-C", dir, "rebase", "--abort"]).catch(() => {
|
|
450
|
-
});
|
|
451
|
-
if (files.length === 0) throw err;
|
|
452
|
-
throw new RebaseConflictError(mergeBranch, files);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
async function mergeInMergeBranch(ctx, dir) {
|
|
456
|
-
const { mergeBranch } = ctx.repo;
|
|
457
|
-
if (!mergeBranch) return { conflicted: false };
|
|
458
|
-
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
459
|
-
try {
|
|
460
|
-
await git(["-C", dir, "merge", "--no-edit", "FETCH_HEAD"]);
|
|
461
|
-
return { conflicted: false };
|
|
462
|
-
} catch {
|
|
463
|
-
return { conflicted: true };
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
async function listUnmergedPaths(dir) {
|
|
467
|
-
const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
|
|
468
|
-
stdout: ""
|
|
469
|
-
}));
|
|
470
|
-
return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
471
|
-
}
|
|
472
|
-
async function listConflictMarkerPaths(dir, paths) {
|
|
473
|
-
if (paths.length === 0) return [];
|
|
474
|
-
const { stdout: stdout2 } = await git([
|
|
475
|
-
"-C",
|
|
476
|
-
dir,
|
|
477
|
-
"grep",
|
|
478
|
-
"--no-color",
|
|
479
|
-
"-lE",
|
|
480
|
-
"^(<<<<<<<|>>>>>>>|\\|\\|\\|\\|\\|\\|\\|)",
|
|
481
|
-
"--",
|
|
482
|
-
...paths
|
|
483
|
-
]).catch(() => ({ stdout: "" }));
|
|
484
|
-
return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
485
|
-
}
|
|
486
|
-
async function openPullRequest(ctx) {
|
|
487
|
-
const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
|
|
488
|
-
if (!mergeBranch) return null;
|
|
489
|
-
const apiBase = `https://api.github.com/repos/${owner}/${name}`;
|
|
490
|
-
const headers = {
|
|
491
|
-
authorization: `Bearer ${cloneToken}`,
|
|
492
|
-
accept: "application/vnd.github+json",
|
|
493
|
-
"x-github-api-version": "2022-11-28",
|
|
494
|
-
"content-type": "application/json"
|
|
495
|
-
};
|
|
496
|
-
const title = jobTitle(ctx);
|
|
497
|
-
const body = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
|
|
498
|
-
const res = await fetch(`${apiBase}/pulls`, {
|
|
499
|
-
method: "POST",
|
|
500
|
-
headers,
|
|
501
|
-
body: JSON.stringify({
|
|
502
|
-
title: `FlumeCode: ${title}`,
|
|
503
|
-
head: checkoutBranch,
|
|
504
|
-
base: mergeBranch,
|
|
505
|
-
body
|
|
506
|
-
})
|
|
507
|
-
});
|
|
508
|
-
if (res.status === 201) {
|
|
509
|
-
const data = await res.json();
|
|
510
|
-
return { number: data.number, url: data.html_url };
|
|
511
|
-
}
|
|
512
|
-
if (res.status === 422) {
|
|
513
|
-
const list = await fetch(
|
|
514
|
-
`${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
|
|
515
|
-
{ headers }
|
|
516
|
-
);
|
|
517
|
-
if (list.ok) {
|
|
518
|
-
const open = await list.json();
|
|
519
|
-
if (open[0]) return { number: open[0].number, url: open[0].html_url };
|
|
520
|
-
}
|
|
521
|
-
return null;
|
|
522
|
-
}
|
|
523
|
-
throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
|
|
524
|
-
}
|
|
525
|
-
async function cleanup(dir) {
|
|
526
|
-
await rm(dir, { recursive: true, force: true });
|
|
527
|
-
}
|
|
528
|
-
function parsePrFromSubject(subject) {
|
|
529
|
-
const m = subject.match(/\(#(\d+)\)\s*$/);
|
|
530
|
-
return m ? Number(m[1]) : null;
|
|
531
|
-
}
|
|
532
|
-
async function incomingPrNumbers(ctx, dir, paths) {
|
|
533
|
-
if (!paths.length) return [];
|
|
534
|
-
try {
|
|
535
|
-
const mergeHeadResult = await git(["-C", dir, "rev-parse", "MERGE_HEAD"]);
|
|
536
|
-
const mergeHead = mergeHeadResult.stdout.trim();
|
|
537
|
-
const baseResult = await git(["-C", dir, "merge-base", "HEAD", mergeHead]);
|
|
538
|
-
const base = baseResult.stdout.trim();
|
|
539
|
-
const logResult = await git([
|
|
540
|
-
"-C",
|
|
541
|
-
dir,
|
|
542
|
-
"log",
|
|
543
|
-
"--no-merges",
|
|
544
|
-
`--format=%H%x1f%s`,
|
|
545
|
-
`${base}..${mergeHead}`,
|
|
546
|
-
"--",
|
|
547
|
-
...paths
|
|
548
|
-
]);
|
|
549
|
-
const nums = /* @__PURE__ */ new Set();
|
|
550
|
-
const needLookup = [];
|
|
551
|
-
for (const line of logResult.stdout.split("\n").filter(Boolean)) {
|
|
552
|
-
const idx = line.indexOf("");
|
|
553
|
-
const sha = line.slice(0, idx);
|
|
554
|
-
const subject = line.slice(idx + 1);
|
|
555
|
-
const n = parsePrFromSubject(subject);
|
|
556
|
-
if (n !== null) nums.add(n);
|
|
557
|
-
else needLookup.push(sha);
|
|
558
|
-
}
|
|
559
|
-
for (const sha of needLookup) {
|
|
560
|
-
for (const n of await prNumbersForCommit(ctx, sha)) nums.add(n);
|
|
561
|
-
}
|
|
562
|
-
return [...nums];
|
|
563
|
-
} catch {
|
|
564
|
-
return [];
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
async function prNumbersForCommit(ctx, sha) {
|
|
568
|
-
const { owner, name, cloneToken } = ctx.repo;
|
|
569
|
-
try {
|
|
570
|
-
const res = await fetch(`https://api.github.com/repos/${owner}/${name}/commits/${sha}/pulls`, {
|
|
571
|
-
headers: {
|
|
572
|
-
authorization: `Bearer ${cloneToken}`,
|
|
573
|
-
accept: "application/vnd.github+json",
|
|
574
|
-
"x-github-api-version": "2022-11-28"
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
if (!res.ok) return [];
|
|
578
|
-
return (await res.json()).map((p) => p.number);
|
|
579
|
-
} catch {
|
|
580
|
-
return [];
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// src/plugins/manifest.ts
|
|
585
|
-
import { existsSync as existsSync3 } from "node:fs";
|
|
586
|
-
import { readdir as readdir2, readFile } from "node:fs/promises";
|
|
587
|
-
import { join as join3 } from "node:path";
|
|
588
|
-
async function loadPlugins(dir) {
|
|
589
|
-
const pluginsDir = join3(dir, ".flumecode", "plugins");
|
|
590
|
-
if (!existsSync3(pluginsDir)) return [];
|
|
591
|
-
let entries;
|
|
592
|
-
try {
|
|
593
|
-
entries = await readdir2(pluginsDir);
|
|
594
|
-
} catch {
|
|
595
|
-
return [];
|
|
596
|
-
}
|
|
597
|
-
const manifests = [];
|
|
598
|
-
for (const entry of entries) {
|
|
599
|
-
const manifestPath = join3(pluginsDir, entry, "plugin.json");
|
|
600
|
-
try {
|
|
601
|
-
const raw = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
602
|
-
const manifest = parseManifest(raw);
|
|
603
|
-
if (manifest) manifests.push(manifest);
|
|
604
|
-
} catch {
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
return manifests;
|
|
608
|
-
}
|
|
609
|
-
function parseManifest(raw) {
|
|
610
|
-
if (typeof raw !== "object" || raw === null) return null;
|
|
611
|
-
const r = raw;
|
|
612
|
-
if (typeof r.key !== "string" || !r.key) return null;
|
|
613
|
-
if (r.socket !== "pre-commit") return null;
|
|
614
|
-
if (typeof r.run !== "string" || !r.run) return null;
|
|
615
|
-
return { key: r.key, socket: r.socket, run: r.run };
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// src/plugins/socket.ts
|
|
619
|
-
var exec2 = promisify2(execCb);
|
|
620
|
-
var MAX_OUTPUT = 8 * 1024;
|
|
621
|
-
function cap(s) {
|
|
622
|
-
return s.length <= MAX_OUTPUT ? s : s.slice(s.length - MAX_OUTPUT);
|
|
623
|
-
}
|
|
624
|
-
var lastSocketResults = [];
|
|
625
|
-
function resetSocketResults() {
|
|
626
|
-
lastSocketResults = [];
|
|
627
|
-
}
|
|
628
|
-
function getSocketResults() {
|
|
629
|
-
return lastSocketResults;
|
|
630
|
-
}
|
|
631
|
-
async function runSocket(socketName, dir) {
|
|
632
|
-
const plugins = (await loadPlugins(dir)).filter((p) => p.socket === socketName);
|
|
633
|
-
const results = [];
|
|
634
|
-
for (const plugin of plugins) {
|
|
635
|
-
const result = await runPluginCommand(plugin.run, dir);
|
|
636
|
-
if (result.exitCode !== 0) {
|
|
637
|
-
results.push({ key: plugin.key, status: "failed", output: cap(result.output) });
|
|
638
|
-
lastSocketResults = results;
|
|
639
|
-
throw new PreCommitError(`[plugin:${plugin.key}] ${result.output}`);
|
|
640
|
-
}
|
|
641
|
-
results.push({ key: plugin.key, status: "passed", output: cap(result.output) });
|
|
642
|
-
}
|
|
643
|
-
lastSocketResults = results;
|
|
644
|
-
}
|
|
645
|
-
async function runPluginCommand(command2, cwd) {
|
|
646
|
-
try {
|
|
647
|
-
const result = await exec2(command2, { cwd, maxBuffer: 1 << 24 });
|
|
648
|
-
const output = [result.stdout, result.stderr].map((s) => s.trim()).filter(Boolean).join("\n");
|
|
649
|
-
return { exitCode: 0, output };
|
|
650
|
-
} catch (err) {
|
|
651
|
-
const e = err;
|
|
652
|
-
const output = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
|
|
653
|
-
return { exitCode: typeof e.code === "number" ? e.code : 1, output };
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// src/executor.ts
|
|
658
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
659
|
-
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
660
|
-
|
|
661
|
-
// src/widgets.ts
|
|
662
|
-
import { randomUUID } from "node:crypto";
|
|
663
|
-
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
664
|
-
import { z } from "zod";
|
|
665
|
-
var SERVER_NAME = "flume_widgets";
|
|
666
|
-
var SINGLE_SELECT = "single_select";
|
|
667
|
-
var MULTI_SELECT = "multi_select";
|
|
668
|
-
var WIDGET_TOOL_NAMES = [
|
|
669
|
-
`mcp__${SERVER_NAME}__${SINGLE_SELECT}`,
|
|
670
|
-
`mcp__${SERVER_NAME}__${MULTI_SELECT}`
|
|
671
|
-
];
|
|
672
|
-
var optionsSchema = z.array(z.string().min(1)).min(2).max(8).describe("2\u20138 short, distinct choices for the user to pick from.");
|
|
673
|
-
var TAIL = "Do NOT add an 'Other' or 'None of these' catch-all \u2014 the UI always offers an 'Other' free-text option automatically. After calling this, END YOUR TURN and wait: the user's answer arrives as their next message and starts a fresh run.";
|
|
674
|
-
function createWidgetTooling() {
|
|
675
|
-
const collected = [];
|
|
676
|
-
const singleSelect = tool(
|
|
677
|
-
SINGLE_SELECT,
|
|
678
|
-
"Ask the user a single-select (radio-button) question \u2014 exactly one answer. Use this for a genuine either/or choice (competing approaches, scope decisions, yes/no) instead of writing the options as prose. " + TAIL,
|
|
679
|
-
{
|
|
680
|
-
question: z.string().min(1).describe("The question to ask the user."),
|
|
681
|
-
options: optionsSchema
|
|
682
|
-
},
|
|
683
|
-
async (args) => {
|
|
684
|
-
collected.push({
|
|
685
|
-
id: randomUUID(),
|
|
686
|
-
type: "single_select",
|
|
687
|
-
question: args.question,
|
|
688
|
-
options: args.options.map((label) => ({ id: randomUUID(), label })),
|
|
689
|
-
selectedOptionId: null,
|
|
690
|
-
customAnswer: null
|
|
691
|
-
});
|
|
692
|
-
return widgetPosted("single-select");
|
|
693
|
-
}
|
|
694
|
-
);
|
|
695
|
-
const multiSelect = tool(
|
|
696
|
-
MULTI_SELECT,
|
|
697
|
-
"Ask the user a multi-select (checkbox) question \u2014 they may pick any number of options, including none of the presets if they use 'Other'. Use this for 'select all that apply' questions (which features to include, which files to touch). " + TAIL,
|
|
698
|
-
{
|
|
699
|
-
question: z.string().min(1).describe("The question to ask the user."),
|
|
700
|
-
options: optionsSchema
|
|
701
|
-
},
|
|
702
|
-
async (args) => {
|
|
703
|
-
collected.push({
|
|
704
|
-
id: randomUUID(),
|
|
705
|
-
type: "multi_select",
|
|
706
|
-
question: args.question,
|
|
707
|
-
options: args.options.map((label) => ({ id: randomUUID(), label })),
|
|
708
|
-
selectedOptionIds: null,
|
|
709
|
-
customAnswer: null
|
|
710
|
-
});
|
|
711
|
-
return widgetPosted("multi-select");
|
|
712
|
-
}
|
|
713
|
-
);
|
|
714
|
-
const mcpServer = createSdkMcpServer({
|
|
715
|
-
name: SERVER_NAME,
|
|
716
|
-
tools: [singleSelect, multiSelect]
|
|
717
|
-
});
|
|
718
|
-
return { mcpServer, collected };
|
|
719
|
-
}
|
|
720
|
-
function widgetPosted(kind) {
|
|
721
|
-
return {
|
|
722
|
-
content: [
|
|
723
|
-
{
|
|
724
|
-
type: "text",
|
|
725
|
-
text: `Question posted to the user as a ${kind} widget. End your turn now and wait \u2014 their answer will arrive as their next message.`
|
|
726
|
-
}
|
|
727
|
-
]
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// src/plan.ts
|
|
732
|
-
import { createSdkMcpServer as createSdkMcpServer2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
|
|
733
|
-
import { z as z2 } from "zod";
|
|
734
|
-
|
|
735
|
-
// src/schema-hints.ts
|
|
736
|
-
var INLINE_CODE_HINT = "Wrap code identifiers (function, variable, type, and file names, commands, and flags) in inline backticks, e.g. `getCodingSessionsForRequest`.";
|
|
737
|
-
|
|
738
|
-
// src/plan.ts
|
|
739
|
-
var SERVER_NAME2 = "flume_plan";
|
|
740
|
-
var SUBMIT_PLAN = "submit_plan";
|
|
741
|
-
var PLAN_TOOL_NAME = `mcp__${SERVER_NAME2}__${SUBMIT_PLAN}`;
|
|
742
|
-
var PLAN_MARKER = "<!-- flumecode:end-of-plan -->";
|
|
743
|
-
var pseudoCodeEntrySchema = z2.object({
|
|
744
|
-
file: z2.string().min(1),
|
|
745
|
-
pseudoCode: z2.string().min(1)
|
|
746
|
-
});
|
|
747
|
-
var stepSchema = z2.object({
|
|
748
|
-
title: z2.string().min(1).describe("A concise imperative title for this step."),
|
|
749
|
-
description: z2.array(z2.string().min(1)).min(1).describe(
|
|
750
|
-
"Bullet points that explain this step's change so a reviewer can judge whether the design is correct. Each array item is one short, self-contained bullet \u2014 not a single paragraph, and not a restatement of the pseudo code. " + INLINE_CODE_HINT
|
|
751
|
-
),
|
|
752
|
-
pseudoCode: z2.array(pseudoCodeEntrySchema).optional().describe(
|
|
753
|
-
"Per-file pseudo code. Provide an entry for every non-documentation file this step touches. Each entry contains the file path and pseudo code describing the changes to that file."
|
|
754
|
-
)
|
|
755
|
-
});
|
|
756
|
-
var planInputSchema = {
|
|
757
|
-
title: z2.string().min(1).max(120).describe(
|
|
758
|
-
"A concise, descriptive name for THIS plan. Must be distinct from the request title and from any sibling plans on the same request. Keep it under 120 characters."
|
|
759
|
-
),
|
|
760
|
-
scope: z2.enum(["feat", "fix", "chore", "docs", "test", "refactor"]).describe("The primary intent of the change."),
|
|
761
|
-
goal: z2.string().min(1).describe("One or two sentences stating the outcome. " + INLINE_CODE_HINT),
|
|
762
|
-
assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
|
|
763
|
-
steps: z2.array(stepSchema).min(1).describe("Ordered list of changes. Each step says what and why, with file references."),
|
|
764
|
-
acceptanceCriteria: z2.array(z2.string().min(1)).min(2).describe(
|
|
765
|
-
"Concrete, deterministically-checkable conditions that together define done. Each names a trigger/precondition and the exact observable result (run X -> output Y; file Z contains W; f(a) returns b) \u2014 no vague adjectives, not a restatement of a step. The set must collectively cover every step's change. At least 2 required. " + INLINE_CODE_HINT
|
|
766
|
-
),
|
|
767
|
-
risks: z2.array(z2.string()).describe("Anything that could change the approach."),
|
|
768
|
-
outOfScope: z2.array(z2.string()).describe("What is deliberately not being done.")
|
|
769
|
-
};
|
|
770
|
-
var planSchema = z2.object(planInputSchema);
|
|
771
|
-
function renderPlan(plan) {
|
|
772
|
-
const lines2 = [];
|
|
773
|
-
lines2.push(`# ${plan.title}`);
|
|
774
|
-
lines2.push("");
|
|
775
|
-
lines2.push(`**Scope** \u2014 \`${plan.scope}\``);
|
|
776
|
-
lines2.push("");
|
|
777
|
-
lines2.push(`**Goal** \u2014 ${plan.goal}`);
|
|
778
|
-
if (plan.assumptions.length > 0) {
|
|
779
|
-
lines2.push("");
|
|
780
|
-
lines2.push("**Assumptions**");
|
|
781
|
-
for (const assumption of plan.assumptions) {
|
|
782
|
-
lines2.push(`- ${assumption}`);
|
|
783
|
-
}
|
|
341
|
+
lines2.push("");
|
|
342
|
+
lines2.push("## Requirements");
|
|
343
|
+
for (const requirement of plan.requirements) {
|
|
344
|
+
lines2.push(`- ${requirement}`);
|
|
784
345
|
}
|
|
785
346
|
lines2.push("");
|
|
786
347
|
lines2.push("## Steps");
|
|
@@ -826,7 +387,7 @@ function renderPlan(plan) {
|
|
|
826
387
|
return lines2.join("\n");
|
|
827
388
|
}
|
|
828
389
|
var submitPlanInputSchema = {
|
|
829
|
-
plans: z2.array(z2.object(planInputSchema)).min(1).refine(
|
|
390
|
+
plans: z2.array(requireRootCauseForFix(z2.object(planInputSchema))).min(1).refine(
|
|
830
391
|
(arr) => {
|
|
831
392
|
const titles = arr.map((p) => p.title.trim()).filter((t) => t.length > 0);
|
|
832
393
|
return new Set(titles).size === titles.length;
|
|
@@ -839,7 +400,7 @@ function createPlanTooling() {
|
|
|
839
400
|
let renderedPlans = null;
|
|
840
401
|
const submitPlan = tool2(
|
|
841
402
|
SUBMIT_PLAN,
|
|
842
|
-
|
|
403
|
+
`Submit ALL your plans in a single call \u2014 one entry per plan; each becomes its own independently-acceptable Accept-as-plan draft. Do NOT call submit_plan more than once. acceptanceCriteria is required in each plan and must contain at least 2 observable, verifiable conditions. The 'title' field names each specific plan \u2014 make it concise and distinct from the request title and from sibling plan titles. requirements is required in each plan: at least 1 plain-language statement of what the change must accomplish and why (human-readable intent), separate from the machine-checkable acceptanceCriteria. When a plan's scope is "fix", rootCause is required: a non-empty explanation of the underlying cause of the bug (not just the symptom). `,
|
|
843
404
|
submitPlanInputSchema,
|
|
844
405
|
async (args) => {
|
|
845
406
|
const parsed = submitPlanSchema.parse(args);
|
|
@@ -911,8 +472,11 @@ var reportInputSchema = {
|
|
|
911
472
|
caveats: z3.string().min(1).describe(
|
|
912
473
|
"Markdown: anything deferred, unmet, or worth a human's eyes, incl. diff hunks that map to no plan AC. Write 'None.' if nothing. Rendered under '## Caveats / follow-ups'. " + INLINE_CODE_HINT
|
|
913
474
|
),
|
|
914
|
-
acceptanceCriteria: z3.array(acVerdictSchema).
|
|
915
|
-
"One entry per acceptance criterion from the plan, in plan order, each with a verdict and the diff evidence behind it."
|
|
475
|
+
acceptanceCriteria: z3.array(acVerdictSchema).describe(
|
|
476
|
+
"One entry per acceptance criterion from the plan, in plan order, each with a verdict and the diff evidence behind it. May be empty for resolve runs (no plan to verify)."
|
|
477
|
+
),
|
|
478
|
+
conflictResolution: z3.string().optional().describe(
|
|
479
|
+
"Markdown: present ONLY when a merge conflict was actually resolved. Explain, per conflicted file, how ours/theirs were integrated. Rendered under '## Conflict resolution'. Omit entirely when no conflict occurred."
|
|
916
480
|
)
|
|
917
481
|
};
|
|
918
482
|
var reportSchema = z3.object(reportInputSchema);
|
|
@@ -920,21 +484,26 @@ function renderReport(report) {
|
|
|
920
484
|
const lines2 = [];
|
|
921
485
|
lines2.push(report.summary.trim());
|
|
922
486
|
lines2.push("", "## Files changed", "", report.filesChanged.trim());
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
lines2.push(`### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
|
|
927
|
-
lines2.push("");
|
|
928
|
-
lines2.push(ac.rationale.trim());
|
|
929
|
-
for (const ev of ac.evidence) {
|
|
487
|
+
if (report.acceptanceCriteria.length > 0) {
|
|
488
|
+
lines2.push("", "## Acceptance criteria");
|
|
489
|
+
for (const ac of report.acceptanceCriteria) {
|
|
930
490
|
lines2.push("");
|
|
931
|
-
lines2.push(
|
|
491
|
+
lines2.push(`### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
|
|
932
492
|
lines2.push("");
|
|
933
|
-
lines2.push(
|
|
934
|
-
|
|
935
|
-
|
|
493
|
+
lines2.push(ac.rationale.trim());
|
|
494
|
+
for (const ev of ac.evidence) {
|
|
495
|
+
lines2.push("");
|
|
496
|
+
lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
|
|
497
|
+
lines2.push("");
|
|
498
|
+
lines2.push("```diff");
|
|
499
|
+
lines2.push(ev.hunk.replace(/\n+$/, ""));
|
|
500
|
+
lines2.push("```");
|
|
501
|
+
}
|
|
936
502
|
}
|
|
937
503
|
}
|
|
504
|
+
if (report.conflictResolution?.trim()) {
|
|
505
|
+
lines2.push("", "## Conflict resolution", "", report.conflictResolution.trim());
|
|
506
|
+
}
|
|
938
507
|
lines2.push("", "## Code quality", "", report.codeQuality.trim());
|
|
939
508
|
lines2.push("", "## Caveats / follow-ups", "", report.caveats.trim());
|
|
940
509
|
return lines2.join("\n");
|
|
@@ -964,6 +533,35 @@ function createReportTooling() {
|
|
|
964
533
|
return { mcpServer, getReport: () => submittedReport };
|
|
965
534
|
}
|
|
966
535
|
|
|
536
|
+
// src/logger.ts
|
|
537
|
+
var lines = [];
|
|
538
|
+
var secrets = [];
|
|
539
|
+
var MAX_BYTES = 10 * 1024 * 1024;
|
|
540
|
+
function startJobLog(opts) {
|
|
541
|
+
lines = [];
|
|
542
|
+
secrets = opts.secrets.filter(Boolean);
|
|
543
|
+
logEvent("meta", `job ${opts.jobId} (${opts.kind}) started at ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
544
|
+
}
|
|
545
|
+
function redact(s) {
|
|
546
|
+
for (const sec of secrets) {
|
|
547
|
+
s = s.split(sec).join("***REDACTED***");
|
|
548
|
+
}
|
|
549
|
+
return s;
|
|
550
|
+
}
|
|
551
|
+
function logEvent(section, text) {
|
|
552
|
+
lines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] [${section}] ${redact(text)}`);
|
|
553
|
+
}
|
|
554
|
+
function getJobLog() {
|
|
555
|
+
const full = lines.join("\n");
|
|
556
|
+
if (full.length <= MAX_BYTES) return full;
|
|
557
|
+
const half = Math.floor(MAX_BYTES / 2);
|
|
558
|
+
return full.slice(0, half) + `
|
|
559
|
+
|
|
560
|
+
\u2026[truncated ${full.length - MAX_BYTES} bytes]\u2026
|
|
561
|
+
|
|
562
|
+
` + full.slice(-half);
|
|
563
|
+
}
|
|
564
|
+
|
|
967
565
|
// src/executor.ts
|
|
968
566
|
var FLUME_PLUGIN_DIR = fileURLToPath2(new URL("../skills-plugin", import.meta.url));
|
|
969
567
|
function emptyUsage() {
|
|
@@ -1114,11 +712,11 @@ function errorMessage(err) {
|
|
|
1114
712
|
|
|
1115
713
|
// src/rules.ts
|
|
1116
714
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1117
|
-
import { join as
|
|
715
|
+
import { join as join2 } from "node:path";
|
|
1118
716
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
1119
717
|
var RULES_DIR = fileURLToPath3(new URL("../skills-plugin/rules", import.meta.url));
|
|
1120
718
|
function loadRule(name) {
|
|
1121
|
-
const raw = readFileSync3(
|
|
719
|
+
const raw = readFileSync3(join2(RULES_DIR, `${name}.md`), "utf8");
|
|
1122
720
|
return stripFrontMatter(raw).trim();
|
|
1123
721
|
}
|
|
1124
722
|
function stripFrontMatter(raw) {
|
|
@@ -1181,8 +779,123 @@ function buildRevisePrompt(ctx) {
|
|
|
1181
779
|
const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this change. If there is no wiki, work from the code directly.`;
|
|
1182
780
|
const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
|
|
1183
781
|
const lines2 = [
|
|
1184
|
-
`You are "${ctx.agentName}", an autonomous coding agent fine-tuning an implemented FlumeCode plan in an ongoing thread with the user.`,
|
|
1185
|
-
`The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from, so any change you push updates that PR.`,
|
|
782
|
+
`You are "${ctx.agentName}", an autonomous coding agent fine-tuning an implemented FlumeCode plan in an ongoing thread with the user.`,
|
|
783
|
+
`The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from, so any change you push updates that PR.`,
|
|
784
|
+
task,
|
|
785
|
+
orient,
|
|
786
|
+
widgets,
|
|
787
|
+
"",
|
|
788
|
+
"These coding guidelines apply to all code produced in this run:",
|
|
789
|
+
"",
|
|
790
|
+
loadRule("coding-guideline"),
|
|
791
|
+
"",
|
|
792
|
+
WRITING_INTRO,
|
|
793
|
+
"",
|
|
794
|
+
loadRule("technical-writing"),
|
|
795
|
+
"",
|
|
796
|
+
`# Plan: ${ctx.request?.title ?? ""}`
|
|
797
|
+
];
|
|
798
|
+
if (ctx.request?.body) {
|
|
799
|
+
lines2.push("", ctx.request.body);
|
|
800
|
+
}
|
|
801
|
+
appendThread(lines2, ctx);
|
|
802
|
+
lines2.push(
|
|
803
|
+
"",
|
|
804
|
+
"The last message above is the user's request for this turn. Your final reply is posted verbatim as your comment in the plan thread: if you implemented a change, make it a short report of what you changed (the runner appends the pull-request link); if you asked a question, called `submit_plan`, or pushed back, your reply text is posted as-is."
|
|
805
|
+
);
|
|
806
|
+
return lines2.join("\n");
|
|
807
|
+
}
|
|
808
|
+
function buildResolvePrompt(ctx, related = []) {
|
|
809
|
+
const mergeBranch = ctx.repo.mergeBranch ?? "the merge branch";
|
|
810
|
+
const task = `Use the \`flumecode:resolve-merge-conflict\` skill to handle this turn. A merge of \`${mergeBranch}\` into this branch is IN PROGRESS and has left conflict markers in your working tree. Resolve every conflicted file by correctly integrating BOTH sides \u2014 the change this session implemented (described below) and the incoming changes from \`${mergeBranch}\` \u2014 never blindly discard either side. Remove all conflict markers and verify the result builds and tests pass. Do NOT \`git add\`, commit, push, or open a pull request \u2014 the runner finalizes the merge commit and updates the existing pull request.`;
|
|
811
|
+
const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to the conflicting code. If there is no wiki, work from the code directly.`;
|
|
812
|
+
const lines2 = [
|
|
813
|
+
`You are "${ctx.agentName}", an autonomous coding agent resolving merge conflicts on an implemented FlumeCode plan.`,
|
|
814
|
+
`The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from \u2014 with an in-progress merge of "${mergeBranch}".`,
|
|
815
|
+
task,
|
|
816
|
+
orient,
|
|
817
|
+
"",
|
|
818
|
+
"These coding guidelines apply to all code produced in this run:",
|
|
819
|
+
"",
|
|
820
|
+
loadRule("coding-guideline"),
|
|
821
|
+
"",
|
|
822
|
+
WRITING_INTRO,
|
|
823
|
+
"",
|
|
824
|
+
loadRule("technical-writing"),
|
|
825
|
+
"",
|
|
826
|
+
`# Plan: ${ctx.request?.title ?? ""}`
|
|
827
|
+
];
|
|
828
|
+
if (ctx.request?.body) {
|
|
829
|
+
lines2.push("", ctx.request.body);
|
|
830
|
+
}
|
|
831
|
+
appendThread(lines2, ctx);
|
|
832
|
+
if (related.length > 0) {
|
|
833
|
+
lines2.push(
|
|
834
|
+
"",
|
|
835
|
+
"# Related sessions behind the incoming changes",
|
|
836
|
+
"Each conflicting change on the merge branch came from another coding session whose plan and report follow. Preserve THEIR intent too while integrating them with this session's work \u2014 do not undo what they built."
|
|
837
|
+
);
|
|
838
|
+
for (const r of related) {
|
|
839
|
+
lines2.push("", `## PR #${r.prNumber} \u2014 ${r.title}`);
|
|
840
|
+
if (r.plan) lines2.push("", "### Accepted plan", r.plan);
|
|
841
|
+
if (r.report) lines2.push("", "### Final report", r.report);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
lines2.push(
|
|
845
|
+
"",
|
|
846
|
+
"Resolve the conflicts now. Your final reply is posted as a report in the plan thread: summarize which files conflicted and how you resolved each (the runner appends the pull-request link, so don't add one)."
|
|
847
|
+
);
|
|
848
|
+
return lines2.join("\n");
|
|
849
|
+
}
|
|
850
|
+
function buildDocumentPrompt(ctx, changedFiles) {
|
|
851
|
+
const lines2 = [
|
|
852
|
+
`You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
|
|
853
|
+
`An implementation just ran in this working directory to satisfy the request below; its changes are uncommitted in the working tree.`,
|
|
854
|
+
`Use the \`flumecode:document\` skill to bring the wiki in sync with those changes. Only edit files under \`.flumecode/wiki/\` \u2014 do not touch application code. The runner commits the wiki alongside the implementation in the same pull request.`,
|
|
855
|
+
"",
|
|
856
|
+
`# Request: ${ctx.request?.title ?? ""}`
|
|
857
|
+
];
|
|
858
|
+
if (ctx.request?.body) {
|
|
859
|
+
lines2.push("", ctx.request.body);
|
|
860
|
+
}
|
|
861
|
+
appendThread(lines2, ctx);
|
|
862
|
+
if (changedFiles && changedFiles.trim()) {
|
|
863
|
+
lines2.push(
|
|
864
|
+
"",
|
|
865
|
+
"Files changed by this implementation (reconcile only the wiki pages these affect \u2014 do not re-survey the whole repo):",
|
|
866
|
+
"",
|
|
867
|
+
changedFiles.trim()
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
lines2.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
|
|
871
|
+
return lines2.join("\n");
|
|
872
|
+
}
|
|
873
|
+
function buildRepairPrompt(ctx, hookLog) {
|
|
874
|
+
const lines2 = [
|
|
875
|
+
`You are "${ctx.agentName}", fixing a failed pre-commit check in the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
|
|
876
|
+
`The changes from the previous step are still uncommitted in the working tree. When the runner tried to commit them, the repository's pre-commit hook \u2014 which runs the project's own checks (lint / typecheck / unit tests) \u2014 failed. Make the working tree pass those checks: fix the failing code or tests at their root. Do NOT delete or skip tests, weaken assertions, or disable the checks to silence the failure. Preserve the intent of the original change; repair only what's broken. Do NOT commit or push \u2014 the runner re-commits once the checks pass.`,
|
|
877
|
+
"",
|
|
878
|
+
"These coding guidelines apply to all code produced in this run:",
|
|
879
|
+
"",
|
|
880
|
+
loadRule("coding-guideline"),
|
|
881
|
+
"",
|
|
882
|
+
"# Pre-commit hook output",
|
|
883
|
+
"",
|
|
884
|
+
"```",
|
|
885
|
+
hookLog,
|
|
886
|
+
"```",
|
|
887
|
+
"",
|
|
888
|
+
"When done, reply with a one-line summary of what you fixed."
|
|
889
|
+
];
|
|
890
|
+
return lines2.join("\n");
|
|
891
|
+
}
|
|
892
|
+
function buildReleasePrompt(ctx, baseChecks) {
|
|
893
|
+
const task = `Use the \`flumecode:create-release\` skill to handle this turn. You are driving a release: first analyse commits since the last tag, propose version bumps, and ask the user to confirm via widgets (Phase 1); once the user's widget answers appear in the thread, apply the bumps to package.json files and update CHANGELOG.md (Phase 2). Do NOT commit or push \u2014 the runner handles that and opens the bump PR.`;
|
|
894
|
+
const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this release. If there is no wiki, work from the code directly.`;
|
|
895
|
+
const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
|
|
896
|
+
const lines2 = [
|
|
897
|
+
`You are "${ctx.agentName}", an autonomous coding agent driving a FlumeCode release.`,
|
|
898
|
+
`The repository ${ctx.repo.fullName} is checked out in your current working directory on the release bump branch "${ctx.repo.checkoutBranch}".`,
|
|
1186
899
|
task,
|
|
1187
900
|
orient,
|
|
1188
901
|
widgets,
|
|
@@ -1191,157 +904,408 @@ function buildRevisePrompt(ctx) {
|
|
|
1191
904
|
"",
|
|
1192
905
|
loadRule("coding-guideline"),
|
|
1193
906
|
"",
|
|
1194
|
-
|
|
1195
|
-
"",
|
|
1196
|
-
loadRule("technical-writing"),
|
|
1197
|
-
"",
|
|
1198
|
-
`# Plan: ${ctx.request?.title ?? ""}`
|
|
907
|
+
`# Release: ${ctx.request?.title ?? ""}`
|
|
1199
908
|
];
|
|
1200
909
|
if (ctx.request?.body) {
|
|
1201
910
|
lines2.push("", ctx.request.body);
|
|
1202
911
|
}
|
|
912
|
+
if (baseChecks && !baseChecks.ok) {
|
|
913
|
+
lines2.push(
|
|
914
|
+
"",
|
|
915
|
+
"# Pre-release check status",
|
|
916
|
+
"",
|
|
917
|
+
"\u26A0\uFE0F The repository's pre-commit checks (lint / typecheck / tests) are currently FAILING on the base branch, independently of any version bump. A release must not ship a broken base:",
|
|
918
|
+
"",
|
|
919
|
+
"- **Phase 1 (propose):** tell the user, in your reply, that the base currently fails these checks and that the release will fix them as part of the bump.",
|
|
920
|
+
"- **Phase 2 (apply):** fix the failing code at its root so the checks pass, THEN apply the version bumps and CHANGELOG. Do NOT delete/skip tests or weaken assertions. The fixes ship in the same bump PR. Still do NOT commit or push \u2014 the runner does.",
|
|
921
|
+
"",
|
|
922
|
+
"Failing check output:",
|
|
923
|
+
"",
|
|
924
|
+
"```",
|
|
925
|
+
baseChecks.log,
|
|
926
|
+
"```"
|
|
927
|
+
);
|
|
928
|
+
}
|
|
1203
929
|
appendThread(lines2, ctx);
|
|
1204
930
|
lines2.push(
|
|
1205
931
|
"",
|
|
1206
|
-
"
|
|
932
|
+
"Your final reply is posted verbatim as your comment in the release thread \u2014 if you called widgets (Phase 1), your reply text accompanies the questions; if you applied the bumps (Phase 2), make it the report the skill produced. The runner appends the pull-request link."
|
|
1207
933
|
);
|
|
1208
934
|
return lines2.join("\n");
|
|
1209
935
|
}
|
|
1210
|
-
function
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
const lines2 = [
|
|
1215
|
-
`You are "${ctx.agentName}", an autonomous coding agent resolving merge conflicts on an implemented FlumeCode plan.`,
|
|
1216
|
-
`The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from \u2014 with an in-progress merge of "${mergeBranch}".`,
|
|
1217
|
-
task,
|
|
1218
|
-
orient,
|
|
1219
|
-
"",
|
|
1220
|
-
"These coding guidelines apply to all code produced in this run:",
|
|
1221
|
-
"",
|
|
1222
|
-
loadRule("coding-guideline"),
|
|
1223
|
-
"",
|
|
1224
|
-
WRITING_INTRO,
|
|
1225
|
-
"",
|
|
1226
|
-
loadRule("technical-writing"),
|
|
936
|
+
function buildInitPrompt(ctx) {
|
|
937
|
+
return [
|
|
938
|
+
`You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
|
|
939
|
+
`Use the \`flumecode:document\` skill to create the initial repository wiki under \`.flumecode/wiki/\`. The wiki does not exist yet, so the skill will bootstrap it: survey the codebase and produce a high-level overview plus per-component pages. Only create files under \`.flumecode/\` \u2014 do not modify application code. The runner commits the result and opens a pull request.`,
|
|
1227
940
|
"",
|
|
1228
|
-
|
|
1229
|
-
];
|
|
1230
|
-
|
|
1231
|
-
|
|
941
|
+
"When done, reply with a one- or two-line summary of the wiki you created."
|
|
942
|
+
].join("\n");
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// src/types.ts
|
|
946
|
+
function jobTitle(ctx) {
|
|
947
|
+
return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// src/workspace.ts
|
|
951
|
+
import { execFile } from "node:child_process";
|
|
952
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
953
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
954
|
+
import { tmpdir } from "node:os";
|
|
955
|
+
import { join as join3 } from "node:path";
|
|
956
|
+
import { promisify } from "node:util";
|
|
957
|
+
var exec = promisify(execFile);
|
|
958
|
+
var WORKSPACE_PREFIX = "flume-runner-";
|
|
959
|
+
var MAX_BUFFER = 1 << 24;
|
|
960
|
+
async function git(args) {
|
|
961
|
+
logEvent("git", `git ${args.join(" ")}`);
|
|
962
|
+
try {
|
|
963
|
+
const result = await exec("git", args, { maxBuffer: MAX_BUFFER });
|
|
964
|
+
if (result.stdout.trim()) logEvent("git:out", result.stdout.trim());
|
|
965
|
+
if (result.stderr.trim()) logEvent("git:err", result.stderr.trim());
|
|
966
|
+
return result;
|
|
967
|
+
} catch (err) {
|
|
968
|
+
logEvent("git:err", String(err.stderr ?? err));
|
|
969
|
+
throw err;
|
|
1232
970
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
971
|
+
}
|
|
972
|
+
async function ensureGitIdentity(dir, identity) {
|
|
973
|
+
await git(["-C", dir, "config", "user.email", identity.email]);
|
|
974
|
+
await git(["-C", dir, "config", "user.name", identity.name]);
|
|
975
|
+
}
|
|
976
|
+
function cloneUrl(ctx) {
|
|
977
|
+
const { owner, name, cloneToken } = ctx.repo;
|
|
978
|
+
return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
|
|
979
|
+
}
|
|
980
|
+
function detectPackageManager(dir) {
|
|
981
|
+
if (!existsSync2(join3(dir, "package.json"))) return null;
|
|
982
|
+
if (existsSync2(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
983
|
+
if (existsSync2(join3(dir, "yarn.lock"))) return "yarn";
|
|
984
|
+
if (existsSync2(join3(dir, "package-lock.json"))) return "npm";
|
|
985
|
+
if (existsSync2(join3(dir, "bun.lockb"))) return "bun";
|
|
986
|
+
return "npm";
|
|
987
|
+
}
|
|
988
|
+
async function installDependencies(dir) {
|
|
989
|
+
const manager = detectPackageManager(dir);
|
|
990
|
+
if (manager === null) return { status: "skipped" };
|
|
991
|
+
const env = { ...process.env, CI: "1", ADBLOCK: "1", DISABLE_OPENCOLLECTIVE: "1" };
|
|
992
|
+
logEvent("install", `${manager} install`);
|
|
993
|
+
try {
|
|
994
|
+
const result = await exec(manager, ["install"], {
|
|
995
|
+
cwd: dir,
|
|
996
|
+
maxBuffer: MAX_BUFFER,
|
|
997
|
+
env,
|
|
998
|
+
timeout: 5 * 6e4
|
|
999
|
+
});
|
|
1000
|
+
if (result.stdout.trim()) logEvent("install:out", result.stdout.trim());
|
|
1001
|
+
if (result.stderr.trim()) logEvent("install:err", result.stderr.trim());
|
|
1002
|
+
return { status: "installed", manager };
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
const e = err;
|
|
1005
|
+
const detail = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
|
|
1006
|
+
logEvent("install:err", detail || (err instanceof Error ? err.message : String(err)));
|
|
1007
|
+
return { status: "failed", manager, error: err instanceof Error ? err.message : String(err) };
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
async function makeWorkspace() {
|
|
1011
|
+
return mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX));
|
|
1012
|
+
}
|
|
1013
|
+
var MAX_WORKSPACES = 8;
|
|
1014
|
+
var workspaceRegistry = /* @__PURE__ */ new Map();
|
|
1015
|
+
async function acquireWorkspace(key) {
|
|
1016
|
+
const existing = workspaceRegistry.get(key);
|
|
1017
|
+
if (existing !== void 0 && existsSync2(existing)) {
|
|
1018
|
+
workspaceRegistry.delete(key);
|
|
1019
|
+
workspaceRegistry.set(key, existing);
|
|
1020
|
+
return { dir: existing, reused: true };
|
|
1021
|
+
}
|
|
1022
|
+
const dir = await makeWorkspace();
|
|
1023
|
+
workspaceRegistry.set(key, dir);
|
|
1024
|
+
if (workspaceRegistry.size > MAX_WORKSPACES) {
|
|
1025
|
+
const oldest = workspaceRegistry.keys().next().value;
|
|
1026
|
+
const oldDir = workspaceRegistry.get(oldest);
|
|
1027
|
+
workspaceRegistry.delete(oldest);
|
|
1028
|
+
rm(oldDir, { recursive: true, force: true }).catch(() => {
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
return { dir, reused: false };
|
|
1032
|
+
}
|
|
1033
|
+
async function discardWorkspace(key) {
|
|
1034
|
+
const dir = workspaceRegistry.get(key);
|
|
1035
|
+
workspaceRegistry.delete(key);
|
|
1036
|
+
if (dir !== void 0) {
|
|
1037
|
+
await cleanup(dir).catch(() => {
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
async function resetWorkspace(dir) {
|
|
1042
|
+
await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
|
|
1043
|
+
});
|
|
1044
|
+
await git(["-C", dir, "clean", "-fd"]).catch(() => {
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
async function prepareAtSha(ctx, dir, reused) {
|
|
1048
|
+
const identity = { name: ctx.agentName, email: ctx.agentEmail };
|
|
1049
|
+
if (!reused) {
|
|
1050
|
+
await cloneAtSha(ctx, dir);
|
|
1051
|
+
await ensureGitIdentity(dir, identity);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
1055
|
+
await ensureGitIdentity(dir, identity);
|
|
1056
|
+
}
|
|
1057
|
+
async function prepareResumingBranch(ctx, dir, reused) {
|
|
1058
|
+
const identity = { name: ctx.agentName, email: ctx.agentEmail };
|
|
1059
|
+
if (!reused) {
|
|
1060
|
+
const result = await cloneResumingBranch(ctx, dir);
|
|
1061
|
+
await ensureGitIdentity(dir, identity);
|
|
1062
|
+
return result;
|
|
1063
|
+
}
|
|
1064
|
+
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
1065
|
+
await ensureGitIdentity(dir, identity);
|
|
1066
|
+
return { resumed: true };
|
|
1067
|
+
}
|
|
1068
|
+
async function sweepWorkspaces() {
|
|
1069
|
+
const base = tmpdir();
|
|
1070
|
+
let entries;
|
|
1071
|
+
try {
|
|
1072
|
+
entries = await readdir(base);
|
|
1073
|
+
} catch {
|
|
1074
|
+
return 0;
|
|
1075
|
+
}
|
|
1076
|
+
let removed = 0;
|
|
1077
|
+
for (const entry of entries) {
|
|
1078
|
+
if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
|
|
1079
|
+
try {
|
|
1080
|
+
await rm(join3(base, entry), { recursive: true, force: true });
|
|
1081
|
+
removed++;
|
|
1082
|
+
} catch {
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return removed;
|
|
1086
|
+
}
|
|
1087
|
+
async function cloneAtSha(ctx, dir) {
|
|
1088
|
+
await git(["clone", "--quiet", cloneUrl(ctx), dir]);
|
|
1089
|
+
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
|
|
1090
|
+
}
|
|
1091
|
+
async function cloneResumingBranch(ctx, dir) {
|
|
1092
|
+
await git(["clone", "--quiet", cloneUrl(ctx), dir]);
|
|
1093
|
+
try {
|
|
1094
|
+
await git(["-C", dir, "fetch", "--quiet", "origin", ctx.repo.checkoutBranch]);
|
|
1095
|
+
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, "FETCH_HEAD"]);
|
|
1096
|
+
return { resumed: true };
|
|
1097
|
+
} catch {
|
|
1098
|
+
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
|
|
1099
|
+
return { resumed: false };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async function hasChanges(dir) {
|
|
1103
|
+
await git(["-C", dir, "add", "-A"]);
|
|
1104
|
+
const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
|
|
1105
|
+
return stdout2.trim().length > 0;
|
|
1106
|
+
}
|
|
1107
|
+
async function gitDiffStat(dir) {
|
|
1108
|
+
const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "--stat"]);
|
|
1109
|
+
return stdout2;
|
|
1110
|
+
}
|
|
1111
|
+
var PreCommitError = class extends Error {
|
|
1112
|
+
constructor(log) {
|
|
1113
|
+
super("pre-commit checks failed");
|
|
1114
|
+
this.log = log;
|
|
1115
|
+
this.name = "PreCommitError";
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
function commitFailureLog(err) {
|
|
1119
|
+
const e = err;
|
|
1120
|
+
const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
|
|
1121
|
+
return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
|
|
1122
|
+
}
|
|
1123
|
+
function isUnsupportedGitSubcommand(err) {
|
|
1124
|
+
const e = err;
|
|
1125
|
+
const text = `${typeof e.stderr === "string" ? e.stderr : ""}
|
|
1126
|
+
${e.message ?? ""}`;
|
|
1127
|
+
return /is not a git command|unknown subcommand|usage: git hook/i.test(text);
|
|
1128
|
+
}
|
|
1129
|
+
async function runRepoChecks(dir) {
|
|
1130
|
+
try {
|
|
1131
|
+
await git(["-C", dir, "hook", "run", "pre-commit"]);
|
|
1132
|
+
logEvent("checks", "pre-commit hook passed");
|
|
1133
|
+
return { ok: true, log: "", skipped: false };
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
if (isUnsupportedGitSubcommand(err)) {
|
|
1136
|
+
logEvent("checks", "pre-commit hook skipped (git too old)");
|
|
1137
|
+
return { ok: true, log: "", skipped: true };
|
|
1244
1138
|
}
|
|
1139
|
+
const log = commitFailureLog(err);
|
|
1140
|
+
logEvent("checks:err", log);
|
|
1141
|
+
return { ok: false, log, skipped: false };
|
|
1245
1142
|
}
|
|
1246
|
-
lines2.push(
|
|
1247
|
-
"",
|
|
1248
|
-
"Resolve the conflicts now. Your final reply is posted as a report in the plan thread: summarize which files conflicted and how you resolved each (the runner appends the pull-request link, so don't add one)."
|
|
1249
|
-
);
|
|
1250
|
-
return lines2.join("\n");
|
|
1251
1143
|
}
|
|
1252
|
-
function
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
`# Request: ${ctx.request?.title ?? ""}`
|
|
1259
|
-
];
|
|
1260
|
-
if (ctx.request?.body) {
|
|
1261
|
-
lines2.push("", ctx.request.body);
|
|
1144
|
+
async function commitChanges(ctx, dir) {
|
|
1145
|
+
if (!await hasChanges(dir)) return false;
|
|
1146
|
+
try {
|
|
1147
|
+
await git(["-C", dir, "commit", "--quiet", "-m", `FlumeCode: ${jobTitle(ctx)}`]);
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
throw new PreCommitError(commitFailureLog(err));
|
|
1262
1150
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
async function pushBranch(ctx, dir) {
|
|
1154
|
+
await git(["-C", dir, "push", "--quiet", "-u", "origin", ctx.repo.checkoutBranch]);
|
|
1155
|
+
}
|
|
1156
|
+
var RebaseConflictError = class extends Error {
|
|
1157
|
+
constructor(mergeBranch, files) {
|
|
1158
|
+
const list = files.length ? `: ${files.join(", ")}` : "";
|
|
1159
|
+
super(`Rebase onto ${mergeBranch} hit conflicts in ${files.length} file(s)${list}`);
|
|
1160
|
+
this.mergeBranch = mergeBranch;
|
|
1161
|
+
this.files = files;
|
|
1162
|
+
this.name = "RebaseConflictError";
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
async function rebaseOntoMergeBranch(ctx, dir) {
|
|
1166
|
+
const { mergeBranch } = ctx.repo;
|
|
1167
|
+
if (!mergeBranch) return;
|
|
1168
|
+
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
1169
|
+
try {
|
|
1170
|
+
await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
|
|
1173
|
+
() => ({ stdout: "" })
|
|
1270
1174
|
);
|
|
1175
|
+
const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1176
|
+
await git(["-C", dir, "rebase", "--abort"]).catch(() => {
|
|
1177
|
+
});
|
|
1178
|
+
if (files.length === 0) throw err;
|
|
1179
|
+
throw new RebaseConflictError(mergeBranch, files);
|
|
1271
1180
|
}
|
|
1272
|
-
lines2.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
|
|
1273
|
-
return lines2.join("\n");
|
|
1274
1181
|
}
|
|
1275
|
-
function
|
|
1276
|
-
const
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
"
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
"",
|
|
1286
|
-
"```",
|
|
1287
|
-
hookLog,
|
|
1288
|
-
"```",
|
|
1289
|
-
"",
|
|
1290
|
-
"When done, reply with a one-line summary of what you fixed."
|
|
1291
|
-
];
|
|
1292
|
-
return lines2.join("\n");
|
|
1182
|
+
async function mergeInMergeBranch(ctx, dir) {
|
|
1183
|
+
const { mergeBranch } = ctx.repo;
|
|
1184
|
+
if (!mergeBranch) return { conflicted: false };
|
|
1185
|
+
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
1186
|
+
try {
|
|
1187
|
+
await git(["-C", dir, "merge", "--no-edit", "FETCH_HEAD"]);
|
|
1188
|
+
return { conflicted: false };
|
|
1189
|
+
} catch {
|
|
1190
|
+
return { conflicted: true };
|
|
1191
|
+
}
|
|
1293
1192
|
}
|
|
1294
|
-
function
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
"
|
|
1306
|
-
"",
|
|
1307
|
-
|
|
1308
|
-
"",
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1193
|
+
async function listUnmergedPaths(dir) {
|
|
1194
|
+
const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
|
|
1195
|
+
stdout: ""
|
|
1196
|
+
}));
|
|
1197
|
+
return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1198
|
+
}
|
|
1199
|
+
async function listConflictMarkerPaths(dir, paths) {
|
|
1200
|
+
if (paths.length === 0) return [];
|
|
1201
|
+
const { stdout: stdout2 } = await git([
|
|
1202
|
+
"-C",
|
|
1203
|
+
dir,
|
|
1204
|
+
"grep",
|
|
1205
|
+
"--no-color",
|
|
1206
|
+
"-lE",
|
|
1207
|
+
"^(<<<<<<<|>>>>>>>|\\|\\|\\|\\|\\|\\|\\|)",
|
|
1208
|
+
"--",
|
|
1209
|
+
...paths
|
|
1210
|
+
]).catch(() => ({ stdout: "" }));
|
|
1211
|
+
return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1212
|
+
}
|
|
1213
|
+
async function openPullRequest(ctx) {
|
|
1214
|
+
const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
|
|
1215
|
+
if (!mergeBranch) return null;
|
|
1216
|
+
const apiBase = `https://api.github.com/repos/${owner}/${name}`;
|
|
1217
|
+
const headers = {
|
|
1218
|
+
authorization: `Bearer ${cloneToken}`,
|
|
1219
|
+
accept: "application/vnd.github+json",
|
|
1220
|
+
"x-github-api-version": "2022-11-28",
|
|
1221
|
+
"content-type": "application/json"
|
|
1222
|
+
};
|
|
1223
|
+
const title = jobTitle(ctx);
|
|
1224
|
+
const body = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
|
|
1225
|
+
const res = await fetch(`${apiBase}/pulls`, {
|
|
1226
|
+
method: "POST",
|
|
1227
|
+
headers,
|
|
1228
|
+
body: JSON.stringify({
|
|
1229
|
+
title: `FlumeCode: ${title}`,
|
|
1230
|
+
head: checkoutBranch,
|
|
1231
|
+
base: mergeBranch,
|
|
1232
|
+
body
|
|
1233
|
+
})
|
|
1234
|
+
});
|
|
1235
|
+
if (res.status === 201) {
|
|
1236
|
+
const data = await res.json();
|
|
1237
|
+
return { number: data.number, url: data.html_url };
|
|
1313
1238
|
}
|
|
1314
|
-
if (
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
"",
|
|
1319
|
-
"\u26A0\uFE0F The repository's pre-commit checks (lint / typecheck / tests) are currently FAILING on the base branch, independently of any version bump. A release must not ship a broken base:",
|
|
1320
|
-
"",
|
|
1321
|
-
"- **Phase 1 (propose):** tell the user, in your reply, that the base currently fails these checks and that the release will fix them as part of the bump.",
|
|
1322
|
-
"- **Phase 2 (apply):** fix the failing code at its root so the checks pass, THEN apply the version bumps and CHANGELOG. Do NOT delete/skip tests or weaken assertions. The fixes ship in the same bump PR. Still do NOT commit or push \u2014 the runner does.",
|
|
1323
|
-
"",
|
|
1324
|
-
"Failing check output:",
|
|
1325
|
-
"",
|
|
1326
|
-
"```",
|
|
1327
|
-
baseChecks.log,
|
|
1328
|
-
"```"
|
|
1239
|
+
if (res.status === 422) {
|
|
1240
|
+
const list = await fetch(
|
|
1241
|
+
`${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
|
|
1242
|
+
{ headers }
|
|
1329
1243
|
);
|
|
1244
|
+
if (list.ok) {
|
|
1245
|
+
const open = await list.json();
|
|
1246
|
+
if (open[0]) return { number: open[0].number, url: open[0].html_url };
|
|
1247
|
+
}
|
|
1248
|
+
return null;
|
|
1330
1249
|
}
|
|
1331
|
-
|
|
1332
|
-
lines2.push(
|
|
1333
|
-
"",
|
|
1334
|
-
"Your final reply is posted verbatim as your comment in the release thread \u2014 if you called widgets (Phase 1), your reply text accompanies the questions; if you applied the bumps (Phase 2), make it the report the skill produced. The runner appends the pull-request link."
|
|
1335
|
-
);
|
|
1336
|
-
return lines2.join("\n");
|
|
1250
|
+
throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
|
|
1337
1251
|
}
|
|
1338
|
-
function
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1252
|
+
async function cleanup(dir) {
|
|
1253
|
+
await rm(dir, { recursive: true, force: true });
|
|
1254
|
+
}
|
|
1255
|
+
function parsePrFromSubject(subject) {
|
|
1256
|
+
const m = subject.match(/\(#(\d+)\)\s*$/);
|
|
1257
|
+
return m ? Number(m[1]) : null;
|
|
1258
|
+
}
|
|
1259
|
+
async function incomingPrNumbers(ctx, dir, paths) {
|
|
1260
|
+
if (!paths.length) return [];
|
|
1261
|
+
try {
|
|
1262
|
+
const mergeHeadResult = await git(["-C", dir, "rev-parse", "MERGE_HEAD"]);
|
|
1263
|
+
const mergeHead = mergeHeadResult.stdout.trim();
|
|
1264
|
+
const baseResult = await git(["-C", dir, "merge-base", "HEAD", mergeHead]);
|
|
1265
|
+
const base = baseResult.stdout.trim();
|
|
1266
|
+
const logResult = await git([
|
|
1267
|
+
"-C",
|
|
1268
|
+
dir,
|
|
1269
|
+
"log",
|
|
1270
|
+
"--no-merges",
|
|
1271
|
+
`--format=%H%x1f%s`,
|
|
1272
|
+
`${base}..${mergeHead}`,
|
|
1273
|
+
"--",
|
|
1274
|
+
...paths
|
|
1275
|
+
]);
|
|
1276
|
+
const nums = /* @__PURE__ */ new Set();
|
|
1277
|
+
const needLookup = [];
|
|
1278
|
+
for (const line of logResult.stdout.split("\n").filter(Boolean)) {
|
|
1279
|
+
const idx = line.indexOf("");
|
|
1280
|
+
const sha = line.slice(0, idx);
|
|
1281
|
+
const subject = line.slice(idx + 1);
|
|
1282
|
+
const n = parsePrFromSubject(subject);
|
|
1283
|
+
if (n !== null) nums.add(n);
|
|
1284
|
+
else needLookup.push(sha);
|
|
1285
|
+
}
|
|
1286
|
+
for (const sha of needLookup) {
|
|
1287
|
+
for (const n of await prNumbersForCommit(ctx, sha)) nums.add(n);
|
|
1288
|
+
}
|
|
1289
|
+
return [...nums];
|
|
1290
|
+
} catch {
|
|
1291
|
+
return [];
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
async function prNumbersForCommit(ctx, sha) {
|
|
1295
|
+
const { owner, name, cloneToken } = ctx.repo;
|
|
1296
|
+
try {
|
|
1297
|
+
const res = await fetch(`https://api.github.com/repos/${owner}/${name}/commits/${sha}/pulls`, {
|
|
1298
|
+
headers: {
|
|
1299
|
+
authorization: `Bearer ${cloneToken}`,
|
|
1300
|
+
accept: "application/vnd.github+json",
|
|
1301
|
+
"x-github-api-version": "2022-11-28"
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
if (!res.ok) return [];
|
|
1305
|
+
return (await res.json()).map((p) => p.number);
|
|
1306
|
+
} catch {
|
|
1307
|
+
return [];
|
|
1308
|
+
}
|
|
1345
1309
|
}
|
|
1346
1310
|
|
|
1347
1311
|
// src/run.ts
|
|
@@ -1359,6 +1323,7 @@ async function pushAndOpenPr(ctx, dir, config, abort, opts = { rebase: true }) {
|
|
|
1359
1323
|
const committed = await commitWithRepair(ctx, dir, abort);
|
|
1360
1324
|
if (!committed) return { outcome: { kind: "none" }, autoMerged: false };
|
|
1361
1325
|
let autoMerged = false;
|
|
1326
|
+
let conflictResolution;
|
|
1362
1327
|
if (opts.rebase) {
|
|
1363
1328
|
try {
|
|
1364
1329
|
await rebaseOntoMergeBranch(ctx, dir);
|
|
@@ -1368,14 +1333,15 @@ async function pushAndOpenPr(ctx, dir, config, abort, opts = { rebase: true }) {
|
|
|
1368
1333
|
console.warn(
|
|
1369
1334
|
` rebase onto ${ctx.repo.mergeBranch} conflicted \u2014 merging it in and resolving with the agent\u2026`
|
|
1370
1335
|
);
|
|
1371
|
-
await mergeAndResolveConflicts(ctx, dir, config, abort);
|
|
1372
|
-
|
|
1336
|
+
const { report: mergeReport } = await mergeAndResolveConflicts(ctx, dir, config, abort);
|
|
1337
|
+
conflictResolution = mergeReport?.conflictResolution;
|
|
1338
|
+
await commitWithRepair(ctx, dir, abort);
|
|
1373
1339
|
autoMerged = true;
|
|
1374
1340
|
}
|
|
1375
1341
|
}
|
|
1376
1342
|
await pushBranch(ctx, dir);
|
|
1377
1343
|
const pr = await openPullRequest(ctx);
|
|
1378
|
-
return { outcome: pr ? { kind: "pr", pr } : { kind: "pushed" }, autoMerged };
|
|
1344
|
+
return { outcome: pr ? { kind: "pr", pr } : { kind: "pushed" }, autoMerged, conflictResolution };
|
|
1379
1345
|
}
|
|
1380
1346
|
async function mergeAndResolveConflicts(ctx, dir, config, abort) {
|
|
1381
1347
|
const { conflicted } = await mergeInMergeBranch(ctx, dir);
|
|
@@ -1401,12 +1367,11 @@ async function mergeAndResolveConflicts(ctx, dir, config, abort) {
|
|
|
1401
1367
|
`Could not fully resolve the merge \u2014 ${unresolved.length} file(s) still contain conflict markers: ${unresolved.join(", ")}`
|
|
1402
1368
|
);
|
|
1403
1369
|
}
|
|
1404
|
-
return { resolved: true, text: result.text.trim() || null };
|
|
1370
|
+
return { resolved: true, text: result.text.trim() || null, report: result.report ?? void 0 };
|
|
1405
1371
|
}
|
|
1406
|
-
async function commitWithRepair(ctx, dir, abort
|
|
1372
|
+
async function commitWithRepair(ctx, dir, abort) {
|
|
1407
1373
|
for (let attempt = 1; ; attempt++) {
|
|
1408
1374
|
try {
|
|
1409
|
-
if (!opts.skipSocket) await runSocket("pre-commit", dir);
|
|
1410
1375
|
return await commitChanges(ctx, dir);
|
|
1411
1376
|
} catch (err) {
|
|
1412
1377
|
if (!(err instanceof PreCommitError) || attempt > MAX_COMMIT_REPAIRS) throw err;
|
|
@@ -1531,7 +1496,7 @@ async function processChatJob(ctx, dir, config, abort) {
|
|
|
1531
1496
|
console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
|
|
1532
1497
|
return { text: reply, widgets: result.widgets };
|
|
1533
1498
|
}
|
|
1534
|
-
const wikiExists =
|
|
1499
|
+
const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
|
|
1535
1500
|
let documented = false;
|
|
1536
1501
|
if (ctx.permissionMode !== "plan" && wikiExists && await hasChanges(dir)) {
|
|
1537
1502
|
try {
|
|
@@ -1620,7 +1585,7 @@ ${reply}`;
|
|
|
1620
1585
|
|
|
1621
1586
|
> \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
|
|
1622
1587
|
}
|
|
1623
|
-
const wikiExists =
|
|
1588
|
+
const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
|
|
1624
1589
|
let documented = false;
|
|
1625
1590
|
if (wikiExists && await hasChanges(dir)) {
|
|
1626
1591
|
try {
|
|
@@ -1640,12 +1605,11 @@ ${reply}`;
|
|
|
1640
1605
|
} else if (!wikiExists) {
|
|
1641
1606
|
console.log(` no .flumecode/wiki \u2014 skipping wiki reconcile for ${ctx.jobId}`);
|
|
1642
1607
|
}
|
|
1643
|
-
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort, {
|
|
1608
|
+
const { outcome, autoMerged, conflictResolution } = await pushAndOpenPr(ctx, dir, config, abort, {
|
|
1644
1609
|
rebase: !resumed
|
|
1645
1610
|
});
|
|
1646
1611
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
1647
|
-
const
|
|
1648
|
-
const finalReport = report && lintPlugins.length ? { ...report, lint: { plugins: lintPlugins } } : report;
|
|
1612
|
+
const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
|
|
1649
1613
|
return {
|
|
1650
1614
|
text: reply,
|
|
1651
1615
|
widgets: [],
|
|
@@ -1665,8 +1629,8 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
|
1665
1629
|
maxTurns: ORCHESTRATOR_MAX_TURNS,
|
|
1666
1630
|
abortController: abort
|
|
1667
1631
|
});
|
|
1668
|
-
const
|
|
1669
|
-
let reply =
|
|
1632
|
+
const report = result.report ?? void 0;
|
|
1633
|
+
let reply = (report ? renderReport(report) : result.text.trim()) || "(the agent produced no reply)";
|
|
1670
1634
|
if (result.plans?.length) reply = result.plans[0] ?? reply;
|
|
1671
1635
|
if (installResult.status === "failed") {
|
|
1672
1636
|
reply += `
|
|
@@ -1677,7 +1641,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
|
1677
1641
|
console.log(` \u2026revise ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
|
|
1678
1642
|
return { text: reply, widgets: result.widgets };
|
|
1679
1643
|
}
|
|
1680
|
-
const wikiExists =
|
|
1644
|
+
const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
|
|
1681
1645
|
let documented = false;
|
|
1682
1646
|
if (wikiExists && await hasChanges(dir)) {
|
|
1683
1647
|
try {
|
|
@@ -1697,15 +1661,17 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
|
1697
1661
|
} else if (!wikiExists) {
|
|
1698
1662
|
console.log(` no .flumecode/wiki \u2014 skipping wiki reconcile for ${ctx.jobId}`);
|
|
1699
1663
|
}
|
|
1700
|
-
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort, {
|
|
1664
|
+
const { outcome, autoMerged, conflictResolution } = await pushAndOpenPr(ctx, dir, config, abort, {
|
|
1701
1665
|
rebase: !resumed
|
|
1702
1666
|
});
|
|
1703
1667
|
if (outcome.kind !== "none") {
|
|
1704
1668
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
1705
1669
|
}
|
|
1670
|
+
const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
|
|
1706
1671
|
return {
|
|
1707
1672
|
text: reply,
|
|
1708
1673
|
widgets: [],
|
|
1674
|
+
...finalReport ? { report: finalReport } : {},
|
|
1709
1675
|
...outcome.kind === "pr" ? { pr: outcome.pr } : {},
|
|
1710
1676
|
...result.plans?.length ? { plans: result.plans } : {}
|
|
1711
1677
|
};
|
|
@@ -1714,20 +1680,25 @@ async function processResolveJob(ctx, dir, config, abort) {
|
|
|
1714
1680
|
console.log(`
|
|
1715
1681
|
\u25B6 Resolve ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
1716
1682
|
const installResult = await installDependencies(dir);
|
|
1717
|
-
const { resolved, text } = await mergeAndResolveConflicts(ctx, dir, config, abort);
|
|
1718
|
-
let reply
|
|
1683
|
+
const { resolved, text, report } = await mergeAndResolveConflicts(ctx, dir, config, abort);
|
|
1684
|
+
let reply;
|
|
1685
|
+
if (resolved) {
|
|
1686
|
+
reply = report ? renderReport(report) : text || "(the agent produced no report)";
|
|
1687
|
+
} else {
|
|
1688
|
+
reply = `Merged \`${ctx.repo.mergeBranch ?? "the merge branch"}\` into \`${ctx.repo.checkoutBranch}\` cleanly \u2014 there were no conflicts to resolve.`;
|
|
1689
|
+
}
|
|
1719
1690
|
if (installResult.status === "failed") {
|
|
1720
1691
|
reply += `
|
|
1721
1692
|
|
|
1722
1693
|
> \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
|
|
1723
1694
|
}
|
|
1724
1695
|
if (abort.signal.aborted) throw new Error("Run canceled by user");
|
|
1725
|
-
await commitWithRepair(ctx, dir, abort
|
|
1696
|
+
await commitWithRepair(ctx, dir, abort);
|
|
1726
1697
|
await pushBranch(ctx, dir);
|
|
1727
1698
|
const pr = await openPullRequest(ctx);
|
|
1728
1699
|
const outcome = pr ? { kind: "pr", pr } : { kind: "pushed" };
|
|
1729
1700
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch });
|
|
1730
|
-
return { text: reply, widgets: [], ...pr ? { pr } : {} };
|
|
1701
|
+
return { text: reply, widgets: [], ...report ? { report } : {}, ...pr ? { pr } : {} };
|
|
1731
1702
|
}
|
|
1732
1703
|
async function processReleaseJob(ctx, dir, resumed, config, abort) {
|
|
1733
1704
|
console.log(`
|
|
@@ -1836,7 +1807,6 @@ async function pollLoop(config) {
|
|
|
1836
1807
|
scheduleCancelPoll();
|
|
1837
1808
|
try {
|
|
1838
1809
|
resetUsage();
|
|
1839
|
-
resetSocketResults();
|
|
1840
1810
|
const { text, widgets, pr, plans, report } = await processJob(ctx, config, abort);
|
|
1841
1811
|
const usage = getUsage();
|
|
1842
1812
|
await reportJob(config, ctx.jobId, {
|