automata-cli 0.11.0-develop.519 → 0.11.0-develop.529

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 +199 -53
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -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;
@@ -8422,6 +8438,44 @@ function toPlanJson(decision) {
8422
8438
  // src/commands/conductor.ts
8423
8439
  import { Command as Command7 } from "commander";
8424
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
+
8425
8479
  // src/conductor/replyDecision.ts
8426
8480
  function isWatchClosed(state) {
8427
8481
  return state === "closed";
@@ -8482,10 +8536,33 @@ function decideConversation(conversation, p) {
8482
8536
  reason: `${p.agentUser}'s message at ${newest2.createdAt} has no answer from an allowed user`
8483
8537
  };
8484
8538
  }
8485
- function decideConductorReply(conversations, p) {
8486
- 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
+ );
8487
8545
  return { needsReply: decisions.some((decision) => decision.kind === "reply"), decisions };
8488
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
+ }
8489
8566
 
8490
8567
  // src/conductor/thread.ts
8491
8568
  function defaultReplyTarget(thread) {
@@ -8497,13 +8574,19 @@ function postingInstruction(target) {
8497
8574
  return [
8498
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.`,
8499
8576
  `Run \`gh ${command} comment ${number} --body-file -\` and give the body on standard input, for example with a here-document.`,
8500
- "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()
8501
8580
  ].join("\n");
8502
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
+ }
8503
8585
  function dryRunInstruction(target) {
8504
8586
  return [
8505
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.`,
8506
- "Make the reply, and nothing else, your final message. Do not approve, merge or close anything."
8588
+ "Make the reply, and nothing else, your final message. Do not approve, merge or close anything.",
8589
+ needsHumanInstruction()
8507
8590
  ].join("\n");
8508
8591
  }
8509
8592
  function visible(messages, participants) {
@@ -8730,6 +8813,10 @@ function watchSections(watched) {
8730
8813
  listLines.push(`${name}: closed, a tick would drop it from the watch list`);
8731
8814
  continue;
8732
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
+ }
8733
8820
  listLines.push(`${name}: open`);
8734
8821
  for (const decision of entry.decisions) replyLines.push(decisionLine(decision));
8735
8822
  }
@@ -8853,24 +8940,25 @@ function readWatchedItem(id, linkMap = getOpenPrLinkMap) {
8853
8940
  const target = getWatchTarget(id);
8854
8941
  if (target.kind === "pr") {
8855
8942
  const pr = readPr(id);
8856
- 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 };
8857
8944
  }
8858
8945
  const issue = getIssueSurface(id);
8859
8946
  const prs = (linkMap().byIssue.get(id) ?? []).map((ref) => readPr(ref.number));
8860
8947
  return {
8948
+ target,
8861
8949
  thread: { kind: "issue", issue, prs },
8862
- conversations: [issueConversation(issue), ...prs.map((pr) => prConversation(pr.surface))]
8950
+ conversations: [issueConversation(issue), ...prs.map((pr) => prConversation(pr.surface))],
8951
+ labels: target.labels
8863
8952
  };
8864
8953
  }
8865
8954
  function readTargetMessages(target) {
8866
8955
  return target.kind === "issue" ? issueConversation(getIssueSurface(target.number)).messages : prConversation(getPrSurface(target.number)).messages;
8867
8956
  }
8868
- function runModel(prompt, execution, replyTo) {
8957
+ function runModel(prompt, execution, replyTo, sink2) {
8869
8958
  const { owner, repo } = getRepoSlug();
8870
- return runClaude(prompt, { model: execution.model, effort: execution.effort, readOnly: true, replyTo, ghRepo: `${owner}/${repo}` });
8959
+ return runClaude(prompt, { model: execution.model, effort: execution.effort, readOnly: true, replyTo, ghRepo: `${owner}/${repo}`, sink: sink2 });
8871
8960
  }
8872
- async function runModelForText(prompt, execution) {
8873
- const { owner, repo } = getRepoSlug();
8961
+ function captureStdout() {
8874
8962
  let stdout = "";
8875
8963
  const sink2 = {
8876
8964
  output: (stream, text) => {
@@ -8878,30 +8966,76 @@ async function runModelForText(prompt, execution) {
8878
8966
  },
8879
8967
  exited: () => void 0
8880
8968
  };
8881
- const options = { model: execution.model, effort: execution.effort, readOnly: true, ghRepo: `${owner}/${repo}`, sink: sink2 };
8969
+ return { sink: sink2, text: () => stdout };
8970
+ }
8971
+ async function runModelForText(prompt, execution) {
8972
+ const { owner, repo } = getRepoSlug();
8973
+ const captured = captureStdout();
8974
+ const options = { model: execution.model, effort: execution.effort, readOnly: true, ghRepo: `${owner}/${repo}`, sink: captured.sink };
8882
8975
  await runClaude(prompt, options);
8883
- return extractDryRunReply(execution.executor, stdout);
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.`;
8884
8983
  }
8885
8984
  function targetKey(target) {
8886
8985
  return `${target.kind}#${String(target.number)}`;
8887
8986
  }
8888
- async function conductItem(id, config, login2, participants, execution, dryRun) {
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";
9018
+ }
9019
+ async function conductItem(id, config, login2, participants, execution, dryRun, linkMap) {
8889
9020
  const label2 = `#${String(id)}`;
8890
9021
  let item;
8891
9022
  try {
8892
- item = readWatchedItem(id, dryRun?.linkMap);
9023
+ item = readWatchedItem(id, dryRun?.linkMap ?? linkMap);
8893
9024
  } catch (err) {
8894
9025
  process.stderr.write(`Warning: could not read ${label2}, skipping it this tick: ${err.message}
8895
9026
  `);
8896
9027
  return "idle";
8897
9028
  }
8898
9029
  const { thread, conversations } = item;
8899
- const verdict = decideConductorReply(conversations, participants);
9030
+ const loop = loopStateOf(conversations, item.labels, resolveMaxReplies(config.conductor));
9031
+ const verdict = decideConductorReply(conversations, participants, loop);
8900
9032
  const owed = verdict.decisions.find(
8901
9033
  (decision) => decision.kind === "reply" && dryRun?.answered.has(targetKey(decision.surface)) !== true
8902
9034
  );
8903
9035
  if (owed === void 0) {
8904
- process.stdout.write(`Conductor: ${label2} 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}
8905
9039
  `);
8906
9040
  return "idle";
8907
9041
  }
@@ -8911,25 +9045,17 @@ async function conductItem(id, config, login2, participants, execution, dryRun)
8911
9045
  const where = `${replyTo.kind === "pr" ? "pull request" : "issue"} #${String(replyTo.number)}`;
8912
9046
  process.stdout.write(`Conductor: ${label2} needs a reply on ${where}; running ${describeConductorExecution(execution)}.
8913
9047
  `);
8914
- if (dryRun) {
8915
- const reply = await runModelForText(prompt, execution);
8916
- if (reply === null) {
8917
- process.stderr.write(`Error: the dry run for ${label2} produced no reply text.
8918
- `);
8919
- return "failed";
8920
- }
8921
- dryRun.answered.add(targetKey(replyTo));
8922
- process.stdout.write(`--- Dry run: reply for ${where} (not posted) ---
8923
- ${reply}
8924
- ---
8925
- `);
8926
- return "replied";
8927
- }
9048
+ if (dryRun) return conductDryRunItem(prompt, execution, label2, where, replyTo, dryRun);
9049
+ const captured = captureStdout();
8928
9050
  const outcome = await conductReply({
8929
9051
  login: login2,
8930
9052
  read: () => readTargetMessages(replyTo),
8931
- run: () => runModel(prompt, execution, replyTo)
9053
+ run: () => runModel(prompt, execution, replyTo, captured.sink)
8932
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
+ }
8933
9059
  switch (outcome.kind) {
8934
9060
  case "posted":
8935
9061
  process.stdout.write(`Conductor: posted a reply on ${where}.
@@ -9062,6 +9188,8 @@ function preflight() {
9062
9188
  }
9063
9189
  const executionProblem = conductorExecutionProblem(config.conductor);
9064
9190
  if (executionProblem !== null) return { problem: executionProblem };
9191
+ const maxReplies = maxRepliesProblem(config.conductor);
9192
+ if (maxReplies !== null) return { problem: maxReplies };
9065
9193
  if (config.conductor?.executor === "codex") {
9066
9194
  return { problem: "conductor.executor codex is not supported: Codex cannot be limited to comments on the reply target. Use claude." };
9067
9195
  }
@@ -9075,12 +9203,14 @@ function preflight() {
9075
9203
  function staleMinutesOf(config) {
9076
9204
  return config.doWork?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
9077
9205
  }
9078
- function checkWatched(id, participants, linkMap) {
9206
+ function checkWatched(id, participants, maxReplies, linkMap, coveredBy) {
9079
9207
  try {
9080
9208
  const target = getWatchTarget(id);
9081
9209
  if (isWatchClosed(target.state)) return { id, kind: target.kind, state: "closed", decisions: [] };
9082
- const { conversations } = readWatchedItem(id, linkMap);
9083
- return { id, kind: target.kind, state: "open", decisions: decideConductorReply(conversations, participants).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 };
9084
9214
  } catch (err) {
9085
9215
  return { id, unavailable: err.message };
9086
9216
  }
@@ -9095,6 +9225,8 @@ function runConductorCheck() {
9095
9225
  } catch {
9096
9226
  }
9097
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);
9098
9230
  const report = "problem" in checked ? buildConductorCheck({
9099
9231
  generatedAt,
9100
9232
  repo,
@@ -9112,7 +9244,7 @@ function runConductorCheck() {
9112
9244
  execution: describeConductorExecution(checked.execution),
9113
9245
  lock: inspectConductorLock(staleMinutesOf(checked.config)),
9114
9246
  staleMinutes: staleMinutesOf(checked.config),
9115
- watched: normalizeWatch(checked.config.conductor?.watch).map((id) => checkWatched(id, checked.participants, linkMap))
9247
+ watched: watchIds.map((id) => checkWatched(id, checked.participants, resolveMaxReplies(checked.config.conductor), linkMap, covered.get(id)))
9116
9248
  });
9117
9249
  process.stdout.write(report.text);
9118
9250
  return report.exitCode;
@@ -9128,7 +9260,9 @@ async function runConductorDryRun(checked) {
9128
9260
  `);
9129
9261
  let failed = 0;
9130
9262
  const context = { linkMap: onceLinkMap(), answered: /* @__PURE__ */ new Set() };
9131
- for (const id of normalizeWatch(config.conductor?.watch)) {
9263
+ const watchIds = normalizeWatch(config.conductor?.watch);
9264
+ const covered = coveredOf(watchIds, context.linkMap);
9265
+ for (const id of watchIds) {
9132
9266
  try {
9133
9267
  if (isWatchClosed(getWatchTarget(id).state)) {
9134
9268
  process.stdout.write(`Conductor: #${String(id)} is closed; a tick would drop it from the watch list.
@@ -9137,6 +9271,12 @@ async function runConductorDryRun(checked) {
9137
9271
  }
9138
9272
  } catch {
9139
9273
  }
9274
+ const coveredBy = covered.get(id);
9275
+ if (coveredBy !== void 0) {
9276
+ process.stdout.write(`${coveredNote(id, coveredBy)}
9277
+ `);
9278
+ continue;
9279
+ }
9140
9280
  try {
9141
9281
  if (await conductItem(id, config, login2, participants, execution, context) === "failed") failed++;
9142
9282
  } catch (err) {
@@ -9147,6 +9287,18 @@ async function runConductorDryRun(checked) {
9147
9287
  }
9148
9288
  return failed > 0 ? 1 : 0;
9149
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.
9293
+ `
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.
9298
+ `
9299
+ );
9300
+ return 2;
9301
+ }
9150
9302
  async function runConductor(options = {}) {
9151
9303
  if (options.check === true && options.dryRun === true) {
9152
9304
  return fail2("--check and --dry-run cannot be used together.");
@@ -9158,29 +9310,23 @@ async function runConductor(options = {}) {
9158
9310
  const { config, login: login2, participants, execution } = checked;
9159
9311
  const staleMinutes = config.doWork?.lockStaleMinutes ?? DEFAULT_DO_WORK.lockStaleMinutes;
9160
9312
  const lock = acquireConductorLock(staleMinutes);
9161
- if (!lock.ok) {
9162
- const held = lock.heldBy;
9163
- process.stdout.write(
9164
- `Another conductor is already running here (pid ${String(held.pid)} on ${held.host}, started ${held.startedAt}). Doing nothing.
9165
- `
9166
- );
9167
- if (lock.suspect) {
9168
- process.stderr.write(
9169
- `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.
9170
- `
9171
- );
9172
- return 2;
9173
- }
9174
- return 0;
9175
- }
9313
+ if (!lock.ok) return reportHeldLock(lock.heldBy, lock.suspect, staleMinutes);
9176
9314
  try {
9177
9315
  process.stdout.write(`Conductor: running as ${login2}.
9178
9316
  `);
9179
9317
  const watch = pruneWatchList(config);
9180
9318
  let failed = 0;
9319
+ const linkMap = onceLinkMap();
9320
+ const covered = coveredOf(watch, linkMap);
9181
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
+ }
9182
9328
  try {
9183
- if (await conductItem(id, config, login2, participants, execution) === "failed") failed++;
9329
+ if (await conductItem(id, config, login2, participants, execution, void 0, linkMap) === "failed") failed++;
9184
9330
  } catch (err) {
9185
9331
  process.stderr.write(`Error: #${String(id)} could not be conducted: ${err.message}
9186
9332
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "automata-cli",
3
- "version": "0.11.0-develop.519",
3
+ "version": "0.11.0-develop.529",
4
4
  "description": "Automata CLI tool",
5
5
  "type": "module",
6
6
  "engines": {