@flumecode/runner 0.19.0 → 0.20.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 existsSync3 } from "node:fs";
30
- import { join as join4 } from "node:path";
29
+ import { existsSync as existsSync4 } from "node:fs";
30
+ import { join as join5 } from "node:path";
31
31
 
32
32
  // src/version.ts
33
33
  import { readFileSync as readFileSync2 } from "node:fs";
@@ -203,51 +203,59 @@ var WIDGET_TOOL_NAMES = [
203
203
  ];
204
204
  var optionsSchema = z.array(z.string().min(1)).min(2).max(8).describe("2\u20138 short, distinct choices for the user to pick from. " + WIDGET_LANGUAGE_HINT);
205
205
  var TAIL = "Do NOT add an 'Other' or 'None of these' catch-all \u2014 the UI always offers an 'Other' free-text option automatically. " + WIDGET_LANGUAGE_HINT + " After calling this, END YOUR TURN and wait: the user's answer arrives as their next message and starts a fresh run.";
206
+ var singleSelectShape = {
207
+ question: z.string().min(1).describe("The question to ask the user. " + WIDGET_LANGUAGE_HINT),
208
+ body: z.string().optional().describe(
209
+ "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."
210
+ ),
211
+ options: optionsSchema
212
+ };
213
+ var multiSelectShape = {
214
+ question: z.string().min(1).describe("The question to ask the user. " + WIDGET_LANGUAGE_HINT),
215
+ body: z.string().optional().describe(
216
+ "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."
217
+ ),
218
+ options: optionsSchema
219
+ };
220
+ function buildSingleSelectWidget(args) {
221
+ return {
222
+ id: randomUUID(),
223
+ type: "single_select",
224
+ question: args.question,
225
+ body: args.body,
226
+ options: args.options.map((label) => ({ id: randomUUID(), label })),
227
+ selectedOptionId: null,
228
+ customAnswer: null
229
+ };
230
+ }
231
+ function buildMultiSelectWidget(args) {
232
+ return {
233
+ id: randomUUID(),
234
+ type: "multi_select",
235
+ question: args.question,
236
+ body: args.body,
237
+ options: args.options.map((label) => ({ id: randomUUID(), label })),
238
+ selectedOptionIds: null,
239
+ customAnswer: null
240
+ };
241
+ }
206
242
  function createWidgetTooling() {
207
243
  const collected = [];
208
244
  const singleSelect = tool(
209
245
  SINGLE_SELECT,
210
246
  "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,
211
- {
212
- question: z.string().min(1).describe("The question to ask the user. " + WIDGET_LANGUAGE_HINT),
213
- body: z.string().optional().describe(
214
- "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."
215
- ),
216
- options: optionsSchema
217
- },
247
+ singleSelectShape,
218
248
  async (args) => {
219
- collected.push({
220
- id: randomUUID(),
221
- type: "single_select",
222
- question: args.question,
223
- body: args.body,
224
- options: args.options.map((label) => ({ id: randomUUID(), label })),
225
- selectedOptionId: null,
226
- customAnswer: null
227
- });
249
+ collected.push(buildSingleSelectWidget(args));
228
250
  return widgetPosted("single-select");
229
251
  }
230
252
  );
231
253
  const multiSelect = tool(
232
254
  MULTI_SELECT,
233
255
  "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,
234
- {
235
- question: z.string().min(1).describe("The question to ask the user. " + WIDGET_LANGUAGE_HINT),
236
- body: z.string().optional().describe(
237
- "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."
238
- ),
239
- options: optionsSchema
240
- },
256
+ multiSelectShape,
241
257
  async (args) => {
242
- collected.push({
243
- id: randomUUID(),
244
- type: "multi_select",
245
- question: args.question,
246
- body: args.body,
247
- options: args.options.map((label) => ({ id: randomUUID(), label })),
248
- selectedOptionIds: null,
249
- customAnswer: null
250
- });
258
+ collected.push(buildMultiSelectWidget(args));
251
259
  return widgetPosted("multi-select");
252
260
  }
253
261
  );
@@ -321,6 +329,9 @@ var planInputSchema = {
321
329
  rootCause: z2.string().optional().describe(
322
330
  '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
323
331
  ),
332
+ motivation: z2.string().optional().describe(
333
+ "Why the user is making this request \u2014 the underlying motivation or problem the change addresses. Fill this especially when the request content/context does NOT already state the why (ask the user in the Clarify phase); omit when there is no additional motivation to record. Useful for future understanding of the system. " + INLINE_CODE_HINT
334
+ ),
324
335
  assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
325
336
  requirements: z2.array(z2.string().min(1)).min(1).describe(
326
337
  "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
@@ -351,6 +362,11 @@ function renderPlan(plan) {
351
362
  lines2.push(`**Scope** \u2014 \`${plan.scope}\``);
352
363
  lines2.push("");
353
364
  lines2.push(`**Goal** \u2014 ${plan.goal}`);
365
+ if (plan.motivation && plan.motivation.trim().length > 0) {
366
+ lines2.push("");
367
+ lines2.push("## Motivation");
368
+ lines2.push(plan.motivation);
369
+ }
354
370
  if (plan.assumptions.length > 0) {
355
371
  lines2.push("");
356
372
  lines2.push("**Assumptions**");
@@ -426,7 +442,7 @@ function createPlanTooling() {
426
442
  let renderedPlans = null;
427
443
  const submitPlan = tool2(
428
444
  SUBMIT_PLAN,
429
- `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). `,
445
+ `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). motivation is optional: the user's stated or asked-for reason for the request. `,
430
446
  submitPlanInputSchema,
431
447
  async (args) => {
432
448
  const parsed = submitPlanSchema.parse(args);
@@ -709,6 +725,87 @@ async function runClaudeCode(opts) {
709
725
  return { text: finalText, widgets: collected, plans: getPlans(), report: getReport() };
710
726
  }
711
727
 
728
+ // src/codex.ts
729
+ import { spawn } from "node:child_process";
730
+ import { mkdtempSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "node:fs";
731
+ import { join as join2 } from "node:path";
732
+ import { tmpdir } from "node:os";
733
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
734
+ var MCP_ENTRY = fileURLToPath3(new URL("./mcp-stdio.js", import.meta.url));
735
+ async function runCodex(opts) {
736
+ const tmpDir = mkdtempSync(join2(tmpdir(), "flume-codex-"));
737
+ const outFile = join2(tmpDir, "flume-mcp.jsonl");
738
+ writeFileSync2(outFile, "");
739
+ const child = spawn(
740
+ "codex",
741
+ [
742
+ "exec",
743
+ "--cwd",
744
+ opts.cwd,
745
+ "-c",
746
+ `mcp_servers.flume.command=node`,
747
+ "-c",
748
+ `mcp_servers.flume.args=${JSON.stringify([MCP_ENTRY])}`,
749
+ opts.prompt
750
+ ],
751
+ {
752
+ cwd: opts.cwd,
753
+ env: { ...process.env, FLUME_MCP_OUTPUT: outFile },
754
+ // stdin is inherited (codex may read from it), stdout/stderr are piped for streaming
755
+ stdio: ["inherit", "pipe", "pipe"]
756
+ }
757
+ );
758
+ child.stdout?.on("data", (chunk) => {
759
+ const text = chunk.toString();
760
+ process.stdout.write(text);
761
+ logEvent("agent", text);
762
+ });
763
+ child.stderr?.on("data", (chunk) => {
764
+ const text = chunk.toString();
765
+ process.stderr.write(text);
766
+ logEvent("agent", text);
767
+ });
768
+ const onAbort = () => {
769
+ child.kill("SIGTERM");
770
+ };
771
+ opts.abortController?.signal.addEventListener("abort", onAbort, { once: true });
772
+ await new Promise((resolve, reject) => {
773
+ child.on("error", (err) => {
774
+ if (err.code === "ENOENT") {
775
+ reject(
776
+ new Error(
777
+ "codex CLI not found. Install it with `npm install -g @openai/codex` and log in before running plan-mode jobs with an openai agent."
778
+ )
779
+ );
780
+ } else {
781
+ reject(err);
782
+ }
783
+ });
784
+ child.on("close", () => resolve());
785
+ });
786
+ opts.abortController?.signal.removeEventListener("abort", onAbort);
787
+ if (opts.abortController?.signal.aborted) {
788
+ throw new Error("Run canceled by user");
789
+ }
790
+ const raw = existsSync2(outFile) ? readFileSync3(outFile, "utf8") : "";
791
+ const records = raw.split("\n").filter(Boolean).map(parseJsonLine).filter((r) => r !== null);
792
+ const plans = records.filter((r) => r.kind === "plans").flatMap((r) => r.plans ?? []);
793
+ const widgets = records.filter((r) => r.kind === "widget").map((r) => r.widget);
794
+ return {
795
+ text: "",
796
+ widgets,
797
+ plans: plans.length > 0 ? plans : null,
798
+ report: null
799
+ };
800
+ }
801
+ function parseJsonLine(line) {
802
+ try {
803
+ return JSON.parse(line);
804
+ } catch {
805
+ return null;
806
+ }
807
+ }
808
+
712
809
  // src/health.ts
713
810
  import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
714
811
  var PROBE_TIMEOUT_MS = 6e4;
@@ -758,18 +855,23 @@ function errorMessage(err) {
758
855
  }
759
856
 
760
857
  // src/rules.ts
761
- import { readFileSync as readFileSync3 } from "node:fs";
762
- import { join as join2 } from "node:path";
763
- import { fileURLToPath as fileURLToPath3 } from "node:url";
764
- var RULES_DIR = fileURLToPath3(new URL("../skills-plugin/rules", import.meta.url));
858
+ import { readFileSync as readFileSync4 } from "node:fs";
859
+ import { join as join3 } from "node:path";
860
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
861
+ var RULES_DIR = fileURLToPath4(new URL("../skills-plugin/rules", import.meta.url));
765
862
  function loadRule(name) {
766
- const raw = readFileSync3(join2(RULES_DIR, `${name}.md`), "utf8");
863
+ const raw = readFileSync4(join3(RULES_DIR, `${name}.md`), "utf8");
767
864
  return stripFrontMatter(raw).trim();
768
865
  }
769
866
  function stripFrontMatter(raw) {
770
867
  const match = raw.match(/^---\n.*?\n---\n/s);
771
868
  return match ? raw.slice(match[0].length) : raw;
772
869
  }
870
+ var SKILLS_DIR = fileURLToPath4(new URL("../skills-plugin/skills", import.meta.url));
871
+ function loadSkill(name) {
872
+ const raw = readFileSync4(join3(SKILLS_DIR, name, "SKILL.md"), "utf8");
873
+ return stripFrontMatter(raw).trim();
874
+ }
773
875
 
774
876
  // src/prompt.ts
775
877
  function appendRule(lines2, intro, ruleName) {
@@ -992,6 +1094,12 @@ function buildReleasePrompt(ctx, baseChecks) {
992
1094
  );
993
1095
  return lines2.join("\n");
994
1096
  }
1097
+ function buildCodexPrompt(ctx) {
1098
+ const skill = loadSkill("request-to-plan");
1099
+ return ["# request-to-plan skill (follow these instructions)", skill, "", buildPrompt(ctx)].join(
1100
+ "\n"
1101
+ );
1102
+ }
995
1103
  function buildInitPrompt(ctx) {
996
1104
  return [
997
1105
  `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
@@ -1008,10 +1116,10 @@ function jobTitle(ctx) {
1008
1116
 
1009
1117
  // src/workspace.ts
1010
1118
  import { execFile } from "node:child_process";
1011
- import { existsSync as existsSync2 } from "node:fs";
1119
+ import { existsSync as existsSync3 } from "node:fs";
1012
1120
  import { mkdtemp, readdir, rm } from "node:fs/promises";
1013
- import { tmpdir } from "node:os";
1014
- import { join as join3 } from "node:path";
1121
+ import { tmpdir as tmpdir2 } from "node:os";
1122
+ import { join as join4 } from "node:path";
1015
1123
  import { promisify } from "node:util";
1016
1124
  var exec = promisify(execFile);
1017
1125
  var WORKSPACE_PREFIX = "flume-runner-";
@@ -1037,11 +1145,11 @@ function cloneUrl(ctx) {
1037
1145
  return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
1038
1146
  }
1039
1147
  function detectPackageManager(dir) {
1040
- if (!existsSync2(join3(dir, "package.json"))) return null;
1041
- if (existsSync2(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
1042
- if (existsSync2(join3(dir, "yarn.lock"))) return "yarn";
1043
- if (existsSync2(join3(dir, "package-lock.json"))) return "npm";
1044
- if (existsSync2(join3(dir, "bun.lockb"))) return "bun";
1148
+ if (!existsSync3(join4(dir, "package.json"))) return null;
1149
+ if (existsSync3(join4(dir, "pnpm-lock.yaml"))) return "pnpm";
1150
+ if (existsSync3(join4(dir, "yarn.lock"))) return "yarn";
1151
+ if (existsSync3(join4(dir, "package-lock.json"))) return "npm";
1152
+ if (existsSync3(join4(dir, "bun.lockb"))) return "bun";
1045
1153
  return "npm";
1046
1154
  }
1047
1155
  async function installDependencies(dir) {
@@ -1067,13 +1175,13 @@ async function installDependencies(dir) {
1067
1175
  }
1068
1176
  }
1069
1177
  async function makeWorkspace() {
1070
- return mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX));
1178
+ return mkdtemp(join4(tmpdir2(), WORKSPACE_PREFIX));
1071
1179
  }
1072
1180
  var MAX_WORKSPACES = 8;
1073
1181
  var workspaceRegistry = /* @__PURE__ */ new Map();
1074
1182
  async function acquireWorkspace(key) {
1075
1183
  const existing = workspaceRegistry.get(key);
1076
- if (existing !== void 0 && existsSync2(existing)) {
1184
+ if (existing !== void 0 && existsSync3(existing)) {
1077
1185
  workspaceRegistry.delete(key);
1078
1186
  workspaceRegistry.set(key, existing);
1079
1187
  return { dir: existing, reused: true };
@@ -1125,7 +1233,7 @@ async function prepareResumingBranch(ctx, dir, reused) {
1125
1233
  return { resumed: true };
1126
1234
  }
1127
1235
  async function sweepWorkspaces() {
1128
- const base = tmpdir();
1236
+ const base = tmpdir2();
1129
1237
  let entries;
1130
1238
  try {
1131
1239
  entries = await readdir(base);
@@ -1136,7 +1244,7 @@ async function sweepWorkspaces() {
1136
1244
  for (const entry of entries) {
1137
1245
  if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
1138
1246
  try {
1139
- await rm(join3(base, entry), { recursive: true, force: true });
1247
+ await rm(join4(base, entry), { recursive: true, force: true });
1140
1248
  removed++;
1141
1249
  } catch {
1142
1250
  }
@@ -1533,8 +1641,14 @@ async function processChatJob(ctx, dir, config, abort) {
1533
1641
  console.log(`
1534
1642
  \u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1535
1643
  const orchestrating = ctx.permissionMode !== "plan";
1644
+ const provider = ctx.provider ?? "anthropic";
1645
+ if (provider === "openai" && orchestrating) {
1646
+ throw new Error(
1647
+ "Codex backend currently supports plan mode only. implement/revise/resolve/release jobs with an openai agent are not yet supported."
1648
+ );
1649
+ }
1536
1650
  const installResult = orchestrating ? await installDependencies(dir) : null;
1537
- const result = await runClaudeCode({
1651
+ const result = provider === "openai" ? await runCodex({ cwd: dir, prompt: buildCodexPrompt(ctx), abortController: abort }) : await runClaudeCode({
1538
1652
  cwd: dir,
1539
1653
  prompt: buildPrompt(ctx),
1540
1654
  permissionMode: ctx.permissionMode,
@@ -1555,7 +1669,7 @@ async function processChatJob(ctx, dir, config, abort) {
1555
1669
  console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1556
1670
  return { text: reply, widgets: result.widgets };
1557
1671
  }
1558
- const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
1672
+ const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
1559
1673
  let documented = false;
1560
1674
  if (ctx.permissionMode !== "plan" && wikiExists && await hasChanges(dir)) {
1561
1675
  try {
@@ -1644,7 +1758,7 @@ ${reply}`;
1644
1758
 
1645
1759
  > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1646
1760
  }
1647
- const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
1761
+ const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
1648
1762
  let documented = false;
1649
1763
  if (wikiExists && await hasChanges(dir)) {
1650
1764
  try {
@@ -1700,7 +1814,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1700
1814
  console.log(` \u2026revise ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1701
1815
  return { text: reply, widgets: result.widgets };
1702
1816
  }
1703
- const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
1817
+ const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
1704
1818
  let documented = false;
1705
1819
  if (wikiExists && await hasChanges(dir)) {
1706
1820
  try {
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ #!/usr/bin/env node
3
+
4
+ // src/mcp-stdio.ts
5
+ import { appendFileSync, writeFileSync, existsSync } from "node:fs";
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
+ import { toJSONSchema } from "zod/v4/core";
10
+ import { z as z3 } from "zod";
11
+
12
+ // src/plan.ts
13
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
14
+ import { z } from "zod";
15
+
16
+ // src/code-lang.ts
17
+ var EXT_TO_LANG = {
18
+ ts: "typescript",
19
+ tsx: "tsx",
20
+ js: "javascript",
21
+ jsx: "jsx",
22
+ json: "json",
23
+ css: "css",
24
+ md: "markdown",
25
+ sh: "bash",
26
+ py: "python",
27
+ yaml: "yaml",
28
+ yml: "yaml",
29
+ html: "markup",
30
+ xml: "markup",
31
+ sql: "sql"
32
+ };
33
+ function langFromPath(path) {
34
+ const ext = path.split(".").pop()?.toLowerCase();
35
+ return ext ? EXT_TO_LANG[ext] : void 0;
36
+ }
37
+
38
+ // src/schema-hints.ts
39
+ var INLINE_CODE_HINT = "Wrap code identifiers (function, variable, type, and file names, commands, and flags) in inline backticks, e.g. `getCodingSessionsForRequest`.";
40
+ var WIDGET_LANGUAGE_HINT = "Write this in the same natural language as the incoming thread (the request body and the user's messages). If the thread is in English, keep it in English; do not switch languages. Keep code identifiers, file paths, and quoted code verbatim.";
41
+
42
+ // src/plan.ts
43
+ var SERVER_NAME = "flume_plan";
44
+ var SUBMIT_PLAN = "submit_plan";
45
+ var PLAN_TOOL_NAME = `mcp__${SERVER_NAME}__${SUBMIT_PLAN}`;
46
+ var PLAN_MARKER = "<!-- flumecode:end-of-plan -->";
47
+ var pseudoCodeEntrySchema = z.object({
48
+ file: z.string().min(1),
49
+ pseudoCode: z.string().min(1)
50
+ });
51
+ var stepSchema = z.object({
52
+ title: z.string().min(1).describe("A concise imperative title for this step."),
53
+ description: z.array(z.string().min(1)).min(1).describe(
54
+ "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
55
+ ),
56
+ pseudoCode: z.array(pseudoCodeEntrySchema).optional().describe(
57
+ "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."
58
+ )
59
+ });
60
+ var planInputSchema = {
61
+ title: z.string().min(1).max(120).describe(
62
+ "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."
63
+ ),
64
+ scope: z.enum(["feat", "fix", "chore", "docs", "test", "refactor"]).describe("The primary intent of the change."),
65
+ goal: z.string().min(1).describe("One or two sentences stating the outcome. " + INLINE_CODE_HINT),
66
+ rootCause: z.string().optional().describe(
67
+ '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
68
+ ),
69
+ motivation: z.string().optional().describe(
70
+ "Why the user is making this request \u2014 the underlying motivation or problem the change addresses. Fill this especially when the request content/context does NOT already state the why (ask the user in the Clarify phase); omit when there is no additional motivation to record. Useful for future understanding of the system. " + INLINE_CODE_HINT
71
+ ),
72
+ assumptions: z.array(z.string()).describe("Anything decided during planning, including unanswered defaults."),
73
+ requirements: z.array(z.string().min(1)).min(1).describe(
74
+ "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
75
+ ),
76
+ steps: z.array(stepSchema).min(1).describe("Ordered list of changes. Each step says what and why, with file references."),
77
+ acceptanceCriteria: z.array(z.string().min(1)).min(2).describe(
78
+ "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
79
+ ),
80
+ risks: z.array(z.string()).describe("Anything that could change the approach."),
81
+ outOfScope: z.array(z.string()).describe("What is deliberately not being done.")
82
+ };
83
+ function requireRootCauseForFix(schema) {
84
+ return schema.superRefine((plan, ctx) => {
85
+ if (plan.scope === "fix" && (plan.rootCause === void 0 || plan.rootCause.trim() === "")) {
86
+ ctx.addIssue({
87
+ code: z.ZodIssueCode.custom,
88
+ path: ["rootCause"],
89
+ message: 'rootCause is required and must be non-empty when scope is "fix".'
90
+ });
91
+ }
92
+ });
93
+ }
94
+ var planSchema = requireRootCauseForFix(z.object(planInputSchema));
95
+ function renderPlan(plan) {
96
+ const lines = [];
97
+ lines.push(`# ${plan.title}`);
98
+ lines.push("");
99
+ lines.push(`**Scope** \u2014 \`${plan.scope}\``);
100
+ lines.push("");
101
+ lines.push(`**Goal** \u2014 ${plan.goal}`);
102
+ if (plan.motivation && plan.motivation.trim().length > 0) {
103
+ lines.push("");
104
+ lines.push("## Motivation");
105
+ lines.push(plan.motivation);
106
+ }
107
+ if (plan.assumptions.length > 0) {
108
+ lines.push("");
109
+ lines.push("**Assumptions**");
110
+ for (const assumption of plan.assumptions) {
111
+ lines.push(`- ${assumption}`);
112
+ }
113
+ }
114
+ if (plan.rootCause && plan.rootCause.trim().length > 0) {
115
+ lines.push("");
116
+ lines.push("## Root cause");
117
+ lines.push(plan.rootCause);
118
+ }
119
+ lines.push("");
120
+ lines.push("## Requirements");
121
+ for (const requirement of plan.requirements) {
122
+ lines.push(`- ${requirement}`);
123
+ }
124
+ lines.push("");
125
+ lines.push("## Steps");
126
+ for (const [i, step] of plan.steps.entries()) {
127
+ lines.push("");
128
+ lines.push(`### ${i + 1}. ${step.title}`);
129
+ lines.push("");
130
+ for (const bullet of step.description) {
131
+ lines.push(`- ${bullet}`);
132
+ }
133
+ if (step.pseudoCode && step.pseudoCode.length > 0) {
134
+ for (const entry of step.pseudoCode) {
135
+ lines.push("");
136
+ lines.push(`\`${entry.file}\``);
137
+ lines.push("");
138
+ const lang = langFromPath(entry.file);
139
+ lines.push(lang ? "```" + lang : "```");
140
+ lines.push(entry.pseudoCode);
141
+ lines.push("```");
142
+ }
143
+ }
144
+ }
145
+ lines.push("");
146
+ lines.push("## Acceptance criteria");
147
+ for (const criterion of plan.acceptanceCriteria) {
148
+ lines.push(`- [ ] ${criterion}`);
149
+ }
150
+ if (plan.risks.length > 0) {
151
+ lines.push("");
152
+ lines.push("**Risks / open questions**");
153
+ for (const risk of plan.risks) {
154
+ lines.push(`- ${risk}`);
155
+ }
156
+ }
157
+ if (plan.outOfScope.length > 0) {
158
+ lines.push("");
159
+ lines.push("**Out of scope**");
160
+ for (const item of plan.outOfScope) {
161
+ lines.push(`- ${item}`);
162
+ }
163
+ }
164
+ lines.push("");
165
+ lines.push(PLAN_MARKER);
166
+ return lines.join("\n");
167
+ }
168
+ var submitPlanInputSchema = {
169
+ plans: z.array(requireRootCauseForFix(z.object(planInputSchema))).min(1).refine(
170
+ (arr) => {
171
+ const titles = arr.map((p) => p.title.trim()).filter((t) => t.length > 0);
172
+ return new Set(titles).size === titles.length;
173
+ },
174
+ { message: "Each plan must have a distinct non-empty title" }
175
+ )
176
+ };
177
+ var submitPlanSchema = z.object(submitPlanInputSchema);
178
+
179
+ // src/widgets.ts
180
+ import { randomUUID } from "node:crypto";
181
+ import { createSdkMcpServer as createSdkMcpServer2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
182
+ import { z as z2 } from "zod";
183
+ var SERVER_NAME2 = "flume_widgets";
184
+ var SINGLE_SELECT = "single_select";
185
+ var MULTI_SELECT = "multi_select";
186
+ var WIDGET_TOOL_NAMES = [
187
+ `mcp__${SERVER_NAME2}__${SINGLE_SELECT}`,
188
+ `mcp__${SERVER_NAME2}__${MULTI_SELECT}`
189
+ ];
190
+ var optionsSchema = z2.array(z2.string().min(1)).min(2).max(8).describe("2\u20138 short, distinct choices for the user to pick from. " + WIDGET_LANGUAGE_HINT);
191
+ var TAIL = "Do NOT add an 'Other' or 'None of these' catch-all \u2014 the UI always offers an 'Other' free-text option automatically. " + WIDGET_LANGUAGE_HINT + " After calling this, END YOUR TURN and wait: the user's answer arrives as their next message and starts a fresh run.";
192
+ var singleSelectShape = {
193
+ question: z2.string().min(1).describe("The question to ask the user. " + WIDGET_LANGUAGE_HINT),
194
+ body: z2.string().optional().describe(
195
+ "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."
196
+ ),
197
+ options: optionsSchema
198
+ };
199
+ var multiSelectShape = {
200
+ question: z2.string().min(1).describe("The question to ask the user. " + WIDGET_LANGUAGE_HINT),
201
+ body: z2.string().optional().describe(
202
+ "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."
203
+ ),
204
+ options: optionsSchema
205
+ };
206
+ function buildSingleSelectWidget(args) {
207
+ return {
208
+ id: randomUUID(),
209
+ type: "single_select",
210
+ question: args.question,
211
+ body: args.body,
212
+ options: args.options.map((label) => ({ id: randomUUID(), label })),
213
+ selectedOptionId: null,
214
+ customAnswer: null
215
+ };
216
+ }
217
+ function buildMultiSelectWidget(args) {
218
+ return {
219
+ id: randomUUID(),
220
+ type: "multi_select",
221
+ question: args.question,
222
+ body: args.body,
223
+ options: args.options.map((label) => ({ id: randomUUID(), label })),
224
+ selectedOptionIds: null,
225
+ customAnswer: null
226
+ };
227
+ }
228
+
229
+ // src/mcp-stdio.ts
230
+ var OUT = process.env.FLUME_MCP_OUTPUT;
231
+ if (!OUT) {
232
+ console.error("FLUME_MCP_OUTPUT env var not set");
233
+ process.exit(1);
234
+ }
235
+ if (!existsSync(OUT)) {
236
+ writeFileSync(OUT, "");
237
+ }
238
+ function emit(rec) {
239
+ appendFileSync(OUT, JSON.stringify(rec) + "\n");
240
+ }
241
+ function ack(text) {
242
+ return { content: [{ type: "text", text }] };
243
+ }
244
+ function shapeToJsonSchema(shape) {
245
+ return toJSONSchema(z3.object(shape));
246
+ }
247
+ var TOOLS = [
248
+ {
249
+ name: "submit_plan",
250
+ description: "Submit ALL your plans in a single call \u2014 one entry per plan; each becomes its own independently-acceptable plan draft. Do NOT call submit_plan more than once.",
251
+ inputSchema: toJSONSchema(submitPlanSchema)
252
+ },
253
+ {
254
+ name: "single_select",
255
+ description: "Ask the user a single-select (radio-button) question \u2014 exactly one answer.",
256
+ inputSchema: shapeToJsonSchema(singleSelectShape)
257
+ },
258
+ {
259
+ name: "multi_select",
260
+ description: "Ask the user a multi-select (checkbox) question \u2014 they may pick any number of options.",
261
+ inputSchema: shapeToJsonSchema(multiSelectShape)
262
+ }
263
+ ];
264
+ var server = new Server({ name: "flume", version: "1" }, { capabilities: { tools: {} } });
265
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
266
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
267
+ const { name, arguments: args } = request.params;
268
+ if (name === "submit_plan") {
269
+ const parsed = submitPlanSchema.parse(args);
270
+ emit({ kind: "plans", plans: parsed.plans.map(renderPlan) });
271
+ return ack("Plan(s) submitted. End your turn.");
272
+ }
273
+ if (name === "single_select") {
274
+ const parsed = z3.object(singleSelectShape).parse(args);
275
+ emit({ kind: "widget", widget: buildSingleSelectWidget(parsed) });
276
+ return ack("Question posted as a single-select widget. End your turn.");
277
+ }
278
+ if (name === "multi_select") {
279
+ const parsed = z3.object(multiSelectShape).parse(args);
280
+ emit({ kind: "widget", widget: buildMultiSelectWidget(parsed) });
281
+ return ack("Question posted as a multi-select widget. End your turn.");
282
+ }
283
+ throw new Error(`Unknown tool: ${name}`);
284
+ });
285
+ var transport = new StdioServerTransport();
286
+ await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flumecode/runner",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "description": "FlumeCode local runner — claims jobs and drives your local Claude Code against a real checkout.",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "provenance": false
19
19
  },
20
20
  "scripts": {
21
- "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --packages=external --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\"",
21
+ "build": "esbuild src/cli.ts src/mcp-stdio.ts --bundle --platform=node --format=esm --target=node20 --packages=external --outdir=dist --banner:js=\"#!/usr/bin/env node\"",
22
22
  "dev": "tsx src/cli.ts",
23
23
  "login": "tsx src/cli.ts login",
24
24
  "start": "tsx src/cli.ts start",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@anthropic-ai/claude-agent-sdk": "^0.3.0",
30
+ "@modelcontextprotocol/sdk": "^1",
30
31
  "zod": "4.4.3"
31
32
  },
32
33
  "devDependencies": {
@@ -34,7 +34,10 @@ and determine which phase you are in:**
34
34
  If the request is a bug fix, investigate deeply enough to identify the **root cause** (the specific code or condition producing the incorrect behavior) before proceeding to Plan — you will need it for the `rootCause` field.
35
35
  2. Identify genuine ambiguity only: scope, intended behavior, edge cases,
36
36
  competing approaches, acceptance criteria. Skip anything you can reasonably
37
- decide yourself.
37
+ decide yourself. One high-leverage question is the user's motivation — **why** they
38
+ are making this request — but only when the request title/body and context don't
39
+ already explain it. Never re-ask what the thread already answers (consistent with
40
+ "Never ask what the code answers").
38
41
  3. End your turn with **2–5 specific, high-leverage questions**, each with a
39
42
  recommended default so the user can reply "all defaults" if they want. Group
40
43
  them; keep it short. Do **not** include a plan yet.
@@ -66,6 +69,7 @@ Field-by-field guidance:
66
69
  answers the request's title and body. Must be achievable by the steps below
67
70
  and nothing more.
68
71
  - **`rootCause`** — required when `scope` is `fix`; omit for all other scopes. Identify the underlying cause of the bug — the specific code, logic, or condition that produces the incorrect behavior, **not** the symptom. To fill this accurately, you must investigate the codebase deeply enough to find the root cause before writing the plan.
72
+ - **`motivation`** — optional. The user's stated or asked-for reason for making this request — the underlying motivation or problem the change addresses. Fill this when the request content/context does NOT already state the why (ask during Phase 1 — Clarify if needed); omit when there is no additional motivation to record. Useful for future understanding of the system.
69
73
  - **`assumptions`** — anything you decided during investigation (including
70
74
  unanswered defaults from Phase 1).
71
75
  - **`requirements`** — **required; at least 1 item.** Plain-language statements of what this change must accomplish and why, written so a non-technical reader can follow them. Distinct from `acceptanceCriteria`: requirements explain intent and rationale; acceptance criteria are the machine-checkable proof. At least 1 item required.