@flumecode/runner 0.5.0 → 0.6.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
@@ -572,6 +572,33 @@ function buildRepairPrompt(ctx, hookLog) {
572
572
  ];
573
573
  return lines.join("\n");
574
574
  }
575
+ function buildReleasePrompt(ctx) {
576
+ 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.`;
577
+ 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.`;
578
+ 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.`;
579
+ const lines = [
580
+ `You are "${ctx.agentName}", an autonomous coding agent driving a FlumeCode release.`,
581
+ `The repository ${ctx.repo.fullName} is checked out in your current working directory on the release bump branch "${ctx.repo.checkoutBranch}".`,
582
+ task,
583
+ orient,
584
+ widgets,
585
+ "",
586
+ "These coding guidelines apply to all code produced in this run:",
587
+ "",
588
+ loadRule("coding-guideline"),
589
+ "",
590
+ `# Release: ${ctx.request?.title ?? ""}`
591
+ ];
592
+ if (ctx.request?.body) {
593
+ lines.push("", ctx.request.body);
594
+ }
595
+ appendThread(lines, ctx);
596
+ lines.push(
597
+ "",
598
+ "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."
599
+ );
600
+ return lines.join("\n");
601
+ }
575
602
  function buildInitPrompt(ctx) {
576
603
  return [
577
604
  `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
@@ -761,14 +788,15 @@ async function rebaseOntoMergeBranch(ctx, dir) {
761
788
  if (!mergeBranch) return;
762
789
  await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
763
790
  try {
764
- await git(["-C", dir, "rebase", "FETCH_HEAD"]);
765
- } catch {
791
+ await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
792
+ } catch (err) {
766
793
  const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
767
794
  () => ({ stdout: "" })
768
795
  );
769
796
  const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
770
797
  await git(["-C", dir, "rebase", "--abort"]).catch(() => {
771
798
  });
799
+ if (files.length === 0) throw err;
772
800
  throw new RebaseConflictError(mergeBranch, files);
773
801
  }
774
802
  }
@@ -854,11 +882,44 @@ var HEARTBEAT_MS = 5 * 6e4;
854
882
  async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
855
883
  if (abort.signal.aborted) throw new Error("Run canceled by user");
856
884
  const committed = await commitWithRepair(ctx, dir, abort);
857
- if (!committed) return { kind: "none" };
858
- if (opts.rebase) await rebaseOntoMergeBranch(ctx, dir);
885
+ if (!committed) return { outcome: { kind: "none" }, autoMerged: false };
886
+ let autoMerged = false;
887
+ if (opts.rebase) {
888
+ try {
889
+ await rebaseOntoMergeBranch(ctx, dir);
890
+ } catch (err) {
891
+ if (!(err instanceof RebaseConflictError)) throw err;
892
+ if (abort.signal.aborted) throw new Error("Run canceled by user");
893
+ console.warn(
894
+ ` rebase onto ${ctx.repo.mergeBranch} conflicted \u2014 merging it in and resolving with the agent\u2026`
895
+ );
896
+ await mergeAndResolveConflicts(ctx, dir, abort);
897
+ await commitWithRepair(ctx, dir, abort);
898
+ autoMerged = true;
899
+ }
900
+ }
859
901
  await pushBranch(ctx, dir);
860
902
  const pr = await openPullRequest(ctx);
861
- return pr ? { kind: "pr", pr } : { kind: "pushed" };
903
+ return { outcome: pr ? { kind: "pr", pr } : { kind: "pushed" }, autoMerged };
904
+ }
905
+ async function mergeAndResolveConflicts(ctx, dir, abort) {
906
+ const { conflicted } = await mergeInMergeBranch(ctx, dir);
907
+ if (!conflicted) return { resolved: false, text: null };
908
+ const result = await runClaudeCode({
909
+ cwd: dir,
910
+ prompt: buildResolvePrompt(ctx),
911
+ permissionMode: ctx.permissionMode,
912
+ model: ORCHESTRATOR_MODEL,
913
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
914
+ abortController: abort
915
+ });
916
+ const unresolved = await listUnmergedPaths(dir);
917
+ if (unresolved.length > 0) {
918
+ throw new Error(
919
+ `Could not fully resolve the merge \u2014 ${unresolved.length} file(s) still conflict: ${unresolved.join(", ")}`
920
+ );
921
+ }
922
+ return { resolved: true, text: result.text.trim() || null };
862
923
  }
863
924
  async function commitWithRepair(ctx, dir, abort) {
864
925
  for (let attempt = 1; ; attempt++) {
@@ -882,17 +943,18 @@ async function commitWithRepair(ctx, dir, abort) {
882
943
  }
883
944
  function outcomeBanner(outcome, opts) {
884
945
  const wikiNote = opts.documented ? " (with wiki updates)" : "";
946
+ const mergeNote = opts.autoMerged ? "\n\u{1F500} The base branch had advanced with conflicting changes \u2014 merged it in and resolved the conflicts automatically." : "";
885
947
  switch (outcome.kind) {
886
948
  case "pr":
887
949
  return `
888
950
 
889
951
  ---
890
- \u2705 Opened pull request from \`${opts.branch}\`${wikiNote} \xB7 [View pull request](${outcome.pr.url})`;
952
+ \u2705 Opened pull request from \`${opts.branch}\`${wikiNote} \xB7 [View pull request](${outcome.pr.url})${mergeNote}`;
891
953
  case "pushed":
892
954
  return `
893
955
 
894
956
  ---
895
- \u26A0\uFE0F Pushed \`${opts.branch}\`${wikiNote}, but couldn't open a pull request (no diff against the base branch, or one is already open).`;
957
+ \u26A0\uFE0F Pushed \`${opts.branch}\`${wikiNote}, but couldn't open a pull request (no diff against the base branch, or one is already open).${mergeNote}`;
896
958
  case "none":
897
959
  return `
898
960
 
@@ -924,6 +986,11 @@ async function processJob(ctx, abort = new AbortController()) {
924
986
  prepared = true;
925
987
  return await processResolveJob(ctx, dir, abort);
926
988
  }
989
+ if (ctx.kind === "release") {
990
+ const { resumed } = await prepareResumingBranch(ctx, dir, reused);
991
+ prepared = true;
992
+ return await processReleaseJob(ctx, dir, resumed, abort);
993
+ }
927
994
  await prepareAtSha(ctx, dir, reused);
928
995
  prepared = true;
929
996
  return await processChatJob(ctx, dir, abort);
@@ -947,10 +1014,11 @@ async function processInitJob(ctx, dir, abort) {
947
1014
  abortController: abort
948
1015
  })).text.trim();
949
1016
  let reply = summary || "(the agent produced no summary)";
950
- const outcome = await pushAndOpenPr(ctx, dir, abort);
1017
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort);
951
1018
  reply += outcomeBanner(outcome, {
952
1019
  branch: ctx.repo.checkoutBranch,
953
- noChange: "no files were generated; the wiki may already exist."
1020
+ noChange: "no files were generated; the wiki may already exist.",
1021
+ autoMerged
954
1022
  });
955
1023
  return { text: reply, widgets: [] };
956
1024
  }
@@ -996,8 +1064,8 @@ async function processChatJob(ctx, dir, abort) {
996
1064
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
997
1065
  }
998
1066
  }
999
- const outcome = await pushAndOpenPr(ctx, dir, abort);
1000
- reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
1067
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort);
1068
+ reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1001
1069
  return { text: reply, widgets: [] };
1002
1070
  }
1003
1071
  async function processImplementJob(ctx, dir, resumed, abort) {
@@ -1034,8 +1102,8 @@ async function processImplementJob(ctx, dir, resumed, abort) {
1034
1102
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
1035
1103
  }
1036
1104
  }
1037
- const outcome = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
1038
- reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
1105
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
1106
+ reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1039
1107
  return { text: reply, widgets: [], ...outcome.kind === "pr" ? { pr: outcome.pr } : {} };
1040
1108
  }
1041
1109
  async function processReviseJob(ctx, dir, resumed, abort) {
@@ -1078,9 +1146,9 @@ async function processReviseJob(ctx, dir, resumed, abort) {
1078
1146
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
1079
1147
  }
1080
1148
  }
1081
- const outcome = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
1149
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
1082
1150
  if (outcome.kind !== "none") {
1083
- reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
1151
+ reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1084
1152
  }
1085
1153
  return {
1086
1154
  text: reply,
@@ -1093,27 +1161,8 @@ async function processResolveJob(ctx, dir, abort) {
1093
1161
  console.log(`
1094
1162
  \u25B6 Resolve ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1095
1163
  const installResult = await installDependencies(dir);
1096
- const { conflicted } = await mergeInMergeBranch(ctx, dir);
1097
- let reply;
1098
- if (conflicted) {
1099
- const result = await runClaudeCode({
1100
- cwd: dir,
1101
- prompt: buildResolvePrompt(ctx),
1102
- permissionMode: ctx.permissionMode,
1103
- model: ORCHESTRATOR_MODEL,
1104
- maxTurns: ORCHESTRATOR_MAX_TURNS,
1105
- abortController: abort
1106
- });
1107
- reply = result.text.trim() || "(the agent produced no report)";
1108
- const unresolved = await listUnmergedPaths(dir);
1109
- if (unresolved.length > 0) {
1110
- throw new Error(
1111
- `Could not fully resolve the merge \u2014 ${unresolved.length} file(s) still conflict: ${unresolved.join(", ")}`
1112
- );
1113
- }
1114
- } else {
1115
- reply = `Merged \`${ctx.repo.mergeBranch ?? "the merge branch"}\` into \`${ctx.repo.checkoutBranch}\` cleanly \u2014 there were no conflicts to resolve.`;
1116
- }
1164
+ const { resolved, text } = await mergeAndResolveConflicts(ctx, dir, abort);
1165
+ let reply = resolved ? text || "(the agent produced no report)" : `Merged \`${ctx.repo.mergeBranch ?? "the merge branch"}\` into \`${ctx.repo.checkoutBranch}\` cleanly \u2014 there were no conflicts to resolve.`;
1117
1166
  if (installResult.status === "failed") {
1118
1167
  reply += `
1119
1168
 
@@ -1127,6 +1176,40 @@ async function processResolveJob(ctx, dir, abort) {
1127
1176
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch });
1128
1177
  return { text: reply, widgets: [], ...pr ? { pr } : {} };
1129
1178
  }
1179
+ async function processReleaseJob(ctx, dir, resumed, abort) {
1180
+ console.log(`
1181
+ \u25B6 Release ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1182
+ const installResult = await installDependencies(dir);
1183
+ const result = await runClaudeCode({
1184
+ cwd: dir,
1185
+ prompt: buildReleasePrompt(ctx),
1186
+ permissionMode: ctx.permissionMode,
1187
+ model: ORCHESTRATOR_MODEL,
1188
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
1189
+ abortController: abort
1190
+ });
1191
+ let reply = result.text.trim() || "(the agent produced no reply)";
1192
+ if (installResult.status === "failed") {
1193
+ reply += `
1194
+
1195
+ > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1196
+ }
1197
+ if (result.widgets.length > 0) {
1198
+ console.log(
1199
+ ` \u2026release ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`
1200
+ );
1201
+ return { text: reply, widgets: result.widgets };
1202
+ }
1203
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
1204
+ if (outcome.kind !== "none") {
1205
+ reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, autoMerged });
1206
+ }
1207
+ return {
1208
+ text: reply,
1209
+ widgets: [],
1210
+ ...outcome.kind === "pr" ? { pr: outcome.pr } : {}
1211
+ };
1212
+ }
1130
1213
  async function heartbeat(config) {
1131
1214
  const health = await checkClaudeCode();
1132
1215
  if (health.ready) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flumecode/runner",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": {
@@ -0,0 +1,176 @@
1
+ ---
2
+ name: create-release
3
+ description: >-
4
+ Draft release notes and version suggestions for a release, then (after the
5
+ user confirms) open a bump PR that updates package.json version(s) and
6
+ CHANGELOG.md. Two-turn flow: first turn asks the user to confirm versions via
7
+ widgets; second turn (answers in thread) writes the bumps and opens the PR.
8
+ ---
9
+
10
+ # create-release
11
+
12
+ You are driving a FlumeCode release. This skill has two distinct phases; detect
13
+ which one applies before acting.
14
+
15
+ ## Phase detection
16
+
17
+ Check the thread (`# Conversation so far` in the prompt). If **no widget answers**
18
+ appear in any prior agent turn, this is **Phase 1** — propose versions and ask.
19
+ If a prior turn contains widget-answer selections (the user picked a version), this
20
+ is **Phase 2** — apply the bumps and report.
21
+
22
+ ---
23
+
24
+ ## Phase 1 — Propose versions and ask
25
+
26
+ ### 1. Find the last release tags
27
+
28
+ For `@flumecode/web`, find the most recent `v*` tag:
29
+
30
+ ```
31
+ git tag -l --sort=-version:refname 'v*' | head -1
32
+ ```
33
+
34
+ For `@flumecode/runner`, find the most recent `runner-v*` tag:
35
+
36
+ ```
37
+ git tag -l --sort=-version:refname 'runner-v*' | head -1
38
+ ```
39
+
40
+ If no matching tag exists for a package, treat the current version in that
41
+ package's `package.json` as the baseline.
42
+
43
+ ### 2. List commits since the last tag, split by package
44
+
45
+ Get all commits since the tag (or all commits if no tag):
46
+
47
+ ```
48
+ git log <lastWebTag>..HEAD --oneline
49
+ ```
50
+
51
+ Then split by what they touch:
52
+
53
+ ```
54
+ # web + shared packages
55
+ git log <lastWebTag>..HEAD --oneline -- apps/web/ packages/
56
+
57
+ # runner only
58
+ git log <lastRunnerTag>..HEAD --oneline -- apps/runner/
59
+ ```
60
+
61
+ If a package has no commits, mark it **unchanged** — do not bump it.
62
+
63
+ ### 3. Infer bump level per package
64
+
65
+ For each package that has commits:
66
+
67
+ - `feat:` in a commit subject, or any breaking change → **minor** (or major if
68
+ explicitly breaking).
69
+ - `fix:`, `chore:`, `docs:`, `refactor:`, `test:` → **patch**.
70
+ - When in doubt, default to **patch**.
71
+
72
+ ### 4. Compute suggested next versions
73
+
74
+ Read current versions from `apps/web/package.json` and `apps/runner/package.json`.
75
+ Apply the inferred bump (semver: patch = last digit +1, minor = middle digit +1 +
76
+ reset patch to 0). For unchanged packages, keep the current version.
77
+
78
+ ### 5. Draft concise release notes
79
+
80
+ 3–10 user-readable bullet points summarising what changed. Group by theme; skip
81
+ internal chore commits unless they affect the user.
82
+
83
+ ### 6. Ask the user to confirm
84
+
85
+ For **each package that has commits** (skip unchanged ones), call
86
+ `mcp__flume_widgets__single_select` with:
87
+
88
+ - `question`: e.g. `@flumecode/web version for this release?`
89
+ - `options`: the suggested next version, plus the two sibling semver levels (e.g.
90
+ if patch suggested: also offer minor and major), plus the current version as
91
+ "No change (keep X.Y.Z)".
92
+
93
+ Also present the drafted release notes in your reply text so the user can read
94
+ them, then call `mcp__flume_widgets__single_select` for a confirmatory question:
95
+ `Do the release notes look correct?` with options `Yes, use these notes` and
96
+ `I'll edit them in the PR`.
97
+
98
+ **After calling widgets, end your turn.** Do NOT open a PR in Phase 1.
99
+
100
+ ---
101
+
102
+ ## Phase 2 — Apply the bumps and report
103
+
104
+ ### 1. Read the widget answers
105
+
106
+ The user's confirmed version selections appear in the `# Conversation so far`
107
+ thread as agent messages (the widget-answer turn). Extract the chosen version for
108
+ each package from those selections.
109
+
110
+ ### 2. Update package.json files
111
+
112
+ For each package whose version changed, edit the `"version"` field in:
113
+
114
+ - `apps/web/package.json` — for `@flumecode/web`
115
+ - `apps/runner/package.json` — for `@flumecode/runner`
116
+
117
+ Change only the `"version"` line; do not reformat the file.
118
+
119
+ ### 3. Update CHANGELOG.md
120
+
121
+ Edit (or create) `CHANGELOG.md` at the repo root. Insert a new section at the
122
+ top, below any existing `# Changelog` title line:
123
+
124
+ ```
125
+ ## [X.Y.Z / runner-X.Y.Z] - YYYY-MM-DD
126
+
127
+ - Bullet point from release notes
128
+ - Another bullet point
129
+ ```
130
+
131
+ Use the ISO date format (`YYYY-MM-DD`). Preserve all existing entries — do not
132
+ delete or rewrite prior sections.
133
+
134
+ If both packages are bumped, list both versions in the heading (e.g.
135
+ `## [0.9.0 / runner-0.5.0] - 2026-06-06`). If only one package is bumped, list
136
+ only that version in the heading.
137
+
138
+ ### 4. Stop — do not commit or push
139
+
140
+ Leave the edited files in the working tree. The runner commits them and opens the
141
+ pull request.
142
+
143
+ ### 5. End with this exact report format
144
+
145
+ Your final message must match this shape (adjust versions and files to match what
146
+ actually changed):
147
+
148
+ ```
149
+ **Bumped versions:**
150
+ - `@flumecode/web`: `0.8.0` → `0.9.0`
151
+ - `@flumecode/runner`: `0.4.0` → `0.5.0`
152
+
153
+ **CHANGELOG updated** with release notes.
154
+
155
+ **Files changed:** `apps/web/package.json`, `apps/runner/package.json`, `CHANGELOG.md`
156
+
157
+ <!-- flumecode:versions {"@flumecode/web":"0.9.0","@flumecode/runner":"0.5.0"} -->
158
+ ```
159
+
160
+ The `<!-- flumecode:versions {...} -->` comment is machine-readable and **must be
161
+ the last line** of your message body (before any trailing newline). The runner
162
+ reads it to persist the confirmed versions on the release entity. Use the exact
163
+ JSON key names `@flumecode/web` and `@flumecode/runner`; omit a package if its
164
+ version did not change.
165
+
166
+ ---
167
+
168
+ ## Notes
169
+
170
+ - **Runner-only bump:** if only `apps/runner/` has commits, bump only
171
+ `apps/runner/package.json`. Leave `apps/web/package.json` unchanged.
172
+ - **Clear Phase 1 text:** be explicit about what changed since the last tag so the
173
+ user can confidently confirm or override your suggestions.
174
+ - **Never edit** any file other than `apps/web/package.json`,
175
+ `apps/runner/package.json`, and `CHANGELOG.md`.
176
+ - **Never commit, push, or open a PR** — the runner does that.