@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 CHANGED
@@ -26,8 +26,8 @@ function writeConfig(config) {
26
26
  }
27
27
 
28
28
  // src/run.ts
29
- import { existsSync as existsSync4 } from "node:fs";
30
- import { join as join5 } from "node:path";
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/plugins/socket.ts
184
- import { exec as execCb } from "node:child_process";
185
- import { promisify as promisify2 } from "node:util";
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/logger.ts
201
- var lines = [];
202
- var secrets = [];
203
- var MAX_BYTES = 10 * 1024 * 1024;
204
- function startJobLog(opts) {
205
- lines = [];
206
- secrets = opts.secrets.filter(Boolean);
207
- logEvent("meta", `job ${opts.jobId} (${opts.kind}) started at ${(/* @__PURE__ */ new Date()).toISOString()}`);
208
- }
209
- function redact(s) {
210
- for (const sec of secrets) {
211
- s = s.split(sec).join("***REDACTED***");
212
- }
213
- return s;
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 logEvent(section, text) {
216
- lines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] [${section}] ${redact(text)}`);
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
- \u2026[truncated ${full.length - MAX_BYTES} bytes]\u2026
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
- ` + full.slice(-half);
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/workspace.ts
230
- var exec = promisify(execFile);
231
- var WORKSPACE_PREFIX = "flume-runner-";
232
- var MAX_BUFFER = 1 << 24;
233
- async function git(args) {
234
- logEvent("git", `git ${args.join(" ")}`);
235
- try {
236
- const result = await exec("git", args, { maxBuffer: MAX_BUFFER });
237
- if (result.stdout.trim()) logEvent("git:out", result.stdout.trim());
238
- if (result.stderr.trim()) logEvent("git:err", result.stderr.trim());
239
- return result;
240
- } catch (err) {
241
- logEvent("git:err", String(err.stderr ?? err));
242
- throw err;
243
- }
244
- }
245
- async function ensureGitIdentity(dir, identity) {
246
- await git(["-C", dir, "config", "user.email", identity.email]);
247
- await git(["-C", dir, "config", "user.name", identity.name]);
248
- }
249
- function cloneUrl(ctx) {
250
- const { owner, name, cloneToken } = ctx.repo;
251
- return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
252
- }
253
- function detectPackageManager(dir) {
254
- if (!existsSync2(join2(dir, "package.json"))) return null;
255
- if (existsSync2(join2(dir, "pnpm-lock.yaml"))) return "pnpm";
256
- if (existsSync2(join2(dir, "yarn.lock"))) return "yarn";
257
- if (existsSync2(join2(dir, "package-lock.json"))) return "npm";
258
- if (existsSync2(join2(dir, "bun.lockb"))) return "bun";
259
- return "npm";
260
- }
261
- async function installDependencies(dir) {
262
- const manager = detectPackageManager(dir);
263
- if (manager === null) return { status: "skipped" };
264
- const env = { ...process.env, CI: "1", ADBLOCK: "1", DISABLE_OPENCOLLECTIVE: "1" };
265
- logEvent("install", `${manager} install`);
266
- try {
267
- const result = await exec(manager, ["install"], {
268
- cwd: dir,
269
- maxBuffer: MAX_BUFFER,
270
- env,
271
- timeout: 5 * 6e4
272
- });
273
- if (result.stdout.trim()) logEvent("install:out", result.stdout.trim());
274
- if (result.stderr.trim()) logEvent("install:err", result.stderr.trim());
275
- return { status: "installed", manager };
276
- } catch (err) {
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 MAX_WORKSPACES = 8;
287
- var workspaceRegistry = /* @__PURE__ */ new Map();
288
- async function acquireWorkspace(key) {
289
- const existing = workspaceRegistry.get(key);
290
- if (existing !== void 0 && existsSync2(existing)) {
291
- workspaceRegistry.delete(key);
292
- workspaceRegistry.set(key, existing);
293
- return { dir: existing, reused: true };
294
- }
295
- const dir = await makeWorkspace();
296
- workspaceRegistry.set(key, dir);
297
- if (workspaceRegistry.size > MAX_WORKSPACES) {
298
- const oldest = workspaceRegistry.keys().next().value;
299
- const oldDir = workspaceRegistry.get(oldest);
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
- return { dir, reused: false };
305
- }
306
- async function discardWorkspace(key) {
307
- const dir = workspaceRegistry.get(key);
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
- async function resetWorkspace(dir) {
315
- await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
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
- "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.",
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).min(1).describe(
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
- lines2.push("", "## Acceptance criteria");
924
- for (const ac of report.acceptanceCriteria) {
925
- lines2.push("");
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(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
491
+ lines2.push(`### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
932
492
  lines2.push("");
933
- lines2.push("```diff");
934
- lines2.push(ev.hunk.replace(/\n+$/, ""));
935
- lines2.push("```");
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 join4 } from "node:path";
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(join4(RULES_DIR, `${name}.md`), "utf8");
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
- WRITING_INTRO,
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
- "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."
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 buildResolvePrompt(ctx, related = []) {
1211
- const mergeBranch = ctx.repo.mergeBranch ?? "the merge branch";
1212
- 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.`;
1213
- 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.`;
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
- `# Plan: ${ctx.request?.title ?? ""}`
1229
- ];
1230
- if (ctx.request?.body) {
1231
- lines2.push("", ctx.request.body);
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
- appendThread(lines2, ctx);
1234
- if (related.length > 0) {
1235
- lines2.push(
1236
- "",
1237
- "# Related sessions behind the incoming changes",
1238
- "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."
1239
- );
1240
- for (const r of related) {
1241
- lines2.push("", `## PR #${r.prNumber} \u2014 ${r.title}`);
1242
- if (r.plan) lines2.push("", "### Accepted plan", r.plan);
1243
- if (r.report) lines2.push("", "### Final report", r.report);
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 buildDocumentPrompt(ctx, changedFiles) {
1253
- const lines2 = [
1254
- `You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
1255
- `An implementation just ran in this working directory to satisfy the request below; its changes are uncommitted in the working tree.`,
1256
- `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.`,
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
- appendThread(lines2, ctx);
1264
- if (changedFiles && changedFiles.trim()) {
1265
- lines2.push(
1266
- "",
1267
- "Files changed by this implementation (reconcile only the wiki pages these affect \u2014 do not re-survey the whole repo):",
1268
- "",
1269
- changedFiles.trim()
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 buildRepairPrompt(ctx, hookLog) {
1276
- const lines2 = [
1277
- `You are "${ctx.agentName}", fixing a failed pre-commit check in the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
1278
- `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.`,
1279
- "",
1280
- "These coding guidelines apply to all code produced in this run:",
1281
- "",
1282
- loadRule("coding-guideline"),
1283
- "",
1284
- "# Pre-commit hook output",
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 buildReleasePrompt(ctx, baseChecks) {
1295
- 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.`;
1296
- 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.`;
1297
- 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.`;
1298
- const lines2 = [
1299
- `You are "${ctx.agentName}", an autonomous coding agent driving a FlumeCode release.`,
1300
- `The repository ${ctx.repo.fullName} is checked out in your current working directory on the release bump branch "${ctx.repo.checkoutBranch}".`,
1301
- task,
1302
- orient,
1303
- widgets,
1304
- "",
1305
- "These coding guidelines apply to all code produced in this run:",
1306
- "",
1307
- loadRule("coding-guideline"),
1308
- "",
1309
- `# Release: ${ctx.request?.title ?? ""}`
1310
- ];
1311
- if (ctx.request?.body) {
1312
- lines2.push("", ctx.request.body);
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 (baseChecks && !baseChecks.ok) {
1315
- lines2.push(
1316
- "",
1317
- "# Pre-release check status",
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
- appendThread(lines2, ctx);
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 buildInitPrompt(ctx) {
1339
- return [
1340
- `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
1341
- `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.`,
1342
- "",
1343
- "When done, reply with a one- or two-line summary of the wiki you created."
1344
- ].join("\n");
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
- await commitWithRepair(ctx, dir, abort, { skipSocket: true });
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, opts = {}) {
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 = existsSync4(join5(dir, ".flumecode", "wiki"));
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 = existsSync4(join5(dir, ".flumecode", "wiki"));
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 lintPlugins = getSocketResults();
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 summary = result.text.trim();
1669
- let reply = summary || "(the agent produced no 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 = existsSync4(join5(dir, ".flumecode", "wiki"));
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 = resolved ? text || "(the agent produced no report)" : `Merged \`${ctx.repo.mergeBranch ?? "the merge branch"}\` into \`${ctx.repo.checkoutBranch}\` cleanly \u2014 there were no conflicts to resolve.`;
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, { skipSocket: true });
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, {