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

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 +1281 -1191
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1534,7 +1534,7 @@ function getCurrentBranchPr(branch) {
1534
1534
  if (branch) {
1535
1535
  args.push(branch);
1536
1536
  }
1537
- args.push("--json", "number,url,body");
1537
+ args.push("--json", "number,url,body,assignees");
1538
1538
  const { stdout, stderr, status } = run3("gh", args);
1539
1539
  if (status !== 0) {
1540
1540
  if (stderr.includes("no pull requests found") || stderr.includes("Could not resolve")) {
@@ -1542,7 +1542,15 @@ function getCurrentBranchPr(branch) {
1542
1542
  }
1543
1543
  throw new Error(stderr.trim() || "Failed to query PR for current branch.");
1544
1544
  }
1545
- return JSON.parse(stdout);
1545
+ const raw = JSON.parse(stdout);
1546
+ return {
1547
+ number: raw.number,
1548
+ url: raw.url,
1549
+ body: raw.body,
1550
+ // Absent when an older `gh` ignores the field, and `[]` is the right reading
1551
+ // of "nobody is assigned" either way.
1552
+ assignees: (raw.assignees ?? []).map((a) => a.login ?? "").filter((name) => name.length > 0)
1553
+ };
1546
1554
  }
1547
1555
  function addClosesRefToPr(prNumber, issueNumber) {
1548
1556
  const { stdout, status: viewStatus } = run3("gh", [
@@ -1577,1335 +1585,1350 @@ function addCopilotReviewer(prNumber) {
1577
1585
  }
1578
1586
  }
1579
1587
 
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);
1588
+ // src/github/ghWorkService.ts
1589
+ import { spawnSync as spawnSync4 } from "child_process";
1590
+ function run4(cmd, args) {
1591
+ const result = spawnSync4(cmd, args, { encoding: "utf8" });
1592
+ if (result.error) {
1593
+ const err = result.error;
1594
+ if (err.code === "ENOENT") {
1595
+ throw new Error(`\`${cmd}\` CLI is not installed or not on PATH.`);
1596
+ }
1597
+ throw new Error(err.message);
1603
1598
  }
1604
- process.stderr.write(`Error: ${err.message}
1605
- `);
1606
- process.exit(1);
1599
+ return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status ?? 1 };
1607
1600
  }
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
- }
1601
+ function ghJson(args, what) {
1602
+ const { stdout, stderr, status } = run4("gh", args);
1614
1603
  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);
1604
+ throw new Error(stderr.trim() || `Failed to ${what}. Is \`gh\` installed and authenticated?`);
1626
1605
  }
1627
- return trimmed2;
1628
- }
1629
-
1630
- // src/cli/childRegistry.ts
1631
- var active = /* @__PURE__ */ new Set();
1632
- function trackChild(child) {
1633
- active.add(child);
1606
+ return JSON.parse(stdout);
1634
1607
  }
1635
- function untrackChild(child) {
1636
- active.delete(child);
1608
+ function login(author) {
1609
+ return author?.login ?? "";
1637
1610
  }
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
- });
1611
+ function byCreatedAt(a, b) {
1612
+ return a.createdAt.localeCompare(b.createdAt);
1646
1613
  }
1647
- function afterDelay(ms) {
1648
- return new Promise((resolve2) => {
1649
- const timer = setTimeout(() => resolve2("timeout"), ms);
1650
- timer.unref();
1651
- });
1614
+ function visibleAt(createdAt, submittedAt) {
1615
+ if (submittedAt === null || submittedAt === void 0) return createdAt;
1616
+ return submittedAt > createdAt ? submittedAt : createdAt;
1652
1617
  }
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");
1618
+ function getRepoSlug() {
1619
+ const { stdout, status } = run4("git", ["remote", "get-url", "origin"]);
1620
+ if (status !== 0) {
1621
+ throw new Error("Could not read the `origin` remote. Is this a git repository with a remote?");
1659
1622
  }
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");
1623
+ const url = stdout.trim();
1624
+ const match = /github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url) ?? /github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url);
1625
+ if (!match) {
1626
+ throw new Error(`Could not determine the GitHub owner/repo from the origin remote: ${url}`);
1664
1627
  }
1665
- const escalated = await Promise.race([
1666
- Promise.all(exits).then(() => "exited"),
1667
- afterDelay(killGraceMs)
1668
- ]);
1669
- return escalated === "exited";
1628
+ return { owner: match[1], repo: match[2] };
1670
1629
  }
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;
1630
+ function getAuthenticatedLogin() {
1631
+ const { stdout, status } = run4("gh", ["api", "user", "--jq", ".login"]);
1632
+ if (status !== 0) return null;
1633
+ const login2 = stdout.trim();
1634
+ return login2.length > 0 ? login2 : null;
1635
+ }
1636
+ function listCandidateIssues(technique, value, limit) {
1637
+ const args = [
1638
+ "issue",
1639
+ "list",
1640
+ "--state",
1641
+ "open",
1642
+ "--limit",
1643
+ String(limit),
1644
+ "--json",
1645
+ "number,title,body,url"
1646
+ ];
1647
+ switch (technique) {
1648
+ case "label":
1649
+ args.push("--label", value);
1650
+ break;
1651
+ case "assignee":
1652
+ args.push("--assignee", value);
1653
+ break;
1654
+ case "title-contains":
1655
+ args.push("--search", `${value} in:title`);
1656
+ break;
1678
1657
  }
1679
- return name;
1658
+ return ghJson(args, "query GitHub issues");
1680
1659
  }
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;
1660
+ function getIssueSurface(issueNumber) {
1661
+ const raw = ghJson(
1662
+ [
1663
+ "issue",
1664
+ "view",
1665
+ String(issueNumber),
1666
+ "--json",
1667
+ "number,title,body,url,state,author,createdAt,assignees,labels,comments"
1668
+ ],
1669
+ `read issue #${String(issueNumber)}`
1670
+ );
1671
+ const messages = [
1672
+ {
1673
+ kind: "issue-body",
1674
+ author: login(raw.author),
1675
+ body: raw.body,
1676
+ createdAt: raw.createdAt
1677
+ },
1678
+ ...(raw.comments ?? []).map((comment) => ({
1679
+ kind: "issue-comment",
1680
+ author: login(comment.author),
1681
+ body: comment.body,
1682
+ createdAt: comment.createdAt
1683
+ }))
1684
+ ].sort(byCreatedAt);
1685
+ return {
1686
+ issue: { number: raw.number, title: raw.title, body: raw.body, url: raw.url },
1687
+ state: raw.state === "CLOSED" ? "CLOSED" : "OPEN",
1688
+ assignees: (raw.assignees ?? []).map(login).filter((name) => name.length > 0),
1689
+ labels: (raw.labels ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
1690
+ messages
1691
+ };
1689
1692
  }
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;
1693
+ var LINK_MAP_QUERY = `
1694
+ query($owner:String!,$repo:String!,$cursor:String){
1695
+ repository(owner:$owner,name:$repo){
1696
+ defaultBranchRef{ name }
1697
+ pullRequests(states:OPEN, first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}){
1698
+ pageInfo{ hasNextPage endCursor }
1699
+ nodes{
1700
+ number url title headRefName baseRefName isCrossRepository isDraft updatedAt
1701
+ labels(first:50){ nodes{ name } }
1702
+ assignees(first:50){ nodes{ login } }
1703
+ closingIssuesReferences(first:50){
1704
+ pageInfo{ hasNextPage }
1705
+ nodes{ number repository{ nameWithOwner } }
1706
+ }
1724
1707
  }
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);
1708
+ }
1736
1709
  }
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
- `);
1710
+ }`.trim();
1711
+ var MAX_LINK_MAP_PAGES = 50;
1712
+ function indexPullRequest(map, node, nameWithOwner) {
1713
+ const ref = toPullRequestRef(node);
1714
+ if (node.closingIssuesReferences.pageInfo?.hasNextPage) {
1715
+ throw new Error(
1716
+ `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.`
1717
+ );
1718
+ }
1719
+ let closedAnyHere = false;
1720
+ for (const issue of node.closingIssuesReferences.nodes) {
1721
+ if (issue.repository.nameWithOwner.toLowerCase() !== nameWithOwner.toLowerCase()) continue;
1722
+ closedAnyHere = true;
1723
+ const existing = map.get(issue.number);
1724
+ if (existing) {
1725
+ existing.push(ref);
1726
+ } else {
1727
+ map.set(issue.number, [ref]);
1779
1728
  }
1780
1729
  }
1730
+ return closedAnyHere;
1781
1731
  }
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;
1732
+ function toPullRequestRef(node) {
1733
+ return {
1734
+ number: node.number,
1735
+ url: node.url,
1736
+ title: node.title,
1737
+ headRefName: node.headRefName,
1738
+ baseRefName: node.baseRefName,
1739
+ isCrossRepository: node.isCrossRepository,
1740
+ state: "OPEN",
1741
+ isDraft: node.isDraft,
1742
+ updatedAt: node.updatedAt
1743
+ };
1795
1744
  }
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
- }
1745
+ function toOrphanPr(node) {
1746
+ return {
1747
+ pr: toPullRequestRef(node),
1748
+ labels: (node.labels?.nodes ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
1749
+ assignees: (node.assignees?.nodes ?? []).map((assignee) => assignee.login ?? "").filter((login2) => login2.length > 0)
1750
+ };
1813
1751
  }
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);
1752
+ function getOpenPrLinkMap() {
1753
+ const { owner, repo } = getRepoSlug();
1754
+ const map = /* @__PURE__ */ new Map();
1755
+ const orphans = [];
1756
+ let defaultBranch = null;
1757
+ let cursor = null;
1758
+ for (let page = 0; page < MAX_LINK_MAP_PAGES; page++) {
1759
+ const args = ["api", "graphql", "-f", `query=${LINK_MAP_QUERY}`, "-f", `owner=${owner}`, "-f", `repo=${repo}`];
1760
+ if (cursor !== null) args.push("-f", `cursor=${cursor}`);
1761
+ const response = ghJson(args, "query open pull requests");
1762
+ defaultBranch = response.data.repository.defaultBranchRef?.name ?? defaultBranch;
1763
+ const connection = response.data.repository.pullRequests;
1764
+ for (const node of connection.nodes) {
1765
+ if (!indexPullRequest(map, node, `${owner}/${repo}`)) {
1766
+ orphans.push(toOrphanPr(node));
1767
+ }
1768
+ }
1769
+ if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
1770
+ return { byIssue: map, defaultBranch, orphans };
1771
+ }
1772
+ cursor = connection.pageInfo.endCursor;
1820
1773
  }
1774
+ throw new Error(
1775
+ `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.`
1776
+ );
1821
1777
  }
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}`;
1778
+ var REVIEW_THREADS_QUERY2 = `
1779
+ query($owner:String!,$repo:String!,$prNumber:Int!,$cursor:String){
1780
+ repository(owner:$owner,name:$repo){
1781
+ pullRequest(number:$prNumber){
1782
+ reviewThreads(first:100, after:$cursor){
1783
+ pageInfo{ hasNextPage endCursor }
1784
+ nodes{
1785
+ isResolved isOutdated path line
1786
+ comments(last:100){
1787
+ pageInfo{ hasPreviousPage }
1788
+ nodes{ author{login} body createdAt url pullRequestReview{ submittedAt } }
1789
+ }
1790
+ }
1791
+ }
1792
+ }
1844
1793
  }
1794
+ }`.trim();
1795
+ var MAX_THREAD_PAGES = 50;
1796
+ function normalizePrState(state) {
1797
+ if (state === "MERGED") return "MERGED";
1798
+ if (state === "CLOSED") return "CLOSED";
1799
+ return "OPEN";
1845
1800
  }
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);
1851
- }
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;
1859
- }
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");
1801
+ function getReviewThreads(prNumber) {
1802
+ const { owner, repo } = getRepoSlug();
1803
+ const threads = [];
1804
+ let cursor = null;
1805
+ for (let page = 0; page < MAX_THREAD_PAGES; page++) {
1806
+ const args = [
1807
+ "api",
1808
+ "graphql",
1809
+ "-f",
1810
+ `query=${REVIEW_THREADS_QUERY2}`,
1811
+ "-f",
1812
+ `owner=${owner}`,
1813
+ "-f",
1814
+ `repo=${repo}`,
1815
+ "-F",
1816
+ `prNumber=${String(prNumber)}`
1817
+ ];
1818
+ if (cursor !== null) args.push("-f", `cursor=${cursor}`);
1819
+ const response = ghJson(
1820
+ args,
1821
+ `query review threads for pull request #${String(prNumber)}`
1822
+ );
1823
+ const connection = response.data.repository.pullRequest.reviewThreads;
1824
+ for (const node of connection.nodes) {
1825
+ if (node.comments.pageInfo?.hasPreviousPage) {
1826
+ throw new Error(
1827
+ `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.`
1828
+ );
1829
+ }
1830
+ threads.push({
1831
+ path: node.path,
1832
+ line: node.line ?? null,
1833
+ isResolved: node.isResolved,
1834
+ url: node.comments.nodes.at(-1)?.url ?? null,
1835
+ comments: node.comments.nodes.map((comment) => ({
1836
+ kind: "thread-comment",
1837
+ author: login(comment.author),
1838
+ body: comment.body,
1839
+ // When the comment became *visible*, not when it was drafted.
1840
+ // GitHub stamps `createdAt` the moment a comment is added to a
1841
+ // pending review, and it only becomes visible when the review is
1842
+ // submitted — minutes later for a human working through a diff.
1843
+ // Using `createdAt` let an agent answer posted in between look newer
1844
+ // than the review, which marked every one of its threads answered
1845
+ // and discarded the whole review silently.
1846
+ createdAt: visibleAt(comment.createdAt, comment.pullRequestReview?.submittedAt)
1847
+ })).sort(byCreatedAt)
1848
+ });
1849
+ }
1850
+ if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
1851
+ return threads;
1852
+ }
1853
+ cursor = connection.pageInfo.endCursor;
1863
1854
  }
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");
1855
+ throw new Error(
1856
+ `Stopped paginating review threads for pull request #${String(prNumber)} after ${String(MAX_THREAD_PAGES)} pages; refusing rather than answering only part of the feedback.`
1857
+ );
1872
1858
  }
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;
1891
- }
1892
- reject(
1893
- new Error(
1894
- signal === null ? `Codex exited with code ${String(code)}.` : `Codex terminated on ${signal}.`
1895
- )
1896
- );
1897
- });
1898
- });
1859
+ function getPrSurface(prNumber) {
1860
+ const raw = ghJson(
1861
+ [
1862
+ "pr",
1863
+ "view",
1864
+ String(prNumber),
1865
+ "--json",
1866
+ "number,title,url,headRefName,baseRefName,isCrossRepository,state,isDraft,body,author,createdAt,updatedAt,assignees,comments,reviews"
1867
+ ],
1868
+ `read pull request #${String(prNumber)}`
1869
+ );
1870
+ const messages = [
1871
+ ...(raw.comments ?? []).map((comment) => ({
1872
+ kind: "pr-comment",
1873
+ author: login(comment.author),
1874
+ body: comment.body,
1875
+ createdAt: comment.createdAt
1876
+ })),
1877
+ // A review with no body carries no message — only its inline comments do,
1878
+ // and those arrive through the review-thread query.
1879
+ ...(raw.reviews ?? []).filter((review) => review.body.trim().length > 0).map((review) => ({
1880
+ kind: "pr-review",
1881
+ author: login(review.author),
1882
+ body: review.body,
1883
+ createdAt: review.submittedAt ?? review.createdAt ?? ""
1884
+ }))
1885
+ ].sort(byCreatedAt);
1886
+ const threads = getReviewThreads(prNumber);
1887
+ const state = normalizePrState(raw.state);
1888
+ return {
1889
+ pr: {
1890
+ number: raw.number,
1891
+ url: raw.url,
1892
+ title: raw.title,
1893
+ headRefName: raw.headRefName,
1894
+ baseRefName: raw.baseRefName ?? "",
1895
+ isCrossRepository: raw.isCrossRepository ?? false,
1896
+ state,
1897
+ isDraft: raw.isDraft ?? false,
1898
+ updatedAt: raw.updatedAt ?? raw.createdAt
1899
+ },
1900
+ assignees: (raw.assignees ?? []).map(login).filter((name) => name.length > 0),
1901
+ messages,
1902
+ threads
1903
+ };
1899
1904
  }
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
- `);
1905
+ function assignIssueToAgent(issueNumber, agentUser) {
1906
+ const { stderr, status } = run4("gh", [
1907
+ "issue",
1908
+ "edit",
1909
+ String(issueNumber),
1910
+ "--add-assignee",
1911
+ agentUser
1912
+ ]);
1913
+ if (status !== 0) {
1914
+ throw new Error(stderr.trim() || `Failed to assign issue #${String(issueNumber)} to ${agentUser}.`);
1906
1915
  }
1907
1916
  }
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
- `);
1917
+ function assignPrToAgent(prNumber, agentUser) {
1918
+ const { stderr, status } = run4("gh", ["pr", "edit", String(prNumber), "--add-assignee", agentUser]);
1919
+ if (status !== 0) {
1920
+ throw new Error(
1921
+ stderr.trim() || `Failed to assign pull request #${String(prNumber)} to ${agentUser}.`
1922
+ );
1913
1923
  }
1914
- writeOverflowHint(output, issues, limit);
1915
1924
  }
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)
1925
+ function postMarker(_surface, number, body) {
1926
+ const { owner, repo } = getRepoSlug();
1927
+ const raw = ghJson(
1928
+ [
1929
+ "api",
1930
+ "--method",
1931
+ "POST",
1932
+ `repos/${owner}/${repo}/issues/${String(number)}/comments`,
1933
+ "-f",
1934
+ `body=${body}`
1935
+ ],
1936
+ `post a comment on #${String(number)}`
1922
1937
  );
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);
1938
+ return { commentId: String(raw.id), createdAt: raw.created_at };
1939
+ }
1940
+ function updateMarker(marker, body) {
1941
+ const { owner, repo } = getRepoSlug();
1942
+ const { stderr, status } = run4("gh", [
1943
+ "api",
1944
+ "--method",
1945
+ "PATCH",
1946
+ `repos/${owner}/${repo}/issues/comments/${marker.commentId}`,
1947
+ "-f",
1948
+ `body=${body}`
1949
+ ]);
1950
+ if (status !== 0) {
1951
+ throw new Error(stderr.trim() || `Failed to update comment ${marker.commentId}.`);
1929
1952
  }
1930
- return issues[n - 1];
1931
1953
  }
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);
1954
+ function deleteMarker(marker) {
1955
+ const { owner, repo } = getRepoSlug();
1956
+ const { stderr, status } = run4("gh", [
1957
+ "api",
1958
+ "--method",
1959
+ "DELETE",
1960
+ `repos/${owner}/${repo}/issues/comments/${marker.commentId}`
1961
+ ]);
1962
+ if (status !== 0) {
1963
+ if (/not found/i.test(stderr) || /HTTP 404/i.test(stderr)) return;
1964
+ throw new Error(stderr.trim() || `Failed to delete comment ${marker.commentId}.`);
1938
1965
  }
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);
1966
+ }
1967
+ function toHeadState(state) {
1968
+ return state === "OPEN" || state === "MERGED" ? state : "CLOSED";
1969
+ }
1970
+ function listPullRequestsForHead(branch) {
1971
+ const raw = ghJson(
1972
+ ["pr", "list", "--head", branch, "--state", "all", "--json", "number,state,url,updatedAt,author"],
1973
+ `list pull requests for branch ${branch}`
1974
+ );
1975
+ return raw.map((pr) => ({
1976
+ number: pr.number,
1977
+ url: pr.url,
1978
+ state: toHeadState(pr.state),
1979
+ updatedAt: pr.updatedAt,
1980
+ author: pr.author?.login ?? ""
1981
+ })).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1982
+ }
1983
+ var MISSING_LABEL_PATTERNS = [
1984
+ // gh's own message: `could not add label: 'rescue' not found`.
1985
+ /could not add label/i,
1986
+ // The GraphQL error it wraps, seen directly on some gh versions.
1987
+ /could not resolve to a label/i,
1988
+ /\blabels?\b[^\n]*\b(?:not found|does not exist)\b/i
1989
+ ];
1990
+ function isMissingLabelError(stderr) {
1991
+ return MISSING_LABEL_PATTERNS.some((pattern) => pattern.test(stderr));
1992
+ }
1993
+ function createDraftPullRequest(input) {
1994
+ const base = [
1995
+ "pr",
1996
+ "create",
1997
+ "--draft",
1998
+ "--head",
1999
+ input.head,
2000
+ "--base",
2001
+ input.base,
2002
+ "--title",
2003
+ input.title,
2004
+ "--body",
2005
+ input.body
2006
+ ];
2007
+ if (input.label !== void 0 && input.label.length > 0) {
2008
+ const labelled = run4("gh", [...base, "--label", input.label]);
2009
+ if (labelled.status === 0) return parseCreatedPrUrl(labelled.stdout, input.head);
2010
+ if (!isMissingLabelError(labelled.stderr)) {
2011
+ throw new Error(
2012
+ labelled.stderr.trim() || `Failed to open a draft pull request for ${input.head}.`
2013
+ );
2014
+ }
1944
2015
  }
1945
- if (!config.issueDiscoveryValue) {
1946
- process.stderr.write(
1947
- "Error: No issue discovery value configured. Run `automata config` to set one.\n"
1948
- );
1949
- process.exit(1);
2016
+ const { stdout, stderr, status } = run4("gh", base);
2017
+ if (status !== 0) {
2018
+ throw new Error(stderr.trim() || `Failed to open a draft pull request for ${input.head}.`);
1950
2019
  }
2020
+ return parseCreatedPrUrl(stdout, input.head);
1951
2021
  }
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);
2022
+ function parseCreatedPrUrl(stdout, head) {
2023
+ const url = stdout.trim().split("\n").pop()?.trim() ?? "";
2024
+ const match = /\/pull\/(\d+)\s*$/.exec(url);
2025
+ if (!match) {
2026
+ throw new Error(`Opened a pull request for ${head} but could not read its number from: ${url}`);
1957
2027
  }
1958
- if (issues.length > 1 && options.queryOnly) {
1959
- writeIssueList(selectionOutput, issues, limit);
1960
- process.exit(0);
2028
+ return { number: Number(match[1]), url };
2029
+ }
2030
+
2031
+ // src/claude/claudeService.ts
2032
+ import { spawn, spawnSync as spawnSync5 } from "child_process";
2033
+ import { createInterface } from "readline";
2034
+ import { existsSync } from "fs";
2035
+ import { delimiter, join } from "path";
2036
+
2037
+ // src/cli/spawnUtils.ts
2038
+ var SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
2039
+ var ESCAPED_QUOTE = String.raw`'\''`;
2040
+ function shellQuote(arg) {
2041
+ if (SHELL_SAFE.test(arg)) return arg;
2042
+ return "'" + arg.replaceAll("'", ESCAPED_QUOTE) + "'";
2043
+ }
2044
+ function truncate(str, max) {
2045
+ return str.length > max ? str.slice(0, max) + "..." : str;
2046
+ }
2047
+ function handleSpawnError(error, toolName) {
2048
+ if (!error) return;
2049
+ const err = error;
2050
+ if (err.code === "ENOENT") {
2051
+ process.stderr.write(`Error: \`${toolName}\` CLI is not installed or not on PATH.
2052
+ `);
2053
+ process.exit(1);
1961
2054
  }
1962
- let issue;
1963
- if (issues.length === 1) {
1964
- issue = issues[0];
1965
- selectionOutput.write(`Issue: #${issue.number}
1966
- Title: ${issue.title}
2055
+ process.stderr.write(`Error: ${err.message}
1967
2056
  `);
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;
2057
+ process.exit(1);
1980
2058
  }
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}
2059
+ function handleExitCode(status, toolName) {
2060
+ if (status === null) {
2061
+ process.stderr.write(`Error: ${toolName} terminated abnormally (exit code is null, likely due to a signal).
1995
2062
  `);
1996
2063
  process.exit(1);
1997
2064
  }
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}
2065
+ if (status !== 0) {
2066
+ process.stderr.write(`Error: ${toolName} exited with code ${status}.
2005
2067
  `);
2068
+ process.exit(status);
2006
2069
  }
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
- `);
2070
+ }
2071
+ function resolveEffortOption(value) {
2072
+ if (value === void 0) return void 0;
2073
+ const trimmed2 = value.trim();
2074
+ if (trimmed2.length === 0) {
2075
+ process.stderr.write("Error: --effort must be a non-empty level.\n");
2017
2076
  process.exit(1);
2018
2077
  }
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}
2078
+ return trimmed2;
2079
+ }
2025
2080
 
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);
2049
- }
2050
- return executor;
2081
+ // src/cli/childRegistry.ts
2082
+ var active = /* @__PURE__ */ new Set();
2083
+ function trackChild(child) {
2084
+ active.add(child);
2051
2085
  }
2052
- function warnOnFailure(what, action) {
2053
- try {
2054
- action();
2055
- } catch (err) {
2056
- process.stderr.write(`Warning: could not ${what}: ${err.message}
2057
- `);
2058
- }
2086
+ function untrackChild(child) {
2087
+ active.delete(child);
2059
2088
  }
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;
2068
- }
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
- });
2074
- }
2075
- warnOnFailure(`add Closes #${String(issueNumber)} to PR`, () => {
2076
- addClosesRefToPr(pr.number, issueNumber);
2089
+ function waitForExit(child) {
2090
+ return new Promise((resolve2) => {
2091
+ if (child.exitCode !== null || child.signalCode !== null) {
2092
+ resolve2();
2093
+ return;
2094
+ }
2095
+ child.once("exit", () => resolve2());
2077
2096
  });
2078
- if (askCopilotReview) {
2079
- warnOnFailure("request Copilot review", () => {
2080
- addCopilotReviewer(pr.number);
2081
- });
2082
- }
2083
2097
  }
2084
-
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);
2098
+ function afterDelay(ms) {
2099
+ return new Promise((resolve2) => {
2100
+ const timer = setTimeout(() => resolve2("timeout"), ms);
2101
+ timer.unref();
2094
2102
  });
2095
2103
  }
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");
2101
- process.exit(1);
2102
- }
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}
2112
- `);
2113
- process.exit(1);
2114
- }
2104
+ async function terminateTrackedChildren(timeoutMs = 1e4, killGraceMs = 5e3) {
2105
+ const children = [...active];
2106
+ if (children.length === 0) return true;
2107
+ const exits = children.map((child) => waitForExit(child));
2108
+ for (const child of children) {
2109
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
2115
2110
  }
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;
2111
+ const settled = await Promise.race([Promise.all(exits).then(() => "exited"), afterDelay(timeoutMs)]);
2112
+ if (settled === "exited") return true;
2113
+ for (const child of children) {
2114
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
2123
2115
  }
2124
- process.stderr.write("Error: No prompt provided. Use --prompt, --file-prompt, or pipe via stdin.\n");
2125
- process.exit(1);
2116
+ const escalated = await Promise.race([
2117
+ Promise.all(exits).then(() => "exited"),
2118
+ afterDelay(killGraceMs)
2119
+ ]);
2120
+ return escalated === "exited";
2126
2121
  }
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}'.
2131
- `);
2132
- process.exit(1);
2122
+
2123
+ // src/claude/claudeService.ts
2124
+ function resolveCommand(name) {
2125
+ const pathDirs = (process.env["PATH"] ?? "").split(delimiter);
2126
+ for (const dir of pathDirs) {
2127
+ const candidate = join(dir, name);
2128
+ if (existsSync(candidate)) return candidate;
2133
2129
  }
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, {
2130
+ return name;
2131
+ }
2132
+ function buildClaudeArgs(prompt, options = {}) {
2133
+ const args = [];
2134
+ if (options.yolo) args.push("--dangerously-skip-permissions");
2135
+ if (options.model) args.push("--model", options.model);
2136
+ if (options.effort) args.push("--effort", options.effort);
2137
+ if (options.verbose) args.push("--verbose", "--output-format", "stream-json");
2138
+ args.push("-p", prompt);
2139
+ return args;
2140
+ }
2141
+ function runClaude(prompt, options = {}) {
2142
+ return new Promise((resolve2, reject) => {
2143
+ const claudeBin = resolveCommand("claude");
2144
+ const args = buildClaudeArgs(prompt, {
2140
2145
  yolo: true,
2141
- verbose: !options.silent,
2142
2146
  model: options.model,
2143
- effort
2147
+ effort: options.effort,
2148
+ verbose: true
2144
2149
  });
2150
+ const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
2151
+ trackChild(child);
2152
+ const rl = createInterface({ input: child.stdout });
2153
+ let turnCount = 0;
2154
+ rl.on("line", (line) => {
2155
+ if (options.printSteps !== true) return;
2156
+ try {
2157
+ const event = JSON.parse(line);
2158
+ formatEvent(event, turnCount);
2159
+ if (event["type"] === "assistant") turnCount++;
2160
+ } catch {
2161
+ }
2162
+ });
2163
+ child.on("error", (err) => {
2164
+ untrackChild(child);
2165
+ const nodeErr = err;
2166
+ reject(
2167
+ nodeErr.code === "ENOENT" ? new Error("`claude` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
2168
+ );
2169
+ });
2170
+ child.on("close", (code, signal) => {
2171
+ untrackChild(child);
2172
+ if (code === 0) {
2173
+ resolve2();
2174
+ return;
2175
+ }
2176
+ reject(
2177
+ new Error(
2178
+ signal === null ? `Claude Code exited with code ${String(code)}.` : `Claude Code terminated on ${signal}.`
2179
+ )
2180
+ );
2181
+ });
2182
+ });
2183
+ }
2184
+ function invokeClaudeCode(prompt, options = {}) {
2185
+ if (options.verbose) {
2186
+ return invokeClaudeCodeVerbose(prompt, options.yolo ?? false, options.model, options.effort);
2145
2187
  }
2146
- });
2147
-
2148
- // src/commands/executePrompt.ts
2149
- import { Command as Command5 } from "commander";
2150
-
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);
2161
- }
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";
2166
- }
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;
2175
- }
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
- };
2188
+ invokeClaudeCodeSync(prompt, options.yolo ?? false, options.model, options.effort);
2192
2189
  }
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);
2190
+ function invokeClaudeCodeSync(prompt, yolo, model, effort) {
2191
+ const claudeBin = resolveCommand("claude");
2192
+ const args = buildClaudeArgs(prompt, { yolo, model, effort, verbose: false });
2193
+ const result = spawnSync5(claudeBin, args, { encoding: "utf8", stdio: "inherit" });
2194
+ handleSpawnError(result.error, "claude");
2195
+ handleExitCode(result.status, "Claude Code");
2197
2196
  }
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");
2197
+ function invokeClaudeCodeVerbose(prompt, yolo, model, effort) {
2198
+ return new Promise((resolve2) => {
2199
+ const claudeBin = resolveCommand("claude");
2200
+ const args = buildClaudeArgs(prompt, { yolo, model, effort, verbose: true });
2201
+ const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
2202
+ const rl = createInterface({ input: child.stdout });
2203
+ let turnCount = 0;
2204
+ child.on("error", (err) => {
2205
+ handleSpawnError(err, "claude");
2206
+ });
2207
+ rl.on("line", (line) => {
2208
+ try {
2209
+ const event = JSON.parse(line);
2210
+ formatEvent(event, turnCount);
2211
+ if (event["type"] === "assistant") turnCount++;
2212
+ } catch {
2213
+ }
2214
+ });
2215
+ child.on("close", (code) => {
2216
+ handleExitCode(code, "Claude Code");
2217
+ resolve2();
2218
+ });
2219
+ });
2204
2220
  }
2205
-
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
- };
2221
+ function formatAssistantEvent(event, turnCount) {
2222
+ const message = event["message"];
2223
+ const content = message?.["content"];
2224
+ if (!content) return;
2225
+ for (const block of content) {
2226
+ const line = formatContentBlock(block);
2227
+ if (line !== null) {
2228
+ process.stderr.write(` [step ${turnCount + 1}] ${line}
2229
+ `);
2230
+ }
2231
+ }
2230
2232
  }
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
- };
2233
+ function formatContentBlock(block) {
2234
+ if (block["type"] === "tool_use") {
2235
+ const toolName = block["name"];
2236
+ const input = block["input"];
2237
+ return summarizeTool(toolName, input);
2238
+ }
2239
+ if (block["type"] === "text") {
2240
+ const text = block["text"] ?? "";
2241
+ if (text.length === 0) return null;
2242
+ const preview = text.length > 120 ? text.slice(0, 120) + "..." : text;
2243
+ return preview.split("\n")[0] ?? null;
2244
+ }
2245
+ return null;
2239
2246
  }
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
- );
2247
+ function formatResultEvent(event) {
2248
+ const result = event["result"];
2249
+ const cost = event["cost_usd"];
2250
+ const duration = event["duration_ms"];
2251
+ const turns = event["num_turns"];
2252
+ process.stderr.write("\n--- Result ---\n");
2253
+ const parts = [];
2254
+ if (turns !== void 0) parts.push(`${turns} turns`);
2255
+ if (duration !== void 0) parts.push(`${(duration / 1e3).toFixed(1)}s`);
2256
+ if (cost !== void 0) parts.push(`$${cost.toFixed(4)}`);
2257
+ if (parts.length > 0) {
2258
+ process.stderr.write(` [info] ${parts.join(" | ")}
2259
+ `);
2260
+ }
2261
+ if (result) {
2262
+ process.stdout.write(result + "\n");
2263
+ }
2250
2264
  }
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;
2265
+ function formatEvent(event, turnCount) {
2266
+ const type = event["type"];
2267
+ if (type === "assistant") {
2268
+ formatAssistantEvent(event, turnCount);
2269
+ } else if (type === "result") {
2270
+ formatResultEvent(event);
2271
+ }
2258
2272
  }
2259
- function formatPrInfoContext(pr) {
2260
- return JSON.stringify(pr, null, 2);
2273
+ function asText(value, fallback) {
2274
+ return typeof value === "string" ? value : fallback;
2261
2275
  }
2262
- function pluralSuffix(count) {
2263
- return count === 1 ? "" : "s";
2276
+ function summarizeTool(name, input) {
2277
+ if (!input) return `tool: ${name}`;
2278
+ switch (name) {
2279
+ case "Read":
2280
+ return `reading ${asText(input["file_path"], "file")}`;
2281
+ case "Write":
2282
+ return `writing ${asText(input["file_path"], "file")}`;
2283
+ case "Edit":
2284
+ return `editing ${asText(input["file_path"], "file")}`;
2285
+ case "Bash":
2286
+ return `running: ${truncate(asText(input["command"], ""), 80)}`;
2287
+ case "Glob":
2288
+ return `searching files: ${asText(input["pattern"], "")}`;
2289
+ case "Grep":
2290
+ return `searching content: ${truncate(asText(input["pattern"], ""), 60)}`;
2291
+ case "Agent":
2292
+ return `spawning agent: ${asText(input["description"], name)}`;
2293
+ default:
2294
+ return `tool: ${name}`;
2295
+ }
2264
2296
  }
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");
2297
+
2298
+ // src/codex/codexService.ts
2299
+ import { spawn as spawn2, spawnSync as spawnSync6 } from "child_process";
2300
+ function toTomlBasicString(value) {
2301
+ return JSON.stringify(value);
2267
2302
  }
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}'.
2272
- `);
2273
- process.exit(1);
2274
- }
2275
- return executor;
2303
+ function buildCodexArgs(prompt, options = {}) {
2304
+ const args = ["exec"];
2305
+ if (options.yolo) args.push("--dangerously-bypass-approvals-and-sandbox");
2306
+ if (options.model) args.push("--model", options.model);
2307
+ if (options.effort) args.push("-c", `model_reasoning_effort=${toTomlBasicString(options.effort)}`);
2308
+ args.push(prompt);
2309
+ return args;
2276
2310
  }
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;
2311
+ function invokeCodexCode(prompt, options = {}) {
2312
+ if (options.verbose) {
2313
+ process.stderr.write("Warning: --verbose is not supported for Codex and will be ignored.\n");
2282
2314
  }
2283
- return invokeClaudeCode(prompt, {
2284
- yolo: true,
2285
- verbose: !options.silent,
2286
- model: options.model,
2287
- effort
2288
- });
2315
+ invokeCodexCodeSync(prompt, options.yolo ?? false, options.model, options.effort);
2289
2316
  }
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}
2301
- `);
2302
- process.exit(1);
2303
- }
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);
2311
- }
2312
- if (pr === null) {
2313
- process.stderr.write(`Error: No pull request found for branch: ${branch}
2314
- `);
2315
- process.exit(1);
2316
- }
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);
2323
- }
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}
2330
-
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");
2317
+ function invokeCodexCodeSync(prompt, yolo, model, effort) {
2318
+ const codexBin = resolveCommand("codex");
2319
+ const args = buildCodexArgs(prompt, { yolo, model, effort });
2320
+ const result = spawnSync6(codexBin, args, { encoding: "utf8", stdio: "inherit" });
2321
+ handleSpawnError(result.error, "codex");
2322
+ handleExitCode(result.status, "Codex");
2343
2323
  }
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
- `
2324
+ function runCodex(prompt, options = {}) {
2325
+ return new Promise((resolve2, reject) => {
2326
+ const codexBin = resolveCommand("codex");
2327
+ const args = buildCodexArgs(prompt, { yolo: true, model: options.model, effort: options.effort });
2328
+ const child = spawn2(codexBin, args, { stdio: "inherit" });
2329
+ trackChild(child);
2330
+ child.on("error", (err) => {
2331
+ untrackChild(child);
2332
+ const nodeErr = err;
2333
+ reject(
2334
+ nodeErr.code === "ENOENT" ? new Error("`codex` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
2361
2335
  );
2362
- process.exit(1);
2363
- }
2364
- process.stderr.write(`Error: No pull request found for branch: ${result.branch}
2336
+ });
2337
+ child.on("close", (code, signal) => {
2338
+ untrackChild(child);
2339
+ if (code === 0) {
2340
+ resolve2();
2341
+ return;
2342
+ }
2343
+ reject(
2344
+ new Error(
2345
+ signal === null ? `Codex exited with code ${String(code)}.` : `Codex terminated on ${signal}.`
2346
+ )
2347
+ );
2348
+ });
2349
+ });
2350
+ }
2351
+
2352
+ // src/commands/getReady.ts
2353
+ function writeOverflowHint(output, issues, limit) {
2354
+ if (issues.length === limit) {
2355
+ output.write(`(Showing first ${limit} matching issues \u2014 there may be more. Use --limit to fetch more.)
2365
2356
  `);
2366
- process.exit(1);
2367
2357
  }
2368
- const { comments } = result;
2369
- if (comments.length === 0) {
2370
- process.stderr.write(`Error: No open review comments found on the pull request.
2358
+ }
2359
+ function writeIssueList(output, issues, limit) {
2360
+ output.write("\nAvailable issues:\n");
2361
+ for (let i = 0; i < issues.length; i++) {
2362
+ output.write(` [${i + 1}] #${issues[i].number} - ${issues[i].title}
2371
2363
  `);
2372
- process.exit(1);
2373
2364
  }
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
2365
+ writeOverflowHint(output, issues, limit);
2366
+ }
2367
+ async function promptSelection(issues, limit, output) {
2368
+ writeIssueList(output, issues, limit);
2369
+ const rl = createInterface2({ input: process.stdin, output });
2370
+ const answer = await new Promise(
2371
+ (resolve2) => rl.question(`
2372
+ Select issue (1-${issues.length}): `, resolve2)
2385
2373
  );
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}').
2374
+ rl.close();
2375
+ const n = Number.parseInt(answer.trim(), 10);
2376
+ if (Number.isNaN(n) || n < 1 || n > issues.length) {
2377
+ process.stderr.write(`Error: Invalid selection "${answer.trim()}". Enter a number between 1 and ${issues.length}.
2397
2378
  `);
2398
2379
  process.exit(1);
2399
2380
  }
2400
- const config = readConfig();
2401
- if (config.remoteType === "azdo") {
2381
+ return issues[n - 1];
2382
+ }
2383
+ function validateConfig(config) {
2384
+ if (config.remoteType !== "gh") {
2402
2385
  process.stderr.write(
2403
- "Error: check-issue is not supported for Azure DevOps. See docs/azdo-gap.md for details.\n"
2386
+ "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
2387
  );
2405
2388
  process.exit(1);
2406
2389
  }
2407
- const allowedUsers = (config.allowedUsers ?? []).filter((user) => user.trim().length > 0);
2408
- if (allowedUsers.length === 0) {
2390
+ if (!config.issueDiscoveryTechnique) {
2409
2391
  process.stderr.write(
2410
- "Error: No allowed users configured. Run `automata config` or `automata config set allowed-users <user1,user2>` to set them.\n"
2392
+ "Error: No issue discovery technique configured. Run `automata config` to set one.\n"
2411
2393
  );
2412
2394
  process.exit(1);
2413
2395
  }
2414
- const agentUser = (config.agentUser ?? "").trim();
2415
- if (agentUser.length === 0) {
2396
+ if (!config.issueDiscoveryValue) {
2416
2397
  process.stderr.write(
2417
- "Error: No agent user configured. Run `automata config` or `automata config set agent-user <login>` to set it.\n"
2398
+ "Error: No issue discovery value configured. Run `automata config` to set one.\n"
2418
2399
  );
2419
2400
  process.exit(1);
2420
2401
  }
2421
- let conversation;
2402
+ }
2403
+ async function resolveIssue(issues, options, limit) {
2404
+ const selectionOutput = options.json ? process.stderr : process.stdout;
2405
+ if (issues.length === 0) {
2406
+ process.stdout.write("No issues found matching the configured filter.\n");
2407
+ process.exit(0);
2408
+ }
2409
+ if (issues.length > 1 && options.queryOnly) {
2410
+ writeIssueList(selectionOutput, issues, limit);
2411
+ process.exit(0);
2412
+ }
2413
+ let issue;
2414
+ if (issues.length === 1) {
2415
+ issue = issues[0];
2416
+ selectionOutput.write(`Issue: #${issue.number}
2417
+ Title: ${issue.title}
2418
+ `);
2419
+ } else if (options.takeFirst) {
2420
+ issue = issues[0];
2421
+ selectionOutput.write(`Selecting issue #${issue.number}: ${issue.title}
2422
+ `);
2423
+ } else {
2424
+ issue = await promptSelection(issues, limit, selectionOutput);
2425
+ selectionOutput.write(`
2426
+ Issue: #${issue.number}
2427
+ Title: ${issue.title}
2428
+ `);
2429
+ }
2430
+ return issue;
2431
+ }
2432
+ 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) => {
2433
+ const config = readConfig();
2434
+ validateConfig(config);
2435
+ const limit = Number.parseInt(options.limit, 10);
2436
+ if (Number.isNaN(limit) || limit <= 0) {
2437
+ process.stderr.write(`Error: --limit must be a positive integer (got "${options.limit}").
2438
+ `);
2439
+ process.exit(1);
2440
+ }
2441
+ let issues;
2422
2442
  try {
2423
- conversation = getIssueConversation(issueNumber);
2443
+ issues = listIssues(config.issueDiscoveryTechnique, config.issueDiscoveryValue, limit);
2424
2444
  } catch (err) {
2425
2445
  process.stderr.write(`Error: ${err.message}
2426
2446
  `);
2427
2447
  process.exit(1);
2428
2448
  }
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;
2437
- }
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
- );
2449
+ const issue = await resolveIssue(issues, options, limit);
2450
+ if (options.json) {
2451
+ process.stdout.write(JSON.stringify({ number: issue.number, title: issue.title, body: issue.body, url: issue.url }, null, 2) + "\n");
2443
2452
  } else {
2444
- process.stdout.write(`No new messages on issue #${String(issueNumber)} \u2014 forced run. Invoking AI\u2026
2453
+ process.stdout.write(`URL: ${issue.url}
2454
+
2455
+ ${issue.body}
2445
2456
  `);
2446
2457
  }
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.";
2458
+ if (options.queryOnly) {
2459
+ process.exit(0);
2460
+ }
2461
+ const executor = options.claude === false ? void 0 : resolveExecutor(options.with);
2462
+ let commentUrl;
2460
2463
  try {
2461
- postComment(issueNumber, marker);
2464
+ commentUrl = postComment(issue.number, "working");
2462
2465
  } catch (err) {
2463
- process.stderr.write(
2464
- `Error: could not post the execution marker comment on issue #${String(issueNumber)}: ${err.message}
2465
- `
2466
- );
2466
+ process.stderr.write(`Error: ${err.message}
2467
+ `);
2467
2468
  process.exit(1);
2468
2469
  }
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";
2470
+ const effort = resolveEffortOption(options.effort);
2471
+ if (options.claude !== false) {
2472
+ const systemPrompt = config.claudeSystemPrompt ?? DEFAULT_CLAUDE_SYSTEM_PROMPT;
2473
+ const prompt = `Resolving issue #${issue.number}:
2475
2474
 
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.`);
2475
+ ${systemPrompt}
2476
+
2477
+ ${issue.body}`;
2478
+ if (executor === "codex") {
2479
+ if (options.silent) {
2480
+ process.stderr.write("Warning: --silent is only supported with Claude and has no effect when used with Codex.\n");
2481
+ }
2482
+ invokeCodexCode(prompt, { yolo: options.yolo, model: options.model, effort });
2483
+ } else {
2484
+ await invokeClaudeCode(prompt, {
2485
+ yolo: options.yolo,
2486
+ verbose: !options.silent,
2487
+ model: options.model,
2488
+ effort
2489
+ });
2484
2490
  }
2485
- throw new Error(err.message);
2486
2491
  }
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?`);
2492
+ linkPrToIssue(issue.number, commentUrl, options.askCopilotReview === true, config.agentUser);
2493
+ });
2494
+ function resolveExecutor(requested) {
2495
+ const executor = requested.toLowerCase();
2496
+ if (executor !== "claude" && executor !== "codex") {
2497
+ process.stderr.write(`Error: --with must be 'claude' or 'codex', got '${requested}'.
2498
+ `);
2499
+ process.exit(1);
2493
2500
  }
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;
2501
+ return executor;
2505
2502
  }
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}`);
2503
+ function warnOnFailure(what, action) {
2504
+ try {
2505
+ action();
2506
+ } catch (err) {
2507
+ process.stderr.write(`Warning: could not ${what}: ${err.message}
2508
+ `);
2515
2509
  }
2516
- return { owner: match[1], repo: match[2] };
2517
2510
  }
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;
2523
- }
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;
2511
+ function linkPrToIssue(issueNumber, commentUrl, askCopilotReview, agentUser) {
2512
+ let pr;
2513
+ try {
2514
+ pr = getCurrentBranchPr();
2515
+ } catch (err) {
2516
+ process.stderr.write(`Warning: could not detect current branch PR: ${err.message}
2517
+ `);
2518
+ return;
2545
2519
  }
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
- }
2520
+ if (!pr) return;
2521
+ if (commentUrl) {
2522
+ warnOnFailure("update issue comment", () => {
2523
+ editComment(commentUrl, `Working on this in PR #${pr.number} \u2014 ${pr.url}`);
2524
+ });
2597
2525
  }
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
- );
2526
+ warnOnFailure(`add Closes #${String(issueNumber)} to PR`, () => {
2527
+ addClosesRefToPr(pr.number, issueNumber);
2528
+ });
2529
+ if (pr.assignees.length === 0) {
2530
+ const claimant = (agentUser ?? "").trim() || "@me";
2531
+ warnOnFailure(`assign PR #${String(pr.number)} to ${claimant}`, () => {
2532
+ assignPrToAgent(pr.number, claimant);
2533
+ });
2606
2534
  }
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);
2614
- } else {
2615
- map.set(issue.number, [ref]);
2616
- }
2535
+ if (askCopilotReview) {
2536
+ warnOnFailure("request Copilot review", () => {
2537
+ addCopilotReviewer(pr.number);
2538
+ });
2617
2539
  }
2618
- return closedAnyHere;
2619
- }
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
- };
2632
2540
  }
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
- };
2639
- }
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 };
2659
- }
2660
- cursor = connection.pageInfo.endCursor;
2661
- }
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
- );
2541
+
2542
+ // src/commands/execute.ts
2543
+ import { readFileSync as readFileSync2 } from "fs";
2544
+ import { Command as Command4 } from "commander";
2545
+ function readStdin() {
2546
+ return new Promise((resolve2, reject) => {
2547
+ const chunks = [];
2548
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
2549
+ process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
2550
+ process.stdin.on("error", reject);
2551
+ });
2665
2552
  }
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
- }
2680
- }
2553
+ async function resolvePrompt(options) {
2554
+ const hasCli = options.prompt !== void 0;
2555
+ const hasFile = options.filePrompt !== void 0;
2556
+ if (hasCli && hasFile) {
2557
+ process.stderr.write("Error: --prompt is mutually exclusive with --file-prompt.\n");
2558
+ process.exit(1);
2681
2559
  }
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";
2688
- }
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
- });
2560
+ if (hasCli) {
2561
+ return options.prompt;
2562
+ }
2563
+ if (hasFile) {
2564
+ const path = options.filePrompt;
2565
+ try {
2566
+ return readFileSync2(path, "utf8");
2567
+ } catch {
2568
+ process.stderr.write(`Error: Cannot read file: ${path}
2569
+ `);
2570
+ process.exit(1);
2737
2571
  }
2738
- if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
2739
- return threads;
2572
+ }
2573
+ if (!process.stdin.isTTY) {
2574
+ const content = await readStdin();
2575
+ if (content.trim().length === 0) {
2576
+ process.stderr.write("Error: No prompt provided. Use --prompt, --file-prompt, or pipe via stdin.\n");
2577
+ process.exit(1);
2740
2578
  }
2741
- cursor = connection.pageInfo.endCursor;
2579
+ return content;
2742
2580
  }
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
- );
2581
+ process.stderr.write("Error: No prompt provided. Use --prompt, --file-prompt, or pipe via stdin.\n");
2582
+ process.exit(1);
2746
2583
  }
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
- );
2584
+ 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) => {
2585
+ const executor = options.with.toLowerCase();
2586
+ if (executor !== "claude" && executor !== "codex") {
2587
+ process.stderr.write(`Error: --with must be 'claude' or 'codex', got '${options.with}'.
2588
+ `);
2589
+ process.exit(1);
2590
+ }
2591
+ const prompt = await resolvePrompt(options);
2592
+ const effort = resolveEffortOption(options.effort);
2593
+ if (executor === "codex") {
2594
+ invokeCodexCode(prompt, { yolo: true, model: options.model, effort });
2595
+ } else {
2596
+ await invokeClaudeCode(prompt, {
2597
+ yolo: true,
2598
+ verbose: !options.silent,
2599
+ model: options.model,
2600
+ effort
2601
+ });
2602
+ }
2603
+ });
2604
+
2605
+ // src/commands/executePrompt.ts
2606
+ import { Command as Command5 } from "commander";
2607
+
2608
+ // src/github/conversation.ts
2609
+ var KIND_LABELS = {
2610
+ "issue-body": "issue description",
2611
+ "issue-comment": "comment",
2612
+ "pr-comment": "pull request comment",
2613
+ "pr-review": "pull request review",
2614
+ "thread-comment": "review thread comment"
2615
+ };
2616
+ function byCreatedAt2(a, b) {
2617
+ return a.createdAt.localeCompare(b.createdAt);
2618
+ }
2619
+ function classify(author, p) {
2620
+ const login2 = author.toLowerCase();
2621
+ if (login2 === p.agentUser.toLowerCase()) return "agent";
2622
+ return p.allowedUsers.some((user) => user.toLowerCase() === login2) ? "authorized" : "other";
2623
+ }
2624
+ function analyzeSurface(messages, p) {
2625
+ const ordered = [...messages].sort(byCreatedAt2);
2626
+ let lastAgentAt = null;
2627
+ for (const message of ordered) {
2628
+ if (message.kind === "issue-body") continue;
2629
+ if (classify(message.author, p) !== "agent") continue;
2630
+ if (lastAgentAt === null || message.createdAt > lastAgentAt) {
2631
+ lastAgentAt = message.createdAt;
2632
+ }
2633
+ }
2634
+ const kept = [];
2635
+ for (const message of ordered) {
2636
+ const authorClass = classify(message.author, p);
2637
+ if (authorClass === "other") continue;
2638
+ const isNew = authorClass === "authorized" && (lastAgentAt === null || message.createdAt > lastAgentAt);
2639
+ kept.push({ ...message, isNew });
2640
+ }
2641
+ const newMessages = kept.filter((message) => message.isNew);
2642
+ return {
2643
+ messages: kept,
2644
+ newMessages,
2645
+ newMessageCount: newMessages.length,
2646
+ hasNewMessage: newMessages.length > 0,
2647
+ lastAgentAt
2648
+ };
2649
+ }
2650
+ function lastAuthorClass(messages, p) {
2651
+ if (messages.length === 0) return "none";
2652
+ const newest2 = [...messages].sort(byCreatedAt2).at(-1);
2653
+ return newest2 === void 0 ? "none" : classify(newest2.author, p);
2654
+ }
2655
+ function formatMessages(messages) {
2656
+ return messages.map((message) => {
2657
+ const marker = message.isNew ? " \xB7 NEW since last agent run" : "";
2658
+ return `[${message.author}] ${KIND_LABELS[message.kind]} \xB7 ${message.createdAt}${marker}
2659
+ ${message.body}`;
2660
+ }).join("\n\n");
2661
+ }
2662
+
2663
+ // src/github/issueConversation.ts
2664
+ function analyzeConversation(conversation, allowedUsers, agentUser) {
2665
+ const participants = { allowedUsers, agentUser };
2758
2666
  const messages = [
2759
- ...(raw.comments ?? []).map((comment) => ({
2760
- kind: "pr-comment",
2761
- author: login(comment.author),
2667
+ {
2668
+ kind: "issue-body",
2669
+ author: conversation.author,
2670
+ body: conversation.body,
2671
+ createdAt: conversation.createdAt
2672
+ },
2673
+ ...conversation.comments.map((comment) => ({
2674
+ kind: "issue-comment",
2675
+ author: comment.author,
2762
2676
  body: comment.body,
2763
2677
  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
2678
  }))
2773
- ].sort(byCreatedAt2);
2774
- const threads = getReviewThreads(prNumber);
2775
- const state = normalizePrState(raw.state);
2679
+ ];
2680
+ const analysis = analyzeSurface(messages, participants);
2776
2681
  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
2682
+ messages: analysis.messages.map(toConversationMessage),
2683
+ newMessageCount: analysis.newMessageCount,
2684
+ hasNewMessage: analysis.hasNewMessage,
2685
+ lastAgentAt: analysis.lastAgentAt
2790
2686
  };
2791
2687
  }
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
- }
2688
+ function toConversationMessage(message) {
2689
+ return {
2690
+ kind: message.kind === "issue-body" ? "issue" : "comment",
2691
+ author: message.author,
2692
+ body: message.body,
2693
+ createdAt: message.createdAt,
2694
+ isNew: message.isNew
2695
+ };
2803
2696
  }
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)}`
2697
+ function formatConversation(messages) {
2698
+ return formatMessages(
2699
+ messages.map((message) => ({
2700
+ kind: message.kind === "issue" ? "issue-body" : "issue-comment",
2701
+ author: message.author,
2702
+ body: message.body,
2703
+ createdAt: message.createdAt,
2704
+ isNew: message.isNew
2705
+ }))
2816
2706
  );
2817
- return { commentId: String(raw.id), createdAt: raw.created_at };
2818
2707
  }
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}.`);
2708
+
2709
+ // src/commands/executePrompt.ts
2710
+ 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.";
2711
+ function withPush(prompt, push) {
2712
+ return push ? `${prompt}
2713
+
2714
+ ${PUSH_INSTRUCTION}` : prompt;
2715
+ }
2716
+ function formatPrInfoContext(pr) {
2717
+ return JSON.stringify(pr, null, 2);
2718
+ }
2719
+ function pluralSuffix(count) {
2720
+ return count === 1 ? "" : "s";
2721
+ }
2722
+ function addAiOptions(cmd) {
2723
+ 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");
2724
+ }
2725
+ function resolveExecutor2(withOption) {
2726
+ const executor = withOption.toLowerCase();
2727
+ if (executor !== "claude" && executor !== "codex") {
2728
+ process.stderr.write(`Error: --with must be 'claude' or 'codex', got '${withOption}'.
2729
+ `);
2730
+ process.exit(1);
2731
+ }
2732
+ return executor;
2733
+ }
2734
+ function invokeSelectedExecutor(prompt, executor, options) {
2735
+ const effort = resolveEffortOption(options.effort);
2736
+ if (executor === "codex") {
2737
+ invokeCodexCode(prompt, { yolo: true, model: options.model, effort });
2738
+ return;
2739
+ }
2740
+ return invokeClaudeCode(prompt, {
2741
+ yolo: true,
2742
+ verbose: !options.silent,
2743
+ model: options.model,
2744
+ effort
2745
+ });
2746
+ }
2747
+ var executeSonarCmd = addAiOptions(
2748
+ new Command5("sonar").description(
2749
+ "Check the current branch for a SonarCloud analysis and invoke the AI with the Sonar prompt and analysis URL"
2750
+ )
2751
+ ).action(async (options) => {
2752
+ const executor = resolveExecutor2(options.with);
2753
+ let branch;
2754
+ try {
2755
+ branch = getCurrentBranch();
2756
+ } catch (err) {
2757
+ process.stderr.write(`Error: ${err.message}
2758
+ `);
2759
+ process.exit(1);
2831
2760
  }
2832
- }
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}.`);
2761
+ let pr;
2762
+ try {
2763
+ pr = await getPrInfo2(branch);
2764
+ } catch (err) {
2765
+ process.stderr.write(`Error: ${err.message}
2766
+ `);
2767
+ process.exit(1);
2844
2768
  }
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}`
2769
+ if (pr === null) {
2770
+ process.stderr.write(`Error: No pull request found for branch: ${branch}
2771
+ `);
2772
+ process.exit(1);
2773
+ }
2774
+ if (!pr.sonarcloudUrl) {
2775
+ process.stderr.write(
2776
+ `Error: No SonarCloud analysis found for PR #${pr.number}. Ensure a SonarCloud check is configured on this repository.
2777
+ `
2778
+ );
2779
+ process.exit(1);
2780
+ }
2781
+ const config = readConfig();
2782
+ const sonarPromptText = config.prompts?.sonar ?? DEFAULT_SONAR_PROMPT;
2783
+ const fullPrompt = withPush(
2784
+ `${sonarPromptText}
2785
+
2786
+ SonarCloud analysis URL: ${pr.sonarcloudUrl}
2787
+
2788
+ Current PR context from automata git get-pr-info --json:
2789
+ ${formatPrInfoContext(pr)}`,
2790
+ options.push
2853
2791
  );
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));
2792
+ await invokeSelectedExecutor(fullPrompt, executor, options);
2793
+ });
2794
+ function formatComments(comments) {
2795
+ return comments.map((c) => {
2796
+ const loc = c.line === null ? `${c.path}:(file)` : `${c.path}:${String(c.line)}`;
2797
+ return `[${c.author}] on ${loc}
2798
+ ${c.body}`;
2799
+ }).join("\n\n");
2871
2800
  }
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}.`
2801
+ var executeFixCommentsCmd = addAiOptions(
2802
+ new Command5("fix-comments").description(
2803
+ "Fetch open review comments on the current PR and invoke the AI with the Fix-Comments prompt"
2804
+ )
2805
+ ).action(async (options) => {
2806
+ const executor = resolveExecutor2(options.with);
2807
+ const result = resolveCurrentBranchComments();
2808
+ if (!result.ok) {
2809
+ if (result.kind === "error") {
2810
+ process.stderr.write(`Error: ${result.message}
2811
+ `);
2812
+ process.exit(1);
2813
+ }
2814
+ if (result.kind === "unsupported") {
2815
+ process.stderr.write(
2816
+ `Error: fix-comments is not supported for Azure DevOps. See docs/azdo-gap.md for details.
2817
+ `
2892
2818
  );
2819
+ process.exit(1);
2893
2820
  }
2821
+ process.stderr.write(`Error: No pull request found for branch: ${result.branch}
2822
+ `);
2823
+ process.exit(1);
2894
2824
  }
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}.`);
2825
+ const { comments } = result;
2826
+ if (comments.length === 0) {
2827
+ process.stderr.write(`Error: No open review comments found on the pull request.
2828
+ `);
2829
+ process.exit(1);
2898
2830
  }
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}`);
2831
+ process.stdout.write(`Found ${String(comments.length)} open review comment${comments.length === 1 ? "" : "s"} on PR. Invoking AI\u2026
2832
+ `);
2833
+ const config = readConfig();
2834
+ const promptText = config.prompts?.fixComments ?? DEFAULT_FIX_COMMENTS_PROMPT;
2835
+ const fullPrompt = withPush(
2836
+ `${promptText}
2837
+
2838
+ Open review comments:
2839
+
2840
+ ${formatComments(comments)}`,
2841
+ options.push
2842
+ );
2843
+ await invokeSelectedExecutor(fullPrompt, executor, options);
2844
+ });
2845
+ var executeCheckIssueCmd = addAiOptions(
2846
+ new Command5("check-issue").description(
2847
+ "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"
2848
+ ).argument("<issue-number>", "GitHub issue number to check")
2849
+ ).option("--force", "Skip the new-message check and invoke the AI directly").action(async (issueNumberArg, options) => {
2850
+ const executor = resolveExecutor2(options.with);
2851
+ const issueNumber = Number.parseInt(issueNumberArg, 10);
2852
+ if (Number.isNaN(issueNumber) || issueNumber <= 0) {
2853
+ process.stderr.write(`Error: <issue-number> must be a positive integer (got '${issueNumberArg}').
2854
+ `);
2855
+ process.exit(1);
2906
2856
  }
2907
- return { number: Number(match[1]), url };
2908
- }
2857
+ const config = readConfig();
2858
+ if (config.remoteType === "azdo") {
2859
+ process.stderr.write(
2860
+ "Error: check-issue is not supported for Azure DevOps. See docs/azdo-gap.md for details.\n"
2861
+ );
2862
+ process.exit(1);
2863
+ }
2864
+ const allowedUsers = (config.allowedUsers ?? []).filter((user) => user.trim().length > 0);
2865
+ if (allowedUsers.length === 0) {
2866
+ process.stderr.write(
2867
+ "Error: No allowed users configured. Run `automata config` or `automata config set allowed-users <user1,user2>` to set them.\n"
2868
+ );
2869
+ process.exit(1);
2870
+ }
2871
+ const agentUser = (config.agentUser ?? "").trim();
2872
+ if (agentUser.length === 0) {
2873
+ process.stderr.write(
2874
+ "Error: No agent user configured. Run `automata config` or `automata config set agent-user <login>` to set it.\n"
2875
+ );
2876
+ process.exit(1);
2877
+ }
2878
+ let conversation;
2879
+ try {
2880
+ conversation = getIssueConversation(issueNumber);
2881
+ } catch (err) {
2882
+ process.stderr.write(`Error: ${err.message}
2883
+ `);
2884
+ process.exit(1);
2885
+ }
2886
+ const analysis = analyzeConversation(conversation, allowedUsers, agentUser);
2887
+ if (!analysis.hasNewMessage && !options.force) {
2888
+ const since = analysis.lastAgentAt === null ? "" : ` (last agent message: ${analysis.lastAgentAt})`;
2889
+ process.stdout.write(
2890
+ `No new messages from allowed users on issue #${String(issueNumber)}${since}. Use --force to invoke the AI anyway.
2891
+ `
2892
+ );
2893
+ return;
2894
+ }
2895
+ if (analysis.hasNewMessage) {
2896
+ process.stdout.write(
2897
+ `Found ${String(analysis.newMessageCount)} new message${pluralSuffix(analysis.newMessageCount)} on issue #${String(issueNumber)}. Invoking AI\u2026
2898
+ `
2899
+ );
2900
+ } else {
2901
+ process.stdout.write(`No new messages on issue #${String(issueNumber)} \u2014 forced run. Invoking AI\u2026
2902
+ `);
2903
+ }
2904
+ const promptText = config.prompts?.checkIssue ?? DEFAULT_CHECK_ISSUE_PROMPT;
2905
+ const fullPrompt = withPush(
2906
+ `${promptText}
2907
+
2908
+ Issue #${String(issueNumber)}: ${conversation.title}
2909
+ URL: ${conversation.url}
2910
+
2911
+ Conversation (only messages from allowed users and the agent, oldest first):
2912
+
2913
+ ${formatConversation(analysis.messages)}`,
2914
+ options.push
2915
+ );
2916
+ 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.";
2917
+ try {
2918
+ postComment(issueNumber, marker);
2919
+ } catch (err) {
2920
+ process.stderr.write(
2921
+ `Error: could not post the execution marker comment on issue #${String(issueNumber)}: ${err.message}
2922
+ `
2923
+ );
2924
+ process.exit(1);
2925
+ }
2926
+ await invokeSelectedExecutor(fullPrompt, executor, options);
2927
+ });
2928
+ var executePromptCommand = new Command5("execute-prompt").description("Execute a configured custom prompt using an AI assistant").addCommand(executeSonarCmd).addCommand(executeFixCommentsCmd).addCommand(executeCheckIssueCmd);
2929
+
2930
+ // src/commands/doWork.ts
2931
+ import { Command as Command6 } from "commander";
2909
2932
 
2910
2933
  // src/github/workDetection.ts
2911
2934
  var NO_ISSUE_MESSAGES = {
@@ -2915,10 +2938,6 @@ var NO_ISSUE_MESSAGES = {
2915
2938
  hasNewMessage: false,
2916
2939
  lastAgentAt: null
2917
2940
  };
2918
- function isAssignedToAgent(assignees, agentUser) {
2919
- const agent = agentUser.toLowerCase();
2920
- return assignees.some((name) => name.toLowerCase() === agent);
2921
- }
2922
2941
  function findActionableThreads(threads, p, prLastAgentAt) {
2923
2942
  const actionable = [];
2924
2943
  for (const thread of threads) {
@@ -2983,7 +3002,7 @@ function decideWork(state, p, policy) {
2983
3002
  return { kind: "skip", issue, pr: null, reason: "issue-closed", detail: "the issue is closed" };
2984
3003
  }
2985
3004
  const issueAnalysis = analyzeSurface(issueSurface.messages, p);
2986
- const needsAssignment = !isAssignedToAgent(issueSurface.assignees, p.agentUser);
3005
+ const needsAssignment = issueSurface.assignees.length === 0;
2987
3006
  const openPrs = state.linkedPrs.filter((pr) => pr.state === "OPEN");
2988
3007
  const hasOpenPr = openPrs.length > 0 && prSurface !== null && prSurface.pr.state === "OPEN";
2989
3008
  if (!hasOpenPr) {
@@ -3004,6 +3023,7 @@ function decideWork(state, p, policy) {
3004
3023
  pr: null,
3005
3024
  branch: baseBranch,
3006
3025
  needsAssignment,
3026
+ prNeedsAssignment: false,
3007
3027
  issueAnalysis,
3008
3028
  prAnalysis: null,
3009
3029
  actionableThreads: [],
@@ -3044,6 +3064,7 @@ function decideWork(state, p, policy) {
3044
3064
  pr: surface.pr,
3045
3065
  branch: surface.pr.headRefName,
3046
3066
  needsAssignment,
3067
+ prNeedsAssignment: surface.assignees.length === 0,
3047
3068
  issueAnalysis,
3048
3069
  prAnalysis,
3049
3070
  actionableThreads,
@@ -3097,6 +3118,13 @@ function decideOrphanPrWork(state, p, policy) {
3097
3118
  // agent here would change what the discovery filter matches next tick.
3098
3119
  // The `working…` marker on the pull request is the claim.
3099
3120
  needsAssignment: false,
3121
+ // For the same reason, and more directly: the orphan pass discovers by
3122
+ // the *pull request's own* assignees, so claiming an unassigned orphan
3123
+ // would make it match the filter on the next tick — the agent would
3124
+ // permanently own a pull request the operator never opted in. A build
3125
+ // turn is safe because it reaches its pull request through an issue that
3126
+ // matched the filter, not through the pull request's assignees.
3127
+ prNeedsAssignment: false,
3100
3128
  issueAnalysis: NO_ISSUE_MESSAGES,
3101
3129
  prAnalysis,
3102
3130
  actionableThreads,
@@ -3129,6 +3157,30 @@ function selectLinkedPr(linkedPrs) {
3129
3157
  const open = linkedPrs.filter((pr) => pr.state === "OPEN");
3130
3158
  return open.length === 0 ? null : newestPr(open);
3131
3159
  }
3160
+ function claimStates(item) {
3161
+ const states = [];
3162
+ if (item.issue !== null) {
3163
+ states.push({
3164
+ surface: "issue",
3165
+ number: item.issue.number,
3166
+ state: item.needsAssignment ? "would-claim" : "already-assigned"
3167
+ });
3168
+ }
3169
+ if (item.pr !== null) {
3170
+ states.push({
3171
+ surface: "pull request",
3172
+ number: item.pr.number,
3173
+ // An exemption is a rule, not a state, so it outranks the assignee list:
3174
+ // an orphan pull request with nobody on it is still not claimed.
3175
+ state: prClaimState(item)
3176
+ });
3177
+ }
3178
+ return states;
3179
+ }
3180
+ function prClaimState(item) {
3181
+ if (item.turn === "pr-orphan") return "rule-exempt";
3182
+ return item.prNeedsAssignment ? "would-claim" : "already-assigned";
3183
+ }
3132
3184
 
3133
3185
  // src/github/workPrompt.ts
3134
3186
  function promptBaseBranch(item, configuredBaseBranch) {
@@ -4271,10 +4323,32 @@ function planRun(item, settings, execution) {
4271
4323
  });
4272
4324
  return { prompt, bin, args, command: [bin, ...args].map(shellQuote).join(" ") };
4273
4325
  }
4326
+ function describeAssignment(item, agentUser) {
4327
+ return claimStates(item).map((claim) => {
4328
+ const label = claim.surface === "issue" ? "issue" : `pull request #${String(claim.number)}`;
4329
+ switch (claim.state) {
4330
+ case "would-claim":
4331
+ return `would assign ${label} to ${agentUser}`;
4332
+ case "already-assigned":
4333
+ return `${label} already assigned`;
4334
+ case "rule-exempt":
4335
+ return `${label} not claimed (orphan pass)`;
4336
+ }
4337
+ }).join(" \xB7 ");
4338
+ }
4339
+ function describePlanClaim(item) {
4340
+ const claims = claimStates(item);
4341
+ const would = claims.filter((claim) => claim.state === "would-claim").map((claim) => `the ${claim.surface}`);
4342
+ const exempt = claims.filter((claim) => claim.state === "rule-exempt").map((claim) => claim.surface);
4343
+ const parts = [];
4344
+ if (would.length > 0) parts.push(`will assign ${would.join(" and ")} to the agent`);
4345
+ if (exempt.length > 0) parts.push(`${exempt.join(" and ")} not claimed (orphan pass)`);
4346
+ return parts.length === 0 ? "" : `, ${parts.join(", ")}`;
4347
+ }
4274
4348
  function describePlannedRun(item, settings, run5, execution) {
4275
4349
  const rule = "\u2500".repeat(72);
4276
4350
  const branchAction = item.turn === "issue-discuss" ? " and pull" : " and fast-forward";
4277
- const assignment = item.needsAssignment ? `would assign to ${settings.participants.agentUser}` : "already assigned";
4351
+ const assignment = describeAssignment(item, settings.participants.agentUser);
4278
4352
  const markerTarget = markerSurfaceLabel(item);
4279
4353
  const lines = [
4280
4354
  rule,
@@ -4469,8 +4543,7 @@ function describePlan(decisions) {
4469
4543
  return ` ${subjectLabel(decision.issue?.number ?? null, decision.pr?.number ?? null)} nothing to do \u2014 ${decision.detail}`;
4470
4544
  }
4471
4545
  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}`;
4546
+ return ` ${itemLabel(item)} ${item.turn} on ${item.branch} \u2014 ${item.reason}${describePlanClaim(item)}`;
4474
4547
  });
4475
4548
  return lines.length === 0 ? " (nothing matched the discovery filter)\n" : lines.join("\n") + "\n";
4476
4549
  }
@@ -4485,6 +4558,16 @@ function claimIssue(item, settings) {
4485
4558
  `);
4486
4559
  }
4487
4560
  }
4561
+ function claimPr(prNumber, agentUser) {
4562
+ try {
4563
+ assignPrToAgent(prNumber, agentUser);
4564
+ progress(` assigned pull request #${String(prNumber)} to ${agentUser}.
4565
+ `);
4566
+ } catch (err) {
4567
+ progress(` warning: could not assign pull request #${String(prNumber)}: ${err.message}
4568
+ `);
4569
+ }
4570
+ }
4488
4571
  function notePickupOnIssue(item, marker) {
4489
4572
  if (item.turn !== "pr-work" || item.pr === null || item.issue === null) return null;
4490
4573
  if (!item.issueAnalysis.hasNewMessage) return null;
@@ -4657,7 +4740,7 @@ async function invokeExecutor(prompt, execution, silent) {
4657
4740
  }
4658
4741
  await runClaude(prompt, { model: execution.model, effort: execution.effort, printSteps: !silent });
4659
4742
  }
4660
- function repairIssueLink(item, baseBranch) {
4743
+ function repairIssueLink(item, baseBranch, agentUser) {
4661
4744
  const issue = item.issue;
4662
4745
  if (issue === null) return false;
4663
4746
  try {
@@ -4673,6 +4756,9 @@ function repairIssueLink(item, baseBranch) {
4673
4756
  `);
4674
4757
  return false;
4675
4758
  }
4759
+ if (pr.assignees.length === 0) {
4760
+ claimPr(pr.number, agentUser);
4761
+ }
4676
4762
  const closesRef = new RegExp(String.raw`\bcloses\s+#` + String(issue.number) + String.raw`\b`, "i");
4677
4763
  if (closesRef.test(pr.body)) {
4678
4764
  progress(` pull request #${String(pr.number)} already closes issue #${String(issue.number)}.
@@ -4736,6 +4822,9 @@ ${itemLabel(planned)} ${planned.turn}: ${planned.reason}
4736
4822
  return { ...base, outcome: "skipped", detail: `${prepared.reason}: ${prepared.detail}` };
4737
4823
  }
4738
4824
  claimIssue(item, settings);
4825
+ if (item.turn === "pr-work" && item.pr !== null && item.prNeedsAssignment) {
4826
+ claimPr(item.pr.number, settings.participants.agentUser);
4827
+ }
4739
4828
  let marker;
4740
4829
  const markerTarget = markerSurfaceTarget(item);
4741
4830
  try {
@@ -4819,7 +4908,7 @@ function adjustOutcome(reconciled, item, settings, buriedByNote) {
4819
4908
  };
4820
4909
  }
4821
4910
  if (item.turn !== "issue-discuss") return outcome;
4822
- const linked = repairIssueLink(item, settings.baseBranch);
4911
+ const linked = repairIssueLink(item, settings.baseBranch, settings.participants.agentUser);
4823
4912
  if (linked && outcome.reason === "no-answer") {
4824
4913
  progress(" a pull request was opened, so the turn is counted as answered.\n");
4825
4914
  return {
@@ -5227,6 +5316,7 @@ function toPlanJson(decision) {
5227
5316
  turn: item.turn,
5228
5317
  branch: item.branch,
5229
5318
  needsAssignment: item.needsAssignment,
5319
+ prNeedsAssignment: item.prNeedsAssignment,
5230
5320
  reason: item.reason
5231
5321
  };
5232
5322
  }