cubefarm 0.3.7 → 0.3.8

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.
@@ -17,6 +17,7 @@ var DEMO = process.argv.includes("--demo") || process.env.SWARM_DEMO === "1" ||
17
17
  var STATE_FILE = path.join(HOME_DIR, DEMO ? "demo-state.json" : "state.json");
18
18
  var SYNC_INTERVAL_MS = 45e3;
19
19
  var SCHEDULER_INTERVAL_MS = 8e3;
20
+ var DESK_SWEEP_INTERVAL_MS = 30 * 6e4;
20
21
  var LOG_BUFFER = 600;
21
22
  function defaultProjectsDir(appDir, home = os.homedir()) {
22
23
  if (!appDir.split(/[\\/]/).includes("node_modules")) return path.resolve(appDir, "..");
@@ -162,6 +163,9 @@ async function issueState(fullName, number) {
162
163
  return null;
163
164
  }
164
165
  }
166
+ async function closeIssue(fullName, number, comment) {
167
+ await gh(["issue", "close", String(number), "-R", fullName, "--reason", "completed", "--comment", comment]);
168
+ }
165
169
  async function editIssue(fullName, number, edit) {
166
170
  const args = ["issue", "edit", String(number), "-R", fullName];
167
171
  if (edit.body !== void 0) args.push("--body-file", "-");
@@ -296,6 +300,33 @@ function npmInstall(dir) {
296
300
  installs.set(dir, next);
297
301
  return next;
298
302
  }
303
+ function porcelainPaths(porcelain) {
304
+ return porcelain.split(/\r?\n/).map((line) => /^([ MTADRCU?!]{1,2}) (.+)$/.exec(line)).filter((m) => m !== null).map(([, xy, file]) => /[RC]/.test(xy) ? /^(?:"(?:[^"\\]|\\.)*"|.*?) -> (.+)$/.exec(file)?.[1] ?? file : file);
305
+ }
306
+ function overwrittenPaths(stderr) {
307
+ const lines = stderr.split(/\r?\n/);
308
+ const start = lines.findIndex((l) => /would be overwritten by/.test(l));
309
+ if (start < 0) return [];
310
+ const files = [];
311
+ for (const line of lines.slice(start + 1)) {
312
+ if (!line.startsWith(" ")) break;
313
+ files.push(line.trim());
314
+ }
315
+ return files.filter(Boolean);
316
+ }
317
+ function fileList(files, max = 3, maxChars = 120) {
318
+ const shown = [];
319
+ let length = 0;
320
+ for (const file of files.slice(0, max)) {
321
+ const name = file.length > maxChars ? `\u2026${file.slice(-(maxChars - 1))}` : file;
322
+ const add = name.length + (shown.length ? 2 : 0);
323
+ if (shown.length && length + add > maxChars) break;
324
+ shown.push(name);
325
+ length += add;
326
+ }
327
+ const more = files.length - shown.length;
328
+ return `${shown.join(", ")}${more ? ` +${more} more` : ""}`;
329
+ }
299
330
  async function syncMain(fullName, defaultBranch, opts) {
300
331
  const dir = mainDir(fullName);
301
332
  const result = await withRepoLock(fullName, async () => {
@@ -313,12 +344,17 @@ async function syncMain(fullName, defaultBranch, opts) {
313
344
  if (behind === 0) return stays("in sync");
314
345
  if (Number(await g(["rev-list", "--count", `${target}..HEAD`])) > 0) return stays(`diverged: local commits, and ${commits} to pull`);
315
346
  if (!opts.touch) return { status: `update ready (${commits})`, behind, updatable: true };
316
- if (await g(["status", "--porcelain", "--untracked-files=no"])) return { status: `${behind} behind: local changes`, behind, updatable: true };
347
+ const dirty = await g(["status", "--porcelain", "--untracked-files=no"]);
348
+ if (dirty) {
349
+ const files = porcelainPaths(dirty);
350
+ return { status: `${behind} behind: local changes${files.length ? ` in ${fileList(files)}` : ""}`, behind, updatable: true };
351
+ }
317
352
  const before = await g(["rev-parse", "HEAD"]);
318
353
  try {
319
354
  await g(["merge", "--ff-only", target], 12e4);
320
- } catch {
321
- return { status: `${behind} behind: local files are in the way`, behind, updatable: true };
355
+ } catch (err) {
356
+ const files = overwrittenPaths(err.stderr ?? "");
357
+ return { status: `${behind} behind: local files are in the way${files.length ? `: ${fileList(files)}` : ""}`, behind, updatable: true };
322
358
  }
323
359
  const changed = (await g(["diff", "--name-only", before, "HEAD"])).split(/\r?\n/);
324
360
  const install = changed.some((f) => f === "package.json" || f === "package-lock.json") && await exists(path2.join(dir, "package.json"));
@@ -463,9 +499,8 @@ function prepareDesk(fullName, base, agentSlug, branch) {
463
499
  return wt;
464
500
  });
465
501
  }
466
- function removeDesk(fullName, agentSlug) {
502
+ function removeDesk(fullName, agentSlug, main = mainDir(fullName)) {
467
503
  return withRepoLock(fullName, async () => {
468
- const main = mainDir(fullName);
469
504
  const wt = deskDir(fullName, agentSlug);
470
505
  if (!await exists(wt)) return;
471
506
  await git(["worktree", "remove", "--force", wt], { cwd: main }).catch(() => void 0);
@@ -473,6 +508,121 @@ function removeDesk(fullName, agentSlug) {
473
508
  await git(["worktree", "prune"], { cwd: main }).catch(() => void 0);
474
509
  });
475
510
  }
511
+ var OLD_HOMES = [".office-swarm"];
512
+ var OFFICE_BRANCH = /^(swarm\/issue-|qa\/pr-)/;
513
+ function parseWorktrees(porcelain) {
514
+ const out = [];
515
+ for (const line of porcelain.split(/\r?\n/)) {
516
+ if (line.startsWith("worktree ")) out.push({ path: path2.resolve(line.slice(9)), branch: null, locked: false });
517
+ else if (!out.length) continue;
518
+ else if (line.startsWith("branch ")) out[out.length - 1].branch = line.slice(7).replace(/^refs\/heads\//, "");
519
+ else if (line === "locked" || line.startsWith("locked ")) out[out.length - 1].locked = true;
520
+ }
521
+ return out;
522
+ }
523
+ var samePath = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
524
+ function isOfficeDesk(p, fullName, root) {
525
+ const desks = path2.dirname(p);
526
+ const repo = path2.dirname(desks);
527
+ const workspaces = path2.dirname(repo);
528
+ if (!samePath(path2.basename(desks), "desks") || !samePath(path2.basename(repo), fullName.replace("/", "__"))) return false;
529
+ if (samePath(workspaces, path2.resolve(root))) return true;
530
+ return path2.basename(workspaces) === "workspaces" && OLD_HOMES.some((h) => samePath(path2.basename(path2.dirname(workspaces)), h));
531
+ }
532
+ function planSweep(input, keep) {
533
+ const deskRoot = path2.join(path2.resolve(input.root), input.fullName.replace("/", "__"), "desks");
534
+ const kept = (p) => keep.desks.some((slug) => samePath(p, path2.join(deskRoot, slug)));
535
+ const worktrees = input.worktrees.slice(1).filter((w) => !w.locked && isOfficeDesk(w.path, input.fullName, input.root) && !kept(w.path));
536
+ const stays = input.worktrees.filter((w) => !worktrees.includes(w));
537
+ const folders = input.folders.filter((name) => {
538
+ const p = path2.join(deskRoot, name);
539
+ return !kept(p) && !input.worktrees.some((w) => samePath(w.path, p));
540
+ });
541
+ const branches = input.branches.filter((b) => OFFICE_BRANCH.test(b) && !keep.branches.includes(b) && !stays.some((w) => w.branch === b));
542
+ return { worktrees, folders, branches };
543
+ }
544
+ async function inUse(dir) {
545
+ const probe = `${dir}.sweep`;
546
+ try {
547
+ await fs2.rename(dir, probe);
548
+ } catch (err) {
549
+ return err.code !== "ENOENT";
550
+ }
551
+ await fs2.rename(probe, dir);
552
+ return false;
553
+ }
554
+ var stamp = (d) => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
555
+ async function saveLeftovers(fullName, desk) {
556
+ const g = (args) => git(args, { cwd: desk, timeoutMs: 6e4 });
557
+ if (!await g(["rev-parse", "--verify", "-q", "HEAD"]).catch(() => "")) return null;
558
+ const dirty = await g(["status", "--porcelain", "--untracked-files=no"]);
559
+ const commits = Number(await g(["rev-list", "--count", "HEAD", "--not", "--remotes"]));
560
+ if (!dirty && !commits) return null;
561
+ const dir = path2.join(HOME_DIR, "leftovers", fullName.replace("/", "__"));
562
+ await fs2.mkdir(dir, { recursive: true });
563
+ const tmp = await fs2.mkdtemp(path2.join(dir, ".tmp-"));
564
+ try {
565
+ if (commits) await g(["format-patch", "-q", "-o", tmp, "HEAD", "--not", "--remotes"]);
566
+ if (dirty) await g(["diff", "HEAD", "--binary", `--output=${path2.join(tmp, "zz-uncommitted.patch")}`]);
567
+ const parts = (await fs2.readdir(tmp)).sort();
568
+ const base = `${path2.basename(desk)}-${stamp(/* @__PURE__ */ new Date())}`;
569
+ let file = path2.join(dir, `${base}.patch`);
570
+ for (let n = 2; await exists(file); n++) file = path2.join(dir, `${base}-${n}.patch`);
571
+ await fs2.writeFile(file, Buffer.concat(await Promise.all(parts.map((p) => fs2.readFile(path2.join(tmp, p))))));
572
+ return file;
573
+ } finally {
574
+ await removeDir(tmp).catch(() => void 0);
575
+ }
576
+ }
577
+ function sweepDesks(fullName, keep) {
578
+ return withRepoLock(fullName, async () => {
579
+ const result = { desks: 0, folders: 0, branches: 0, patches: [], skipped: [] };
580
+ const main = mainDir(fullName);
581
+ if (!await exists(path2.join(main, ".git"))) return result;
582
+ const g = (args) => git(args, { cwd: main, timeoutMs: 6e4 });
583
+ await g(["worktree", "prune"]);
584
+ const real = (p) => fs2.realpath(p).catch(() => p);
585
+ const worktrees = await Promise.all(parseWorktrees(await g(["worktree", "list", "--porcelain"])).map(async (w) => ({ ...w, path: await real(w.path) })));
586
+ const refs = await g(["for-each-ref", "--format=%(refname)", "refs/heads/swarm", "refs/heads/qa"]);
587
+ const branches = refs.split(/\r?\n/).filter(Boolean).map((r) => r.replace(/^refs\/heads\//, ""));
588
+ const root = await real(WORKSPACE_ROOT);
589
+ const deskRoot = path2.join(root, fullName.replace("/", "__"), "desks");
590
+ const folders = (await fs2.readdir(deskRoot, { withFileTypes: true }).catch(() => [])).filter((e) => e.isDirectory()).map((e) => e.name);
591
+ const plan = planSweep({ fullName, root, worktrees, branches, folders }, keep);
592
+ const held = /* @__PURE__ */ new Set();
593
+ for (const wt of plan.worktrees) {
594
+ try {
595
+ if (await inUse(wt.path)) throw new Error("in use");
596
+ const patch = await saveLeftovers(fullName, wt.path);
597
+ if (patch) result.patches.push(patch);
598
+ await g(["worktree", "remove", "--force", wt.path]).catch(() => void 0);
599
+ await removeDir(wt.path);
600
+ result.desks++;
601
+ } catch {
602
+ result.skipped.push(wt.path);
603
+ if (wt.branch) held.add(wt.branch);
604
+ }
605
+ }
606
+ await g(["worktree", "prune"]).catch(() => void 0);
607
+ for (const name of plan.folders) {
608
+ const dir = path2.join(deskRoot, name);
609
+ try {
610
+ if (await inUse(dir)) throw new Error("in use");
611
+ await removeDir(dir);
612
+ result.folders++;
613
+ } catch {
614
+ result.skipped.push(dir);
615
+ }
616
+ }
617
+ const doomed = plan.branches.filter((b) => !held.has(b));
618
+ for (let i = 0; i < doomed.length; i += 40) await g(["branch", "-D", ...doomed.slice(i, i + 40)]).catch(() => void 0);
619
+ if (doomed.length) {
620
+ const left = new Set((await g(["for-each-ref", "--format=%(refname)", "refs/heads/swarm", "refs/heads/qa"])).split(/\r?\n/));
621
+ result.branches = doomed.filter((b) => !left.has(`refs/heads/${b}`)).length;
622
+ }
623
+ return result;
624
+ });
625
+ }
476
626
  var LAUNCHERS = ["node.exe", "cmd.exe", "bash.exe", "sh.exe", "conhost.exe", "python.exe", "npm.exe", "npx.exe", "bun.exe", "deno.exe"];
477
627
  var pathForms = (p) => [.../* @__PURE__ */ new Set([p, p.replaceAll("\\", "/"), p.replaceAll("\\", "\\\\")])];
478
628
  function leftoversInDesk(listing, desk, keep) {
@@ -2277,6 +2427,7 @@ var realBackend = {
2277
2427
  createIssue,
2278
2428
  issueState,
2279
2429
  editIssue,
2430
+ closeIssue,
2280
2431
  mergePull,
2281
2432
  updateBranch,
2282
2433
  closePull,
@@ -2296,6 +2447,7 @@ var realBackend = {
2296
2447
  deskDir,
2297
2448
  prepareDesk,
2298
2449
  removeDesk,
2450
+ sweepDesks,
2299
2451
  releaseDesk: (fullName, agentSlug, port) => releaseDesk(fullName, agentSlug, port, officeProcesses()),
2300
2452
  startSession: (opts, cb) => opts.terminal ? startCliSession(opts, cb) : startSession(opts, cb),
2301
2453
  terminals: terminalsAvailable,
@@ -2697,8 +2849,16 @@ function createDemoBackend() {
2697
2849
  mergedSinceSync.set(fullName, (mergedSinceSync.get(fullName) ?? 0) + 1);
2698
2850
  pr.state = "MERGED";
2699
2851
  pr.mergedAt = now();
2700
- for (const n of pr.closesIssues) closedIssues.add(`${fullName}#${n}`);
2701
- r.issues = r.issues.filter((i) => !pr.closesIssues.includes(i.number));
2852
+ setTimeout(() => {
2853
+ for (const n of pr.closesIssues) closedIssues.add(`${fullName}#${n}`);
2854
+ r.issues = r.issues.filter((i) => !pr.closesIssues.includes(i.number));
2855
+ }, 2e4).unref();
2856
+ },
2857
+ closeIssue: async (fullName, number) => {
2858
+ const r = repos.get(fullName);
2859
+ if (!r?.issues.some((i) => i.number === number)) throw new Error(`Issue #${number} is not open`);
2860
+ closedIssues.add(`${fullName}#${number}`);
2861
+ r.issues = r.issues.filter((i) => i.number !== number);
2702
2862
  },
2703
2863
  updateBranch: async (fullName, number) => {
2704
2864
  const pr = repos.get(fullName)?.pulls.find((p) => p.number === number);
@@ -2753,6 +2913,7 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
2753
2913
  return dir;
2754
2914
  },
2755
2915
  removeDesk: async () => void 0,
2916
+ sweepDesks: async () => ({ desks: 0, folders: 0, branches: 0, patches: [], skipped: [] }),
2756
2917
  releaseDesk: async () => void 0,
2757
2918
  startSession: (opts, cb) => {
2758
2919
  if (++sessionsStarted === USAGE_WARNING_AT) fakeUsageWarning(cb);
@@ -3517,6 +3678,25 @@ var HttpError = class extends Error {
3517
3678
  status;
3518
3679
  };
3519
3680
 
3681
+ // server/issueClaims.ts
3682
+ var RECENT_MERGE_MS = 6 * 60 * 6e4;
3683
+ function branchIssue(headRefName) {
3684
+ const m = /^swarm\/issue-(\d+)-/.exec(headRefName);
3685
+ return m ? Number(m[1]) : null;
3686
+ }
3687
+ function issuesResolvedBy(pr) {
3688
+ const n = branchIssue(pr.headRefName);
3689
+ return [...new Set(n == null ? pr.closesIssues : [...pr.closesIssues, n])];
3690
+ }
3691
+ function issueTaken(n, agents, pulls, now2) {
3692
+ if (agents.some((a) => a.role === "dev" && a.task !== "qa" && a.issueNumber === n && a.status !== "idle" && a.status !== "error")) return true;
3693
+ return pulls.some((p) => {
3694
+ if (p.state === "CLOSED") return false;
3695
+ if (p.state === "MERGED" && (!p.mergedAt || now2 - Date.parse(p.mergedAt) > RECENT_MERGE_MS)) return false;
3696
+ return issuesResolvedBy(p).includes(n);
3697
+ });
3698
+ }
3699
+
3520
3700
  // server/mergeGate.ts
3521
3701
  var MAX_MERGE_FIXES = 3;
3522
3702
  var CHECKS_ALERT_MS = 30 * 6e4;
@@ -3724,6 +3904,7 @@ var Previews = class {
3724
3904
  }
3725
3905
  /** The floor is leaving the building: stop its preview and remove the preview worktree. */
3726
3906
  async remove(f) {
3907
+ const main = this.backend.mainDir(f.fullName);
3727
3908
  const r = this.runs.get(f.id);
3728
3909
  if (r) {
3729
3910
  r.gen++;
@@ -3732,7 +3913,7 @@ var Previews = class {
3732
3913
  this.runs.delete(f.id);
3733
3914
  this.hasDefault.delete(f.id);
3734
3915
  clearTimeout(this.emitTimers.get(f.id));
3735
- await this.backend.removeDesk(f.fullName, PREVIEW_SLUG).catch(() => void 0);
3916
+ await this.backend.removeDesk(f.fullName, PREVIEW_SLUG, main).catch(() => void 0);
3736
3917
  }
3737
3918
  /** Server shutdown: stop every running preview. */
3738
3919
  async stopAll(floors) {
@@ -3777,6 +3958,95 @@ var Previews = class {
3777
3958
  }
3778
3959
  };
3779
3960
 
3961
+ // server/prompts.ts
3962
+ var PLACEHOLDERS = {
3963
+ worktree: "<your worktree>",
3964
+ issue: "<issue>",
3965
+ pr: "<PR>",
3966
+ prTitle: "<PR title>",
3967
+ prUrl: "<PR URL>",
3968
+ branch: "<branch>"
3969
+ };
3970
+ var devBranch = (issue2, slug) => `swarm/issue-${issue2}-${slug}`;
3971
+ var qaBranch = (pr, slug) => `qa/pr-${pr}-${slug}`;
3972
+ function devSystemPrompt({ agent: a, repo, port, cwd, branch, linked, fixing }) {
3973
+ const push = fixing ? `git push origin HEAD:${fixing.headRef}` : `git push -u origin ${branch}`;
3974
+ const links = linked.map((r) => `- ${r.fullName}: read-only reference clone at ${r.dir}`);
3975
+ return [
3976
+ `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a software engineer"} on an autonomous agent team ("cubefarm"). Several teammates work in parallel on other issues of the same repository, each in their own git worktree. Nobody is watching live to answer questions, so make sensible decisions yourself and record assumptions in the PR description. The manager may occasionally send you messages; follow their instructions.`,
3977
+ `Every pull request is reviewed and tested by a QA teammate. ${repo.autoMerge ? "Once they sign off and GitHub's checks pass, the office merges it by itself" : "Once they sign off, the manager merges it"}. If they find problems, or checks fail, or it conflicts with the default branch, you will get the details; fix them on the same branch.`,
3978
+ a.brief ? `
3979
+ Your job description:
3980
+ ${a.brief}` : "",
3981
+ "",
3982
+ `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
3983
+ repo.summary ? `Project: ${repo.summary}` : "",
3984
+ repo.mission ? `What the team is building (the manager's brief): ${repo.mission}` : "",
3985
+ `Your worktree: ${cwd}`,
3986
+ fixing ? `You are fixing pull request #${fixing.pr}. Its code is checked out on local branch ${branch}; push fixes with: ${push}. Do not open a new pull request.` : `Your branch: ${branch} (already checked out, created from origin/${repo.defaultBranch})`,
3987
+ links.length ? `Related repositories you may read for context (do not modify them):
3988
+ ${links.join("\n")}` : "",
3989
+ "",
3990
+ "Workflow:",
3991
+ "1. Read the issue and explore the relevant code before changing anything.",
3992
+ "2. Implement the change with focused commits and clear messages.",
3993
+ "3. Run the project's existing tests, linters and build (if any) and fix what you broke. Install dependencies first if needed.",
3994
+ repo.browserTesting ? `4. If the project has a web UI, start its dev server in the background on port ${port} (reserved for you, so you don't collide with teammates), then check your change with the Playwright browser tools (mcp__playwright__browser_navigate, browser_snapshot, browser_click, browser_take_screenshot). Stop the dev server when you're done.` : "4. Verify the behaviour you changed as directly as you can.",
3995
+ `5. Push: ${push}`,
3996
+ fixing ? "6. Reply with a short summary of what you fixed." : `6. Open a pull request with the GitHub CLI: gh pr create --base ${repo.defaultBranch} --head ${branch} --title "<concise title>" --body "<what changed, how you verified it, assumptions>". The body must contain "Closes #<issue number>".`,
3997
+ fixing ? "" : "7. End your final message with the pull request URL on its own line.",
3998
+ "",
3999
+ "Rules: never push to the default branch, never force-push, never merge pull requests yourself (the office merges them once QA and the checks pass), and never edit files outside your worktree. If you cannot finish, open a draft PR (gh pr create --draft) explaining what is left and why."
4000
+ ].filter((l) => l !== "").join("\n");
4001
+ }
4002
+ function qaSystemPrompt({ agent: a, repo, port, cwd, branch, pr }) {
4003
+ return [
4004
+ `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a QA engineer"} on an autonomous agent team ("cubefarm"). Developers open pull requests; you review and independently verify each one before it is merged. Your sign-off is the review: ${repo.autoMerge ? "on this floor a PR you pass merges by itself as soon as GitHub's checks are green, so nobody else reads the code after you. " : ""}Be thorough and skeptical, but fair: fail a PR only for real problems (broken behaviour, failing tests or build, the issue's requirements not met, obvious regressions), not for style preferences.`,
4005
+ ...a.brief ? ["", `Your job description:
4006
+ ${a.brief}`] : [],
4007
+ ...a.role === "dev" ? ["", "You're a developer covering for the QA lab while its testers are busy. You didn't write this pull request: test it as an independent QA engineer would."] : [],
4008
+ "",
4009
+ `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
4010
+ ...repo.summary ? [`Project: ${repo.summary}`] : [],
4011
+ ...repo.mission ? [`What the team is building (the manager's brief): ${repo.mission}`] : [],
4012
+ ...repo.qaBrief ? [`What to check on this project (from the CEO):
4013
+ ${repo.qaBrief}`] : [],
4014
+ `Pull request #${pr.number} "${pr.title}" from branch ${pr.headRefName}: ${pr.url}`,
4015
+ `Your worktree: ${cwd}. It has the pull request's code checked out on local branch ${branch}.`,
4016
+ "",
4017
+ "How to test:",
4018
+ "1. Read the PR description and the linked issue, and work out the acceptance criteria.",
4019
+ `2. Review the code as a careful reviewer would: git diff origin/${repo.defaultBranch}...HEAD. Look for bugs, unhandled errors and edge cases, security problems, leftover debug code, and new logic without tests.`,
4020
+ "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
4021
+ repo.browserTesting ? `4. If the project has a UI, start it in the background on port ${port} (reserved for you) and exercise the change in a real browser with the Playwright tools: navigate, click, type, resize to a phone size, try edge cases, and check the console for errors. Take a screenshot with browser_take_screenshot (no filename) of every important state: the screenshots are attached to the PR as evidence. Stop the server afterwards.` : "4. Exercise the changed behaviour directly (run the program, call the API, write a quick script).",
4022
+ "5. You may write throwaway scripts to probe behaviour, but do not commit them.",
4023
+ "",
4024
+ "Rules: do not modify the code under test, do not commit, push, comment on, review or merge anything on GitHub. The office posts your report on the pull request. Finish with the structured QA report: verdict, summary, the checks you performed, the commands you ran and one caption per screenshot."
4025
+ ].join("\n");
4026
+ }
4027
+ var OFFICE = "Office instructions";
4028
+ var MARK = "\0job description\0";
4029
+ function promptParts(build, brief) {
4030
+ if (!brief) return [{ label: OFFICE, text: build(""), editable: false }];
4031
+ const [before, after] = build(MARK).split(MARK);
4032
+ return [
4033
+ { label: OFFICE, text: before, editable: false },
4034
+ { label: "Job description", text: brief, editable: true },
4035
+ { label: OFFICE, text: after, editable: false }
4036
+ ].filter((p) => p.text !== "");
4037
+ }
4038
+ var view = (kind, parts) => ({ kind, text: parts.map((p) => p.text).join(""), parts });
4039
+ function devPromptPreview({ slug, ...i }) {
4040
+ const branch = devBranch(PLACEHOLDERS.issue, slug);
4041
+ return view("dev", promptParts((brief) => devSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch }), i.agent.brief));
4042
+ }
4043
+ function qaPromptPreview({ slug, ...i }) {
4044
+ const pr = { number: PLACEHOLDERS.pr, title: PLACEHOLDERS.prTitle, headRefName: PLACEHOLDERS.branch, url: PLACEHOLDERS.prUrl };
4045
+ const branch = qaBranch(PLACEHOLDERS.pr, slug);
4046
+ return view("qa", promptParts((brief) => qaSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch, pr }), i.agent.brief));
4047
+ }
4048
+ var ceoPromptPreview = (text) => view("ceo", [{ label: OFFICE, text, editable: false }]);
4049
+
3780
4050
  // server/pacing.ts
3781
4051
  var PACING_MS = 60 * 6e4;
3782
4052
  var DEFAULT_PACING_SESSIONS = 3;
@@ -4363,6 +4633,7 @@ var Swarm = class {
4363
4633
  setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.syncRepo(r.id), i * 1500)), SYNC_INTERVAL_MS);
4364
4634
  setInterval(() => this.schedule(), SCHEDULER_INTERVAL_MS);
4365
4635
  setInterval(() => void this.saveTerminals(), TERMINAL_SAVE_MS);
4636
+ setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.sweepFloor(r.id), i * 1500)), DESK_SWEEP_INTERVAL_MS);
4366
4637
  void this.backend.detectClis().then((clis) => {
4367
4638
  this.clis = clis;
4368
4639
  this.broadcast({ type: "clis", clis });
@@ -4805,8 +5076,29 @@ var Swarm = class {
4805
5076
  return this.mergeNote(rec, `merge blocked: ${error}`);
4806
5077
  }
4807
5078
  this.toast("success", `\u{1F500} Merged PR #${pr.number} into ${repo.defaultBranch}: ${pr.title}`);
5079
+ await this.closeResolvedIssues(repo, pr.number, pr);
4808
5080
  return true;
4809
5081
  }
5082
+ /**
5083
+ * After the office merges a PR: close the issues it resolves now rather than waiting for GitHub's "Closes #N" (which
5084
+ * lags, and never comes when the link wasn't recognised), so the scheduler doesn't hand them out again. Never throws.
5085
+ */
5086
+ async closeResolvedIssues(repo, prNumber, pr) {
5087
+ const numbers = issuesResolvedBy(pr);
5088
+ const rt = this.repoRt.get(repo.id);
5089
+ if (rt && rt.issues.some((i) => numbers.includes(i.number))) {
5090
+ rt.issues = rt.issues.filter((i) => !numbers.includes(i.number));
5091
+ this.emitRepo(repo);
5092
+ }
5093
+ for (const n of numbers) {
5094
+ try {
5095
+ if (await this.backend.issueState(repo.fullName, n) !== "OPEN") continue;
5096
+ await this.backend.closeIssue(repo.fullName, n, `Shipped in #${prNumber}`);
5097
+ } catch (err) {
5098
+ console.warn(`could not close ${repo.fullName}#${n} after merging #${prNumber}`, err);
5099
+ }
5100
+ }
5101
+ }
4810
5102
  /** Show where auto-merge stands on a PR's card. Doesn't count as a change to the record. */
4811
5103
  mergeNote(rec, text) {
4812
5104
  if (rec.mergeNote !== text) {
@@ -4849,6 +5141,31 @@ var Swarm = class {
4849
5141
  async syncFolderNow(repoId2) {
4850
5142
  return { folderSync: await this.syncFolder(this.repo(repoId2)) };
4851
5143
  }
5144
+ /** Remove the floor's desks, desk folders and swarm/qa branches nobody uses any more; say so when something went. */
5145
+ async sweepFloor(repoId2) {
5146
+ const repo = this.state.repos.find((r) => r.id === repoId2);
5147
+ const rt = this.repoRt.get(repoId2);
5148
+ if (!repo || !rt || rt.cloneStatus !== "ready") return;
5149
+ try {
5150
+ const pulls = rt.lastSync ? rt.pulls : await this.backend.listPulls(repo.fullName);
5151
+ const agents = this.state.agents.filter((a) => a.repoId === repo.id);
5152
+ const keep = {
5153
+ desks: [...agents.map((a) => this.agentSlug(a)), PREVIEW_SLUG],
5154
+ branches: [...agents.flatMap((a) => a.branch ? [a.branch] : []), ...pulls.filter((p) => p.state === "OPEN").map((p) => p.headRefName)]
5155
+ };
5156
+ const r = await this.backend.sweepDesks(repo.fullName, keep);
5157
+ const desks = r.desks + r.folders;
5158
+ const saved = r.patches.length ? ` (${r.patches.length} patch${r.patches.length === 1 ? "" : "es"} saved to ${path13.dirname(r.patches[0])})` : "";
5159
+ console.log(`desk sweep ${repo.fullName}: ${desks} desks, ${r.branches} branches removed${saved}${r.skipped.length ? `, ${r.skipped.length} still in use` : ""}`);
5160
+ if (!desks && !r.branches) return;
5161
+ const removed = [desks && `${desks} old desk${desks === 1 ? "" : "s"}`, r.branches && `${r.branches} finished branch${r.branches === 1 ? "" : "es"}`].filter(Boolean).join(" and ");
5162
+ const text = `\u{1F9F9} ${repo.fullName.split("/")[1]}: removed ${removed}${saved}`;
5163
+ this.toast("info", text);
5164
+ this.postMessage("office", text);
5165
+ } catch (err) {
5166
+ console.warn(`desk sweep ${repo.fullName} failed: ${oneLine(err)}`);
5167
+ }
5168
+ }
4852
5169
  // ---------- the floor's app (preview monitor) ----------
4853
5170
  /** Run the floor's app from its preview worktree: the default branch, or an open PR. Replaces what it is running now. */
4854
5171
  async startPreview(id, pr) {
@@ -4879,6 +5196,7 @@ var Swarm = class {
4879
5196
  await this.backend.ensureClone(repo.fullName);
4880
5197
  rt.cloneStatus = "ready";
4881
5198
  void this.previews.refreshDefault(repo);
5199
+ void this.sweepFloor(id);
4882
5200
  } catch (err) {
4883
5201
  rt.cloneStatus = "error";
4884
5202
  rt.cloneError = err.message;
@@ -4954,8 +5272,10 @@ var Swarm = class {
4954
5272
  }
4955
5273
  async mergePull(repoId2, number, method = "squash") {
4956
5274
  const repo = this.repo(repoId2);
5275
+ const pr = this.repoRt.get(repo.id)?.pulls.find((p) => p.number === number) ?? await this.backend.prDetails(repo.fullName, number);
4957
5276
  await this.backend.mergePull(repo.fullName, number, method);
4958
5277
  this.toast("success", `Merged PR #${number} into ${repo.defaultBranch}`);
5278
+ await this.closeResolvedIssues(repo, number, pr);
4959
5279
  await this.syncRepo(repo.id);
4960
5280
  setTimeout(() => this.schedule(), 200);
4961
5281
  }
@@ -5072,7 +5392,8 @@ var Swarm = class {
5072
5392
  const repo = this.state.repos.find((r) => r.id === a.repoId);
5073
5393
  if (repo) {
5074
5394
  const slug = this.agentSlug(a);
5075
- void this.backend.releaseDesk(repo.fullName, slug, this.port(a)).then(() => this.backend.removeDesk(repo.fullName, slug)).catch(() => void 0);
5395
+ const main = this.backend.mainDir(repo.fullName);
5396
+ void this.backend.releaseDesk(repo.fullName, slug, this.port(a)).then(() => this.backend.removeDesk(repo.fullName, slug, main)).catch(() => void 0).then(() => this.sweepFloor(repo.id));
5076
5397
  }
5077
5398
  this.state.agents = this.state.agents.filter((x) => x.id !== id);
5078
5399
  this.agentRt.delete(id);
@@ -5116,9 +5437,12 @@ var Swarm = class {
5116
5437
  this.save();
5117
5438
  }
5118
5439
  issueTaken(repo, n) {
5119
- if (this.state.agents.some((a) => a.repoId === repo.id && a.role === "dev" && a.task !== "qa" && a.issueNumber === n && a.status !== "idle" && a.status !== "error")) return true;
5120
- const pulls = this.repoRt.get(repo.id)?.pulls ?? [];
5121
- return pulls.some((p) => p.state === "OPEN" && (p.closesIssues.includes(n) || p.headRefName.startsWith(`swarm/issue-${n}-`)));
5440
+ return issueTaken(
5441
+ n,
5442
+ this.state.agents.filter((a) => a.repoId === repo.id),
5443
+ this.repoRt.get(repo.id)?.pulls ?? [],
5444
+ Date.now()
5445
+ );
5122
5446
  }
5123
5447
  ensureSlot() {
5124
5448
  if (this.slotsFull()) {
@@ -5197,34 +5521,18 @@ var Swarm = class {
5197
5521
  t.attach(ws);
5198
5522
  }
5199
5523
  buildSystemAppend(a, repo, cwd, branch, fixing) {
5200
- const linked = this.linkedRepos(repo).map((r) => `- ${r.fullName}: read-only reference clone at ${this.backend.mainDir(r.fullName)}`);
5201
- const push = fixing ? `git push origin HEAD:${fixing.headRef}` : `git push -u origin ${branch}`;
5202
- return [
5203
- `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a software engineer"} on an autonomous agent team ("cubefarm"). Several teammates work in parallel on other issues of the same repository, each in their own git worktree. Nobody is watching live to answer questions, so make sensible decisions yourself and record assumptions in the PR description. The manager may occasionally send you messages; follow their instructions.`,
5204
- `Every pull request is reviewed and tested by a QA teammate. ${repo.autoMerge ? "Once they sign off and GitHub's checks pass, the office merges it by itself" : "Once they sign off, the manager merges it"}. If they find problems, or checks fail, or it conflicts with the default branch, you will get the details; fix them on the same branch.`,
5205
- a.brief ? `
5206
- Your job description:
5207
- ${a.brief}` : "",
5208
- "",
5209
- `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
5210
- repo.summary ? `Project: ${repo.summary}` : "",
5211
- repo.mission ? `What the team is building (the manager's brief): ${repo.mission}` : "",
5212
- `Your worktree: ${cwd}`,
5213
- fixing ? `You are fixing pull request #${fixing.pr}. Its code is checked out on local branch ${branch}; push fixes with: ${push}. Do not open a new pull request.` : `Your branch: ${branch} (already checked out, created from origin/${repo.defaultBranch})`,
5214
- linked.length ? `Related repositories you may read for context (do not modify them):
5215
- ${linked.join("\n")}` : "",
5216
- "",
5217
- "Workflow:",
5218
- "1. Read the issue and explore the relevant code before changing anything.",
5219
- "2. Implement the change with focused commits and clear messages.",
5220
- "3. Run the project's existing tests, linters and build (if any) and fix what you broke. Install dependencies first if needed.",
5221
- repo.browserTesting ? `4. If the project has a web UI, start its dev server in the background on port ${this.port(a)} (reserved for you, so you don't collide with teammates), then check your change with the Playwright browser tools (mcp__playwright__browser_navigate, browser_snapshot, browser_click, browser_take_screenshot). Stop the dev server when you're done.` : "4. Verify the behaviour you changed as directly as you can.",
5222
- `5. Push: ${push}`,
5223
- fixing ? "6. Reply with a short summary of what you fixed." : `6. Open a pull request with the GitHub CLI: gh pr create --base ${repo.defaultBranch} --head ${branch} --title "<concise title>" --body "<what changed, how you verified it, assumptions>". The body must contain "Closes #<issue number>".`,
5224
- fixing ? "" : "7. End your final message with the pull request URL on its own line.",
5225
- "",
5226
- "Rules: never push to the default branch, never force-push, never merge pull requests yourself (the office merges them once QA and the checks pass), and never edit files outside your worktree. If you cannot finish, open a draft PR (gh pr create --draft) explaining what is left and why."
5227
- ].filter((l) => l !== "").join("\n");
5524
+ return devSystemPrompt({ agent: a, repo, port: this.port(a), cwd, branch, linked: this.linkedDirs(repo), fixing });
5525
+ }
5526
+ linkedDirs(repo) {
5527
+ return this.linkedRepos(repo).map((r) => ({ fullName: r.fullName, dir: this.backend.mainDir(r.fullName) }));
5528
+ }
5529
+ /** What an agent is told on a task, previewed with placeholders for the task's details. */
5530
+ agentPrompt(id) {
5531
+ const a = this.agent(id);
5532
+ if (a.role === "ceo") return ceoPromptPreview(ceoSystemPrompt(this.ceoPromptInput(a)));
5533
+ const repo = this.repo(a.repoId);
5534
+ const base = { agent: a, repo, port: this.port(a), slug: slugify(a.name) };
5535
+ return a.role === "qa" ? qaPromptPreview(base) : devPromptPreview({ ...base, linked: this.linkedDirs(repo) });
5228
5536
  }
5229
5537
  beginTask(a, patch, banner, preparing) {
5230
5538
  const rt = this.agentRt.get(a.id);
@@ -5267,7 +5575,7 @@ ${linked.join("\n")}` : "",
5267
5575
  }
5268
5576
  }
5269
5577
  async runTask(a, repo, issue2, note) {
5270
- const branch = `swarm/issue-${issue2.number}-${slugify(a.name)}`;
5578
+ const branch = devBranch(issue2.number, slugify(a.name));
5271
5579
  this.beginTask(
5272
5580
  a,
5273
5581
  { task: "issue", issueNumber: issue2.number, issueTitle: issue2.title, branch, prNumber: null, prUrl: null, sessionId: null },
@@ -5469,32 +5777,10 @@ Note from the manager: ${note}` : ""
5469
5777
  this.toast("info", `PR #${prNumber} is queued for QA`);
5470
5778
  }
5471
5779
  buildQaSystemAppend(a, repo, cwd, branch, pr) {
5472
- return [
5473
- `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a QA engineer"} on an autonomous agent team ("cubefarm"). Developers open pull requests; you review and independently verify each one before it is merged. Your sign-off is the review: ${repo.autoMerge ? "on this floor a PR you pass merges by itself as soon as GitHub's checks are green, so nobody else reads the code after you. " : ""}Be thorough and skeptical, but fair: fail a PR only for real problems (broken behaviour, failing tests or build, the issue's requirements not met, obvious regressions), not for style preferences.`,
5474
- ...a.brief ? ["", `Your job description:
5475
- ${a.brief}`] : [],
5476
- ...a.role === "dev" ? ["", "You're a developer covering for the QA lab while its testers are busy. You didn't write this pull request: test it as an independent QA engineer would."] : [],
5477
- "",
5478
- `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
5479
- ...repo.summary ? [`Project: ${repo.summary}`] : [],
5480
- ...repo.mission ? [`What the team is building (the manager's brief): ${repo.mission}`] : [],
5481
- ...repo.qaBrief ? [`What to check on this project (from the CEO):
5482
- ${repo.qaBrief}`] : [],
5483
- `Pull request #${pr.number} "${pr.title}" from branch ${pr.headRefName}: ${pr.url}`,
5484
- `Your worktree: ${cwd}. It has the pull request's code checked out on local branch ${branch}.`,
5485
- "",
5486
- "How to test:",
5487
- "1. Read the PR description and the linked issue, and work out the acceptance criteria.",
5488
- `2. Review the code as a careful reviewer would: git diff origin/${repo.defaultBranch}...HEAD. Look for bugs, unhandled errors and edge cases, security problems, leftover debug code, and new logic without tests.`,
5489
- "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
5490
- repo.browserTesting ? `4. If the project has a UI, start it in the background on port ${this.port(a)} (reserved for you) and exercise the change in a real browser with the Playwright tools: navigate, click, type, resize to a phone size, try edge cases, and check the console for errors. Take a screenshot with browser_take_screenshot (no filename) of every important state: the screenshots are attached to the PR as evidence. Stop the server afterwards.` : "4. Exercise the changed behaviour directly (run the program, call the API, write a quick script).",
5491
- "5. You may write throwaway scripts to probe behaviour, but do not commit them.",
5492
- "",
5493
- "Rules: do not modify the code under test, do not commit, push, comment on, review or merge anything on GitHub. The office posts your report on the pull request. Finish with the structured QA report: verdict, summary, the checks you performed, the commands you ran and one caption per screenshot."
5494
- ].join("\n");
5780
+ return qaSystemPrompt({ agent: a, repo, port: this.port(a), cwd, branch, pr });
5495
5781
  }
5496
5782
  async runQa(a, repo, rec) {
5497
- const branch = `qa/pr-${rec.prNumber}-${slugify(a.name)}`;
5783
+ const branch = qaBranch(rec.prNumber, slugify(a.name));
5498
5784
  this.setQa(rec, { status: "testing", qaAgentId: a.id });
5499
5785
  this.beginTask(
5500
5786
  a,
@@ -6010,11 +6296,11 @@ Full report with screenshots: ${rec.commentUrl}` : "",
6010
6296
  }
6011
6297
  emitOfficeUpdate() {
6012
6298
  if (!this.officeHead) return;
6013
- const view = this.officeUpdateView();
6014
- const key = JSON.stringify(view);
6299
+ const view2 = this.officeUpdateView();
6300
+ const key = JSON.stringify(view2);
6015
6301
  if (key === this.lastOfficeView) return;
6016
6302
  this.lastOfficeView = key;
6017
- this.broadcast({ type: "officeUpdate", officeUpdate: view });
6303
+ this.broadcast({ type: "officeUpdate", officeUpdate: view2 });
6018
6304
  }
6019
6305
  /** The own-folder check found the office this many commits behind GitHub. */
6020
6306
  setOfficeBehind(behind) {
@@ -6168,11 +6454,11 @@ Full report with screenshots: ${rec.commentUrl}` : "",
6168
6454
  }
6169
6455
  ceoInfo() {
6170
6456
  const c = this.state.ceo;
6171
- const view = (j) => ({ kind: j.kind, label: jobLabel(j, this.ceoFloor(j.repoId)) });
6457
+ const view2 = (j) => ({ kind: j.kind, label: jobLabel(j, this.ceoFloor(j.repoId)) });
6172
6458
  const min = this.state.settings.ceoHeartbeatMin;
6173
6459
  return {
6174
- queue: c.queue.map(view),
6175
- job: c.job ? view(c.job) : null,
6460
+ queue: c.queue.map(view2),
6461
+ job: c.job ? view2(c.job) : null,
6176
6462
  lastReviewAt: c.lastReviewAt,
6177
6463
  nextReviewAt: min > 0 && c.lastReviewAt ? c.lastReviewAt + min * 6e4 : null
6178
6464
  };
@@ -6216,6 +6502,10 @@ ${job.text}` } : job;
6216
6502
  }
6217
6503
  void this.runCeoJob(a, job);
6218
6504
  }
6505
+ ceoPromptInput(a) {
6506
+ const s = this.state.settings;
6507
+ return { name: a.name, company: s.companyName, manager: s.managerName, notesFile: path13.join(CEO_DIR, "NOTES.md"), sessionLimit: s.sessionLimit, teamCap: s.teamCap, hiring: s.hiring };
6508
+ }
6219
6509
  async runCeoJob(a, job) {
6220
6510
  const rt = this.agentRt.get(a.id);
6221
6511
  const floor = this.ceoFloor(job.repoId);
@@ -6241,21 +6531,12 @@ ${job.text}` } : job;
6241
6531
  this.emitCeo();
6242
6532
  return;
6243
6533
  }
6244
- const s = this.state.settings;
6245
6534
  const how = this.sessionRuntime(a, job.kind === "chat" ? a.sessionId ?? void 0 : void 0);
6246
6535
  rt.session = this.backend.startSession(
6247
6536
  {
6248
6537
  cwd: CEO_DIR,
6249
6538
  prompt: ceoJobPrompt(job, floor),
6250
- systemAppend: ceoSystemPrompt({
6251
- name: a.name,
6252
- company: s.companyName,
6253
- manager: s.managerName,
6254
- notesFile: path13.join(CEO_DIR, "NOTES.md"),
6255
- sessionLimit: s.sessionLimit,
6256
- teamCap: s.teamCap,
6257
- hiring: s.hiring
6258
- }),
6539
+ systemAppend: ceoSystemPrompt(this.ceoPromptInput(a)),
6259
6540
  model: a.model || CEO_MODEL,
6260
6541
  effort: a.effort || CEO_EFFORT,
6261
6542
  browserTesting: false,
@@ -6848,6 +7129,7 @@ app.post("/api/agents/:id/assign", route((req) => swarm.assign(String(req.params
6848
7129
  app.post("/api/agents/:id/stop", route((req) => swarm.stopAgent(String(req.params.id))));
6849
7130
  app.post("/api/agents/:id/reset", route((req) => swarm.resetAgent(String(req.params.id))));
6850
7131
  app.post("/api/agents/:id/message", route((req) => swarm.message(String(req.params.id), str(req.body.text))));
7132
+ app.get("/api/agents/:id/prompt", route((req) => swarm.agentPrompt(String(req.params.id))));
6851
7133
  app.get("/api/agents/:id/screen", (req, res) => {
6852
7134
  const shot = swarm.screenshot(String(req.params.id));
6853
7135
  if (!shot) return void res.status(404).end();