@flumecode/runner 0.16.0 → 0.18.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 join6 } 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,865 +180,459 @@ 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 { readFile as readFile2 } from "node:fs/promises";
186
- import { join as join4 } from "node:path";
187
- import { promisify as promisify2 } from "node:util";
188
-
189
- // src/workspace.ts
190
- import { execFile } from "node:child_process";
191
- import { existsSync as existsSync2 } from "node:fs";
192
- import { mkdtemp, readdir, rm } from "node:fs/promises";
193
- import { tmpdir } from "node:os";
194
- import { join as join2 } from "node:path";
195
- import { promisify } from "node:util";
196
-
197
- // src/types.ts
198
- function jobTitle(ctx) {
199
- return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
200
- }
183
+ // src/executor.ts
184
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
185
+ import { query } from "@anthropic-ai/claude-agent-sdk";
201
186
 
202
- // src/logger.ts
203
- var lines = [];
204
- var secrets = [];
205
- var MAX_BYTES = 10 * 1024 * 1024;
206
- function startJobLog(opts) {
207
- lines = [];
208
- secrets = opts.secrets.filter(Boolean);
209
- logEvent("meta", `job ${opts.jobId} (${opts.kind}) started at ${(/* @__PURE__ */ new Date()).toISOString()}`);
210
- }
211
- function redact(s) {
212
- for (const sec of secrets) {
213
- s = s.split(sec).join("***REDACTED***");
214
- }
215
- 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 };
216
253
  }
217
- function logEvent(section, text) {
218
- 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
+ };
219
263
  }
220
- function getJobLog() {
221
- const full = lines.join("\n");
222
- if (full.length <= MAX_BYTES) return full;
223
- const half = Math.floor(MAX_BYTES / 2);
224
- return full.slice(0, half) + `
225
264
 
226
- \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";
227
268
 
228
- ` + full.slice(-half);
269
+ // src/code-lang.ts
270
+ var EXT_TO_LANG = {
271
+ ts: "typescript",
272
+ tsx: "tsx",
273
+ js: "javascript",
274
+ jsx: "jsx",
275
+ json: "json",
276
+ css: "css",
277
+ md: "markdown",
278
+ sh: "bash",
279
+ py: "python",
280
+ yaml: "yaml",
281
+ yml: "yaml",
282
+ html: "markup",
283
+ xml: "markup",
284
+ sql: "sql"
285
+ };
286
+ function langFromPath(path) {
287
+ const ext = path.split(".").pop()?.toLowerCase();
288
+ return ext ? EXT_TO_LANG[ext] : void 0;
229
289
  }
230
290
 
231
- // src/workspace.ts
232
- var exec = promisify(execFile);
233
- var WORKSPACE_PREFIX = "flume-runner-";
234
- var MAX_BUFFER = 1 << 24;
235
- async function git(args) {
236
- logEvent("git", `git ${args.join(" ")}`);
237
- try {
238
- const result = await exec("git", args, { maxBuffer: MAX_BUFFER });
239
- if (result.stdout.trim()) logEvent("git:out", result.stdout.trim());
240
- if (result.stderr.trim()) logEvent("git:err", result.stderr.trim());
241
- return result;
242
- } catch (err) {
243
- logEvent("git:err", String(err.stderr ?? err));
244
- throw err;
245
- }
246
- }
247
- async function ensureGitIdentity(dir, identity) {
248
- await git(["-C", dir, "config", "user.email", identity.email]);
249
- await git(["-C", dir, "config", "user.name", identity.name]);
250
- }
251
- function cloneUrl(ctx) {
252
- const { owner, name, cloneToken } = ctx.repo;
253
- return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
254
- }
255
- function detectPackageManager(dir) {
256
- if (!existsSync2(join2(dir, "package.json"))) return null;
257
- if (existsSync2(join2(dir, "pnpm-lock.yaml"))) return "pnpm";
258
- if (existsSync2(join2(dir, "yarn.lock"))) return "yarn";
259
- if (existsSync2(join2(dir, "package-lock.json"))) return "npm";
260
- if (existsSync2(join2(dir, "bun.lockb"))) return "bun";
261
- return "npm";
262
- }
263
- async function installDependencies(dir) {
264
- const manager = detectPackageManager(dir);
265
- if (manager === null) return { status: "skipped" };
266
- const env = { ...process.env, CI: "1", ADBLOCK: "1", DISABLE_OPENCOLLECTIVE: "1" };
267
- logEvent("install", `${manager} install`);
268
- try {
269
- const result = await exec(manager, ["install"], {
270
- cwd: dir,
271
- maxBuffer: MAX_BUFFER,
272
- env,
273
- timeout: 5 * 6e4
274
- });
275
- if (result.stdout.trim()) logEvent("install:out", result.stdout.trim());
276
- if (result.stderr.trim()) logEvent("install:err", result.stderr.trim());
277
- return { status: "installed", manager };
278
- } catch (err) {
279
- const e = err;
280
- const detail = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
281
- logEvent("install:err", detail || (err instanceof Error ? err.message : String(err)));
282
- return { status: "failed", manager, error: err instanceof Error ? err.message : String(err) };
283
- }
284
- }
285
- async function makeWorkspace() {
286
- return mkdtemp(join2(tmpdir(), WORKSPACE_PREFIX));
291
+ // src/schema-hints.ts
292
+ var INLINE_CODE_HINT = "Wrap code identifiers (function, variable, type, and file names, commands, and flags) in inline backticks, e.g. `getCodingSessionsForRequest`.";
293
+
294
+ // src/plan.ts
295
+ var SERVER_NAME2 = "flume_plan";
296
+ var SUBMIT_PLAN = "submit_plan";
297
+ var PLAN_TOOL_NAME = `mcp__${SERVER_NAME2}__${SUBMIT_PLAN}`;
298
+ var PLAN_MARKER = "<!-- flumecode:end-of-plan -->";
299
+ var pseudoCodeEntrySchema = z2.object({
300
+ file: z2.string().min(1),
301
+ pseudoCode: z2.string().min(1)
302
+ });
303
+ var stepSchema = z2.object({
304
+ title: z2.string().min(1).describe("A concise imperative title for this step."),
305
+ description: z2.array(z2.string().min(1)).min(1).describe(
306
+ "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
307
+ ),
308
+ pseudoCode: z2.array(pseudoCodeEntrySchema).optional().describe(
309
+ "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."
310
+ )
311
+ });
312
+ var planInputSchema = {
313
+ title: z2.string().min(1).max(120).describe(
314
+ "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."
315
+ ),
316
+ scope: z2.enum(["feat", "fix", "chore", "docs", "test", "refactor"]).describe("The primary intent of the change."),
317
+ goal: z2.string().min(1).describe("One or two sentences stating the outcome. " + INLINE_CODE_HINT),
318
+ rootCause: z2.string().optional().describe(
319
+ '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
320
+ ),
321
+ assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
322
+ requirements: z2.array(z2.string().min(1)).min(1).describe(
323
+ "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
324
+ ),
325
+ steps: z2.array(stepSchema).min(1).describe("Ordered list of changes. Each step says what and why, with file references."),
326
+ acceptanceCriteria: z2.array(z2.string().min(1)).min(2).describe(
327
+ "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
328
+ ),
329
+ risks: z2.array(z2.string()).describe("Anything that could change the approach."),
330
+ outOfScope: z2.array(z2.string()).describe("What is deliberately not being done.")
331
+ };
332
+ function requireRootCauseForFix(schema) {
333
+ return schema.superRefine((plan, ctx) => {
334
+ if (plan.scope === "fix" && (plan.rootCause === void 0 || plan.rootCause.trim() === "")) {
335
+ ctx.addIssue({
336
+ code: z2.ZodIssueCode.custom,
337
+ path: ["rootCause"],
338
+ message: 'rootCause is required and must be non-empty when scope is "fix".'
339
+ });
340
+ }
341
+ });
287
342
  }
288
- var MAX_WORKSPACES = 8;
289
- var workspaceRegistry = /* @__PURE__ */ new Map();
290
- async function acquireWorkspace(key) {
291
- const existing = workspaceRegistry.get(key);
292
- if (existing !== void 0 && existsSync2(existing)) {
293
- workspaceRegistry.delete(key);
294
- workspaceRegistry.set(key, existing);
295
- return { dir: existing, reused: true };
343
+ var planSchema = requireRootCauseForFix(z2.object(planInputSchema));
344
+ function renderPlan(plan) {
345
+ const lines2 = [];
346
+ lines2.push(`# ${plan.title}`);
347
+ lines2.push("");
348
+ lines2.push(`**Scope** \u2014 \`${plan.scope}\``);
349
+ lines2.push("");
350
+ lines2.push(`**Goal** \u2014 ${plan.goal}`);
351
+ if (plan.assumptions.length > 0) {
352
+ lines2.push("");
353
+ lines2.push("**Assumptions**");
354
+ for (const assumption of plan.assumptions) {
355
+ lines2.push(`- ${assumption}`);
356
+ }
296
357
  }
297
- const dir = await makeWorkspace();
298
- workspaceRegistry.set(key, dir);
299
- if (workspaceRegistry.size > MAX_WORKSPACES) {
300
- const oldest = workspaceRegistry.keys().next().value;
301
- const oldDir = workspaceRegistry.get(oldest);
302
- workspaceRegistry.delete(oldest);
303
- rm(oldDir, { recursive: true, force: true }).catch(() => {
304
- });
358
+ if (plan.rootCause && plan.rootCause.trim().length > 0) {
359
+ lines2.push("");
360
+ lines2.push("## Root cause");
361
+ lines2.push(plan.rootCause);
305
362
  }
306
- return { dir, reused: false };
307
- }
308
- async function discardWorkspace(key) {
309
- const dir = workspaceRegistry.get(key);
310
- workspaceRegistry.delete(key);
311
- if (dir !== void 0) {
312
- await cleanup(dir).catch(() => {
313
- });
363
+ lines2.push("");
364
+ lines2.push("## Requirements");
365
+ for (const requirement of plan.requirements) {
366
+ lines2.push(`- ${requirement}`);
314
367
  }
315
- }
316
- async function resetWorkspace(dir) {
317
- await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
318
- });
319
- await git(["-C", dir, "clean", "-fd"]).catch(() => {
320
- });
321
- }
322
- async function prepareAtSha(ctx, dir, reused) {
323
- const identity = { name: ctx.agentName, email: ctx.agentEmail };
324
- if (!reused) {
325
- await cloneAtSha(ctx, dir);
326
- await ensureGitIdentity(dir, identity);
327
- return;
368
+ lines2.push("");
369
+ lines2.push("## Steps");
370
+ for (const [i, step] of plan.steps.entries()) {
371
+ lines2.push("");
372
+ lines2.push(`### ${i + 1}. ${step.title}`);
373
+ lines2.push("");
374
+ for (const bullet of step.description) {
375
+ lines2.push(`- ${bullet}`);
376
+ }
377
+ if (step.pseudoCode && step.pseudoCode.length > 0) {
378
+ for (const entry of step.pseudoCode) {
379
+ lines2.push("");
380
+ lines2.push(`\`${entry.file}\``);
381
+ lines2.push("");
382
+ const lang = langFromPath(entry.file);
383
+ lines2.push(lang ? "```" + lang : "```");
384
+ lines2.push(entry.pseudoCode);
385
+ lines2.push("```");
386
+ }
387
+ }
328
388
  }
329
- await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
330
- await ensureGitIdentity(dir, identity);
331
- }
332
- async function prepareResumingBranch(ctx, dir, reused) {
333
- const identity = { name: ctx.agentName, email: ctx.agentEmail };
334
- if (!reused) {
335
- const result = await cloneResumingBranch(ctx, dir);
336
- await ensureGitIdentity(dir, identity);
337
- return result;
389
+ lines2.push("");
390
+ lines2.push("## Acceptance criteria");
391
+ for (const criterion of plan.acceptanceCriteria) {
392
+ lines2.push(`- [ ] ${criterion}`);
338
393
  }
339
- await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
340
- await ensureGitIdentity(dir, identity);
341
- return { resumed: true };
342
- }
343
- async function sweepWorkspaces() {
344
- const base = tmpdir();
345
- let entries;
346
- try {
347
- entries = await readdir(base);
348
- } catch {
349
- return 0;
394
+ if (plan.risks.length > 0) {
395
+ lines2.push("");
396
+ lines2.push("**Risks / open questions**");
397
+ for (const risk of plan.risks) {
398
+ lines2.push(`- ${risk}`);
399
+ }
350
400
  }
351
- let removed = 0;
352
- for (const entry of entries) {
353
- if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
354
- try {
355
- await rm(join2(base, entry), { recursive: true, force: true });
356
- removed++;
357
- } catch {
401
+ if (plan.outOfScope.length > 0) {
402
+ lines2.push("");
403
+ lines2.push("**Out of scope**");
404
+ for (const item of plan.outOfScope) {
405
+ lines2.push(`- ${item}`);
358
406
  }
359
407
  }
360
- return removed;
408
+ lines2.push("");
409
+ lines2.push(PLAN_MARKER);
410
+ return lines2.join("\n");
361
411
  }
362
- async function cloneAtSha(ctx, dir) {
363
- await git(["clone", "--quiet", cloneUrl(ctx), dir]);
364
- await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
412
+ var submitPlanInputSchema = {
413
+ plans: z2.array(requireRootCauseForFix(z2.object(planInputSchema))).min(1).refine(
414
+ (arr) => {
415
+ const titles = arr.map((p) => p.title.trim()).filter((t) => t.length > 0);
416
+ return new Set(titles).size === titles.length;
417
+ },
418
+ { message: "Each plan must have a distinct non-empty title" }
419
+ )
420
+ };
421
+ var submitPlanSchema = z2.object(submitPlanInputSchema);
422
+ function createPlanTooling() {
423
+ let renderedPlans = null;
424
+ const submitPlan = tool2(
425
+ SUBMIT_PLAN,
426
+ `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). `,
427
+ submitPlanInputSchema,
428
+ async (args) => {
429
+ const parsed = submitPlanSchema.parse(args);
430
+ renderedPlans = parsed.plans.map(renderPlan);
431
+ return {
432
+ content: [
433
+ {
434
+ type: "text",
435
+ text: "Plan(s) submitted. The runner will render and post them as your comment(s). End your turn now."
436
+ }
437
+ ]
438
+ };
439
+ }
440
+ );
441
+ const mcpServer = createSdkMcpServer2({
442
+ name: SERVER_NAME2,
443
+ tools: [submitPlan]
444
+ });
445
+ return { mcpServer, getPlans: () => renderedPlans };
365
446
  }
366
- async function cloneResumingBranch(ctx, dir) {
367
- await git(["clone", "--quiet", cloneUrl(ctx), dir]);
368
- try {
369
- await git(["-C", dir, "fetch", "--quiet", "origin", ctx.repo.checkoutBranch]);
370
- await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, "FETCH_HEAD"]);
371
- return { resumed: true };
372
- } catch {
373
- await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
374
- return { resumed: false };
447
+ function countPlanAcceptanceCriteria(planBody) {
448
+ if (!planBody) return 0;
449
+ const lines2 = planBody.split("\n");
450
+ const start2 = lines2.findIndex((l) => l.trim() === "## Acceptance criteria");
451
+ if (start2 === -1) return 0;
452
+ let count = 0;
453
+ for (let i = start2 + 1; i < lines2.length; i++) {
454
+ const line = lines2[i] ?? "";
455
+ if (line.startsWith("## ")) break;
456
+ if (line.startsWith("- [ ] ")) count++;
375
457
  }
458
+ return count;
376
459
  }
377
- async function hasChanges(dir) {
378
- await git(["-C", dir, "add", "-A"]);
379
- const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
380
- return stdout2.trim().length > 0;
381
- }
382
- async function gitDiffStat(dir) {
383
- const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "--stat"]);
384
- return stdout2;
385
- }
386
- var PreCommitError = class extends Error {
387
- constructor(log) {
388
- super("pre-commit checks failed");
389
- this.log = log;
390
- this.name = "PreCommitError";
391
- }
460
+
461
+ // src/report.ts
462
+ import { createSdkMcpServer as createSdkMcpServer3, tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
463
+ import { z as z3 } from "zod";
464
+ var SERVER_NAME3 = "flume_report";
465
+ var SUBMIT_REPORT = "submit_report";
466
+ var REPORT_TOOL_NAME = `mcp__${SERVER_NAME3}__${SUBMIT_REPORT}`;
467
+ var STATUS_ICON = {
468
+ met: "\u2705",
469
+ not_met: "\u274C",
470
+ unclear: "\u26A0\uFE0F"
392
471
  };
393
- function commitFailureLog(err) {
394
- const e = err;
395
- const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
396
- return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
397
- }
398
- function isUnsupportedGitSubcommand(err) {
399
- const e = err;
400
- const text = `${typeof e.stderr === "string" ? e.stderr : ""}
401
- ${e.message ?? ""}`;
402
- return /is not a git command|unknown subcommand|usage: git hook/i.test(text);
403
- }
404
- async function runRepoChecks(dir) {
405
- try {
406
- await git(["-C", dir, "hook", "run", "pre-commit"]);
407
- logEvent("checks", "pre-commit hook passed");
408
- return { ok: true, log: "", skipped: false };
409
- } catch (err) {
410
- if (isUnsupportedGitSubcommand(err)) {
411
- logEvent("checks", "pre-commit hook skipped (git too old)");
412
- return { ok: true, log: "", skipped: true };
472
+ var CICD_STATUS_ICON = {
473
+ passed: "\u2705",
474
+ failed: "\u274C"
475
+ };
476
+ var cicdCheckSchema = z3.object({
477
+ command: z3.string().min(1).describe("The exact verification command run, e.g. `pnpm typecheck`."),
478
+ status: z3.enum(["passed", "failed"]).describe("Whether the command passed or failed."),
479
+ output: z3.string().optional().describe("Short excerpt of failing output; include on failure.")
480
+ });
481
+ var evidenceSchema = z3.object({
482
+ file: z3.string().min(1).describe("Repo-relative path the hunk comes from."),
483
+ hunk: z3.string().min(1).describe(
484
+ "A unified-diff hunk proving the criterion \u2014 the lines that matter, not the whole file. MUST keep the `@@ -a,b +c,d @@` hunk header line(s) exactly as they appear in `git --no-pager diff`; the report renders file line numbers from them. Rendered verbatim as a ```diff block."
485
+ ),
486
+ note: z3.string().optional().describe("Optional one-line explanation of why this hunk satisfies the criterion.")
487
+ });
488
+ var acVerdictSchema = z3.object({
489
+ criterion: z3.string().min(1).describe("The acceptance-criterion text, verbatim from the plan."),
490
+ status: z3.enum(["met", "not_met", "unclear"]).describe("Verdict for this criterion, verified against the actual diff."),
491
+ rationale: z3.string().min(1).describe("One or two sentences on why the verdict holds. " + INLINE_CODE_HINT),
492
+ evidence: z3.array(evidenceSchema).describe(
493
+ "Diff hunks proving the verdict, copied verbatim from git --no-pager diff. Across ALL criteria the evidence must collectively cover every hunk in the diff \u2014 each changed hunk appears under at least one criterion. Cite the relevant hunk(s) for a met criterion; may be empty for not_met / unclear."
494
+ )
495
+ });
496
+ var reportInputSchema = {
497
+ summary: z3.string().min(1).describe("One or two sentences on what was implemented. " + INLINE_CODE_HINT),
498
+ filesChanged: z3.string().min(1).describe(
499
+ "Markdown: the list of files changed (from the diff). Rendered under '## Files changed'."
500
+ ),
501
+ codeQuality: z3.string().min(1).describe(
502
+ "Markdown: the code-quality review outcome and anything left as nice-to-have. Rendered under '## Code quality'. " + INLINE_CODE_HINT
503
+ ),
504
+ caveats: z3.string().min(1).describe(
505
+ "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
506
+ ),
507
+ acceptanceCriteria: z3.array(acVerdictSchema).describe(
508
+ "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)."
509
+ ),
510
+ conflictResolution: z3.string().optional().describe(
511
+ "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."
512
+ ),
513
+ cicd: z3.array(cicdCheckSchema).optional().describe(
514
+ "Verify-phase build/typecheck/lint/test results. Omit when the repo has no verification setup. Rendered under '## CI/CD'."
515
+ )
516
+ };
517
+ var reportSchema = z3.object(reportInputSchema);
518
+ function renderReport(report) {
519
+ const lines2 = [];
520
+ lines2.push(report.summary.trim());
521
+ lines2.push("", "## Files changed", "", report.filesChanged.trim());
522
+ if (report.acceptanceCriteria.length > 0) {
523
+ lines2.push("", "## Acceptance criteria");
524
+ for (const ac of report.acceptanceCriteria) {
525
+ lines2.push("");
526
+ lines2.push(`### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
527
+ lines2.push("");
528
+ lines2.push(ac.rationale.trim());
529
+ for (const ev of ac.evidence) {
530
+ lines2.push("");
531
+ lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
532
+ lines2.push("");
533
+ lines2.push("```diff");
534
+ lines2.push(ev.hunk.replace(/\n+$/, ""));
535
+ lines2.push("```");
536
+ }
413
537
  }
414
- const log = commitFailureLog(err);
415
- logEvent("checks:err", log);
416
- return { ok: false, log, skipped: false };
417
538
  }
418
- }
419
- async function commitChanges(ctx, dir) {
420
- if (!await hasChanges(dir)) return false;
421
- try {
422
- await git(["-C", dir, "commit", "--quiet", "-m", `FlumeCode: ${jobTitle(ctx)}`]);
423
- } catch (err) {
424
- throw new PreCommitError(commitFailureLog(err));
539
+ if (report.conflictResolution?.trim()) {
540
+ lines2.push("", "## Conflict resolution", "", report.conflictResolution.trim());
425
541
  }
426
- return true;
427
- }
428
- async function pushBranch(ctx, dir) {
429
- await git(["-C", dir, "push", "--quiet", "-u", "origin", ctx.repo.checkoutBranch]);
430
- }
431
- var RebaseConflictError = class extends Error {
432
- constructor(mergeBranch, files) {
433
- const list = files.length ? `: ${files.join(", ")}` : "";
434
- super(`Rebase onto ${mergeBranch} hit conflicts in ${files.length} file(s)${list}`);
435
- this.mergeBranch = mergeBranch;
436
- this.files = files;
437
- this.name = "RebaseConflictError";
542
+ if (report.cicd && report.cicd.length > 0) {
543
+ lines2.push("", "## CI/CD");
544
+ for (const check of report.cicd) {
545
+ lines2.push("", `- ${CICD_STATUS_ICON[check.status]} \`${check.command}\``);
546
+ if (check.status === "failed" && check.output?.trim()) {
547
+ lines2.push("", "```", check.output.trim(), "```");
548
+ }
549
+ }
438
550
  }
439
- };
440
- async function rebaseOntoMergeBranch(ctx, dir) {
441
- const { mergeBranch } = ctx.repo;
442
- if (!mergeBranch) return;
443
- await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
444
- try {
445
- await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
446
- } catch (err) {
447
- const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
448
- () => ({ stdout: "" })
449
- );
450
- const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
451
- await git(["-C", dir, "rebase", "--abort"]).catch(() => {
452
- });
453
- if (files.length === 0) throw err;
454
- throw new RebaseConflictError(mergeBranch, files);
455
- }
456
- }
457
- async function mergeInMergeBranch(ctx, dir) {
458
- const { mergeBranch } = ctx.repo;
459
- if (!mergeBranch) return { conflicted: false };
460
- await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
461
- try {
462
- await git(["-C", dir, "merge", "--no-edit", "FETCH_HEAD"]);
463
- return { conflicted: false };
464
- } catch {
465
- return { conflicted: true };
466
- }
467
- }
468
- async function listUnmergedPaths(dir) {
469
- const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
470
- stdout: ""
471
- }));
472
- return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
473
- }
474
- async function listConflictMarkerPaths(dir, paths) {
475
- if (paths.length === 0) return [];
476
- const { stdout: stdout2 } = await git([
477
- "-C",
478
- dir,
479
- "grep",
480
- "--no-color",
481
- "-lE",
482
- "^(<<<<<<<|>>>>>>>|\\|\\|\\|\\|\\|\\|\\|)",
483
- "--",
484
- ...paths
485
- ]).catch(() => ({ stdout: "" }));
486
- return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
551
+ lines2.push("", "## Code quality", "", report.codeQuality.trim());
552
+ lines2.push("", "## Caveats / follow-ups", "", report.caveats.trim());
553
+ return lines2.join("\n");
487
554
  }
488
- async function openPullRequest(ctx) {
489
- const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
490
- if (!mergeBranch) return null;
491
- const apiBase = `https://api.github.com/repos/${owner}/${name}`;
492
- const headers = {
493
- authorization: `Bearer ${cloneToken}`,
494
- accept: "application/vnd.github+json",
495
- "x-github-api-version": "2022-11-28",
496
- "content-type": "application/json"
497
- };
498
- const title = jobTitle(ctx);
499
- 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}".`;
500
- const res = await fetch(`${apiBase}/pulls`, {
501
- method: "POST",
502
- headers,
503
- body: JSON.stringify({
504
- title: `FlumeCode: ${title}`,
505
- head: checkoutBranch,
506
- base: mergeBranch,
507
- body
508
- })
509
- });
510
- if (res.status === 201) {
511
- const data = await res.json();
512
- return { number: data.number, url: data.html_url };
513
- }
514
- if (res.status === 422) {
515
- const list = await fetch(
516
- `${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
517
- { headers }
518
- );
519
- if (list.ok) {
520
- const open = await list.json();
521
- if (open[0]) return { number: open[0].number, url: open[0].html_url };
555
+ function createReportTooling() {
556
+ let submittedReport = null;
557
+ const submitReport = tool3(
558
+ SUBMIT_REPORT,
559
+ "Submit the final implementation report as structured data. Call this exactly once, at the end of the run. `acceptanceCriteria` must contain one entry per plan criterion, each with a met / not_met / unclear verdict and the diff hunk(s) that prove it. `summary`, `filesChanged`, `codeQuality`, and `caveats` are the four named markdown sections. `cicd` (optional) holds Verify-phase check results (one entry per command with `command`, `status` `passed`/`failed`, and `output` on failure); omit when no verification setup exists. Do NOT include a PR link \u2014 the runner appends it.",
560
+ reportInputSchema,
561
+ async (args) => {
562
+ submittedReport = reportSchema.parse(args);
563
+ return {
564
+ content: [
565
+ {
566
+ type: "text",
567
+ text: "Report submitted. The runner will render and post it. End your turn now."
568
+ }
569
+ ]
570
+ };
522
571
  }
523
- return null;
524
- }
525
- throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
526
- }
527
- async function cleanup(dir) {
528
- await rm(dir, { recursive: true, force: true });
572
+ );
573
+ const mcpServer = createSdkMcpServer3({
574
+ name: SERVER_NAME3,
575
+ tools: [submitReport]
576
+ });
577
+ return { mcpServer, getReport: () => submittedReport };
529
578
  }
530
- function parsePrFromSubject(subject) {
531
- const m = subject.match(/\(#(\d+)\)\s*$/);
532
- return m ? Number(m[1]) : null;
579
+
580
+ // src/logger.ts
581
+ var lines = [];
582
+ var secrets = [];
583
+ var MAX_BYTES = 10 * 1024 * 1024;
584
+ function startJobLog(opts) {
585
+ lines = [];
586
+ secrets = opts.secrets.filter(Boolean);
587
+ logEvent("meta", `job ${opts.jobId} (${opts.kind}) started at ${(/* @__PURE__ */ new Date()).toISOString()}`);
533
588
  }
534
- async function incomingPrNumbers(ctx, dir, paths) {
535
- if (!paths.length) return [];
536
- try {
537
- const mergeHeadResult = await git(["-C", dir, "rev-parse", "MERGE_HEAD"]);
538
- const mergeHead = mergeHeadResult.stdout.trim();
539
- const baseResult = await git(["-C", dir, "merge-base", "HEAD", mergeHead]);
540
- const base = baseResult.stdout.trim();
541
- const logResult = await git([
542
- "-C",
543
- dir,
544
- "log",
545
- "--no-merges",
546
- `--format=%H%x1f%s`,
547
- `${base}..${mergeHead}`,
548
- "--",
549
- ...paths
550
- ]);
551
- const nums = /* @__PURE__ */ new Set();
552
- const needLookup = [];
553
- for (const line of logResult.stdout.split("\n").filter(Boolean)) {
554
- const idx = line.indexOf("");
555
- const sha = line.slice(0, idx);
556
- const subject = line.slice(idx + 1);
557
- const n = parsePrFromSubject(subject);
558
- if (n !== null) nums.add(n);
559
- else needLookup.push(sha);
560
- }
561
- for (const sha of needLookup) {
562
- for (const n of await prNumbersForCommit(ctx, sha)) nums.add(n);
563
- }
564
- return [...nums];
565
- } catch {
566
- return [];
589
+ function redact(s) {
590
+ for (const sec of secrets) {
591
+ s = s.split(sec).join("***REDACTED***");
567
592
  }
593
+ return s;
568
594
  }
569
- async function prNumbersForCommit(ctx, sha) {
570
- const { owner, name, cloneToken } = ctx.repo;
571
- try {
572
- const res = await fetch(`https://api.github.com/repos/${owner}/${name}/commits/${sha}/pulls`, {
573
- headers: {
574
- authorization: `Bearer ${cloneToken}`,
575
- accept: "application/vnd.github+json",
576
- "x-github-api-version": "2022-11-28"
577
- }
578
- });
579
- if (!res.ok) return [];
580
- return (await res.json()).map((p) => p.number);
581
- } catch {
582
- return [];
583
- }
595
+ function logEvent(section, text) {
596
+ lines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] [${section}] ${redact(text)}`);
584
597
  }
598
+ function getJobLog() {
599
+ const full = lines.join("\n");
600
+ if (full.length <= MAX_BYTES) return full;
601
+ const half = Math.floor(MAX_BYTES / 2);
602
+ return full.slice(0, half) + `
585
603
 
586
- // src/plugins/manifest.ts
587
- import { existsSync as existsSync3 } from "node:fs";
588
- import { readdir as readdir2, readFile } from "node:fs/promises";
589
- import { join as join3 } from "node:path";
590
- async function loadPlugins(dir) {
591
- const pluginsDir = join3(dir, ".flumecode", "plugins");
592
- if (!existsSync3(pluginsDir)) return [];
593
- let entries;
594
- try {
595
- entries = await readdir2(pluginsDir);
596
- } catch {
597
- return [];
598
- }
599
- const manifests = [];
600
- for (const entry of entries) {
601
- const manifestPath = join3(pluginsDir, entry, "plugin.json");
602
- try {
603
- const raw = JSON.parse(await readFile(manifestPath, "utf8"));
604
- const manifest = parseManifest(raw);
605
- if (manifest) manifests.push(manifest);
606
- } catch {
607
- }
608
- }
609
- return manifests;
610
- }
611
- function parseManifest(raw) {
612
- if (typeof raw !== "object" || raw === null) return null;
613
- const r = raw;
614
- if (typeof r.key !== "string" || !r.key) return null;
615
- if (r.socket !== "pre-commit") return null;
616
- if (typeof r.run !== "string" || !r.run) return null;
617
- let report;
618
- const rep = r.report;
619
- if (rep && typeof rep.file === "string" && rep.file && rep.format === "jest") {
620
- report = { file: rep.file, format: "jest" };
621
- }
622
- return { key: r.key, socket: r.socket, run: r.run, ...report ? { report } : {} };
604
+ \u2026[truncated ${full.length - MAX_BYTES} bytes]\u2026
605
+
606
+ ` + full.slice(-half);
623
607
  }
624
608
 
625
- // src/plugins/socket.ts
626
- var exec2 = promisify2(execCb);
627
- var MAX_OUTPUT = 8 * 1024;
628
- function cap(s) {
629
- return s.length <= MAX_OUTPUT ? s : s.slice(s.length - MAX_OUTPUT);
630
- }
631
- var lastSocketResults = [];
632
- function resetSocketResults() {
633
- lastSocketResults = [];
634
- }
635
- function getSocketResults() {
636
- return lastSocketResults;
637
- }
638
- async function runSocket(socketName, dir) {
639
- const plugins = (await loadPlugins(dir)).filter((p) => p.socket === socketName);
640
- const results = [];
641
- for (const plugin of plugins) {
642
- const result = await runPluginCommand(plugin.run, dir);
643
- const metrics = await readMetrics(plugin.report, dir);
644
- if (result.exitCode !== 0) {
645
- results.push({
646
- key: plugin.key,
647
- status: "failed",
648
- output: cap(result.output),
649
- ...metrics ? { metrics } : {}
650
- });
651
- lastSocketResults = results;
652
- throw new PreCommitError(`[plugin:${plugin.key}] ${result.output}`);
653
- }
654
- results.push({
655
- key: plugin.key,
656
- status: "passed",
657
- output: cap(result.output),
658
- ...metrics ? { metrics } : {}
659
- });
660
- }
661
- lastSocketResults = results;
609
+ // src/executor.ts
610
+ var FLUME_PLUGIN_DIR = fileURLToPath2(new URL("../skills-plugin", import.meta.url));
611
+ function emptyUsage() {
612
+ return {
613
+ inputTokens: 0,
614
+ outputTokens: 0,
615
+ cacheCreationTokens: 0,
616
+ cacheReadTokens: 0,
617
+ costUsd: 0
618
+ };
662
619
  }
663
- async function readMetrics(report, dir) {
664
- if (!report) return void 0;
665
- try {
666
- const raw = JSON.parse(await readFile2(join4(dir, report.file), "utf8"));
667
- if (report.format === "jest") {
668
- return {
669
- testsRun: Number(raw.numTotalTests) || 0,
670
- testsFailed: Number(raw.numFailedTests) || 0
671
- };
672
- }
673
- } catch {
674
- }
675
- return void 0;
620
+ var usageAcc = emptyUsage();
621
+ function resetUsage() {
622
+ usageAcc = emptyUsage();
676
623
  }
677
- async function runPluginCommand(command2, cwd) {
678
- try {
679
- const result = await exec2(command2, { cwd, maxBuffer: 1 << 24 });
680
- const output = [result.stdout, result.stderr].map((s) => s.trim()).filter(Boolean).join("\n");
681
- return { exitCode: 0, output };
682
- } catch (err) {
683
- const e = err;
684
- const output = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
685
- return { exitCode: typeof e.code === "number" ? e.code : 1, output };
624
+ function getUsage() {
625
+ const totalTokens = usageAcc.inputTokens + usageAcc.outputTokens + usageAcc.cacheCreationTokens + usageAcc.cacheReadTokens;
626
+ return { ...usageAcc, totalTokens };
627
+ }
628
+ function stringifyResult(content) {
629
+ if (typeof content === "string") return content;
630
+ if (Array.isArray(content)) {
631
+ return content.map(
632
+ (c) => typeof c === "object" && c !== null && "text" in c ? String(c.text) : JSON.stringify(c)
633
+ ).join("\n");
686
634
  }
687
- }
688
-
689
- // src/executor.ts
690
- import { fileURLToPath as fileURLToPath2 } from "node:url";
691
- import { query } from "@anthropic-ai/claude-agent-sdk";
692
-
693
- // src/widgets.ts
694
- import { randomUUID } from "node:crypto";
695
- import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
696
- import { z } from "zod";
697
- var SERVER_NAME = "flume_widgets";
698
- var SINGLE_SELECT = "single_select";
699
- var MULTI_SELECT = "multi_select";
700
- var WIDGET_TOOL_NAMES = [
701
- `mcp__${SERVER_NAME}__${SINGLE_SELECT}`,
702
- `mcp__${SERVER_NAME}__${MULTI_SELECT}`
703
- ];
704
- var optionsSchema = z.array(z.string().min(1)).min(2).max(8).describe("2\u20138 short, distinct choices for the user to pick from.");
705
- 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.";
706
- function createWidgetTooling() {
707
- const collected = [];
708
- const singleSelect = tool(
709
- SINGLE_SELECT,
710
- "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,
711
- {
712
- question: z.string().min(1).describe("The question to ask the user."),
713
- options: optionsSchema
714
- },
715
- async (args) => {
716
- collected.push({
717
- id: randomUUID(),
718
- type: "single_select",
719
- question: args.question,
720
- options: args.options.map((label) => ({ id: randomUUID(), label })),
721
- selectedOptionId: null,
722
- customAnswer: null
723
- });
724
- return widgetPosted("single-select");
725
- }
726
- );
727
- const multiSelect = tool(
728
- MULTI_SELECT,
729
- "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,
730
- {
731
- question: z.string().min(1).describe("The question to ask the user."),
732
- options: optionsSchema
733
- },
734
- async (args) => {
735
- collected.push({
736
- id: randomUUID(),
737
- type: "multi_select",
738
- question: args.question,
739
- options: args.options.map((label) => ({ id: randomUUID(), label })),
740
- selectedOptionIds: null,
741
- customAnswer: null
742
- });
743
- return widgetPosted("multi-select");
744
- }
745
- );
746
- const mcpServer = createSdkMcpServer({
747
- name: SERVER_NAME,
748
- tools: [singleSelect, multiSelect]
749
- });
750
- return { mcpServer, collected };
751
- }
752
- function widgetPosted(kind) {
753
- return {
754
- content: [
755
- {
756
- type: "text",
757
- 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.`
758
- }
759
- ]
760
- };
761
- }
762
-
763
- // src/plan.ts
764
- import { createSdkMcpServer as createSdkMcpServer2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
765
- import { z as z2 } from "zod";
766
-
767
- // src/schema-hints.ts
768
- var INLINE_CODE_HINT = "Wrap code identifiers (function, variable, type, and file names, commands, and flags) in inline backticks, e.g. `getCodingSessionsForRequest`.";
769
-
770
- // src/plan.ts
771
- var SERVER_NAME2 = "flume_plan";
772
- var SUBMIT_PLAN = "submit_plan";
773
- var PLAN_TOOL_NAME = `mcp__${SERVER_NAME2}__${SUBMIT_PLAN}`;
774
- var PLAN_MARKER = "<!-- flumecode:end-of-plan -->";
775
- var pseudoCodeEntrySchema = z2.object({
776
- file: z2.string().min(1),
777
- pseudoCode: z2.string().min(1)
778
- });
779
- var stepSchema = z2.object({
780
- title: z2.string().min(1).describe("A concise imperative title for this step."),
781
- description: z2.array(z2.string().min(1)).min(1).describe(
782
- "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
783
- ),
784
- pseudoCode: z2.array(pseudoCodeEntrySchema).optional().describe(
785
- "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."
786
- )
787
- });
788
- var planInputSchema = {
789
- title: z2.string().min(1).max(120).describe(
790
- "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."
791
- ),
792
- scope: z2.enum(["feat", "fix", "chore", "docs", "test", "refactor"]).describe("The primary intent of the change."),
793
- goal: z2.string().min(1).describe("One or two sentences stating the outcome. " + INLINE_CODE_HINT),
794
- assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
795
- requirements: z2.array(z2.string().min(1)).min(1).describe(
796
- "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
797
- ),
798
- steps: z2.array(stepSchema).min(1).describe("Ordered list of changes. Each step says what and why, with file references."),
799
- acceptanceCriteria: z2.array(z2.string().min(1)).min(2).describe(
800
- "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
801
- ),
802
- risks: z2.array(z2.string()).describe("Anything that could change the approach."),
803
- outOfScope: z2.array(z2.string()).describe("What is deliberately not being done.")
804
- };
805
- var planSchema = z2.object(planInputSchema);
806
- function renderPlan(plan) {
807
- const lines2 = [];
808
- lines2.push(`# ${plan.title}`);
809
- lines2.push("");
810
- lines2.push(`**Scope** \u2014 \`${plan.scope}\``);
811
- lines2.push("");
812
- lines2.push(`**Goal** \u2014 ${plan.goal}`);
813
- if (plan.assumptions.length > 0) {
814
- lines2.push("");
815
- lines2.push("**Assumptions**");
816
- for (const assumption of plan.assumptions) {
817
- lines2.push(`- ${assumption}`);
818
- }
819
- }
820
- lines2.push("");
821
- lines2.push("## Requirements");
822
- for (const requirement of plan.requirements) {
823
- lines2.push(`- ${requirement}`);
824
- }
825
- lines2.push("");
826
- lines2.push("## Steps");
827
- for (const [i, step] of plan.steps.entries()) {
828
- lines2.push("");
829
- lines2.push(`### ${i + 1}. ${step.title}`);
830
- lines2.push("");
831
- for (const bullet of step.description) {
832
- lines2.push(`- ${bullet}`);
833
- }
834
- if (step.pseudoCode && step.pseudoCode.length > 0) {
835
- for (const entry of step.pseudoCode) {
836
- lines2.push("");
837
- lines2.push(`\`${entry.file}\``);
838
- lines2.push("");
839
- lines2.push("```");
840
- lines2.push(entry.pseudoCode);
841
- lines2.push("```");
842
- }
843
- }
844
- }
845
- lines2.push("");
846
- lines2.push("## Acceptance criteria");
847
- for (const criterion of plan.acceptanceCriteria) {
848
- lines2.push(`- [ ] ${criterion}`);
849
- }
850
- if (plan.risks.length > 0) {
851
- lines2.push("");
852
- lines2.push("**Risks / open questions**");
853
- for (const risk of plan.risks) {
854
- lines2.push(`- ${risk}`);
855
- }
856
- }
857
- if (plan.outOfScope.length > 0) {
858
- lines2.push("");
859
- lines2.push("**Out of scope**");
860
- for (const item of plan.outOfScope) {
861
- lines2.push(`- ${item}`);
862
- }
863
- }
864
- lines2.push("");
865
- lines2.push(PLAN_MARKER);
866
- return lines2.join("\n");
867
- }
868
- var submitPlanInputSchema = {
869
- plans: z2.array(z2.object(planInputSchema)).min(1).refine(
870
- (arr) => {
871
- const titles = arr.map((p) => p.title.trim()).filter((t) => t.length > 0);
872
- return new Set(titles).size === titles.length;
873
- },
874
- { message: "Each plan must have a distinct non-empty title" }
875
- )
876
- };
877
- var submitPlanSchema = z2.object(submitPlanInputSchema);
878
- function createPlanTooling() {
879
- let renderedPlans = null;
880
- const submitPlan = tool2(
881
- SUBMIT_PLAN,
882
- "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. ",
883
- submitPlanInputSchema,
884
- async (args) => {
885
- const parsed = submitPlanSchema.parse(args);
886
- renderedPlans = parsed.plans.map(renderPlan);
887
- return {
888
- content: [
889
- {
890
- type: "text",
891
- text: "Plan(s) submitted. The runner will render and post them as your comment(s). End your turn now."
892
- }
893
- ]
894
- };
895
- }
896
- );
897
- const mcpServer = createSdkMcpServer2({
898
- name: SERVER_NAME2,
899
- tools: [submitPlan]
900
- });
901
- return { mcpServer, getPlans: () => renderedPlans };
902
- }
903
- function countPlanAcceptanceCriteria(planBody) {
904
- if (!planBody) return 0;
905
- const lines2 = planBody.split("\n");
906
- const start2 = lines2.findIndex((l) => l.trim() === "## Acceptance criteria");
907
- if (start2 === -1) return 0;
908
- let count = 0;
909
- for (let i = start2 + 1; i < lines2.length; i++) {
910
- const line = lines2[i] ?? "";
911
- if (line.startsWith("## ")) break;
912
- if (line.startsWith("- [ ] ")) count++;
913
- }
914
- return count;
915
- }
916
-
917
- // src/report.ts
918
- import { createSdkMcpServer as createSdkMcpServer3, tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
919
- import { z as z3 } from "zod";
920
- var SERVER_NAME3 = "flume_report";
921
- var SUBMIT_REPORT = "submit_report";
922
- var REPORT_TOOL_NAME = `mcp__${SERVER_NAME3}__${SUBMIT_REPORT}`;
923
- var STATUS_ICON = {
924
- met: "\u2705",
925
- not_met: "\u274C",
926
- unclear: "\u26A0\uFE0F"
927
- };
928
- var evidenceSchema = z3.object({
929
- file: z3.string().min(1).describe("Repo-relative path the hunk comes from."),
930
- hunk: z3.string().min(1).describe(
931
- "A unified-diff hunk proving the criterion \u2014 the lines that matter, not the whole file. MUST keep the `@@ -a,b +c,d @@` hunk header line(s) exactly as they appear in `git --no-pager diff`; the report renders file line numbers from them. Rendered verbatim as a ```diff block."
932
- ),
933
- note: z3.string().optional().describe("Optional one-line explanation of why this hunk satisfies the criterion.")
934
- });
935
- var acVerdictSchema = z3.object({
936
- criterion: z3.string().min(1).describe("The acceptance-criterion text, verbatim from the plan."),
937
- status: z3.enum(["met", "not_met", "unclear"]).describe("Verdict for this criterion, verified against the actual diff."),
938
- rationale: z3.string().min(1).describe("One or two sentences on why the verdict holds. " + INLINE_CODE_HINT),
939
- evidence: z3.array(evidenceSchema).describe(
940
- "Diff hunks proving the verdict, copied verbatim from git --no-pager diff. Across ALL criteria the evidence must collectively cover every hunk in the diff \u2014 each changed hunk appears under at least one criterion. Cite the relevant hunk(s) for a met criterion; may be empty for not_met / unclear."
941
- )
942
- });
943
- var reportInputSchema = {
944
- summary: z3.string().min(1).describe("One or two sentences on what was implemented. " + INLINE_CODE_HINT),
945
- filesChanged: z3.string().min(1).describe(
946
- "Markdown: the list of files changed (from the diff). Rendered under '## Files changed'."
947
- ),
948
- codeQuality: z3.string().min(1).describe(
949
- "Markdown: the code-quality review outcome and anything left as nice-to-have. Rendered under '## Code quality'. " + INLINE_CODE_HINT
950
- ),
951
- caveats: z3.string().min(1).describe(
952
- "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
953
- ),
954
- acceptanceCriteria: z3.array(acVerdictSchema).describe(
955
- "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)."
956
- ),
957
- conflictResolution: z3.string().optional().describe(
958
- "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."
959
- )
960
- };
961
- var reportSchema = z3.object(reportInputSchema);
962
- function renderReport(report) {
963
- const lines2 = [];
964
- lines2.push(report.summary.trim());
965
- lines2.push("", "## Files changed", "", report.filesChanged.trim());
966
- if (report.acceptanceCriteria.length > 0) {
967
- lines2.push("", "## Acceptance criteria");
968
- for (const ac of report.acceptanceCriteria) {
969
- lines2.push("");
970
- lines2.push(`### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
971
- lines2.push("");
972
- lines2.push(ac.rationale.trim());
973
- for (const ev of ac.evidence) {
974
- lines2.push("");
975
- lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
976
- lines2.push("");
977
- lines2.push("```diff");
978
- lines2.push(ev.hunk.replace(/\n+$/, ""));
979
- lines2.push("```");
980
- }
981
- }
982
- }
983
- if (report.conflictResolution?.trim()) {
984
- lines2.push("", "## Conflict resolution", "", report.conflictResolution.trim());
985
- }
986
- lines2.push("", "## Code quality", "", report.codeQuality.trim());
987
- lines2.push("", "## Caveats / follow-ups", "", report.caveats.trim());
988
- return lines2.join("\n");
989
- }
990
- function createReportTooling() {
991
- let submittedReport = null;
992
- const submitReport = tool3(
993
- SUBMIT_REPORT,
994
- "Submit the final implementation report as structured data. Call this exactly once, at the end of the run. `acceptanceCriteria` must contain one entry per plan criterion, each with a met / not_met / unclear verdict and the diff hunk(s) that prove it. `summary`, `filesChanged`, `codeQuality`, and `caveats` are the four named markdown sections. Do NOT include a PR link \u2014 the runner appends it.",
995
- reportInputSchema,
996
- async (args) => {
997
- submittedReport = reportSchema.parse(args);
998
- return {
999
- content: [
1000
- {
1001
- type: "text",
1002
- text: "Report submitted. The runner will render and post it. End your turn now."
1003
- }
1004
- ]
1005
- };
1006
- }
1007
- );
1008
- const mcpServer = createSdkMcpServer3({
1009
- name: SERVER_NAME3,
1010
- tools: [submitReport]
1011
- });
1012
- return { mcpServer, getReport: () => submittedReport };
1013
- }
1014
-
1015
- // src/executor.ts
1016
- var FLUME_PLUGIN_DIR = fileURLToPath2(new URL("../skills-plugin", import.meta.url));
1017
- function emptyUsage() {
1018
- return {
1019
- inputTokens: 0,
1020
- outputTokens: 0,
1021
- cacheCreationTokens: 0,
1022
- cacheReadTokens: 0,
1023
- costUsd: 0
1024
- };
1025
- }
1026
- var usageAcc = emptyUsage();
1027
- function resetUsage() {
1028
- usageAcc = emptyUsage();
1029
- }
1030
- function getUsage() {
1031
- const totalTokens = usageAcc.inputTokens + usageAcc.outputTokens + usageAcc.cacheCreationTokens + usageAcc.cacheReadTokens;
1032
- return { ...usageAcc, totalTokens };
1033
- }
1034
- function stringifyResult(content) {
1035
- if (typeof content === "string") return content;
1036
- if (Array.isArray(content)) {
1037
- return content.map(
1038
- (c) => typeof c === "object" && c !== null && "text" in c ? String(c.text) : JSON.stringify(c)
1039
- ).join("\n");
1040
- }
1041
- return JSON.stringify(content);
635
+ return JSON.stringify(content);
1042
636
  }
1043
637
  async function runClaudeCode(opts) {
1044
638
  let finalText = "";
@@ -1162,11 +756,11 @@ function errorMessage(err) {
1162
756
 
1163
757
  // src/rules.ts
1164
758
  import { readFileSync as readFileSync3 } from "node:fs";
1165
- import { join as join5 } from "node:path";
759
+ import { join as join2 } from "node:path";
1166
760
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1167
761
  var RULES_DIR = fileURLToPath3(new URL("../skills-plugin/rules", import.meta.url));
1168
762
  function loadRule(name) {
1169
- const raw = readFileSync3(join5(RULES_DIR, `${name}.md`), "utf8");
763
+ const raw = readFileSync3(join2(RULES_DIR, `${name}.md`), "utf8");
1170
764
  return stripFrontMatter(raw).trim();
1171
765
  }
1172
766
  function stripFrontMatter(raw) {
@@ -1239,157 +833,523 @@ function buildRevisePrompt(ctx) {
1239
833
  "",
1240
834
  loadRule("coding-guideline"),
1241
835
  "",
1242
- WRITING_INTRO,
836
+ WRITING_INTRO,
837
+ "",
838
+ loadRule("technical-writing"),
839
+ "",
840
+ `# Plan: ${ctx.request?.title ?? ""}`
841
+ ];
842
+ if (ctx.request?.body) {
843
+ lines2.push("", ctx.request.body);
844
+ }
845
+ appendThread(lines2, ctx);
846
+ lines2.push(
847
+ "",
848
+ "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."
849
+ );
850
+ return lines2.join("\n");
851
+ }
852
+ function buildResolvePrompt(ctx, related = []) {
853
+ const mergeBranch = ctx.repo.mergeBranch ?? "the merge branch";
854
+ 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.`;
855
+ 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.`;
856
+ const lines2 = [
857
+ `You are "${ctx.agentName}", an autonomous coding agent resolving merge conflicts on an implemented FlumeCode plan.`,
858
+ `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}".`,
859
+ task,
860
+ orient,
861
+ "",
862
+ "These coding guidelines apply to all code produced in this run:",
863
+ "",
864
+ loadRule("coding-guideline"),
865
+ "",
866
+ WRITING_INTRO,
867
+ "",
868
+ loadRule("technical-writing"),
869
+ "",
870
+ `# Plan: ${ctx.request?.title ?? ""}`
871
+ ];
872
+ if (ctx.request?.body) {
873
+ lines2.push("", ctx.request.body);
874
+ }
875
+ appendThread(lines2, ctx);
876
+ if (related.length > 0) {
877
+ lines2.push(
878
+ "",
879
+ "# Related sessions behind the incoming changes",
880
+ "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."
881
+ );
882
+ for (const r of related) {
883
+ lines2.push("", `## PR #${r.prNumber} \u2014 ${r.title}`);
884
+ if (r.plan) lines2.push("", "### Accepted plan", r.plan);
885
+ if (r.report) lines2.push("", "### Final report", r.report);
886
+ }
887
+ }
888
+ lines2.push(
889
+ "",
890
+ "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)."
891
+ );
892
+ return lines2.join("\n");
893
+ }
894
+ function buildDocumentPrompt(ctx, changedFiles) {
895
+ const lines2 = [
896
+ `You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
897
+ `An implementation just ran in this working directory to satisfy the request below; its changes are uncommitted in the working tree.`,
898
+ `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.`,
899
+ "",
900
+ `# Request: ${ctx.request?.title ?? ""}`
901
+ ];
902
+ if (ctx.request?.body) {
903
+ lines2.push("", ctx.request.body);
904
+ }
905
+ appendThread(lines2, ctx);
906
+ if (changedFiles && changedFiles.trim()) {
907
+ lines2.push(
908
+ "",
909
+ "Files changed by this implementation (reconcile only the wiki pages these affect \u2014 do not re-survey the whole repo):",
910
+ "",
911
+ changedFiles.trim()
912
+ );
913
+ }
914
+ lines2.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
915
+ return lines2.join("\n");
916
+ }
917
+ function buildRepairPrompt(ctx, hookLog) {
918
+ const lines2 = [
919
+ `You are "${ctx.agentName}", fixing a failed pre-commit check in the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
920
+ `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.`,
921
+ "",
922
+ "These coding guidelines apply to all code produced in this run:",
923
+ "",
924
+ loadRule("coding-guideline"),
925
+ "",
926
+ "# Pre-commit hook output",
1243
927
  "",
1244
- loadRule("technical-writing"),
928
+ "```",
929
+ hookLog,
930
+ "```",
1245
931
  "",
1246
- `# Plan: ${ctx.request?.title ?? ""}`
932
+ "When done, reply with a one-line summary of what you fixed."
1247
933
  ];
1248
- if (ctx.request?.body) {
1249
- lines2.push("", ctx.request.body);
1250
- }
1251
- appendThread(lines2, ctx);
1252
- lines2.push(
1253
- "",
1254
- "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."
1255
- );
1256
934
  return lines2.join("\n");
1257
935
  }
1258
- function buildResolvePrompt(ctx, related = []) {
1259
- const mergeBranch = ctx.repo.mergeBranch ?? "the merge branch";
1260
- 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.`;
1261
- 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.`;
936
+ function buildReleasePrompt(ctx, baseChecks) {
937
+ 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.`;
938
+ 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.`;
939
+ 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.`;
1262
940
  const lines2 = [
1263
- `You are "${ctx.agentName}", an autonomous coding agent resolving merge conflicts on an implemented FlumeCode plan.`,
1264
- `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}".`,
941
+ `You are "${ctx.agentName}", an autonomous coding agent driving a FlumeCode release.`,
942
+ `The repository ${ctx.repo.fullName} is checked out in your current working directory on the release bump branch "${ctx.repo.checkoutBranch}".`,
1265
943
  task,
1266
944
  orient,
945
+ widgets,
1267
946
  "",
1268
947
  "These coding guidelines apply to all code produced in this run:",
1269
948
  "",
1270
949
  loadRule("coding-guideline"),
1271
950
  "",
1272
- WRITING_INTRO,
1273
- "",
1274
- loadRule("technical-writing"),
1275
- "",
1276
- `# Plan: ${ctx.request?.title ?? ""}`
951
+ `# Release: ${ctx.request?.title ?? ""}`
1277
952
  ];
1278
953
  if (ctx.request?.body) {
1279
954
  lines2.push("", ctx.request.body);
1280
955
  }
1281
- appendThread(lines2, ctx);
1282
- if (related.length > 0) {
956
+ if (baseChecks && !baseChecks.ok) {
1283
957
  lines2.push(
1284
958
  "",
1285
- "# Related sessions behind the incoming changes",
1286
- "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."
959
+ "# Pre-release check status",
960
+ "",
961
+ "\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:",
962
+ "",
963
+ "- **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.",
964
+ "- **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.",
965
+ "",
966
+ "Failing check output:",
967
+ "",
968
+ "```",
969
+ baseChecks.log,
970
+ "```"
1287
971
  );
1288
- for (const r of related) {
1289
- lines2.push("", `## PR #${r.prNumber} \u2014 ${r.title}`);
1290
- if (r.plan) lines2.push("", "### Accepted plan", r.plan);
1291
- if (r.report) lines2.push("", "### Final report", r.report);
972
+ }
973
+ appendThread(lines2, ctx);
974
+ lines2.push(
975
+ "",
976
+ "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."
977
+ );
978
+ return lines2.join("\n");
979
+ }
980
+ function buildInitPrompt(ctx) {
981
+ return [
982
+ `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
983
+ `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.`,
984
+ "",
985
+ "When done, reply with a one- or two-line summary of the wiki you created."
986
+ ].join("\n");
987
+ }
988
+
989
+ // src/types.ts
990
+ function jobTitle(ctx) {
991
+ return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
992
+ }
993
+
994
+ // src/workspace.ts
995
+ import { execFile } from "node:child_process";
996
+ import { existsSync as existsSync2 } from "node:fs";
997
+ import { mkdtemp, readdir, rm } from "node:fs/promises";
998
+ import { tmpdir } from "node:os";
999
+ import { join as join3 } from "node:path";
1000
+ import { promisify } from "node:util";
1001
+ var exec = promisify(execFile);
1002
+ var WORKSPACE_PREFIX = "flume-runner-";
1003
+ var MAX_BUFFER = 1 << 24;
1004
+ async function git(args) {
1005
+ logEvent("git", `git ${args.join(" ")}`);
1006
+ try {
1007
+ const result = await exec("git", args, { maxBuffer: MAX_BUFFER });
1008
+ if (result.stdout.trim()) logEvent("git:out", result.stdout.trim());
1009
+ if (result.stderr.trim()) logEvent("git:err", result.stderr.trim());
1010
+ return result;
1011
+ } catch (err) {
1012
+ logEvent("git:err", String(err.stderr ?? err));
1013
+ throw err;
1014
+ }
1015
+ }
1016
+ async function ensureGitIdentity(dir, identity) {
1017
+ await git(["-C", dir, "config", "user.email", identity.email]);
1018
+ await git(["-C", dir, "config", "user.name", identity.name]);
1019
+ }
1020
+ function cloneUrl(ctx) {
1021
+ const { owner, name, cloneToken } = ctx.repo;
1022
+ return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
1023
+ }
1024
+ function detectPackageManager(dir) {
1025
+ if (!existsSync2(join3(dir, "package.json"))) return null;
1026
+ if (existsSync2(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
1027
+ if (existsSync2(join3(dir, "yarn.lock"))) return "yarn";
1028
+ if (existsSync2(join3(dir, "package-lock.json"))) return "npm";
1029
+ if (existsSync2(join3(dir, "bun.lockb"))) return "bun";
1030
+ return "npm";
1031
+ }
1032
+ async function installDependencies(dir) {
1033
+ const manager = detectPackageManager(dir);
1034
+ if (manager === null) return { status: "skipped" };
1035
+ const env = { ...process.env, CI: "1", ADBLOCK: "1", DISABLE_OPENCOLLECTIVE: "1" };
1036
+ logEvent("install", `${manager} install`);
1037
+ try {
1038
+ const result = await exec(manager, ["install"], {
1039
+ cwd: dir,
1040
+ maxBuffer: MAX_BUFFER,
1041
+ env,
1042
+ timeout: 5 * 6e4
1043
+ });
1044
+ if (result.stdout.trim()) logEvent("install:out", result.stdout.trim());
1045
+ if (result.stderr.trim()) logEvent("install:err", result.stderr.trim());
1046
+ return { status: "installed", manager };
1047
+ } catch (err) {
1048
+ const e = err;
1049
+ const detail = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
1050
+ logEvent("install:err", detail || (err instanceof Error ? err.message : String(err)));
1051
+ return { status: "failed", manager, error: err instanceof Error ? err.message : String(err) };
1052
+ }
1053
+ }
1054
+ async function makeWorkspace() {
1055
+ return mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX));
1056
+ }
1057
+ var MAX_WORKSPACES = 8;
1058
+ var workspaceRegistry = /* @__PURE__ */ new Map();
1059
+ async function acquireWorkspace(key) {
1060
+ const existing = workspaceRegistry.get(key);
1061
+ if (existing !== void 0 && existsSync2(existing)) {
1062
+ workspaceRegistry.delete(key);
1063
+ workspaceRegistry.set(key, existing);
1064
+ return { dir: existing, reused: true };
1065
+ }
1066
+ const dir = await makeWorkspace();
1067
+ workspaceRegistry.set(key, dir);
1068
+ if (workspaceRegistry.size > MAX_WORKSPACES) {
1069
+ const oldest = workspaceRegistry.keys().next().value;
1070
+ const oldDir = workspaceRegistry.get(oldest);
1071
+ workspaceRegistry.delete(oldest);
1072
+ rm(oldDir, { recursive: true, force: true }).catch(() => {
1073
+ });
1074
+ }
1075
+ return { dir, reused: false };
1076
+ }
1077
+ async function discardWorkspace(key) {
1078
+ const dir = workspaceRegistry.get(key);
1079
+ workspaceRegistry.delete(key);
1080
+ if (dir !== void 0) {
1081
+ await cleanup(dir).catch(() => {
1082
+ });
1083
+ }
1084
+ }
1085
+ async function resetWorkspace(dir) {
1086
+ await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
1087
+ });
1088
+ await git(["-C", dir, "clean", "-fd"]).catch(() => {
1089
+ });
1090
+ }
1091
+ async function prepareAtSha(ctx, dir, reused) {
1092
+ const identity = { name: ctx.agentName, email: ctx.agentEmail };
1093
+ if (!reused) {
1094
+ await cloneAtSha(ctx, dir);
1095
+ await ensureGitIdentity(dir, identity);
1096
+ return;
1097
+ }
1098
+ await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
1099
+ await ensureGitIdentity(dir, identity);
1100
+ }
1101
+ async function prepareResumingBranch(ctx, dir, reused) {
1102
+ const identity = { name: ctx.agentName, email: ctx.agentEmail };
1103
+ if (!reused) {
1104
+ const result = await cloneResumingBranch(ctx, dir);
1105
+ await ensureGitIdentity(dir, identity);
1106
+ return result;
1107
+ }
1108
+ await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
1109
+ await ensureGitIdentity(dir, identity);
1110
+ return { resumed: true };
1111
+ }
1112
+ async function sweepWorkspaces() {
1113
+ const base = tmpdir();
1114
+ let entries;
1115
+ try {
1116
+ entries = await readdir(base);
1117
+ } catch {
1118
+ return 0;
1119
+ }
1120
+ let removed = 0;
1121
+ for (const entry of entries) {
1122
+ if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
1123
+ try {
1124
+ await rm(join3(base, entry), { recursive: true, force: true });
1125
+ removed++;
1126
+ } catch {
1127
+ }
1128
+ }
1129
+ return removed;
1130
+ }
1131
+ async function cloneAtSha(ctx, dir) {
1132
+ await git(["clone", "--quiet", cloneUrl(ctx), dir]);
1133
+ await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
1134
+ }
1135
+ async function cloneResumingBranch(ctx, dir) {
1136
+ await git(["clone", "--quiet", cloneUrl(ctx), dir]);
1137
+ try {
1138
+ await git(["-C", dir, "fetch", "--quiet", "origin", ctx.repo.checkoutBranch]);
1139
+ await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, "FETCH_HEAD"]);
1140
+ return { resumed: true };
1141
+ } catch {
1142
+ await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
1143
+ return { resumed: false };
1144
+ }
1145
+ }
1146
+ async function hasChanges(dir) {
1147
+ await git(["-C", dir, "add", "-A"]);
1148
+ const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
1149
+ return stdout2.trim().length > 0;
1150
+ }
1151
+ async function gitDiffStat(dir) {
1152
+ const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "--stat"]);
1153
+ return stdout2;
1154
+ }
1155
+ var PreCommitError = class extends Error {
1156
+ constructor(log) {
1157
+ super("pre-commit checks failed");
1158
+ this.log = log;
1159
+ this.name = "PreCommitError";
1160
+ }
1161
+ };
1162
+ function commitFailureLog(err) {
1163
+ const e = err;
1164
+ const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
1165
+ return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
1166
+ }
1167
+ function isUnsupportedGitSubcommand(err) {
1168
+ const e = err;
1169
+ const text = `${typeof e.stderr === "string" ? e.stderr : ""}
1170
+ ${e.message ?? ""}`;
1171
+ return /is not a git command|unknown subcommand|usage: git hook/i.test(text);
1172
+ }
1173
+ async function runRepoChecks(dir) {
1174
+ try {
1175
+ await git(["-C", dir, "hook", "run", "pre-commit"]);
1176
+ logEvent("checks", "pre-commit hook passed");
1177
+ return { ok: true, log: "", skipped: false };
1178
+ } catch (err) {
1179
+ if (isUnsupportedGitSubcommand(err)) {
1180
+ logEvent("checks", "pre-commit hook skipped (git too old)");
1181
+ return { ok: true, log: "", skipped: true };
1292
1182
  }
1183
+ const log = commitFailureLog(err);
1184
+ logEvent("checks:err", log);
1185
+ return { ok: false, log, skipped: false };
1293
1186
  }
1294
- lines2.push(
1295
- "",
1296
- "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)."
1297
- );
1298
- return lines2.join("\n");
1299
1187
  }
1300
- function buildDocumentPrompt(ctx, changedFiles) {
1301
- const lines2 = [
1302
- `You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
1303
- `An implementation just ran in this working directory to satisfy the request below; its changes are uncommitted in the working tree.`,
1304
- `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.`,
1305
- "",
1306
- `# Request: ${ctx.request?.title ?? ""}`
1307
- ];
1308
- if (ctx.request?.body) {
1309
- lines2.push("", ctx.request.body);
1188
+ async function commitChanges(ctx, dir) {
1189
+ if (!await hasChanges(dir)) return false;
1190
+ try {
1191
+ await git(["-C", dir, "commit", "--quiet", "-m", `FlumeCode: ${jobTitle(ctx)}`]);
1192
+ } catch (err) {
1193
+ throw new PreCommitError(commitFailureLog(err));
1310
1194
  }
1311
- appendThread(lines2, ctx);
1312
- if (changedFiles && changedFiles.trim()) {
1313
- lines2.push(
1314
- "",
1315
- "Files changed by this implementation (reconcile only the wiki pages these affect \u2014 do not re-survey the whole repo):",
1316
- "",
1317
- changedFiles.trim()
1195
+ return true;
1196
+ }
1197
+ async function pushBranch(ctx, dir) {
1198
+ await git(["-C", dir, "push", "--quiet", "-u", "origin", ctx.repo.checkoutBranch]);
1199
+ }
1200
+ var RebaseConflictError = class extends Error {
1201
+ constructor(mergeBranch, files) {
1202
+ const list = files.length ? `: ${files.join(", ")}` : "";
1203
+ super(`Rebase onto ${mergeBranch} hit conflicts in ${files.length} file(s)${list}`);
1204
+ this.mergeBranch = mergeBranch;
1205
+ this.files = files;
1206
+ this.name = "RebaseConflictError";
1207
+ }
1208
+ };
1209
+ async function rebaseOntoMergeBranch(ctx, dir) {
1210
+ const { mergeBranch } = ctx.repo;
1211
+ if (!mergeBranch) return;
1212
+ await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
1213
+ try {
1214
+ await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
1215
+ } catch (err) {
1216
+ const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
1217
+ () => ({ stdout: "" })
1318
1218
  );
1219
+ const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
1220
+ await git(["-C", dir, "rebase", "--abort"]).catch(() => {
1221
+ });
1222
+ if (files.length === 0) throw err;
1223
+ throw new RebaseConflictError(mergeBranch, files);
1319
1224
  }
1320
- lines2.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
1321
- return lines2.join("\n");
1322
1225
  }
1323
- function buildRepairPrompt(ctx, hookLog) {
1324
- const lines2 = [
1325
- `You are "${ctx.agentName}", fixing a failed pre-commit check in the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
1326
- `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.`,
1327
- "",
1328
- "These coding guidelines apply to all code produced in this run:",
1329
- "",
1330
- loadRule("coding-guideline"),
1331
- "",
1332
- "# Pre-commit hook output",
1333
- "",
1334
- "```",
1335
- hookLog,
1336
- "```",
1337
- "",
1338
- "When done, reply with a one-line summary of what you fixed."
1339
- ];
1340
- return lines2.join("\n");
1226
+ async function mergeInMergeBranch(ctx, dir) {
1227
+ const { mergeBranch } = ctx.repo;
1228
+ if (!mergeBranch) return { conflicted: false };
1229
+ await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
1230
+ try {
1231
+ await git(["-C", dir, "merge", "--no-edit", "FETCH_HEAD"]);
1232
+ return { conflicted: false };
1233
+ } catch {
1234
+ return { conflicted: true };
1235
+ }
1341
1236
  }
1342
- function buildReleasePrompt(ctx, baseChecks) {
1343
- 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.`;
1344
- 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.`;
1345
- 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.`;
1346
- const lines2 = [
1347
- `You are "${ctx.agentName}", an autonomous coding agent driving a FlumeCode release.`,
1348
- `The repository ${ctx.repo.fullName} is checked out in your current working directory on the release bump branch "${ctx.repo.checkoutBranch}".`,
1349
- task,
1350
- orient,
1351
- widgets,
1352
- "",
1353
- "These coding guidelines apply to all code produced in this run:",
1354
- "",
1355
- loadRule("coding-guideline"),
1356
- "",
1357
- `# Release: ${ctx.request?.title ?? ""}`
1358
- ];
1359
- if (ctx.request?.body) {
1360
- lines2.push("", ctx.request.body);
1237
+ async function listUnmergedPaths(dir) {
1238
+ const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
1239
+ stdout: ""
1240
+ }));
1241
+ return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
1242
+ }
1243
+ async function listConflictMarkerPaths(dir, paths) {
1244
+ if (paths.length === 0) return [];
1245
+ const { stdout: stdout2 } = await git([
1246
+ "-C",
1247
+ dir,
1248
+ "grep",
1249
+ "--no-color",
1250
+ "-lE",
1251
+ "^(<<<<<<<|>>>>>>>|\\|\\|\\|\\|\\|\\|\\|)",
1252
+ "--",
1253
+ ...paths
1254
+ ]).catch(() => ({ stdout: "" }));
1255
+ return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
1256
+ }
1257
+ async function openPullRequest(ctx) {
1258
+ const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
1259
+ if (!mergeBranch) return null;
1260
+ const apiBase = `https://api.github.com/repos/${owner}/${name}`;
1261
+ const headers = {
1262
+ authorization: `Bearer ${cloneToken}`,
1263
+ accept: "application/vnd.github+json",
1264
+ "x-github-api-version": "2022-11-28",
1265
+ "content-type": "application/json"
1266
+ };
1267
+ const title = jobTitle(ctx);
1268
+ 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}".`;
1269
+ const res = await fetch(`${apiBase}/pulls`, {
1270
+ method: "POST",
1271
+ headers,
1272
+ body: JSON.stringify({
1273
+ title: `FlumeCode: ${title}`,
1274
+ head: checkoutBranch,
1275
+ base: mergeBranch,
1276
+ body
1277
+ })
1278
+ });
1279
+ if (res.status === 201) {
1280
+ const data = await res.json();
1281
+ return { number: data.number, url: data.html_url };
1361
1282
  }
1362
- if (baseChecks && !baseChecks.ok) {
1363
- lines2.push(
1364
- "",
1365
- "# Pre-release check status",
1366
- "",
1367
- "\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:",
1368
- "",
1369
- "- **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.",
1370
- "- **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.",
1371
- "",
1372
- "Failing check output:",
1373
- "",
1374
- "```",
1375
- baseChecks.log,
1376
- "```"
1283
+ if (res.status === 422) {
1284
+ const list = await fetch(
1285
+ `${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
1286
+ { headers }
1377
1287
  );
1288
+ if (list.ok) {
1289
+ const open = await list.json();
1290
+ if (open[0]) return { number: open[0].number, url: open[0].html_url };
1291
+ }
1292
+ return null;
1378
1293
  }
1379
- appendThread(lines2, ctx);
1380
- lines2.push(
1381
- "",
1382
- "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."
1383
- );
1384
- return lines2.join("\n");
1294
+ throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
1385
1295
  }
1386
- function buildInitPrompt(ctx) {
1387
- return [
1388
- `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
1389
- `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.`,
1390
- "",
1391
- "When done, reply with a one- or two-line summary of the wiki you created."
1392
- ].join("\n");
1296
+ async function cleanup(dir) {
1297
+ await rm(dir, { recursive: true, force: true });
1298
+ }
1299
+ function parsePrFromSubject(subject) {
1300
+ const m = subject.match(/\(#(\d+)\)\s*$/);
1301
+ return m ? Number(m[1]) : null;
1302
+ }
1303
+ async function incomingPrNumbers(ctx, dir, paths) {
1304
+ if (!paths.length) return [];
1305
+ try {
1306
+ const mergeHeadResult = await git(["-C", dir, "rev-parse", "MERGE_HEAD"]);
1307
+ const mergeHead = mergeHeadResult.stdout.trim();
1308
+ const baseResult = await git(["-C", dir, "merge-base", "HEAD", mergeHead]);
1309
+ const base = baseResult.stdout.trim();
1310
+ const logResult = await git([
1311
+ "-C",
1312
+ dir,
1313
+ "log",
1314
+ "--no-merges",
1315
+ `--format=%H%x1f%s`,
1316
+ `${base}..${mergeHead}`,
1317
+ "--",
1318
+ ...paths
1319
+ ]);
1320
+ const nums = /* @__PURE__ */ new Set();
1321
+ const needLookup = [];
1322
+ for (const line of logResult.stdout.split("\n").filter(Boolean)) {
1323
+ const idx = line.indexOf("");
1324
+ const sha = line.slice(0, idx);
1325
+ const subject = line.slice(idx + 1);
1326
+ const n = parsePrFromSubject(subject);
1327
+ if (n !== null) nums.add(n);
1328
+ else needLookup.push(sha);
1329
+ }
1330
+ for (const sha of needLookup) {
1331
+ for (const n of await prNumbersForCommit(ctx, sha)) nums.add(n);
1332
+ }
1333
+ return [...nums];
1334
+ } catch {
1335
+ return [];
1336
+ }
1337
+ }
1338
+ async function prNumbersForCommit(ctx, sha) {
1339
+ const { owner, name, cloneToken } = ctx.repo;
1340
+ try {
1341
+ const res = await fetch(`https://api.github.com/repos/${owner}/${name}/commits/${sha}/pulls`, {
1342
+ headers: {
1343
+ authorization: `Bearer ${cloneToken}`,
1344
+ accept: "application/vnd.github+json",
1345
+ "x-github-api-version": "2022-11-28"
1346
+ }
1347
+ });
1348
+ if (!res.ok) return [];
1349
+ return (await res.json()).map((p) => p.number);
1350
+ } catch {
1351
+ return [];
1352
+ }
1393
1353
  }
1394
1354
 
1395
1355
  // src/run.ts
@@ -1419,7 +1379,7 @@ async function pushAndOpenPr(ctx, dir, config, abort, opts = { rebase: true }) {
1419
1379
  );
1420
1380
  const { report: mergeReport } = await mergeAndResolveConflicts(ctx, dir, config, abort);
1421
1381
  conflictResolution = mergeReport?.conflictResolution;
1422
- await commitWithRepair(ctx, dir, abort, { skipSocket: true });
1382
+ await commitWithRepair(ctx, dir, abort);
1423
1383
  autoMerged = true;
1424
1384
  }
1425
1385
  }
@@ -1453,10 +1413,9 @@ async function mergeAndResolveConflicts(ctx, dir, config, abort) {
1453
1413
  }
1454
1414
  return { resolved: true, text: result.text.trim() || null, report: result.report ?? void 0 };
1455
1415
  }
1456
- async function commitWithRepair(ctx, dir, abort, opts = {}) {
1416
+ async function commitWithRepair(ctx, dir, abort) {
1457
1417
  for (let attempt = 1; ; attempt++) {
1458
1418
  try {
1459
- if (!opts.skipSocket) await runSocket("pre-commit", dir);
1460
1419
  return await commitChanges(ctx, dir);
1461
1420
  } catch (err) {
1462
1421
  if (!(err instanceof PreCommitError) || attempt > MAX_COMMIT_REPAIRS) throw err;
@@ -1581,7 +1540,7 @@ async function processChatJob(ctx, dir, config, abort) {
1581
1540
  console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1582
1541
  return { text: reply, widgets: result.widgets };
1583
1542
  }
1584
- const wikiExists = existsSync4(join6(dir, ".flumecode", "wiki"));
1543
+ const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
1585
1544
  let documented = false;
1586
1545
  if (ctx.permissionMode !== "plan" && wikiExists && await hasChanges(dir)) {
1587
1546
  try {
@@ -1670,7 +1629,7 @@ ${reply}`;
1670
1629
 
1671
1630
  > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1672
1631
  }
1673
- const wikiExists = existsSync4(join6(dir, ".flumecode", "wiki"));
1632
+ const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
1674
1633
  let documented = false;
1675
1634
  if (wikiExists && await hasChanges(dir)) {
1676
1635
  try {
@@ -1694,9 +1653,7 @@ ${reply}`;
1694
1653
  rebase: !resumed
1695
1654
  });
1696
1655
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1697
- const lintPlugins = getSocketResults();
1698
- const reportWithConflict = report && conflictResolution ? { ...report, conflictResolution } : report;
1699
- const finalReport = reportWithConflict && lintPlugins.length ? { ...reportWithConflict, lint: { plugins: lintPlugins } } : reportWithConflict;
1656
+ const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
1700
1657
  return {
1701
1658
  text: reply,
1702
1659
  widgets: [],
@@ -1728,7 +1685,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1728
1685
  console.log(` \u2026revise ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1729
1686
  return { text: reply, widgets: result.widgets };
1730
1687
  }
1731
- const wikiExists = existsSync4(join6(dir, ".flumecode", "wiki"));
1688
+ const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
1732
1689
  let documented = false;
1733
1690
  if (wikiExists && await hasChanges(dir)) {
1734
1691
  try {
@@ -1754,9 +1711,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1754
1711
  if (outcome.kind !== "none") {
1755
1712
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1756
1713
  }
1757
- const lintPlugins = getSocketResults();
1758
- const reportWithConflict = report && conflictResolution ? { ...report, conflictResolution } : report;
1759
- const finalReport = reportWithConflict && lintPlugins.length ? { ...reportWithConflict, lint: { plugins: lintPlugins } } : reportWithConflict;
1714
+ const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
1760
1715
  return {
1761
1716
  text: reply,
1762
1717
  widgets: [],
@@ -1782,7 +1737,7 @@ async function processResolveJob(ctx, dir, config, abort) {
1782
1737
  > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1783
1738
  }
1784
1739
  if (abort.signal.aborted) throw new Error("Run canceled by user");
1785
- await commitWithRepair(ctx, dir, abort, { skipSocket: true });
1740
+ await commitWithRepair(ctx, dir, abort);
1786
1741
  await pushBranch(ctx, dir);
1787
1742
  const pr = await openPullRequest(ctx);
1788
1743
  const outcome = pr ? { kind: "pr", pr } : { kind: "pushed" };
@@ -1896,7 +1851,6 @@ async function pollLoop(config) {
1896
1851
  scheduleCancelPoll();
1897
1852
  try {
1898
1853
  resetUsage();
1899
- resetSocketResults();
1900
1854
  const { text, widgets, pr, plans, report } = await processJob(ctx, config, abort);
1901
1855
  const usage = getUsage();
1902
1856
  await reportJob(config, ctx.jobId, {