@autoclawd/autoclawd 1.1.23 → 1.1.24

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
@@ -56,6 +56,10 @@ var SafetyConfigSchema = z.object({
56
56
  branchPrefix: z.string().default("autoclawd/"),
57
57
  maxFileChanges: z.number().min(1).optional()
58
58
  });
59
+ var IntegrationConfigSchema = z.object({
60
+ crossBranchValidate: z.boolean().default(true),
61
+ maxCrossBranchIterations: z.number().min(1).max(10).default(3)
62
+ });
59
63
  var ConfigSchema = z.object({
60
64
  linear: LinearConfigSchema,
61
65
  github: GitHubConfigSchema,
@@ -63,6 +67,7 @@ var ConfigSchema = z.object({
63
67
  agent: AgentConfigSchema.default({}),
64
68
  webhook: WebhookConfigSchema.default({}),
65
69
  safety: SafetyConfigSchema.default({}),
70
+ integration: IntegrationConfigSchema.default({}),
66
71
  maxConcurrent: z.number().default(1),
67
72
  validate: z.array(z.string()).optional()
68
73
  // Global default validation commands
@@ -72,8 +77,9 @@ var RepoLocalConfigSchema = z.object({
72
77
  base: z.string().default("main"),
73
78
  agent: AgentConfigSchema.partial().optional(),
74
79
  docker: DockerConfigSchema.partial().optional(),
75
- validate: z.array(z.string()).optional()
80
+ validate: z.array(z.string()).optional(),
76
81
  // Validation commands to run before PR
82
+ integration: IntegrationConfigSchema.partial().optional()
77
83
  });
78
84
  function resolveEnvVars(obj) {
79
85
  if (typeof obj === "string") {
@@ -135,8 +141,12 @@ function mergeConfigs(host, local) {
135
141
  ...host.docker,
136
142
  ...local?.docker
137
143
  });
144
+ const integration = IntegrationConfigSchema.parse({
145
+ ...host.integration,
146
+ ...local?.integration
147
+ });
138
148
  const validate = local?.validate ?? host.validate;
139
- return { agent, docker: docker2, prompt: local?.prompt, validate };
149
+ return { agent, docker: docker2, prompt: local?.prompt, validate, integration };
140
150
  }
141
151
  function parseRepoFromLabels(labels) {
142
152
  for (const label of labels) {
@@ -411,6 +421,31 @@ async function addBranchLabel(client, ticketId, branchName) {
411
421
  log.debug(`Could not add branch label: ${err instanceof Error ? err.message : err}`);
412
422
  }
413
423
  }
424
+ async function findTicketsWithLabel(client, teamId, labelName) {
425
+ const team = await client.team(teamId);
426
+ const teamLabels = await team.labels();
427
+ const label = teamLabels.nodes.find((l) => l.name === labelName);
428
+ if (!label) return [];
429
+ const issues = await label.issues({ first: 50 });
430
+ const tickets = [];
431
+ for (const issue of issues.nodes) {
432
+ const labels = await issue.labels();
433
+ const labelNames = labels.nodes.map((l) => l.name);
434
+ const repoUrl = parseRepoFromLabels(labelNames);
435
+ const baseBranch = parseBaseFromLabels(labelNames);
436
+ tickets.push({
437
+ id: issue.id,
438
+ identifier: issue.identifier,
439
+ title: issue.title,
440
+ description: issue.description ?? "",
441
+ labels: labelNames,
442
+ repoUrl,
443
+ baseBranch,
444
+ url: issue.url
445
+ });
446
+ }
447
+ return tickets;
448
+ }
414
449
  async function failTicket(client, ticketId, reason) {
415
450
  await client.createComment({
416
451
  issueId: ticketId,
@@ -630,6 +665,21 @@ async function enableAutoMerge(octokit, opts) {
630
665
  }
631
666
  }, { label: "GitHub auto-merge", retryIf: isTransientError });
632
667
  }
668
+ async function findSiblingPRs(octokit, opts) {
669
+ const { owner, repo } = parseRepoUrl(opts.repoUrl);
670
+ const { data: prs } = await octokit.rest.pulls.list({
671
+ owner,
672
+ repo,
673
+ base: opts.baseBranch,
674
+ state: "open"
675
+ });
676
+ return prs.filter((pr) => pr.head.ref !== opts.excludeHead && pr.head.ref.startsWith(opts.branchPrefix)).map((pr) => ({
677
+ number: pr.number,
678
+ head: pr.head.ref,
679
+ base: pr.base.ref,
680
+ title: pr.title
681
+ }));
682
+ }
633
683
  async function retargetPR(octokit, opts) {
634
684
  return retry(async () => {
635
685
  const { owner, repo } = parseRepoUrl(opts.repoUrl);
@@ -1516,7 +1566,7 @@ function countRuns() {
1516
1566
  }
1517
1567
 
1518
1568
  // src/worker.ts
1519
- function buildPrompt(ticket, repoPrompt) {
1569
+ function buildPrompt(ticket, repoPrompt, siblings) {
1520
1570
  const parts = [];
1521
1571
  if (repoPrompt) {
1522
1572
  parts.push(repoPrompt);
@@ -1527,6 +1577,17 @@ function buildPrompt(ticket, repoPrompt) {
1527
1577
  if (ticket.description) {
1528
1578
  parts.push(ticket.description);
1529
1579
  }
1580
+ if (siblings && siblings.length > 0) {
1581
+ parts.push("");
1582
+ parts.push("## Related Tickets (being worked on in parallel branches)");
1583
+ parts.push("The following tickets target the same codebase and may be worked on concurrently.");
1584
+ parts.push("Ensure your implementation is compatible \u2014 use consistent type definitions, interfaces, and patterns.");
1585
+ parts.push("");
1586
+ for (const sib of siblings) {
1587
+ const desc = sib.description ? sib.description.length > 300 ? sib.description.slice(0, 300) + "..." : sib.description : "(no description)";
1588
+ parts.push(`- **${sib.identifier}: ${sib.title}** \u2014 ${desc}`);
1589
+ }
1590
+ }
1530
1591
  parts.push("");
1531
1592
  parts.push("## Instructions");
1532
1593
  parts.push("- Start by reading CLAUDE.md if it exists \u2014 it has project-specific guidance");
@@ -1665,6 +1726,97 @@ function buildValidationFixPrompt(failures) {
1665
1726
  parts.push("- When done, include [autoclawd:done] in your output");
1666
1727
  return parts.join("\n");
1667
1728
  }
1729
+ async function runCrossBranchValidation(container, validate, siblingBranches, ticketId) {
1730
+ const results = [];
1731
+ for (const siblingBranch of siblingBranches) {
1732
+ log.ticket(ticketId, `Cross-branch check: trial merge with ${siblingBranch}`);
1733
+ const fetchResult = await exec(container, ["git", "fetch", "origin", siblingBranch], {
1734
+ user: "autoclawd",
1735
+ env: ["HOME=/home/autoclawd"],
1736
+ timeout: 6e4
1737
+ });
1738
+ if (fetchResult.exitCode !== 0) {
1739
+ log.ticket(ticketId, ` Could not fetch ${siblingBranch}, skipping`);
1740
+ continue;
1741
+ }
1742
+ const mergeResult = await exec(container, [
1743
+ "git",
1744
+ "merge",
1745
+ "--no-commit",
1746
+ "--no-ff",
1747
+ `origin/${siblingBranch}`
1748
+ ], {
1749
+ user: "autoclawd",
1750
+ env: ["HOME=/home/autoclawd"],
1751
+ timeout: 6e4
1752
+ });
1753
+ if (mergeResult.exitCode !== 0) {
1754
+ const mergeOutput = (mergeResult.stdout + mergeResult.stderr).trim();
1755
+ const diffResult = await exec(container, ["git", "diff", "--diff-filter=U"], {
1756
+ user: "autoclawd",
1757
+ env: ["HOME=/home/autoclawd"],
1758
+ timeout: 1e4
1759
+ });
1760
+ const conflictDetail = diffResult.stdout.trim();
1761
+ const output = [
1762
+ mergeOutput,
1763
+ "",
1764
+ "Conflict details (files with conflict markers):",
1765
+ conflictDetail || "(no diff available)"
1766
+ ].join("\n").slice(-3e3);
1767
+ results.push({
1768
+ branch: siblingBranch,
1769
+ failures: [{
1770
+ command: `git merge origin/${siblingBranch}`,
1771
+ exitCode: mergeResult.exitCode,
1772
+ output
1773
+ }]
1774
+ });
1775
+ await exec(container, ["git", "merge", "--abort"], {
1776
+ user: "autoclawd",
1777
+ env: ["HOME=/home/autoclawd"],
1778
+ timeout: 1e4
1779
+ });
1780
+ continue;
1781
+ }
1782
+ const failures = await runValidation(container, validate, ticketId);
1783
+ results.push({ branch: siblingBranch, failures });
1784
+ await exec(container, ["git", "merge", "--abort"], {
1785
+ user: "autoclawd",
1786
+ env: ["HOME=/home/autoclawd"],
1787
+ timeout: 1e4
1788
+ });
1789
+ }
1790
+ return results;
1791
+ }
1792
+ function buildCrossBranchFixPrompt(crossResults) {
1793
+ const parts = [];
1794
+ parts.push("## Cross-Branch Compatibility Issues");
1795
+ parts.push("");
1796
+ parts.push("Your branch was trial-merged with sibling branches that target the same codebase.");
1797
+ parts.push("The following issues were found. You must fix YOUR code to be compatible \u2014 do not assume the sibling is wrong.");
1798
+ parts.push("");
1799
+ for (const { branch, failures } of crossResults) {
1800
+ if (failures.length === 0) continue;
1801
+ parts.push(`### Conflicts with \`${branch}\``);
1802
+ for (const f of failures) {
1803
+ parts.push(`#### \`${f.command}\` (exit code ${f.exitCode})`);
1804
+ parts.push("```");
1805
+ parts.push(f.output);
1806
+ parts.push("```");
1807
+ parts.push("");
1808
+ }
1809
+ }
1810
+ parts.push("## Instructions");
1811
+ parts.push("- Fix YOUR code to be compatible with the sibling branches");
1812
+ parts.push("- Focus on: consistent type definitions, matching interfaces, compatible exports");
1813
+ parts.push("- If there are merge conflicts in a file (e.g. src/index.ts), you MUST include BOTH your changes AND the sibling's changes in that file \u2014 fetch the sibling branch, look at their version of the file, and make yours include both sets of changes");
1814
+ parts.push("- To see what the sibling added: run `git diff main..origin/<sibling-branch> -- <file>`");
1815
+ parts.push("- Run validation commands to verify your fix");
1816
+ parts.push('- Commit your fixes with: git add -A && git commit -m "fix: resolve cross-branch compatibility"');
1817
+ parts.push("- When done, include [autoclawd:done] in your output");
1818
+ return parts.join("\n");
1819
+ }
1668
1820
  async function executeTicket(opts) {
1669
1821
  const { ticket, config, linearClient, octokit, force } = opts;
1670
1822
  const startedAt = /* @__PURE__ */ new Date();
@@ -1792,7 +1944,7 @@ async function executeTicket(opts) {
1792
1944
  log.ticket(ticket.identifier, "Cloned repo");
1793
1945
  const repoLocal = loadRepoLocalConfig(workDir);
1794
1946
  const actualBase = ticket.baseBranch ?? repoLocal?.base ?? detectedBase;
1795
- const { agent, docker: docker2, prompt, validate } = mergeConfigs(config, repoLocal);
1947
+ const { agent, docker: docker2, prompt, validate, integration } = mergeConfigs(config, repoLocal);
1796
1948
  if (repoLocal) {
1797
1949
  log.ticket(ticket.identifier, "Loaded .autoclawd.yaml from repo");
1798
1950
  }
@@ -1826,7 +1978,46 @@ async function executeTicket(opts) {
1826
1978
  name: containerName
1827
1979
  });
1828
1980
  await setupRepo(container, { branchName });
1829
- const agentPrompt = buildPrompt(ticket, prompt);
1981
+ let siblings = [];
1982
+ try {
1983
+ const siblingPRs = await findSiblingPRs(octokit, {
1984
+ repoUrl: ticket.repoUrl,
1985
+ baseBranch: actualBase,
1986
+ excludeHead: branchName,
1987
+ branchPrefix: config.safety.branchPrefix
1988
+ });
1989
+ for (const spr of siblingPRs) {
1990
+ const match = spr.head.match(/^(?:autoclawd\/)([A-Za-z]+-\d+)/);
1991
+ if (!match) continue;
1992
+ const sibTicket = await fetchTicket(linearClient, match[1]);
1993
+ if (sibTicket && sibTicket.identifier !== ticket.identifier) {
1994
+ siblings.push({
1995
+ identifier: sibTicket.identifier,
1996
+ title: sibTicket.title,
1997
+ description: sibTicket.description
1998
+ });
1999
+ }
2000
+ }
2001
+ if (ticket.baseBranch && siblings.length === 0) {
2002
+ const baseLabelName = `base:${ticket.baseBranch}`;
2003
+ const siblingTickets = await findTicketsWithLabel(linearClient, config.linear.teamId, baseLabelName);
2004
+ for (const sib of siblingTickets) {
2005
+ if (sib.identifier !== ticket.identifier) {
2006
+ siblings.push({
2007
+ identifier: sib.identifier,
2008
+ title: sib.title,
2009
+ description: sib.description
2010
+ });
2011
+ }
2012
+ }
2013
+ }
2014
+ if (siblings.length > 0) {
2015
+ log.ticket(ticket.identifier, `Found ${siblings.length} sibling ticket(s): ${siblings.map((s) => s.identifier).join(", ")}`);
2016
+ }
2017
+ } catch (err) {
2018
+ log.debug(`Could not discover siblings: ${err instanceof Error ? err.message : err}`);
2019
+ }
2020
+ const agentPrompt = buildPrompt(ticket, prompt, siblings.length > 0 ? siblings : void 0);
1830
2021
  let agentResult = await runAgentLoop({
1831
2022
  container,
1832
2023
  agentConfig: agent,
@@ -1967,6 +2158,78 @@ async function executeTicket(opts) {
1967
2158
  }
1968
2159
  }
1969
2160
  }
2161
+ if (integration.crossBranchValidate && validate && validate.length > 0) {
2162
+ try {
2163
+ const siblingPRs = await findSiblingPRs(octokit, {
2164
+ repoUrl: ticket.repoUrl,
2165
+ baseBranch: actualBase,
2166
+ excludeHead: branchName,
2167
+ branchPrefix: config.safety.branchPrefix
2168
+ });
2169
+ const siblingBranches = siblingPRs.map((s) => s.head);
2170
+ if (siblingBranches.length > 0) {
2171
+ log.ticket(ticket.identifier, `Running cross-branch validation against ${siblingBranches.length} sibling(s)`);
2172
+ let crossIterations = 0;
2173
+ const maxCrossIterations = integration.maxCrossBranchIterations;
2174
+ while (crossIterations < maxCrossIterations) {
2175
+ const crossResults = await runCrossBranchValidation(
2176
+ container,
2177
+ validate,
2178
+ siblingBranches,
2179
+ ticket.identifier
2180
+ );
2181
+ const failing = crossResults.filter((r) => r.failures.length > 0);
2182
+ if (failing.length === 0) {
2183
+ log.ticket(ticket.identifier, "Cross-branch validation passed");
2184
+ break;
2185
+ }
2186
+ crossIterations++;
2187
+ log.ticket(ticket.identifier, `Cross-branch issues with ${failing.map((f) => f.branch).join(", ")} (attempt ${crossIterations}/${maxCrossIterations})`);
2188
+ const fixPrompt = buildCrossBranchFixPrompt(failing);
2189
+ const fixResult = await runAgentLoop({
2190
+ container,
2191
+ agentConfig: { ...agent, maxIterations: 1 },
2192
+ prompt: fixPrompt,
2193
+ ticketId: ticket.identifier
2194
+ });
2195
+ agentResult = {
2196
+ iterations: agentResult.iterations + fixResult.iterations,
2197
+ success: fixResult.success,
2198
+ lastOutput: fixResult.lastOutput
2199
+ };
2200
+ await commitAndPush(container, {
2201
+ branchName,
2202
+ ticketId: ticket.identifier,
2203
+ title: ticket.title,
2204
+ repoUrl: ticket.repoUrl,
2205
+ githubToken: config.github.token,
2206
+ branchPrefix: config.safety.branchPrefix,
2207
+ maxFileChanges: config.safety.maxFileChanges
2208
+ });
2209
+ }
2210
+ const finalCross = await runCrossBranchValidation(
2211
+ container,
2212
+ validate,
2213
+ siblingBranches,
2214
+ ticket.identifier
2215
+ );
2216
+ const stillFailing = finalCross.filter((r) => r.failures.length > 0);
2217
+ if (stillFailing.length > 0) {
2218
+ const allFailures = stillFailing.flatMap(
2219
+ (r) => r.failures.map((f) => ({ ...f, command: `[cross: ${r.branch}] ${f.command}` }))
2220
+ );
2221
+ log.ticket(ticket.identifier, "Cross-branch validation still failing \u2014 adding warnings to PR");
2222
+ await updatePRBody(octokit, {
2223
+ repoUrl: ticket.repoUrl,
2224
+ prNumber: pr.number,
2225
+ body: buildPRBody({ validationWarnings: allFailures, commitCount: gitResult.commitCount })
2226
+ });
2227
+ }
2228
+ }
2229
+ } catch (err) {
2230
+ log.debug(`Cross-branch validation error: ${err instanceof Error ? err.message : err}`);
2231
+ }
2232
+ }
1970
2233
  await reviewTicket(linearClient, config, ticket.id, `PR opened: ${pr.url}`);
1971
2234
  await addBranchLabel(linearClient, ticket.id, branchName);
1972
2235
  log.success(`${ticket.identifier} done \u2014 ${pr.url}`);
@@ -2223,6 +2486,7 @@ var WebhookServer = class {
2223
2486
  } else {
2224
2487
  return;
2225
2488
  }
2489
+ this.recentlyRequeued.clear();
2226
2490
  if (this.activeInMemory.has(data.id) || isTicketActive(data.id)) {
2227
2491
  log.debug(`${data.identifier}: already active, ignoring`);
2228
2492
  return;
@@ -2288,6 +2552,7 @@ var WebhookServer = class {
2288
2552
  log.ticket(ticket.identifier, "Re-queued (waiting for dependency branch)");
2289
2553
  finishRun(ticket.id, "failed", result.error);
2290
2554
  removeFromProcessed(ticket.id);
2555
+ this.recentlyRequeued.add(ticket.id);
2291
2556
  enqueue(ticket);
2292
2557
  } else if (result.success) {
2293
2558
  finishRun(ticket.id, "success");
@@ -2303,14 +2568,33 @@ var WebhookServer = class {
2303
2568
  this.drainQueue();
2304
2569
  });
2305
2570
  }
2571
+ // Track recently requeued tickets to prevent cycling in the same drain
2572
+ recentlyRequeued = /* @__PURE__ */ new Set();
2306
2573
  drainQueue() {
2307
2574
  if (this.pausedUntil > Date.now()) return;
2308
- while (this.activeInMemory.size < this.config.maxConcurrent) {
2575
+ const tickets = [];
2576
+ while (true) {
2309
2577
  const next = dequeue();
2310
2578
  if (!next) break;
2311
- if (this.activeInMemory.has(next.id)) continue;
2312
- log.ticket(next.identifier, `Dequeued (${getQueueLength()} remaining)`);
2313
- this.dispatch(next);
2579
+ tickets.push(next);
2580
+ }
2581
+ tickets.sort((a, b) => {
2582
+ const aStacked = a.baseBranch ? 1 : 0;
2583
+ const bStacked = b.baseBranch ? 1 : 0;
2584
+ return aStacked - bStacked;
2585
+ });
2586
+ for (const ticket of tickets) {
2587
+ if (this.activeInMemory.has(ticket.id)) continue;
2588
+ if (ticket.baseBranch && this.recentlyRequeued.has(ticket.id)) {
2589
+ enqueue(ticket);
2590
+ continue;
2591
+ }
2592
+ if (this.activeInMemory.size >= this.config.maxConcurrent) {
2593
+ enqueue(ticket);
2594
+ continue;
2595
+ }
2596
+ log.ticket(ticket.identifier, `Dequeued (${getQueueLength()} remaining)`);
2597
+ this.dispatch(ticket);
2314
2598
  }
2315
2599
  }
2316
2600
  };