automata-cli 0.7.0-develop.316 → 0.7.0-develop.324

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.
Files changed (2) hide show
  1. package/dist/index.js +1364 -1219
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -815,10 +815,14 @@ function listRemoteBranches() {
815
815
  function createBranchAtHead(branch) {
816
816
  return gitCommand(["checkout", "-b", branch]);
817
817
  }
818
+ function pathIsIgnored(path) {
819
+ return run2("git", ["check-ignore", "-q", "--", path]).status === 0;
820
+ }
818
821
  function stageAllExcept(excludePaths) {
822
+ const needed = excludePaths.filter((path) => !pathIsIgnored(path));
819
823
  const args = ["add", "-A"];
820
- if (excludePaths.length > 0) {
821
- args.push("--", ".", ...excludePaths.map((path) => `:(exclude)${path}`));
824
+ if (needed.length > 0) {
825
+ args.push("--", ".", ...needed.map((path) => `:(exclude)${path}`));
822
826
  }
823
827
  return gitCommand(args);
824
828
  }
@@ -1534,7 +1538,7 @@ function getCurrentBranchPr(branch) {
1534
1538
  if (branch) {
1535
1539
  args.push(branch);
1536
1540
  }
1537
- args.push("--json", "number,url,body");
1541
+ args.push("--json", "number,url,body,assignees");
1538
1542
  const { stdout, stderr, status } = run3("gh", args);
1539
1543
  if (status !== 0) {
1540
1544
  if (stderr.includes("no pull requests found") || stderr.includes("Could not resolve")) {
@@ -1542,7 +1546,15 @@ function getCurrentBranchPr(branch) {
1542
1546
  }
1543
1547
  throw new Error(stderr.trim() || "Failed to query PR for current branch.");
1544
1548
  }
1545
- return JSON.parse(stdout);
1549
+ const raw = JSON.parse(stdout);
1550
+ return {
1551
+ number: raw.number,
1552
+ url: raw.url,
1553
+ body: raw.body,
1554
+ // Absent when an older `gh` ignores the field, and `[]` is the right reading
1555
+ // of "nobody is assigned" either way.
1556
+ assignees: (raw.assignees ?? []).map((a) => a.login ?? "").filter((name) => name.length > 0)
1557
+ };
1546
1558
  }
1547
1559
  function addClosesRefToPr(prNumber, issueNumber) {
1548
1560
  const { stdout, status: viewStatus } = run3("gh", [
@@ -1577,1335 +1589,1350 @@ function addCopilotReviewer(prNumber) {
1577
1589
  }
1578
1590
  }
1579
1591
 
1580
- // src/claude/claudeService.ts
1581
- import { spawn, spawnSync as spawnSync4 } from "child_process";
1582
- import { createInterface } from "readline";
1583
- import { existsSync } from "fs";
1584
- import { delimiter, join } from "path";
1585
-
1586
- // src/cli/spawnUtils.ts
1587
- var SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
1588
- var ESCAPED_QUOTE = String.raw`'\''`;
1589
- function shellQuote(arg) {
1590
- if (SHELL_SAFE.test(arg)) return arg;
1591
- return "'" + arg.replaceAll("'", ESCAPED_QUOTE) + "'";
1592
- }
1593
- function truncate(str, max) {
1594
- return str.length > max ? str.slice(0, max) + "..." : str;
1595
- }
1596
- function handleSpawnError(error, toolName) {
1597
- if (!error) return;
1598
- const err = error;
1599
- if (err.code === "ENOENT") {
1600
- process.stderr.write(`Error: \`${toolName}\` CLI is not installed or not on PATH.
1601
- `);
1602
- process.exit(1);
1592
+ // src/github/ghWorkService.ts
1593
+ import { spawnSync as spawnSync4 } from "child_process";
1594
+ function run4(cmd, args) {
1595
+ const result = spawnSync4(cmd, args, { encoding: "utf8" });
1596
+ if (result.error) {
1597
+ const err = result.error;
1598
+ if (err.code === "ENOENT") {
1599
+ throw new Error(`\`${cmd}\` CLI is not installed or not on PATH.`);
1600
+ }
1601
+ throw new Error(err.message);
1603
1602
  }
1604
- process.stderr.write(`Error: ${err.message}
1605
- `);
1606
- process.exit(1);
1603
+ return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status ?? 1 };
1607
1604
  }
1608
- function handleExitCode(status, toolName) {
1609
- if (status === null) {
1610
- process.stderr.write(`Error: ${toolName} terminated abnormally (exit code is null, likely due to a signal).
1611
- `);
1612
- process.exit(1);
1613
- }
1605
+ function ghJson(args, what) {
1606
+ const { stdout, stderr, status } = run4("gh", args);
1614
1607
  if (status !== 0) {
1615
- process.stderr.write(`Error: ${toolName} exited with code ${status}.
1616
- `);
1617
- process.exit(status);
1618
- }
1619
- }
1620
- function resolveEffortOption(value) {
1621
- if (value === void 0) return void 0;
1622
- const trimmed2 = value.trim();
1623
- if (trimmed2.length === 0) {
1624
- process.stderr.write("Error: --effort must be a non-empty level.\n");
1625
- process.exit(1);
1608
+ throw new Error(stderr.trim() || `Failed to ${what}. Is \`gh\` installed and authenticated?`);
1626
1609
  }
1627
- return trimmed2;
1628
- }
1629
-
1630
- // src/cli/childRegistry.ts
1631
- var active = /* @__PURE__ */ new Set();
1632
- function trackChild(child) {
1633
- active.add(child);
1610
+ return JSON.parse(stdout);
1634
1611
  }
1635
- function untrackChild(child) {
1636
- active.delete(child);
1612
+ function login(author) {
1613
+ return author?.login ?? "";
1637
1614
  }
1638
- function waitForExit(child) {
1639
- return new Promise((resolve2) => {
1640
- if (child.exitCode !== null || child.signalCode !== null) {
1641
- resolve2();
1642
- return;
1643
- }
1644
- child.once("exit", () => resolve2());
1645
- });
1615
+ function byCreatedAt(a, b) {
1616
+ return a.createdAt.localeCompare(b.createdAt);
1646
1617
  }
1647
- function afterDelay(ms) {
1648
- return new Promise((resolve2) => {
1649
- const timer = setTimeout(() => resolve2("timeout"), ms);
1650
- timer.unref();
1651
- });
1618
+ function visibleAt(createdAt, submittedAt) {
1619
+ if (submittedAt === null || submittedAt === void 0) return createdAt;
1620
+ return submittedAt > createdAt ? submittedAt : createdAt;
1652
1621
  }
1653
- async function terminateTrackedChildren(timeoutMs = 1e4, killGraceMs = 5e3) {
1654
- const children = [...active];
1655
- if (children.length === 0) return true;
1656
- const exits = children.map((child) => waitForExit(child));
1657
- for (const child of children) {
1658
- if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
1622
+ function getRepoSlug() {
1623
+ const { stdout, status } = run4("git", ["remote", "get-url", "origin"]);
1624
+ if (status !== 0) {
1625
+ throw new Error("Could not read the `origin` remote. Is this a git repository with a remote?");
1659
1626
  }
1660
- const settled = await Promise.race([Promise.all(exits).then(() => "exited"), afterDelay(timeoutMs)]);
1661
- if (settled === "exited") return true;
1662
- for (const child of children) {
1663
- if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
1627
+ const url = stdout.trim();
1628
+ const match = /github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url) ?? /github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url);
1629
+ if (!match) {
1630
+ throw new Error(`Could not determine the GitHub owner/repo from the origin remote: ${url}`);
1664
1631
  }
1665
- const escalated = await Promise.race([
1666
- Promise.all(exits).then(() => "exited"),
1667
- afterDelay(killGraceMs)
1668
- ]);
1669
- return escalated === "exited";
1632
+ return { owner: match[1], repo: match[2] };
1670
1633
  }
1671
-
1672
- // src/claude/claudeService.ts
1673
- function resolveCommand(name) {
1674
- const pathDirs = (process.env["PATH"] ?? "").split(delimiter);
1675
- for (const dir of pathDirs) {
1676
- const candidate = join(dir, name);
1677
- if (existsSync(candidate)) return candidate;
1634
+ function getAuthenticatedLogin() {
1635
+ const { stdout, status } = run4("gh", ["api", "user", "--jq", ".login"]);
1636
+ if (status !== 0) return null;
1637
+ const login2 = stdout.trim();
1638
+ return login2.length > 0 ? login2 : null;
1639
+ }
1640
+ function listCandidateIssues(technique, value, limit) {
1641
+ const args = [
1642
+ "issue",
1643
+ "list",
1644
+ "--state",
1645
+ "open",
1646
+ "--limit",
1647
+ String(limit),
1648
+ "--json",
1649
+ "number,title,body,url"
1650
+ ];
1651
+ switch (technique) {
1652
+ case "label":
1653
+ args.push("--label", value);
1654
+ break;
1655
+ case "assignee":
1656
+ args.push("--assignee", value);
1657
+ break;
1658
+ case "title-contains":
1659
+ args.push("--search", `${value} in:title`);
1660
+ break;
1678
1661
  }
1679
- return name;
1662
+ return ghJson(args, "query GitHub issues");
1680
1663
  }
1681
- function buildClaudeArgs(prompt, options = {}) {
1682
- const args = [];
1683
- if (options.yolo) args.push("--dangerously-skip-permissions");
1684
- if (options.model) args.push("--model", options.model);
1685
- if (options.effort) args.push("--effort", options.effort);
1686
- if (options.verbose) args.push("--verbose", "--output-format", "stream-json");
1687
- args.push("-p", prompt);
1688
- return args;
1664
+ function getIssueSurface(issueNumber) {
1665
+ const raw = ghJson(
1666
+ [
1667
+ "issue",
1668
+ "view",
1669
+ String(issueNumber),
1670
+ "--json",
1671
+ "number,title,body,url,state,author,createdAt,assignees,labels,comments"
1672
+ ],
1673
+ `read issue #${String(issueNumber)}`
1674
+ );
1675
+ const messages = [
1676
+ {
1677
+ kind: "issue-body",
1678
+ author: login(raw.author),
1679
+ body: raw.body,
1680
+ createdAt: raw.createdAt
1681
+ },
1682
+ ...(raw.comments ?? []).map((comment) => ({
1683
+ kind: "issue-comment",
1684
+ author: login(comment.author),
1685
+ body: comment.body,
1686
+ createdAt: comment.createdAt
1687
+ }))
1688
+ ].sort(byCreatedAt);
1689
+ return {
1690
+ issue: { number: raw.number, title: raw.title, body: raw.body, url: raw.url },
1691
+ state: raw.state === "CLOSED" ? "CLOSED" : "OPEN",
1692
+ assignees: (raw.assignees ?? []).map(login).filter((name) => name.length > 0),
1693
+ labels: (raw.labels ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
1694
+ messages
1695
+ };
1689
1696
  }
1690
- function runClaude(prompt, options = {}) {
1691
- return new Promise((resolve2, reject) => {
1692
- const claudeBin = resolveCommand("claude");
1693
- const args = buildClaudeArgs(prompt, {
1694
- yolo: true,
1695
- model: options.model,
1696
- effort: options.effort,
1697
- verbose: true
1698
- });
1699
- const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
1700
- trackChild(child);
1701
- const rl = createInterface({ input: child.stdout });
1702
- let turnCount = 0;
1703
- rl.on("line", (line) => {
1704
- if (options.printSteps !== true) return;
1705
- try {
1706
- const event = JSON.parse(line);
1707
- formatEvent(event, turnCount);
1708
- if (event["type"] === "assistant") turnCount++;
1709
- } catch {
1710
- }
1711
- });
1712
- child.on("error", (err) => {
1713
- untrackChild(child);
1714
- const nodeErr = err;
1715
- reject(
1716
- nodeErr.code === "ENOENT" ? new Error("`claude` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
1717
- );
1718
- });
1719
- child.on("close", (code, signal) => {
1720
- untrackChild(child);
1721
- if (code === 0) {
1722
- resolve2();
1723
- return;
1697
+ var LINK_MAP_QUERY = `
1698
+ query($owner:String!,$repo:String!,$cursor:String){
1699
+ repository(owner:$owner,name:$repo){
1700
+ defaultBranchRef{ name }
1701
+ pullRequests(states:OPEN, first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}){
1702
+ pageInfo{ hasNextPage endCursor }
1703
+ nodes{
1704
+ number url title headRefName baseRefName isCrossRepository isDraft updatedAt
1705
+ labels(first:50){ nodes{ name } }
1706
+ assignees(first:50){ nodes{ login } }
1707
+ closingIssuesReferences(first:50){
1708
+ pageInfo{ hasNextPage }
1709
+ nodes{ number repository{ nameWithOwner } }
1710
+ }
1724
1711
  }
1725
- reject(
1726
- new Error(
1727
- signal === null ? `Claude Code exited with code ${String(code)}.` : `Claude Code terminated on ${signal}.`
1728
- )
1729
- );
1730
- });
1731
- });
1732
- }
1733
- function invokeClaudeCode(prompt, options = {}) {
1734
- if (options.verbose) {
1735
- return invokeClaudeCodeVerbose(prompt, options.yolo ?? false, options.model, options.effort);
1712
+ }
1736
1713
  }
1737
- invokeClaudeCodeSync(prompt, options.yolo ?? false, options.model, options.effort);
1738
- }
1739
- function invokeClaudeCodeSync(prompt, yolo, model, effort) {
1740
- const claudeBin = resolveCommand("claude");
1741
- const args = buildClaudeArgs(prompt, { yolo, model, effort, verbose: false });
1742
- const result = spawnSync4(claudeBin, args, { encoding: "utf8", stdio: "inherit" });
1743
- handleSpawnError(result.error, "claude");
1744
- handleExitCode(result.status, "Claude Code");
1745
- }
1746
- function invokeClaudeCodeVerbose(prompt, yolo, model, effort) {
1747
- return new Promise((resolve2) => {
1748
- const claudeBin = resolveCommand("claude");
1749
- const args = buildClaudeArgs(prompt, { yolo, model, effort, verbose: true });
1750
- const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
1751
- const rl = createInterface({ input: child.stdout });
1752
- let turnCount = 0;
1753
- child.on("error", (err) => {
1754
- handleSpawnError(err, "claude");
1755
- });
1756
- rl.on("line", (line) => {
1757
- try {
1758
- const event = JSON.parse(line);
1759
- formatEvent(event, turnCount);
1760
- if (event["type"] === "assistant") turnCount++;
1761
- } catch {
1762
- }
1763
- });
1764
- child.on("close", (code) => {
1765
- handleExitCode(code, "Claude Code");
1766
- resolve2();
1767
- });
1768
- });
1769
- }
1770
- function formatAssistantEvent(event, turnCount) {
1771
- const message = event["message"];
1772
- const content = message?.["content"];
1773
- if (!content) return;
1774
- for (const block of content) {
1775
- const line = formatContentBlock(block);
1776
- if (line !== null) {
1777
- process.stderr.write(` [step ${turnCount + 1}] ${line}
1778
- `);
1779
- }
1780
- }
1781
- }
1782
- function formatContentBlock(block) {
1783
- if (block["type"] === "tool_use") {
1784
- const toolName = block["name"];
1785
- const input = block["input"];
1786
- return summarizeTool(toolName, input);
1787
- }
1788
- if (block["type"] === "text") {
1789
- const text = block["text"] ?? "";
1790
- if (text.length === 0) return null;
1791
- const preview = text.length > 120 ? text.slice(0, 120) + "..." : text;
1792
- return preview.split("\n")[0] ?? null;
1793
- }
1794
- return null;
1795
- }
1796
- function formatResultEvent(event) {
1797
- const result = event["result"];
1798
- const cost = event["cost_usd"];
1799
- const duration = event["duration_ms"];
1800
- const turns = event["num_turns"];
1801
- process.stderr.write("\n--- Result ---\n");
1802
- const parts = [];
1803
- if (turns !== void 0) parts.push(`${turns} turns`);
1804
- if (duration !== void 0) parts.push(`${(duration / 1e3).toFixed(1)}s`);
1805
- if (cost !== void 0) parts.push(`$${cost.toFixed(4)}`);
1806
- if (parts.length > 0) {
1807
- process.stderr.write(` [info] ${parts.join(" | ")}
1808
- `);
1809
- }
1810
- if (result) {
1811
- process.stdout.write(result + "\n");
1812
- }
1813
- }
1814
- function formatEvent(event, turnCount) {
1815
- const type = event["type"];
1816
- if (type === "assistant") {
1817
- formatAssistantEvent(event, turnCount);
1818
- } else if (type === "result") {
1819
- formatResultEvent(event);
1714
+ }`.trim();
1715
+ var MAX_LINK_MAP_PAGES = 50;
1716
+ function indexPullRequest(map, node, nameWithOwner) {
1717
+ const ref = toPullRequestRef(node);
1718
+ if (node.closingIssuesReferences.pageInfo?.hasNextPage) {
1719
+ throw new Error(
1720
+ `Pull request #${String(node.number)} closes more than 50 issues, so the issue-to-pull-request map cannot be read completely. Refusing the tick rather than risk starting work on an issue that already has a pull request.`
1721
+ );
1820
1722
  }
1821
- }
1822
- function asText(value, fallback) {
1823
- return typeof value === "string" ? value : fallback;
1824
- }
1825
- function summarizeTool(name, input) {
1826
- if (!input) return `tool: ${name}`;
1827
- switch (name) {
1828
- case "Read":
1829
- return `reading ${asText(input["file_path"], "file")}`;
1830
- case "Write":
1831
- return `writing ${asText(input["file_path"], "file")}`;
1832
- case "Edit":
1833
- return `editing ${asText(input["file_path"], "file")}`;
1834
- case "Bash":
1835
- return `running: ${truncate(asText(input["command"], ""), 80)}`;
1836
- case "Glob":
1837
- return `searching files: ${asText(input["pattern"], "")}`;
1838
- case "Grep":
1839
- return `searching content: ${truncate(asText(input["pattern"], ""), 60)}`;
1840
- case "Agent":
1841
- return `spawning agent: ${asText(input["description"], name)}`;
1842
- default:
1843
- return `tool: ${name}`;
1723
+ let closedAnyHere = false;
1724
+ for (const issue of node.closingIssuesReferences.nodes) {
1725
+ if (issue.repository.nameWithOwner.toLowerCase() !== nameWithOwner.toLowerCase()) continue;
1726
+ closedAnyHere = true;
1727
+ const existing = map.get(issue.number);
1728
+ if (existing) {
1729
+ existing.push(ref);
1730
+ } else {
1731
+ map.set(issue.number, [ref]);
1732
+ }
1844
1733
  }
1734
+ return closedAnyHere;
1845
1735
  }
1846
-
1847
- // src/codex/codexService.ts
1848
- import { spawn as spawn2, spawnSync as spawnSync5 } from "child_process";
1849
- function toTomlBasicString(value) {
1850
- return JSON.stringify(value);
1736
+ function toPullRequestRef(node) {
1737
+ return {
1738
+ number: node.number,
1739
+ url: node.url,
1740
+ title: node.title,
1741
+ headRefName: node.headRefName,
1742
+ baseRefName: node.baseRefName,
1743
+ isCrossRepository: node.isCrossRepository,
1744
+ state: "OPEN",
1745
+ isDraft: node.isDraft,
1746
+ updatedAt: node.updatedAt
1747
+ };
1851
1748
  }
1852
- function buildCodexArgs(prompt, options = {}) {
1853
- const args = ["exec"];
1854
- if (options.yolo) args.push("--dangerously-bypass-approvals-and-sandbox");
1855
- if (options.model) args.push("--model", options.model);
1856
- if (options.effort) args.push("-c", `model_reasoning_effort=${toTomlBasicString(options.effort)}`);
1857
- args.push(prompt);
1858
- return args;
1749
+ function toOrphanPr(node) {
1750
+ return {
1751
+ pr: toPullRequestRef(node),
1752
+ labels: (node.labels?.nodes ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
1753
+ assignees: (node.assignees?.nodes ?? []).map((assignee) => assignee.login ?? "").filter((login2) => login2.length > 0)
1754
+ };
1859
1755
  }
1860
- function invokeCodexCode(prompt, options = {}) {
1861
- if (options.verbose) {
1862
- process.stderr.write("Warning: --verbose is not supported for Codex and will be ignored.\n");
1756
+ function getOpenPrLinkMap() {
1757
+ const { owner, repo } = getRepoSlug();
1758
+ const map = /* @__PURE__ */ new Map();
1759
+ const orphans = [];
1760
+ let defaultBranch = null;
1761
+ let cursor = null;
1762
+ for (let page = 0; page < MAX_LINK_MAP_PAGES; page++) {
1763
+ const args = ["api", "graphql", "-f", `query=${LINK_MAP_QUERY}`, "-f", `owner=${owner}`, "-f", `repo=${repo}`];
1764
+ if (cursor !== null) args.push("-f", `cursor=${cursor}`);
1765
+ const response = ghJson(args, "query open pull requests");
1766
+ defaultBranch = response.data.repository.defaultBranchRef?.name ?? defaultBranch;
1767
+ const connection = response.data.repository.pullRequests;
1768
+ for (const node of connection.nodes) {
1769
+ if (!indexPullRequest(map, node, `${owner}/${repo}`)) {
1770
+ orphans.push(toOrphanPr(node));
1771
+ }
1772
+ }
1773
+ if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
1774
+ return { byIssue: map, defaultBranch, orphans };
1775
+ }
1776
+ cursor = connection.pageInfo.endCursor;
1863
1777
  }
1864
- invokeCodexCodeSync(prompt, options.yolo ?? false, options.model, options.effort);
1865
- }
1866
- function invokeCodexCodeSync(prompt, yolo, model, effort) {
1867
- const codexBin = resolveCommand("codex");
1868
- const args = buildCodexArgs(prompt, { yolo, model, effort });
1869
- const result = spawnSync5(codexBin, args, { encoding: "utf8", stdio: "inherit" });
1870
- handleSpawnError(result.error, "codex");
1871
- handleExitCode(result.status, "Codex");
1778
+ throw new Error(
1779
+ `Stopped paginating open pull requests after ${String(MAX_LINK_MAP_PAGES)} pages, so the issue-to-pull-request map cannot be trusted. Refusing the tick rather than risk starting work on an issue that already has a pull request.`
1780
+ );
1872
1781
  }
1873
- function runCodex(prompt, options = {}) {
1874
- return new Promise((resolve2, reject) => {
1875
- const codexBin = resolveCommand("codex");
1876
- const args = buildCodexArgs(prompt, { yolo: true, model: options.model, effort: options.effort });
1877
- const child = spawn2(codexBin, args, { stdio: "inherit" });
1878
- trackChild(child);
1879
- child.on("error", (err) => {
1880
- untrackChild(child);
1881
- const nodeErr = err;
1882
- reject(
1883
- nodeErr.code === "ENOENT" ? new Error("`codex` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
1884
- );
1885
- });
1886
- child.on("close", (code, signal) => {
1887
- untrackChild(child);
1888
- if (code === 0) {
1889
- resolve2();
1890
- return;
1782
+ var REVIEW_THREADS_QUERY2 = `
1783
+ query($owner:String!,$repo:String!,$prNumber:Int!,$cursor:String){
1784
+ repository(owner:$owner,name:$repo){
1785
+ pullRequest(number:$prNumber){
1786
+ reviewThreads(first:100, after:$cursor){
1787
+ pageInfo{ hasNextPage endCursor }
1788
+ nodes{
1789
+ isResolved isOutdated path line
1790
+ comments(last:100){
1791
+ pageInfo{ hasPreviousPage }
1792
+ nodes{ author{login} body createdAt url pullRequestReview{ submittedAt } }
1793
+ }
1794
+ }
1891
1795
  }
1892
- reject(
1893
- new Error(
1894
- signal === null ? `Codex exited with code ${String(code)}.` : `Codex terminated on ${signal}.`
1895
- )
1896
- );
1897
- });
1898
- });
1899
- }
1900
-
1901
- // src/commands/getReady.ts
1902
- function writeOverflowHint(output, issues, limit) {
1903
- if (issues.length === limit) {
1904
- output.write(`(Showing first ${limit} matching issues \u2014 there may be more. Use --limit to fetch more.)
1905
- `);
1796
+ }
1906
1797
  }
1798
+ }`.trim();
1799
+ var MAX_THREAD_PAGES = 50;
1800
+ function normalizePrState(state) {
1801
+ if (state === "MERGED") return "MERGED";
1802
+ if (state === "CLOSED") return "CLOSED";
1803
+ return "OPEN";
1907
1804
  }
1908
- function writeIssueList(output, issues, limit) {
1909
- output.write("\nAvailable issues:\n");
1910
- for (let i = 0; i < issues.length; i++) {
1911
- output.write(` [${i + 1}] #${issues[i].number} - ${issues[i].title}
1912
- `);
1805
+ function getReviewThreads(prNumber) {
1806
+ const { owner, repo } = getRepoSlug();
1807
+ const threads = [];
1808
+ let cursor = null;
1809
+ for (let page = 0; page < MAX_THREAD_PAGES; page++) {
1810
+ const args = [
1811
+ "api",
1812
+ "graphql",
1813
+ "-f",
1814
+ `query=${REVIEW_THREADS_QUERY2}`,
1815
+ "-f",
1816
+ `owner=${owner}`,
1817
+ "-f",
1818
+ `repo=${repo}`,
1819
+ "-F",
1820
+ `prNumber=${String(prNumber)}`
1821
+ ];
1822
+ if (cursor !== null) args.push("-f", `cursor=${cursor}`);
1823
+ const response = ghJson(
1824
+ args,
1825
+ `query review threads for pull request #${String(prNumber)}`
1826
+ );
1827
+ const connection = response.data.repository.pullRequest.reviewThreads;
1828
+ for (const node of connection.nodes) {
1829
+ if (node.comments.pageInfo?.hasPreviousPage) {
1830
+ throw new Error(
1831
+ `Review thread on ${node.path} in pull request #${String(prNumber)} has more than 100 comments, so the earliest ones were not read. Refusing rather than risk suppressing a maintainer's request.`
1832
+ );
1833
+ }
1834
+ threads.push({
1835
+ path: node.path,
1836
+ line: node.line ?? null,
1837
+ isResolved: node.isResolved,
1838
+ url: node.comments.nodes.at(-1)?.url ?? null,
1839
+ comments: node.comments.nodes.map((comment) => ({
1840
+ kind: "thread-comment",
1841
+ author: login(comment.author),
1842
+ body: comment.body,
1843
+ // When the comment became *visible*, not when it was drafted.
1844
+ // GitHub stamps `createdAt` the moment a comment is added to a
1845
+ // pending review, and it only becomes visible when the review is
1846
+ // submitted — minutes later for a human working through a diff.
1847
+ // Using `createdAt` let an agent answer posted in between look newer
1848
+ // than the review, which marked every one of its threads answered
1849
+ // and discarded the whole review silently.
1850
+ createdAt: visibleAt(comment.createdAt, comment.pullRequestReview?.submittedAt)
1851
+ })).sort(byCreatedAt)
1852
+ });
1853
+ }
1854
+ if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
1855
+ return threads;
1856
+ }
1857
+ cursor = connection.pageInfo.endCursor;
1913
1858
  }
1914
- writeOverflowHint(output, issues, limit);
1859
+ throw new Error(
1860
+ `Stopped paginating review threads for pull request #${String(prNumber)} after ${String(MAX_THREAD_PAGES)} pages; refusing rather than answering only part of the feedback.`
1861
+ );
1915
1862
  }
1916
- async function promptSelection(issues, limit, output) {
1917
- writeIssueList(output, issues, limit);
1918
- const rl = createInterface2({ input: process.stdin, output });
1919
- const answer = await new Promise(
1920
- (resolve2) => rl.question(`
1921
- Select issue (1-${issues.length}): `, resolve2)
1863
+ function getPrSurface(prNumber) {
1864
+ const raw = ghJson(
1865
+ [
1866
+ "pr",
1867
+ "view",
1868
+ String(prNumber),
1869
+ "--json",
1870
+ "number,title,url,headRefName,baseRefName,isCrossRepository,state,isDraft,body,author,createdAt,updatedAt,assignees,comments,reviews"
1871
+ ],
1872
+ `read pull request #${String(prNumber)}`
1922
1873
  );
1923
- rl.close();
1924
- const n = Number.parseInt(answer.trim(), 10);
1925
- if (Number.isNaN(n) || n < 1 || n > issues.length) {
1926
- process.stderr.write(`Error: Invalid selection "${answer.trim()}". Enter a number between 1 and ${issues.length}.
1927
- `);
1928
- process.exit(1);
1929
- }
1930
- return issues[n - 1];
1874
+ const messages = [
1875
+ ...(raw.comments ?? []).map((comment) => ({
1876
+ kind: "pr-comment",
1877
+ author: login(comment.author),
1878
+ body: comment.body,
1879
+ createdAt: comment.createdAt
1880
+ })),
1881
+ // A review with no body carries no message — only its inline comments do,
1882
+ // and those arrive through the review-thread query.
1883
+ ...(raw.reviews ?? []).filter((review) => review.body.trim().length > 0).map((review) => ({
1884
+ kind: "pr-review",
1885
+ author: login(review.author),
1886
+ body: review.body,
1887
+ createdAt: review.submittedAt ?? review.createdAt ?? ""
1888
+ }))
1889
+ ].sort(byCreatedAt);
1890
+ const threads = getReviewThreads(prNumber);
1891
+ const state = normalizePrState(raw.state);
1892
+ return {
1893
+ pr: {
1894
+ number: raw.number,
1895
+ url: raw.url,
1896
+ title: raw.title,
1897
+ headRefName: raw.headRefName,
1898
+ baseRefName: raw.baseRefName ?? "",
1899
+ isCrossRepository: raw.isCrossRepository ?? false,
1900
+ state,
1901
+ isDraft: raw.isDraft ?? false,
1902
+ updatedAt: raw.updatedAt ?? raw.createdAt
1903
+ },
1904
+ assignees: (raw.assignees ?? []).map(login).filter((name) => name.length > 0),
1905
+ messages,
1906
+ threads
1907
+ };
1931
1908
  }
1932
- function validateConfig(config) {
1933
- if (config.remoteType !== "gh") {
1934
- process.stderr.write(
1935
- "Error: implement-next is not supported in Azure DevOps mode. Work item discovery is not available in azdo-cli. See docs/azdo-gap.md for details.\n"
1936
- );
1937
- process.exit(1);
1938
- }
1939
- if (!config.issueDiscoveryTechnique) {
1940
- process.stderr.write(
1941
- "Error: No issue discovery technique configured. Run `automata config` to set one.\n"
1942
- );
1943
- process.exit(1);
1909
+ function assignIssueToAgent(issueNumber, agentUser) {
1910
+ const { stderr, status } = run4("gh", [
1911
+ "issue",
1912
+ "edit",
1913
+ String(issueNumber),
1914
+ "--add-assignee",
1915
+ agentUser
1916
+ ]);
1917
+ if (status !== 0) {
1918
+ throw new Error(stderr.trim() || `Failed to assign issue #${String(issueNumber)} to ${agentUser}.`);
1944
1919
  }
1945
- if (!config.issueDiscoveryValue) {
1946
- process.stderr.write(
1947
- "Error: No issue discovery value configured. Run `automata config` to set one.\n"
1920
+ }
1921
+ function assignPrToAgent(prNumber, agentUser) {
1922
+ const { stderr, status } = run4("gh", ["pr", "edit", String(prNumber), "--add-assignee", agentUser]);
1923
+ if (status !== 0) {
1924
+ throw new Error(
1925
+ stderr.trim() || `Failed to assign pull request #${String(prNumber)} to ${agentUser}.`
1948
1926
  );
1949
- process.exit(1);
1950
1927
  }
1951
1928
  }
1952
- async function resolveIssue(issues, options, limit) {
1953
- const selectionOutput = options.json ? process.stderr : process.stdout;
1954
- if (issues.length === 0) {
1955
- process.stdout.write("No issues found matching the configured filter.\n");
1956
- process.exit(0);
1957
- }
1958
- if (issues.length > 1 && options.queryOnly) {
1959
- writeIssueList(selectionOutput, issues, limit);
1960
- process.exit(0);
1961
- }
1962
- let issue;
1963
- if (issues.length === 1) {
1964
- issue = issues[0];
1965
- selectionOutput.write(`Issue: #${issue.number}
1966
- Title: ${issue.title}
1967
- `);
1968
- } else if (options.takeFirst) {
1969
- issue = issues[0];
1970
- selectionOutput.write(`Selecting issue #${issue.number}: ${issue.title}
1971
- `);
1972
- } else {
1973
- issue = await promptSelection(issues, limit, selectionOutput);
1974
- selectionOutput.write(`
1975
- Issue: #${issue.number}
1976
- Title: ${issue.title}
1977
- `);
1978
- }
1979
- return issue;
1929
+ function postMarker(_surface, number, body) {
1930
+ const { owner, repo } = getRepoSlug();
1931
+ const raw = ghJson(
1932
+ [
1933
+ "api",
1934
+ "--method",
1935
+ "POST",
1936
+ `repos/${owner}/${repo}/issues/${String(number)}/comments`,
1937
+ "-f",
1938
+ `body=${body}`
1939
+ ],
1940
+ `post a comment on #${String(number)}`
1941
+ );
1942
+ return { commentId: String(raw.id), createdAt: raw.created_at };
1980
1943
  }
1981
- var implementNextCommand = new Command3("implement-next").description("Find the next open GitHub issue matching the configured filter, claim it, and invoke the AI code assistant (Claude or Codex)").option("--json", "Output issue details as JSON").option("--no-claude", "Skip all AI invocation (Claude or Codex) after claiming the issue").option("--with <executor>", "Executor to use: claude or codex", "claude").option("--query-only", "Print issue content and exit without claiming or invoking any AI tools").option("--yolo", "Launch with --dangerously-skip-permissions (Claude) or --dangerously-bypass-approvals-and-sandbox (Codex)").option("--silent", "Suppress step-by-step Claude output; show only the final summary").option("--model <string>", "Model identifier to pass to the executor").option("--effort <level>", "Reasoning effort to pass to the executor").option("--take-first", "When multiple issues match, pick the first without prompting").option("--limit <n>", "Max issues to fetch and display (default: 10)", "10").option("--ask-copilot-review", "Request a Copilot code review on the PR after AI invocation finishes").action(async (options) => {
1982
- const config = readConfig();
1983
- validateConfig(config);
1984
- const limit = Number.parseInt(options.limit, 10);
1985
- if (Number.isNaN(limit) || limit <= 0) {
1986
- process.stderr.write(`Error: --limit must be a positive integer (got "${options.limit}").
1987
- `);
1988
- process.exit(1);
1989
- }
1990
- let issues;
1991
- try {
1992
- issues = listIssues(config.issueDiscoveryTechnique, config.issueDiscoveryValue, limit);
1993
- } catch (err) {
1994
- process.stderr.write(`Error: ${err.message}
1995
- `);
1996
- process.exit(1);
1997
- }
1998
- const issue = await resolveIssue(issues, options, limit);
1999
- if (options.json) {
2000
- process.stdout.write(JSON.stringify({ number: issue.number, title: issue.title, body: issue.body, url: issue.url }, null, 2) + "\n");
2001
- } else {
2002
- process.stdout.write(`URL: ${issue.url}
2003
-
2004
- ${issue.body}
2005
- `);
2006
- }
2007
- if (options.queryOnly) {
2008
- process.exit(0);
2009
- }
2010
- const executor = options.claude === false ? void 0 : resolveExecutor(options.with);
2011
- let commentUrl;
2012
- try {
2013
- commentUrl = postComment(issue.number, "working");
2014
- } catch (err) {
2015
- process.stderr.write(`Error: ${err.message}
2016
- `);
2017
- process.exit(1);
2018
- }
2019
- const effort = resolveEffortOption(options.effort);
2020
- if (options.claude !== false) {
2021
- const systemPrompt = config.claudeSystemPrompt ?? DEFAULT_CLAUDE_SYSTEM_PROMPT;
2022
- const prompt = `Resolving issue #${issue.number}:
2023
-
2024
- ${systemPrompt}
2025
-
2026
- ${issue.body}`;
2027
- if (executor === "codex") {
2028
- if (options.silent) {
2029
- process.stderr.write("Warning: --silent is only supported with Claude and has no effect when used with Codex.\n");
2030
- }
2031
- invokeCodexCode(prompt, { yolo: options.yolo, model: options.model, effort });
2032
- } else {
2033
- await invokeClaudeCode(prompt, {
2034
- yolo: options.yolo,
2035
- verbose: !options.silent,
2036
- model: options.model,
2037
- effort
2038
- });
2039
- }
2040
- }
2041
- linkPrToIssue(issue.number, commentUrl, options.askCopilotReview === true);
2042
- });
2043
- function resolveExecutor(requested) {
2044
- const executor = requested.toLowerCase();
2045
- if (executor !== "claude" && executor !== "codex") {
2046
- process.stderr.write(`Error: --with must be 'claude' or 'codex', got '${requested}'.
2047
- `);
2048
- process.exit(1);
1944
+ function updateMarker(marker, body) {
1945
+ const { owner, repo } = getRepoSlug();
1946
+ const { stderr, status } = run4("gh", [
1947
+ "api",
1948
+ "--method",
1949
+ "PATCH",
1950
+ `repos/${owner}/${repo}/issues/comments/${marker.commentId}`,
1951
+ "-f",
1952
+ `body=${body}`
1953
+ ]);
1954
+ if (status !== 0) {
1955
+ throw new Error(stderr.trim() || `Failed to update comment ${marker.commentId}.`);
2049
1956
  }
2050
- return executor;
2051
1957
  }
2052
- function warnOnFailure(what, action) {
2053
- try {
2054
- action();
2055
- } catch (err) {
2056
- process.stderr.write(`Warning: could not ${what}: ${err.message}
2057
- `);
1958
+ function deleteMarker(marker) {
1959
+ const { owner, repo } = getRepoSlug();
1960
+ const { stderr, status } = run4("gh", [
1961
+ "api",
1962
+ "--method",
1963
+ "DELETE",
1964
+ `repos/${owner}/${repo}/issues/comments/${marker.commentId}`
1965
+ ]);
1966
+ if (status !== 0) {
1967
+ if (/not found/i.test(stderr) || /HTTP 404/i.test(stderr)) return;
1968
+ throw new Error(stderr.trim() || `Failed to delete comment ${marker.commentId}.`);
2058
1969
  }
2059
1970
  }
2060
- function linkPrToIssue(issueNumber, commentUrl, askCopilotReview) {
2061
- let pr;
2062
- try {
2063
- pr = getCurrentBranchPr();
2064
- } catch (err) {
2065
- process.stderr.write(`Warning: could not detect current branch PR: ${err.message}
2066
- `);
2067
- return;
1971
+ function toHeadState(state) {
1972
+ return state === "OPEN" || state === "MERGED" ? state : "CLOSED";
1973
+ }
1974
+ function listPullRequestsForHead(branch) {
1975
+ const raw = ghJson(
1976
+ ["pr", "list", "--head", branch, "--state", "all", "--json", "number,state,url,updatedAt,author"],
1977
+ `list pull requests for branch ${branch}`
1978
+ );
1979
+ return raw.map((pr) => ({
1980
+ number: pr.number,
1981
+ url: pr.url,
1982
+ state: toHeadState(pr.state),
1983
+ updatedAt: pr.updatedAt,
1984
+ author: pr.author?.login ?? ""
1985
+ })).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1986
+ }
1987
+ var MISSING_LABEL_PATTERNS = [
1988
+ // gh's own message: `could not add label: 'rescue' not found`.
1989
+ /could not add label/i,
1990
+ // The GraphQL error it wraps, seen directly on some gh versions.
1991
+ /could not resolve to a label/i,
1992
+ /\blabels?\b[^\n]*\b(?:not found|does not exist)\b/i
1993
+ ];
1994
+ function isMissingLabelError(stderr) {
1995
+ return MISSING_LABEL_PATTERNS.some((pattern) => pattern.test(stderr));
1996
+ }
1997
+ function createDraftPullRequest(input) {
1998
+ const base = [
1999
+ "pr",
2000
+ "create",
2001
+ "--draft",
2002
+ "--head",
2003
+ input.head,
2004
+ "--base",
2005
+ input.base,
2006
+ "--title",
2007
+ input.title,
2008
+ "--body",
2009
+ input.body
2010
+ ];
2011
+ if (input.label !== void 0 && input.label.length > 0) {
2012
+ const labelled = run4("gh", [...base, "--label", input.label]);
2013
+ if (labelled.status === 0) return parseCreatedPrUrl(labelled.stdout, input.head);
2014
+ if (!isMissingLabelError(labelled.stderr)) {
2015
+ throw new Error(
2016
+ labelled.stderr.trim() || `Failed to open a draft pull request for ${input.head}.`
2017
+ );
2018
+ }
2068
2019
  }
2069
- if (!pr) return;
2070
- if (commentUrl) {
2071
- warnOnFailure("update issue comment", () => {
2072
- editComment(commentUrl, `Working on this in PR #${pr.number} \u2014 ${pr.url}`);
2073
- });
2020
+ const { stdout, stderr, status } = run4("gh", base);
2021
+ if (status !== 0) {
2022
+ throw new Error(stderr.trim() || `Failed to open a draft pull request for ${input.head}.`);
2074
2023
  }
2075
- warnOnFailure(`add Closes #${String(issueNumber)} to PR`, () => {
2076
- addClosesRefToPr(pr.number, issueNumber);
2077
- });
2078
- if (askCopilotReview) {
2079
- warnOnFailure("request Copilot review", () => {
2080
- addCopilotReviewer(pr.number);
2081
- });
2024
+ return parseCreatedPrUrl(stdout, input.head);
2025
+ }
2026
+ function parseCreatedPrUrl(stdout, head) {
2027
+ const url = stdout.trim().split("\n").pop()?.trim() ?? "";
2028
+ const match = /\/pull\/(\d+)\s*$/.exec(url);
2029
+ if (!match) {
2030
+ throw new Error(`Opened a pull request for ${head} but could not read its number from: ${url}`);
2082
2031
  }
2032
+ return { number: Number(match[1]), url };
2083
2033
  }
2084
2034
 
2085
- // src/commands/execute.ts
2086
- import { readFileSync as readFileSync2 } from "fs";
2087
- import { Command as Command4 } from "commander";
2088
- function readStdin() {
2089
- return new Promise((resolve2, reject) => {
2090
- const chunks = [];
2091
- process.stdin.on("data", (chunk) => chunks.push(chunk));
2092
- process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
2093
- process.stdin.on("error", reject);
2094
- });
2035
+ // src/claude/claudeService.ts
2036
+ import { spawn, spawnSync as spawnSync5 } from "child_process";
2037
+ import { createInterface } from "readline";
2038
+ import { existsSync } from "fs";
2039
+ import { delimiter, join } from "path";
2040
+
2041
+ // src/cli/spawnUtils.ts
2042
+ var SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
2043
+ var ESCAPED_QUOTE = String.raw`'\''`;
2044
+ function shellQuote(arg) {
2045
+ if (SHELL_SAFE.test(arg)) return arg;
2046
+ return "'" + arg.replaceAll("'", ESCAPED_QUOTE) + "'";
2047
+ }
2048
+ function truncate(str, max) {
2049
+ return str.length > max ? str.slice(0, max) + "..." : str;
2095
2050
  }
2096
- async function resolvePrompt(options) {
2097
- const hasCli = options.prompt !== void 0;
2098
- const hasFile = options.filePrompt !== void 0;
2099
- if (hasCli && hasFile) {
2100
- process.stderr.write("Error: --prompt is mutually exclusive with --file-prompt.\n");
2051
+ function handleSpawnError(error, toolName) {
2052
+ if (!error) return;
2053
+ const err = error;
2054
+ if (err.code === "ENOENT") {
2055
+ process.stderr.write(`Error: \`${toolName}\` CLI is not installed or not on PATH.
2056
+ `);
2101
2057
  process.exit(1);
2102
2058
  }
2103
- if (hasCli) {
2104
- return options.prompt;
2105
- }
2106
- if (hasFile) {
2107
- const path = options.filePrompt;
2108
- try {
2109
- return readFileSync2(path, "utf8");
2110
- } catch {
2111
- process.stderr.write(`Error: Cannot read file: ${path}
2059
+ process.stderr.write(`Error: ${err.message}
2112
2060
  `);
2113
- process.exit(1);
2114
- }
2115
- }
2116
- if (!process.stdin.isTTY) {
2117
- const content = await readStdin();
2118
- if (content.trim().length === 0) {
2119
- process.stderr.write("Error: No prompt provided. Use --prompt, --file-prompt, or pipe via stdin.\n");
2120
- process.exit(1);
2121
- }
2122
- return content;
2123
- }
2124
- process.stderr.write("Error: No prompt provided. Use --prompt, --file-prompt, or pipe via stdin.\n");
2125
2061
  process.exit(1);
2126
2062
  }
2127
- var executeCommand = new Command4("execute").description("Delegate work to an AI executor").requiredOption("--with <executor>", "Executor to use: claude or codex").option("--prompt <string>", "Prompt to send to the executor").option("--file-prompt <path>", "Path to a file whose content is used as the prompt").option("--silent", "Suppress step-by-step Claude output; show only the final summary").option("--model <string>", "Model identifier to pass to the executor").option("--effort <level>", "Reasoning effort to pass to the executor").action(async (options) => {
2128
- const executor = options.with.toLowerCase();
2129
- if (executor !== "claude" && executor !== "codex") {
2130
- process.stderr.write(`Error: --with must be 'claude' or 'codex', got '${options.with}'.
2063
+ function handleExitCode(status, toolName) {
2064
+ if (status === null) {
2065
+ process.stderr.write(`Error: ${toolName} terminated abnormally (exit code is null, likely due to a signal).
2131
2066
  `);
2132
2067
  process.exit(1);
2133
2068
  }
2134
- const prompt = await resolvePrompt(options);
2135
- const effort = resolveEffortOption(options.effort);
2136
- if (executor === "codex") {
2137
- invokeCodexCode(prompt, { yolo: true, model: options.model, effort });
2138
- } else {
2139
- await invokeClaudeCode(prompt, {
2140
- yolo: true,
2141
- verbose: !options.silent,
2142
- model: options.model,
2143
- effort
2144
- });
2069
+ if (status !== 0) {
2070
+ process.stderr.write(`Error: ${toolName} exited with code ${status}.
2071
+ `);
2072
+ process.exit(status);
2145
2073
  }
2146
- });
2147
-
2148
- // src/commands/executePrompt.ts
2149
- import { Command as Command5 } from "commander";
2074
+ }
2075
+ function resolveEffortOption(value) {
2076
+ if (value === void 0) return void 0;
2077
+ const trimmed2 = value.trim();
2078
+ if (trimmed2.length === 0) {
2079
+ process.stderr.write("Error: --effort must be a non-empty level.\n");
2080
+ process.exit(1);
2081
+ }
2082
+ return trimmed2;
2083
+ }
2150
2084
 
2151
- // src/github/conversation.ts
2152
- var KIND_LABELS = {
2153
- "issue-body": "issue description",
2154
- "issue-comment": "comment",
2155
- "pr-comment": "pull request comment",
2156
- "pr-review": "pull request review",
2157
- "thread-comment": "review thread comment"
2158
- };
2159
- function byCreatedAt(a, b) {
2160
- return a.createdAt.localeCompare(b.createdAt);
2085
+ // src/cli/childRegistry.ts
2086
+ var active = /* @__PURE__ */ new Set();
2087
+ function trackChild(child) {
2088
+ active.add(child);
2161
2089
  }
2162
- function classify(author, p) {
2163
- const login2 = author.toLowerCase();
2164
- if (login2 === p.agentUser.toLowerCase()) return "agent";
2165
- return p.allowedUsers.some((user) => user.toLowerCase() === login2) ? "authorized" : "other";
2090
+ function untrackChild(child) {
2091
+ active.delete(child);
2166
2092
  }
2167
- function analyzeSurface(messages, p) {
2168
- const ordered = [...messages].sort(byCreatedAt);
2169
- let lastAgentAt = null;
2170
- for (const message of ordered) {
2171
- if (message.kind === "issue-body") continue;
2172
- if (classify(message.author, p) !== "agent") continue;
2173
- if (lastAgentAt === null || message.createdAt > lastAgentAt) {
2174
- lastAgentAt = message.createdAt;
2093
+ function waitForExit(child) {
2094
+ return new Promise((resolve2) => {
2095
+ if (child.exitCode !== null || child.signalCode !== null) {
2096
+ resolve2();
2097
+ return;
2175
2098
  }
2176
- }
2177
- const kept = [];
2178
- for (const message of ordered) {
2179
- const authorClass = classify(message.author, p);
2180
- if (authorClass === "other") continue;
2181
- const isNew = authorClass === "authorized" && (lastAgentAt === null || message.createdAt > lastAgentAt);
2182
- kept.push({ ...message, isNew });
2183
- }
2184
- const newMessages = kept.filter((message) => message.isNew);
2185
- return {
2186
- messages: kept,
2187
- newMessages,
2188
- newMessageCount: newMessages.length,
2189
- hasNewMessage: newMessages.length > 0,
2190
- lastAgentAt
2191
- };
2099
+ child.once("exit", () => resolve2());
2100
+ });
2192
2101
  }
2193
- function lastAuthorClass(messages, p) {
2194
- if (messages.length === 0) return "none";
2195
- const newest2 = [...messages].sort(byCreatedAt).at(-1);
2196
- return newest2 === void 0 ? "none" : classify(newest2.author, p);
2102
+ function afterDelay(ms) {
2103
+ return new Promise((resolve2) => {
2104
+ const timer = setTimeout(() => resolve2("timeout"), ms);
2105
+ timer.unref();
2106
+ });
2197
2107
  }
2198
- function formatMessages(messages) {
2199
- return messages.map((message) => {
2200
- const marker = message.isNew ? " \xB7 NEW since last agent run" : "";
2201
- return `[${message.author}] ${KIND_LABELS[message.kind]} \xB7 ${message.createdAt}${marker}
2202
- ${message.body}`;
2203
- }).join("\n\n");
2108
+ async function terminateTrackedChildren(timeoutMs = 1e4, killGraceMs = 5e3) {
2109
+ const children = [...active];
2110
+ if (children.length === 0) return true;
2111
+ const exits = children.map((child) => waitForExit(child));
2112
+ for (const child of children) {
2113
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
2114
+ }
2115
+ const settled = await Promise.race([Promise.all(exits).then(() => "exited"), afterDelay(timeoutMs)]);
2116
+ if (settled === "exited") return true;
2117
+ for (const child of children) {
2118
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
2119
+ }
2120
+ const escalated = await Promise.race([
2121
+ Promise.all(exits).then(() => "exited"),
2122
+ afterDelay(killGraceMs)
2123
+ ]);
2124
+ return escalated === "exited";
2204
2125
  }
2205
2126
 
2206
- // src/github/issueConversation.ts
2207
- function analyzeConversation(conversation, allowedUsers, agentUser) {
2208
- const participants = { allowedUsers, agentUser };
2209
- const messages = [
2210
- {
2211
- kind: "issue-body",
2212
- author: conversation.author,
2213
- body: conversation.body,
2214
- createdAt: conversation.createdAt
2215
- },
2216
- ...conversation.comments.map((comment) => ({
2217
- kind: "issue-comment",
2218
- author: comment.author,
2219
- body: comment.body,
2220
- createdAt: comment.createdAt
2221
- }))
2222
- ];
2223
- const analysis = analyzeSurface(messages, participants);
2224
- return {
2225
- messages: analysis.messages.map(toConversationMessage),
2226
- newMessageCount: analysis.newMessageCount,
2227
- hasNewMessage: analysis.hasNewMessage,
2228
- lastAgentAt: analysis.lastAgentAt
2229
- };
2230
- }
2231
- function toConversationMessage(message) {
2232
- return {
2233
- kind: message.kind === "issue-body" ? "issue" : "comment",
2234
- author: message.author,
2235
- body: message.body,
2236
- createdAt: message.createdAt,
2237
- isNew: message.isNew
2238
- };
2127
+ // src/claude/claudeService.ts
2128
+ function resolveCommand(name) {
2129
+ const pathDirs = (process.env["PATH"] ?? "").split(delimiter);
2130
+ for (const dir of pathDirs) {
2131
+ const candidate = join(dir, name);
2132
+ if (existsSync(candidate)) return candidate;
2133
+ }
2134
+ return name;
2239
2135
  }
2240
- function formatConversation(messages) {
2241
- return formatMessages(
2242
- messages.map((message) => ({
2243
- kind: message.kind === "issue" ? "issue-body" : "issue-comment",
2244
- author: message.author,
2245
- body: message.body,
2246
- createdAt: message.createdAt,
2247
- isNew: message.isNew
2248
- }))
2249
- );
2136
+ function buildClaudeArgs(prompt, options = {}) {
2137
+ const args = [];
2138
+ if (options.yolo) args.push("--dangerously-skip-permissions");
2139
+ if (options.model) args.push("--model", options.model);
2140
+ if (options.effort) args.push("--effort", options.effort);
2141
+ if (options.verbose) args.push("--verbose", "--output-format", "stream-json");
2142
+ args.push("-p", prompt);
2143
+ return args;
2250
2144
  }
2251
-
2252
- // src/commands/executePrompt.ts
2253
- var PUSH_INSTRUCTION = "Once all changes are complete, stage every modified file, create a single commit with a clear and descriptive commit message that summarises what was fixed, and push the branch to the remote.";
2254
- function withPush(prompt, push) {
2255
- return push ? `${prompt}
2256
-
2257
- ${PUSH_INSTRUCTION}` : prompt;
2145
+ function runClaude(prompt, options = {}) {
2146
+ return new Promise((resolve2, reject) => {
2147
+ const claudeBin = resolveCommand("claude");
2148
+ const args = buildClaudeArgs(prompt, {
2149
+ yolo: true,
2150
+ model: options.model,
2151
+ effort: options.effort,
2152
+ verbose: true
2153
+ });
2154
+ const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
2155
+ trackChild(child);
2156
+ const rl = createInterface({ input: child.stdout });
2157
+ let turnCount = 0;
2158
+ rl.on("line", (line) => {
2159
+ if (options.printSteps !== true) return;
2160
+ try {
2161
+ const event = JSON.parse(line);
2162
+ formatEvent(event, turnCount);
2163
+ if (event["type"] === "assistant") turnCount++;
2164
+ } catch {
2165
+ }
2166
+ });
2167
+ child.on("error", (err) => {
2168
+ untrackChild(child);
2169
+ const nodeErr = err;
2170
+ reject(
2171
+ nodeErr.code === "ENOENT" ? new Error("`claude` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
2172
+ );
2173
+ });
2174
+ child.on("close", (code, signal) => {
2175
+ untrackChild(child);
2176
+ if (code === 0) {
2177
+ resolve2();
2178
+ return;
2179
+ }
2180
+ reject(
2181
+ new Error(
2182
+ signal === null ? `Claude Code exited with code ${String(code)}.` : `Claude Code terminated on ${signal}.`
2183
+ )
2184
+ );
2185
+ });
2186
+ });
2258
2187
  }
2259
- function formatPrInfoContext(pr) {
2260
- return JSON.stringify(pr, null, 2);
2188
+ function invokeClaudeCode(prompt, options = {}) {
2189
+ if (options.verbose) {
2190
+ return invokeClaudeCodeVerbose(prompt, options.yolo ?? false, options.model, options.effort);
2191
+ }
2192
+ invokeClaudeCodeSync(prompt, options.yolo ?? false, options.model, options.effort);
2261
2193
  }
2262
- function pluralSuffix(count) {
2263
- return count === 1 ? "" : "s";
2194
+ function invokeClaudeCodeSync(prompt, yolo, model, effort) {
2195
+ const claudeBin = resolveCommand("claude");
2196
+ const args = buildClaudeArgs(prompt, { yolo, model, effort, verbose: false });
2197
+ const result = spawnSync5(claudeBin, args, { encoding: "utf8", stdio: "inherit" });
2198
+ handleSpawnError(result.error, "claude");
2199
+ handleExitCode(result.status, "Claude Code");
2264
2200
  }
2265
- function addAiOptions(cmd) {
2266
- return cmd.requiredOption("--with <executor>", "Executor to use: claude or codex").option("--model <string>", "Model identifier to pass to the executor").option("--effort <level>", "Reasoning effort to pass to the executor").option("--silent", "Suppress step-by-step Claude output; show only the final summary").option("--push", "Append instruction to commit and push changes after the AI finishes");
2201
+ function invokeClaudeCodeVerbose(prompt, yolo, model, effort) {
2202
+ return new Promise((resolve2) => {
2203
+ const claudeBin = resolveCommand("claude");
2204
+ const args = buildClaudeArgs(prompt, { yolo, model, effort, verbose: true });
2205
+ const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
2206
+ const rl = createInterface({ input: child.stdout });
2207
+ let turnCount = 0;
2208
+ child.on("error", (err) => {
2209
+ handleSpawnError(err, "claude");
2210
+ });
2211
+ rl.on("line", (line) => {
2212
+ try {
2213
+ const event = JSON.parse(line);
2214
+ formatEvent(event, turnCount);
2215
+ if (event["type"] === "assistant") turnCount++;
2216
+ } catch {
2217
+ }
2218
+ });
2219
+ child.on("close", (code) => {
2220
+ handleExitCode(code, "Claude Code");
2221
+ resolve2();
2222
+ });
2223
+ });
2267
2224
  }
2268
- function resolveExecutor2(withOption) {
2269
- const executor = withOption.toLowerCase();
2270
- if (executor !== "claude" && executor !== "codex") {
2271
- process.stderr.write(`Error: --with must be 'claude' or 'codex', got '${withOption}'.
2225
+ function formatAssistantEvent(event, turnCount) {
2226
+ const message = event["message"];
2227
+ const content = message?.["content"];
2228
+ if (!content) return;
2229
+ for (const block of content) {
2230
+ const line = formatContentBlock(block);
2231
+ if (line !== null) {
2232
+ process.stderr.write(` [step ${turnCount + 1}] ${line}
2272
2233
  `);
2273
- process.exit(1);
2234
+ }
2274
2235
  }
2275
- return executor;
2276
2236
  }
2277
- function invokeSelectedExecutor(prompt, executor, options) {
2278
- const effort = resolveEffortOption(options.effort);
2279
- if (executor === "codex") {
2280
- invokeCodexCode(prompt, { yolo: true, model: options.model, effort });
2281
- return;
2237
+ function formatContentBlock(block) {
2238
+ if (block["type"] === "tool_use") {
2239
+ const toolName = block["name"];
2240
+ const input = block["input"];
2241
+ return summarizeTool(toolName, input);
2282
2242
  }
2283
- return invokeClaudeCode(prompt, {
2284
- yolo: true,
2285
- verbose: !options.silent,
2286
- model: options.model,
2287
- effort
2288
- });
2243
+ if (block["type"] === "text") {
2244
+ const text = block["text"] ?? "";
2245
+ if (text.length === 0) return null;
2246
+ const preview = text.length > 120 ? text.slice(0, 120) + "..." : text;
2247
+ return preview.split("\n")[0] ?? null;
2248
+ }
2249
+ return null;
2289
2250
  }
2290
- var executeSonarCmd = addAiOptions(
2291
- new Command5("sonar").description(
2292
- "Check the current branch for a SonarCloud analysis and invoke the AI with the Sonar prompt and analysis URL"
2293
- )
2294
- ).action(async (options) => {
2295
- const executor = resolveExecutor2(options.with);
2296
- let branch;
2297
- try {
2298
- branch = getCurrentBranch();
2299
- } catch (err) {
2300
- process.stderr.write(`Error: ${err.message}
2251
+ function formatResultEvent(event) {
2252
+ const result = event["result"];
2253
+ const cost = event["cost_usd"];
2254
+ const duration = event["duration_ms"];
2255
+ const turns = event["num_turns"];
2256
+ process.stderr.write("\n--- Result ---\n");
2257
+ const parts = [];
2258
+ if (turns !== void 0) parts.push(`${turns} turns`);
2259
+ if (duration !== void 0) parts.push(`${(duration / 1e3).toFixed(1)}s`);
2260
+ if (cost !== void 0) parts.push(`$${cost.toFixed(4)}`);
2261
+ if (parts.length > 0) {
2262
+ process.stderr.write(` [info] ${parts.join(" | ")}
2301
2263
  `);
2302
- process.exit(1);
2303
2264
  }
2304
- let pr;
2305
- try {
2306
- pr = await getPrInfo2(branch);
2307
- } catch (err) {
2308
- process.stderr.write(`Error: ${err.message}
2309
- `);
2310
- process.exit(1);
2265
+ if (result) {
2266
+ process.stdout.write(result + "\n");
2311
2267
  }
2312
- if (pr === null) {
2313
- process.stderr.write(`Error: No pull request found for branch: ${branch}
2314
- `);
2315
- process.exit(1);
2268
+ }
2269
+ function formatEvent(event, turnCount) {
2270
+ const type = event["type"];
2271
+ if (type === "assistant") {
2272
+ formatAssistantEvent(event, turnCount);
2273
+ } else if (type === "result") {
2274
+ formatResultEvent(event);
2316
2275
  }
2317
- if (!pr.sonarcloudUrl) {
2318
- process.stderr.write(
2319
- `Error: No SonarCloud analysis found for PR #${pr.number}. Ensure a SonarCloud check is configured on this repository.
2320
- `
2321
- );
2322
- process.exit(1);
2276
+ }
2277
+ function asText(value, fallback) {
2278
+ return typeof value === "string" ? value : fallback;
2279
+ }
2280
+ function summarizeTool(name, input) {
2281
+ if (!input) return `tool: ${name}`;
2282
+ switch (name) {
2283
+ case "Read":
2284
+ return `reading ${asText(input["file_path"], "file")}`;
2285
+ case "Write":
2286
+ return `writing ${asText(input["file_path"], "file")}`;
2287
+ case "Edit":
2288
+ return `editing ${asText(input["file_path"], "file")}`;
2289
+ case "Bash":
2290
+ return `running: ${truncate(asText(input["command"], ""), 80)}`;
2291
+ case "Glob":
2292
+ return `searching files: ${asText(input["pattern"], "")}`;
2293
+ case "Grep":
2294
+ return `searching content: ${truncate(asText(input["pattern"], ""), 60)}`;
2295
+ case "Agent":
2296
+ return `spawning agent: ${asText(input["description"], name)}`;
2297
+ default:
2298
+ return `tool: ${name}`;
2323
2299
  }
2324
- const config = readConfig();
2325
- const sonarPromptText = config.prompts?.sonar ?? DEFAULT_SONAR_PROMPT;
2326
- const fullPrompt = withPush(
2327
- `${sonarPromptText}
2328
-
2329
- SonarCloud analysis URL: ${pr.sonarcloudUrl}
2300
+ }
2330
2301
 
2331
- Current PR context from automata git get-pr-info --json:
2332
- ${formatPrInfoContext(pr)}`,
2333
- options.push
2334
- );
2335
- await invokeSelectedExecutor(fullPrompt, executor, options);
2336
- });
2337
- function formatComments(comments) {
2338
- return comments.map((c) => {
2339
- const loc = c.line === null ? `${c.path}:(file)` : `${c.path}:${String(c.line)}`;
2340
- return `[${c.author}] on ${loc}
2341
- ${c.body}`;
2342
- }).join("\n\n");
2302
+ // src/codex/codexService.ts
2303
+ import { spawn as spawn2, spawnSync as spawnSync6 } from "child_process";
2304
+ function toTomlBasicString(value) {
2305
+ return JSON.stringify(value);
2343
2306
  }
2344
- var executeFixCommentsCmd = addAiOptions(
2345
- new Command5("fix-comments").description(
2346
- "Fetch open review comments on the current PR and invoke the AI with the Fix-Comments prompt"
2347
- )
2348
- ).action(async (options) => {
2349
- const executor = resolveExecutor2(options.with);
2350
- const result = resolveCurrentBranchComments();
2351
- if (!result.ok) {
2352
- if (result.kind === "error") {
2353
- process.stderr.write(`Error: ${result.message}
2354
- `);
2355
- process.exit(1);
2356
- }
2357
- if (result.kind === "unsupported") {
2358
- process.stderr.write(
2359
- `Error: fix-comments is not supported for Azure DevOps. See docs/azdo-gap.md for details.
2360
- `
2307
+ function buildCodexArgs(prompt, options = {}) {
2308
+ const args = ["exec"];
2309
+ if (options.yolo) args.push("--dangerously-bypass-approvals-and-sandbox");
2310
+ if (options.model) args.push("--model", options.model);
2311
+ if (options.effort) args.push("-c", `model_reasoning_effort=${toTomlBasicString(options.effort)}`);
2312
+ args.push(prompt);
2313
+ return args;
2314
+ }
2315
+ function invokeCodexCode(prompt, options = {}) {
2316
+ if (options.verbose) {
2317
+ process.stderr.write("Warning: --verbose is not supported for Codex and will be ignored.\n");
2318
+ }
2319
+ invokeCodexCodeSync(prompt, options.yolo ?? false, options.model, options.effort);
2320
+ }
2321
+ function invokeCodexCodeSync(prompt, yolo, model, effort) {
2322
+ const codexBin = resolveCommand("codex");
2323
+ const args = buildCodexArgs(prompt, { yolo, model, effort });
2324
+ const result = spawnSync6(codexBin, args, { encoding: "utf8", stdio: "inherit" });
2325
+ handleSpawnError(result.error, "codex");
2326
+ handleExitCode(result.status, "Codex");
2327
+ }
2328
+ function runCodex(prompt, options = {}) {
2329
+ return new Promise((resolve2, reject) => {
2330
+ const codexBin = resolveCommand("codex");
2331
+ const args = buildCodexArgs(prompt, { yolo: true, model: options.model, effort: options.effort });
2332
+ const child = spawn2(codexBin, args, { stdio: "inherit" });
2333
+ trackChild(child);
2334
+ child.on("error", (err) => {
2335
+ untrackChild(child);
2336
+ const nodeErr = err;
2337
+ reject(
2338
+ nodeErr.code === "ENOENT" ? new Error("`codex` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
2361
2339
  );
2362
- process.exit(1);
2363
- }
2364
- process.stderr.write(`Error: No pull request found for branch: ${result.branch}
2340
+ });
2341
+ child.on("close", (code, signal) => {
2342
+ untrackChild(child);
2343
+ if (code === 0) {
2344
+ resolve2();
2345
+ return;
2346
+ }
2347
+ reject(
2348
+ new Error(
2349
+ signal === null ? `Codex exited with code ${String(code)}.` : `Codex terminated on ${signal}.`
2350
+ )
2351
+ );
2352
+ });
2353
+ });
2354
+ }
2355
+
2356
+ // src/commands/getReady.ts
2357
+ function writeOverflowHint(output, issues, limit) {
2358
+ if (issues.length === limit) {
2359
+ output.write(`(Showing first ${limit} matching issues \u2014 there may be more. Use --limit to fetch more.)
2365
2360
  `);
2366
- process.exit(1);
2367
2361
  }
2368
- const { comments } = result;
2369
- if (comments.length === 0) {
2370
- process.stderr.write(`Error: No open review comments found on the pull request.
2362
+ }
2363
+ function writeIssueList(output, issues, limit) {
2364
+ output.write("\nAvailable issues:\n");
2365
+ for (let i = 0; i < issues.length; i++) {
2366
+ output.write(` [${i + 1}] #${issues[i].number} - ${issues[i].title}
2371
2367
  `);
2372
- process.exit(1);
2373
2368
  }
2374
- process.stdout.write(`Found ${String(comments.length)} open review comment${comments.length === 1 ? "" : "s"} on PR. Invoking AI\u2026
2375
- `);
2376
- const config = readConfig();
2377
- const promptText = config.prompts?.fixComments ?? DEFAULT_FIX_COMMENTS_PROMPT;
2378
- const fullPrompt = withPush(
2379
- `${promptText}
2380
-
2381
- Open review comments:
2382
-
2383
- ${formatComments(comments)}`,
2384
- options.push
2369
+ writeOverflowHint(output, issues, limit);
2370
+ }
2371
+ async function promptSelection(issues, limit, output) {
2372
+ writeIssueList(output, issues, limit);
2373
+ const rl = createInterface2({ input: process.stdin, output });
2374
+ const answer = await new Promise(
2375
+ (resolve2) => rl.question(`
2376
+ Select issue (1-${issues.length}): `, resolve2)
2385
2377
  );
2386
- await invokeSelectedExecutor(fullPrompt, executor, options);
2387
- });
2388
- var executeCheckIssueCmd = addAiOptions(
2389
- new Command5("check-issue").description(
2390
- "Check a GitHub issue for a new message from an allowed user since the last agent run and invoke the AI with the issue conversation"
2391
- ).argument("<issue-number>", "GitHub issue number to check")
2392
- ).option("--force", "Skip the new-message check and invoke the AI directly").action(async (issueNumberArg, options) => {
2393
- const executor = resolveExecutor2(options.with);
2394
- const issueNumber = Number.parseInt(issueNumberArg, 10);
2395
- if (Number.isNaN(issueNumber) || issueNumber <= 0) {
2396
- process.stderr.write(`Error: <issue-number> must be a positive integer (got '${issueNumberArg}').
2378
+ rl.close();
2379
+ const n = Number.parseInt(answer.trim(), 10);
2380
+ if (Number.isNaN(n) || n < 1 || n > issues.length) {
2381
+ process.stderr.write(`Error: Invalid selection "${answer.trim()}". Enter a number between 1 and ${issues.length}.
2397
2382
  `);
2398
2383
  process.exit(1);
2399
2384
  }
2400
- const config = readConfig();
2401
- if (config.remoteType === "azdo") {
2385
+ return issues[n - 1];
2386
+ }
2387
+ function validateConfig(config) {
2388
+ if (config.remoteType !== "gh") {
2402
2389
  process.stderr.write(
2403
- "Error: check-issue is not supported for Azure DevOps. See docs/azdo-gap.md for details.\n"
2390
+ "Error: implement-next is not supported in Azure DevOps mode. Work item discovery is not available in azdo-cli. See docs/azdo-gap.md for details.\n"
2404
2391
  );
2405
2392
  process.exit(1);
2406
2393
  }
2407
- const allowedUsers = (config.allowedUsers ?? []).filter((user) => user.trim().length > 0);
2408
- if (allowedUsers.length === 0) {
2394
+ if (!config.issueDiscoveryTechnique) {
2409
2395
  process.stderr.write(
2410
- "Error: No allowed users configured. Run `automata config` or `automata config set allowed-users <user1,user2>` to set them.\n"
2396
+ "Error: No issue discovery technique configured. Run `automata config` to set one.\n"
2411
2397
  );
2412
2398
  process.exit(1);
2413
2399
  }
2414
- const agentUser = (config.agentUser ?? "").trim();
2415
- if (agentUser.length === 0) {
2400
+ if (!config.issueDiscoveryValue) {
2416
2401
  process.stderr.write(
2417
- "Error: No agent user configured. Run `automata config` or `automata config set agent-user <login>` to set it.\n"
2402
+ "Error: No issue discovery value configured. Run `automata config` to set one.\n"
2418
2403
  );
2419
2404
  process.exit(1);
2420
2405
  }
2421
- let conversation;
2422
- try {
2423
- conversation = getIssueConversation(issueNumber);
2424
- } catch (err) {
2425
- process.stderr.write(`Error: ${err.message}
2426
- `);
2427
- process.exit(1);
2406
+ }
2407
+ async function resolveIssue(issues, options, limit) {
2408
+ const selectionOutput = options.json ? process.stderr : process.stdout;
2409
+ if (issues.length === 0) {
2410
+ process.stdout.write("No issues found matching the configured filter.\n");
2411
+ process.exit(0);
2428
2412
  }
2429
- const analysis = analyzeConversation(conversation, allowedUsers, agentUser);
2430
- if (!analysis.hasNewMessage && !options.force) {
2431
- const since = analysis.lastAgentAt === null ? "" : ` (last agent message: ${analysis.lastAgentAt})`;
2432
- process.stdout.write(
2433
- `No new messages from allowed users on issue #${String(issueNumber)}${since}. Use --force to invoke the AI anyway.
2434
- `
2435
- );
2436
- return;
2413
+ if (issues.length > 1 && options.queryOnly) {
2414
+ writeIssueList(selectionOutput, issues, limit);
2415
+ process.exit(0);
2437
2416
  }
2438
- if (analysis.hasNewMessage) {
2439
- process.stdout.write(
2440
- `Found ${String(analysis.newMessageCount)} new message${pluralSuffix(analysis.newMessageCount)} on issue #${String(issueNumber)}. Invoking AI\u2026
2441
- `
2442
- );
2417
+ let issue;
2418
+ if (issues.length === 1) {
2419
+ issue = issues[0];
2420
+ selectionOutput.write(`Issue: #${issue.number}
2421
+ Title: ${issue.title}
2422
+ `);
2423
+ } else if (options.takeFirst) {
2424
+ issue = issues[0];
2425
+ selectionOutput.write(`Selecting issue #${issue.number}: ${issue.title}
2426
+ `);
2443
2427
  } else {
2444
- process.stdout.write(`No new messages on issue #${String(issueNumber)} \u2014 forced run. Invoking AI\u2026
2428
+ issue = await promptSelection(issues, limit, selectionOutput);
2429
+ selectionOutput.write(`
2430
+ Issue: #${issue.number}
2431
+ Title: ${issue.title}
2445
2432
  `);
2446
2433
  }
2447
- const promptText = config.prompts?.checkIssue ?? DEFAULT_CHECK_ISSUE_PROMPT;
2448
- const fullPrompt = withPush(
2449
- `${promptText}
2450
-
2451
- Issue #${String(issueNumber)}: ${conversation.title}
2452
- URL: ${conversation.url}
2453
-
2454
- Conversation (only messages from allowed users and the agent, oldest first):
2455
-
2456
- ${formatConversation(analysis.messages)}`,
2457
- options.push
2458
- );
2459
- const marker = analysis.hasNewMessage ? `automata check-issue: picked up ${String(analysis.newMessageCount)} new message${pluralSuffix(analysis.newMessageCount)}, starting an agent run.` : "automata check-issue: forced run, starting an agent run.";
2460
- try {
2461
- postComment(issueNumber, marker);
2462
- } catch (err) {
2463
- process.stderr.write(
2464
- `Error: could not post the execution marker comment on issue #${String(issueNumber)}: ${err.message}
2465
- `
2466
- );
2467
- process.exit(1);
2468
- }
2469
- await invokeSelectedExecutor(fullPrompt, executor, options);
2470
- });
2471
- var executePromptCommand = new Command5("execute-prompt").description("Execute a configured custom prompt using an AI assistant").addCommand(executeSonarCmd).addCommand(executeFixCommentsCmd).addCommand(executeCheckIssueCmd);
2472
-
2473
- // src/commands/doWork.ts
2474
- import { Command as Command6 } from "commander";
2475
-
2476
- // src/github/ghWorkService.ts
2477
- import { spawnSync as spawnSync6 } from "child_process";
2478
- function run4(cmd, args) {
2479
- const result = spawnSync6(cmd, args, { encoding: "utf8" });
2480
- if (result.error) {
2481
- const err = result.error;
2482
- if (err.code === "ENOENT") {
2483
- throw new Error(`\`${cmd}\` CLI is not installed or not on PATH.`);
2484
- }
2485
- throw new Error(err.message);
2486
- }
2487
- return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status ?? 1 };
2488
- }
2489
- function ghJson(args, what) {
2490
- const { stdout, stderr, status } = run4("gh", args);
2491
- if (status !== 0) {
2492
- throw new Error(stderr.trim() || `Failed to ${what}. Is \`gh\` installed and authenticated?`);
2493
- }
2494
- return JSON.parse(stdout);
2495
- }
2496
- function login(author) {
2497
- return author?.login ?? "";
2498
- }
2499
- function byCreatedAt2(a, b) {
2500
- return a.createdAt.localeCompare(b.createdAt);
2501
- }
2502
- function visibleAt(createdAt, submittedAt) {
2503
- if (submittedAt === null || submittedAt === void 0) return createdAt;
2504
- return submittedAt > createdAt ? submittedAt : createdAt;
2505
- }
2506
- function getRepoSlug() {
2507
- const { stdout, status } = run4("git", ["remote", "get-url", "origin"]);
2508
- if (status !== 0) {
2509
- throw new Error("Could not read the `origin` remote. Is this a git repository with a remote?");
2510
- }
2511
- const url = stdout.trim();
2512
- const match = /github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url) ?? /github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url);
2513
- if (!match) {
2514
- throw new Error(`Could not determine the GitHub owner/repo from the origin remote: ${url}`);
2515
- }
2516
- return { owner: match[1], repo: match[2] };
2517
- }
2518
- function getAuthenticatedLogin() {
2519
- const { stdout, status } = run4("gh", ["api", "user", "--jq", ".login"]);
2520
- if (status !== 0) return null;
2521
- const login2 = stdout.trim();
2522
- return login2.length > 0 ? login2 : null;
2434
+ return issue;
2523
2435
  }
2524
- function listCandidateIssues(technique, value, limit) {
2525
- const args = [
2526
- "issue",
2527
- "list",
2528
- "--state",
2529
- "open",
2530
- "--limit",
2531
- String(limit),
2532
- "--json",
2533
- "number,title,body,url"
2534
- ];
2535
- switch (technique) {
2536
- case "label":
2537
- args.push("--label", value);
2538
- break;
2539
- case "assignee":
2540
- args.push("--assignee", value);
2541
- break;
2542
- case "title-contains":
2543
- args.push("--search", `${value} in:title`);
2544
- break;
2436
+ var implementNextCommand = new Command3("implement-next").description("Find the next open GitHub issue matching the configured filter, claim it, and invoke the AI code assistant (Claude or Codex)").option("--json", "Output issue details as JSON").option("--no-claude", "Skip all AI invocation (Claude or Codex) after claiming the issue").option("--with <executor>", "Executor to use: claude or codex", "claude").option("--query-only", "Print issue content and exit without claiming or invoking any AI tools").option("--yolo", "Launch with --dangerously-skip-permissions (Claude) or --dangerously-bypass-approvals-and-sandbox (Codex)").option("--silent", "Suppress step-by-step Claude output; show only the final summary").option("--model <string>", "Model identifier to pass to the executor").option("--effort <level>", "Reasoning effort to pass to the executor").option("--take-first", "When multiple issues match, pick the first without prompting").option("--limit <n>", "Max issues to fetch and display (default: 10)", "10").option("--ask-copilot-review", "Request a Copilot code review on the PR after AI invocation finishes").action(async (options) => {
2437
+ const config = readConfig();
2438
+ validateConfig(config);
2439
+ const limit = Number.parseInt(options.limit, 10);
2440
+ if (Number.isNaN(limit) || limit <= 0) {
2441
+ process.stderr.write(`Error: --limit must be a positive integer (got "${options.limit}").
2442
+ `);
2443
+ process.exit(1);
2545
2444
  }
2546
- return ghJson(args, "query GitHub issues");
2547
- }
2548
- function getIssueSurface(issueNumber) {
2549
- const raw = ghJson(
2550
- [
2551
- "issue",
2552
- "view",
2553
- String(issueNumber),
2554
- "--json",
2555
- "number,title,body,url,state,author,createdAt,assignees,labels,comments"
2556
- ],
2557
- `read issue #${String(issueNumber)}`
2558
- );
2559
- const messages = [
2560
- {
2561
- kind: "issue-body",
2562
- author: login(raw.author),
2563
- body: raw.body,
2564
- createdAt: raw.createdAt
2565
- },
2566
- ...(raw.comments ?? []).map((comment) => ({
2567
- kind: "issue-comment",
2568
- author: login(comment.author),
2569
- body: comment.body,
2570
- createdAt: comment.createdAt
2571
- }))
2572
- ].sort(byCreatedAt2);
2573
- return {
2574
- issue: { number: raw.number, title: raw.title, body: raw.body, url: raw.url },
2575
- state: raw.state === "CLOSED" ? "CLOSED" : "OPEN",
2576
- assignees: (raw.assignees ?? []).map(login).filter((name) => name.length > 0),
2577
- labels: (raw.labels ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
2578
- messages
2579
- };
2580
- }
2581
- var LINK_MAP_QUERY = `
2582
- query($owner:String!,$repo:String!,$cursor:String){
2583
- repository(owner:$owner,name:$repo){
2584
- defaultBranchRef{ name }
2585
- pullRequests(states:OPEN, first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}){
2586
- pageInfo{ hasNextPage endCursor }
2587
- nodes{
2588
- number url title headRefName baseRefName isCrossRepository isDraft updatedAt
2589
- labels(first:50){ nodes{ name } }
2590
- assignees(first:50){ nodes{ login } }
2591
- closingIssuesReferences(first:50){
2592
- pageInfo{ hasNextPage }
2593
- nodes{ number repository{ nameWithOwner } }
2594
- }
2595
- }
2596
- }
2445
+ let issues;
2446
+ try {
2447
+ issues = listIssues(config.issueDiscoveryTechnique, config.issueDiscoveryValue, limit);
2448
+ } catch (err) {
2449
+ process.stderr.write(`Error: ${err.message}
2450
+ `);
2451
+ process.exit(1);
2597
2452
  }
2598
- }`.trim();
2599
- var MAX_LINK_MAP_PAGES = 50;
2600
- function indexPullRequest(map, node, nameWithOwner) {
2601
- const ref = toPullRequestRef(node);
2602
- if (node.closingIssuesReferences.pageInfo?.hasNextPage) {
2603
- throw new Error(
2604
- `Pull request #${String(node.number)} closes more than 50 issues, so the issue-to-pull-request map cannot be read completely. Refusing the tick rather than risk starting work on an issue that already has a pull request.`
2605
- );
2453
+ const issue = await resolveIssue(issues, options, limit);
2454
+ if (options.json) {
2455
+ process.stdout.write(JSON.stringify({ number: issue.number, title: issue.title, body: issue.body, url: issue.url }, null, 2) + "\n");
2456
+ } else {
2457
+ process.stdout.write(`URL: ${issue.url}
2458
+
2459
+ ${issue.body}
2460
+ `);
2606
2461
  }
2607
- let closedAnyHere = false;
2608
- for (const issue of node.closingIssuesReferences.nodes) {
2609
- if (issue.repository.nameWithOwner.toLowerCase() !== nameWithOwner.toLowerCase()) continue;
2610
- closedAnyHere = true;
2611
- const existing = map.get(issue.number);
2612
- if (existing) {
2613
- existing.push(ref);
2462
+ if (options.queryOnly) {
2463
+ process.exit(0);
2464
+ }
2465
+ const executor = options.claude === false ? void 0 : resolveExecutor(options.with);
2466
+ let commentUrl;
2467
+ try {
2468
+ commentUrl = postComment(issue.number, "working");
2469
+ } catch (err) {
2470
+ process.stderr.write(`Error: ${err.message}
2471
+ `);
2472
+ process.exit(1);
2473
+ }
2474
+ const effort = resolveEffortOption(options.effort);
2475
+ if (options.claude !== false) {
2476
+ const systemPrompt = config.claudeSystemPrompt ?? DEFAULT_CLAUDE_SYSTEM_PROMPT;
2477
+ const prompt = `Resolving issue #${issue.number}:
2478
+
2479
+ ${systemPrompt}
2480
+
2481
+ ${issue.body}`;
2482
+ if (executor === "codex") {
2483
+ if (options.silent) {
2484
+ process.stderr.write("Warning: --silent is only supported with Claude and has no effect when used with Codex.\n");
2485
+ }
2486
+ invokeCodexCode(prompt, { yolo: options.yolo, model: options.model, effort });
2614
2487
  } else {
2615
- map.set(issue.number, [ref]);
2488
+ await invokeClaudeCode(prompt, {
2489
+ yolo: options.yolo,
2490
+ verbose: !options.silent,
2491
+ model: options.model,
2492
+ effort
2493
+ });
2616
2494
  }
2617
2495
  }
2618
- return closedAnyHere;
2496
+ linkPrToIssue(issue.number, commentUrl, options.askCopilotReview === true, config.agentUser);
2497
+ });
2498
+ function resolveExecutor(requested) {
2499
+ const executor = requested.toLowerCase();
2500
+ if (executor !== "claude" && executor !== "codex") {
2501
+ process.stderr.write(`Error: --with must be 'claude' or 'codex', got '${requested}'.
2502
+ `);
2503
+ process.exit(1);
2504
+ }
2505
+ return executor;
2619
2506
  }
2620
- function toPullRequestRef(node) {
2621
- return {
2622
- number: node.number,
2623
- url: node.url,
2624
- title: node.title,
2625
- headRefName: node.headRefName,
2626
- baseRefName: node.baseRefName,
2627
- isCrossRepository: node.isCrossRepository,
2628
- state: "OPEN",
2629
- isDraft: node.isDraft,
2630
- updatedAt: node.updatedAt
2631
- };
2507
+ function warnOnFailure(what, action) {
2508
+ try {
2509
+ action();
2510
+ } catch (err) {
2511
+ process.stderr.write(`Warning: could not ${what}: ${err.message}
2512
+ `);
2513
+ }
2632
2514
  }
2633
- function toOrphanPr(node) {
2634
- return {
2635
- pr: toPullRequestRef(node),
2636
- labels: (node.labels?.nodes ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
2637
- assignees: (node.assignees?.nodes ?? []).map((assignee) => assignee.login ?? "").filter((login2) => login2.length > 0)
2638
- };
2515
+ function linkPrToIssue(issueNumber, commentUrl, askCopilotReview, agentUser) {
2516
+ let pr;
2517
+ try {
2518
+ pr = getCurrentBranchPr();
2519
+ } catch (err) {
2520
+ process.stderr.write(`Warning: could not detect current branch PR: ${err.message}
2521
+ `);
2522
+ return;
2523
+ }
2524
+ if (!pr) return;
2525
+ if (commentUrl) {
2526
+ warnOnFailure("update issue comment", () => {
2527
+ editComment(commentUrl, `Working on this in PR #${pr.number} \u2014 ${pr.url}`);
2528
+ });
2529
+ }
2530
+ warnOnFailure(`add Closes #${String(issueNumber)} to PR`, () => {
2531
+ addClosesRefToPr(pr.number, issueNumber);
2532
+ });
2533
+ if (pr.assignees.length === 0) {
2534
+ const claimant = (agentUser ?? "").trim() || "@me";
2535
+ warnOnFailure(`assign PR #${String(pr.number)} to ${claimant}`, () => {
2536
+ assignPrToAgent(pr.number, claimant);
2537
+ });
2538
+ }
2539
+ if (askCopilotReview) {
2540
+ warnOnFailure("request Copilot review", () => {
2541
+ addCopilotReviewer(pr.number);
2542
+ });
2543
+ }
2639
2544
  }
2640
- function getOpenPrLinkMap() {
2641
- const { owner, repo } = getRepoSlug();
2642
- const map = /* @__PURE__ */ new Map();
2643
- const orphans = [];
2644
- let defaultBranch = null;
2645
- let cursor = null;
2646
- for (let page = 0; page < MAX_LINK_MAP_PAGES; page++) {
2647
- const args = ["api", "graphql", "-f", `query=${LINK_MAP_QUERY}`, "-f", `owner=${owner}`, "-f", `repo=${repo}`];
2648
- if (cursor !== null) args.push("-f", `cursor=${cursor}`);
2649
- const response = ghJson(args, "query open pull requests");
2650
- defaultBranch = response.data.repository.defaultBranchRef?.name ?? defaultBranch;
2651
- const connection = response.data.repository.pullRequests;
2652
- for (const node of connection.nodes) {
2653
- if (!indexPullRequest(map, node, `${owner}/${repo}`)) {
2654
- orphans.push(toOrphanPr(node));
2655
- }
2656
- }
2657
- if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
2658
- return { byIssue: map, defaultBranch, orphans };
2545
+
2546
+ // src/commands/execute.ts
2547
+ import { readFileSync as readFileSync2 } from "fs";
2548
+ import { Command as Command4 } from "commander";
2549
+ function readStdin() {
2550
+ return new Promise((resolve2, reject) => {
2551
+ const chunks = [];
2552
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
2553
+ process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
2554
+ process.stdin.on("error", reject);
2555
+ });
2556
+ }
2557
+ async function resolvePrompt(options) {
2558
+ const hasCli = options.prompt !== void 0;
2559
+ const hasFile = options.filePrompt !== void 0;
2560
+ if (hasCli && hasFile) {
2561
+ process.stderr.write("Error: --prompt is mutually exclusive with --file-prompt.\n");
2562
+ process.exit(1);
2563
+ }
2564
+ if (hasCli) {
2565
+ return options.prompt;
2566
+ }
2567
+ if (hasFile) {
2568
+ const path = options.filePrompt;
2569
+ try {
2570
+ return readFileSync2(path, "utf8");
2571
+ } catch {
2572
+ process.stderr.write(`Error: Cannot read file: ${path}
2573
+ `);
2574
+ process.exit(1);
2659
2575
  }
2660
- cursor = connection.pageInfo.endCursor;
2661
2576
  }
2662
- throw new Error(
2663
- `Stopped paginating open pull requests after ${String(MAX_LINK_MAP_PAGES)} pages, so the issue-to-pull-request map cannot be trusted. Refusing the tick rather than risk starting work on an issue that already has a pull request.`
2664
- );
2665
- }
2666
- var REVIEW_THREADS_QUERY2 = `
2667
- query($owner:String!,$repo:String!,$prNumber:Int!,$cursor:String){
2668
- repository(owner:$owner,name:$repo){
2669
- pullRequest(number:$prNumber){
2670
- reviewThreads(first:100, after:$cursor){
2671
- pageInfo{ hasNextPage endCursor }
2672
- nodes{
2673
- isResolved isOutdated path line
2674
- comments(last:100){
2675
- pageInfo{ hasPreviousPage }
2676
- nodes{ author{login} body createdAt url pullRequestReview{ submittedAt } }
2677
- }
2678
- }
2679
- }
2577
+ if (!process.stdin.isTTY) {
2578
+ const content = await readStdin();
2579
+ if (content.trim().length === 0) {
2580
+ process.stderr.write("Error: No prompt provided. Use --prompt, --file-prompt, or pipe via stdin.\n");
2581
+ process.exit(1);
2680
2582
  }
2583
+ return content;
2681
2584
  }
2682
- }`.trim();
2683
- var MAX_THREAD_PAGES = 50;
2684
- function normalizePrState(state) {
2685
- if (state === "MERGED") return "MERGED";
2686
- if (state === "CLOSED") return "CLOSED";
2687
- return "OPEN";
2585
+ process.stderr.write("Error: No prompt provided. Use --prompt, --file-prompt, or pipe via stdin.\n");
2586
+ process.exit(1);
2688
2587
  }
2689
- function getReviewThreads(prNumber) {
2690
- const { owner, repo } = getRepoSlug();
2691
- const threads = [];
2692
- let cursor = null;
2693
- for (let page = 0; page < MAX_THREAD_PAGES; page++) {
2694
- const args = [
2695
- "api",
2696
- "graphql",
2697
- "-f",
2698
- `query=${REVIEW_THREADS_QUERY2}`,
2699
- "-f",
2700
- `owner=${owner}`,
2701
- "-f",
2702
- `repo=${repo}`,
2703
- "-F",
2704
- `prNumber=${String(prNumber)}`
2705
- ];
2706
- if (cursor !== null) args.push("-f", `cursor=${cursor}`);
2707
- const response = ghJson(
2708
- args,
2709
- `query review threads for pull request #${String(prNumber)}`
2710
- );
2711
- const connection = response.data.repository.pullRequest.reviewThreads;
2712
- for (const node of connection.nodes) {
2713
- if (node.comments.pageInfo?.hasPreviousPage) {
2714
- throw new Error(
2715
- `Review thread on ${node.path} in pull request #${String(prNumber)} has more than 100 comments, so the earliest ones were not read. Refusing rather than risk suppressing a maintainer's request.`
2716
- );
2717
- }
2718
- threads.push({
2719
- path: node.path,
2720
- line: node.line ?? null,
2721
- isResolved: node.isResolved,
2722
- url: node.comments.nodes.at(-1)?.url ?? null,
2723
- comments: node.comments.nodes.map((comment) => ({
2724
- kind: "thread-comment",
2725
- author: login(comment.author),
2726
- body: comment.body,
2727
- // When the comment became *visible*, not when it was drafted.
2728
- // GitHub stamps `createdAt` the moment a comment is added to a
2729
- // pending review, and it only becomes visible when the review is
2730
- // submitted minutes later for a human working through a diff.
2731
- // Using `createdAt` let an agent answer posted in between look newer
2732
- // than the review, which marked every one of its threads answered
2733
- // and discarded the whole review silently.
2734
- createdAt: visibleAt(comment.createdAt, comment.pullRequestReview?.submittedAt)
2735
- })).sort(byCreatedAt2)
2736
- });
2737
- }
2738
- if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
2739
- return threads;
2588
+ var executeCommand = new Command4("execute").description("Delegate work to an AI executor").requiredOption("--with <executor>", "Executor to use: claude or codex").option("--prompt <string>", "Prompt to send to the executor").option("--file-prompt <path>", "Path to a file whose content is used as the prompt").option("--silent", "Suppress step-by-step Claude output; show only the final summary").option("--model <string>", "Model identifier to pass to the executor").option("--effort <level>", "Reasoning effort to pass to the executor").action(async (options) => {
2589
+ const executor = options.with.toLowerCase();
2590
+ if (executor !== "claude" && executor !== "codex") {
2591
+ process.stderr.write(`Error: --with must be 'claude' or 'codex', got '${options.with}'.
2592
+ `);
2593
+ process.exit(1);
2594
+ }
2595
+ const prompt = await resolvePrompt(options);
2596
+ const effort = resolveEffortOption(options.effort);
2597
+ if (executor === "codex") {
2598
+ invokeCodexCode(prompt, { yolo: true, model: options.model, effort });
2599
+ } else {
2600
+ await invokeClaudeCode(prompt, {
2601
+ yolo: true,
2602
+ verbose: !options.silent,
2603
+ model: options.model,
2604
+ effort
2605
+ });
2606
+ }
2607
+ });
2608
+
2609
+ // src/commands/executePrompt.ts
2610
+ import { Command as Command5 } from "commander";
2611
+
2612
+ // src/github/conversation.ts
2613
+ var KIND_LABELS = {
2614
+ "issue-body": "issue description",
2615
+ "issue-comment": "comment",
2616
+ "pr-comment": "pull request comment",
2617
+ "pr-review": "pull request review",
2618
+ "thread-comment": "review thread comment"
2619
+ };
2620
+ function byCreatedAt2(a, b) {
2621
+ return a.createdAt.localeCompare(b.createdAt);
2622
+ }
2623
+ function classify(author, p) {
2624
+ const login2 = author.toLowerCase();
2625
+ if (login2 === p.agentUser.toLowerCase()) return "agent";
2626
+ return p.allowedUsers.some((user) => user.toLowerCase() === login2) ? "authorized" : "other";
2627
+ }
2628
+ function analyzeSurface(messages, p) {
2629
+ const ordered = [...messages].sort(byCreatedAt2);
2630
+ let lastAgentAt = null;
2631
+ for (const message of ordered) {
2632
+ if (message.kind === "issue-body") continue;
2633
+ if (classify(message.author, p) !== "agent") continue;
2634
+ if (lastAgentAt === null || message.createdAt > lastAgentAt) {
2635
+ lastAgentAt = message.createdAt;
2740
2636
  }
2741
- cursor = connection.pageInfo.endCursor;
2742
2637
  }
2743
- throw new Error(
2744
- `Stopped paginating review threads for pull request #${String(prNumber)} after ${String(MAX_THREAD_PAGES)} pages; refusing rather than answering only part of the feedback.`
2745
- );
2638
+ const kept = [];
2639
+ for (const message of ordered) {
2640
+ const authorClass = classify(message.author, p);
2641
+ if (authorClass === "other") continue;
2642
+ const isNew = authorClass === "authorized" && (lastAgentAt === null || message.createdAt > lastAgentAt);
2643
+ kept.push({ ...message, isNew });
2644
+ }
2645
+ const newMessages = kept.filter((message) => message.isNew);
2646
+ return {
2647
+ messages: kept,
2648
+ newMessages,
2649
+ newMessageCount: newMessages.length,
2650
+ hasNewMessage: newMessages.length > 0,
2651
+ lastAgentAt
2652
+ };
2746
2653
  }
2747
- function getPrSurface(prNumber) {
2748
- const raw = ghJson(
2749
- [
2750
- "pr",
2751
- "view",
2752
- String(prNumber),
2753
- "--json",
2754
- "number,title,url,headRefName,baseRefName,isCrossRepository,state,isDraft,body,author,createdAt,updatedAt,comments,reviews"
2755
- ],
2756
- `read pull request #${String(prNumber)}`
2757
- );
2654
+ function lastAuthorClass(messages, p) {
2655
+ if (messages.length === 0) return "none";
2656
+ const newest2 = [...messages].sort(byCreatedAt2).at(-1);
2657
+ return newest2 === void 0 ? "none" : classify(newest2.author, p);
2658
+ }
2659
+ function formatMessages(messages) {
2660
+ return messages.map((message) => {
2661
+ const marker = message.isNew ? " \xB7 NEW since last agent run" : "";
2662
+ return `[${message.author}] ${KIND_LABELS[message.kind]} \xB7 ${message.createdAt}${marker}
2663
+ ${message.body}`;
2664
+ }).join("\n\n");
2665
+ }
2666
+
2667
+ // src/github/issueConversation.ts
2668
+ function analyzeConversation(conversation, allowedUsers, agentUser) {
2669
+ const participants = { allowedUsers, agentUser };
2758
2670
  const messages = [
2759
- ...(raw.comments ?? []).map((comment) => ({
2760
- kind: "pr-comment",
2761
- author: login(comment.author),
2671
+ {
2672
+ kind: "issue-body",
2673
+ author: conversation.author,
2674
+ body: conversation.body,
2675
+ createdAt: conversation.createdAt
2676
+ },
2677
+ ...conversation.comments.map((comment) => ({
2678
+ kind: "issue-comment",
2679
+ author: comment.author,
2762
2680
  body: comment.body,
2763
2681
  createdAt: comment.createdAt
2764
- })),
2765
- // A review with no body carries no message — only its inline comments do,
2766
- // and those arrive through the review-thread query.
2767
- ...(raw.reviews ?? []).filter((review) => review.body.trim().length > 0).map((review) => ({
2768
- kind: "pr-review",
2769
- author: login(review.author),
2770
- body: review.body,
2771
- createdAt: review.submittedAt ?? review.createdAt ?? ""
2772
2682
  }))
2773
- ].sort(byCreatedAt2);
2774
- const threads = getReviewThreads(prNumber);
2775
- const state = normalizePrState(raw.state);
2683
+ ];
2684
+ const analysis = analyzeSurface(messages, participants);
2776
2685
  return {
2777
- pr: {
2778
- number: raw.number,
2779
- url: raw.url,
2780
- title: raw.title,
2781
- headRefName: raw.headRefName,
2782
- baseRefName: raw.baseRefName ?? "",
2783
- isCrossRepository: raw.isCrossRepository ?? false,
2784
- state,
2785
- isDraft: raw.isDraft ?? false,
2786
- updatedAt: raw.updatedAt ?? raw.createdAt
2787
- },
2788
- messages,
2789
- threads
2686
+ messages: analysis.messages.map(toConversationMessage),
2687
+ newMessageCount: analysis.newMessageCount,
2688
+ hasNewMessage: analysis.hasNewMessage,
2689
+ lastAgentAt: analysis.lastAgentAt
2790
2690
  };
2791
2691
  }
2792
- function assignIssueToAgent(issueNumber, agentUser) {
2793
- const { stderr, status } = run4("gh", [
2794
- "issue",
2795
- "edit",
2796
- String(issueNumber),
2797
- "--add-assignee",
2798
- agentUser
2799
- ]);
2800
- if (status !== 0) {
2801
- throw new Error(stderr.trim() || `Failed to assign issue #${String(issueNumber)} to ${agentUser}.`);
2802
- }
2692
+ function toConversationMessage(message) {
2693
+ return {
2694
+ kind: message.kind === "issue-body" ? "issue" : "comment",
2695
+ author: message.author,
2696
+ body: message.body,
2697
+ createdAt: message.createdAt,
2698
+ isNew: message.isNew
2699
+ };
2803
2700
  }
2804
- function postMarker(_surface, number, body) {
2805
- const { owner, repo } = getRepoSlug();
2806
- const raw = ghJson(
2807
- [
2808
- "api",
2809
- "--method",
2810
- "POST",
2811
- `repos/${owner}/${repo}/issues/${String(number)}/comments`,
2812
- "-f",
2813
- `body=${body}`
2814
- ],
2815
- `post a comment on #${String(number)}`
2701
+ function formatConversation(messages) {
2702
+ return formatMessages(
2703
+ messages.map((message) => ({
2704
+ kind: message.kind === "issue" ? "issue-body" : "issue-comment",
2705
+ author: message.author,
2706
+ body: message.body,
2707
+ createdAt: message.createdAt,
2708
+ isNew: message.isNew
2709
+ }))
2816
2710
  );
2817
- return { commentId: String(raw.id), createdAt: raw.created_at };
2818
2711
  }
2819
- function updateMarker(marker, body) {
2820
- const { owner, repo } = getRepoSlug();
2821
- const { stderr, status } = run4("gh", [
2822
- "api",
2823
- "--method",
2824
- "PATCH",
2825
- `repos/${owner}/${repo}/issues/comments/${marker.commentId}`,
2826
- "-f",
2827
- `body=${body}`
2828
- ]);
2829
- if (status !== 0) {
2830
- throw new Error(stderr.trim() || `Failed to update comment ${marker.commentId}.`);
2712
+
2713
+ // src/commands/executePrompt.ts
2714
+ var PUSH_INSTRUCTION = "Once all changes are complete, stage every modified file, create a single commit with a clear and descriptive commit message that summarises what was fixed, and push the branch to the remote.";
2715
+ function withPush(prompt, push) {
2716
+ return push ? `${prompt}
2717
+
2718
+ ${PUSH_INSTRUCTION}` : prompt;
2719
+ }
2720
+ function formatPrInfoContext(pr) {
2721
+ return JSON.stringify(pr, null, 2);
2722
+ }
2723
+ function pluralSuffix(count) {
2724
+ return count === 1 ? "" : "s";
2725
+ }
2726
+ function addAiOptions(cmd) {
2727
+ return cmd.requiredOption("--with <executor>", "Executor to use: claude or codex").option("--model <string>", "Model identifier to pass to the executor").option("--effort <level>", "Reasoning effort to pass to the executor").option("--silent", "Suppress step-by-step Claude output; show only the final summary").option("--push", "Append instruction to commit and push changes after the AI finishes");
2728
+ }
2729
+ function resolveExecutor2(withOption) {
2730
+ const executor = withOption.toLowerCase();
2731
+ if (executor !== "claude" && executor !== "codex") {
2732
+ process.stderr.write(`Error: --with must be 'claude' or 'codex', got '${withOption}'.
2733
+ `);
2734
+ process.exit(1);
2831
2735
  }
2736
+ return executor;
2832
2737
  }
2833
- function deleteMarker(marker) {
2834
- const { owner, repo } = getRepoSlug();
2835
- const { stderr, status } = run4("gh", [
2836
- "api",
2837
- "--method",
2838
- "DELETE",
2839
- `repos/${owner}/${repo}/issues/comments/${marker.commentId}`
2840
- ]);
2841
- if (status !== 0) {
2842
- if (/not found/i.test(stderr) || /HTTP 404/i.test(stderr)) return;
2843
- throw new Error(stderr.trim() || `Failed to delete comment ${marker.commentId}.`);
2738
+ function invokeSelectedExecutor(prompt, executor, options) {
2739
+ const effort = resolveEffortOption(options.effort);
2740
+ if (executor === "codex") {
2741
+ invokeCodexCode(prompt, { yolo: true, model: options.model, effort });
2742
+ return;
2743
+ }
2744
+ return invokeClaudeCode(prompt, {
2745
+ yolo: true,
2746
+ verbose: !options.silent,
2747
+ model: options.model,
2748
+ effort
2749
+ });
2750
+ }
2751
+ var executeSonarCmd = addAiOptions(
2752
+ new Command5("sonar").description(
2753
+ "Check the current branch for a SonarCloud analysis and invoke the AI with the Sonar prompt and analysis URL"
2754
+ )
2755
+ ).action(async (options) => {
2756
+ const executor = resolveExecutor2(options.with);
2757
+ let branch;
2758
+ try {
2759
+ branch = getCurrentBranch();
2760
+ } catch (err) {
2761
+ process.stderr.write(`Error: ${err.message}
2762
+ `);
2763
+ process.exit(1);
2764
+ }
2765
+ let pr;
2766
+ try {
2767
+ pr = await getPrInfo2(branch);
2768
+ } catch (err) {
2769
+ process.stderr.write(`Error: ${err.message}
2770
+ `);
2771
+ process.exit(1);
2772
+ }
2773
+ if (pr === null) {
2774
+ process.stderr.write(`Error: No pull request found for branch: ${branch}
2775
+ `);
2776
+ process.exit(1);
2777
+ }
2778
+ if (!pr.sonarcloudUrl) {
2779
+ process.stderr.write(
2780
+ `Error: No SonarCloud analysis found for PR #${pr.number}. Ensure a SonarCloud check is configured on this repository.
2781
+ `
2782
+ );
2783
+ process.exit(1);
2844
2784
  }
2845
- }
2846
- function toHeadState(state) {
2847
- return state === "OPEN" || state === "MERGED" ? state : "CLOSED";
2848
- }
2849
- function listPullRequestsForHead(branch) {
2850
- const raw = ghJson(
2851
- ["pr", "list", "--head", branch, "--state", "all", "--json", "number,state,url,updatedAt,author"],
2852
- `list pull requests for branch ${branch}`
2785
+ const config = readConfig();
2786
+ const sonarPromptText = config.prompts?.sonar ?? DEFAULT_SONAR_PROMPT;
2787
+ const fullPrompt = withPush(
2788
+ `${sonarPromptText}
2789
+
2790
+ SonarCloud analysis URL: ${pr.sonarcloudUrl}
2791
+
2792
+ Current PR context from automata git get-pr-info --json:
2793
+ ${formatPrInfoContext(pr)}`,
2794
+ options.push
2853
2795
  );
2854
- return raw.map((pr) => ({
2855
- number: pr.number,
2856
- url: pr.url,
2857
- state: toHeadState(pr.state),
2858
- updatedAt: pr.updatedAt,
2859
- author: pr.author?.login ?? ""
2860
- })).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
2861
- }
2862
- var MISSING_LABEL_PATTERNS = [
2863
- // gh's own message: `could not add label: 'rescue' not found`.
2864
- /could not add label/i,
2865
- // The GraphQL error it wraps, seen directly on some gh versions.
2866
- /could not resolve to a label/i,
2867
- /\blabels?\b[^\n]*\b(?:not found|does not exist)\b/i
2868
- ];
2869
- function isMissingLabelError(stderr) {
2870
- return MISSING_LABEL_PATTERNS.some((pattern) => pattern.test(stderr));
2796
+ await invokeSelectedExecutor(fullPrompt, executor, options);
2797
+ });
2798
+ function formatComments(comments) {
2799
+ return comments.map((c) => {
2800
+ const loc = c.line === null ? `${c.path}:(file)` : `${c.path}:${String(c.line)}`;
2801
+ return `[${c.author}] on ${loc}
2802
+ ${c.body}`;
2803
+ }).join("\n\n");
2871
2804
  }
2872
- function createDraftPullRequest(input) {
2873
- const base = [
2874
- "pr",
2875
- "create",
2876
- "--draft",
2877
- "--head",
2878
- input.head,
2879
- "--base",
2880
- input.base,
2881
- "--title",
2882
- input.title,
2883
- "--body",
2884
- input.body
2885
- ];
2886
- if (input.label !== void 0 && input.label.length > 0) {
2887
- const labelled = run4("gh", [...base, "--label", input.label]);
2888
- if (labelled.status === 0) return parseCreatedPrUrl(labelled.stdout, input.head);
2889
- if (!isMissingLabelError(labelled.stderr)) {
2890
- throw new Error(
2891
- labelled.stderr.trim() || `Failed to open a draft pull request for ${input.head}.`
2805
+ var executeFixCommentsCmd = addAiOptions(
2806
+ new Command5("fix-comments").description(
2807
+ "Fetch open review comments on the current PR and invoke the AI with the Fix-Comments prompt"
2808
+ )
2809
+ ).action(async (options) => {
2810
+ const executor = resolveExecutor2(options.with);
2811
+ const result = resolveCurrentBranchComments();
2812
+ if (!result.ok) {
2813
+ if (result.kind === "error") {
2814
+ process.stderr.write(`Error: ${result.message}
2815
+ `);
2816
+ process.exit(1);
2817
+ }
2818
+ if (result.kind === "unsupported") {
2819
+ process.stderr.write(
2820
+ `Error: fix-comments is not supported for Azure DevOps. See docs/azdo-gap.md for details.
2821
+ `
2892
2822
  );
2823
+ process.exit(1);
2893
2824
  }
2825
+ process.stderr.write(`Error: No pull request found for branch: ${result.branch}
2826
+ `);
2827
+ process.exit(1);
2894
2828
  }
2895
- const { stdout, stderr, status } = run4("gh", base);
2896
- if (status !== 0) {
2897
- throw new Error(stderr.trim() || `Failed to open a draft pull request for ${input.head}.`);
2829
+ const { comments } = result;
2830
+ if (comments.length === 0) {
2831
+ process.stderr.write(`Error: No open review comments found on the pull request.
2832
+ `);
2833
+ process.exit(1);
2898
2834
  }
2899
- return parseCreatedPrUrl(stdout, input.head);
2900
- }
2901
- function parseCreatedPrUrl(stdout, head) {
2902
- const url = stdout.trim().split("\n").pop()?.trim() ?? "";
2903
- const match = /\/pull\/(\d+)\s*$/.exec(url);
2904
- if (!match) {
2905
- throw new Error(`Opened a pull request for ${head} but could not read its number from: ${url}`);
2835
+ process.stdout.write(`Found ${String(comments.length)} open review comment${comments.length === 1 ? "" : "s"} on PR. Invoking AI\u2026
2836
+ `);
2837
+ const config = readConfig();
2838
+ const promptText = config.prompts?.fixComments ?? DEFAULT_FIX_COMMENTS_PROMPT;
2839
+ const fullPrompt = withPush(
2840
+ `${promptText}
2841
+
2842
+ Open review comments:
2843
+
2844
+ ${formatComments(comments)}`,
2845
+ options.push
2846
+ );
2847
+ await invokeSelectedExecutor(fullPrompt, executor, options);
2848
+ });
2849
+ var executeCheckIssueCmd = addAiOptions(
2850
+ new Command5("check-issue").description(
2851
+ "Check a GitHub issue for a new message from an allowed user since the last agent run and invoke the AI with the issue conversation"
2852
+ ).argument("<issue-number>", "GitHub issue number to check")
2853
+ ).option("--force", "Skip the new-message check and invoke the AI directly").action(async (issueNumberArg, options) => {
2854
+ const executor = resolveExecutor2(options.with);
2855
+ const issueNumber = Number.parseInt(issueNumberArg, 10);
2856
+ if (Number.isNaN(issueNumber) || issueNumber <= 0) {
2857
+ process.stderr.write(`Error: <issue-number> must be a positive integer (got '${issueNumberArg}').
2858
+ `);
2859
+ process.exit(1);
2906
2860
  }
2907
- return { number: Number(match[1]), url };
2908
- }
2861
+ const config = readConfig();
2862
+ if (config.remoteType === "azdo") {
2863
+ process.stderr.write(
2864
+ "Error: check-issue is not supported for Azure DevOps. See docs/azdo-gap.md for details.\n"
2865
+ );
2866
+ process.exit(1);
2867
+ }
2868
+ const allowedUsers = (config.allowedUsers ?? []).filter((user) => user.trim().length > 0);
2869
+ if (allowedUsers.length === 0) {
2870
+ process.stderr.write(
2871
+ "Error: No allowed users configured. Run `automata config` or `automata config set allowed-users <user1,user2>` to set them.\n"
2872
+ );
2873
+ process.exit(1);
2874
+ }
2875
+ const agentUser = (config.agentUser ?? "").trim();
2876
+ if (agentUser.length === 0) {
2877
+ process.stderr.write(
2878
+ "Error: No agent user configured. Run `automata config` or `automata config set agent-user <login>` to set it.\n"
2879
+ );
2880
+ process.exit(1);
2881
+ }
2882
+ let conversation;
2883
+ try {
2884
+ conversation = getIssueConversation(issueNumber);
2885
+ } catch (err) {
2886
+ process.stderr.write(`Error: ${err.message}
2887
+ `);
2888
+ process.exit(1);
2889
+ }
2890
+ const analysis = analyzeConversation(conversation, allowedUsers, agentUser);
2891
+ if (!analysis.hasNewMessage && !options.force) {
2892
+ const since = analysis.lastAgentAt === null ? "" : ` (last agent message: ${analysis.lastAgentAt})`;
2893
+ process.stdout.write(
2894
+ `No new messages from allowed users on issue #${String(issueNumber)}${since}. Use --force to invoke the AI anyway.
2895
+ `
2896
+ );
2897
+ return;
2898
+ }
2899
+ if (analysis.hasNewMessage) {
2900
+ process.stdout.write(
2901
+ `Found ${String(analysis.newMessageCount)} new message${pluralSuffix(analysis.newMessageCount)} on issue #${String(issueNumber)}. Invoking AI\u2026
2902
+ `
2903
+ );
2904
+ } else {
2905
+ process.stdout.write(`No new messages on issue #${String(issueNumber)} \u2014 forced run. Invoking AI\u2026
2906
+ `);
2907
+ }
2908
+ const promptText = config.prompts?.checkIssue ?? DEFAULT_CHECK_ISSUE_PROMPT;
2909
+ const fullPrompt = withPush(
2910
+ `${promptText}
2911
+
2912
+ Issue #${String(issueNumber)}: ${conversation.title}
2913
+ URL: ${conversation.url}
2914
+
2915
+ Conversation (only messages from allowed users and the agent, oldest first):
2916
+
2917
+ ${formatConversation(analysis.messages)}`,
2918
+ options.push
2919
+ );
2920
+ const marker = analysis.hasNewMessage ? `automata check-issue: picked up ${String(analysis.newMessageCount)} new message${pluralSuffix(analysis.newMessageCount)}, starting an agent run.` : "automata check-issue: forced run, starting an agent run.";
2921
+ try {
2922
+ postComment(issueNumber, marker);
2923
+ } catch (err) {
2924
+ process.stderr.write(
2925
+ `Error: could not post the execution marker comment on issue #${String(issueNumber)}: ${err.message}
2926
+ `
2927
+ );
2928
+ process.exit(1);
2929
+ }
2930
+ await invokeSelectedExecutor(fullPrompt, executor, options);
2931
+ });
2932
+ var executePromptCommand = new Command5("execute-prompt").description("Execute a configured custom prompt using an AI assistant").addCommand(executeSonarCmd).addCommand(executeFixCommentsCmd).addCommand(executeCheckIssueCmd);
2933
+
2934
+ // src/commands/doWork.ts
2935
+ import { Command as Command6 } from "commander";
2909
2936
 
2910
2937
  // src/github/workDetection.ts
2911
2938
  var NO_ISSUE_MESSAGES = {
@@ -2915,10 +2942,6 @@ var NO_ISSUE_MESSAGES = {
2915
2942
  hasNewMessage: false,
2916
2943
  lastAgentAt: null
2917
2944
  };
2918
- function isAssignedToAgent(assignees, agentUser) {
2919
- const agent = agentUser.toLowerCase();
2920
- return assignees.some((name) => name.toLowerCase() === agent);
2921
- }
2922
2945
  function findActionableThreads(threads, p, prLastAgentAt) {
2923
2946
  const actionable = [];
2924
2947
  for (const thread of threads) {
@@ -2983,7 +3006,7 @@ function decideWork(state, p, policy) {
2983
3006
  return { kind: "skip", issue, pr: null, reason: "issue-closed", detail: "the issue is closed" };
2984
3007
  }
2985
3008
  const issueAnalysis = analyzeSurface(issueSurface.messages, p);
2986
- const needsAssignment = !isAssignedToAgent(issueSurface.assignees, p.agentUser);
3009
+ const needsAssignment = issueSurface.assignees.length === 0;
2987
3010
  const openPrs = state.linkedPrs.filter((pr) => pr.state === "OPEN");
2988
3011
  const hasOpenPr = openPrs.length > 0 && prSurface !== null && prSurface.pr.state === "OPEN";
2989
3012
  if (!hasOpenPr) {
@@ -3004,6 +3027,7 @@ function decideWork(state, p, policy) {
3004
3027
  pr: null,
3005
3028
  branch: baseBranch,
3006
3029
  needsAssignment,
3030
+ prNeedsAssignment: false,
3007
3031
  issueAnalysis,
3008
3032
  prAnalysis: null,
3009
3033
  actionableThreads: [],
@@ -3044,6 +3068,7 @@ function decideWork(state, p, policy) {
3044
3068
  pr: surface.pr,
3045
3069
  branch: surface.pr.headRefName,
3046
3070
  needsAssignment,
3071
+ prNeedsAssignment: surface.assignees.length === 0,
3047
3072
  issueAnalysis,
3048
3073
  prAnalysis,
3049
3074
  actionableThreads,
@@ -3097,6 +3122,13 @@ function decideOrphanPrWork(state, p, policy) {
3097
3122
  // agent here would change what the discovery filter matches next tick.
3098
3123
  // The `working…` marker on the pull request is the claim.
3099
3124
  needsAssignment: false,
3125
+ // For the same reason, and more directly: the orphan pass discovers by
3126
+ // the *pull request's own* assignees, so claiming an unassigned orphan
3127
+ // would make it match the filter on the next tick — the agent would
3128
+ // permanently own a pull request the operator never opted in. A build
3129
+ // turn is safe because it reaches its pull request through an issue that
3130
+ // matched the filter, not through the pull request's assignees.
3131
+ prNeedsAssignment: false,
3100
3132
  issueAnalysis: NO_ISSUE_MESSAGES,
3101
3133
  prAnalysis,
3102
3134
  actionableThreads,
@@ -3129,6 +3161,30 @@ function selectLinkedPr(linkedPrs) {
3129
3161
  const open = linkedPrs.filter((pr) => pr.state === "OPEN");
3130
3162
  return open.length === 0 ? null : newestPr(open);
3131
3163
  }
3164
+ function claimStates(item) {
3165
+ const states = [];
3166
+ if (item.issue !== null) {
3167
+ states.push({
3168
+ surface: "issue",
3169
+ number: item.issue.number,
3170
+ state: item.needsAssignment ? "would-claim" : "already-assigned"
3171
+ });
3172
+ }
3173
+ if (item.pr !== null) {
3174
+ states.push({
3175
+ surface: "pull request",
3176
+ number: item.pr.number,
3177
+ // An exemption is a rule, not a state, so it outranks the assignee list:
3178
+ // an orphan pull request with nobody on it is still not claimed.
3179
+ state: prClaimState(item)
3180
+ });
3181
+ }
3182
+ return states;
3183
+ }
3184
+ function prClaimState(item) {
3185
+ if (item.turn === "pr-orphan") return "rule-exempt";
3186
+ return item.prNeedsAssignment ? "would-claim" : "already-assigned";
3187
+ }
3132
3188
 
3133
3189
  // src/github/workPrompt.ts
3134
3190
  function promptBaseBranch(item, configuredBaseBranch) {
@@ -3711,6 +3767,12 @@ function rescueUncommittedChanges(options, now) {
3711
3767
  );
3712
3768
  return { kind: "would-rescue", branch: target.branch, createdBranch: target.createdBranch };
3713
3769
  }
3770
+ const staged = stageAllExcept([RUN_LOCK_RELATIVE_PATH]);
3771
+ if (!staged.ok) {
3772
+ options.log(` rescue FAILED to stage the changes: ${staged.stderr}
3773
+ `);
3774
+ return { kind: "failed", step: "stage", detail: staged.stderr };
3775
+ }
3714
3776
  if (target.createdBranch) {
3715
3777
  const created = createBranchAtHead(target.branch);
3716
3778
  if (!created.ok) {
@@ -3719,17 +3781,18 @@ function rescueUncommittedChanges(options, now) {
3719
3781
  return { kind: "failed", step: "branch", detail: created.stderr };
3720
3782
  }
3721
3783
  }
3722
- const staged = stageAllExcept([RUN_LOCK_RELATIVE_PATH]);
3723
- if (!staged.ok) {
3724
- options.log(` rescue FAILED to stage the changes: ${staged.stderr}
3725
- `);
3726
- return { kind: "failed", step: "stage", detail: staged.stderr };
3727
- }
3784
+ const left = target.createdBranch ? { branch: target.branch, createdBranch: target.branch } : { branch: target.branch };
3728
3785
  const committed = commitStaged(`chore(automata): rescue uncommitted work from ${target.source}`);
3729
3786
  if (!committed.ok) {
3730
- options.log(` rescue FAILED to commit: ${committed.stderr}
3787
+ options.log(` rescue FAILED to commit on ${target.branch}: ${committed.stderr}
3731
3788
  `);
3732
- return { kind: "failed", step: "commit", detail: committed.stderr };
3789
+ if (target.createdBranch) {
3790
+ options.log(
3791
+ ` rescue ${target.branch} was created by this rescue and carries none of the work; it is left in the checkout
3792
+ `
3793
+ );
3794
+ }
3795
+ return { kind: "failed", step: "commit", detail: committed.stderr, ...left };
3733
3796
  }
3734
3797
  const pushed = pushSetUpstream(target.branch);
3735
3798
  if (!pushed.ok) {
@@ -3738,7 +3801,7 @@ function rescueUncommittedChanges(options, now) {
3738
3801
  rescue the work is committed locally on ${target.branch}; push it by hand before it is lost
3739
3802
  `
3740
3803
  );
3741
- return { kind: "failed", step: "push", detail: pushed.stderr };
3804
+ return { kind: "failed", step: "push", detail: pushed.stderr, ...left };
3742
3805
  }
3743
3806
  let existing;
3744
3807
  try {
@@ -3748,7 +3811,7 @@ function rescueUncommittedChanges(options, now) {
3748
3811
  ` rescue committed and pushed ${target.branch}, but could not check for an open PR: ${err.message}
3749
3812
  `
3750
3813
  );
3751
- return { kind: "failed", step: "pr", detail: err.message };
3814
+ return { kind: "failed", step: "pr", detail: err.message, ...left };
3752
3815
  }
3753
3816
  if (existing !== null) {
3754
3817
  options.log(
@@ -3789,7 +3852,7 @@ function rescueUncommittedChanges(options, now) {
3789
3852
  ` rescue committed and pushed ${target.branch}, but could not open a draft PR: ${err.message}
3790
3853
  `
3791
3854
  );
3792
- return { kind: "failed", step: "pr", detail: err.message };
3855
+ return { kind: "failed", step: "pr", detail: err.message, ...left };
3793
3856
  }
3794
3857
  }
3795
3858
  function prepareBase(options) {
@@ -3814,12 +3877,13 @@ function prepareBase(options) {
3814
3877
  `);
3815
3878
  return { ok: true };
3816
3879
  }
3817
- function collectCandidates(options) {
3880
+ function collectCandidates(options, rescueBranch) {
3818
3881
  const remote = listRemoteBranches();
3819
3882
  if (remote === null) return null;
3820
3883
  const remoteNames = new Set(remote);
3821
3884
  const current = getCurrentBranch();
3822
3885
  const untouchable = /* @__PURE__ */ new Set([options.baseBranch, current, ...options.protectedBranches]);
3886
+ if (rescueBranch !== null) untouchable.add(rescueBranch);
3823
3887
  return listLocalBranches().filter(
3824
3888
  (branch) => !untouchable.has(branch) && !remoteNames.has(branch)
3825
3889
  );
@@ -3915,8 +3979,8 @@ function pruneCandidate(branch, options) {
3915
3979
  return { kind: "rescued", branch, pr: null, prUrl: null };
3916
3980
  }
3917
3981
  }
3918
- function prune(options) {
3919
- const candidates = collectCandidates(options);
3982
+ function prune(options, rescueBranch) {
3983
+ const candidates = collectCandidates(options, rescueBranch);
3920
3984
  if (candidates === null) {
3921
3985
  options.log(
3922
3986
  " prune skipped: could not list origin's branches, so no branch can be shown to have no remote\n"
@@ -3936,12 +4000,46 @@ function runRepoHygiene(options, now = /* @__PURE__ */ new Date()) {
3936
4000
  options.log(options.dryRun ? "\nPre-flight (dry run):\n" : "\nPre-flight:\n");
3937
4001
  const rescue = rescueUncommittedChanges(options, now);
3938
4002
  const base = prepareBase(options);
3939
- const { outcomes, remoteUnreadable } = prune(options);
4003
+ const { outcomes, remoteUnreadable } = prune(options, rescueCreatedBranch(rescue));
3940
4004
  const degraded = rescue.kind === "failed" || !base.ok || remoteUnreadable || outcomes.some(
3941
4005
  (outcome) => outcome.kind === "kept" && outcome.reason !== "open-pr" || outcome.kind === "rescued" && outcome.pr === null
3942
4006
  );
3943
4007
  return { rescue, base, prunes: outcomes, degraded };
3944
4008
  }
4009
+ function rescueCreatedBranch(rescue) {
4010
+ switch (rescue.kind) {
4011
+ case "rescued":
4012
+ case "would-rescue":
4013
+ return rescue.createdBranch ? rescue.branch : null;
4014
+ case "failed":
4015
+ return rescue.createdBranch ?? null;
4016
+ default:
4017
+ return null;
4018
+ }
4019
+ }
4020
+ function describePreflightFailures(report) {
4021
+ const causes = [];
4022
+ if (report.rescue.kind === "failed") {
4023
+ causes.push(
4024
+ `the rescue failed at the ${report.rescue.step} step (${describeRescueRemains(report.rescue)}): ${report.rescue.detail}`
4025
+ );
4026
+ }
4027
+ if (!report.base.ok) {
4028
+ causes.push(`the base branch ${report.base.step} failed: ${report.base.detail}`);
4029
+ }
4030
+ return causes;
4031
+ }
4032
+ function describeRescueRemains(rescue) {
4033
+ if (rescue.branch === void 0) return "the tree is still dirty";
4034
+ switch (rescue.step) {
4035
+ case "push":
4036
+ return `the work is committed on ${rescue.branch} but not pushed`;
4037
+ case "pr":
4038
+ return `the work is committed and pushed on ${rescue.branch}, without a draft pull request`;
4039
+ default:
4040
+ return `the tree is still dirty and ${rescue.branch} carries none of it`;
4041
+ }
4042
+ }
3945
4043
 
3946
4044
  // src/run/operationLog.ts
3947
4045
  import {
@@ -4271,10 +4369,32 @@ function planRun(item, settings, execution) {
4271
4369
  });
4272
4370
  return { prompt, bin, args, command: [bin, ...args].map(shellQuote).join(" ") };
4273
4371
  }
4372
+ function describeAssignment(item, agentUser) {
4373
+ return claimStates(item).map((claim) => {
4374
+ const label = claim.surface === "issue" ? "issue" : `pull request #${String(claim.number)}`;
4375
+ switch (claim.state) {
4376
+ case "would-claim":
4377
+ return `would assign ${label} to ${agentUser}`;
4378
+ case "already-assigned":
4379
+ return `${label} already assigned`;
4380
+ case "rule-exempt":
4381
+ return `${label} not claimed (orphan pass)`;
4382
+ }
4383
+ }).join(" \xB7 ");
4384
+ }
4385
+ function describePlanClaim(item) {
4386
+ const claims = claimStates(item);
4387
+ const would = claims.filter((claim) => claim.state === "would-claim").map((claim) => `the ${claim.surface}`);
4388
+ const exempt = claims.filter((claim) => claim.state === "rule-exempt").map((claim) => claim.surface);
4389
+ const parts = [];
4390
+ if (would.length > 0) parts.push(`will assign ${would.join(" and ")} to the agent`);
4391
+ if (exempt.length > 0) parts.push(`${exempt.join(" and ")} not claimed (orphan pass)`);
4392
+ return parts.length === 0 ? "" : `, ${parts.join(", ")}`;
4393
+ }
4274
4394
  function describePlannedRun(item, settings, run5, execution) {
4275
4395
  const rule = "\u2500".repeat(72);
4276
4396
  const branchAction = item.turn === "issue-discuss" ? " and pull" : " and fast-forward";
4277
- const assignment = item.needsAssignment ? `would assign to ${settings.participants.agentUser}` : "already assigned";
4397
+ const assignment = describeAssignment(item, settings.participants.agentUser);
4278
4398
  const markerTarget = markerSurfaceLabel(item);
4279
4399
  const lines = [
4280
4400
  rule,
@@ -4469,8 +4589,7 @@ function describePlan(decisions) {
4469
4589
  return ` ${subjectLabel(decision.issue?.number ?? null, decision.pr?.number ?? null)} nothing to do \u2014 ${decision.detail}`;
4470
4590
  }
4471
4591
  const item = decision.item;
4472
- const claim = item.needsAssignment ? ", will assign to the agent" : "";
4473
- return ` ${itemLabel(item)} ${item.turn} on ${item.branch} \u2014 ${item.reason}${claim}`;
4592
+ return ` ${itemLabel(item)} ${item.turn} on ${item.branch} \u2014 ${item.reason}${describePlanClaim(item)}`;
4474
4593
  });
4475
4594
  return lines.length === 0 ? " (nothing matched the discovery filter)\n" : lines.join("\n") + "\n";
4476
4595
  }
@@ -4485,6 +4604,16 @@ function claimIssue(item, settings) {
4485
4604
  `);
4486
4605
  }
4487
4606
  }
4607
+ function claimPr(prNumber, agentUser) {
4608
+ try {
4609
+ assignPrToAgent(prNumber, agentUser);
4610
+ progress(` assigned pull request #${String(prNumber)} to ${agentUser}.
4611
+ `);
4612
+ } catch (err) {
4613
+ progress(` warning: could not assign pull request #${String(prNumber)}: ${err.message}
4614
+ `);
4615
+ }
4616
+ }
4488
4617
  function notePickupOnIssue(item, marker) {
4489
4618
  if (item.turn !== "pr-work" || item.pr === null || item.issue === null) return null;
4490
4619
  if (!item.issueAnalysis.hasNewMessage) return null;
@@ -4657,7 +4786,7 @@ async function invokeExecutor(prompt, execution, silent) {
4657
4786
  }
4658
4787
  await runClaude(prompt, { model: execution.model, effort: execution.effort, printSteps: !silent });
4659
4788
  }
4660
- function repairIssueLink(item, baseBranch) {
4789
+ function repairIssueLink(item, baseBranch, agentUser) {
4661
4790
  const issue = item.issue;
4662
4791
  if (issue === null) return false;
4663
4792
  try {
@@ -4673,6 +4802,9 @@ function repairIssueLink(item, baseBranch) {
4673
4802
  `);
4674
4803
  return false;
4675
4804
  }
4805
+ if (pr.assignees.length === 0) {
4806
+ claimPr(pr.number, agentUser);
4807
+ }
4676
4808
  const closesRef = new RegExp(String.raw`\bcloses\s+#` + String(issue.number) + String.raw`\b`, "i");
4677
4809
  if (closesRef.test(pr.body)) {
4678
4810
  progress(` pull request #${String(pr.number)} already closes issue #${String(issue.number)}.
@@ -4701,7 +4833,10 @@ function refuseBeforeRun(base, marker, detail, markerText) {
4701
4833
  }
4702
4834
  return { ...base, outcome: "failed", detail };
4703
4835
  }
4704
- async function processItem(planned, settings, silent) {
4836
+ function preflightSuffix(causes) {
4837
+ return causes.length === 0 ? "" : ` [pre-flight: ${causes.join("; ")}]`;
4838
+ }
4839
+ async function processItem(planned, settings, silent, preflightCauses) {
4705
4840
  progress(`
4706
4841
  ${itemLabel(planned)} ${planned.turn}: ${planned.reason}
4707
4842
  `);
@@ -4731,11 +4866,19 @@ ${itemLabel(planned)} ${planned.turn}: ${planned.reason}
4731
4866
  };
4732
4867
  const prepared = item.turn === "issue-discuss" ? prepareBaseBranch(item.branch) : preparePrBranch(item.branch);
4733
4868
  if (!prepared.ok) {
4734
- progress(` skipped: ${prepared.reason} \u2014 ${prepared.detail}
4869
+ const why = preflightSuffix(preflightCauses);
4870
+ progress(` skipped: ${prepared.reason} \u2014 ${prepared.detail}${why}
4735
4871
  `);
4736
- return { ...base, outcome: "skipped", detail: `${prepared.reason}: ${prepared.detail}` };
4872
+ return {
4873
+ ...base,
4874
+ outcome: "skipped",
4875
+ detail: `${prepared.reason}: ${prepared.detail}${why}`
4876
+ };
4737
4877
  }
4738
4878
  claimIssue(item, settings);
4879
+ if (item.turn === "pr-work" && item.pr !== null && item.prNeedsAssignment) {
4880
+ claimPr(item.pr.number, settings.participants.agentUser);
4881
+ }
4739
4882
  let marker;
4740
4883
  const markerTarget = markerSurfaceTarget(item);
4741
4884
  try {
@@ -4819,7 +4962,7 @@ function adjustOutcome(reconciled, item, settings, buriedByNote) {
4819
4962
  };
4820
4963
  }
4821
4964
  if (item.turn !== "issue-discuss") return outcome;
4822
- const linked = repairIssueLink(item, settings.baseBranch);
4965
+ const linked = repairIssueLink(item, settings.baseBranch, settings.participants.agentUser);
4823
4966
  if (linked && outcome.reason === "no-answer") {
4824
4967
  progress(" a pull request was opened, so the turn is counted as answered.\n");
4825
4968
  return {
@@ -5021,6 +5164,7 @@ ${describePlan(decisions)}`;
5021
5164
  }
5022
5165
  const reports = [];
5023
5166
  const deferred = [];
5167
+ const preflightCauses = describePreflightFailures(hygiene);
5024
5168
  let runsUsed = 0;
5025
5169
  for (const item of items) {
5026
5170
  if (settings.maxRuns > 0 && runsUsed >= settings.maxRuns) {
@@ -5029,7 +5173,7 @@ ${describePlan(decisions)}`;
5029
5173
  }
5030
5174
  let report;
5031
5175
  try {
5032
- report = await processItem(item, settings, options.silent === true);
5176
+ report = await processItem(item, settings, options.silent === true, preflightCauses);
5033
5177
  } catch (err) {
5034
5178
  progress(` failed: ${err.message}
5035
5179
  `);
@@ -5183,7 +5327,7 @@ function describeRescue(rescue) {
5183
5327
  case "would-rescue":
5184
5328
  return `would rescue onto ${rescue.branch}`;
5185
5329
  case "failed":
5186
- return `${rescue.step} failed \u2014 ${rescue.detail}; the tree is still dirty and nothing was discarded`;
5330
+ return `${rescue.step} failed \u2014 ${rescue.detail}; ${describeRescueRemains(rescue)}; nothing was discarded`;
5187
5331
  }
5188
5332
  }
5189
5333
  function describePrune(outcome) {
@@ -5227,6 +5371,7 @@ function toPlanJson(decision) {
5227
5371
  turn: item.turn,
5228
5372
  branch: item.branch,
5229
5373
  needsAssignment: item.needsAssignment,
5374
+ prNeedsAssignment: item.prNeedsAssignment,
5230
5375
  reason: item.reason
5231
5376
  };
5232
5377
  }