@autoclawd/autoclawd 1.0.4 → 1.0.5

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/index.js CHANGED
@@ -34,7 +34,9 @@ var LinearConfigSchema = z.object({
34
34
  inProgressStatus: z.string().default("In Progress")
35
35
  });
36
36
  var GitHubConfigSchema = z.object({
37
- token: z.string()
37
+ token: z.string(),
38
+ autoMerge: z.boolean().default(false),
39
+ mergeMethod: z.enum(["merge", "squash", "rebase"]).default("squash")
38
40
  });
39
41
  var WebhookConfigSchema = z.object({
40
42
  port: z.number().default(3e3),
@@ -393,6 +395,10 @@ ${reason}`
393
395
 
394
396
  // src/github.ts
395
397
  import { Octokit } from "@octokit/rest";
398
+ import { execSync } from "child_process";
399
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
400
+ import { join as join3 } from "path";
401
+ import { tmpdir } from "os";
396
402
  function createOctokit(config) {
397
403
  return new Octokit({ auth: config.github.token });
398
404
  }
@@ -557,6 +563,144 @@ async function fetchFailedChecks(octokit, opts) {
557
563
  }
558
564
  return results;
559
565
  }
566
+ async function enableAutoMerge(octokit, opts) {
567
+ return retry(async () => {
568
+ const { owner, repo } = parseRepoUrl(opts.repoUrl);
569
+ const { data: pr } = await octokit.rest.pulls.get({
570
+ owner,
571
+ repo,
572
+ pull_number: opts.prNumber
573
+ });
574
+ try {
575
+ await octokit.graphql(`
576
+ mutation EnableAutoMerge($prId: ID!, $mergeMethod: PullRequestMergeMethod!) {
577
+ enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: $mergeMethod}) {
578
+ pullRequest { autoMergeRequest { enabledAt } }
579
+ }
580
+ }
581
+ `, {
582
+ prId: pr.node_id,
583
+ mergeMethod: opts.mergeMethod ?? "SQUASH"
584
+ });
585
+ log.info(`Auto-merge enabled on PR #${opts.prNumber} (${opts.mergeMethod ?? "SQUASH"})`);
586
+ } catch (err) {
587
+ const msg = err instanceof Error ? err.message : String(err);
588
+ const msgLower = msg.toLowerCase();
589
+ if (msgLower.includes("auto-merge") || msgLower.includes("not allowed") || msgLower.includes("branch protection") || msgLower.includes("protected branch")) {
590
+ log.warn(`Auto-merge not available on PR #${opts.prNumber}: ${msg}`);
591
+ } else {
592
+ throw err;
593
+ }
594
+ }
595
+ }, { label: "GitHub auto-merge", retryIf: isTransientError });
596
+ }
597
+ async function retargetPR(octokit, opts) {
598
+ return retry(async () => {
599
+ const { owner, repo } = parseRepoUrl(opts.repoUrl);
600
+ await octokit.rest.pulls.update({
601
+ owner,
602
+ repo,
603
+ pull_number: opts.prNumber,
604
+ base: opts.newBase
605
+ });
606
+ log.info(`Retargeted PR #${opts.prNumber} to base ${opts.newBase}`);
607
+ }, { label: "GitHub retarget", retryIf: isTransientError });
608
+ }
609
+ function createAskpass(githubToken) {
610
+ const dir = mkdtempSync(join3(tmpdir(), "autoclawd-rebase-"));
611
+ const path = join3(dir, "askpass.sh");
612
+ writeFileSync(path, `#!/bin/sh
613
+ echo "${githubToken}"
614
+ `, { mode: 448 });
615
+ return { dir, path };
616
+ }
617
+ function gitEnv(askpassPath) {
618
+ return { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: "0" };
619
+ }
620
+ function detectDefaultBranch(repoUrl, githubToken) {
621
+ const askpass = createAskpass(githubToken);
622
+ const authedUrl = repoUrl.replace("https://", "https://x-access-token@");
623
+ try {
624
+ const output = execSync(
625
+ `git ls-remote --symref -- ${authedUrl} HEAD`,
626
+ { stdio: "pipe", timeout: 3e4, env: gitEnv(askpass.path), encoding: "utf-8" }
627
+ );
628
+ const match = output.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/);
629
+ if (match) return match[1];
630
+ } catch {
631
+ } finally {
632
+ rmSync(askpass.dir, { recursive: true, force: true });
633
+ }
634
+ return "main";
635
+ }
636
+ async function rebaseStackedPRs(octokit, opts) {
637
+ const { owner, repo } = parseRepoUrl(opts.repoUrl);
638
+ const defaultBranch = detectDefaultBranch(opts.repoUrl, opts.githubToken);
639
+ const prefix = opts.branchPrefix ?? "autoclawd/";
640
+ const { data: openPRs } = await octokit.rest.pulls.list({
641
+ owner,
642
+ repo,
643
+ state: "open",
644
+ per_page: 100
645
+ });
646
+ const stackedPRs = openPRs.filter(
647
+ (pr) => pr.head.ref.startsWith(prefix) && pr.base.ref !== defaultBranch
648
+ );
649
+ if (stackedPRs.length === 0) return 0;
650
+ let rebased = 0;
651
+ for (const pr of stackedPRs) {
652
+ const baseBranch = pr.base.ref;
653
+ const { data: basePRs } = await octokit.rest.pulls.list({
654
+ owner,
655
+ repo,
656
+ head: `${owner}:${baseBranch}`,
657
+ state: "closed"
658
+ });
659
+ const mergedBasePR = basePRs.find((p) => p.merged_at !== null);
660
+ if (!mergedBasePR) {
661
+ log.debug(`PR #${pr.number}: base "${baseBranch}" not yet merged, skipping`);
662
+ continue;
663
+ }
664
+ log.info(`PR #${pr.number} ("${pr.title}"): base "${baseBranch}" was merged, rebasing onto ${defaultBranch}`);
665
+ const askpass = createAskpass(opts.githubToken);
666
+ const workDir = mkdtempSync(join3(tmpdir(), "autoclawd-rebase-"));
667
+ try {
668
+ const authedUrl = opts.repoUrl.replace("https://", "https://x-access-token@");
669
+ const env = gitEnv(askpass.path);
670
+ const run = (cmd) => execSync(cmd, { cwd: workDir, stdio: "pipe", timeout: 12e4, env, encoding: "utf-8" });
671
+ run(`git clone --no-tags -- ${authedUrl} .`);
672
+ run(`git checkout ${pr.head.ref}`);
673
+ run(`git fetch origin ${defaultBranch}`);
674
+ try {
675
+ run(`git rebase --onto origin/${defaultBranch} origin/${baseBranch}`);
676
+ } catch {
677
+ try {
678
+ run("git rebase --abort");
679
+ } catch {
680
+ }
681
+ log.warn(`PR #${pr.number}: rebase conflict, skipping (manual resolution needed)`);
682
+ continue;
683
+ }
684
+ run(`git config credential.helper '!f() { echo "password=${opts.githubToken}"; echo "username=x-access-token"; }; f'`);
685
+ run(`git push --force-with-lease origin ${pr.head.ref}`);
686
+ await retargetPR(octokit, {
687
+ repoUrl: opts.repoUrl,
688
+ prNumber: pr.number,
689
+ newBase: defaultBranch
690
+ });
691
+ log.success(`PR #${pr.number}: rebased onto ${defaultBranch} and retargeted`);
692
+ rebased++;
693
+ } catch (err) {
694
+ const msg = err instanceof Error ? err.message : String(err);
695
+ const safe = msg.replace(/x-access-token:[^\s@]+/g, "x-access-token:***");
696
+ log.error(`PR #${pr.number}: rebase failed: ${safe}`);
697
+ } finally {
698
+ rmSync(workDir, { recursive: true, force: true });
699
+ rmSync(askpass.dir, { recursive: true, force: true });
700
+ }
701
+ }
702
+ return rebased;
703
+ }
560
704
 
561
705
  // src/webhook.ts
562
706
  import { createServer } from "http";
@@ -565,18 +709,123 @@ import { createHmac, timingSafeEqual } from "crypto";
565
709
  // src/docker.ts
566
710
  import Docker from "dockerode";
567
711
  import { PassThrough } from "stream";
568
- import { homedir as homedir3 } from "os";
569
- import { join as join3 } from "path";
712
+ import { homedir as homedir3, platform } from "os";
713
+ import { join as join4 } from "path";
570
714
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
715
+ import { execSync as execSync2 } from "child_process";
571
716
  var docker = new Docker();
717
+ function sleep2(ms) {
718
+ return new Promise((r) => setTimeout(r, ms));
719
+ }
720
+ async function tryStartDocker() {
721
+ const os = platform();
722
+ if (os === "darwin") {
723
+ if (existsSync3("/Applications/Docker.app")) {
724
+ log.info("Docker not running \u2014 starting Docker Desktop...");
725
+ try {
726
+ execSync2("open -a Docker", { stdio: "pipe" });
727
+ } catch {
728
+ return false;
729
+ }
730
+ } else {
731
+ return false;
732
+ }
733
+ } else if (os === "linux") {
734
+ log.info("Docker not running \u2014 attempting to start...");
735
+ try {
736
+ execSync2("sudo systemctl start docker 2>/dev/null || sudo service docker start 2>/dev/null", {
737
+ stdio: "pipe",
738
+ timeout: 3e4
739
+ });
740
+ } catch {
741
+ return false;
742
+ }
743
+ } else {
744
+ return false;
745
+ }
746
+ for (let i = 0; i < 30; i++) {
747
+ await sleep2(1e3);
748
+ try {
749
+ await docker.ping();
750
+ log.success("Docker started");
751
+ return true;
752
+ } catch {
753
+ }
754
+ if (i > 0 && i % 5 === 0) {
755
+ log.info(`Waiting for Docker daemon... (${i}s)`);
756
+ }
757
+ }
758
+ return false;
759
+ }
760
+ async function tryInstallDocker() {
761
+ const os = platform();
762
+ if (os === "darwin") {
763
+ try {
764
+ execSync2("which brew", { stdio: "pipe" });
765
+ } catch {
766
+ log.warn("Docker not installed and Homebrew not found \u2014 cannot auto-install");
767
+ return false;
768
+ }
769
+ log.info("Docker not installed \u2014 installing via Homebrew (this may take a few minutes)...");
770
+ try {
771
+ execSync2("brew install --cask docker", { stdio: "inherit", timeout: 6e5 });
772
+ log.success("Docker Desktop installed");
773
+ return true;
774
+ } catch {
775
+ log.warn("Docker install via Homebrew failed");
776
+ return false;
777
+ }
778
+ } else if (os === "linux") {
779
+ const cmds = [
780
+ { check: "apt-get", install: "sudo apt-get update -qq && sudo apt-get install -y docker.io && sudo systemctl enable docker" },
781
+ { check: "dnf", install: "sudo dnf install -y docker && sudo systemctl enable docker" },
782
+ { check: "yum", install: "sudo yum install -y docker && sudo systemctl enable docker" },
783
+ { check: "pacman", install: "sudo pacman -Sy --noconfirm docker && sudo systemctl enable docker" }
784
+ ];
785
+ for (const { check, install } of cmds) {
786
+ try {
787
+ execSync2(`which ${check}`, { stdio: "pipe" });
788
+ log.info(`Docker not installed \u2014 installing via ${check}...`);
789
+ execSync2(install, { stdio: "inherit", timeout: 3e5 });
790
+ log.success("Docker installed");
791
+ return true;
792
+ } catch {
793
+ }
794
+ }
795
+ return false;
796
+ }
797
+ return false;
798
+ }
572
799
  async function checkDockerAvailable() {
573
800
  try {
574
801
  await docker.ping();
575
- } catch (err) {
802
+ return;
803
+ } catch {
804
+ }
805
+ const started = await tryStartDocker();
806
+ if (started) return;
807
+ let installed = false;
808
+ try {
809
+ execSync2("which docker", { stdio: "pipe" });
810
+ installed = true;
811
+ } catch {
812
+ }
813
+ if (installed) {
814
+ throw new Error(
815
+ "Docker is installed but the daemon is not running and could not be started automatically.\nStart it manually:\n" + (platform() === "darwin" ? " open -a Docker\n" : " sudo systemctl start docker\n")
816
+ );
817
+ }
818
+ const didInstall = await tryInstallDocker();
819
+ if (didInstall) {
820
+ const started2 = await tryStartDocker();
821
+ if (started2) return;
576
822
  throw new Error(
577
- "Cannot connect to Docker daemon. Is Docker running?\nInstall: https://docs.docker.com/get-docker/"
823
+ "Docker was installed but could not be started.\n" + (platform() === "darwin" ? "Open Docker Desktop from Applications and try again.\n" : "Run: sudo systemctl start docker\n")
578
824
  );
579
825
  }
826
+ throw new Error(
827
+ "Docker is not installed and could not be auto-installed.\n" + (platform() === "darwin" ? "Install manually: brew install --cask docker\n" : "Install manually: https://docs.docker.com/get-docker/\n")
828
+ );
580
829
  }
581
830
  async function ensureImage(image) {
582
831
  try {
@@ -766,7 +1015,7 @@ async function copyClaudeCredentials(container) {
766
1015
  "-c",
767
1016
  "mkdir -p /home/autoclawd/.claude && chown autoclawd /home/autoclawd/.claude"
768
1017
  ]);
769
- const credFile = join3(home, ".claude", ".credentials.json");
1018
+ const credFile = join4(home, ".claude", ".credentials.json");
770
1019
  if (existsSync3(credFile)) {
771
1020
  const content = readFileSync2(credFile, "utf-8");
772
1021
  const b64 = Buffer.from(content).toString("base64");
@@ -777,7 +1026,7 @@ async function copyClaudeCredentials(container) {
777
1026
  ]);
778
1027
  log.debug("Copied .credentials.json");
779
1028
  }
780
- const settingsFile = join3(home, ".claude", "settings.json");
1029
+ const settingsFile = join4(home, ".claude", "settings.json");
781
1030
  if (existsSync3(settingsFile)) {
782
1031
  const content = readFileSync2(settingsFile, "utf-8");
783
1032
  const b64 = Buffer.from(content).toString("base64");
@@ -969,7 +1218,7 @@ async function runAgentLoop(opts) {
969
1218
  }
970
1219
  const waitMs = estimateResetMs(combined);
971
1220
  log.ticket(ticketId, `Rate limited (${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES}) \u2014 pausing ${Math.round(waitMs / 1e3)}s`);
972
- await sleep2(waitMs);
1221
+ await sleep3(waitMs);
973
1222
  i--;
974
1223
  continue;
975
1224
  }
@@ -980,7 +1229,7 @@ async function runAgentLoop(opts) {
980
1229
  log.ticket(ticketId, `Max iterations (${agentConfig.maxIterations}) reached`);
981
1230
  return { iterations: agentConfig.maxIterations, success: false, lastOutput };
982
1231
  }
983
- function sleep2(ms) {
1232
+ function sleep3(ms) {
984
1233
  return new Promise((resolve) => setTimeout(resolve, ms));
985
1234
  }
986
1235
 
@@ -1051,16 +1300,16 @@ async function commitAndPush(container, opts) {
1051
1300
  }
1052
1301
 
1053
1302
  // src/worker.ts
1054
- import { mkdtempSync, rmSync, writeFileSync } from "fs";
1055
- import { join as join5 } from "path";
1056
- import { tmpdir } from "os";
1057
- import { execSync } from "child_process";
1303
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
1304
+ import { join as join6 } from "path";
1305
+ import { tmpdir as tmpdir2 } from "os";
1306
+ import { execSync as execSync3 } from "child_process";
1058
1307
 
1059
1308
  // src/db.ts
1060
1309
  import Database from "better-sqlite3";
1061
- import { join as join4 } from "path";
1310
+ import { join as join5 } from "path";
1062
1311
  import { mkdirSync as mkdirSync2, existsSync as existsSync4 } from "fs";
1063
- var DB_PATH = join4(CONFIG_DIR, "autoclawd.db");
1312
+ var DB_PATH = join5(CONFIG_DIR, "autoclawd.db");
1064
1313
  var _db;
1065
1314
  function getDb() {
1066
1315
  if (_db) return _db;
@@ -1227,75 +1476,75 @@ function assertSafeRef(name, label) {
1227
1476
  throw new Error(`Invalid ${label}: "${name}"`);
1228
1477
  }
1229
1478
  }
1230
- function createAskpass(githubToken) {
1231
- const dir = mkdtempSync(join5(tmpdir(), "autoclawd-cred-"));
1232
- const path = join5(dir, "askpass.sh");
1233
- writeFileSync(path, `#!/bin/sh
1479
+ function createAskpass2(githubToken) {
1480
+ const dir = mkdtempSync2(join6(tmpdir2(), "autoclawd-cred-"));
1481
+ const path = join6(dir, "askpass.sh");
1482
+ writeFileSync2(path, `#!/bin/sh
1234
1483
  echo "${githubToken}"
1235
1484
  `, { mode: 448 });
1236
1485
  return { dir, path };
1237
1486
  }
1238
- function gitEnv(askpassPath) {
1487
+ function gitEnv2(askpassPath) {
1239
1488
  return { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: "0" };
1240
1489
  }
1241
- function detectDefaultBranch(repoUrl, githubToken) {
1242
- const askpass = createAskpass(githubToken);
1490
+ function detectDefaultBranch2(repoUrl, githubToken) {
1491
+ const askpass = createAskpass2(githubToken);
1243
1492
  const authedUrl = repoUrl.replace("https://", "https://x-access-token@");
1244
1493
  try {
1245
- const output = execSync(
1494
+ const output = execSync3(
1246
1495
  `git ls-remote --symref -- ${authedUrl} HEAD`,
1247
- { stdio: "pipe", timeout: 3e4, env: gitEnv(askpass.path), encoding: "utf-8" }
1496
+ { stdio: "pipe", timeout: 3e4, env: gitEnv2(askpass.path), encoding: "utf-8" }
1248
1497
  );
1249
1498
  const match = output.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/);
1250
1499
  if (match) return match[1];
1251
1500
  } catch {
1252
1501
  } finally {
1253
- rmSync(askpass.dir, { recursive: true, force: true });
1502
+ rmSync2(askpass.dir, { recursive: true, force: true });
1254
1503
  }
1255
1504
  return "main";
1256
1505
  }
1257
1506
  async function pushScaffold(opts) {
1258
- const scaffoldDir = mkdtempSync(join5(tmpdir(), "autoclawd-scaffold-"));
1259
- const askpass = createAskpass(opts.githubToken);
1507
+ const scaffoldDir = mkdtempSync2(join6(tmpdir2(), "autoclawd-scaffold-"));
1508
+ const askpass = createAskpass2(opts.githubToken);
1260
1509
  try {
1261
1510
  const authedUrl = opts.repoUrl.replace("https://", "https://x-access-token@");
1262
- const env = gitEnv(askpass.path);
1511
+ const env = gitEnv2(askpass.path);
1263
1512
  const readmeLines = [`# ${opts.ticketTitle}`];
1264
1513
  if (opts.ticketDescription) {
1265
1514
  readmeLines.push("", opts.ticketDescription);
1266
1515
  }
1267
- writeFileSync(join5(scaffoldDir, "README.md"), readmeLines.join("\n") + "\n");
1268
- writeFileSync(join5(scaffoldDir, ".autoclawd.yaml"), "base: main\n");
1269
- execSync("git init", { cwd: scaffoldDir, stdio: "pipe", env });
1270
- execSync("git checkout -b main", { cwd: scaffoldDir, stdio: "pipe", env });
1271
- execSync('git config user.email "autoclawd@users.noreply.github.com"', { cwd: scaffoldDir, stdio: "pipe", env });
1272
- execSync('git config user.name "autoclawd"', { cwd: scaffoldDir, stdio: "pipe", env });
1273
- execSync("git add .", { cwd: scaffoldDir, stdio: "pipe", env });
1274
- execSync('git commit -m "chore: initial scaffold"', { cwd: scaffoldDir, stdio: "pipe", env });
1275
- execSync(`git remote add origin ${authedUrl}`, { cwd: scaffoldDir, stdio: "pipe", env });
1276
- execSync("git push -u origin main", { cwd: scaffoldDir, stdio: "pipe", timeout: 6e4, env });
1516
+ writeFileSync2(join6(scaffoldDir, "README.md"), readmeLines.join("\n") + "\n");
1517
+ writeFileSync2(join6(scaffoldDir, ".autoclawd.yaml"), "base: main\n");
1518
+ execSync3("git init", { cwd: scaffoldDir, stdio: "pipe", env });
1519
+ execSync3("git checkout -b main", { cwd: scaffoldDir, stdio: "pipe", env });
1520
+ execSync3('git config user.email "autoclawd@users.noreply.github.com"', { cwd: scaffoldDir, stdio: "pipe", env });
1521
+ execSync3('git config user.name "autoclawd"', { cwd: scaffoldDir, stdio: "pipe", env });
1522
+ execSync3("git add .", { cwd: scaffoldDir, stdio: "pipe", env });
1523
+ execSync3('git commit -m "chore: initial scaffold"', { cwd: scaffoldDir, stdio: "pipe", env });
1524
+ execSync3(`git remote add origin ${authedUrl}`, { cwd: scaffoldDir, stdio: "pipe", env });
1525
+ execSync3("git push -u origin main", { cwd: scaffoldDir, stdio: "pipe", timeout: 6e4, env });
1277
1526
  } finally {
1278
- rmSync(scaffoldDir, { recursive: true, force: true });
1279
- rmSync(askpass.dir, { recursive: true, force: true });
1527
+ rmSync2(scaffoldDir, { recursive: true, force: true });
1528
+ rmSync2(askpass.dir, { recursive: true, force: true });
1280
1529
  }
1281
1530
  }
1282
1531
  async function cloneToTemp(repoUrl, baseBranch, githubToken) {
1283
1532
  assertSafeRef(baseBranch, "base branch");
1284
1533
  return retrySync(() => {
1285
- const workDir = mkdtempSync(join5(tmpdir(), "autoclawd-"));
1286
- const askpass = createAskpass(githubToken);
1534
+ const workDir = mkdtempSync2(join6(tmpdir2(), "autoclawd-"));
1535
+ const askpass = createAskpass2(githubToken);
1287
1536
  const authedUrl = repoUrl.replace("https://", "https://x-access-token@");
1288
1537
  try {
1289
- execSync(`git clone --depth=50 -b ${baseBranch} -- ${authedUrl} ${workDir}`, {
1538
+ execSync3(`git clone --depth=50 -b ${baseBranch} -- ${authedUrl} ${workDir}`, {
1290
1539
  stdio: "pipe",
1291
1540
  timeout: 12e4,
1292
- env: gitEnv(askpass.path)
1541
+ env: gitEnv2(askpass.path)
1293
1542
  });
1294
1543
  } catch (err) {
1295
- rmSync(workDir, { recursive: true, force: true });
1544
+ rmSync2(workDir, { recursive: true, force: true });
1296
1545
  throw err;
1297
1546
  } finally {
1298
- rmSync(askpass.dir, { recursive: true, force: true });
1547
+ rmSync2(askpass.dir, { recursive: true, force: true });
1299
1548
  }
1300
1549
  return workDir;
1301
1550
  }, { label: "git clone", retryIf: isTransientError });
@@ -1432,7 +1681,7 @@ async function executeTicket(opts) {
1432
1681
  });
1433
1682
  log.ticket(ticket.identifier, "Repo created, pushed scaffold");
1434
1683
  }
1435
- const detectedBase = repoCreated ? "main" : detectDefaultBranch(ticket.repoUrl, config.github.token);
1684
+ const detectedBase = repoCreated ? "main" : detectDefaultBranch2(ticket.repoUrl, config.github.token);
1436
1685
  log.ticket(ticket.identifier, `Default branch: ${detectedBase}`);
1437
1686
  workDir = await cloneToTemp(ticket.repoUrl, detectedBase, config.github.token);
1438
1687
  log.ticket(ticket.identifier, "Cloned repo");
@@ -1446,7 +1695,7 @@ async function executeTicket(opts) {
1446
1695
  log.ticket(ticket.identifier, `Stacked on branch: ${ticket.baseBranch}`);
1447
1696
  }
1448
1697
  if (actualBase !== detectedBase) {
1449
- rmSync(workDir, { recursive: true, force: true });
1698
+ rmSync2(workDir, { recursive: true, force: true });
1450
1699
  try {
1451
1700
  workDir = await cloneToTemp(ticket.repoUrl, actualBase, config.github.token);
1452
1701
  } catch (err) {
@@ -1463,7 +1712,7 @@ async function executeTicket(opts) {
1463
1712
  throw err;
1464
1713
  }
1465
1714
  }
1466
- execSync(`git checkout -B ${branchName} --`, { cwd: workDir, stdio: "pipe" });
1715
+ execSync3(`git checkout -B ${branchName} --`, { cwd: workDir, stdio: "pipe" });
1467
1716
  container = await createContainer({
1468
1717
  dockerConfig: docker2,
1469
1718
  workspacePath: workDir,
@@ -1538,6 +1787,10 @@ async function executeTicket(opts) {
1538
1787
  body: buildPRBody({ commitCount: gitResult.commitCount }),
1539
1788
  draft: hasValidation ? true : false
1540
1789
  });
1790
+ if (!hasValidation && config.github.autoMerge) {
1791
+ const method = config.github.mergeMethod.toUpperCase();
1792
+ await enableAutoMerge(octokit, { repoUrl: ticket.repoUrl, prNumber: pr.number, mergeMethod: method });
1793
+ }
1541
1794
  if (hasValidation) {
1542
1795
  let iterationsUsed = agentResult.iterations;
1543
1796
  let lastFailures = [];
@@ -1593,6 +1846,10 @@ async function executeTicket(opts) {
1593
1846
  prNumber: pr.number
1594
1847
  });
1595
1848
  log.ticket(ticket.identifier, "PR marked as ready for review");
1849
+ if (config.github.autoMerge) {
1850
+ const method = config.github.mergeMethod.toUpperCase();
1851
+ await enableAutoMerge(octokit, { repoUrl: ticket.repoUrl, prNumber: pr.number, mergeMethod: method });
1852
+ }
1596
1853
  }
1597
1854
  }
1598
1855
  await completeTicket(linearClient, config, ticket.id, `PR opened: ${pr.url}`);
@@ -1630,7 +1887,7 @@ async function executeTicket(opts) {
1630
1887
  }
1631
1888
  if (workDir) {
1632
1889
  try {
1633
- rmSync(workDir, { recursive: true, force: true });
1890
+ rmSync2(workDir, { recursive: true, force: true });
1634
1891
  } catch {
1635
1892
  }
1636
1893
  }
@@ -1719,7 +1976,7 @@ async function fixPR(opts) {
1719
1976
  if (container) await destroyContainer(container);
1720
1977
  if (workDir) {
1721
1978
  try {
1722
- rmSync(workDir, { recursive: true, force: true });
1979
+ rmSync2(workDir, { recursive: true, force: true });
1723
1980
  } catch {
1724
1981
  }
1725
1982
  }
@@ -1993,6 +2250,8 @@ var Watcher = class {
1993
2250
  activeInMemory = /* @__PURE__ */ new Set();
1994
2251
  timer;
1995
2252
  stopped = false;
2253
+ pollCount = 0;
2254
+ rebaseRunning = false;
1996
2255
  async start(intervalSeconds, once) {
1997
2256
  getDb();
1998
2257
  this.resumeQueue();
@@ -2057,6 +2316,36 @@ var Watcher = class {
2057
2316
  } catch (err) {
2058
2317
  log.error(`Poll failed: ${err instanceof Error ? err.message : err}`);
2059
2318
  }
2319
+ this.pollCount++;
2320
+ if (this.pollCount % 5 === 0) {
2321
+ void this.rebaseSweep();
2322
+ }
2323
+ }
2324
+ async rebaseSweep() {
2325
+ if (this.rebaseRunning) return;
2326
+ this.rebaseRunning = true;
2327
+ try {
2328
+ const repos = this.config.safety.allowedRepos;
2329
+ if (!repos || repos.length === 0) return;
2330
+ for (const allowed of repos) {
2331
+ const repoUrl = allowed.startsWith("https://") ? allowed : `https://github.com/${allowed}`;
2332
+ if (!isRepoAllowed(repoUrl, repos)) continue;
2333
+ try {
2334
+ const count = await rebaseStackedPRs(this.octokit, {
2335
+ repoUrl,
2336
+ githubToken: this.config.github.token,
2337
+ branchPrefix: this.config.safety.branchPrefix
2338
+ });
2339
+ if (count > 0) {
2340
+ log.info(`Rebase sweep: rebased ${count} stacked PR(s) in ${allowed}`);
2341
+ }
2342
+ } catch (err) {
2343
+ log.debug(`Rebase sweep failed for ${allowed}: ${err instanceof Error ? err.message : err}`);
2344
+ }
2345
+ }
2346
+ } finally {
2347
+ this.rebaseRunning = false;
2348
+ }
2060
2349
  }
2061
2350
  isNewTicket(ticket) {
2062
2351
  if (this.activeInMemory.has(ticket.id)) return false;
@@ -2118,11 +2407,11 @@ var Watcher = class {
2118
2407
 
2119
2408
  // src/history.ts
2120
2409
  import { readFileSync as readFileSync3, existsSync as existsSync5 } from "fs";
2121
- import { join as join6 } from "path";
2410
+ import { join as join7 } from "path";
2122
2411
  import { homedir as homedir4 } from "os";
2123
2412
  import chalk2 from "chalk";
2124
- var HISTORY_DIR = join6(homedir4(), ".autoclawd");
2125
- var HISTORY_FILE = join6(HISTORY_DIR, "history.jsonl");
2413
+ var HISTORY_DIR = join7(homedir4(), ".autoclawd");
2414
+ var HISTORY_FILE = join7(HISTORY_DIR, "history.jsonl");
2126
2415
  function runToRecord(run) {
2127
2416
  return {
2128
2417
  ticketId: run.ticket_id,
@@ -2212,9 +2501,9 @@ function printHistoryTable(records) {
2212
2501
  }
2213
2502
 
2214
2503
  // src/deps.ts
2215
- import { execSync as execSync2 } from "child_process";
2504
+ import { execSync as execSync4 } from "child_process";
2216
2505
  import { existsSync as existsSync6 } from "fs";
2217
- import { join as join7 } from "path";
2506
+ import { join as join8 } from "path";
2218
2507
  import { homedir as homedir5 } from "os";
2219
2508
  function detectPackageManager() {
2220
2509
  const checks = [
@@ -2227,7 +2516,7 @@ function detectPackageManager() {
2227
2516
  ];
2228
2517
  for (const [name, cmd] of checks) {
2229
2518
  try {
2230
- execSync2(`which ${cmd}`, { stdio: "pipe" });
2519
+ execSync4(`which ${cmd}`, { stdio: "pipe" });
2231
2520
  return name;
2232
2521
  } catch {
2233
2522
  }
@@ -2300,7 +2589,7 @@ function checkDeps() {
2300
2589
  },
2301
2590
  {
2302
2591
  name: "Claude credentials",
2303
- installed: existsSync6(join7(homedir5(), ".claude", ".credentials.json")),
2592
+ installed: existsSync6(join8(homedir5(), ".claude", ".credentials.json")),
2304
2593
  installable: false,
2305
2594
  installCommands: [],
2306
2595
  manualInstructions: 'Run "claude" and complete the login flow'
@@ -2318,7 +2607,7 @@ function installDep(dep) {
2318
2607
  if (dep.installCommands.length === 0) return false;
2319
2608
  for (const cmd of dep.installCommands) {
2320
2609
  try {
2321
- execSync2(cmd, { stdio: "inherit", timeout: 3e5 });
2610
+ execSync4(cmd, { stdio: "inherit", timeout: 3e5 });
2322
2611
  } catch {
2323
2612
  return false;
2324
2613
  }
@@ -2328,15 +2617,15 @@ function installDep(dep) {
2328
2617
  function addUserToDockerGroup() {
2329
2618
  if (process.platform !== "linux") return;
2330
2619
  try {
2331
- const user = execSync2("whoami", { encoding: "utf-8" }).trim();
2332
- execSync2(`sudo usermod -aG docker ${user}`, { stdio: "pipe" });
2620
+ const user = execSync4("whoami", { encoding: "utf-8" }).trim();
2621
+ execSync4(`sudo usermod -aG docker ${user}`, { stdio: "pipe" });
2333
2622
  log.info(`Added ${user} to docker group \u2014 log out and back in to apply`);
2334
2623
  } catch {
2335
2624
  }
2336
2625
  }
2337
2626
  function commandExists(cmd) {
2338
2627
  try {
2339
- execSync2(`which ${cmd}`, { stdio: "pipe" });
2628
+ execSync4(`which ${cmd}`, { stdio: "pipe" });
2340
2629
  return true;
2341
2630
  } catch {
2342
2631
  return false;
@@ -2344,7 +2633,7 @@ function commandExists(cmd) {
2344
2633
  }
2345
2634
  function isDockerRunning() {
2346
2635
  try {
2347
- execSync2("docker info", { stdio: "pipe", timeout: 1e4 });
2636
+ execSync4("docker info", { stdio: "pipe", timeout: 1e4 });
2348
2637
  return true;
2349
2638
  } catch {
2350
2639
  return false;
@@ -2352,10 +2641,10 @@ function isDockerRunning() {
2352
2641
  }
2353
2642
 
2354
2643
  // src/index.ts
2355
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2, readFileSync as readFileSync4, unlinkSync } from "fs";
2356
- import { execSync as execSync3, spawn as spawn2 } from "child_process";
2644
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
2645
+ import { execSync as execSync5, spawn as spawn2 } from "child_process";
2357
2646
  import { createInterface } from "readline";
2358
- import { join as join8 } from "path";
2647
+ import { join as join9 } from "path";
2359
2648
  import { homedir as homedir6 } from "os";
2360
2649
  import { LinearClient as LinearClient2 } from "@linear/sdk";
2361
2650
  import { Octokit as Octokit2 } from "@octokit/rest";
@@ -2363,7 +2652,7 @@ import { createRequire } from "module";
2363
2652
  var require2 = createRequire(import.meta.url);
2364
2653
  var pkg = require2("../package.json");
2365
2654
  var program = new Command();
2366
- var WATCHER_PID_FILE = join8(homedir6(), ".autoclawd", "watcher.pid");
2655
+ var WATCHER_PID_FILE = join9(homedir6(), ".autoclawd", "watcher.pid");
2367
2656
  function readWatcherPid() {
2368
2657
  try {
2369
2658
  const content = readFileSync4(WATCHER_PID_FILE, "utf-8").trim();
@@ -2374,8 +2663,8 @@ function readWatcherPid() {
2374
2663
  }
2375
2664
  }
2376
2665
  function writeWatcherPid(pid) {
2377
- mkdirSync3(join8(homedir6(), ".autoclawd"), { recursive: true });
2378
- writeFileSync2(WATCHER_PID_FILE, String(pid), { mode: 384 });
2666
+ mkdirSync3(join9(homedir6(), ".autoclawd"), { recursive: true });
2667
+ writeFileSync3(WATCHER_PID_FILE, String(pid), { mode: 384 });
2379
2668
  }
2380
2669
  function removeWatcherPid() {
2381
2670
  try {
@@ -2432,25 +2721,7 @@ program.command("serve").description("Start webhook server with auto-tunnel").op
2432
2721
  const webhookUrl = `${tunnel.url}/api/v1/linear/webhook`;
2433
2722
  log.success(`Tunnel: ${tunnel.url}`);
2434
2723
  log.info(`Webhook URL: ${webhookUrl}`);
2435
- if (config.webhook.webhookId) {
2436
- try {
2437
- await linearClient.deleteWebhook(config.webhook.webhookId);
2438
- } catch {
2439
- }
2440
- }
2441
- const team = await linearClient.team(config.linear.teamId);
2442
- const result = await linearClient.createWebhook({
2443
- url: webhookUrl,
2444
- resourceTypes: ["Issue"],
2445
- label: "autoclawd",
2446
- teamId: team.id
2447
- });
2448
- const webhook = await result.webhook;
2449
- if (webhook) {
2450
- log.success(`Linear webhook created \u2192 ${webhookUrl}`);
2451
- config.webhook.webhookId = webhook.id;
2452
- config.webhook.signingSecret = void 0;
2453
- }
2724
+ log.info("Configure this URL as a webhook in Linear (Settings \u2192 API \u2192 Webhooks)");
2454
2725
  } catch (err) {
2455
2726
  log.warn(`Tunnel failed: ${err instanceof Error ? err.message : err}`);
2456
2727
  log.info("Falling back to local-only mode. Set up your own reverse proxy.");
@@ -2705,6 +2976,37 @@ Retrying ticket ${id}...`);
2705
2976
  process.off("SIGTERM", onSignal);
2706
2977
  }
2707
2978
  });
2979
+ program.command("rebase [repo]").description("Rebase stacked PRs whose base branch has been merged (e.g. autoclawd rebase owner/repo)").option("-c, --config <path>", "Config file path").option("-v, --verbose", "Verbose logging").action(async (repoArg, opts) => {
2980
+ if (opts.verbose) setLogLevel("debug");
2981
+ enableFileLogging();
2982
+ const config = loadConfig(opts.config);
2983
+ const octokit = createOctokit(config);
2984
+ let repos;
2985
+ if (repoArg) {
2986
+ repos = [repoArg.startsWith("https://") ? repoArg : `https://github.com/${repoArg}`];
2987
+ } else if (config.safety.allowedRepos && config.safety.allowedRepos.length > 0) {
2988
+ repos = config.safety.allowedRepos.map(
2989
+ (r) => r.startsWith("https://") ? r : `https://github.com/${r}`
2990
+ );
2991
+ } else {
2992
+ throw new Error("Provide a repo (autoclawd rebase owner/repo) or configure safety.allowedRepos");
2993
+ }
2994
+ let totalRebased = 0;
2995
+ for (const repoUrl of repos) {
2996
+ log.info(`Checking ${repoUrl} for stacked PRs to rebase...`);
2997
+ const count = await rebaseStackedPRs(octokit, {
2998
+ repoUrl,
2999
+ githubToken: config.github.token,
3000
+ branchPrefix: config.safety.branchPrefix
3001
+ });
3002
+ totalRebased += count;
3003
+ }
3004
+ if (totalRebased === 0) {
3005
+ log.info("No stacked PRs needed rebasing");
3006
+ } else {
3007
+ log.success(`Rebased ${totalRebased} stacked PR(s)`);
3008
+ }
3009
+ });
2708
3010
  program.command("validate").description("Validate config without running anything").option("-c, --config <path>", "Config file path").action(async (opts) => {
2709
3011
  try {
2710
3012
  const config = loadConfig(opts.config);
@@ -2743,7 +3045,7 @@ program.command("history").description("Show run history").option("-n, --limit <
2743
3045
  }
2744
3046
  });
2745
3047
  function checkClaudeCredentials() {
2746
- return existsSync7(join8(homedir6(), ".claude", ".credentials.json"));
3048
+ return existsSync7(join9(homedir6(), ".claude", ".credentials.json"));
2747
3049
  }
2748
3050
  async function validateGitHubToken(token) {
2749
3051
  try {
@@ -2846,7 +3148,7 @@ program.command("init").description("Set up autoclawd interactively").action(asy
2846
3148
  }
2847
3149
  let ghToken = "";
2848
3150
  try {
2849
- ghToken = execSync3("gh auth token", { encoding: "utf-8" }).trim();
3151
+ ghToken = execSync5("gh auth token", { encoding: "utf-8" }).trim();
2850
3152
  log.success(`GitHub token detected from gh CLI`);
2851
3153
  } catch {
2852
3154
  ghToken = await ask("GitHub personal access token");
@@ -2897,7 +3199,7 @@ agent:
2897
3199
 
2898
3200
  maxConcurrent: 1
2899
3201
  `;
2900
- writeFileSync2(CONFIG_FILE, config, { mode: 384 });
3202
+ writeFileSync3(CONFIG_FILE, config, { mode: 384 });
2901
3203
  log.success(`Config saved to ${CONFIG_FILE}`);
2902
3204
  console.log(`
2903
3205
  Setup complete! Next steps:
@@ -2908,10 +3210,12 @@ maxConcurrent: 1
2908
3210
  2. Tag tickets with a repo: label
2909
3211
 
2910
3212
  3. Start autoclawd:
2911
- $ autoclawd serve
3213
+ $ autoclawd watch # polling mode (recommended)
3214
+ $ autoclawd serve # webhook mode
2912
3215
 
2913
- autoclawd serve will auto-create a tunnel and
2914
- register the webhook with Linear. Just run it.
3216
+ For webhook mode, configure the webhook URL manually in
3217
+ Linear \u2192 Settings \u2192 API \u2192 Webhooks. autoclawd serve will
3218
+ print the tunnel URL to use.
2915
3219
  `);
2916
3220
  });
2917
3221
  program.parseAsync().catch((err) => {