@flumecode/runner 0.3.2 → 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
@@ -97,6 +97,19 @@ async function claimJob(config) {
97
97
  if (!res.ok) throw new Error(`claim failed: ${res.status} ${await safeText(res)}`);
98
98
  return await res.json();
99
99
  }
100
+ async function pollJobCanceling(config, jobId) {
101
+ const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/cancel`, {
102
+ method: "GET",
103
+ headers: {
104
+ authorization: `Bearer ${config.token}`,
105
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
106
+ }
107
+ });
108
+ noteServerVersion(res);
109
+ if (!res.ok) return false;
110
+ const body = await res.json();
111
+ return body.canceling === true;
112
+ }
100
113
  async function reportJob(config, jobId, result) {
101
114
  const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/complete`, {
102
115
  method: "POST",
@@ -328,6 +341,7 @@ async function runClaudeCode(opts) {
328
341
  permissionMode: opts.permissionMode,
329
342
  allowDangerouslySkipPermissions: opts.permissionMode === "bypassPermissions",
330
343
  ...opts.model ? { model: opts.model } : {},
344
+ ...opts.abortController ? { abortController: opts.abortController } : {},
331
345
  maxTurns: opts.maxTurns ?? 40,
332
346
  // Hermetic: ignore ALL config from the checked-out repo (CLAUDE.md,
333
347
  // .claude/settings.json, .claude/skills, …) and the runner owner's
@@ -358,6 +372,9 @@ async function runClaudeCode(opts) {
358
372
  }
359
373
  }
360
374
  process.stdout.write("\n");
375
+ if (opts.abortController?.signal.aborted) {
376
+ throw new Error("Run canceled by user");
377
+ }
361
378
  return { text: finalText, widgets: collected, plans: getPlans() };
362
379
  }
363
380
 
@@ -424,6 +441,20 @@ function stripFrontMatter(raw) {
424
441
  }
425
442
 
426
443
  // src/prompt.ts
444
+ function turnHeading(turn, agentName) {
445
+ if (turn.role === "user") return "User";
446
+ if (turn.failed) return `${agentName} (this run ended in an error)`;
447
+ if (turn.kind === "plan") return `${agentName} (proposed a plan)`;
448
+ if (turn.kind === "report") return `${agentName} (implementation report)`;
449
+ return agentName;
450
+ }
451
+ function appendThread(lines, ctx) {
452
+ if (!ctx.thread || ctx.thread.length === 0) return;
453
+ lines.push("", "# Conversation so far");
454
+ for (const turn of ctx.thread) {
455
+ lines.push("", `## ${turnHeading(turn, ctx.agentName)}`, turn.content);
456
+ }
457
+ }
427
458
  function buildPrompt(ctx) {
428
459
  const task = ctx.permissionMode === "plan" ? `Use the \`flumecode:request-to-plan\` skill to handle this request. You are read-only and cannot modify files \u2014 clarify any ambiguity with the user first, then produce a concrete, actionable plan (the specific changes you would make and why). Cite the relevant files. Do NOT call ExitPlanMode or write the plan to a file. When the plan is ready, call the \`submit_plan\` tool with the structured plan fields; the runner renders it into the canonical plan markdown and posts it as your comment.` : `Use the \`flumecode:implement-plan\` skill to handle this request. You are the ORCHESTRATOR: do not implement, review, or write the report yourself \u2014 follow the skill to delegate each phase to subagents via the Task tool, picking the right model for each. Do not commit or push \u2014 the runner handles that.`;
429
460
  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 request. If there is no wiki, work from the code directly.`;
@@ -447,12 +478,7 @@ function buildPrompt(ctx) {
447
478
  if (ctx.request?.body) {
448
479
  lines.push("", ctx.request.body);
449
480
  }
450
- if (ctx.thread && ctx.thread.length > 0) {
451
- lines.push("", "# Conversation so far");
452
- for (const turn of ctx.thread) {
453
- lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
454
- }
455
- }
481
+ appendThread(lines, ctx);
456
482
  lines.push(
457
483
  "",
458
484
  ctx.permissionMode === "plan" ? "Your final reply is posted verbatim as your comment in the thread \u2014 if you called `submit_plan`, the rendered plan is posted automatically; for clarifying questions, your reply text is posted as-is." : "Your final reply is posted verbatim as your comment in the thread \u2014 make it the implementation report your report subagent produced, with nothing added. The runner appends the pull-request link."
@@ -460,7 +486,7 @@ function buildPrompt(ctx) {
460
486
  return lines.join("\n");
461
487
  }
462
488
  function buildRevisePrompt(ctx) {
463
- const task = `Use the \`flumecode:revise-implementation\` skill to handle this turn. The plan below was already implemented (its report is included); the user is now asking to fine-tune that implementation. Decide how to respond to their latest message: if it's unclear, ask a clarifying question (as a widget); if it's a bad idea or not feasible, push back with your reasoning; if it warrants rethinking the plan, call \`submit_plan\` with a revised plan; otherwise implement the requested change. When you implement, you are the ORCHESTRATOR: delegate the work to subagents via the Task tool as the skill directs, and do not commit or push \u2014 the runner handles that, updating the existing pull request.`;
489
+ const task = `Use the \`flumecode:revise-implementation\` skill to handle this turn. The plan below was already implemented (its implementation report appears in the conversation below, tagged as such); the user is now asking to fine-tune that implementation. Decide how to respond to their latest message: if it's unclear, ask a clarifying question (as a widget); if it's a bad idea or not feasible, push back with your reasoning; if it warrants rethinking the plan, call \`submit_plan\` with a revised plan; otherwise implement the requested change. When you implement, you are the ORCHESTRATOR: delegate the work to subagents via the Task tool as the skill directs, and do not commit or push \u2014 the runner handles that, updating the existing pull request.`;
464
490
  const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this change. If there is no wiki, work from the code directly.`;
465
491
  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.`;
466
492
  const lines = [
@@ -479,21 +505,39 @@ function buildRevisePrompt(ctx) {
479
505
  if (ctx.request?.body) {
480
506
  lines.push("", ctx.request.body);
481
507
  }
482
- if (ctx.priorReport) {
483
- lines.push("", "# Latest implementation report", "", ctx.priorReport);
484
- }
485
- if (ctx.thread && ctx.thread.length > 0) {
486
- lines.push("", "# Conversation so far");
487
- for (const turn of ctx.thread) {
488
- lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
489
- }
490
- }
508
+ appendThread(lines, ctx);
491
509
  lines.push(
492
510
  "",
493
511
  "The last message above is the user's request for this turn. Your final reply is posted verbatim as your comment in the plan thread: if you implemented a change, make it a short report of what you changed (the runner appends the pull-request link); if you asked a question, called `submit_plan`, or pushed back, your reply text is posted as-is."
494
512
  );
495
513
  return lines.join("\n");
496
514
  }
515
+ function buildResolvePrompt(ctx) {
516
+ const mergeBranch = ctx.repo.mergeBranch ?? "the merge branch";
517
+ const task = `Use the \`flumecode:resolve-merge-conflict\` skill to handle this turn. A merge of \`${mergeBranch}\` into this branch is IN PROGRESS and has left conflict markers in your working tree. Resolve every conflicted file by correctly integrating BOTH sides \u2014 the change this session implemented (described below) and the incoming changes from \`${mergeBranch}\` \u2014 never blindly discard either side. Remove all conflict markers and verify the result builds and tests pass. Do NOT \`git add\`, commit, push, or open a pull request \u2014 the runner finalizes the merge commit and updates the existing pull request.`;
518
+ const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to the conflicting code. If there is no wiki, work from the code directly.`;
519
+ const lines = [
520
+ `You are "${ctx.agentName}", an autonomous coding agent resolving merge conflicts on an implemented FlumeCode plan.`,
521
+ `The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from \u2014 with an in-progress merge of "${mergeBranch}".`,
522
+ task,
523
+ orient,
524
+ "",
525
+ "These coding guidelines apply to all code produced in this run:",
526
+ "",
527
+ loadRule("coding-guideline"),
528
+ "",
529
+ `# Plan: ${ctx.request?.title ?? ""}`
530
+ ];
531
+ if (ctx.request?.body) {
532
+ lines.push("", ctx.request.body);
533
+ }
534
+ appendThread(lines, ctx);
535
+ lines.push(
536
+ "",
537
+ "Resolve the conflicts now. Your final reply is posted as a report in the plan thread: summarize which files conflicted and how you resolved each (the runner appends the pull-request link, so don't add one)."
538
+ );
539
+ return lines.join("\n");
540
+ }
497
541
  function buildDocumentPrompt(ctx) {
498
542
  const lines = [
499
543
  `You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
@@ -505,12 +549,7 @@ function buildDocumentPrompt(ctx) {
505
549
  if (ctx.request?.body) {
506
550
  lines.push("", ctx.request.body);
507
551
  }
508
- if (ctx.thread && ctx.thread.length > 0) {
509
- lines.push("", "# Conversation so far");
510
- for (const turn of ctx.thread) {
511
- lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
512
- }
513
- }
552
+ appendThread(lines, ctx);
514
553
  lines.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
515
554
  return lines.join("\n");
516
555
  }
@@ -533,6 +572,33 @@ function buildRepairPrompt(ctx, hookLog) {
533
572
  ];
534
573
  return lines.join("\n");
535
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
+ }
536
602
  function buildInitPrompt(ctx) {
537
603
  return [
538
604
  `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
@@ -614,6 +680,12 @@ async function discardWorkspace(key) {
614
680
  });
615
681
  }
616
682
  }
683
+ async function resetWorkspace(dir) {
684
+ await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
685
+ });
686
+ await git(["-C", dir, "clean", "-fd"]).catch(() => {
687
+ });
688
+ }
617
689
  async function prepareAtSha(ctx, dir, reused) {
618
690
  if (!reused) {
619
691
  await cloneAtSha(ctx, dir);
@@ -716,17 +788,45 @@ async function rebaseOntoMergeBranch(ctx, dir) {
716
788
  if (!mergeBranch) return;
717
789
  await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
718
790
  try {
719
- await git(["-C", dir, "rebase", "FETCH_HEAD"]);
720
- } catch {
791
+ await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
792
+ } catch (err) {
721
793
  const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
722
794
  () => ({ stdout: "" })
723
795
  );
724
796
  const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
725
797
  await git(["-C", dir, "rebase", "--abort"]).catch(() => {
726
798
  });
799
+ if (files.length === 0) throw err;
727
800
  throw new RebaseConflictError(mergeBranch, files);
728
801
  }
729
802
  }
803
+ async function mergeInMergeBranch(ctx, dir) {
804
+ const { mergeBranch } = ctx.repo;
805
+ if (!mergeBranch) return { conflicted: false };
806
+ await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
807
+ try {
808
+ await git([
809
+ "-C",
810
+ dir,
811
+ "-c",
812
+ "user.email=runner@flumecode.local",
813
+ "-c",
814
+ "user.name=FlumeCode Runner",
815
+ "merge",
816
+ "--no-edit",
817
+ "FETCH_HEAD"
818
+ ]);
819
+ return { conflicted: false };
820
+ } catch {
821
+ return { conflicted: true };
822
+ }
823
+ }
824
+ async function listUnmergedPaths(dir) {
825
+ const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
826
+ stdout: ""
827
+ }));
828
+ return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
829
+ }
730
830
  async function openPullRequest(ctx) {
731
831
  const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
732
832
  if (!mergeBranch) return null;
@@ -772,21 +872,56 @@ async function cleanup(dir) {
772
872
 
773
873
  // src/run.ts
774
874
  var IDLE_MS = 5e3;
875
+ var CANCEL_POLL_MS = 2500;
775
876
  var ORCHESTRATOR_MODEL = "sonnet";
776
877
  var ORCHESTRATOR_MAX_TURNS = 80;
777
878
  var MAX_COMMIT_REPAIRS = 2;
778
879
  var INIT_MAX_TURNS = 200;
779
880
  var DOCUMENT_MAX_TURNS = 120;
780
881
  var HEARTBEAT_MS = 5 * 6e4;
781
- async function pushAndOpenPr(ctx, dir, opts = { rebase: true }) {
782
- const committed = await commitWithRepair(ctx, dir);
783
- if (!committed) return { kind: "none" };
784
- if (opts.rebase) await rebaseOntoMergeBranch(ctx, dir);
882
+ async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
883
+ if (abort.signal.aborted) throw new Error("Run canceled by user");
884
+ const committed = await commitWithRepair(ctx, dir, abort);
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
+ }
785
901
  await pushBranch(ctx, dir);
786
902
  const pr = await openPullRequest(ctx);
787
- return pr ? { kind: "pr", pr } : { kind: "pushed" };
903
+ return { outcome: pr ? { kind: "pr", pr } : { kind: "pushed" }, autoMerged };
788
904
  }
789
- async function commitWithRepair(ctx, dir) {
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 };
923
+ }
924
+ async function commitWithRepair(ctx, dir, abort) {
790
925
  for (let attempt = 1; ; attempt++) {
791
926
  try {
792
927
  return await commitChanges(ctx, dir);
@@ -800,24 +935,26 @@ async function commitWithRepair(ctx, dir) {
800
935
  prompt: buildRepairPrompt(ctx, err.log),
801
936
  permissionMode: ctx.permissionMode,
802
937
  model: ORCHESTRATOR_MODEL,
803
- maxTurns: ORCHESTRATOR_MAX_TURNS
938
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
939
+ abortController: abort
804
940
  });
805
941
  }
806
942
  }
807
943
  }
808
944
  function outcomeBanner(outcome, opts) {
809
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." : "";
810
947
  switch (outcome.kind) {
811
948
  case "pr":
812
949
  return `
813
950
 
814
951
  ---
815
- \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}`;
816
953
  case "pushed":
817
954
  return `
818
955
 
819
956
  ---
820
- \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}`;
821
958
  case "none":
822
959
  return `
823
960
 
@@ -825,51 +962,67 @@ function outcomeBanner(outcome, opts) {
825
962
  \u2139\uFE0F **No code changes were made** \u2014 ${opts.noChange ?? "there was nothing to open a pull request for."}`;
826
963
  }
827
964
  }
828
- async function processJob(ctx) {
965
+ async function processJob(ctx, abort = new AbortController()) {
829
966
  const { dir, reused } = await acquireWorkspace(ctx.workspaceKey);
830
967
  let prepared = false;
831
968
  try {
832
969
  if (ctx.kind === "init") {
833
970
  await prepareAtSha(ctx, dir, reused);
834
971
  prepared = true;
835
- return await processInitJob(ctx, dir);
972
+ return await processInitJob(ctx, dir, abort);
836
973
  }
837
974
  if (ctx.kind === "implement") {
838
975
  const { resumed } = await prepareResumingBranch(ctx, dir, reused);
839
976
  prepared = true;
840
- return await processImplementJob(ctx, dir, resumed);
977
+ return await processImplementJob(ctx, dir, resumed, abort);
841
978
  }
842
979
  if (ctx.kind === "revise") {
843
980
  const { resumed } = await prepareResumingBranch(ctx, dir, reused);
844
981
  prepared = true;
845
- return await processReviseJob(ctx, dir, resumed);
982
+ return await processReviseJob(ctx, dir, resumed, abort);
983
+ }
984
+ if (ctx.kind === "resolve") {
985
+ await prepareResumingBranch(ctx, dir, reused);
986
+ prepared = true;
987
+ return await processResolveJob(ctx, dir, abort);
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);
846
993
  }
847
994
  await prepareAtSha(ctx, dir, reused);
848
995
  prepared = true;
849
- return await processChatJob(ctx, dir);
996
+ return await processChatJob(ctx, dir, abort);
850
997
  } catch (err) {
851
- if (!prepared) await discardWorkspace(ctx.workspaceKey);
998
+ if (abort.signal.aborted && prepared) {
999
+ await resetWorkspace(dir);
1000
+ } else if (!prepared) {
1001
+ await discardWorkspace(ctx.workspaceKey);
1002
+ }
852
1003
  throw err;
853
1004
  }
854
1005
  }
855
- async function processInitJob(ctx, dir) {
1006
+ async function processInitJob(ctx, dir, abort) {
856
1007
  console.log(`
857
1008
  \u25B6 Init ${ctx.jobId} \u2014 ${ctx.repo.fullName} on ${ctx.repo.checkoutBranch}`);
858
1009
  const summary = (await runClaudeCode({
859
1010
  cwd: dir,
860
1011
  prompt: buildInitPrompt(ctx),
861
1012
  permissionMode: ctx.permissionMode,
862
- maxTurns: INIT_MAX_TURNS
1013
+ maxTurns: INIT_MAX_TURNS,
1014
+ abortController: abort
863
1015
  })).text.trim();
864
1016
  let reply = summary || "(the agent produced no summary)";
865
- const outcome = await pushAndOpenPr(ctx, dir);
1017
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort);
866
1018
  reply += outcomeBanner(outcome, {
867
1019
  branch: ctx.repo.checkoutBranch,
868
- noChange: "no files were generated; the wiki may already exist."
1020
+ noChange: "no files were generated; the wiki may already exist.",
1021
+ autoMerged
869
1022
  });
870
1023
  return { text: reply, widgets: [] };
871
1024
  }
872
- async function processChatJob(ctx, dir) {
1025
+ async function processChatJob(ctx, dir, abort) {
873
1026
  console.log(`
874
1027
  \u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
875
1028
  const orchestrating = ctx.permissionMode !== "plan";
@@ -878,6 +1031,7 @@ async function processChatJob(ctx, dir) {
878
1031
  cwd: dir,
879
1032
  prompt: buildPrompt(ctx),
880
1033
  permissionMode: ctx.permissionMode,
1034
+ abortController: abort,
881
1035
  ...orchestrating ? { model: ORCHESTRATOR_MODEL, maxTurns: ORCHESTRATOR_MAX_TURNS } : {}
882
1036
  });
883
1037
  const summary = result.text.trim();
@@ -902,18 +1056,19 @@ async function processChatJob(ctx, dir) {
902
1056
  cwd: dir,
903
1057
  prompt: buildDocumentPrompt(ctx),
904
1058
  permissionMode: ctx.permissionMode,
905
- maxTurns: DOCUMENT_MAX_TURNS
1059
+ maxTurns: DOCUMENT_MAX_TURNS,
1060
+ abortController: abort
906
1061
  });
907
1062
  documented = true;
908
1063
  } catch (err) {
909
1064
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
910
1065
  }
911
1066
  }
912
- const outcome = await pushAndOpenPr(ctx, dir);
913
- 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 });
914
1069
  return { text: reply, widgets: [] };
915
1070
  }
916
- async function processImplementJob(ctx, dir, resumed) {
1071
+ async function processImplementJob(ctx, dir, resumed, abort) {
917
1072
  console.log(`
918
1073
  \u25B6 Implement ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
919
1074
  const installResult = await installDependencies(dir);
@@ -922,7 +1077,8 @@ async function processImplementJob(ctx, dir, resumed) {
922
1077
  prompt: buildPrompt(ctx),
923
1078
  permissionMode: ctx.permissionMode,
924
1079
  model: ORCHESTRATOR_MODEL,
925
- maxTurns: ORCHESTRATOR_MAX_TURNS
1080
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
1081
+ abortController: abort
926
1082
  });
927
1083
  let reply = result.text.trim() || "(the agent produced no report)";
928
1084
  if (installResult.status === "failed") {
@@ -938,18 +1094,19 @@ async function processImplementJob(ctx, dir, resumed) {
938
1094
  cwd: dir,
939
1095
  prompt: buildDocumentPrompt(ctx),
940
1096
  permissionMode: ctx.permissionMode,
941
- maxTurns: DOCUMENT_MAX_TURNS
1097
+ maxTurns: DOCUMENT_MAX_TURNS,
1098
+ abortController: abort
942
1099
  });
943
1100
  documented = true;
944
1101
  } catch (err) {
945
1102
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
946
1103
  }
947
1104
  }
948
- const outcome = await pushAndOpenPr(ctx, dir, { rebase: !resumed });
949
- 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 });
950
1107
  return { text: reply, widgets: [], ...outcome.kind === "pr" ? { pr: outcome.pr } : {} };
951
1108
  }
952
- async function processReviseJob(ctx, dir, resumed) {
1109
+ async function processReviseJob(ctx, dir, resumed, abort) {
953
1110
  console.log(`
954
1111
  \u25B6 Revise ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
955
1112
  const installResult = await installDependencies(dir);
@@ -958,7 +1115,8 @@ async function processReviseJob(ctx, dir, resumed) {
958
1115
  prompt: buildRevisePrompt(ctx),
959
1116
  permissionMode: ctx.permissionMode,
960
1117
  model: ORCHESTRATOR_MODEL,
961
- maxTurns: ORCHESTRATOR_MAX_TURNS
1118
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
1119
+ abortController: abort
962
1120
  });
963
1121
  const summary = result.text.trim();
964
1122
  let reply = summary || "(the agent produced no reply)";
@@ -980,16 +1138,17 @@ async function processReviseJob(ctx, dir, resumed) {
980
1138
  cwd: dir,
981
1139
  prompt: buildDocumentPrompt(ctx),
982
1140
  permissionMode: ctx.permissionMode,
983
- maxTurns: DOCUMENT_MAX_TURNS
1141
+ maxTurns: DOCUMENT_MAX_TURNS,
1142
+ abortController: abort
984
1143
  });
985
1144
  documented = true;
986
1145
  } catch (err) {
987
1146
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
988
1147
  }
989
1148
  }
990
- const outcome = await pushAndOpenPr(ctx, dir, { rebase: !resumed });
1149
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
991
1150
  if (outcome.kind !== "none") {
992
- reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
1151
+ reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
993
1152
  }
994
1153
  return {
995
1154
  text: reply,
@@ -998,6 +1157,59 @@ async function processReviseJob(ctx, dir, resumed) {
998
1157
  ...result.plans?.length ? { plans: result.plans } : {}
999
1158
  };
1000
1159
  }
1160
+ async function processResolveJob(ctx, dir, abort) {
1161
+ console.log(`
1162
+ \u25B6 Resolve ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1163
+ const installResult = await installDependencies(dir);
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.`;
1166
+ if (installResult.status === "failed") {
1167
+ reply += `
1168
+
1169
+ > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1170
+ }
1171
+ if (abort.signal.aborted) throw new Error("Run canceled by user");
1172
+ await commitWithRepair(ctx, dir, abort);
1173
+ await pushBranch(ctx, dir);
1174
+ const pr = await openPullRequest(ctx);
1175
+ const outcome = pr ? { kind: "pr", pr } : { kind: "pushed" };
1176
+ reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch });
1177
+ return { text: reply, widgets: [], ...pr ? { pr } : {} };
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
+ }
1001
1213
  async function heartbeat(config) {
1002
1214
  const health = await checkClaudeCode();
1003
1215
  if (health.ready) {
@@ -1016,43 +1228,78 @@ async function pollLoop(config) {
1016
1228
  const swept = await sweepWorkspaces();
1017
1229
  if (swept > 0) console.log(`Cleared ${swept} stale workspace${swept === 1 ? "" : "s"}.`);
1018
1230
  await heartbeat(config);
1019
- let nextHeartbeatAt = Date.now() + HEARTBEAT_MS;
1020
- for (; ; ) {
1021
- if (Date.now() >= nextHeartbeatAt) {
1231
+ let heartbeatTimer;
1232
+ const scheduleHeartbeat = () => {
1233
+ heartbeatTimer = setTimeout(async () => {
1022
1234
  await heartbeat(config);
1023
- nextHeartbeatAt = Date.now() + HEARTBEAT_MS;
1024
- }
1025
- let ctx = null;
1026
- try {
1027
- ctx = await claimJob(config);
1028
- } catch (err) {
1029
- console.error(`Claim error: ${errorMessage2(err)}`);
1030
- await sleep(IDLE_MS);
1031
- continue;
1032
- }
1033
- if (!ctx) {
1034
- await sleep(IDLE_MS);
1035
- continue;
1036
- }
1037
- try {
1038
- const { text, widgets, pr, plans } = await processJob(ctx);
1039
- await reportJob(config, ctx.jobId, {
1040
- status: "done",
1041
- text,
1042
- widgets,
1043
- pr,
1044
- ...plans?.length ? { plans } : {}
1045
- });
1046
- console.log(`\u2713 Job ${ctx.jobId} done`);
1047
- } catch (err) {
1048
- const message = errorMessage2(err);
1049
- console.error(`\u2717 Job ${ctx.jobId} failed: ${message}`);
1235
+ scheduleHeartbeat();
1236
+ }, HEARTBEAT_MS);
1237
+ };
1238
+ scheduleHeartbeat();
1239
+ try {
1240
+ for (; ; ) {
1241
+ let ctx = null;
1050
1242
  try {
1051
- await reportJob(config, ctx.jobId, { status: "error", error: message });
1052
- } catch (reportErr) {
1053
- console.error(` (also failed to report the error: ${errorMessage2(reportErr)})`);
1243
+ ctx = await claimJob(config);
1244
+ } catch (err) {
1245
+ console.error(`Claim error: ${errorMessage2(err)}`);
1246
+ await sleep(IDLE_MS);
1247
+ continue;
1248
+ }
1249
+ if (!ctx) {
1250
+ await sleep(IDLE_MS);
1251
+ continue;
1252
+ }
1253
+ const abort = new AbortController();
1254
+ let stopPolling = false;
1255
+ const scheduleCancelPoll = () => {
1256
+ setTimeout(async () => {
1257
+ if (stopPolling) return;
1258
+ try {
1259
+ if (await pollJobCanceling(config, ctx.jobId)) {
1260
+ console.log(`\u23F9 Stop requested for job ${ctx.jobId} \u2014 aborting\u2026`);
1261
+ abort.abort();
1262
+ return;
1263
+ }
1264
+ } catch {
1265
+ }
1266
+ if (!stopPolling) scheduleCancelPoll();
1267
+ }, CANCEL_POLL_MS);
1268
+ };
1269
+ scheduleCancelPoll();
1270
+ try {
1271
+ const { text, widgets, pr, plans } = await processJob(ctx, abort);
1272
+ await reportJob(config, ctx.jobId, {
1273
+ status: "done",
1274
+ text,
1275
+ widgets,
1276
+ pr,
1277
+ ...plans?.length ? { plans } : {}
1278
+ });
1279
+ console.log(`\u2713 Job ${ctx.jobId} done`);
1280
+ } catch (err) {
1281
+ if (abort.signal.aborted) {
1282
+ console.log(`\u23F9 Job ${ctx.jobId} canceled`);
1283
+ try {
1284
+ await reportJob(config, ctx.jobId, { status: "canceled" });
1285
+ } catch (reportErr) {
1286
+ console.error(` (failed to report the cancellation: ${errorMessage2(reportErr)})`);
1287
+ }
1288
+ } else {
1289
+ const message = errorMessage2(err);
1290
+ console.error(`\u2717 Job ${ctx.jobId} failed: ${message}`);
1291
+ try {
1292
+ await reportJob(config, ctx.jobId, { status: "error", error: message });
1293
+ } catch (reportErr) {
1294
+ console.error(` (also failed to report the error: ${errorMessage2(reportErr)})`);
1295
+ }
1296
+ }
1297
+ } finally {
1298
+ stopPolling = true;
1054
1299
  }
1055
1300
  }
1301
+ } finally {
1302
+ if (heartbeatTimer) clearTimeout(heartbeatTimer);
1056
1303
  }
1057
1304
  }
1058
1305
  function sleep(ms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flumecode/runner",
3
- "version": "0.3.2",
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.
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: resolve-merge-conflict
3
+ description: >-
4
+ Resolve the merge conflicts left by an in-progress merge of a coding session's
5
+ merge branch into its pull-request branch. Use in resolve-job runs. The working
6
+ tree already has conflict markers; integrate both sides of every conflict
7
+ correctly, verify the build/tests, and leave the resolved changes in the working
8
+ tree. Never `git add`, commit, push, or open a PR — the runner finalizes the merge.
9
+ ---
10
+
11
+ # resolve-merge-conflict
12
+
13
+ The plan in the context above was **already implemented** and has an open pull
14
+ request. Its merge branch (e.g. `main`) has since advanced and now **conflicts** with
15
+ the PR branch. The runner has already started a `git merge` of the merge branch into
16
+ your current branch, so your working tree contains the **conflict markers**
17
+ (`<<<<<<<`, `=======`, `>>>>>>>`). Your job is to resolve every conflict so the PR
18
+ becomes mergeable again — without losing this session's work or the incoming changes.
19
+
20
+ ## You are stateless — orient yourself first
21
+
22
+ You see the whole session thread (the plan and its implementation report) but keep
23
+ **no memory** between turns. Read it first: the plan and report tell you what this
24
+ branch was building, which is exactly the intent you must preserve when you choose how
25
+ to integrate each conflict. Check the FlumeCode wiki (`.flumecode/wiki/README.md`) if
26
+ one exists to understand the conflicting code.
27
+
28
+ ## Step 1 — Find every conflict
29
+
30
+ - `git status` shows the unmerged paths; `git diff --diff-filter=U` shows the
31
+ conflicting hunks. Work through **all** of them — a single leftover marker fails the
32
+ run.
33
+
34
+ ## Step 2 — Resolve each conflict correctly
35
+
36
+ For each conflicted file, understand **both** sides before editing:
37
+
38
+ - **Ours** (the conflict's first side) is this session's implementation — the change
39
+ the plan/report describes.
40
+ - **Theirs** (the second side) is what landed on the merge branch since.
41
+
42
+ Integrate them so **both** intents survive. Do **not** blindly pick a side or delete
43
+ one half to make the markers go away — that silently drops work. When the two changes
44
+ are genuinely incompatible, prefer keeping this session's implementation intent while
45
+ adapting it to the incoming code (e.g. a renamed function, a moved file, a new
46
+ signature). Remove every conflict marker; the file must read as clean, correct code.
47
+
48
+ You may delegate isolated, well-scoped resolutions to **Task** subagents, but give
49
+ each a self-contained prompt (the conflicting file's content, both intents, and the
50
+ coding guidelines verbatim) — subagents start blank.
51
+
52
+ ## Step 3 — Verify
53
+
54
+ Run the project's build and tests to confirm the resolved tree is correct (the
55
+ runner's commit will also run the repo's pre-commit hook, so failing checks would
56
+ block the merge anyway). Fix anything the merge broke.
57
+
58
+ ## Never
59
+
60
+ - Never `git add`, `git commit`, `git merge --continue`, `git push`, or open a PR —
61
+ the runner stages the resolved tree, finalizes the merge commit, and updates the
62
+ **existing** pull request. A new PR must never be created.
63
+ - Never leave a conflict marker behind, and never resolve a conflict by discarding one
64
+ side's intent.
65
+
66
+ ## Your final reply
67
+
68
+ Your last message **is** the report posted to the session thread. Write it for the
69
+ user: list which files conflicted and, briefly, how you resolved each, plus how you
70
+ verified (build/tests). The runner appends the pull-request link, so don't add one.