@flumecode/runner 0.18.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";
@@ -188,6 +188,12 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
188
188
  import { randomUUID } from "node:crypto";
189
189
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
190
190
  import { z } from "zod";
191
+
192
+ // src/schema-hints.ts
193
+ var INLINE_CODE_HINT = "Wrap code identifiers (function, variable, type, and file names, commands, and flags) in inline backticks, e.g. `getCodingSessionsForRequest`.";
194
+ 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.";
195
+
196
+ // src/widgets.ts
191
197
  var SERVER_NAME = "flume_widgets";
192
198
  var SINGLE_SELECT = "single_select";
193
199
  var MULTI_SELECT = "multi_select";
@@ -195,53 +201,61 @@ var WIDGET_TOOL_NAMES = [
195
201
  `mcp__${SERVER_NAME}__${SINGLE_SELECT}`,
196
202
  `mcp__${SERVER_NAME}__${MULTI_SELECT}`
197
203
  ];
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.";
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
+ 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
+ }
200
242
  function createWidgetTooling() {
201
243
  const collected = [];
202
244
  const singleSelect = tool(
203
245
  SINGLE_SELECT,
204
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,
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
- },
247
+ singleSelectShape,
212
248
  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
- });
249
+ collected.push(buildSingleSelectWidget(args));
222
250
  return widgetPosted("single-select");
223
251
  }
224
252
  );
225
253
  const multiSelect = tool(
226
254
  MULTI_SELECT,
227
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,
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
- },
256
+ multiSelectShape,
235
257
  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
- });
258
+ collected.push(buildMultiSelectWidget(args));
245
259
  return widgetPosted("multi-select");
246
260
  }
247
261
  );
@@ -288,9 +302,6 @@ function langFromPath(path) {
288
302
  return ext ? EXT_TO_LANG[ext] : void 0;
289
303
  }
290
304
 
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
305
  // src/plan.ts
295
306
  var SERVER_NAME2 = "flume_plan";
296
307
  var SUBMIT_PLAN = "submit_plan";
@@ -318,6 +329,9 @@ var planInputSchema = {
318
329
  rootCause: z2.string().optional().describe(
319
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
320
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
+ ),
321
335
  assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
322
336
  requirements: z2.array(z2.string().min(1)).min(1).describe(
323
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
@@ -348,6 +362,11 @@ function renderPlan(plan) {
348
362
  lines2.push(`**Scope** \u2014 \`${plan.scope}\``);
349
363
  lines2.push("");
350
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
+ }
351
370
  if (plan.assumptions.length > 0) {
352
371
  lines2.push("");
353
372
  lines2.push("**Assumptions**");
@@ -423,7 +442,7 @@ function createPlanTooling() {
423
442
  let renderedPlans = null;
424
443
  const submitPlan = tool2(
425
444
  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). `,
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. `,
427
446
  submitPlanInputSchema,
428
447
  async (args) => {
429
448
  const parsed = submitPlanSchema.parse(args);
@@ -706,6 +725,87 @@ async function runClaudeCode(opts) {
706
725
  return { text: finalText, widgets: collected, plans: getPlans(), report: getReport() };
707
726
  }
708
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
+
709
809
  // src/health.ts
710
810
  import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
711
811
  var PROBE_TIMEOUT_MS = 6e4;
@@ -755,24 +855,30 @@ function errorMessage(err) {
755
855
  }
756
856
 
757
857
  // src/rules.ts
758
- import { readFileSync as readFileSync3 } from "node:fs";
759
- import { join as join2 } from "node:path";
760
- import { fileURLToPath as fileURLToPath3 } from "node:url";
761
- 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));
762
862
  function loadRule(name) {
763
- const raw = readFileSync3(join2(RULES_DIR, `${name}.md`), "utf8");
863
+ const raw = readFileSync4(join3(RULES_DIR, `${name}.md`), "utf8");
764
864
  return stripFrontMatter(raw).trim();
765
865
  }
766
866
  function stripFrontMatter(raw) {
767
867
  const match = raw.match(/^---\n.*?\n---\n/s);
768
868
  return match ? raw.slice(match[0].length) : raw;
769
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
+ }
770
875
 
771
876
  // src/prompt.ts
772
877
  function appendRule(lines2, intro, ruleName) {
773
878
  lines2.push("", intro, "", loadRule(ruleName));
774
879
  }
775
880
  var WRITING_INTRO = "These technical-writing guidelines apply to the plan and report prose you author in this run:";
881
+ var LANGUAGE_DIRECTIVE = "First, determine the dominant natural language of the incoming thread (the request title/body and the user's messages). Use that one language for EVERYTHING you author this run - your reply body, any plan or report fields, AND every clarifying question and its widget options. Never mix languages: if the thread is in English, your questions and options must be in English too. Keep code identifiers, file paths, and quoted code verbatim.";
776
882
  function turnHeading(turn, agentName) {
777
883
  if (turn.role === "user") return "User";
778
884
  if (turn.failed) return `${agentName} (this run ended in an error)`;
@@ -796,7 +902,8 @@ function buildPrompt(ctx) {
796
902
  `The repository ${ctx.repo.fullName} is checked out in your current working directory on branch "${ctx.repo.checkoutBranch}" at commit ${ctx.repo.checkoutSha.slice(0, 7)}.`,
797
903
  task,
798
904
  orient,
799
- widgets
905
+ widgets,
906
+ LANGUAGE_DIRECTIVE
800
907
  ];
801
908
  if (ctx.permissionMode !== "plan") {
802
909
  lines2.push(
@@ -828,6 +935,7 @@ function buildRevisePrompt(ctx) {
828
935
  task,
829
936
  orient,
830
937
  widgets,
938
+ LANGUAGE_DIRECTIVE,
831
939
  "",
832
940
  "These coding guidelines apply to all code produced in this run:",
833
941
  "",
@@ -943,6 +1051,7 @@ function buildReleasePrompt(ctx, baseChecks) {
943
1051
  task,
944
1052
  orient,
945
1053
  widgets,
1054
+ LANGUAGE_DIRECTIVE,
946
1055
  "",
947
1056
  "These coding guidelines apply to all code produced in this run:",
948
1057
  "",
@@ -970,6 +1079,14 @@ function buildReleasePrompt(ctx, baseChecks) {
970
1079
  "```"
971
1080
  );
972
1081
  }
1082
+ if (ctx.prerelease) {
1083
+ lines2.push(
1084
+ "",
1085
+ "# Pre-release",
1086
+ "",
1087
+ "This is a PRE-RELEASE. When proposing and applying versions, use a semver pre-release version string (e.g. `0.9.0-beta.1`): take the next stable version you would otherwise pick and append `-beta.N`, where N is the next unused beta number for that version (check existing `v<version>-beta.*` tags). Offer these pre-release strings in the version-confirmation widgets, and write them to package.json, CHANGELOG.md, and the `flumecode:versions` comment as usual."
1088
+ );
1089
+ }
973
1090
  appendThread(lines2, ctx);
974
1091
  lines2.push(
975
1092
  "",
@@ -977,6 +1094,12 @@ function buildReleasePrompt(ctx, baseChecks) {
977
1094
  );
978
1095
  return lines2.join("\n");
979
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
+ }
980
1103
  function buildInitPrompt(ctx) {
981
1104
  return [
982
1105
  `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
@@ -993,10 +1116,10 @@ function jobTitle(ctx) {
993
1116
 
994
1117
  // src/workspace.ts
995
1118
  import { execFile } from "node:child_process";
996
- import { existsSync as existsSync2 } from "node:fs";
1119
+ import { existsSync as existsSync3 } from "node:fs";
997
1120
  import { mkdtemp, readdir, rm } from "node:fs/promises";
998
- import { tmpdir } from "node:os";
999
- import { join as join3 } from "node:path";
1121
+ import { tmpdir as tmpdir2 } from "node:os";
1122
+ import { join as join4 } from "node:path";
1000
1123
  import { promisify } from "node:util";
1001
1124
  var exec = promisify(execFile);
1002
1125
  var WORKSPACE_PREFIX = "flume-runner-";
@@ -1022,11 +1145,11 @@ function cloneUrl(ctx) {
1022
1145
  return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
1023
1146
  }
1024
1147
  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";
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";
1030
1153
  return "npm";
1031
1154
  }
1032
1155
  async function installDependencies(dir) {
@@ -1052,13 +1175,13 @@ async function installDependencies(dir) {
1052
1175
  }
1053
1176
  }
1054
1177
  async function makeWorkspace() {
1055
- return mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX));
1178
+ return mkdtemp(join4(tmpdir2(), WORKSPACE_PREFIX));
1056
1179
  }
1057
1180
  var MAX_WORKSPACES = 8;
1058
1181
  var workspaceRegistry = /* @__PURE__ */ new Map();
1059
1182
  async function acquireWorkspace(key) {
1060
1183
  const existing = workspaceRegistry.get(key);
1061
- if (existing !== void 0 && existsSync2(existing)) {
1184
+ if (existing !== void 0 && existsSync3(existing)) {
1062
1185
  workspaceRegistry.delete(key);
1063
1186
  workspaceRegistry.set(key, existing);
1064
1187
  return { dir: existing, reused: true };
@@ -1110,7 +1233,7 @@ async function prepareResumingBranch(ctx, dir, reused) {
1110
1233
  return { resumed: true };
1111
1234
  }
1112
1235
  async function sweepWorkspaces() {
1113
- const base = tmpdir();
1236
+ const base = tmpdir2();
1114
1237
  let entries;
1115
1238
  try {
1116
1239
  entries = await readdir(base);
@@ -1121,7 +1244,7 @@ async function sweepWorkspaces() {
1121
1244
  for (const entry of entries) {
1122
1245
  if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
1123
1246
  try {
1124
- await rm(join3(base, entry), { recursive: true, force: true });
1247
+ await rm(join4(base, entry), { recursive: true, force: true });
1125
1248
  removed++;
1126
1249
  } catch {
1127
1250
  }
@@ -1518,8 +1641,14 @@ async function processChatJob(ctx, dir, config, abort) {
1518
1641
  console.log(`
1519
1642
  \u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1520
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
+ }
1521
1650
  const installResult = orchestrating ? await installDependencies(dir) : null;
1522
- const result = await runClaudeCode({
1651
+ const result = provider === "openai" ? await runCodex({ cwd: dir, prompt: buildCodexPrompt(ctx), abortController: abort }) : await runClaudeCode({
1523
1652
  cwd: dir,
1524
1653
  prompt: buildPrompt(ctx),
1525
1654
  permissionMode: ctx.permissionMode,
@@ -1540,7 +1669,7 @@ async function processChatJob(ctx, dir, config, abort) {
1540
1669
  console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1541
1670
  return { text: reply, widgets: result.widgets };
1542
1671
  }
1543
- const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
1672
+ const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
1544
1673
  let documented = false;
1545
1674
  if (ctx.permissionMode !== "plan" && wikiExists && await hasChanges(dir)) {
1546
1675
  try {
@@ -1629,7 +1758,7 @@ ${reply}`;
1629
1758
 
1630
1759
  > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1631
1760
  }
1632
- const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
1761
+ const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
1633
1762
  let documented = false;
1634
1763
  if (wikiExists && await hasChanges(dir)) {
1635
1764
  try {
@@ -1685,7 +1814,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1685
1814
  console.log(` \u2026revise ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1686
1815
  return { text: reply, widgets: result.widgets };
1687
1816
  }
1688
- const wikiExists = existsSync3(join4(dir, ".flumecode", "wiki"));
1817
+ const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
1689
1818
  let documented = false;
1690
1819
  if (wikiExists && await hasChanges(dir)) {
1691
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.18.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": {
@@ -16,9 +16,9 @@ This convention applies to all free-text fields in plans and reports: goals, ste
16
16
 
17
17
  ## Output language
18
18
 
19
- Write all free-text prose in the same natural language as the user's request
20
- (Korean in Korean out, English in English out). This applies to every
21
- free-text field you author plan goals/steps/risks, report summaries,
22
- clarifying questions, and push-backs. Keep code identifiers, file paths,
23
- commands, and quoted code/diffs verbatim; only the surrounding prose follows
24
- the request language.
19
+ Before writing anything, determine the dominant natural language of the incoming thread (the
20
+ request title/body and the user's messages). Use that one language for all free-text prose in
21
+ this run your reply body, plan goals/steps/risks, report summaries, clarifying questions,
22
+ widget options, and push-backs. Never switch languages mid-response. Keep code identifiers, file
23
+ paths, commands, and quoted code/diffs verbatim; only the surrounding prose follows the thread
24
+ language.
@@ -183,6 +183,32 @@ version did not change.
183
183
  silence them.
184
184
  - **Never commit, push, or open a PR** — the runner does that.
185
185
 
186
+ ## Pre-release
187
+
188
+ When the prompt contains a `# Pre-release` section, this release uses semver
189
+ pre-release version strings instead of stable ones:
190
+
191
+ - **Compute versions:** take the next stable version you would otherwise propose
192
+ (patch or minor bump), then append `-beta.N`, where N is the next unused beta
193
+ number for that base version. Check existing tags with:
194
+
195
+ ```
196
+ git tag -l --sort=-version:refname 'v<version>-beta.*' | head -1
197
+ ```
198
+
199
+ If no beta tags exist for that base version, start at `-beta.1`.
200
+
201
+ - **Phase 1 (propose):** offer the pre-release version string (e.g.
202
+ `0.9.0-beta.1`) in the version-confirmation widgets instead of the stable
203
+ version.
204
+
205
+ - **Phase 2 (apply):** write the pre-release version string (e.g.
206
+ `0.9.0-beta.1`) to `package.json`, `CHANGELOG.md`, and the
207
+ `<!-- flumecode:versions {...} -->` comment — exactly as you would for a
208
+ stable release, just with the pre-release suffix included.
209
+
210
+ ---
211
+
186
212
  ## Pre-release checks
187
213
 
188
214
  We cannot release code with failing checks. Before this turn, the runner ran the
@@ -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.
@@ -108,6 +112,13 @@ own independently-acceptable "Accept as plan" draft. After a plan is accepted th
108
112
  keep commenting to refine it; treat a later turn as a fresh **Plan** phase and call
109
113
  `submit_plan` again with a `plans[]` array containing the revised fields.
110
114
 
115
+ Before adding an entry to `plans[]`, apply this right-sizing checklist — if a plan fails any criterion, split it into separate entries:
116
+
117
+ - **Single, clear outcome** — one bug fixed, one feature increment, one refactor. If the `title` needs "and", consider splitting.
118
+ - **Fits in a sprint comfortably** — if it can't fit in one iteration, it's likely an epic that needs breaking down.
119
+ - **Reviewable PR** — small enough that a reviewer can hold it in their head (often cited as under ~200–400 lines of diff, though this varies).
120
+ - **Testable acceptance criteria** — you can state up front what "done" looks like; use the `acceptanceCriteria` field to capture this.
121
+
111
122
  ## Always
112
123
 
113
124
  - Stay read-only. Propose; do not edit.