automata-cli 0.11.0-develop.514 → 0.11.0-develop.526

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +467 -89
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -90,11 +90,11 @@ function writeDoWork(patch) {
90
90
  const current = readRawConfig();
91
91
  writeConfig({ ...current, doWork: { ...current.doWork, ...patch } });
92
92
  }
93
- function parseNonNegativeInt(value, label) {
93
+ function parseNonNegativeInt(value, label2) {
94
94
  const trimmed2 = value.trim();
95
95
  const parsed = Number.parseInt(trimmed2, 10);
96
96
  if (Number.isNaN(parsed) || parsed < 0 || String(parsed) !== trimmed2 || !Number.isSafeInteger(parsed)) {
97
- process.stderr.write(`Error: ${label} must be a non-negative integer (got "${value}").
97
+ process.stderr.write(`Error: ${label2} must be a non-negative integer (got "${value}").
98
98
  `);
99
99
  process.exit(1);
100
100
  }
@@ -1705,7 +1705,7 @@ function formatSonarIssue(issue) {
1705
1705
  const location = formatLocation(issue.path, issue.line);
1706
1706
  if (location) lines.push(` Location: ${location}`);
1707
1707
  if (issue.severity || issue.type) {
1708
- const labels = [issue.severity, issue.type].filter((label) => Boolean(label)).map(sanitizeText).join(" / ");
1708
+ const labels = [issue.severity, issue.type].filter((label2) => Boolean(label2)).map(sanitizeText).join(" / ");
1709
1709
  lines.push(` Classification: ${labels}`);
1710
1710
  }
1711
1711
  if (issue.rule) lines.push(` Rule: ${sanitizeText(issue.rule)}`);
@@ -1718,7 +1718,7 @@ function formatSonarHotspot(hotspot) {
1718
1718
  if (location) lines.push(` Location: ${location}`);
1719
1719
  if (hotspot.status) lines.push(` Status: ${sanitizeText(hotspot.status)}`);
1720
1720
  if (hotspot.vulnerabilityProbability || hotspot.securityCategory) {
1721
- const labels = [hotspot.vulnerabilityProbability, hotspot.securityCategory].filter((label) => Boolean(label)).map(sanitizeText).join(" / ");
1721
+ const labels = [hotspot.vulnerabilityProbability, hotspot.securityCategory].filter((label2) => Boolean(label2)).map(sanitizeText).join(" / ");
1722
1722
  lines.push(` Classification: ${labels}`);
1723
1723
  }
1724
1724
  if (hotspot.rule) {
@@ -2368,7 +2368,7 @@ function getIssueSurface(issueNumber) {
2368
2368
  issue: { number: raw.number, title: raw.title, body: raw.body, url: raw.url },
2369
2369
  state: raw.state === "CLOSED" ? "CLOSED" : "OPEN",
2370
2370
  assignees: (raw.assignees ?? []).map(login).filter((name) => name.length > 0),
2371
- labels: (raw.labels ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
2371
+ labels: (raw.labels ?? []).map((label2) => label2.name ?? "").filter((name) => name.length > 0),
2372
2372
  messages
2373
2373
  };
2374
2374
  }
@@ -2427,7 +2427,7 @@ function toPullRequestRef(node) {
2427
2427
  function toOrphanPr(node) {
2428
2428
  return {
2429
2429
  pr: toPullRequestRef(node),
2430
- labels: (node.labels?.nodes ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
2430
+ labels: (node.labels?.nodes ?? []).map((label2) => label2.name ?? "").filter((name) => name.length > 0),
2431
2431
  assignees: (node.assignees?.nodes ?? []).map((assignee) => assignee.login ?? "").filter((login2) => login2.length > 0)
2432
2432
  };
2433
2433
  }
@@ -2625,9 +2625,25 @@ function getWatchTarget(id) {
2625
2625
  number: raw.number,
2626
2626
  kind: raw.pull_request === void 0 ? "issue" : "pr",
2627
2627
  state: raw.state === "closed" ? "closed" : "open",
2628
- title: raw.title
2628
+ title: raw.title,
2629
+ labels: (raw.labels ?? []).map((label2) => typeof label2 === "string" ? label2 : label2.name ?? "").filter((name) => name.length > 0)
2629
2630
  };
2630
2631
  }
2632
+ function addLabel(target, name, description) {
2633
+ const edit = target.kind === "pr" ? "pr" : "issue";
2634
+ const args = [edit, "edit", String(target.number), "--add-label", name];
2635
+ let result = run4("gh", args);
2636
+ if (result.status !== 0 && isMissingLabelError(result.stderr)) {
2637
+ const created = run4("gh", ["label", "create", name, "--description", description]);
2638
+ if (created.status !== 0 && !/already exists/i.test(created.stderr)) {
2639
+ throw new Error(created.stderr.trim() || `Failed to create the label ${name}.`);
2640
+ }
2641
+ result = run4("gh", args);
2642
+ }
2643
+ if (result.status !== 0) {
2644
+ throw new Error(result.stderr.trim() || `Failed to add the label ${name} to #${String(target.number)}.`);
2645
+ }
2646
+ }
2631
2647
  function applyDiscovery(target, technique, value) {
2632
2648
  const edit = target.kind === "pr" ? "pr" : "issue";
2633
2649
  let flag;
@@ -4489,6 +4505,9 @@ var UNKNOWN_OWNER = {
4489
4505
  command: "unknown",
4490
4506
  token: ""
4491
4507
  };
4508
+ function inspectConductorLock(staleMinutes, now = Date.now()) {
4509
+ return inspectRunLock(staleMinutes, now, CONDUCTOR_LOCK_FILE);
4510
+ }
4492
4511
  function acquireConductorLock(staleMinutes) {
4493
4512
  return acquireRunLock("conductor", staleMinutes, CONDUCTOR_LOCK_FILE);
4494
4513
  }
@@ -4599,8 +4618,8 @@ function heldForMs(owner, now) {
4599
4618
  const startedAt = Date.parse(owner.startedAt);
4600
4619
  return Number.isNaN(startedAt) ? null : now - startedAt;
4601
4620
  }
4602
- function inspectRunLock(staleMinutes, now = Date.now()) {
4603
- const path = lockPath();
4621
+ function inspectRunLock(staleMinutes, now = Date.now(), lockFile = LOCK_FILE) {
4622
+ const path = lockPath(lockFile);
4604
4623
  try {
4605
4624
  statSync2(path);
4606
4625
  } catch (err) {
@@ -4785,9 +4804,9 @@ function detectRescueTarget(options, now) {
4785
4804
  return { branch: current, createdBranch: false, source: current };
4786
4805
  }
4787
4806
  const source = detached ? "detached HEAD" : current === options.baseBranch ? options.baseBranch : current;
4788
- const label = detached ? "detached" : flattenBranchName(source);
4807
+ const label2 = detached ? "detached" : flattenBranchName(source);
4789
4808
  return {
4790
- branch: `${RESCUE_BRANCH_PREFIX}${label}-${utcStamp(now)}`,
4809
+ branch: `${RESCUE_BRANCH_PREFIX}${label2}-${utcStamp(now)}`,
4791
4810
  createdBranch: true,
4792
4811
  source
4793
4812
  };
@@ -5203,9 +5222,9 @@ var RunTranscript = class {
5203
5222
  stderrAtLineStart = true;
5204
5223
  /** A private key began in an earlier line and has not ended yet. */
5205
5224
  inPrivateKey = false;
5206
- constructor(label, root = process.cwd(), now = /* @__PURE__ */ new Date()) {
5225
+ constructor(label2, root = process.cwd(), now = /* @__PURE__ */ new Date()) {
5207
5226
  const stamp = now.toISOString().replace(/[:.]/g, "-");
5208
- this.fileName = `${stamp}-${label}.log`;
5227
+ this.fileName = `${stamp}-${label2}.log`;
5209
5228
  const dir = join5(root, RUN_TRANSCRIPT_DIR);
5210
5229
  this.path = join5(dir, this.fileName);
5211
5230
  try {
@@ -6472,12 +6491,12 @@ function gitSection(status) {
6472
6491
  return build("git", lines, problems, { ...status });
6473
6492
  }
6474
6493
  function assembleReport(input) {
6475
- const byId = new Map(input.sections.map((section) => [section.id, section]));
6494
+ const byId = new Map(input.sections.map((section2) => [section2.id, section2]));
6476
6495
  const ordered = SECTION_ORDER.flatMap((id) => {
6477
- const section = byId.get(id);
6478
- return section === void 0 ? [] : [section];
6496
+ const section2 = byId.get(id);
6497
+ return section2 === void 0 ? [] : [section2];
6479
6498
  });
6480
- const problems = ordered.flatMap((section) => section.problems);
6499
+ const problems = ordered.flatMap((section2) => section2.problems);
6481
6500
  return {
6482
6501
  generatedAt: input.generatedAt,
6483
6502
  repo: input.repo,
@@ -6511,8 +6530,8 @@ function renderProblems(problems) {
6511
6530
  function renderText(report) {
6512
6531
  const head = `automata do-work --check \u2014 ${report.repo ?? "unknown repository"} \u2014 ${report.generatedAt.toISOString()}`;
6513
6532
  const parts = report.blocked === null ? [head, ""] : [`blocked: ${report.blocked.trigger}`, head, ""];
6514
- for (const section of report.sections) {
6515
- parts.push(...renderBlock(section.title, section.lines, "(nothing to report)"));
6533
+ for (const section2 of report.sections) {
6534
+ parts.push(...renderBlock(section2.title, section2.lines, "(nothing to report)"));
6516
6535
  }
6517
6536
  if (report.trace !== null) parts.push(...renderTrace(report.trace));
6518
6537
  if (report.problems.length > 0) parts.push(...renderProblems(report.problems));
@@ -6523,12 +6542,12 @@ function renderText(report) {
6523
6542
  }
6524
6543
  function toJson(report) {
6525
6544
  const sections = {};
6526
- for (const section of report.sections) {
6527
- sections[section.id] = {
6528
- title: section.title,
6529
- lines: section.lines,
6530
- problems: section.problems,
6531
- data: section.data
6545
+ for (const section2 of report.sections) {
6546
+ sections[section2.id] = {
6547
+ title: section2.title,
6548
+ lines: section2.lines,
6549
+ problems: section2.problems,
6550
+ data: section2.data
6532
6551
  };
6533
6552
  }
6534
6553
  return {
@@ -6587,14 +6606,14 @@ var SettingsError = class extends Error {
6587
6606
  function failSettings(message) {
6588
6607
  throw new SettingsError(message);
6589
6608
  }
6590
- function parsePositiveInt(value, label) {
6609
+ function parsePositiveInt(value, label2) {
6591
6610
  const trimmed2 = value.trim();
6592
6611
  if (!/^\d+$/.test(trimmed2)) {
6593
- failSettings(`${label} must be a positive integer (got "${value}").`);
6612
+ failSettings(`${label2} must be a positive integer (got "${value}").`);
6594
6613
  }
6595
6614
  const parsed = Number(trimmed2);
6596
6615
  if (!Number.isSafeInteger(parsed) || parsed <= 0) {
6597
- failSettings(`${label} must be a positive integer within the safe range (got "${value}").`);
6616
+ failSettings(`${label2} must be a positive integer within the safe range (got "${value}").`);
6598
6617
  }
6599
6618
  return parsed;
6600
6619
  }
@@ -6749,14 +6768,14 @@ function planRun(item, settings, execution) {
6749
6768
  }
6750
6769
  function describeAssignment(item, agentUser) {
6751
6770
  return claimStates(item).map((claim) => {
6752
- const label = claim.surface === "issue" ? "issue" : `pull request #${String(claim.number)}`;
6771
+ const label2 = claim.surface === "issue" ? "issue" : `pull request #${String(claim.number)}`;
6753
6772
  switch (claim.state) {
6754
6773
  case "would-claim":
6755
- return `would assign ${label} to ${agentUser}`;
6774
+ return `would assign ${label2} to ${agentUser}`;
6756
6775
  case "already-assigned":
6757
- return `${label} already assigned`;
6776
+ return `${label2} already assigned`;
6758
6777
  case "rule-exempt":
6759
- return `${label} not claimed (orphan pass)`;
6778
+ return `${label2} not claimed (orphan pass)`;
6760
6779
  }
6761
6780
  }).join(" \xB7 ");
6762
6781
  }
@@ -6832,24 +6851,24 @@ function validateOptionalInt(container, key2, path, min, hint) {
6832
6851
  failSettings(`${path} must be ${hint}, got ${JSON.stringify(value)}.`);
6833
6852
  }
6834
6853
  }
6835
- function validateDoWorkConfig(section) {
6836
- if (section === void 0 || section === null) return;
6837
- if (!isPlainObject(section)) {
6838
- failSettings(`doWork must be an object, got ${JSON.stringify(section)}.`);
6854
+ function validateDoWorkConfig(section2) {
6855
+ if (section2 === void 0 || section2 === null) return;
6856
+ if (!isPlainObject(section2)) {
6857
+ failSettings(`doWork must be an object, got ${JSON.stringify(section2)}.`);
6839
6858
  }
6840
- const executor = section["executor"];
6859
+ const executor = section2["executor"];
6841
6860
  if (executor !== void 0 && executor !== "claude" && executor !== "codex") {
6842
6861
  failSettings(`doWork.executor must be 'claude' or 'codex', got ${JSON.stringify(executor)}.`);
6843
6862
  }
6844
- validateOptionalString(section, "baseBranch", "doWork.baseBranch");
6845
- validateOptionalInt(section, "maxRunsPerTick", "doWork.maxRunsPerTick", 0, "a non-negative integer (0 = unlimited)");
6846
- validateOptionalInt(section, "lockStaleMinutes", "doWork.lockStaleMinutes", 1, "a positive integer");
6847
- validateOptionalBoolean(section, "dumpOnBlock", "doWork.dumpOnBlock");
6848
- validateOptionalBoolean(section, "postRunLog", "doWork.postRunLog");
6849
- validateProtectedBranches(section["protectedBranches"]);
6850
- validateSettingContainer(section["models"], "models", ["claude", "codex"]);
6851
- validateSettingContainer(section["effort"], "effort", ["claude", "codex"]);
6852
- validateSettingContainer(section["prompts"], "prompts", ["issueDiscuss", "prWork", "prOrphan"]);
6863
+ validateOptionalString(section2, "baseBranch", "doWork.baseBranch");
6864
+ validateOptionalInt(section2, "maxRunsPerTick", "doWork.maxRunsPerTick", 0, "a non-negative integer (0 = unlimited)");
6865
+ validateOptionalInt(section2, "lockStaleMinutes", "doWork.lockStaleMinutes", 1, "a positive integer");
6866
+ validateOptionalBoolean(section2, "dumpOnBlock", "doWork.dumpOnBlock");
6867
+ validateOptionalBoolean(section2, "postRunLog", "doWork.postRunLog");
6868
+ validateProtectedBranches(section2["protectedBranches"]);
6869
+ validateSettingContainer(section2["models"], "models", ["claude", "codex"]);
6870
+ validateSettingContainer(section2["effort"], "effort", ["claude", "codex"]);
6871
+ validateSettingContainer(section2["prompts"], "prompts", ["issueDiscuss", "prWork", "prOrphan"]);
6853
6872
  }
6854
6873
  function validateOptionalBoolean(container, key2, path) {
6855
6874
  const value = container[key2];
@@ -6912,7 +6931,7 @@ function matchesDiscoveryFilter(subject, settings) {
6912
6931
  const value = settings.discoveryValue.toLowerCase();
6913
6932
  switch (settings.technique) {
6914
6933
  case "label":
6915
- return subject.labels.some((label) => label.toLowerCase() === value);
6934
+ return subject.labels.some((label2) => label2.toLowerCase() === value);
6916
6935
  case "assignee":
6917
6936
  return subject.assignees.some((assignee) => assignee.toLowerCase() === value);
6918
6937
  case "title-contains":
@@ -8419,6 +8438,44 @@ function toPlanJson(decision) {
8419
8438
  // src/commands/conductor.ts
8420
8439
  import { Command as Command7 } from "commander";
8421
8440
 
8441
+ // src/conductor/loopSafety.ts
8442
+ var BLOCKED_LABEL = "conductor-blocked";
8443
+ var REPLY_MARKER = "<!-- automata:conductor -->";
8444
+ var NEEDS_HUMAN_PREFIX = "NEEDS-HUMAN:";
8445
+ var DEFAULT_MAX_REPLIES_PER_ITEM = 5;
8446
+ function maxRepliesProblem(conductor) {
8447
+ const value = conductor?.maxRepliesPerItem;
8448
+ if (value === void 0 || value === null) return null;
8449
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
8450
+ return `conductor.maxRepliesPerItem must be a positive integer, got ${JSON.stringify(value)}.`;
8451
+ }
8452
+ return null;
8453
+ }
8454
+ function resolveMaxReplies(conductor) {
8455
+ return conductor?.maxRepliesPerItem ?? DEFAULT_MAX_REPLIES_PER_ITEM;
8456
+ }
8457
+ function countConductorReplies(messages) {
8458
+ return messages.filter(
8459
+ (message) => (message.kind === "issue-comment" || message.kind === "pr-comment") && message.body.includes(REPLY_MARKER)
8460
+ ).length;
8461
+ }
8462
+ function parseNeedsHuman(output) {
8463
+ const lines = output.trim().split("\n");
8464
+ const last = lines.at(-1)?.trim() ?? "";
8465
+ if (!last.startsWith(NEEDS_HUMAN_PREFIX)) return null;
8466
+ const reason = last.slice(NEEDS_HUMAN_PREFIX.length).trim();
8467
+ return reason.length > 0 ? reason : "no reason given";
8468
+ }
8469
+ function coveredByWatchedIssue(watch, linkedPrs) {
8470
+ const covered = /* @__PURE__ */ new Map();
8471
+ for (const id of watch) {
8472
+ for (const pr of linkedPrs(id)) {
8473
+ if (pr !== id && watch.includes(pr) && !covered.has(pr)) covered.set(pr, id);
8474
+ }
8475
+ }
8476
+ return covered;
8477
+ }
8478
+
8422
8479
  // src/conductor/replyDecision.ts
8423
8480
  function isWatchClosed(state) {
8424
8481
  return state === "closed";
@@ -8479,10 +8536,33 @@ function decideConversation(conversation, p) {
8479
8536
  reason: `${p.agentUser}'s message at ${newest2.createdAt} has no answer from an allowed user`
8480
8537
  };
8481
8538
  }
8482
- function decideConductorReply(conversations, p) {
8483
- const decisions = conversations.map((conversation) => decideConversation(conversation, p));
8539
+ function decideConductorReply(conversations, p, loop) {
8540
+ const decided = conversations.map((conversation) => decideConversation(conversation, p));
8541
+ const stop = loop === void 0 ? null : loopStop(loop);
8542
+ const decisions = decided.map(
8543
+ (decision) => decision.kind === "reply" && stop !== null ? { kind: "skip", surface: decision.surface, reason: stop.reason, detail: stop.detail } : decision
8544
+ );
8484
8545
  return { needsReply: decisions.some((decision) => decision.kind === "reply"), decisions };
8485
8546
  }
8547
+ function loopStop(loop) {
8548
+ if (loop.blocked) {
8549
+ return { reason: "blocked", detail: `the item has the ${BLOCKED_LABEL} label; remove it to resume` };
8550
+ }
8551
+ if (loop.replies >= loop.maxReplies) {
8552
+ return {
8553
+ reason: "limit",
8554
+ detail: `the conductor already replied ${String(loop.replies)} times; the limit is conductor.maxRepliesPerItem (${String(loop.maxReplies)})`
8555
+ };
8556
+ }
8557
+ return null;
8558
+ }
8559
+ function loopStateOf(conversations, labels, maxReplies) {
8560
+ return {
8561
+ blocked: labels.some((label2) => label2.toLowerCase() === BLOCKED_LABEL),
8562
+ replies: countConductorReplies(conversations.flatMap((conversation) => conversation.messages)),
8563
+ maxReplies
8564
+ };
8565
+ }
8486
8566
 
8487
8567
  // src/conductor/thread.ts
8488
8568
  function defaultReplyTarget(thread) {
@@ -8494,7 +8574,19 @@ function postingInstruction(target) {
8494
8574
  return [
8495
8575
  `Post your reply as a comment on ${target.kind === "pr" ? "pull request" : "issue"} #${number}. What you print is discarded; only a posted comment counts.`,
8496
8576
  `Run \`gh ${command} comment ${number} --body-file -\` and give the body on standard input, for example with a here-document.`,
8497
- "Post exactly one comment. Do not approve, merge or close anything."
8577
+ `End the comment body with the line \`${REPLY_MARKER}\` on its own; automata counts the replies with it.`,
8578
+ "Post exactly one comment. Do not approve, merge or close anything.",
8579
+ needsHumanInstruction()
8580
+ ].join("\n");
8581
+ }
8582
+ function needsHumanInstruction() {
8583
+ return `If you cannot go on without a person, post no comment. Make the last line of your final message \`${NEEDS_HUMAN_PREFIX} <one-line reason>\`; automata then labels the item and stays silent.`;
8584
+ }
8585
+ function dryRunInstruction(target) {
8586
+ return [
8587
+ `Write your reply to ${target.kind === "pr" ? "pull request" : "issue"} #${String(target.number)}. This is a dry run: you cannot post a comment, and you must not try.`,
8588
+ "Make the reply, and nothing else, your final message. Do not approve, merge or close anything.",
8589
+ needsHumanInstruction()
8498
8590
  ].join("\n");
8499
8591
  }
8500
8592
  function visible(messages, participants) {
@@ -8576,7 +8668,7 @@ function composeConductorPrompt(input) {
8576
8668
  "Only the messages above exist. Anything from other accounts has been withheld",
8577
8669
  "deliberately \u2014 do not ask about it.",
8578
8670
  "",
8579
- postingInstruction(replyTo)
8671
+ input.dryRun === true ? dryRunInstruction(replyTo) : postingInstruction(replyTo)
8580
8672
  );
8581
8673
  return lines.join("\n");
8582
8674
  }
@@ -8667,6 +8759,117 @@ async function conductReply(input) {
8667
8759
  return runError === null ? { kind: "posted-nothing" } : { kind: "run-failed", error: runError };
8668
8760
  }
8669
8761
 
8762
+ // src/conductor/checkReport.ts
8763
+ function section(title, lines, problems = []) {
8764
+ return { title, lines, problems };
8765
+ }
8766
+ function label(id, kind) {
8767
+ return `${kind === "pr" ? "PR" : "issue"} #${String(id)}`;
8768
+ }
8769
+ function lockSection2(status, staleMinutes) {
8770
+ const fix = "remove .automata/conductor.lock once you have confirmed that no conductor runs";
8771
+ switch (status.kind) {
8772
+ case "free":
8773
+ return section("Run lock", ["no conductor is running in this checkout"]);
8774
+ case "held": {
8775
+ const held = status.heldForMs === null ? "" : ` (${describeDuration(status.heldForMs)} so far)`;
8776
+ return section("Run lock", [
8777
+ `a conductor is running: pid ${String(status.owner.pid)} on ${status.owner.host}, started ${status.owner.startedAt}${held}`,
8778
+ "a tick started now would do nothing"
8779
+ ]);
8780
+ }
8781
+ case "suspect":
8782
+ return section(
8783
+ "Run lock",
8784
+ [`held longer than ${String(staleMinutes)} minutes by pid ${String(status.owner.pid)} on ${status.owner.host}`],
8785
+ [{ section: "lock", summary: `the lock looks alive but outlived the stale window, probably a reused pid: ${fix}` }]
8786
+ );
8787
+ case "stale":
8788
+ return section("Run lock", ["a stale lock is present; the next tick takes it over"]);
8789
+ case "unreadable":
8790
+ return section(
8791
+ "Run lock",
8792
+ [`the lock cannot be read: ${status.detail}`],
8793
+ [{ section: "lock", summary: `the lock file cannot be read (${status.detail}); fix its permissions` }]
8794
+ );
8795
+ }
8796
+ }
8797
+ function decisionLine(decision) {
8798
+ const where = label(decision.surface.number, decision.surface.kind);
8799
+ return decision.kind === "reply" ? `reply on ${where}: ${decision.reason}` : `skip ${where} (${decision.reason}): ${decision.detail}`;
8800
+ }
8801
+ function watchSections(watched) {
8802
+ const listLines = [];
8803
+ const replyLines = [];
8804
+ const problems = [];
8805
+ for (const entry of watched) {
8806
+ if ("unavailable" in entry) {
8807
+ listLines.push(`#${String(entry.id)}: unavailable (${entry.unavailable}); a tick keeps it and skips it`);
8808
+ problems.push({ section: "watch", summary: `#${String(entry.id)} could not be read: ${entry.unavailable}` });
8809
+ continue;
8810
+ }
8811
+ const name = label(entry.id, entry.kind);
8812
+ if (entry.state === "closed") {
8813
+ listLines.push(`${name}: closed, a tick would drop it from the watch list`);
8814
+ continue;
8815
+ }
8816
+ if (entry.coveredBy !== void 0) {
8817
+ listLines.push(`${name}: open, covered by watched issue #${String(entry.coveredBy)}; a tick gives it no separate reply`);
8818
+ continue;
8819
+ }
8820
+ listLines.push(`${name}: open`);
8821
+ for (const decision of entry.decisions) replyLines.push(decisionLine(decision));
8822
+ }
8823
+ return [
8824
+ section(`Watch list (${String(watched.length)})`, listLines, problems),
8825
+ section("Replies a tick would write", replyLines)
8826
+ ];
8827
+ }
8828
+ function buildConductorCheck(input) {
8829
+ const sections = input.configProblem === null ? [
8830
+ section("Configuration", [`running as ${input.identity}`, `executor: ${input.execution}`]),
8831
+ lockSection2(input.lock, input.staleMinutes),
8832
+ ...watchSections(input.watched)
8833
+ ] : [
8834
+ section(
8835
+ "Configuration",
8836
+ [input.configProblem],
8837
+ [{ section: "configuration", summary: input.configProblem }]
8838
+ )
8839
+ ];
8840
+ const problems = sections.flatMap((s) => s.problems);
8841
+ const parts = [
8842
+ `automata conductor --check \u2014 ${input.repo ?? "unknown repository"} \u2014 ${input.generatedAt.toISOString()}`,
8843
+ ""
8844
+ ];
8845
+ for (const s of sections) {
8846
+ parts.push(s.title, ...(s.lines.length === 0 ? ["(nothing to report)"] : s.lines).map((l) => ` ${l}`), "");
8847
+ }
8848
+ if (problems.length > 0) {
8849
+ parts.push(`Problems (${String(problems.length)})`, ...problems.map((p) => ` \xB7 ${p.section}: ${p.summary}`), "");
8850
+ }
8851
+ parts.push(problems.length === 0 ? "RESULT: healthy" : `RESULT: ${String(problems.length)} problem(s) found`);
8852
+ return { text: parts.join("\n") + "\n", exitCode: problems.length === 0 ? 0 : 1, problems };
8853
+ }
8854
+
8855
+ // src/conductor/dryRunReply.ts
8856
+ function claudeResult(raw) {
8857
+ let result = null;
8858
+ for (const line of raw.split("\n")) {
8859
+ if (!line.trim().startsWith("{")) continue;
8860
+ try {
8861
+ const event = JSON.parse(line);
8862
+ if (event["type"] === "result" && typeof event["result"] === "string") result = event["result"];
8863
+ } catch {
8864
+ }
8865
+ }
8866
+ return result;
8867
+ }
8868
+ function extractDryRunReply(executor, stdout) {
8869
+ const text = (executor === "claude" ? claudeResult(stdout) : stdout)?.trim();
8870
+ return text === void 0 || text.length === 0 ? null : text;
8871
+ }
8872
+
8670
8873
  // src/conductor/watchList.ts
8671
8874
  function parseWatchId(raw) {
8672
8875
  const text = raw.trim().replace(/^#/, "");
@@ -8726,58 +8929,133 @@ function pruneWatchList(config) {
8726
8929
  if (dropped.length > 0) updateWatch((current) => current.filter((id) => !dropped.includes(id)));
8727
8930
  return watch.filter((id) => !dropped.includes(id));
8728
8931
  }
8932
+ function onceLinkMap() {
8933
+ let map = null;
8934
+ return () => map ??= getOpenPrLinkMap();
8935
+ }
8729
8936
  function readPr(number) {
8730
8937
  return { surface: getPrSurface(number), checks: getPrChecks(number) };
8731
8938
  }
8732
- function readWatchedItem(id) {
8939
+ function readWatchedItem(id, linkMap = getOpenPrLinkMap) {
8733
8940
  const target = getWatchTarget(id);
8734
8941
  if (target.kind === "pr") {
8735
8942
  const pr = readPr(id);
8736
- return { thread: { kind: "pr", prs: [pr] }, conversations: [prConversation(pr.surface)] };
8943
+ return { target, thread: { kind: "pr", prs: [pr] }, conversations: [prConversation(pr.surface)], labels: target.labels };
8737
8944
  }
8738
8945
  const issue = getIssueSurface(id);
8739
- const prs = (getOpenPrLinkMap().byIssue.get(id) ?? []).map((ref) => readPr(ref.number));
8946
+ const prs = (linkMap().byIssue.get(id) ?? []).map((ref) => readPr(ref.number));
8740
8947
  return {
8948
+ target,
8741
8949
  thread: { kind: "issue", issue, prs },
8742
- conversations: [issueConversation(issue), ...prs.map((pr) => prConversation(pr.surface))]
8950
+ conversations: [issueConversation(issue), ...prs.map((pr) => prConversation(pr.surface))],
8951
+ labels: target.labels
8743
8952
  };
8744
8953
  }
8745
8954
  function readTargetMessages(target) {
8746
8955
  return target.kind === "issue" ? issueConversation(getIssueSurface(target.number)).messages : prConversation(getPrSurface(target.number)).messages;
8747
8956
  }
8748
- function runModel(prompt, execution, replyTo) {
8957
+ function runModel(prompt, execution, replyTo, sink2) {
8958
+ const { owner, repo } = getRepoSlug();
8959
+ return runClaude(prompt, { model: execution.model, effort: execution.effort, readOnly: true, replyTo, ghRepo: `${owner}/${repo}`, sink: sink2 });
8960
+ }
8961
+ function captureStdout() {
8962
+ let stdout = "";
8963
+ const sink2 = {
8964
+ output: (stream, text) => {
8965
+ if (stream === "stdout") stdout += text;
8966
+ },
8967
+ exited: () => void 0
8968
+ };
8969
+ return { sink: sink2, text: () => stdout };
8970
+ }
8971
+ async function runModelForText(prompt, execution) {
8749
8972
  const { owner, repo } = getRepoSlug();
8750
- return runClaude(prompt, { model: execution.model, effort: execution.effort, readOnly: true, replyTo, ghRepo: `${owner}/${repo}` });
8973
+ const captured = captureStdout();
8974
+ const options = { model: execution.model, effort: execution.effort, readOnly: true, ghRepo: `${owner}/${repo}`, sink: captured.sink };
8975
+ await runClaude(prompt, options);
8976
+ return extractDryRunReply(execution.executor, captured.text());
8977
+ }
8978
+ function coveredOf(watch, linkMap) {
8979
+ return coveredByWatchedIssue(watch, (id) => (linkMap().byIssue.get(id) ?? []).map((ref) => ref.number));
8980
+ }
8981
+ function coveredNote(id, issue) {
8982
+ return `Conductor: #${String(id)} is covered by watched issue #${String(issue)} and gets no separate reply.`;
8983
+ }
8984
+ function targetKey(target) {
8985
+ return `${target.kind}#${String(target.number)}`;
8986
+ }
8987
+ function blockItem(item, label2, reason) {
8988
+ try {
8989
+ addLabel(item.target, BLOCKED_LABEL, "The conductor stopped and needs a person; remove this label to resume");
8990
+ } catch (err) {
8991
+ process.stderr.write(`Error: ${label2} needs a human (${reason}) but the ${BLOCKED_LABEL} label was not applied: ${err.message}
8992
+ `);
8993
+ return "failed";
8994
+ }
8995
+ process.stdout.write(`Conductor: ${label2} needs a human (${reason}); applied the ${BLOCKED_LABEL} label.
8996
+ `);
8997
+ return "idle";
8998
+ }
8999
+ async function conductDryRunItem(prompt, execution, label2, where, replyTo, dryRun) {
9000
+ const reply = await runModelForText(prompt, execution);
9001
+ if (reply === null) {
9002
+ process.stderr.write(`Error: the dry run for ${label2} produced no reply text.
9003
+ `);
9004
+ return "failed";
9005
+ }
9006
+ const needsHuman = parseNeedsHuman(reply);
9007
+ if (needsHuman !== null) {
9008
+ process.stdout.write(`--- Dry run: ${label2} needs a human (not labelled): ${needsHuman} ---
9009
+ `);
9010
+ return "idle";
9011
+ }
9012
+ dryRun.answered.add(targetKey(replyTo));
9013
+ process.stdout.write(`--- Dry run: reply for ${where} (not posted) ---
9014
+ ${reply}
9015
+ ---
9016
+ `);
9017
+ return "replied";
8751
9018
  }
8752
- async function conductItem(id, config, login2, participants, execution) {
8753
- const label = `#${String(id)}`;
9019
+ async function conductItem(id, config, login2, participants, execution, dryRun, linkMap) {
9020
+ const label2 = `#${String(id)}`;
8754
9021
  let item;
8755
9022
  try {
8756
- item = readWatchedItem(id);
9023
+ item = readWatchedItem(id, dryRun?.linkMap ?? linkMap);
8757
9024
  } catch (err) {
8758
- process.stderr.write(`Warning: could not read ${label}, skipping it this tick: ${err.message}
9025
+ process.stderr.write(`Warning: could not read ${label2}, skipping it this tick: ${err.message}
8759
9026
  `);
8760
9027
  return "idle";
8761
9028
  }
8762
9029
  const { thread, conversations } = item;
8763
- const verdict = decideConductorReply(conversations, participants);
8764
- const owed = verdict.decisions.find((decision) => decision.kind === "reply");
9030
+ const loop = loopStateOf(conversations, item.labels, resolveMaxReplies(config.conductor));
9031
+ const verdict = decideConductorReply(conversations, participants, loop);
9032
+ const owed = verdict.decisions.find(
9033
+ (decision) => decision.kind === "reply" && dryRun?.answered.has(targetKey(decision.surface)) !== true
9034
+ );
8765
9035
  if (owed === void 0) {
8766
- process.stdout.write(`Conductor: ${label} needs no reply.
9036
+ const stopped = verdict.decisions.find((decision) => decision.kind === "skip" && (decision.reason === "blocked" || decision.reason === "limit"));
9037
+ const why = stopped?.kind === "skip" ? `Conductor: ${label2} gets no reply: ${stopped.detail}.` : `Conductor: ${label2} needs no reply.`;
9038
+ process.stdout.write(`${why}
8767
9039
  `);
8768
9040
  return "idle";
8769
9041
  }
8770
9042
  const replyTo = { kind: owed.surface.kind, number: owed.surface.number };
8771
9043
  const frame = thread.kind === "issue" ? config.conductor?.prompts?.issue ?? DEFAULT_CONDUCTOR_ISSUE_PROMPT : config.conductor?.prompts?.pr ?? DEFAULT_CONDUCTOR_PR_PROMPT;
8772
- const prompt = composeConductorPrompt({ thread, repo: getRepoSlug(), participants, frame, replyTo });
9044
+ const prompt = composeConductorPrompt({ thread, repo: getRepoSlug(), participants, frame, replyTo, dryRun: dryRun !== void 0 });
8773
9045
  const where = `${replyTo.kind === "pr" ? "pull request" : "issue"} #${String(replyTo.number)}`;
8774
- process.stdout.write(`Conductor: ${label} needs a reply on ${where}; running ${describeConductorExecution(execution)}.
9046
+ process.stdout.write(`Conductor: ${label2} needs a reply on ${where}; running ${describeConductorExecution(execution)}.
8775
9047
  `);
9048
+ if (dryRun) return conductDryRunItem(prompt, execution, label2, where, replyTo, dryRun);
9049
+ const captured = captureStdout();
8776
9050
  const outcome = await conductReply({
8777
9051
  login: login2,
8778
9052
  read: () => readTargetMessages(replyTo),
8779
- run: () => runModel(prompt, execution, replyTo)
9053
+ run: () => runModel(prompt, execution, replyTo, captured.sink)
8780
9054
  });
9055
+ const needsHuman = parseNeedsHuman(extractDryRunReply(execution.executor, captured.text()) ?? "");
9056
+ if (needsHuman !== null && outcome.kind !== "run-failed" && outcome.kind !== "unverified") {
9057
+ return blockItem(item, label2, needsHuman);
9058
+ }
8781
9059
  switch (outcome.kind) {
8782
9060
  case "posted":
8783
9061
  process.stdout.write(`Conductor: posted a reply on ${where}.
@@ -8788,15 +9066,15 @@ async function conductItem(id, config, login2, participants, execution) {
8788
9066
  }
8789
9067
  return "replied";
8790
9068
  case "posted-nothing":
8791
- process.stderr.write(`Error: the run for ${label} finished but posted no comment on ${where}.
9069
+ process.stderr.write(`Error: the run for ${label2} finished but posted no comment on ${where}.
8792
9070
  `);
8793
9071
  return "failed";
8794
9072
  case "run-failed":
8795
- process.stderr.write(`Error: the run for ${label} failed and posted no comment on ${where}: ${outcome.error}
9073
+ process.stderr.write(`Error: the run for ${label2} failed and posted no comment on ${where}: ${outcome.error}
8796
9074
  `);
8797
9075
  return "failed";
8798
9076
  case "unverified":
8799
- process.stderr.write(`Error: could not tell whether ${label} got a reply: ${outcome.error}
9077
+ process.stderr.write(`Error: could not tell whether ${label2} got a reply: ${outcome.error}
8800
9078
  `);
8801
9079
  return "failed";
8802
9080
  }
@@ -8910,6 +9188,8 @@ function preflight() {
8910
9188
  }
8911
9189
  const executionProblem = conductorExecutionProblem(config.conductor);
8912
9190
  if (executionProblem !== null) return { problem: executionProblem };
9191
+ const maxReplies = maxRepliesProblem(config.conductor);
9192
+ if (maxReplies !== null) return { problem: maxReplies };
8913
9193
  if (config.conductor?.executor === "codex") {
8914
9194
  return { problem: "conductor.executor codex is not supported: Codex cannot be limited to comments on the reply target. Use claude." };
8915
9195
  }
@@ -8920,35 +9200,133 @@ function preflight() {
8920
9200
  execution: resolveConductorExecution(config.conductor)
8921
9201
  };
8922
9202
  }
8923
- async function runConductor() {
9203
+ function staleMinutesOf(config) {
9204
+ return config.doWork?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
9205
+ }
9206
+ function checkWatched(id, participants, maxReplies, linkMap, coveredBy) {
9207
+ try {
9208
+ const target = getWatchTarget(id);
9209
+ if (isWatchClosed(target.state)) return { id, kind: target.kind, state: "closed", decisions: [] };
9210
+ if (coveredBy !== void 0) return { id, kind: target.kind, state: "open", decisions: [], coveredBy };
9211
+ const { conversations, labels } = readWatchedItem(id, linkMap);
9212
+ const loop = loopStateOf(conversations, labels, maxReplies);
9213
+ return { id, kind: target.kind, state: "open", decisions: decideConductorReply(conversations, participants, loop).decisions };
9214
+ } catch (err) {
9215
+ return { id, unavailable: err.message };
9216
+ }
9217
+ }
9218
+ function runConductorCheck() {
8924
9219
  const checked = preflight();
8925
- if ("problem" in checked) return fail2(checked.problem);
9220
+ const generatedAt = /* @__PURE__ */ new Date();
9221
+ let repo = null;
9222
+ try {
9223
+ const slug = getRepoSlug();
9224
+ repo = `${slug.owner}/${slug.repo}`;
9225
+ } catch {
9226
+ }
9227
+ const linkMap = onceLinkMap();
9228
+ const watchIds = "problem" in checked ? [] : normalizeWatch(checked.config.conductor?.watch);
9229
+ const covered = "problem" in checked ? /* @__PURE__ */ new Map() : coveredOf(watchIds, linkMap);
9230
+ const report = "problem" in checked ? buildConductorCheck({
9231
+ generatedAt,
9232
+ repo,
9233
+ configProblem: checked.problem,
9234
+ identity: "",
9235
+ execution: "",
9236
+ lock: { kind: "free" },
9237
+ staleMinutes: DEFAULT_DO_WORK.lockStaleMinutes,
9238
+ watched: []
9239
+ }) : buildConductorCheck({
9240
+ generatedAt,
9241
+ repo,
9242
+ configProblem: null,
9243
+ identity: checked.login,
9244
+ execution: describeConductorExecution(checked.execution),
9245
+ lock: inspectConductorLock(staleMinutesOf(checked.config)),
9246
+ staleMinutes: staleMinutesOf(checked.config),
9247
+ watched: watchIds.map((id) => checkWatched(id, checked.participants, resolveMaxReplies(checked.config.conductor), linkMap, covered.get(id)))
9248
+ });
9249
+ process.stdout.write(report.text);
9250
+ return report.exitCode;
9251
+ }
9252
+ async function runConductorDryRun(checked) {
8926
9253
  const { config, login: login2, participants, execution } = checked;
8927
- const staleMinutes = config.doWork?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
8928
- const lock = acquireConductorLock(staleMinutes);
8929
- if (!lock.ok) {
8930
- const held = lock.heldBy;
8931
- process.stdout.write(
8932
- `Another conductor is already running here (pid ${String(held.pid)} on ${held.host}, started ${held.startedAt}). Doing nothing.
9254
+ const lock = inspectConductorLock(staleMinutesOf(config));
9255
+ if (lock.kind === "held" || lock.kind === "suspect") {
9256
+ process.stderr.write(`Warning: a conductor is running here (pid ${String(lock.owner.pid)}); the dry run does not take the lock.
9257
+ `);
9258
+ }
9259
+ process.stdout.write(`Conductor: dry run as ${login2}; nothing is posted and the watch list is not changed.
9260
+ `);
9261
+ let failed = 0;
9262
+ const context = { linkMap: onceLinkMap(), answered: /* @__PURE__ */ new Set() };
9263
+ const watchIds = normalizeWatch(config.conductor?.watch);
9264
+ const covered = coveredOf(watchIds, context.linkMap);
9265
+ for (const id of watchIds) {
9266
+ try {
9267
+ if (isWatchClosed(getWatchTarget(id).state)) {
9268
+ process.stdout.write(`Conductor: #${String(id)} is closed; a tick would drop it from the watch list.
9269
+ `);
9270
+ continue;
9271
+ }
9272
+ } catch {
9273
+ }
9274
+ const coveredBy = covered.get(id);
9275
+ if (coveredBy !== void 0) {
9276
+ process.stdout.write(`${coveredNote(id, coveredBy)}
9277
+ `);
9278
+ continue;
9279
+ }
9280
+ try {
9281
+ if (await conductItem(id, config, login2, participants, execution, context) === "failed") failed++;
9282
+ } catch (err) {
9283
+ process.stderr.write(`Error: #${String(id)} could not be conducted: ${err.message}
9284
+ `);
9285
+ failed++;
9286
+ }
9287
+ }
9288
+ return failed > 0 ? 1 : 0;
9289
+ }
9290
+ function reportHeldLock(held, suspect, staleMinutes) {
9291
+ process.stdout.write(
9292
+ `Another conductor is already running here (pid ${String(held.pid)} on ${held.host}, started ${held.startedAt}). Doing nothing.
8933
9293
  `
8934
- );
8935
- if (lock.suspect) {
8936
- process.stderr.write(
8937
- `Warning: that lock has been held longer than ${String(staleMinutes)} minutes. If no tick is really running, its process id was probably reused; remove ${CONDUCTOR_LOCK_RELATIVE_PATH} once you have confirmed that.
9294
+ );
9295
+ if (!suspect) return 0;
9296
+ process.stderr.write(
9297
+ `Warning: that lock has been held longer than ${String(staleMinutes)} minutes. If no tick is really running, its process id was probably reused; remove ${CONDUCTOR_LOCK_RELATIVE_PATH} once you have confirmed that.
8938
9298
  `
8939
- );
8940
- return 2;
8941
- }
8942
- return 0;
9299
+ );
9300
+ return 2;
9301
+ }
9302
+ async function runConductor(options = {}) {
9303
+ if (options.check === true && options.dryRun === true) {
9304
+ return fail2("--check and --dry-run cannot be used together.");
8943
9305
  }
9306
+ if (options.check === true) return runConductorCheck();
9307
+ const checked = preflight();
9308
+ if ("problem" in checked) return fail2(checked.problem);
9309
+ if (options.dryRun === true) return runConductorDryRun(checked);
9310
+ const { config, login: login2, participants, execution } = checked;
9311
+ const staleMinutes = config.doWork?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
9312
+ const lock = acquireConductorLock(staleMinutes);
9313
+ if (!lock.ok) return reportHeldLock(lock.heldBy, lock.suspect, staleMinutes);
8944
9314
  try {
8945
9315
  process.stdout.write(`Conductor: running as ${login2}.
8946
9316
  `);
8947
9317
  const watch = pruneWatchList(config);
8948
9318
  let failed = 0;
9319
+ const linkMap = onceLinkMap();
9320
+ const covered = coveredOf(watch, linkMap);
8949
9321
  for (const id of watch) {
9322
+ const coveredBy = covered.get(id);
9323
+ if (coveredBy !== void 0) {
9324
+ process.stdout.write(`${coveredNote(id, coveredBy)}
9325
+ `);
9326
+ continue;
9327
+ }
8950
9328
  try {
8951
- if (await conductItem(id, config, login2, participants, execution) === "failed") failed++;
9329
+ if (await conductItem(id, config, login2, participants, execution, void 0, linkMap) === "failed") failed++;
8952
9330
  } catch (err) {
8953
9331
  process.stderr.write(`Error: #${String(id)} could not be conducted: ${err.message}
8954
9332
  `);
@@ -8962,8 +9340,8 @@ async function runConductor() {
8962
9340
  }
8963
9341
  var conductorCommand = new Command7("conductor").description(
8964
9342
  "Run one conductor tick as an allowed (human) account: verify the identity, take the conductor's own run lock, then act"
8965
- ).action(async () => {
8966
- exitWith(await runConductor());
9343
+ ).option("--check", "Report what a tick would do, including pending prunes, and change nothing; exit 1 on a problem").option("--dry-run", "Run the model for each owed reply and print it instead of posting; no lock, no prune").action(async (options) => {
9344
+ exitWith(await runConductor(options));
8967
9345
  });
8968
9346
  conductorCommand.command("add <id>").description("Watch an issue or pull request and make do-work pick it up (an issue also follows its linked PR)").action((id) => {
8969
9347
  exitWith(runWatchAdd(id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "automata-cli",
3
- "version": "0.11.0-develop.514",
3
+ "version": "0.11.0-develop.526",
4
4
  "description": "Automata CLI tool",
5
5
  "type": "module",
6
6
  "engines": {