@flumecode/runner 0.1.0 → 0.3.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/README.md CHANGED
@@ -45,7 +45,8 @@ pnpm --filter @flumecode/runner dev login # or: dev start
45
45
 
46
46
  `pnpm --filter @flumecode/runner build` produces the published single-file bundle
47
47
  (`dist/cli.js`); it is published to npm by the `release-runner` GitHub workflow
48
- when a `runner-v*` tag is pushed.
48
+ when a GitHub Release is created (publishing the version in `package.json`, and
49
+ skipping if that version is already on npm).
49
50
 
50
51
  ## What a job does
51
52
 
package/dist/cli.js CHANGED
@@ -175,6 +175,9 @@ var stepSchema = z2.object({
175
175
  files: z2.array(z2.string()).optional().describe("Affected file paths.")
176
176
  });
177
177
  var planInputSchema = {
178
+ title: z2.string().min(1).max(120).describe(
179
+ "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."
180
+ ),
178
181
  scope: z2.enum(["feat", "fix", "chore", "docs", "test", "refactor"]).describe("The primary intent of the change."),
179
182
  goal: z2.string().min(1).describe("One or two sentences stating the outcome."),
180
183
  assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
@@ -188,6 +191,8 @@ var planInputSchema = {
188
191
  var planSchema = z2.object(planInputSchema);
189
192
  function renderPlan(plan) {
190
193
  const lines = [];
194
+ lines.push(`# ${plan.title}`);
195
+ lines.push("");
191
196
  lines.push(`**Scope** \u2014 \`${plan.scope}\``);
192
197
  lines.push("");
193
198
  lines.push(`**Goal** \u2014 ${plan.goal}`);
@@ -235,7 +240,7 @@ function createPlanTooling() {
235
240
  let renderedPlan = null;
236
241
  const submitPlan = tool2(
237
242
  SUBMIT_PLAN,
238
- "Submit the finished plan. Call this \u2014 and only this \u2014 when the plan is complete and ready to post. The runner renders your structured fields into the canonical plan markdown and posts it as your comment. acceptanceCriteria is required and must contain at least 2 observable, verifiable conditions (behaviors, tests, or states you could check) that together define 'done'. After calling this, end your turn.",
243
+ "Submit the finished plan. Call this \u2014 and only this \u2014 when the plan is complete and ready to post. The runner renders your structured fields into the canonical plan markdown and posts it as your comment. acceptanceCriteria is required and must contain at least 2 observable, verifiable conditions (behaviors, tests, or states you could check) that together define 'done'. After calling this, end your turn. The `title` field names this specific plan \u2014 make it concise and distinct from the request title.",
239
244
  planInputSchema,
240
245
  async (args) => {
241
246
  renderedPlan = renderPlan(planSchema.parse(args));
@@ -455,6 +460,25 @@ function buildDocumentPrompt(ctx) {
455
460
  lines.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
456
461
  return lines.join("\n");
457
462
  }
463
+ function buildRepairPrompt(ctx, hookLog) {
464
+ const lines = [
465
+ `You are "${ctx.agentName}", fixing a failed pre-commit check in the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
466
+ `The changes from the previous step are still uncommitted in the working tree. When the runner tried to commit them, the repository's pre-commit hook \u2014 which runs the project's own checks (lint / typecheck / unit tests) \u2014 failed. Make the working tree pass those checks: fix the failing code or tests at their root. Do NOT delete or skip tests, weaken assertions, or disable the checks to silence the failure. Preserve the intent of the original change; repair only what's broken. Do NOT commit or push \u2014 the runner re-commits once the checks pass.`,
467
+ "",
468
+ "These coding guidelines apply to all code produced in this run:",
469
+ "",
470
+ loadRule("coding-guideline"),
471
+ "",
472
+ "# Pre-commit hook output",
473
+ "",
474
+ "```",
475
+ hookLog,
476
+ "```",
477
+ "",
478
+ "When done, reply with a one-line summary of what you fixed."
479
+ ];
480
+ return lines.join("\n");
481
+ }
458
482
  function buildInitPrompt(ctx) {
459
483
  return [
460
484
  `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
@@ -508,6 +532,48 @@ async function installDependencies(dir) {
508
532
  async function makeWorkspace() {
509
533
  return mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX));
510
534
  }
535
+ var MAX_WORKSPACES = 8;
536
+ var workspaceRegistry = /* @__PURE__ */ new Map();
537
+ async function acquireWorkspace(key) {
538
+ const existing = workspaceRegistry.get(key);
539
+ if (existing !== void 0 && existsSync2(existing)) {
540
+ workspaceRegistry.delete(key);
541
+ workspaceRegistry.set(key, existing);
542
+ return { dir: existing, reused: true };
543
+ }
544
+ const dir = await makeWorkspace();
545
+ workspaceRegistry.set(key, dir);
546
+ if (workspaceRegistry.size > MAX_WORKSPACES) {
547
+ const oldest = workspaceRegistry.keys().next().value;
548
+ const oldDir = workspaceRegistry.get(oldest);
549
+ workspaceRegistry.delete(oldest);
550
+ rm(oldDir, { recursive: true, force: true }).catch(() => {
551
+ });
552
+ }
553
+ return { dir, reused: false };
554
+ }
555
+ async function discardWorkspace(key) {
556
+ const dir = workspaceRegistry.get(key);
557
+ workspaceRegistry.delete(key);
558
+ if (dir !== void 0) {
559
+ await cleanup(dir).catch(() => {
560
+ });
561
+ }
562
+ }
563
+ async function prepareAtSha(ctx, dir, reused) {
564
+ if (!reused) {
565
+ await cloneAtSha(ctx, dir);
566
+ return;
567
+ }
568
+ await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
569
+ }
570
+ async function prepareResumingBranch(ctx, dir, reused) {
571
+ if (!reused) {
572
+ return cloneResumingBranch(ctx, dir);
573
+ }
574
+ await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
575
+ return { resumed: true };
576
+ }
511
577
  async function sweepWorkspaces() {
512
578
  const base = tmpdir();
513
579
  let entries;
@@ -547,20 +613,36 @@ async function hasChanges(dir) {
547
613
  const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
548
614
  return stdout2.trim().length > 0;
549
615
  }
616
+ var PreCommitError = class extends Error {
617
+ constructor(log) {
618
+ super("pre-commit checks failed");
619
+ this.log = log;
620
+ this.name = "PreCommitError";
621
+ }
622
+ };
623
+ function commitFailureLog(err) {
624
+ const e = err;
625
+ const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
626
+ return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
627
+ }
550
628
  async function commitChanges(ctx, dir) {
551
629
  if (!await hasChanges(dir)) return false;
552
- await git([
553
- "-C",
554
- dir,
555
- "-c",
556
- "user.email=runner@flumecode.local",
557
- "-c",
558
- "user.name=FlumeCode Runner",
559
- "commit",
560
- "--quiet",
561
- "-m",
562
- `FlumeCode: ${jobTitle(ctx)}`
563
- ]);
630
+ try {
631
+ await git([
632
+ "-C",
633
+ dir,
634
+ "-c",
635
+ "user.email=runner@flumecode.local",
636
+ "-c",
637
+ "user.name=FlumeCode Runner",
638
+ "commit",
639
+ "--quiet",
640
+ "-m",
641
+ `FlumeCode: ${jobTitle(ctx)}`
642
+ ]);
643
+ } catch (err) {
644
+ throw new PreCommitError(commitFailureLog(err));
645
+ }
564
646
  return true;
565
647
  }
566
648
  async function pushBranch(ctx, dir) {
@@ -638,17 +720,37 @@ async function cleanup(dir) {
638
720
  var IDLE_MS = 5e3;
639
721
  var ORCHESTRATOR_MODEL = "sonnet";
640
722
  var ORCHESTRATOR_MAX_TURNS = 80;
723
+ var MAX_COMMIT_REPAIRS = 2;
641
724
  var INIT_MAX_TURNS = 200;
642
725
  var DOCUMENT_MAX_TURNS = 120;
643
726
  var HEARTBEAT_MS = 5 * 6e4;
644
727
  async function pushAndOpenPr(ctx, dir, opts = { rebase: true }) {
645
- const committed = await commitChanges(ctx, dir);
728
+ const committed = await commitWithRepair(ctx, dir);
646
729
  if (!committed) return { kind: "none" };
647
730
  if (opts.rebase) await rebaseOntoMergeBranch(ctx, dir);
648
731
  await pushBranch(ctx, dir);
649
732
  const pr = await openPullRequest(ctx);
650
733
  return pr ? { kind: "pr", pr } : { kind: "pushed" };
651
734
  }
735
+ async function commitWithRepair(ctx, dir) {
736
+ for (let attempt = 1; ; attempt++) {
737
+ try {
738
+ return await commitChanges(ctx, dir);
739
+ } catch (err) {
740
+ if (!(err instanceof PreCommitError) || attempt > MAX_COMMIT_REPAIRS) throw err;
741
+ console.warn(
742
+ ` pre-commit checks failed (repair ${attempt}/${MAX_COMMIT_REPAIRS}) \u2014 asking the agent to fix\u2026`
743
+ );
744
+ await runClaudeCode({
745
+ cwd: dir,
746
+ prompt: buildRepairPrompt(ctx, err.log),
747
+ permissionMode: ctx.permissionMode,
748
+ model: ORCHESTRATOR_MODEL,
749
+ maxTurns: ORCHESTRATOR_MAX_TURNS
750
+ });
751
+ }
752
+ }
753
+ }
652
754
  function outcomeBanner(outcome, opts) {
653
755
  const wikiNote = opts.documented ? " (with wiki updates)" : "";
654
756
  switch (outcome.kind) {
@@ -670,20 +772,35 @@ function outcomeBanner(outcome, opts) {
670
772
  }
671
773
  }
672
774
  async function processJob(ctx) {
673
- const dir = await makeWorkspace();
775
+ const { dir, reused } = await acquireWorkspace(ctx.workspaceKey);
776
+ let prepared = false;
674
777
  try {
675
- if (ctx.kind === "init") return await processInitJob(ctx, dir);
676
- if (ctx.kind === "implement") return await processImplementJob(ctx, dir);
677
- if (ctx.kind === "revise") return await processReviseJob(ctx, dir);
778
+ if (ctx.kind === "init") {
779
+ await prepareAtSha(ctx, dir, reused);
780
+ prepared = true;
781
+ return await processInitJob(ctx, dir);
782
+ }
783
+ if (ctx.kind === "implement") {
784
+ const { resumed } = await prepareResumingBranch(ctx, dir, reused);
785
+ prepared = true;
786
+ return await processImplementJob(ctx, dir, resumed);
787
+ }
788
+ if (ctx.kind === "revise") {
789
+ const { resumed } = await prepareResumingBranch(ctx, dir, reused);
790
+ prepared = true;
791
+ return await processReviseJob(ctx, dir, resumed);
792
+ }
793
+ await prepareAtSha(ctx, dir, reused);
794
+ prepared = true;
678
795
  return await processChatJob(ctx, dir);
679
- } finally {
680
- await cleanup(dir);
796
+ } catch (err) {
797
+ if (!prepared) await discardWorkspace(ctx.workspaceKey);
798
+ throw err;
681
799
  }
682
800
  }
683
801
  async function processInitJob(ctx, dir) {
684
802
  console.log(`
685
803
  \u25B6 Init ${ctx.jobId} \u2014 ${ctx.repo.fullName} on ${ctx.repo.checkoutBranch}`);
686
- await cloneAtSha(ctx, dir);
687
804
  const summary = (await runClaudeCode({
688
805
  cwd: dir,
689
806
  prompt: buildInitPrompt(ctx),
@@ -701,7 +818,6 @@ async function processInitJob(ctx, dir) {
701
818
  async function processChatJob(ctx, dir) {
702
819
  console.log(`
703
820
  \u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
704
- await cloneAtSha(ctx, dir);
705
821
  const orchestrating = ctx.permissionMode !== "plan";
706
822
  const installResult = orchestrating ? await installDependencies(dir) : null;
707
823
  const result = await runClaudeCode({
@@ -743,10 +859,9 @@ async function processChatJob(ctx, dir) {
743
859
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
744
860
  return { text: reply, widgets: [] };
745
861
  }
746
- async function processImplementJob(ctx, dir) {
862
+ async function processImplementJob(ctx, dir, resumed) {
747
863
  console.log(`
748
864
  \u25B6 Implement ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
749
- const { resumed } = await cloneResumingBranch(ctx, dir);
750
865
  const installResult = await installDependencies(dir);
751
866
  const result = await runClaudeCode({
752
867
  cwd: dir,
@@ -780,10 +895,9 @@ async function processImplementJob(ctx, dir) {
780
895
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
781
896
  return { text: reply, widgets: [], ...outcome.kind === "pr" ? { pr: outcome.pr } : {} };
782
897
  }
783
- async function processReviseJob(ctx, dir) {
898
+ async function processReviseJob(ctx, dir, resumed) {
784
899
  console.log(`
785
900
  \u25B6 Revise ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
786
- const { resumed } = await cloneResumingBranch(ctx, dir);
787
901
  const installResult = await installDependencies(dir);
788
902
  const result = await runClaudeCode({
789
903
  cwd: dir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flumecode/runner",
3
- "version": "0.1.0",
3
+ "version": "0.3.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": {
@@ -57,6 +57,8 @@ it as your comment — do **not** write the plan as your reply text.
57
57
 
58
58
  Field-by-field guidance:
59
59
 
60
+ - **`title`** — a concise, descriptive name for this specific plan. Must be distinct from the
61
+ request title and from any sibling plans on the same request. Keep it under 120 characters.
60
62
  - **`scope`** — exactly one of `feat`, `fix`, `chore`, `docs`, `test`,
61
63
  `refactor`. Pick the one that best matches the primary intent of the request.
62
64
  - **`goal`** — one or two sentences stating the outcome, phrased so it directly
@@ -85,9 +87,10 @@ plan without re-deriving it.
85
87
  A single request can yield **several** plans — one thread can be accepted into
86
88
  many. If the work naturally splits into independent pieces, or the user asks for
87
89
  more than one plan, call `submit_plan` once for each finished plan so each can
88
- be accepted into its own GitHub issue. After a plan is accepted the user may
89
- keep commenting to refine it; treat a later turn as a fresh **Plan** phase and
90
- call `submit_plan` again with the revised fields.
90
+ be accepted into its own GitHub issue. Give each plan its own distinct `title`
91
+ so sibling plans don't collide. After a plan is accepted the user may keep
92
+ commenting to refine it; treat a later turn as a fresh **Plan** phase and call
93
+ `submit_plan` again with the revised fields.
91
94
 
92
95
  ## Always
93
96