automata-cli 0.5.0-develop.176 → 0.5.0-develop.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,15 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ DEFAULT_CHECK_ISSUE_PROMPT,
3
4
  DEFAULT_CLAUDE_SYSTEM_PROMPT,
5
+ DEFAULT_DO_WORK,
6
+ DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT,
7
+ DEFAULT_DO_WORK_PR_WORK_PROMPT,
4
8
  DEFAULT_FIX_COMMENTS_PROMPT,
5
9
  DEFAULT_SONAR_PROMPT,
6
10
  readConfig,
7
11
  readRawConfig,
8
12
  writeConfig
9
- } from "./chunk-LAIP3B6F.js";
13
+ } from "./chunk-LTCVLM4I.js";
10
14
 
11
15
  // src/index.ts
12
- import { Command as Command6 } from "commander";
16
+ import { Command as Command7 } from "commander";
13
17
 
14
18
  // src/version.ts
15
19
  import { readFileSync } from "fs";
@@ -23,6 +27,22 @@ var version = packageJson.version;
23
27
  import { Command } from "commander";
24
28
  var VALID_TYPES = ["gh", "azdo"];
25
29
  var VALID_TECHNIQUES = ["label", "assignee", "title-contains"];
30
+ var VALID_EXECUTORS = ["claude", "codex"];
31
+ var VALID_TURN_KINDS = ["issue-discuss", "pr-work"];
32
+ function writeDoWork(patch) {
33
+ const current = readRawConfig();
34
+ writeConfig({ ...current, doWork: { ...current.doWork, ...patch } });
35
+ }
36
+ function parseNonNegativeInt(value, label) {
37
+ const trimmed = value.trim();
38
+ const parsed = Number.parseInt(trimmed, 10);
39
+ if (Number.isNaN(parsed) || parsed < 0 || String(parsed) !== trimmed || !Number.isSafeInteger(parsed)) {
40
+ process.stderr.write(`Error: ${label} must be a non-negative integer (got "${value}").
41
+ `);
42
+ process.exit(1);
43
+ }
44
+ return parsed;
45
+ }
26
46
  var configSetType = new Command("type").description("Set the remote environment type").argument("<value>", "Remote type: gh (GitHub) or azdo (Azure DevOps)").action((value) => {
27
47
  if (!VALID_TYPES.includes(value)) {
28
48
  process.stderr.write(`Error: invalid type "${value}". Must be one of: ${VALID_TYPES.join(", ")}
@@ -57,12 +77,124 @@ var configSetClaudeSystemPrompt = new Command("claude-system-prompt").descriptio
57
77
  process.stdout.write(`Claude system prompt set.
58
78
  `);
59
79
  });
60
- var configSet = new Command("set").description("Set a configuration value").addCommand(configSetType).addCommand(configSetIssueDiscoveryTechnique).addCommand(configSetIssueDiscoveryValue).addCommand(configSetClaudeSystemPrompt);
80
+ var configSetAllowedUsers = new Command("allowed-users").description("Set the comma-separated list of users allowed to instruct the agent on an issue").argument("<value>", "Comma-separated GitHub logins, e.g. alice,bob").action((value) => {
81
+ const users = value.split(",").map((user) => user.trim()).filter((user) => user.length > 0);
82
+ if (users.length === 0) {
83
+ process.stderr.write("Error: allowed-users requires at least one login.\n");
84
+ process.exit(1);
85
+ }
86
+ const current = readRawConfig();
87
+ writeConfig({ ...current, allowedUsers: users });
88
+ process.stdout.write(`Allowed users set to: ${users.join(", ")}
89
+ `);
90
+ });
91
+ var configSetAgentUser = new Command("agent-user").description("Set the login the agent itself posts as").argument("<value>", "GitHub login used by the agent").action((value) => {
92
+ const user = value.trim();
93
+ if (user.length === 0) {
94
+ process.stderr.write("Error: agent-user requires a non-empty login.\n");
95
+ process.exit(1);
96
+ }
97
+ const current = readRawConfig();
98
+ writeConfig({ ...current, agentUser: user });
99
+ process.stdout.write(`Agent user set to: ${user}
100
+ `);
101
+ });
102
+ var configSetDoWorkBaseBranch = new Command("do-work-base-branch").description("Set the branch `do-work` returns to for discussion turns").argument("<value>", `Branch name (default: ${DEFAULT_DO_WORK.baseBranch})`).action((value) => {
103
+ const branch = value.trim();
104
+ if (branch.length === 0) {
105
+ process.stderr.write("Error: do-work-base-branch requires a non-empty branch name.\n");
106
+ process.exit(1);
107
+ }
108
+ writeDoWork({ baseBranch: branch });
109
+ process.stdout.write(`do-work base branch set to: ${branch}
110
+ `);
111
+ });
112
+ var configSetDoWorkProtectedBranches = new Command("do-work-protected-branches").description("Set the branches a `do-work` build turn must never check out and push to").argument("<value>", `Comma-separated branch names (default: ${DEFAULT_DO_WORK.protectedBranches.join(",")})`).action((value) => {
113
+ const branches = value.split(",").map((branch) => branch.trim()).filter((branch) => branch.length > 0);
114
+ if (branches.length === 0) {
115
+ process.stderr.write("Error: do-work-protected-branches requires at least one branch name.\n");
116
+ process.exit(1);
117
+ }
118
+ writeDoWork({ protectedBranches: branches });
119
+ process.stdout.write(`do-work protected branches set to: ${branches.join(", ")}
120
+ `);
121
+ });
122
+ var configSetDoWorkExecutor = new Command("do-work-executor").description("Set the default executor `do-work` invokes").argument("<value>", `Executor: ${VALID_EXECUTORS.join(", ")}`).action((value) => {
123
+ if (!VALID_EXECUTORS.includes(value)) {
124
+ process.stderr.write(`Error: invalid executor "${value}". Must be one of: ${VALID_EXECUTORS.join(", ")}
125
+ `);
126
+ process.exit(1);
127
+ }
128
+ writeDoWork({ executor: value });
129
+ process.stdout.write(`do-work executor set to: ${value}
130
+ `);
131
+ });
132
+ var configSetDoWorkModel = new Command("do-work-model").description("Set the default model `do-work` passes to one executor").argument("<executor>", `Executor: ${VALID_EXECUTORS.join(", ")}`).argument("<value>", "Model identifier").action((executor, value) => {
133
+ if (!VALID_EXECUTORS.includes(executor)) {
134
+ process.stderr.write(
135
+ `Error: invalid executor "${executor}". Must be one of: ${VALID_EXECUTORS.join(", ")}
136
+ `
137
+ );
138
+ process.exit(1);
139
+ }
140
+ const model = value.trim();
141
+ if (model.length === 0) {
142
+ process.stderr.write("Error: do-work-model requires a non-empty model identifier.\n");
143
+ process.exit(1);
144
+ }
145
+ const current = readRawConfig();
146
+ writeDoWork({ models: { ...current.doWork?.models, [executor]: model } });
147
+ process.stdout.write(`do-work ${executor} model set to: ${model}
148
+ `);
149
+ });
150
+ var configSetDoWorkMaxRuns = new Command("do-work-max-runs").description("Set the maximum number of model runs `do-work` performs per tick (0 = unlimited)").argument("<value>", "Non-negative integer").action((value) => {
151
+ const maxRunsPerTick = parseNonNegativeInt(value, "do-work-max-runs");
152
+ writeDoWork({ maxRunsPerTick });
153
+ process.stdout.write(
154
+ `do-work max runs per tick set to: ${String(maxRunsPerTick)}${maxRunsPerTick === 0 ? " (unlimited)" : ""}
155
+ `
156
+ );
157
+ });
158
+ var configSetDoWorkLockStaleMinutes = new Command("do-work-lock-stale-minutes").description("Set how long a run lock may be held before it is treated as stale").argument("<value>", `Positive integer (default: ${String(DEFAULT_DO_WORK.lockStaleMinutes)})`).action((value) => {
159
+ const minutes = parseNonNegativeInt(value, "do-work-lock-stale-minutes");
160
+ if (minutes === 0) {
161
+ process.stderr.write("Error: do-work-lock-stale-minutes must be greater than zero.\n");
162
+ process.exit(1);
163
+ }
164
+ writeDoWork({ lockStaleMinutes: minutes });
165
+ process.stdout.write(`do-work lock staleness set to: ${String(minutes)} minutes
166
+ `);
167
+ });
168
+ var configSetDoWorkPrompt = new Command("do-work-prompt").description("Set the turn instructions for a `do-work` turn kind (prompt text or a .md filename)").argument("<turn-kind>", `Turn kind: ${VALID_TURN_KINDS.join(", ")}`).argument("<value>", "Prompt text, or a plain .md filename inside .automata/").action((turnKind, value) => {
169
+ if (!VALID_TURN_KINDS.includes(turnKind)) {
170
+ process.stderr.write(
171
+ `Error: invalid turn kind "${turnKind}". Must be one of: ${VALID_TURN_KINDS.join(", ")}
172
+ `
173
+ );
174
+ process.exit(1);
175
+ }
176
+ const prompt = value.trim();
177
+ if (prompt.length === 0) {
178
+ process.stderr.write("Error: do-work-prompt requires a non-empty value.\n");
179
+ process.exit(1);
180
+ }
181
+ const current = readRawConfig();
182
+ const prompts = { ...current.doWork?.prompts };
183
+ if (turnKind === "issue-discuss") {
184
+ prompts.issueDiscuss = prompt;
185
+ } else {
186
+ prompts.prWork = prompt;
187
+ }
188
+ writeDoWork({ prompts });
189
+ process.stdout.write(`do-work ${turnKind} prompt set.
190
+ `);
191
+ });
192
+ var configSet = new Command("set").description("Set a configuration value").addCommand(configSetType).addCommand(configSetIssueDiscoveryTechnique).addCommand(configSetIssueDiscoveryValue).addCommand(configSetClaudeSystemPrompt).addCommand(configSetAllowedUsers).addCommand(configSetAgentUser).addCommand(configSetDoWorkBaseBranch).addCommand(configSetDoWorkProtectedBranches).addCommand(configSetDoWorkExecutor).addCommand(configSetDoWorkModel).addCommand(configSetDoWorkMaxRuns).addCommand(configSetDoWorkLockStaleMinutes).addCommand(configSetDoWorkPrompt);
61
193
  var configCommand = new Command("config").description("Configure automata settings").addCommand(configSet).action(async () => {
62
194
  const [{ render }, React, { ConfigWizard }] = await Promise.all([
63
195
  import("ink"),
64
196
  import("react"),
65
- import("./ConfigWizard-WUGEM3PT.js")
197
+ import("./ConfigWizard-5B5AOGUU.js")
66
198
  ]);
67
199
  const { waitUntilExit } = render(React.createElement(ConfigWizard));
68
200
  await waitUntilExit();
@@ -195,8 +327,8 @@ function resolveSonarPullRequestUrl(url, prNumber) {
195
327
  }
196
328
  function isSonarUrl(url) {
197
329
  try {
198
- const hostname = new URL(url).hostname;
199
- return hostname === "sonarcloud.io" || hostname.endsWith(".sonarcloud.io");
330
+ const hostname2 = new URL(url).hostname;
331
+ return hostname2 === "sonarcloud.io" || hostname2.endsWith(".sonarcloud.io");
200
332
  } catch {
201
333
  return false;
202
334
  }
@@ -572,8 +704,12 @@ function isUpstreamGone(branch) {
572
704
  const { status } = run2("git", ["ls-remote", "--exit-code", "--heads", "origin", branch]);
573
705
  return status !== 0;
574
706
  }
575
- function hasUncommittedChanges() {
576
- const { stdout } = run2("git", ["status", "--porcelain"]);
707
+ function hasUncommittedChanges(excludePaths = []) {
708
+ const args = ["status", "--porcelain"];
709
+ if (excludePaths.length > 0) {
710
+ args.push("--", ".", ...excludePaths.map((path) => `:(exclude)${path}`));
711
+ }
712
+ const { stdout } = run2("git", args);
577
713
  return stdout.trim().length > 0;
578
714
  }
579
715
  function checkoutAndPull(targetBranch) {
@@ -586,6 +722,22 @@ function checkoutAndPull(targetBranch) {
586
722
  throw new Error(`Failed to pull ${targetBranch}: ${pull.stderr.trim()}`);
587
723
  }
588
724
  }
725
+ function gitCommand(args) {
726
+ const { stderr, status } = run2("git", args);
727
+ return { ok: status === 0, stderr: stderr.trim() };
728
+ }
729
+ function checkoutBranch(branch) {
730
+ return gitCommand(["checkout", branch]);
731
+ }
732
+ function createTrackingBranch(branch) {
733
+ return gitCommand(["checkout", "-b", branch, `origin/${branch}`]);
734
+ }
735
+ function fetchBranch(branch) {
736
+ return gitCommand(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
737
+ }
738
+ function pullFastForwardOnly(branch) {
739
+ return gitCommand(branch === void 0 ? ["pull", "--ff-only"] : ["pull", "--ff-only", "origin", branch]);
740
+ }
589
741
  function fetchPrune() {
590
742
  const result = run2("git", ["fetch", "--prune"]);
591
743
  if (result.status !== 0) {
@@ -772,8 +924,8 @@ function sleep(ms) {
772
924
  }
773
925
  function isSonarCheck(check) {
774
926
  try {
775
- const hostname = new URL(check.detailsUrl).hostname;
776
- return hostname === "sonarcloud.io" || hostname.endsWith(".sonarcloud.io");
927
+ const hostname2 = new URL(check.detailsUrl).hostname;
928
+ return hostname2 === "sonarcloud.io" || hostname2.endsWith(".sonarcloud.io");
777
929
  } catch {
778
930
  return false;
779
931
  }
@@ -1184,7 +1336,7 @@ minor segment is incremented (e.g. 1.2.0 \u2192 1.3.0).`
1184
1336
  `);
1185
1337
  }
1186
1338
  });
1187
- var gitCommand = new Command2("git").description("Git workflow commands (some require gh CLI)").addCommand(getPrInfoCmd).addCommand(getPrCommentsCmd).addCommand(finishFeatureCmd).addCommand(publishReleaseCmd);
1339
+ var gitCommand2 = new Command2("git").description("Git workflow commands (some require gh CLI)").addCommand(getPrInfoCmd).addCommand(getPrCommentsCmd).addCommand(finishFeatureCmd).addCommand(publishReleaseCmd);
1188
1340
 
1189
1341
  // src/commands/getReady.ts
1190
1342
  import { createInterface as createInterface2 } from "readline";
@@ -1237,6 +1389,34 @@ function listIssues(technique, value, limit = 10) {
1237
1389
  }
1238
1390
  return JSON.parse(stdout);
1239
1391
  }
1392
+ function getIssueConversation(issueNumber) {
1393
+ const { stdout, stderr, status } = run3("gh", [
1394
+ "issue",
1395
+ "view",
1396
+ String(issueNumber),
1397
+ "--json",
1398
+ "number,title,body,url,author,createdAt,comments"
1399
+ ]);
1400
+ if (status !== 0) {
1401
+ throw new Error(stderr.trim() || `Failed to read issue #${issueNumber}. Is \`gh\` installed and authenticated?`);
1402
+ }
1403
+ const raw = JSON.parse(stdout);
1404
+ const comments = (raw.comments ?? []).map((comment) => ({
1405
+ id: comment.id,
1406
+ author: comment.author?.login ?? "",
1407
+ body: comment.body,
1408
+ createdAt: comment.createdAt
1409
+ })).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1410
+ return {
1411
+ number: raw.number,
1412
+ title: raw.title,
1413
+ body: raw.body,
1414
+ url: raw.url,
1415
+ author: raw.author?.login ?? "",
1416
+ createdAt: raw.createdAt,
1417
+ comments
1418
+ };
1419
+ }
1240
1420
  function postComment(issueNumber, body) {
1241
1421
  const { stderr, status } = run3("gh", ["issue", "comment", String(issueNumber), "--body", body]);
1242
1422
  if (status !== 0) {
@@ -1318,6 +1498,12 @@ import { existsSync } from "fs";
1318
1498
  import { delimiter, join } from "path";
1319
1499
 
1320
1500
  // src/cli/spawnUtils.ts
1501
+ var SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
1502
+ var ESCAPED_QUOTE = String.raw`'\''`;
1503
+ function shellQuote(arg) {
1504
+ if (SHELL_SAFE.test(arg)) return arg;
1505
+ return "'" + arg.replaceAll("'", ESCAPED_QUOTE) + "'";
1506
+ }
1321
1507
  function truncate(str, max) {
1322
1508
  return str.length > max ? str.slice(0, max) + "..." : str;
1323
1509
  }
@@ -1346,6 +1532,48 @@ function handleExitCode(status, toolName) {
1346
1532
  }
1347
1533
  }
1348
1534
 
1535
+ // src/cli/childRegistry.ts
1536
+ var active = /* @__PURE__ */ new Set();
1537
+ function trackChild(child) {
1538
+ active.add(child);
1539
+ }
1540
+ function untrackChild(child) {
1541
+ active.delete(child);
1542
+ }
1543
+ function waitForExit(child) {
1544
+ return new Promise((resolve2) => {
1545
+ if (child.exitCode !== null || child.signalCode !== null) {
1546
+ resolve2();
1547
+ return;
1548
+ }
1549
+ child.once("exit", () => resolve2());
1550
+ });
1551
+ }
1552
+ function afterDelay(ms) {
1553
+ return new Promise((resolve2) => {
1554
+ const timer = setTimeout(() => resolve2("timeout"), ms);
1555
+ timer.unref();
1556
+ });
1557
+ }
1558
+ async function terminateTrackedChildren(timeoutMs = 1e4, killGraceMs = 5e3) {
1559
+ const children = [...active];
1560
+ if (children.length === 0) return true;
1561
+ const exits = children.map((child) => waitForExit(child));
1562
+ for (const child of children) {
1563
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
1564
+ }
1565
+ const settled = await Promise.race([Promise.all(exits).then(() => "exited"), afterDelay(timeoutMs)]);
1566
+ if (settled === "exited") return true;
1567
+ for (const child of children) {
1568
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
1569
+ }
1570
+ const escalated = await Promise.race([
1571
+ Promise.all(exits).then(() => "exited"),
1572
+ afterDelay(killGraceMs)
1573
+ ]);
1574
+ return escalated === "exited";
1575
+ }
1576
+
1349
1577
  // src/claude/claudeService.ts
1350
1578
  function resolveCommand(name) {
1351
1579
  const pathDirs = (process.env["PATH"] ?? "").split(delimiter);
@@ -1355,6 +1583,52 @@ function resolveCommand(name) {
1355
1583
  }
1356
1584
  return name;
1357
1585
  }
1586
+ function buildClaudeArgs(prompt, options = {}) {
1587
+ const args = [];
1588
+ if (options.yolo) args.push("--dangerously-skip-permissions");
1589
+ if (options.model) args.push("--model", options.model);
1590
+ if (options.verbose) args.push("--verbose", "--output-format", "stream-json");
1591
+ args.push("-p", prompt);
1592
+ return args;
1593
+ }
1594
+ function runClaude(prompt, options = {}) {
1595
+ return new Promise((resolve2, reject) => {
1596
+ const claudeBin = resolveCommand("claude");
1597
+ const args = buildClaudeArgs(prompt, { yolo: true, model: options.model, verbose: true });
1598
+ const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
1599
+ trackChild(child);
1600
+ const rl = createInterface({ input: child.stdout });
1601
+ let turnCount = 0;
1602
+ rl.on("line", (line) => {
1603
+ if (options.printSteps !== true) return;
1604
+ try {
1605
+ const event = JSON.parse(line);
1606
+ formatEvent(event, turnCount);
1607
+ if (event["type"] === "assistant") turnCount++;
1608
+ } catch {
1609
+ }
1610
+ });
1611
+ child.on("error", (err) => {
1612
+ untrackChild(child);
1613
+ const nodeErr = err;
1614
+ reject(
1615
+ nodeErr.code === "ENOENT" ? new Error("`claude` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
1616
+ );
1617
+ });
1618
+ child.on("close", (code, signal) => {
1619
+ untrackChild(child);
1620
+ if (code === 0) {
1621
+ resolve2();
1622
+ return;
1623
+ }
1624
+ reject(
1625
+ new Error(
1626
+ signal === null ? `Claude Code exited with code ${String(code)}.` : `Claude Code terminated on ${signal}.`
1627
+ )
1628
+ );
1629
+ });
1630
+ });
1631
+ }
1358
1632
  function invokeClaudeCode(prompt, options = {}) {
1359
1633
  if (options.verbose) {
1360
1634
  return invokeClaudeCodeVerbose(prompt, options.yolo ?? false, options.model);
@@ -1363,10 +1637,7 @@ function invokeClaudeCode(prompt, options = {}) {
1363
1637
  }
1364
1638
  function invokeClaudeCodeSync(prompt, yolo, model) {
1365
1639
  const claudeBin = resolveCommand("claude");
1366
- const args = [];
1367
- if (yolo) args.push("--dangerously-skip-permissions");
1368
- if (model) args.push("--model", model);
1369
- args.push("-p", prompt);
1640
+ const args = buildClaudeArgs(prompt, { yolo, model, verbose: false });
1370
1641
  const result = spawnSync4(claudeBin, args, { encoding: "utf8", stdio: "inherit" });
1371
1642
  handleSpawnError(result.error, "claude");
1372
1643
  handleExitCode(result.status, "Claude Code");
@@ -1374,10 +1645,7 @@ function invokeClaudeCodeSync(prompt, yolo, model) {
1374
1645
  function invokeClaudeCodeVerbose(prompt, yolo, model) {
1375
1646
  return new Promise((resolve2) => {
1376
1647
  const claudeBin = resolveCommand("claude");
1377
- const args = [];
1378
- if (yolo) args.push("--dangerously-skip-permissions");
1379
- if (model) args.push("--model", model);
1380
- args.push("--verbose", "--output-format", "stream-json", "-p", prompt);
1648
+ const args = buildClaudeArgs(prompt, { yolo, model, verbose: true });
1381
1649
  const child = spawn(claudeBin, args, { stdio: ["inherit", "pipe", "inherit"] });
1382
1650
  const rl = createInterface({ input: child.stdout });
1383
1651
  let turnCount = 0;
@@ -1463,7 +1731,14 @@ function summarizeTool(name, input) {
1463
1731
  }
1464
1732
 
1465
1733
  // src/codex/codexService.ts
1466
- import { spawnSync as spawnSync5 } from "child_process";
1734
+ import { spawn as spawn2, spawnSync as spawnSync5 } from "child_process";
1735
+ function buildCodexArgs(prompt, options = {}) {
1736
+ const args = ["exec"];
1737
+ if (options.yolo) args.push("--dangerously-bypass-approvals-and-sandbox");
1738
+ if (options.model) args.push("--model", options.model);
1739
+ args.push(prompt);
1740
+ return args;
1741
+ }
1467
1742
  function invokeCodexCode(prompt, options = {}) {
1468
1743
  if (options.verbose) {
1469
1744
  process.stderr.write("Warning: --verbose is not supported for Codex and will be ignored.\n");
@@ -1472,14 +1747,38 @@ function invokeCodexCode(prompt, options = {}) {
1472
1747
  }
1473
1748
  function invokeCodexCodeSync(prompt, yolo, model) {
1474
1749
  const codexBin = resolveCommand("codex");
1475
- const args = ["exec"];
1476
- if (yolo) args.push("--dangerously-bypass-approvals-and-sandbox");
1477
- if (model) args.push("--model", model);
1478
- args.push(prompt);
1750
+ const args = buildCodexArgs(prompt, { yolo, model });
1479
1751
  const result = spawnSync5(codexBin, args, { encoding: "utf8", stdio: "inherit" });
1480
1752
  handleSpawnError(result.error, "codex");
1481
1753
  handleExitCode(result.status, "Codex");
1482
1754
  }
1755
+ function runCodex(prompt, options = {}) {
1756
+ return new Promise((resolve2, reject) => {
1757
+ const codexBin = resolveCommand("codex");
1758
+ const args = buildCodexArgs(prompt, { yolo: true, model: options.model });
1759
+ const child = spawn2(codexBin, args, { stdio: "inherit" });
1760
+ trackChild(child);
1761
+ child.on("error", (err) => {
1762
+ untrackChild(child);
1763
+ const nodeErr = err;
1764
+ reject(
1765
+ nodeErr.code === "ENOENT" ? new Error("`codex` CLI is not installed or not on PATH.") : new Error(nodeErr.message)
1766
+ );
1767
+ });
1768
+ child.on("close", (code, signal) => {
1769
+ untrackChild(child);
1770
+ if (code === 0) {
1771
+ resolve2();
1772
+ return;
1773
+ }
1774
+ reject(
1775
+ new Error(
1776
+ signal === null ? `Codex exited with code ${String(code)}.` : `Codex terminated on ${signal}.`
1777
+ )
1778
+ );
1779
+ });
1780
+ });
1781
+ }
1483
1782
 
1484
1783
  // src/commands/getReady.ts
1485
1784
  function writeOverflowHint(output, issues, limit) {
@@ -1715,6 +2014,109 @@ var executeCommand = new Command4("execute").description("Delegate work to an AI
1715
2014
 
1716
2015
  // src/commands/executePrompt.ts
1717
2016
  import { Command as Command5 } from "commander";
2017
+
2018
+ // src/github/conversation.ts
2019
+ var KIND_LABELS = {
2020
+ "issue-body": "issue description",
2021
+ "issue-comment": "comment",
2022
+ "pr-comment": "pull request comment",
2023
+ "pr-review": "pull request review",
2024
+ "thread-comment": "review thread comment"
2025
+ };
2026
+ function byCreatedAt(a, b) {
2027
+ return a.createdAt.localeCompare(b.createdAt);
2028
+ }
2029
+ function classify(author, p) {
2030
+ const login2 = author.toLowerCase();
2031
+ if (login2 === p.agentUser.toLowerCase()) return "agent";
2032
+ return p.allowedUsers.some((user) => user.toLowerCase() === login2) ? "authorized" : "other";
2033
+ }
2034
+ function analyzeSurface(messages, p) {
2035
+ const ordered = [...messages].sort(byCreatedAt);
2036
+ let lastAgentAt = null;
2037
+ for (const message of ordered) {
2038
+ if (message.kind === "issue-body") continue;
2039
+ if (classify(message.author, p) !== "agent") continue;
2040
+ if (lastAgentAt === null || message.createdAt > lastAgentAt) {
2041
+ lastAgentAt = message.createdAt;
2042
+ }
2043
+ }
2044
+ const kept = [];
2045
+ for (const message of ordered) {
2046
+ const authorClass = classify(message.author, p);
2047
+ if (authorClass === "other") continue;
2048
+ const isNew = authorClass === "authorized" && (lastAgentAt === null || message.createdAt > lastAgentAt);
2049
+ kept.push({ ...message, isNew });
2050
+ }
2051
+ const newMessages = kept.filter((message) => message.isNew);
2052
+ return {
2053
+ messages: kept,
2054
+ newMessages,
2055
+ newMessageCount: newMessages.length,
2056
+ hasNewMessage: newMessages.length > 0,
2057
+ lastAgentAt
2058
+ };
2059
+ }
2060
+ function lastAuthorClass(messages, p) {
2061
+ if (messages.length === 0) return "none";
2062
+ const newest = [...messages].sort(byCreatedAt).at(-1);
2063
+ return newest === void 0 ? "none" : classify(newest.author, p);
2064
+ }
2065
+ function formatMessages(messages) {
2066
+ return messages.map((message) => {
2067
+ const marker = message.isNew ? " \xB7 NEW since last agent run" : "";
2068
+ return `[${message.author}] ${KIND_LABELS[message.kind]} \xB7 ${message.createdAt}${marker}
2069
+ ${message.body}`;
2070
+ }).join("\n\n");
2071
+ }
2072
+
2073
+ // src/github/issueConversation.ts
2074
+ function analyzeConversation(conversation, allowedUsers, agentUser) {
2075
+ const participants = { allowedUsers, agentUser };
2076
+ const messages = [
2077
+ {
2078
+ kind: "issue-body",
2079
+ author: conversation.author,
2080
+ body: conversation.body,
2081
+ createdAt: conversation.createdAt
2082
+ },
2083
+ ...conversation.comments.map((comment) => ({
2084
+ kind: "issue-comment",
2085
+ author: comment.author,
2086
+ body: comment.body,
2087
+ createdAt: comment.createdAt
2088
+ }))
2089
+ ];
2090
+ const analysis = analyzeSurface(messages, participants);
2091
+ return {
2092
+ messages: analysis.messages.map(toConversationMessage),
2093
+ newMessageCount: analysis.newMessageCount,
2094
+ hasNewMessage: analysis.hasNewMessage,
2095
+ lastAgentAt: analysis.lastAgentAt
2096
+ };
2097
+ }
2098
+ function toConversationMessage(message) {
2099
+ return {
2100
+ kind: message.kind === "issue-body" ? "issue" : "comment",
2101
+ author: message.author,
2102
+ body: message.body,
2103
+ createdAt: message.createdAt,
2104
+ isNew: message.isNew
2105
+ };
2106
+ }
2107
+ function formatConversation(messages) {
2108
+ return formatMessages(
2109
+ messages.map((message) => ({
2110
+ kind: message.kind === "issue" ? "issue-body" : "issue-comment",
2111
+ author: message.author,
2112
+ body: message.body,
2113
+ createdAt: message.createdAt,
2114
+ isNew: message.isNew
2115
+ }))
2116
+ );
2117
+ }
2118
+
2119
+ // src/commands/executePrompt.ts
1718
2120
  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.";
1719
2121
  function withPush(prompt, push) {
1720
2122
  return push ? `${prompt}
@@ -1724,6 +2126,9 @@ ${PUSH_INSTRUCTION}` : prompt;
1724
2126
  function formatPrInfoContext(pr) {
1725
2127
  return JSON.stringify(pr, null, 2);
1726
2128
  }
2129
+ function pluralSuffix(count) {
2130
+ return count === 1 ? "" : "s";
2131
+ }
1727
2132
  function addAiOptions(cmd) {
1728
2133
  return cmd.requiredOption("--with <executor>", "Executor to use: claude or codex").option("--model <string>", "Model identifier 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");
1729
2134
  }
@@ -1841,16 +2246,1774 @@ ${formatComments(comments)}`,
1841
2246
  );
1842
2247
  await invokeSelectedExecutor(fullPrompt, executor, options);
1843
2248
  });
1844
- var executePromptCommand = new Command5("execute-prompt").description("Execute a configured custom prompt using an AI assistant").addCommand(executeSonarCmd).addCommand(executeFixCommentsCmd);
2249
+ var executeCheckIssueCmd = addAiOptions(
2250
+ new Command5("check-issue").description(
2251
+ "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"
2252
+ ).argument("<issue-number>", "GitHub issue number to check")
2253
+ ).option("--force", "Skip the new-message check and invoke the AI directly").action(async (issueNumberArg, options) => {
2254
+ const executor = resolveExecutor(options.with);
2255
+ const issueNumber = Number.parseInt(issueNumberArg, 10);
2256
+ if (Number.isNaN(issueNumber) || issueNumber <= 0) {
2257
+ process.stderr.write(`Error: <issue-number> must be a positive integer (got '${issueNumberArg}').
2258
+ `);
2259
+ process.exit(1);
2260
+ }
2261
+ const config = readConfig();
2262
+ if (config.remoteType === "azdo") {
2263
+ process.stderr.write(
2264
+ "Error: check-issue is not supported for Azure DevOps. See docs/azdo-gap.md for details.\n"
2265
+ );
2266
+ process.exit(1);
2267
+ }
2268
+ const allowedUsers = (config.allowedUsers ?? []).filter((user) => user.trim().length > 0);
2269
+ if (allowedUsers.length === 0) {
2270
+ process.stderr.write(
2271
+ "Error: No allowed users configured. Run `automata config` or `automata config set allowed-users <user1,user2>` to set them.\n"
2272
+ );
2273
+ process.exit(1);
2274
+ }
2275
+ const agentUser = (config.agentUser ?? "").trim();
2276
+ if (agentUser.length === 0) {
2277
+ process.stderr.write(
2278
+ "Error: No agent user configured. Run `automata config` or `automata config set agent-user <login>` to set it.\n"
2279
+ );
2280
+ process.exit(1);
2281
+ }
2282
+ let conversation;
2283
+ try {
2284
+ conversation = getIssueConversation(issueNumber);
2285
+ } catch (err) {
2286
+ process.stderr.write(`Error: ${err.message}
2287
+ `);
2288
+ process.exit(1);
2289
+ }
2290
+ const analysis = analyzeConversation(conversation, allowedUsers, agentUser);
2291
+ if (!analysis.hasNewMessage && !options.force) {
2292
+ const since = analysis.lastAgentAt === null ? "" : ` (last agent message: ${analysis.lastAgentAt})`;
2293
+ process.stdout.write(
2294
+ `No new messages from allowed users on issue #${String(issueNumber)}${since}. Use --force to invoke the AI anyway.
2295
+ `
2296
+ );
2297
+ return;
2298
+ }
2299
+ if (analysis.hasNewMessage) {
2300
+ process.stdout.write(
2301
+ `Found ${String(analysis.newMessageCount)} new message${pluralSuffix(analysis.newMessageCount)} on issue #${String(issueNumber)}. Invoking AI\u2026
2302
+ `
2303
+ );
2304
+ } else {
2305
+ process.stdout.write(`No new messages on issue #${String(issueNumber)} \u2014 forced run. Invoking AI\u2026
2306
+ `);
2307
+ }
2308
+ const promptText = config.prompts?.checkIssue ?? DEFAULT_CHECK_ISSUE_PROMPT;
2309
+ const fullPrompt = withPush(
2310
+ `${promptText}
2311
+
2312
+ Issue #${String(issueNumber)}: ${conversation.title}
2313
+ URL: ${conversation.url}
2314
+
2315
+ Conversation (only messages from allowed users and the agent, oldest first):
2316
+
2317
+ ${formatConversation(analysis.messages)}`,
2318
+ options.push
2319
+ );
2320
+ 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.";
2321
+ try {
2322
+ postComment(issueNumber, marker);
2323
+ } catch (err) {
2324
+ process.stderr.write(
2325
+ `Error: could not post the execution marker comment on issue #${String(issueNumber)}: ${err.message}
2326
+ `
2327
+ );
2328
+ process.exit(1);
2329
+ }
2330
+ await invokeSelectedExecutor(fullPrompt, executor, options);
2331
+ });
2332
+ var executePromptCommand = new Command5("execute-prompt").description("Execute a configured custom prompt using an AI assistant").addCommand(executeSonarCmd).addCommand(executeFixCommentsCmd).addCommand(executeCheckIssueCmd);
2333
+
2334
+ // src/commands/doWork.ts
2335
+ import { Command as Command6 } from "commander";
2336
+
2337
+ // src/github/ghWorkService.ts
2338
+ import { spawnSync as spawnSync6 } from "child_process";
2339
+ function run4(cmd, args) {
2340
+ const result = spawnSync6(cmd, args, { encoding: "utf8" });
2341
+ if (result.error) {
2342
+ const err = result.error;
2343
+ if (err.code === "ENOENT") {
2344
+ throw new Error(`\`${cmd}\` CLI is not installed or not on PATH.`);
2345
+ }
2346
+ throw new Error(err.message);
2347
+ }
2348
+ return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status ?? 1 };
2349
+ }
2350
+ function ghJson(args, what) {
2351
+ const { stdout, stderr, status } = run4("gh", args);
2352
+ if (status !== 0) {
2353
+ throw new Error(stderr.trim() || `Failed to ${what}. Is \`gh\` installed and authenticated?`);
2354
+ }
2355
+ return JSON.parse(stdout);
2356
+ }
2357
+ function login(author) {
2358
+ return author?.login ?? "";
2359
+ }
2360
+ function byCreatedAt2(a, b) {
2361
+ return a.createdAt.localeCompare(b.createdAt);
2362
+ }
2363
+ function visibleAt(createdAt, submittedAt) {
2364
+ if (submittedAt === null || submittedAt === void 0) return createdAt;
2365
+ return submittedAt > createdAt ? submittedAt : createdAt;
2366
+ }
2367
+ function getRepoSlug() {
2368
+ const { stdout, status } = run4("git", ["remote", "get-url", "origin"]);
2369
+ if (status !== 0) {
2370
+ throw new Error("Could not read the `origin` remote. Is this a git repository with a remote?");
2371
+ }
2372
+ const url = stdout.trim();
2373
+ const match = /github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url) ?? /github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url);
2374
+ if (!match) {
2375
+ throw new Error(`Could not determine the GitHub owner/repo from the origin remote: ${url}`);
2376
+ }
2377
+ return { owner: match[1], repo: match[2] };
2378
+ }
2379
+ function getAuthenticatedLogin() {
2380
+ const { stdout, status } = run4("gh", ["api", "user", "--jq", ".login"]);
2381
+ if (status !== 0) return null;
2382
+ const login2 = stdout.trim();
2383
+ return login2.length > 0 ? login2 : null;
2384
+ }
2385
+ function listCandidateIssues(technique, value, limit) {
2386
+ const args = [
2387
+ "issue",
2388
+ "list",
2389
+ "--state",
2390
+ "open",
2391
+ "--limit",
2392
+ String(limit),
2393
+ "--json",
2394
+ "number,title,body,url"
2395
+ ];
2396
+ switch (technique) {
2397
+ case "label":
2398
+ args.push("--label", value);
2399
+ break;
2400
+ case "assignee":
2401
+ args.push("--assignee", value);
2402
+ break;
2403
+ case "title-contains":
2404
+ args.push("--search", `${value} in:title`);
2405
+ break;
2406
+ }
2407
+ return ghJson(args, "query GitHub issues");
2408
+ }
2409
+ function getIssueSurface(issueNumber) {
2410
+ const raw = ghJson(
2411
+ [
2412
+ "issue",
2413
+ "view",
2414
+ String(issueNumber),
2415
+ "--json",
2416
+ "number,title,body,url,state,author,createdAt,assignees,labels,comments"
2417
+ ],
2418
+ `read issue #${String(issueNumber)}`
2419
+ );
2420
+ const messages = [
2421
+ {
2422
+ kind: "issue-body",
2423
+ author: login(raw.author),
2424
+ body: raw.body,
2425
+ createdAt: raw.createdAt
2426
+ },
2427
+ ...(raw.comments ?? []).map((comment) => ({
2428
+ kind: "issue-comment",
2429
+ author: login(comment.author),
2430
+ body: comment.body,
2431
+ createdAt: comment.createdAt
2432
+ }))
2433
+ ].sort(byCreatedAt2);
2434
+ return {
2435
+ issue: { number: raw.number, title: raw.title, body: raw.body, url: raw.url },
2436
+ state: raw.state === "CLOSED" ? "CLOSED" : "OPEN",
2437
+ assignees: (raw.assignees ?? []).map(login).filter((name) => name.length > 0),
2438
+ labels: (raw.labels ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
2439
+ messages
2440
+ };
2441
+ }
2442
+ var LINK_MAP_QUERY = `
2443
+ query($owner:String!,$repo:String!,$cursor:String){
2444
+ repository(owner:$owner,name:$repo){
2445
+ defaultBranchRef{ name }
2446
+ pullRequests(states:OPEN, first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}){
2447
+ pageInfo{ hasNextPage endCursor }
2448
+ nodes{
2449
+ number url title headRefName baseRefName isCrossRepository isDraft updatedAt
2450
+ closingIssuesReferences(first:50){
2451
+ pageInfo{ hasNextPage }
2452
+ nodes{ number repository{ nameWithOwner } }
2453
+ }
2454
+ }
2455
+ }
2456
+ }
2457
+ }`.trim();
2458
+ var MAX_LINK_MAP_PAGES = 50;
2459
+ function indexPullRequest(map, node, nameWithOwner) {
2460
+ const ref = {
2461
+ number: node.number,
2462
+ url: node.url,
2463
+ title: node.title,
2464
+ headRefName: node.headRefName,
2465
+ baseRefName: node.baseRefName,
2466
+ isCrossRepository: node.isCrossRepository,
2467
+ state: "OPEN",
2468
+ isDraft: node.isDraft,
2469
+ updatedAt: node.updatedAt
2470
+ };
2471
+ if (node.closingIssuesReferences.pageInfo?.hasNextPage) {
2472
+ throw new Error(
2473
+ `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.`
2474
+ );
2475
+ }
2476
+ for (const issue of node.closingIssuesReferences.nodes) {
2477
+ if (issue.repository.nameWithOwner !== nameWithOwner) continue;
2478
+ const existing = map.get(issue.number);
2479
+ if (existing) {
2480
+ existing.push(ref);
2481
+ } else {
2482
+ map.set(issue.number, [ref]);
2483
+ }
2484
+ }
2485
+ }
2486
+ function getOpenPrLinkMap() {
2487
+ const { owner, repo } = getRepoSlug();
2488
+ const map = /* @__PURE__ */ new Map();
2489
+ let defaultBranch = null;
2490
+ let cursor = null;
2491
+ for (let page = 0; page < MAX_LINK_MAP_PAGES; page++) {
2492
+ const args = ["api", "graphql", "-f", `query=${LINK_MAP_QUERY}`, "-f", `owner=${owner}`, "-f", `repo=${repo}`];
2493
+ if (cursor !== null) args.push("-f", `cursor=${cursor}`);
2494
+ const response = ghJson(args, "query open pull requests");
2495
+ defaultBranch = response.data.repository.defaultBranchRef?.name ?? defaultBranch;
2496
+ const connection = response.data.repository.pullRequests;
2497
+ for (const node of connection.nodes) {
2498
+ indexPullRequest(map, node, `${owner}/${repo}`);
2499
+ }
2500
+ if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
2501
+ return { byIssue: map, defaultBranch };
2502
+ }
2503
+ cursor = connection.pageInfo.endCursor;
2504
+ }
2505
+ throw new Error(
2506
+ `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.`
2507
+ );
2508
+ }
2509
+ var REVIEW_THREADS_QUERY2 = `
2510
+ query($owner:String!,$repo:String!,$prNumber:Int!,$cursor:String){
2511
+ repository(owner:$owner,name:$repo){
2512
+ pullRequest(number:$prNumber){
2513
+ reviewThreads(first:100, after:$cursor){
2514
+ pageInfo{ hasNextPage endCursor }
2515
+ nodes{
2516
+ isResolved isOutdated path line
2517
+ comments(last:100){
2518
+ pageInfo{ hasPreviousPage }
2519
+ nodes{ author{login} body createdAt url pullRequestReview{ submittedAt } }
2520
+ }
2521
+ }
2522
+ }
2523
+ }
2524
+ }
2525
+ }`.trim();
2526
+ var MAX_THREAD_PAGES = 50;
2527
+ function normalizePrState(state) {
2528
+ if (state === "MERGED") return "MERGED";
2529
+ if (state === "CLOSED") return "CLOSED";
2530
+ return "OPEN";
2531
+ }
2532
+ function getReviewThreads(prNumber) {
2533
+ const { owner, repo } = getRepoSlug();
2534
+ const threads = [];
2535
+ let cursor = null;
2536
+ for (let page = 0; page < MAX_THREAD_PAGES; page++) {
2537
+ const args = [
2538
+ "api",
2539
+ "graphql",
2540
+ "-f",
2541
+ `query=${REVIEW_THREADS_QUERY2}`,
2542
+ "-f",
2543
+ `owner=${owner}`,
2544
+ "-f",
2545
+ `repo=${repo}`,
2546
+ "-F",
2547
+ `prNumber=${String(prNumber)}`
2548
+ ];
2549
+ if (cursor !== null) args.push("-f", `cursor=${cursor}`);
2550
+ const response = ghJson(
2551
+ args,
2552
+ `query review threads for pull request #${String(prNumber)}`
2553
+ );
2554
+ const connection = response.data.repository.pullRequest.reviewThreads;
2555
+ for (const node of connection.nodes) {
2556
+ if (node.comments.pageInfo?.hasPreviousPage) {
2557
+ throw new Error(
2558
+ `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.`
2559
+ );
2560
+ }
2561
+ threads.push({
2562
+ path: node.path,
2563
+ line: node.line ?? null,
2564
+ isResolved: node.isResolved,
2565
+ url: node.comments.nodes.at(-1)?.url ?? null,
2566
+ comments: node.comments.nodes.map((comment) => ({
2567
+ kind: "thread-comment",
2568
+ author: login(comment.author),
2569
+ body: comment.body,
2570
+ // When the comment became *visible*, not when it was drafted.
2571
+ // GitHub stamps `createdAt` the moment a comment is added to a
2572
+ // pending review, and it only becomes visible when the review is
2573
+ // submitted — minutes later for a human working through a diff.
2574
+ // Using `createdAt` let an agent answer posted in between look newer
2575
+ // than the review, which marked every one of its threads answered
2576
+ // and discarded the whole review silently.
2577
+ createdAt: visibleAt(comment.createdAt, comment.pullRequestReview?.submittedAt)
2578
+ })).sort(byCreatedAt2)
2579
+ });
2580
+ }
2581
+ if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
2582
+ return threads;
2583
+ }
2584
+ cursor = connection.pageInfo.endCursor;
2585
+ }
2586
+ throw new Error(
2587
+ `Stopped paginating review threads for pull request #${String(prNumber)} after ${String(MAX_THREAD_PAGES)} pages; refusing rather than answering only part of the feedback.`
2588
+ );
2589
+ }
2590
+ function getPrSurface(prNumber) {
2591
+ const raw = ghJson(
2592
+ [
2593
+ "pr",
2594
+ "view",
2595
+ String(prNumber),
2596
+ "--json",
2597
+ "number,title,url,headRefName,baseRefName,isCrossRepository,state,isDraft,body,author,createdAt,updatedAt,comments,reviews"
2598
+ ],
2599
+ `read pull request #${String(prNumber)}`
2600
+ );
2601
+ const messages = [
2602
+ ...(raw.comments ?? []).map((comment) => ({
2603
+ kind: "pr-comment",
2604
+ author: login(comment.author),
2605
+ body: comment.body,
2606
+ createdAt: comment.createdAt
2607
+ })),
2608
+ // A review with no body carries no message — only its inline comments do,
2609
+ // and those arrive through the review-thread query.
2610
+ ...(raw.reviews ?? []).filter((review) => review.body.trim().length > 0).map((review) => ({
2611
+ kind: "pr-review",
2612
+ author: login(review.author),
2613
+ body: review.body,
2614
+ createdAt: review.submittedAt ?? review.createdAt ?? ""
2615
+ }))
2616
+ ].sort(byCreatedAt2);
2617
+ const threads = getReviewThreads(prNumber);
2618
+ const state = normalizePrState(raw.state);
2619
+ return {
2620
+ pr: {
2621
+ number: raw.number,
2622
+ url: raw.url,
2623
+ title: raw.title,
2624
+ headRefName: raw.headRefName,
2625
+ baseRefName: raw.baseRefName ?? "",
2626
+ isCrossRepository: raw.isCrossRepository ?? false,
2627
+ state,
2628
+ isDraft: raw.isDraft ?? false,
2629
+ updatedAt: raw.updatedAt ?? raw.createdAt
2630
+ },
2631
+ messages,
2632
+ threads
2633
+ };
2634
+ }
2635
+ function assignIssueToAgent(issueNumber, agentUser) {
2636
+ const { stderr, status } = run4("gh", [
2637
+ "issue",
2638
+ "edit",
2639
+ String(issueNumber),
2640
+ "--add-assignee",
2641
+ agentUser
2642
+ ]);
2643
+ if (status !== 0) {
2644
+ throw new Error(stderr.trim() || `Failed to assign issue #${String(issueNumber)} to ${agentUser}.`);
2645
+ }
2646
+ }
2647
+ function postMarker(_surface, number, body) {
2648
+ const { owner, repo } = getRepoSlug();
2649
+ const raw = ghJson(
2650
+ [
2651
+ "api",
2652
+ "--method",
2653
+ "POST",
2654
+ `repos/${owner}/${repo}/issues/${String(number)}/comments`,
2655
+ "-f",
2656
+ `body=${body}`
2657
+ ],
2658
+ `post a comment on #${String(number)}`
2659
+ );
2660
+ return { commentId: String(raw.id), createdAt: raw.created_at };
2661
+ }
2662
+ function updateMarker(marker, body) {
2663
+ const { owner, repo } = getRepoSlug();
2664
+ const { stderr, status } = run4("gh", [
2665
+ "api",
2666
+ "--method",
2667
+ "PATCH",
2668
+ `repos/${owner}/${repo}/issues/comments/${marker.commentId}`,
2669
+ "-f",
2670
+ `body=${body}`
2671
+ ]);
2672
+ if (status !== 0) {
2673
+ throw new Error(stderr.trim() || `Failed to update comment ${marker.commentId}.`);
2674
+ }
2675
+ }
2676
+ function deleteMarker(marker) {
2677
+ const { owner, repo } = getRepoSlug();
2678
+ const { stderr, status } = run4("gh", [
2679
+ "api",
2680
+ "--method",
2681
+ "DELETE",
2682
+ `repos/${owner}/${repo}/issues/comments/${marker.commentId}`
2683
+ ]);
2684
+ if (status !== 0) {
2685
+ if (/not found/i.test(stderr) || /HTTP 404/i.test(stderr)) return;
2686
+ throw new Error(stderr.trim() || `Failed to delete comment ${marker.commentId}.`);
2687
+ }
2688
+ }
2689
+
2690
+ // src/github/workDetection.ts
2691
+ function isAssignedToAgent(assignees, agentUser) {
2692
+ const agent = agentUser.toLowerCase();
2693
+ return assignees.some((name) => name.toLowerCase() === agent);
2694
+ }
2695
+ function findActionableThreads(threads, p, prLastAgentAt) {
2696
+ const actionable = [];
2697
+ for (const thread of threads) {
2698
+ if (thread.isResolved) continue;
2699
+ const comments = thread.comments.filter((comment) => classifyForThread(comment.author, p) !== "other");
2700
+ if (lastAuthorClass(comments, p) !== "authorized") continue;
2701
+ const newestAuthorized = comments.at(-1)?.createdAt;
2702
+ if (newestAuthorized !== void 0 && prLastAgentAt !== null && prLastAgentAt > newestAuthorized) {
2703
+ continue;
2704
+ }
2705
+ actionable.push({ ...thread, comments });
2706
+ }
2707
+ return actionable;
2708
+ }
2709
+ function classifyForThread(author, p) {
2710
+ const login2 = author.toLowerCase();
2711
+ if (login2 === p.agentUser.toLowerCase()) return "agent";
2712
+ return p.allowedUsers.some((user) => user.toLowerCase() === login2) ? "authorized" : "other";
2713
+ }
2714
+ function newestPr(prs) {
2715
+ return [...prs].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2716
+ }
2717
+ function plural(count, noun) {
2718
+ return `${String(count)} ${noun}${count === 1 ? "" : "s"}`;
2719
+ }
2720
+ function protectedHeads(policy) {
2721
+ return [policy.baseBranch, policy.defaultBranch ?? "", ...policy.protectedBranches ?? []].filter(
2722
+ (branch) => branch.length > 0
2723
+ );
2724
+ }
2725
+ function unsafeBranchSkip(pr, issue, policy) {
2726
+ if (pr.isCrossRepository) {
2727
+ return {
2728
+ kind: "skip",
2729
+ issue,
2730
+ reason: "unsafe-pr-branch",
2731
+ detail: `pull request #${String(pr.number)} comes from a fork; its head branch is not in this repository`
2732
+ };
2733
+ }
2734
+ if (protectedHeads(policy).includes(pr.headRefName)) {
2735
+ return {
2736
+ kind: "skip",
2737
+ issue,
2738
+ reason: "unsafe-pr-branch",
2739
+ detail: `pull request #${String(pr.number)} has a protected branch (${pr.headRefName}) as its head, so a turn would have to push to it`
2740
+ };
2741
+ }
2742
+ return null;
2743
+ }
2744
+ function decideWork(state, p, policy) {
2745
+ const baseBranch = policy.baseBranch;
2746
+ const { issueSurface, prSurface } = state;
2747
+ const issue = issueSurface.issue;
2748
+ if (issueSurface.state === "CLOSED") {
2749
+ return { kind: "skip", issue, reason: "issue-closed", detail: "the issue is closed" };
2750
+ }
2751
+ const issueAnalysis = analyzeSurface(issueSurface.messages, p);
2752
+ const needsAssignment = !isAssignedToAgent(issueSurface.assignees, p.agentUser);
2753
+ const openPrs = state.linkedPrs.filter((pr) => pr.state === "OPEN");
2754
+ const hasOpenPr = openPrs.length > 0 && prSurface !== null && prSurface.pr.state === "OPEN";
2755
+ if (!hasOpenPr) {
2756
+ if (!issueAnalysis.hasNewMessage) {
2757
+ return {
2758
+ kind: "skip",
2759
+ issue,
2760
+ reason: "no-new-messages",
2761
+ detail: issueAnalysis.lastAgentAt === null ? "no messages from authorized accounts" : `nothing new since the agent's message at ${issueAnalysis.lastAgentAt}`
2762
+ };
2763
+ }
2764
+ return {
2765
+ kind: "work",
2766
+ item: {
2767
+ issue,
2768
+ turn: "issue-discuss",
2769
+ pr: null,
2770
+ branch: baseBranch,
2771
+ needsAssignment,
2772
+ issueAnalysis,
2773
+ prAnalysis: null,
2774
+ actionableThreads: [],
2775
+ reason: `${plural(issueAnalysis.newMessageCount, "new issue message")}, no open pull request`,
2776
+ ambiguousPrs: []
2777
+ }
2778
+ };
2779
+ }
2780
+ const surface = prSurface;
2781
+ const unsafe = unsafeBranchSkip(surface.pr, issue, policy);
2782
+ if (unsafe !== null) return unsafe;
2783
+ const agentThreadMessages = surface.threads.flatMap((thread) => thread.comments).filter((comment) => comment.author.toLowerCase() === p.agentUser.toLowerCase());
2784
+ const prAnalysis = analyzeSurface([...surface.messages, ...agentThreadMessages], p);
2785
+ const actionableThreads = findActionableThreads(surface.threads, p, prAnalysis.lastAgentAt);
2786
+ const hasPrWork = prAnalysis.hasNewMessage || actionableThreads.length > 0;
2787
+ if (!hasPrWork && !issueAnalysis.hasNewMessage) {
2788
+ return {
2789
+ kind: "skip",
2790
+ issue,
2791
+ reason: "no-new-messages",
2792
+ detail: `nothing new on issue or pull request #${String(surface.pr.number)}`
2793
+ };
2794
+ }
2795
+ const reasons = [];
2796
+ if (prAnalysis.hasNewMessage) {
2797
+ reasons.push(plural(prAnalysis.newMessageCount, "new pull request message"));
2798
+ }
2799
+ if (actionableThreads.length > 0) {
2800
+ reasons.push(plural(actionableThreads.length, "unresolved review thread"));
2801
+ }
2802
+ if (issueAnalysis.hasNewMessage) {
2803
+ reasons.push(plural(issueAnalysis.newMessageCount, "new issue message"));
2804
+ }
2805
+ return {
2806
+ kind: "work",
2807
+ item: {
2808
+ issue,
2809
+ turn: "pr-work",
2810
+ pr: surface.pr,
2811
+ branch: surface.pr.headRefName,
2812
+ needsAssignment,
2813
+ issueAnalysis,
2814
+ prAnalysis,
2815
+ actionableThreads,
2816
+ reason: `${reasons.join(", ")} on pull request #${String(surface.pr.number)}`,
2817
+ // Several open PRs closing one issue is ambiguous rather than wrong; the
2818
+ // most recently updated one is used and the rest are reported.
2819
+ ambiguousPrs: openPrs.length > 1 ? openPrs.filter((pr) => pr.number !== surface.pr.number) : []
2820
+ }
2821
+ };
2822
+ }
2823
+ function selectLinkedPr(linkedPrs) {
2824
+ const open = linkedPrs.filter((pr) => pr.state === "OPEN");
2825
+ return open.length === 0 ? null : newestPr(open);
2826
+ }
2827
+
2828
+ // src/github/workPrompt.ts
2829
+ function formatThreads(threads) {
2830
+ return threads.map((thread) => {
2831
+ const location = thread.line === null ? `${thread.path}:(file)` : `${thread.path}:${String(thread.line)}`;
2832
+ const newest = thread.comments.at(-1);
2833
+ const author = newest?.author ?? "unknown";
2834
+ const body = newest?.body ?? "";
2835
+ const link = thread.url === null ? "" : `
2836
+ ${thread.url}`;
2837
+ return `[${author}] ${location}${link}
2838
+ ${body}`;
2839
+ }).join("\n\n");
2840
+ }
2841
+ function composePrompt(input) {
2842
+ const { item, repo, agentUser, baseBranch, frame } = input;
2843
+ const lines = [
2844
+ frame.trimEnd(),
2845
+ "",
2846
+ "--- Context assembled by automata ---",
2847
+ `Repository: ${repo.owner}/${repo.repo}`,
2848
+ `You are: ${agentUser}`,
2849
+ `Turn: ${item.turn}`,
2850
+ `Base branch: ${baseBranch}`,
2851
+ `Issue #${String(item.issue.number)}: ${item.issue.title}`,
2852
+ `Issue URL: ${item.issue.url}`
2853
+ ];
2854
+ if (item.pr) {
2855
+ lines.push(
2856
+ `Pull request #${String(item.pr.number)}: ${item.pr.title}`,
2857
+ `Pull request URL: ${item.pr.url}`,
2858
+ `Branch: ${item.pr.headRefName} (checked out and up to date)`
2859
+ );
2860
+ }
2861
+ const newMessages = [
2862
+ ...item.issueAnalysis.newMessages,
2863
+ ...item.prAnalysis?.newMessages ?? []
2864
+ ].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2865
+ if (newMessages.length > 0) {
2866
+ lines.push("", "New since your last message \u2014 this is what you must answer:", "", formatMessages(newMessages));
2867
+ }
2868
+ lines.push(
2869
+ "",
2870
+ "Full conversation on the issue (authorized accounts and you only, oldest first):",
2871
+ "",
2872
+ formatMessages(item.issueAnalysis.messages)
2873
+ );
2874
+ if (item.prAnalysis && item.prAnalysis.messages.length > 0) {
2875
+ lines.push(
2876
+ "",
2877
+ "Conversation on the pull request (authorized accounts and you only, oldest first):",
2878
+ "",
2879
+ formatMessages(item.prAnalysis.messages)
2880
+ );
2881
+ }
2882
+ if (item.actionableThreads.length > 0) {
2883
+ lines.push("", "Unresolved review threads needing an answer:", "", formatThreads(item.actionableThreads));
2884
+ }
2885
+ if (item.ambiguousPrs.length > 0) {
2886
+ const others = item.ambiguousPrs.map((pr) => `#${String(pr.number)}`).join(", ");
2887
+ lines.push(
2888
+ "",
2889
+ `Note: this issue is also closed by ${others}. You are working on #${String(item.pr?.number ?? 0)}, the most recently updated one.`
2890
+ );
2891
+ }
2892
+ lines.push(
2893
+ "",
2894
+ "Only the messages above exist. Anything from other accounts has been withheld",
2895
+ "deliberately \u2014 do not ask about it."
2896
+ );
2897
+ return lines.join("\n");
2898
+ }
2899
+
2900
+ // src/github/markerReconciliation.ts
2901
+ function isAuthorized(author, p) {
2902
+ const login2 = author.toLowerCase();
2903
+ return login2 !== p.agentUser.toLowerCase() && p.allowedUsers.some((user) => user.toLowerCase() === login2);
2904
+ }
2905
+ function promptWatermark(messageSets) {
2906
+ let watermark = null;
2907
+ for (const messages of messageSets) {
2908
+ for (const message of messages) {
2909
+ if (watermark === null || message.createdAt > watermark) watermark = message.createdAt;
2910
+ }
2911
+ }
2912
+ return watermark;
2913
+ }
2914
+ function messagesBetween(messages, p, watermark, until) {
2915
+ const since = watermark ?? "";
2916
+ return messages.filter(
2917
+ (message) => isAuthorized(message.author, p) && message.createdAt > since && message.createdAt <= until
2918
+ );
2919
+ }
2920
+ function analyseAnswer(messages, p, marker, watermark) {
2921
+ let answeredAt = null;
2922
+ for (const message of messages) {
2923
+ if (message.kind === "issue-body") continue;
2924
+ if (message.author.toLowerCase() !== p.agentUser.toLowerCase()) continue;
2925
+ if (message.createdAt <= marker.createdAt) continue;
2926
+ if (answeredAt === null || message.createdAt > answeredAt) answeredAt = message.createdAt;
2927
+ }
2928
+ const since = watermark ?? marker.createdAt;
2929
+ const toReport = messages.filter(
2930
+ (message) => isAuthorized(message.author, p) && message.createdAt > since
2931
+ );
2932
+ const missed = answeredAt === null ? [] : toReport.filter((message) => message.createdAt <= answeredAt);
2933
+ return { answeredAt, missed, toReport };
2934
+ }
2935
+
2936
+ // src/run/runLock.ts
2937
+ import { writeFileSync, readFileSync as readFileSync3, unlinkSync, mkdirSync, renameSync, linkSync, statSync } from "fs";
2938
+ import { randomUUID } from "crypto";
2939
+ import { hostname } from "os";
2940
+ import { join as join2 } from "path";
2941
+ var LOCK_DIR = ".automata";
2942
+ var LOCK_FILE = "automata.lock";
2943
+ var RUN_LOCK_RELATIVE_PATH = `${LOCK_DIR}/${LOCK_FILE}`;
2944
+ function lockPath() {
2945
+ return join2(process.cwd(), LOCK_DIR, LOCK_FILE);
2946
+ }
2947
+ function processStartedAt(pid) {
2948
+ try {
2949
+ const stat = readFileSync3(`/proc/${String(pid)}/stat`, "utf8");
2950
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
2951
+ const field = afterComm.split(" ")[19];
2952
+ return field === void 0 || field.length === 0 ? null : field;
2953
+ } catch {
2954
+ return null;
2955
+ }
2956
+ }
2957
+ function isAlive(pid) {
2958
+ try {
2959
+ process.kill(pid, 0);
2960
+ return true;
2961
+ } catch (err) {
2962
+ return err.code === "EPERM";
2963
+ }
2964
+ }
2965
+ function readOwner(path) {
2966
+ try {
2967
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
2968
+ if (typeof parsed.pid !== "number" || typeof parsed.startedAt !== "string") return null;
2969
+ return {
2970
+ pid: parsed.pid,
2971
+ startedAt: parsed.startedAt,
2972
+ host: parsed.host ?? "unknown",
2973
+ command: parsed.command ?? "unknown",
2974
+ token: parsed.token ?? "",
2975
+ pidStartedAt: parsed.pidStartedAt
2976
+ };
2977
+ } catch {
2978
+ return null;
2979
+ }
2980
+ }
2981
+ function isStale(owner, staleMinutes) {
2982
+ if (owner === null) return true;
2983
+ if (owner.host === hostname()) {
2984
+ if (!isAlive(owner.pid)) return true;
2985
+ const startedAt2 = processStartedAt(owner.pid);
2986
+ if (owner.pidStartedAt !== void 0 && startedAt2 !== null && startedAt2 !== owner.pidStartedAt) {
2987
+ return true;
2988
+ }
2989
+ return false;
2990
+ }
2991
+ const startedAt = Date.parse(owner.startedAt);
2992
+ if (Number.isNaN(startedAt)) return true;
2993
+ return Date.now() - startedAt > staleMinutes * 60 * 1e3;
2994
+ }
2995
+ function heldTooLong(owner, staleMinutes) {
2996
+ if (owner.host !== hostname()) return false;
2997
+ if (owner.pidStartedAt !== void 0 && processStartedAt(owner.pid) !== null) return false;
2998
+ const startedAt = Date.parse(owner.startedAt);
2999
+ if (Number.isNaN(startedAt)) return true;
3000
+ return Date.now() - startedAt > staleMinutes * 60 * 1e3;
3001
+ }
3002
+ function makeHandle(path, token) {
3003
+ let released = false;
3004
+ return {
3005
+ release() {
3006
+ if (released) return;
3007
+ released = true;
3008
+ const current = readOwner(path);
3009
+ if (current !== null && current.token !== token) {
3010
+ return;
3011
+ }
3012
+ const takenAway = `${path}.releasing.${token}`;
3013
+ try {
3014
+ renameSync(path, takenAway);
3015
+ } catch {
3016
+ return;
3017
+ }
3018
+ const owner = readOwner(takenAway);
3019
+ if (owner === null || owner.token === token) {
3020
+ try {
3021
+ unlinkSync(takenAway);
3022
+ } catch {
3023
+ }
3024
+ return;
3025
+ }
3026
+ try {
3027
+ linkSync(takenAway, path);
3028
+ } catch {
3029
+ }
3030
+ try {
3031
+ unlinkSync(takenAway);
3032
+ } catch {
3033
+ }
3034
+ }
3035
+ };
3036
+ }
3037
+ function publishLock(path, command, token) {
3038
+ const owner = {
3039
+ pid: process.pid,
3040
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3041
+ host: hostname(),
3042
+ command,
3043
+ token,
3044
+ pidStartedAt: processStartedAt(process.pid) ?? void 0
3045
+ };
3046
+ const staging = `${path}.staging.${token}`;
3047
+ try {
3048
+ writeFileSync(staging, JSON.stringify(owner, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
3049
+ } catch {
3050
+ return false;
3051
+ }
3052
+ try {
3053
+ linkSync(staging, path);
3054
+ return true;
3055
+ } catch (err) {
3056
+ if (err.code !== "EEXIST") throw err;
3057
+ return false;
3058
+ } finally {
3059
+ try {
3060
+ unlinkSync(staging);
3061
+ } catch {
3062
+ }
3063
+ }
3064
+ }
3065
+ function acquireRunLock(command, staleMinutes) {
3066
+ const path = lockPath();
3067
+ const token = randomUUID();
3068
+ mkdirSync(join2(process.cwd(), LOCK_DIR), { recursive: true });
3069
+ if (publishLock(path, command, token)) {
3070
+ return { ok: true, handle: makeHandle(path, token) };
3071
+ }
3072
+ const owner = readOwner(path);
3073
+ if (!isStale(owner, staleMinutes)) {
3074
+ const held = owner;
3075
+ return { ok: false, heldBy: held, suspect: heldTooLong(held, staleMinutes) };
3076
+ }
3077
+ return reclaim(path, command, token, owner);
3078
+ }
3079
+ var UNKNOWN_OWNER = {
3080
+ pid: 0,
3081
+ startedAt: "unknown",
3082
+ host: "unknown",
3083
+ command: "unknown",
3084
+ token: ""
3085
+ };
3086
+ function claimStaleLock(path, token, expected) {
3087
+ const claimed = `${path}.stale.${token}`;
3088
+ try {
3089
+ renameSync(path, claimed);
3090
+ } catch (err) {
3091
+ if (err.code === "ENOENT") return false;
3092
+ throw err;
3093
+ }
3094
+ if (expected !== void 0) {
3095
+ const taken = readOwner(claimed);
3096
+ const sameFile = expected === null && taken === null || expected !== null && taken !== null && taken.token === expected.token;
3097
+ if (!sameFile) {
3098
+ try {
3099
+ linkSync(claimed, path);
3100
+ } catch {
3101
+ }
3102
+ try {
3103
+ unlinkSync(claimed);
3104
+ } catch {
3105
+ }
3106
+ return false;
3107
+ }
3108
+ }
3109
+ return true;
3110
+ }
3111
+ var CLAIM_STALE_MS = 6e4;
3112
+ function acquireClaim(path) {
3113
+ const claimPath = `${path}.claim`;
3114
+ try {
3115
+ writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
3116
+ encoding: "utf8",
3117
+ flag: "wx"
3118
+ });
3119
+ return claimPath;
3120
+ } catch (err) {
3121
+ if (err.code !== "EEXIST") throw err;
3122
+ }
3123
+ if (!claimIsAbandoned(claimPath)) return null;
3124
+ try {
3125
+ renameSync(claimPath, `${claimPath}.abandoned.${String(process.pid)}`);
3126
+ } catch {
3127
+ return null;
3128
+ }
3129
+ try {
3130
+ unlinkSync(`${claimPath}.abandoned.${String(process.pid)}`);
3131
+ } catch {
3132
+ }
3133
+ try {
3134
+ writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
3135
+ encoding: "utf8",
3136
+ flag: "wx"
3137
+ });
3138
+ return claimPath;
3139
+ } catch {
3140
+ return null;
3141
+ }
3142
+ }
3143
+ function claimIsAbandoned(claimPath) {
3144
+ let at = Number.NaN;
3145
+ try {
3146
+ const raw = JSON.parse(readFileSync3(claimPath, "utf8"));
3147
+ if (raw.at !== void 0) at = Date.parse(raw.at);
3148
+ } catch {
3149
+ }
3150
+ if (Number.isNaN(at)) {
3151
+ try {
3152
+ at = statSync(claimPath).mtimeMs;
3153
+ } catch {
3154
+ return true;
3155
+ }
3156
+ }
3157
+ return Date.now() - at >= CLAIM_STALE_MS;
3158
+ }
3159
+ function reclaim(path, command, token, expected) {
3160
+ const claimPath = acquireClaim(path);
3161
+ if (claimPath === null) {
3162
+ const owner = readOwner(path);
3163
+ const ownerDead = owner !== null && owner.host === hostname() && !isAlive(owner.pid);
3164
+ return { ok: false, heldBy: owner ?? UNKNOWN_OWNER, suspect: ownerDead };
3165
+ }
3166
+ try {
3167
+ if (!claimStaleLock(path, token, expected)) {
3168
+ return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
3169
+ }
3170
+ const claimed = `${path}.stale.${token}`;
3171
+ try {
3172
+ if (publishLock(path, command, token)) {
3173
+ return { ok: true, handle: makeHandle(path, token) };
3174
+ }
3175
+ return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
3176
+ } finally {
3177
+ try {
3178
+ unlinkSync(claimed);
3179
+ } catch {
3180
+ }
3181
+ }
3182
+ } finally {
3183
+ try {
3184
+ unlinkSync(claimPath);
3185
+ } catch {
3186
+ }
3187
+ }
3188
+ }
3189
+
3190
+ // src/git/workspaceService.ts
3191
+ function dirtyTree() {
3192
+ return {
3193
+ ok: false,
3194
+ reason: "dirty-tree",
3195
+ detail: "the working tree has uncommitted changes; commit or stash them yourself and re-run"
3196
+ };
3197
+ }
3198
+ function prepareBaseBranch(baseBranch) {
3199
+ if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
3200
+ const checkout = checkoutBranch(baseBranch);
3201
+ if (!checkout.ok) {
3202
+ return { ok: false, reason: "checkout-failed", detail: checkout.stderr };
3203
+ }
3204
+ const pull = pullFastForwardOnly();
3205
+ if (!pull.ok) {
3206
+ return { ok: false, reason: "pull-failed", detail: pull.stderr };
3207
+ }
3208
+ return { ok: true, branch: baseBranch };
3209
+ }
3210
+ function preparePrBranch(headRefName) {
3211
+ if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
3212
+ const fetched = fetchBranch(headRefName);
3213
+ if (!fetched.ok) {
3214
+ return { ok: false, reason: "checkout-failed", detail: fetched.stderr };
3215
+ }
3216
+ const checkout = checkoutBranch(headRefName);
3217
+ if (!checkout.ok) {
3218
+ const created = createTrackingBranch(headRefName);
3219
+ if (!created.ok) {
3220
+ return { ok: false, reason: "checkout-failed", detail: created.stderr };
3221
+ }
3222
+ return { ok: true, branch: headRefName };
3223
+ }
3224
+ const pull = pullFastForwardOnly(headRefName);
3225
+ if (!pull.ok) {
3226
+ return { ok: false, reason: "pull-failed", detail: pull.stderr };
3227
+ }
3228
+ return { ok: true, branch: headRefName };
3229
+ }
3230
+
3231
+ // src/commands/doWork.ts
3232
+ var inFlightMarker = null;
3233
+ function out(message) {
3234
+ process.stdout.write(message);
3235
+ }
3236
+ function progress(message) {
3237
+ process.stderr.write(message);
3238
+ }
3239
+ function fail(message) {
3240
+ process.stderr.write(`Error: ${message}
3241
+ `);
3242
+ process.exit(1);
3243
+ }
3244
+ function parsePositiveInt(value, label) {
3245
+ const trimmed = value.trim();
3246
+ if (!/^\d+$/.test(trimmed)) {
3247
+ fail(`${label} must be a positive integer (got "${value}").`);
3248
+ }
3249
+ const parsed = Number(trimmed);
3250
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
3251
+ fail(`${label} must be a positive integer within the safe range (got "${value}").`);
3252
+ }
3253
+ return parsed;
3254
+ }
3255
+ function resolveSettings(options) {
3256
+ let config;
3257
+ try {
3258
+ config = readConfig();
3259
+ } catch (err) {
3260
+ fail(err.message);
3261
+ }
3262
+ if (config.remoteType !== "gh") {
3263
+ fail(
3264
+ "do-work is only supported for GitHub remotes. Set it with `automata config set type gh`. Azure DevOps lacks the issue conversation APIs this needs \u2014 see docs/azdo-gap.md."
3265
+ );
3266
+ }
3267
+ if (!config.issueDiscoveryTechnique) {
3268
+ fail("No issue discovery technique configured. Run `automata config set issue-discovery-technique <value>`.");
3269
+ }
3270
+ if (!config.issueDiscoveryValue) {
3271
+ fail("No issue discovery value configured. Run `automata config set issue-discovery-value <value>`.");
3272
+ }
3273
+ const allowedUsers = (config.allowedUsers ?? []).filter((user) => user.trim().length > 0);
3274
+ if (allowedUsers.length === 0) {
3275
+ fail("No allowed users configured. Run `automata config set allowed-users <user1,user2>`.");
3276
+ }
3277
+ const agentUser = (config.agentUser ?? "").trim();
3278
+ if (agentUser.length === 0) {
3279
+ fail("No agent user configured. Run `automata config set agent-user <login>`.");
3280
+ }
3281
+ validateDoWorkConfig(config.doWork);
3282
+ const doWork = config.doWork ?? {};
3283
+ let executor = doWork.executor ?? DEFAULT_DO_WORK.executor;
3284
+ if (options.with !== void 0) {
3285
+ const requested = options.with.toLowerCase();
3286
+ if (requested !== "claude" && requested !== "codex") {
3287
+ fail(`--with must be 'claude' or 'codex', got '${options.with}'.`);
3288
+ }
3289
+ executor = requested;
3290
+ }
3291
+ if (options.dryRun !== true) {
3292
+ checkAuthenticatedIdentity(agentUser, allowedUsers);
3293
+ }
3294
+ return {
3295
+ baseBranch: doWork.baseBranch ?? DEFAULT_DO_WORK.baseBranch,
3296
+ protectedBranches: doWork.protectedBranches ?? DEFAULT_DO_WORK.protectedBranches,
3297
+ executor,
3298
+ // --model wins; otherwise take the default for the executor in use.
3299
+ model: options.model ?? doWork.models?.[executor],
3300
+ maxRuns: options.maxRuns !== void 0 ? parsePositiveInt(options.maxRuns, "--max-runs") : doWork.maxRunsPerTick ?? DEFAULT_DO_WORK.maxRunsPerTick,
3301
+ lockStaleMinutes: doWork.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes,
3302
+ limit: parsePositiveInt(options.limit, "--limit"),
3303
+ participants: { allowedUsers, agentUser },
3304
+ prompts: {
3305
+ "issue-discuss": doWork.prompts?.issueDiscuss ?? DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT,
3306
+ "pr-work": doWork.prompts?.prWork ?? DEFAULT_DO_WORK_PR_WORK_PROMPT
3307
+ },
3308
+ technique: config.issueDiscoveryTechnique,
3309
+ discoveryValue: config.issueDiscoveryValue,
3310
+ onlyIssue: options.issue === void 0 ? void 0 : parsePositiveInt(options.issue, "--issue")
3311
+ };
3312
+ }
3313
+ function checkAuthenticatedIdentity(agentUser, allowedUsers) {
3314
+ const login2 = getAuthenticatedLogin();
3315
+ if (login2 === null) {
3316
+ progress(
3317
+ `Warning: could not determine which account \`gh\` is authenticated as; assuming it is the agent (${agentUser}).
3318
+ `
3319
+ );
3320
+ return;
3321
+ }
3322
+ if (login2.toLowerCase() === agentUser.toLowerCase()) return;
3323
+ if (allowedUsers.some((user) => user.toLowerCase() === login2.toLowerCase())) {
3324
+ fail(
3325
+ `\`gh\` is authenticated as "${login2}", which is listed in allowedUsers. Everything do-work posts would be attributed to an account that is allowed to instruct the agent, so its own marker comment would look like a new instruction and each tick would answer the previous tick forever. Authenticate \`gh\` as the agent account (${agentUser}) in this environment, or correct \`agentUser\`.`
3326
+ );
3327
+ }
3328
+ fail(
3329
+ `\`gh\` is authenticated as "${login2}" but agentUser is "${agentUser}". Comments posted under that identity are neither the agent's nor an authorized user's, so they are filtered out of the conversation: the answer boundary would never advance and the same message would start a run on every tick. Authenticate \`gh\` as the agent account (${agentUser}) in this environment, or correct \`agentUser\`.`
3330
+ );
3331
+ }
3332
+ function planRun(item, settings) {
3333
+ const prompt = composePrompt({
3334
+ item,
3335
+ repo: getRepoSlug(),
3336
+ agentUser: settings.participants.agentUser,
3337
+ baseBranch: settings.baseBranch,
3338
+ frame: settings.prompts[item.turn]
3339
+ });
3340
+ const bin = resolveCommand(settings.executor === "codex" ? "codex" : "claude");
3341
+ const args = settings.executor === "codex" ? buildCodexArgs(prompt, { yolo: true, model: settings.model }) : buildClaudeArgs(prompt, { yolo: true, verbose: true, model: settings.model });
3342
+ return { prompt, bin, args, command: [bin, ...args].map(shellQuote).join(" ") };
3343
+ }
3344
+ function describePlannedRun(item, settings, run5) {
3345
+ const rule = "\u2500".repeat(72);
3346
+ const branchAction = item.turn === "pr-work" ? " and fast-forward" : " and pull";
3347
+ const assignment = item.needsAssignment ? `would assign to ${settings.participants.agentUser}` : "already assigned";
3348
+ const markerTarget = item.turn === "pr-work" && item.pr ? `pull request #${String(item.pr.number)}` : `issue #${String(item.issue.number)}`;
3349
+ const modelNote = settings.model === void 0 ? " (no model override)" : ` \xB7 model ${settings.model}`;
3350
+ const lines = [
3351
+ rule,
3352
+ `Issue #${String(item.issue.number)} \u2014 ${item.issue.title}`,
3353
+ rule,
3354
+ ` Turn ${item.turn}`,
3355
+ ` Why ${item.reason}`,
3356
+ ` Branch ${item.branch} (would check out${branchAction})`,
3357
+ ` Assign ${assignment}`,
3358
+ ` Marker would post on ${markerTarget}`,
3359
+ ` Executor ${settings.executor}${modelNote}`,
3360
+ " Permissions bypassed (do-work always runs unattended)",
3361
+ ` Prompt ${String(run5.prompt.length)} chars \u2014 frame + assembled context`,
3362
+ "",
3363
+ " Command that would be launched:",
3364
+ // Printed flush-left and unindented on purpose: the prompt is a multi-line
3365
+ // quoted argument, so indenting the continuation lines would inject leading
3366
+ // whitespace into the prompt itself and the command would no longer be the
3367
+ // one that runs.
3368
+ rule,
3369
+ run5.command,
3370
+ rule,
3371
+ ""
3372
+ ];
3373
+ return lines.join("\n") + "\n";
3374
+ }
3375
+ function isPlainObject(value) {
3376
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3377
+ }
3378
+ function validateOptionalString(container, key, path) {
3379
+ const value = container[key];
3380
+ if (value === void 0 || value === null) return;
3381
+ if (typeof value !== "string" || value.trim().length === 0) {
3382
+ fail(`${path} must be a non-empty string.`);
3383
+ }
3384
+ }
3385
+ function validateOptionalInt(container, key, path, min, hint) {
3386
+ const value = container[key];
3387
+ if (value === void 0 || value === null) return;
3388
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) {
3389
+ fail(`${path} must be ${hint}, got ${JSON.stringify(value)}.`);
3390
+ }
3391
+ }
3392
+ function validateDoWorkConfig(section) {
3393
+ if (section === void 0 || section === null) return;
3394
+ if (!isPlainObject(section)) {
3395
+ fail(`doWork must be an object, got ${JSON.stringify(section)}.`);
3396
+ }
3397
+ const executor = section["executor"];
3398
+ if (executor !== void 0 && executor !== "claude" && executor !== "codex") {
3399
+ fail(`doWork.executor must be 'claude' or 'codex', got ${JSON.stringify(executor)}.`);
3400
+ }
3401
+ validateOptionalString(section, "baseBranch", "doWork.baseBranch");
3402
+ validateOptionalInt(section, "maxRunsPerTick", "doWork.maxRunsPerTick", 0, "a non-negative integer (0 = unlimited)");
3403
+ validateOptionalInt(section, "lockStaleMinutes", "doWork.lockStaleMinutes", 1, "a positive integer");
3404
+ const protectedBranches = section["protectedBranches"];
3405
+ if (protectedBranches !== void 0 && protectedBranches !== null) {
3406
+ if (!Array.isArray(protectedBranches) || protectedBranches.some((b) => typeof b !== "string" || b.trim().length === 0)) {
3407
+ fail("doWork.protectedBranches must be an array of non-empty strings.");
3408
+ }
3409
+ }
3410
+ for (const container of ["models", "prompts"]) {
3411
+ const value = section[container];
3412
+ if (value === void 0 || value === null) continue;
3413
+ if (!isPlainObject(value)) {
3414
+ fail(`doWork.${container} must be an object, got ${JSON.stringify(value)}.`);
3415
+ }
3416
+ const keys = container === "models" ? ["claude", "codex"] : ["issueDiscuss", "prWork"];
3417
+ for (const key of keys) {
3418
+ validateOptionalString(value, key, `doWork.${container}.${key}`);
3419
+ }
3420
+ for (const key of Object.keys(value)) {
3421
+ if (!keys.includes(key)) {
3422
+ fail(`doWork.${container}.${key} is not a recognised setting; expected one of: ${keys.join(", ")}.`);
3423
+ }
3424
+ }
3425
+ }
3426
+ }
3427
+ function discoverIssues(settings) {
3428
+ const candidates = listCandidateIssues(settings.technique, settings.discoveryValue, settings.limit);
3429
+ if (settings.onlyIssue === void 0) {
3430
+ if (candidates.length === settings.limit) {
3431
+ progress(
3432
+ `Note: fetched the maximum of ${String(settings.limit)} issues \u2014 there may be more. Use --limit to raise it.
3433
+ `
3434
+ );
3435
+ }
3436
+ return candidates;
3437
+ }
3438
+ const match = candidates.find((issue) => issue.number === settings.onlyIssue);
3439
+ if (match) return [match];
3440
+ const surface = getIssueSurface(settings.onlyIssue);
3441
+ if (!issueMatchesFilter(surface, settings)) {
3442
+ progress(
3443
+ `Note: issue #${String(settings.onlyIssue)} does not match the configured discovery filter (${settings.technique} = ${settings.discoveryValue}); processing it anyway because --issue was given.
3444
+ `
3445
+ );
3446
+ }
3447
+ return [surface.issue];
3448
+ }
3449
+ function issueMatchesFilter(surface, settings) {
3450
+ const value = settings.discoveryValue.toLowerCase();
3451
+ switch (settings.technique) {
3452
+ case "label":
3453
+ return surface.labels.some((label) => label.toLowerCase() === value);
3454
+ case "assignee":
3455
+ return surface.assignees.some((assignee) => assignee.toLowerCase() === value);
3456
+ case "title-contains":
3457
+ return surface.issue.title.toLowerCase().includes(value);
3458
+ }
3459
+ }
3460
+ function buildIssueState(issue, linkMap) {
3461
+ const issueSurface = getIssueSurface(issue.number);
3462
+ const linkedPrs = linkMap.byIssue.get(issue.number) ?? [];
3463
+ const selected = selectLinkedPr(linkedPrs);
3464
+ return {
3465
+ issueSurface,
3466
+ linkedPrs,
3467
+ prSurface: selected === null ? null : getPrSurface(selected.number)
3468
+ };
3469
+ }
3470
+ function describePlan(decisions) {
3471
+ const lines = decisions.map((decision) => {
3472
+ if (decision.kind === "skip") {
3473
+ return ` #${String(decision.issue.number)} nothing to do \u2014 ${decision.detail}`;
3474
+ }
3475
+ const item = decision.item;
3476
+ const claim = item.needsAssignment ? ", will assign to the agent" : "";
3477
+ return ` #${String(item.issue.number)} ${item.turn} on ${item.branch} \u2014 ${item.reason}${claim}`;
3478
+ });
3479
+ return lines.length === 0 ? " (no issues matched the discovery filter)\n" : lines.join("\n") + "\n";
3480
+ }
3481
+ function claimIssue(item, settings) {
3482
+ if (!item.needsAssignment) return;
3483
+ try {
3484
+ assignIssueToAgent(item.issue.number, settings.participants.agentUser);
3485
+ progress(` assigned issue #${String(item.issue.number)} to ${settings.participants.agentUser}.
3486
+ `);
3487
+ } catch (err) {
3488
+ progress(` warning: could not assign issue #${String(item.issue.number)}: ${err.message}
3489
+ `);
3490
+ }
3491
+ }
3492
+ function notePickupOnIssue(item, marker) {
3493
+ if (item.turn !== "pr-work" || item.pr === null || !item.issueAnalysis.hasNewMessage) return null;
3494
+ try {
3495
+ return postMarker(
3496
+ "issue",
3497
+ item.issue.number,
3498
+ `automata do-work: picked this up on pull request #${String(item.pr.number)} \u2014 ${item.pr.url}`
3499
+ );
3500
+ } catch (err) {
3501
+ progress(` skipped: could not note the pickup on issue #${String(item.issue.number)} \u2014 ${err.message}
3502
+ `);
3503
+ try {
3504
+ deleteMarker(marker);
3505
+ } catch (deleteErr) {
3506
+ progress(` warning: could not withdraw the working marker: ${deleteErr.message}
3507
+ `);
3508
+ }
3509
+ return false;
3510
+ }
3511
+ }
3512
+ function reportIssueMessagesBuriedByNote(item, note, participants) {
3513
+ const watermark = promptWatermark([item.issueAnalysis.messages]);
3514
+ let buried;
3515
+ try {
3516
+ const messages = getIssueSurface(item.issue.number).messages;
3517
+ buried = messagesBetween(messages, participants, watermark, note.createdAt);
3518
+ } catch (err) {
3519
+ progress(` warning: could not re-read issue #${String(item.issue.number)}: ${err.message}
3520
+ `);
3521
+ return 0;
3522
+ }
3523
+ if (buried.length === 0) return 0;
3524
+ const authors = [...new Set(buried.map((message) => message.author))].join(", ");
3525
+ try {
3526
+ postMarker(
3527
+ "issue",
3528
+ item.issue.number,
3529
+ `automata do-work: ${authors} posted here while this issue was being picked up, so ${buried.length === 1 ? "that message was" : "those messages were"} not included in the run. Please post again to have them acted on.`
3530
+ );
3531
+ } catch (err) {
3532
+ progress(` warning: could not report the buried issue message(s): ${err.message}
3533
+ `);
3534
+ }
3535
+ return buried.length;
3536
+ }
3537
+ function refreshItem(item, settings) {
3538
+ const linkMap = getOpenPrLinkMap();
3539
+ return decideWork(buildIssueState(item.issue, linkMap), settings.participants, {
3540
+ baseBranch: settings.baseBranch,
3541
+ defaultBranch: linkMap.defaultBranch,
3542
+ protectedBranches: settings.protectedBranches
3543
+ });
3544
+ }
3545
+ function readAnsweringSurface(item) {
3546
+ if (item.turn === "issue-discuss" || item.pr === null) {
3547
+ return getIssueSurface(item.issue.number).messages;
3548
+ }
3549
+ const surface = getPrSurface(item.pr.number);
3550
+ return [...surface.messages, ...surface.threads.flatMap((thread) => thread.comments)];
3551
+ }
3552
+ function markerSurfaceLabel(item) {
3553
+ return item.turn === "pr-work" && item.pr ? `pull request #${String(item.pr.number)}` : `issue #${String(item.issue.number)}`;
3554
+ }
3555
+ function markerSurfaceTarget(item) {
3556
+ return item.turn === "pr-work" && item.pr ? { surface: "pr", number: item.pr.number } : { surface: "issue", number: item.issue.number };
3557
+ }
3558
+ function reportOvertakenMessages(item, analysis) {
3559
+ if (analysis.missed.length === 0) return;
3560
+ const authors = [...new Set(analysis.toReport.map((message) => message.author))].join(", ");
3561
+ const count = analysis.toReport.length;
3562
+ const target = markerSurfaceTarget(item);
3563
+ try {
3564
+ postMarker(
3565
+ target.surface,
3566
+ target.number,
3567
+ `automata do-work: ${authors} posted here while this run was already in progress, so ${count === 1 ? "that message was" : "those messages were"} not included in it. Please post again to have them acted on.`
3568
+ );
3569
+ } catch (err) {
3570
+ progress(` warning: could not report the overtaken message(s): ${err.message}
3571
+ `);
3572
+ }
3573
+ }
3574
+ function reconcileMarker(item, marker, participants, watermark, runError) {
3575
+ let analysis;
3576
+ try {
3577
+ analysis = analyseAnswer(readAnsweringSurface(item), participants, marker, watermark);
3578
+ } catch (err) {
3579
+ progress(
3580
+ ` warning: could not re-read issue #${String(item.issue.number)} to check for an answer: ${err.message}
3581
+ `
3582
+ );
3583
+ return reportUnverified(item, marker);
3584
+ }
3585
+ if (analysis.answeredAt !== null) {
3586
+ reportOvertakenMessages(item, analysis);
3587
+ try {
3588
+ deleteMarker(marker);
3589
+ } catch (err) {
3590
+ progress(` warning: could not delete the marker comment: ${err.message}
3591
+ `);
3592
+ }
3593
+ if (analysis.missed.length > 0) {
3594
+ return {
3595
+ outcome: "answered-no-reply",
3596
+ detail: `answered (${String(analysis.toReport.length)} message(s) arrived mid-run and were flagged)`,
3597
+ reason: "flagged"
3598
+ };
3599
+ }
3600
+ return runError === null ? { outcome: "answered", detail: "answered", reason: "answered" } : { outcome: "answered", detail: `answered, but the run reported: ${runError.message}`, reason: "answered" };
3601
+ }
3602
+ return reportNoAnswer(item, marker, runError);
3603
+ }
3604
+ function reportUnverified(item, marker) {
3605
+ const surface = markerSurfaceLabel(item);
3606
+ try {
3607
+ updateMarker(
3608
+ marker,
3609
+ `automata do-work: the agent run finished, but automata could not read ${surface} afterwards to confirm whether an answer was posted. Check this thread and the branch before assuming either. Reply here to have another attempt made.`
3610
+ );
3611
+ } catch (err) {
3612
+ progress(` warning: could not update the marker comment: ${err.message}
3613
+ `);
3614
+ }
3615
+ return {
3616
+ outcome: "answered-no-reply",
3617
+ detail: "could not verify whether an answer was posted",
3618
+ reason: "unverified"
3619
+ };
3620
+ }
3621
+ function reportNoAnswer(item, marker, runError) {
3622
+ const surface = markerSurfaceLabel(item);
3623
+ const sideEffects = item.turn === "issue-discuss" ? "It may still have created a branch or opened a pull request \u2014 check before assuming otherwise." : `It may still have changed the branch \`${item.branch}\` \u2014 check it before assuming otherwise.`;
3624
+ const explanation = runError === null ? `automata do-work: the agent run finished without posting an answer on ${surface}. ${sideEffects} Reply on ${surface} to have another attempt made.` : `automata do-work: the agent run failed before posting an answer (${runError.message}). ${sideEffects} Reply on ${surface} to have another attempt made.`;
3625
+ try {
3626
+ updateMarker(marker, explanation);
3627
+ } catch (err) {
3628
+ progress(
3629
+ ` warning: could not update the marker comment on issue #${String(item.issue.number)}: ${err.message}
3630
+ the humans have not been told that this run produced no answer.
3631
+ `
3632
+ );
3633
+ }
3634
+ return runError === null ? { outcome: "answered-no-reply", detail: "run finished but posted no answer", reason: "no-answer" } : { outcome: "failed", detail: `run failed: ${runError.message}`, reason: "no-answer" };
3635
+ }
3636
+ async function invokeExecutor(prompt, settings, silent) {
3637
+ if (settings.executor === "codex") {
3638
+ await runCodex(prompt, { model: settings.model });
3639
+ return;
3640
+ }
3641
+ await runClaude(prompt, { model: settings.model, printSteps: !silent });
3642
+ }
3643
+ function repairIssueLink(item, baseBranch) {
3644
+ try {
3645
+ const branch = getCurrentBranch();
3646
+ if (branch === baseBranch) {
3647
+ progress(` issue #${String(item.issue.number)} is still in discussion (no branch was created).
3648
+ `);
3649
+ return false;
3650
+ }
3651
+ const pr = getCurrentBranchPr();
3652
+ if (!pr) {
3653
+ progress(` issue #${String(item.issue.number)} is still in discussion (no pull request).
3654
+ `);
3655
+ return false;
3656
+ }
3657
+ const closesRef = new RegExp(String.raw`\bcloses\s+#` + String(item.issue.number) + String.raw`\b`, "i");
3658
+ if (closesRef.test(pr.body)) {
3659
+ progress(` pull request #${String(pr.number)} already closes issue #${String(item.issue.number)}.
3660
+ `);
3661
+ return true;
3662
+ }
3663
+ addClosesRefToPr(pr.number, item.issue.number);
3664
+ progress(` linked pull request #${String(pr.number)} to issue #${String(item.issue.number)}.
3665
+ `);
3666
+ return true;
3667
+ } catch (err) {
3668
+ progress(` warning: could not link a pull request to issue #${String(item.issue.number)}: ${err.message}
3669
+ `);
3670
+ return false;
3671
+ }
3672
+ }
3673
+ async function processItem(planned, settings, silent) {
3674
+ progress(`
3675
+ #${String(planned.issue.number)} ${planned.turn}: ${planned.reason}
3676
+ `);
3677
+ const refreshed = refreshItem(planned, settings);
3678
+ if (refreshed.kind === "skip") {
3679
+ progress(` skipped: ${refreshed.detail}
3680
+ `);
3681
+ return {
3682
+ issue: planned.issue.number,
3683
+ title: planned.issue.title,
3684
+ turn: planned.turn,
3685
+ outcome: "skipped",
3686
+ detail: `no longer actionable: ${refreshed.detail}`
3687
+ };
3688
+ }
3689
+ const item = refreshed.item;
3690
+ if (item.turn !== planned.turn) {
3691
+ progress(` turn changed to ${item.turn} since the plan was built; using the current state.
3692
+ `);
3693
+ }
3694
+ const base = {
3695
+ issue: item.issue.number,
3696
+ title: item.issue.title,
3697
+ turn: item.turn
3698
+ };
3699
+ const prepared = item.turn === "issue-discuss" ? prepareBaseBranch(item.branch) : preparePrBranch(item.branch);
3700
+ if (!prepared.ok) {
3701
+ progress(` skipped: ${prepared.reason} \u2014 ${prepared.detail}
3702
+ `);
3703
+ return { ...base, outcome: "skipped", detail: `${prepared.reason}: ${prepared.detail}` };
3704
+ }
3705
+ claimIssue(item, settings);
3706
+ let marker;
3707
+ const markerSurface = item.turn === "pr-work" && item.pr ? item.pr.number : item.issue.number;
3708
+ try {
3709
+ marker = postMarker(item.turn === "pr-work" ? "pr" : "issue", markerSurface, "automata do-work: working\u2026");
3710
+ } catch (err) {
3711
+ progress(` skipped: could not post the working marker \u2014 ${err.message}
3712
+ `);
3713
+ return { ...base, outcome: "skipped", detail: `marker failed: ${err.message}` };
3714
+ }
3715
+ inFlightMarker = { marker, item };
3716
+ const note = notePickupOnIssue(item, marker);
3717
+ if (note === false) {
3718
+ inFlightMarker = null;
3719
+ return { ...base, outcome: "skipped", detail: "issue pickup note failed" };
3720
+ }
3721
+ let buriedByNote = 0;
3722
+ if (note !== null) {
3723
+ progress(` noted on issue #${String(item.issue.number)} that the work is on pull request #${String(item.pr?.number ?? 0)}.
3724
+ `);
3725
+ buriedByNote = reportIssueMessagesBuriedByNote(item, note, settings.participants);
3726
+ }
3727
+ const watermark = promptWatermark([
3728
+ item.issueAnalysis.messages,
3729
+ item.prAnalysis?.messages ?? [],
3730
+ item.actionableThreads.flatMap((thread) => thread.comments)
3731
+ ]);
3732
+ const prompt = composePrompt({
3733
+ item,
3734
+ repo: getRepoSlug(),
3735
+ agentUser: settings.participants.agentUser,
3736
+ baseBranch: settings.baseBranch,
3737
+ frame: settings.prompts[item.turn]
3738
+ });
3739
+ const MAX_PROMPT_BYTES = 96 * 1024;
3740
+ const promptBytes = Buffer.byteLength(prompt, "utf8");
3741
+ if (promptBytes > MAX_PROMPT_BYTES) {
3742
+ const detail = `the composed prompt is ${String(Math.round(promptBytes / 1024))} KiB, over the ${String(MAX_PROMPT_BYTES / 1024)} KiB limit for a single command-line argument. The conversation is too long to hand to the executor this way.`;
3743
+ progress(` failed: ${detail}
3744
+ `);
3745
+ inFlightMarker = null;
3746
+ try {
3747
+ updateMarker(
3748
+ marker,
3749
+ `automata do-work: could not start a run because ${detail} Summarise the discussion in a new issue, or shorten the thread, and try again.`
3750
+ );
3751
+ } catch (err) {
3752
+ progress(` warning: could not update the marker comment: ${err.message}
3753
+ `);
3754
+ }
3755
+ return { ...base, outcome: "failed", detail };
3756
+ }
3757
+ let runError = null;
3758
+ try {
3759
+ await invokeExecutor(prompt, settings, silent);
3760
+ } catch (err) {
3761
+ runError = err;
3762
+ }
3763
+ const ranExecutor = true;
3764
+ inFlightMarker = null;
3765
+ const reconciled = reconcileMarker(item, marker, settings.participants, watermark, runError);
3766
+ progress(` ${reconciled.detail}
3767
+ `);
3768
+ let outcome = reconciled;
3769
+ if (buriedByNote > 0 && outcome.outcome === "answered") {
3770
+ outcome = {
3771
+ outcome: "answered-no-reply",
3772
+ detail: `${outcome.detail} (${String(buriedByNote)} issue message(s) were buried by the pickup note and flagged)`,
3773
+ reason: "flagged"
3774
+ };
3775
+ }
3776
+ if (item.turn === "issue-discuss") {
3777
+ const linked = repairIssueLink(item, settings.baseBranch);
3778
+ if (linked && outcome.reason === "no-answer") {
3779
+ outcome = {
3780
+ outcome: "answered",
3781
+ detail: "opened a pull request (no issue comment)",
3782
+ reason: "answered"
3783
+ };
3784
+ progress(" a pull request was opened, so the turn is counted as answered.\n");
3785
+ }
3786
+ }
3787
+ return { ...base, outcome: outcome.outcome, detail: outcome.detail, ranExecutor };
3788
+ }
3789
+ function summarize(reports) {
3790
+ out("\nTick summary:\n");
3791
+ if (reports.length === 0) {
3792
+ out(" nothing to do\n");
3793
+ return;
3794
+ }
3795
+ for (const report of reports) {
3796
+ out(` #${String(report.issue)} ${report.turn ?? "-"} ${report.outcome} \u2014 ${report.detail}
3797
+ `);
3798
+ }
3799
+ }
3800
+ var doWorkCommand = new Command6("do-work").description(
3801
+ "Run one tick of the autonomous loop: find the issues whose newest authorized message the agent has not answered, and answer them"
3802
+ ).option("--with <executor>", "Executor to use: claude or codex (default: from config, else claude)").option("--model <string>", "Model identifier to pass to the executor, overriding the configured default for it").option("--issue <number>", "Restrict the tick to a single issue").option("--limit <n>", "Maximum number of issues to fetch", "10").option("--max-runs <n>", "Maximum number of model runs this tick").option(
3803
+ "--dry-run",
3804
+ "Print the work plan, plus a summary and the exact command that would be launched for each item, and exit without changing anything"
3805
+ ).option("--json", "Emit the work plan and outcomes as JSON on stdout").option("--silent", "Suppress step-by-step Claude output; show only the final summary").action(async (options) => {
3806
+ const settings = resolveSettings(options);
3807
+ if (options.dryRun === true) {
3808
+ const exitCode2 = await runTick(settings, options);
3809
+ if (exitCode2 !== 0) process.exit(exitCode2);
3810
+ return;
3811
+ }
3812
+ const lock = acquireRunLock("do-work", settings.lockStaleMinutes);
3813
+ if (!lock.ok) {
3814
+ const held = lock.heldBy;
3815
+ const sentence = `Another automata instance is already running here (pid ${String(held.pid)} on ${held.host}, started ${held.startedAt}, command ${held.command}). Doing nothing.
3816
+ `;
3817
+ const suspectSentence = lock.suspect ? `Warning: that lock has been held longer than ${String(settings.lockStaleMinutes)} minutes. If no tick is really running, its process id was probably reused; remove ${RUN_LOCK_RELATIVE_PATH} once you have confirmed that.
3818
+ ` : "";
3819
+ const exitCode2 = lock.suspect ? 2 : 0;
3820
+ if (options.json === true) {
3821
+ progress(sentence + suspectSentence);
3822
+ out(
3823
+ JSON.stringify(
3824
+ { lockHeld: true, suspect: lock.suspect, heldBy: held, plan: [], items: [], exitCode: exitCode2 },
3825
+ null,
3826
+ 2
3827
+ ) + "\n"
3828
+ );
3829
+ } else {
3830
+ out(sentence);
3831
+ if (suspectSentence) progress(suspectSentence);
3832
+ }
3833
+ if (exitCode2 !== 0) process.exit(exitCode2);
3834
+ return;
3835
+ }
3836
+ const handle = lock.handle;
3837
+ let shuttingDown = false;
3838
+ const onSignal = () => {
3839
+ if (shuttingDown) return;
3840
+ shuttingDown = true;
3841
+ progress("\nInterrupted: stopping the executor before releasing the run lock\u2026\n");
3842
+ const pending = inFlightMarker;
3843
+ if (pending !== null) {
3844
+ try {
3845
+ updateMarker(
3846
+ pending.marker,
3847
+ `automata do-work: this run was interrupted before it finished, so no answer was produced. The branch \`${pending.item.branch}\` may have been changed. Reply here to have another attempt made.`
3848
+ );
3849
+ } catch (err) {
3850
+ progress(`Warning: could not update the in-flight marker: ${err.message}
3851
+ `);
3852
+ }
3853
+ }
3854
+ void terminateTrackedChildren().then((allExited) => {
3855
+ if (allExited) {
3856
+ handle.release();
3857
+ } else {
3858
+ progress(
3859
+ "Warning: could not confirm the executor exited; leaving the run lock in place. Check for a stray executor process before the next tick.\n"
3860
+ );
3861
+ }
3862
+ process.exit(130);
3863
+ });
3864
+ };
3865
+ process.once("SIGINT", onSignal);
3866
+ process.once("SIGTERM", onSignal);
3867
+ let exitCode;
3868
+ try {
3869
+ exitCode = await runTick(settings, options);
3870
+ } catch (err) {
3871
+ process.stderr.write(`Error: ${err.message}
3872
+ `);
3873
+ exitCode = 1;
3874
+ } finally {
3875
+ handle.release();
3876
+ process.removeListener("SIGINT", onSignal);
3877
+ process.removeListener("SIGTERM", onSignal);
3878
+ }
3879
+ if (exitCode !== 0) process.exit(exitCode);
3880
+ });
3881
+ async function runTick(settings, options) {
3882
+ const issues = discoverIssues(settings);
3883
+ const linkMap = getOpenPrLinkMap();
3884
+ const decisions = issues.map(
3885
+ (issue) => decideWork(buildIssueState(issue, linkMap), settings.participants, {
3886
+ baseBranch: settings.baseBranch,
3887
+ defaultBranch: linkMap.defaultBranch,
3888
+ protectedBranches: settings.protectedBranches
3889
+ })
3890
+ );
3891
+ const items = decisions.flatMap((decision) => decision.kind === "work" ? [decision.item] : []);
3892
+ const planText = `Work plan (${String(items.length)} of ${String(issues.length)} issues need an answer):
3893
+ ${describePlan(decisions)}`;
3894
+ if (options.json) {
3895
+ progress(planText);
3896
+ } else {
3897
+ out(planText);
3898
+ }
3899
+ if (options.dryRun) {
3900
+ reportDryRun(items, decisions, settings, options);
3901
+ return 0;
3902
+ }
3903
+ const reports = [];
3904
+ const deferred = [];
3905
+ let runsUsed = 0;
3906
+ for (const item of items) {
3907
+ if (settings.maxRuns > 0 && runsUsed >= settings.maxRuns) {
3908
+ deferred.push(item);
3909
+ continue;
3910
+ }
3911
+ let report;
3912
+ try {
3913
+ report = await processItem(item, settings, options.silent === true);
3914
+ } catch (err) {
3915
+ progress(` failed: ${err.message}
3916
+ `);
3917
+ report = {
3918
+ issue: item.issue.number,
3919
+ title: item.issue.title,
3920
+ turn: item.turn,
3921
+ outcome: "failed",
3922
+ detail: err.message
3923
+ };
3924
+ }
3925
+ if (report.ranExecutor === true) runsUsed++;
3926
+ reports.push(report);
3927
+ }
3928
+ for (const item of deferred) {
3929
+ progress(`
3930
+ #${String(item.issue.number)} deferred: --max-runs / maxRunsPerTick reached.
3931
+ `);
3932
+ reports.push({
3933
+ issue: item.issue.number,
3934
+ title: item.issue.title,
3935
+ turn: item.turn,
3936
+ outcome: "deferred",
3937
+ detail: `run cap of ${String(settings.maxRuns)} reached`
3938
+ });
3939
+ }
3940
+ const degraded = reports.some((report) => report.outcome !== "answered");
3941
+ const exitCode = degraded ? 2 : 0;
3942
+ if (options.json) {
3943
+ out(JSON.stringify({ dryRun: false, plan: decisions.map(toPlanJson), items: reports, exitCode }, null, 2) + "\n");
3944
+ } else {
3945
+ summarize(reports);
3946
+ }
3947
+ return exitCode;
3948
+ }
3949
+ function reportDryRun(items, decisions, settings, options) {
3950
+ const describable = settings.maxRuns > 0 ? items.slice(0, settings.maxRuns) : items;
3951
+ const planned = describable.map((item) => planRun(item, settings));
3952
+ if (options.json) {
3953
+ out(
3954
+ JSON.stringify(
3955
+ {
3956
+ dryRun: true,
3957
+ plan: decisions.map(toPlanJson),
3958
+ runs: planned.map((run5, index) => ({
3959
+ issue: describable[index].issue.number,
3960
+ turn: describable[index].turn,
3961
+ executor: settings.executor,
3962
+ model: settings.model ?? null,
3963
+ bin: run5.bin,
3964
+ args: run5.args,
3965
+ command: run5.command,
3966
+ prompt: run5.prompt
3967
+ }))
3968
+ },
3969
+ null,
3970
+ 2
3971
+ ) + "\n"
3972
+ );
3973
+ return;
3974
+ }
3975
+ for (const [index, run5] of planned.entries()) {
3976
+ out("\n" + describePlannedRun(describable[index], settings, run5));
3977
+ }
3978
+ const deferred = items.length - describable.length;
3979
+ if (deferred > 0) {
3980
+ out(`
3981
+ (${String(deferred)} further item(s) deferred by the run cap.)
3982
+ `);
3983
+ }
3984
+ out("\nDry run: nothing was assigned, posted, checked out or executed.\n");
3985
+ }
3986
+ function toPlanJson(decision) {
3987
+ if (decision.kind === "skip") {
3988
+ return {
3989
+ issue: decision.issue.number,
3990
+ title: decision.issue.title,
3991
+ turn: null,
3992
+ skipReason: decision.reason,
3993
+ reason: decision.detail
3994
+ };
3995
+ }
3996
+ const item = decision.item;
3997
+ return {
3998
+ issue: item.issue.number,
3999
+ title: item.issue.title,
4000
+ turn: item.turn,
4001
+ branch: item.branch,
4002
+ pr: item.pr?.number ?? null,
4003
+ needsAssignment: item.needsAssignment,
4004
+ reason: item.reason
4005
+ };
4006
+ }
1845
4007
 
1846
4008
  // src/index.ts
1847
- var program = new Command6();
4009
+ var program = new Command7();
1848
4010
  program.name("automata").description("Automata CLI tool").version(version, "-v, --version");
1849
4011
  program.addCommand(configCommand);
1850
- program.addCommand(gitCommand);
4012
+ program.addCommand(gitCommand2);
1851
4013
  program.addCommand(implementNextCommand);
1852
4014
  program.addCommand(executeCommand);
1853
4015
  program.addCommand(executePromptCommand);
4016
+ program.addCommand(doWorkCommand);
1854
4017
  program.showHelpAfterError();
1855
4018
  program.parse();
1856
4019
  if (process.argv.length <= 2) {