automata-cli 0.6.0-develop.293 → 0.6.0-develop.300

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/README.md CHANGED
@@ -129,7 +129,7 @@ See [docs/execute-prompt.md](docs/execute-prompt.md) for full details.
129
129
 
130
130
  ## `automata do-work`
131
131
 
132
- One tick of the autonomous loop: answer the open issues whose newest message from an authorized account the agent has not answered. Reference: [docs/do-work.md](docs/do-work.md). Process, trust model and setup: [the wiki](docs/wiki/Home.md).
132
+ One tick of the autonomous loop: answer the open issues whose newest message from an authorized account the agent has not answered, then do the same for the open pull requests that close no issue of this repository. Reference: [docs/do-work.md](docs/do-work.md). Process, trust model and setup: [the wiki](docs/wiki/Home.md).
133
133
 
134
134
  ---
135
135
 
@@ -3,13 +3,14 @@ import {
3
3
  DEFAULT_CHECK_ISSUE_PROMPT,
4
4
  DEFAULT_DO_WORK,
5
5
  DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT,
6
+ DEFAULT_DO_WORK_PR_ORPHAN_PROMPT,
6
7
  DEFAULT_DO_WORK_PR_WORK_PROMPT,
7
8
  DEFAULT_FIX_COMMENTS_PROMPT,
8
9
  DEFAULT_SONAR_PROMPT,
9
10
  readConfig,
10
11
  readRawConfig,
11
12
  writeConfig
12
- } from "./chunk-LTCVLM4I.js";
13
+ } from "./chunk-MIPBYVK5.js";
13
14
 
14
15
  // src/config/ConfigWizard.tsx
15
16
  import { useState } from "react";
@@ -41,7 +42,8 @@ var PROMPTS_MENU_OPTIONS = [
41
42
  "Fix-Comments",
42
43
  "Check-Issue",
43
44
  "Do Work \u2014 Discuss",
44
- "Do Work \u2014 PR"
45
+ "Do Work \u2014 PR",
46
+ "Do Work \u2014 Orphan PR"
45
47
  ];
46
48
  function parseAllowedUsers(value) {
47
49
  return value.split(",").map((user) => user.trim()).filter((user) => user.length > 0);
@@ -121,7 +123,8 @@ var PROMPT_SCREEN_BY_OPTION = {
121
123
  "Fix-Comments": "fix-comments-prompt",
122
124
  "Check-Issue": "check-issue-prompt",
123
125
  "Do Work \u2014 Discuss": "do-work-discuss-prompt",
124
- "Do Work \u2014 PR": "do-work-pr-prompt"
126
+ "Do Work \u2014 PR": "do-work-pr-prompt",
127
+ "Do Work \u2014 Orphan PR": "do-work-pr-orphan-prompt"
125
128
  };
126
129
  function ConfigWizard() {
127
130
  const existing = readConfig();
@@ -167,6 +170,9 @@ function ConfigWizard() {
167
170
  const [doWorkPrPrompt, setDoWorkPrPrompt] = useState(
168
171
  existing.doWork?.prompts?.prWork ?? DEFAULT_DO_WORK_PR_WORK_PROMPT
169
172
  );
173
+ const [doWorkPrOrphanPrompt, setDoWorkPrOrphanPrompt] = useState(
174
+ existing.doWork?.prompts?.prOrphan ?? DEFAULT_DO_WORK_PR_ORPHAN_PROMPT
175
+ );
170
176
  const [pendingRemote, setPendingRemote] = useState(existing.remoteType ?? "gh");
171
177
  const [pendingTechnique, setPendingTechnique] = useState(
172
178
  existing.issueDiscoveryTechnique ?? "label"
@@ -362,6 +368,17 @@ function ConfigWizard() {
362
368
  setScreen("prompts-menu");
363
369
  },
364
370
  onBack: () => setScreen("prompts-menu")
371
+ },
372
+ "do-work-pr-orphan-prompt": {
373
+ setValue: setDoWorkPrOrphanPrompt,
374
+ onSubmit: () => {
375
+ savePrompt("do-work-pr-orphan.md", doWorkPrOrphanPrompt, (value, current) => ({
376
+ ...current,
377
+ doWork: { ...current.doWork, prompts: { ...current.doWork?.prompts, prOrphan: value } }
378
+ }));
379
+ setScreen("prompts-menu");
380
+ },
381
+ onBack: () => setScreen("prompts-menu")
365
382
  }
366
383
  };
367
384
  const menus = {
@@ -527,6 +544,12 @@ function ConfigWizard() {
527
544
  label: "Pull request turn instructions:",
528
545
  value: doWorkPrPrompt,
529
546
  hint: `Type prompt \xB7 Enter to save \xB7 ${BACK}`
547
+ },
548
+ "do-work-pr-orphan-prompt": {
549
+ title: "Prompts \u2014 Do Work \u2014 Orphan PR",
550
+ label: "Instructions for a pull request with no linked issue:",
551
+ value: doWorkPrOrphanPrompt,
552
+ hint: `Type prompt \xB7 Enter to save \xB7 ${BACK}`
530
553
  }
531
554
  };
532
555
  const menuViews = {
@@ -16,6 +16,7 @@ var DEFAULT_DO_WORK = {
16
16
  };
17
17
  var DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT = "You are the agent named in the context below, working on a GitHub issue together with the people allowed to instruct you. Answer the messages marked NEW; the earlier messages are context only.\n\nDo not modify, create or delete any file, and do not create a branch or a pull request, UNLESS a message marked NEW explicitly asks you to implement the work. If it does: create a branch off the base branch named below, implement the change following the project's existing conventions, run the tests and the linter, and open a pull request whose body contains `Closes #<issue number>`.\n\nOtherwise do not touch the code at all: reply on the issue with the specification, the plan, or the open questions you need answered. Keep the reply short and concrete.\n\nEither way, always post a reply on the issue before you finish \u2014 including when you implemented and opened a pull request. Silence is indistinguishable from a crash, and the run will be reported as having produced no answer.";
18
18
  var DEFAULT_DO_WORK_PR_WORK_PROMPT = "You are the agent named in the context below, working on the pull request for a GitHub issue together with the people allowed to instruct you. Work on the branch named below, which is already checked out and up to date.\n\nAddress every message marked NEW and every unresolved review thread listed. Follow the project's existing conventions, run the tests and the linter, then commit and push to that branch. Do not merge the pull request and do not push to the base branch.\n\nReply on the pull request with a short summary of what you changed, or reply in the review thread when your answer belongs to a specific comment. Always post a reply \u2014 silence looks like a crash.";
19
+ var DEFAULT_DO_WORK_PR_ORPHAN_PROMPT = "You are the agent named in the context below, working on a pull request that is not linked to any issue \u2014 a dependency bump, or another change opened without one \u2014 together with the people allowed to instruct you. Work on the branch named below, which is already checked out and up to date.\n\nAddress every message marked NEW and every unresolved review thread listed. Typical work here is finding out why the checks are failing, bringing the branch up to date with the base branch, and fixing whatever the change needs in order to be mergeable. Follow the project's existing conventions, run the tests and the linter, then commit and push to that branch.\n\nDo not merge the pull request, do not close it, and do not push to the base branch. If you conclude that it should be merged or closed, say so in your reply and leave the decision to the humans.\n\nReply on the pull request with a short summary of what you did and what you recommend, or reply in the review thread when your answer belongs to a specific comment. Always post a reply \u2014 silence looks like a crash.";
19
20
  var CONFIG_DIR = ".automata";
20
21
  var CONFIG_FILE = "config.json";
21
22
  function configPath() {
@@ -81,6 +82,9 @@ function readConfig() {
81
82
  if (config.doWork?.prompts?.prWork) {
82
83
  config.doWork.prompts.prWork = resolvePromptRef(config.doWork.prompts.prWork, dir);
83
84
  }
85
+ if (config.doWork?.prompts?.prOrphan) {
86
+ config.doWork.prompts.prOrphan = resolvePromptRef(config.doWork.prompts.prOrphan, dir);
87
+ }
84
88
  return config;
85
89
  }
86
90
  function writeConfig(config) {
@@ -97,6 +101,7 @@ export {
97
101
  DEFAULT_DO_WORK,
98
102
  DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT,
99
103
  DEFAULT_DO_WORK_PR_WORK_PROMPT,
104
+ DEFAULT_DO_WORK_PR_ORPHAN_PROMPT,
100
105
  readRawConfig,
101
106
  readConfig,
102
107
  writeConfig
package/dist/index.js CHANGED
@@ -4,13 +4,14 @@ import {
4
4
  DEFAULT_CLAUDE_SYSTEM_PROMPT,
5
5
  DEFAULT_DO_WORK,
6
6
  DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT,
7
+ DEFAULT_DO_WORK_PR_ORPHAN_PROMPT,
7
8
  DEFAULT_DO_WORK_PR_WORK_PROMPT,
8
9
  DEFAULT_FIX_COMMENTS_PROMPT,
9
10
  DEFAULT_SONAR_PROMPT,
10
11
  readConfig,
11
12
  readRawConfig,
12
13
  writeConfig
13
- } from "./chunk-LTCVLM4I.js";
14
+ } from "./chunk-MIPBYVK5.js";
14
15
 
15
16
  // src/index.ts
16
17
  import { Command as Command7 } from "commander";
@@ -28,7 +29,7 @@ import { Command } from "commander";
28
29
  var VALID_TYPES = ["gh", "azdo"];
29
30
  var VALID_TECHNIQUES = ["label", "assignee", "title-contains"];
30
31
  var VALID_EXECUTORS = ["claude", "codex"];
31
- var VALID_TURN_KINDS = ["issue-discuss", "pr-work"];
32
+ var VALID_TURN_KINDS = ["issue-discuss", "pr-work", "pr-orphan"];
32
33
  function writeDoWork(patch) {
33
34
  const current = readRawConfig();
34
35
  writeConfig({ ...current, doWork: { ...current.doWork, ...patch } });
@@ -198,10 +199,16 @@ var configSetDoWorkPrompt = new Command("do-work-prompt").description("Set the t
198
199
  }
199
200
  const current = readRawConfig();
200
201
  const prompts = { ...current.doWork?.prompts };
201
- if (turnKind === "issue-discuss") {
202
- prompts.issueDiscuss = prompt;
203
- } else {
204
- prompts.prWork = prompt;
202
+ switch (turnKind) {
203
+ case "issue-discuss":
204
+ prompts.issueDiscuss = prompt;
205
+ break;
206
+ case "pr-work":
207
+ prompts.prWork = prompt;
208
+ break;
209
+ case "pr-orphan":
210
+ prompts.prOrphan = prompt;
211
+ break;
205
212
  }
206
213
  writeDoWork({ prompts });
207
214
  process.stdout.write(`do-work ${turnKind} prompt set.
@@ -212,7 +219,7 @@ var configCommand = new Command("config").description("Configure automata settin
212
219
  const [{ render }, React, { ConfigWizard }] = await Promise.all([
213
220
  import("ink"),
214
221
  import("react"),
215
- import("./ConfigWizard-HDHKPY6J.js")
222
+ import("./ConfigWizard-D6NYL2E6.js")
216
223
  ]);
217
224
  const { waitUntilExit } = render(React.createElement(ConfigWizard));
218
225
  await waitUntilExit();
@@ -766,6 +773,18 @@ function fetchBranch(branch) {
766
773
  function pullFastForwardOnly(branch) {
767
774
  return gitCommand(branch === void 0 ? ["pull", "--ff-only"] : ["pull", "--ff-only", "origin", branch]);
768
775
  }
776
+ function revParse(ref) {
777
+ const { stdout, status } = run2("git", ["rev-parse", "--verify", "--quiet", ref]);
778
+ if (status !== 0) return null;
779
+ const sha = stdout.trim();
780
+ return sha.length === 0 ? null : sha;
781
+ }
782
+ function isAncestorCommit(maybeAncestor, descendant) {
783
+ return run2("git", ["merge-base", "--is-ancestor", maybeAncestor, descendant]).status === 0;
784
+ }
785
+ function resetHardTo(ref) {
786
+ return gitCommand(["reset", "--hard", ref]);
787
+ }
769
788
  function fetchPrune() {
770
789
  const result = run2("git", ["fetch", "--prune"]);
771
790
  if (result.status !== 0) {
@@ -2567,6 +2586,8 @@ query($owner:String!,$repo:String!,$cursor:String){
2567
2586
  pageInfo{ hasNextPage endCursor }
2568
2587
  nodes{
2569
2588
  number url title headRefName baseRefName isCrossRepository isDraft updatedAt
2589
+ labels(first:50){ nodes{ name } }
2590
+ assignees(first:50){ nodes{ login } }
2570
2591
  closingIssuesReferences(first:50){
2571
2592
  pageInfo{ hasNextPage }
2572
2593
  nodes{ number repository{ nameWithOwner } }
@@ -2577,24 +2598,16 @@ query($owner:String!,$repo:String!,$cursor:String){
2577
2598
  }`.trim();
2578
2599
  var MAX_LINK_MAP_PAGES = 50;
2579
2600
  function indexPullRequest(map, node, nameWithOwner) {
2580
- const ref = {
2581
- number: node.number,
2582
- url: node.url,
2583
- title: node.title,
2584
- headRefName: node.headRefName,
2585
- baseRefName: node.baseRefName,
2586
- isCrossRepository: node.isCrossRepository,
2587
- state: "OPEN",
2588
- isDraft: node.isDraft,
2589
- updatedAt: node.updatedAt
2590
- };
2601
+ const ref = toPullRequestRef(node);
2591
2602
  if (node.closingIssuesReferences.pageInfo?.hasNextPage) {
2592
2603
  throw new Error(
2593
2604
  `Pull request #${String(node.number)} closes more than 50 issues, so the issue-to-pull-request map cannot be read completely. Refusing the tick rather than risk starting work on an issue that already has a pull request.`
2594
2605
  );
2595
2606
  }
2607
+ let closedAnyHere = false;
2596
2608
  for (const issue of node.closingIssuesReferences.nodes) {
2597
- if (issue.repository.nameWithOwner !== nameWithOwner) continue;
2609
+ if (issue.repository.nameWithOwner.toLowerCase() !== nameWithOwner.toLowerCase()) continue;
2610
+ closedAnyHere = true;
2598
2611
  const existing = map.get(issue.number);
2599
2612
  if (existing) {
2600
2613
  existing.push(ref);
@@ -2602,10 +2615,32 @@ function indexPullRequest(map, node, nameWithOwner) {
2602
2615
  map.set(issue.number, [ref]);
2603
2616
  }
2604
2617
  }
2618
+ return closedAnyHere;
2619
+ }
2620
+ function toPullRequestRef(node) {
2621
+ return {
2622
+ number: node.number,
2623
+ url: node.url,
2624
+ title: node.title,
2625
+ headRefName: node.headRefName,
2626
+ baseRefName: node.baseRefName,
2627
+ isCrossRepository: node.isCrossRepository,
2628
+ state: "OPEN",
2629
+ isDraft: node.isDraft,
2630
+ updatedAt: node.updatedAt
2631
+ };
2632
+ }
2633
+ function toOrphanPr(node) {
2634
+ return {
2635
+ pr: toPullRequestRef(node),
2636
+ labels: (node.labels?.nodes ?? []).map((label) => label.name ?? "").filter((name) => name.length > 0),
2637
+ assignees: (node.assignees?.nodes ?? []).map((assignee) => assignee.login ?? "").filter((login2) => login2.length > 0)
2638
+ };
2605
2639
  }
2606
2640
  function getOpenPrLinkMap() {
2607
2641
  const { owner, repo } = getRepoSlug();
2608
2642
  const map = /* @__PURE__ */ new Map();
2643
+ const orphans = [];
2609
2644
  let defaultBranch = null;
2610
2645
  let cursor = null;
2611
2646
  for (let page = 0; page < MAX_LINK_MAP_PAGES; page++) {
@@ -2615,10 +2650,12 @@ function getOpenPrLinkMap() {
2615
2650
  defaultBranch = response.data.repository.defaultBranchRef?.name ?? defaultBranch;
2616
2651
  const connection = response.data.repository.pullRequests;
2617
2652
  for (const node of connection.nodes) {
2618
- indexPullRequest(map, node, `${owner}/${repo}`);
2653
+ if (!indexPullRequest(map, node, `${owner}/${repo}`)) {
2654
+ orphans.push(toOrphanPr(node));
2655
+ }
2619
2656
  }
2620
2657
  if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
2621
- return { byIssue: map, defaultBranch };
2658
+ return { byIssue: map, defaultBranch, orphans };
2622
2659
  }
2623
2660
  cursor = connection.pageInfo.endCursor;
2624
2661
  }
@@ -2811,14 +2848,15 @@ function toHeadState(state) {
2811
2848
  }
2812
2849
  function listPullRequestsForHead(branch) {
2813
2850
  const raw = ghJson(
2814
- ["pr", "list", "--head", branch, "--state", "all", "--json", "number,state,url,updatedAt"],
2851
+ ["pr", "list", "--head", branch, "--state", "all", "--json", "number,state,url,updatedAt,author"],
2815
2852
  `list pull requests for branch ${branch}`
2816
2853
  );
2817
2854
  return raw.map((pr) => ({
2818
2855
  number: pr.number,
2819
2856
  url: pr.url,
2820
2857
  state: toHeadState(pr.state),
2821
- updatedAt: pr.updatedAt
2858
+ updatedAt: pr.updatedAt,
2859
+ author: pr.author?.login ?? ""
2822
2860
  })).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
2823
2861
  }
2824
2862
  var MISSING_LABEL_PATTERNS = [
@@ -2870,6 +2908,13 @@ function parseCreatedPrUrl(stdout, head) {
2870
2908
  }
2871
2909
 
2872
2910
  // src/github/workDetection.ts
2911
+ var NO_ISSUE_MESSAGES = {
2912
+ messages: [],
2913
+ newMessages: [],
2914
+ newMessageCount: 0,
2915
+ hasNewMessage: false,
2916
+ lastAgentAt: null
2917
+ };
2873
2918
  function isAssignedToAgent(assignees, agentUser) {
2874
2919
  const agent = agentUser.toLowerCase();
2875
2920
  return assignees.some((name) => name.toLowerCase() === agent);
@@ -2909,6 +2954,7 @@ function unsafeBranchSkip(pr, issue, policy) {
2909
2954
  return {
2910
2955
  kind: "skip",
2911
2956
  issue,
2957
+ pr,
2912
2958
  reason: "unsafe-pr-branch",
2913
2959
  detail: `pull request #${String(pr.number)} comes from a fork; its head branch is not in this repository`
2914
2960
  };
@@ -2917,18 +2963,24 @@ function unsafeBranchSkip(pr, issue, policy) {
2917
2963
  return {
2918
2964
  kind: "skip",
2919
2965
  issue,
2966
+ pr,
2920
2967
  reason: "unsafe-pr-branch",
2921
2968
  detail: `pull request #${String(pr.number)} has a protected branch (${pr.headRefName}) as its head, so a turn would have to push to it`
2922
2969
  };
2923
2970
  }
2924
2971
  return null;
2925
2972
  }
2973
+ function analysePrSurface(surface, p) {
2974
+ const agentThreadMessages = surface.threads.flatMap((thread) => thread.comments).filter((comment) => comment.author.toLowerCase() === p.agentUser.toLowerCase());
2975
+ const prAnalysis = analyzeSurface([...surface.messages, ...agentThreadMessages], p);
2976
+ return { prAnalysis, actionableThreads: findActionableThreads(surface.threads, p, prAnalysis.lastAgentAt) };
2977
+ }
2926
2978
  function decideWork(state, p, policy) {
2927
2979
  const baseBranch = policy.baseBranch;
2928
2980
  const { issueSurface, prSurface } = state;
2929
2981
  const issue = issueSurface.issue;
2930
2982
  if (issueSurface.state === "CLOSED") {
2931
- return { kind: "skip", issue, reason: "issue-closed", detail: "the issue is closed" };
2983
+ return { kind: "skip", issue, pr: null, reason: "issue-closed", detail: "the issue is closed" };
2932
2984
  }
2933
2985
  const issueAnalysis = analyzeSurface(issueSurface.messages, p);
2934
2986
  const needsAssignment = !isAssignedToAgent(issueSurface.assignees, p.agentUser);
@@ -2939,6 +2991,7 @@ function decideWork(state, p, policy) {
2939
2991
  return {
2940
2992
  kind: "skip",
2941
2993
  issue,
2994
+ pr: null,
2942
2995
  reason: "no-new-messages",
2943
2996
  detail: issueAnalysis.lastAgentAt === null ? "no messages from authorized accounts" : `nothing new since the agent's message at ${issueAnalysis.lastAgentAt}`
2944
2997
  };
@@ -2962,14 +3015,13 @@ function decideWork(state, p, policy) {
2962
3015
  const surface = prSurface;
2963
3016
  const unsafe = unsafeBranchSkip(surface.pr, issue, policy);
2964
3017
  if (unsafe !== null) return unsafe;
2965
- const agentThreadMessages = surface.threads.flatMap((thread) => thread.comments).filter((comment) => comment.author.toLowerCase() === p.agentUser.toLowerCase());
2966
- const prAnalysis = analyzeSurface([...surface.messages, ...agentThreadMessages], p);
2967
- const actionableThreads = findActionableThreads(surface.threads, p, prAnalysis.lastAgentAt);
3018
+ const { prAnalysis, actionableThreads } = analysePrSurface(surface, p);
2968
3019
  const hasPrWork = prAnalysis.hasNewMessage || actionableThreads.length > 0;
2969
3020
  if (!hasPrWork && !issueAnalysis.hasNewMessage) {
2970
3021
  return {
2971
3022
  kind: "skip",
2972
3023
  issue,
3024
+ pr: surface.pr,
2973
3025
  reason: "no-new-messages",
2974
3026
  detail: `nothing new on issue or pull request #${String(surface.pr.number)}`
2975
3027
  };
@@ -3002,12 +3054,87 @@ function decideWork(state, p, policy) {
3002
3054
  }
3003
3055
  };
3004
3056
  }
3057
+ function decideOrphanPrWork(state, p, policy) {
3058
+ const surface = state.prSurface;
3059
+ const pr = surface.pr;
3060
+ if (pr.state !== "OPEN") {
3061
+ return {
3062
+ kind: "skip",
3063
+ issue: null,
3064
+ pr,
3065
+ reason: "pr-closed",
3066
+ detail: `pull request #${String(pr.number)} is ${pr.state.toLowerCase()}`
3067
+ };
3068
+ }
3069
+ const unsafe = unsafeBranchSkip(pr, null, policy);
3070
+ if (unsafe !== null) return unsafe;
3071
+ const { prAnalysis, actionableThreads } = analysePrSurface(surface, p);
3072
+ if (!prAnalysis.hasNewMessage && actionableThreads.length === 0) {
3073
+ return {
3074
+ kind: "skip",
3075
+ issue: null,
3076
+ pr,
3077
+ reason: "no-new-messages",
3078
+ detail: prAnalysis.lastAgentAt === null ? `no messages from authorized accounts on pull request #${String(pr.number)}` : `nothing new on pull request #${String(pr.number)} since the agent's message at ${prAnalysis.lastAgentAt}`
3079
+ };
3080
+ }
3081
+ const reasons = [];
3082
+ if (prAnalysis.hasNewMessage) {
3083
+ reasons.push(plural(prAnalysis.newMessageCount, "new pull request message"));
3084
+ }
3085
+ if (actionableThreads.length > 0) {
3086
+ reasons.push(plural(actionableThreads.length, "unresolved review thread"));
3087
+ }
3088
+ return {
3089
+ kind: "work",
3090
+ item: {
3091
+ issue: null,
3092
+ turn: "pr-orphan",
3093
+ pr,
3094
+ branch: pr.headRefName,
3095
+ // Assignment is an issue mechanism: it makes the claim visible in the
3096
+ // issue list, and with `issueDiscoveryTechnique: assignee` assigning the
3097
+ // agent here would change what the discovery filter matches next tick.
3098
+ // The `working…` marker on the pull request is the claim.
3099
+ needsAssignment: false,
3100
+ issueAnalysis: NO_ISSUE_MESSAGES,
3101
+ prAnalysis,
3102
+ actionableThreads,
3103
+ reason: `${reasons.join(", ")} on pull request #${String(pr.number)} (no linked issue)`,
3104
+ ambiguousPrs: []
3105
+ }
3106
+ };
3107
+ }
3108
+ function skipDuplicateHeadBranches(decisions) {
3109
+ const owners = /* @__PURE__ */ new Map();
3110
+ return decisions.map((decision) => {
3111
+ if (decision.kind !== "work") return decision;
3112
+ const { item } = decision;
3113
+ if (item.turn === "issue-discuss" || item.pr === null) return decision;
3114
+ const owner = owners.get(item.branch);
3115
+ if (owner === void 0) {
3116
+ owners.set(item.branch, item.pr);
3117
+ return decision;
3118
+ }
3119
+ return {
3120
+ kind: "skip",
3121
+ issue: item.issue,
3122
+ pr: item.pr,
3123
+ reason: "branch-busy",
3124
+ detail: `pull request #${String(item.pr.number)} shares its head branch (${item.branch}) with pull request #${String(owner.number)}, which this tick works on first`
3125
+ };
3126
+ });
3127
+ }
3005
3128
  function selectLinkedPr(linkedPrs) {
3006
3129
  const open = linkedPrs.filter((pr) => pr.state === "OPEN");
3007
3130
  return open.length === 0 ? null : newestPr(open);
3008
3131
  }
3009
3132
 
3010
3133
  // src/github/workPrompt.ts
3134
+ function promptBaseBranch(item, configuredBaseBranch) {
3135
+ const prBase = item.pr?.baseRefName ?? "";
3136
+ return prBase.length > 0 ? prBase : configuredBaseBranch;
3137
+ }
3011
3138
  function formatThreads(threads) {
3012
3139
  return threads.map((thread) => {
3013
3140
  const location = thread.line === null ? `${thread.path}:(file)` : `${thread.path}:${String(thread.line)}`;
@@ -3029,10 +3156,11 @@ function composePrompt(input) {
3029
3156
  `Repository: ${repo.owner}/${repo.repo}`,
3030
3157
  `You are: ${agentUser}`,
3031
3158
  `Turn: ${item.turn}`,
3032
- `Base branch: ${baseBranch}`,
3033
- `Issue #${String(item.issue.number)}: ${item.issue.title}`,
3034
- `Issue URL: ${item.issue.url}`
3159
+ `Base branch: ${promptBaseBranch(item, baseBranch)}`
3035
3160
  ];
3161
+ if (item.issue) {
3162
+ lines.push(`Issue #${String(item.issue.number)}: ${item.issue.title}`, `Issue URL: ${item.issue.url}`);
3163
+ }
3036
3164
  if (item.pr) {
3037
3165
  lines.push(
3038
3166
  `Pull request #${String(item.pr.number)}: ${item.pr.title}`,
@@ -3047,12 +3175,14 @@ function composePrompt(input) {
3047
3175
  if (newMessages.length > 0) {
3048
3176
  lines.push("", "New since your last message \u2014 this is what you must answer:", "", formatMessages(newMessages));
3049
3177
  }
3050
- lines.push(
3051
- "",
3052
- "Full conversation on the issue (authorized accounts and you only, oldest first):",
3053
- "",
3054
- formatMessages(item.issueAnalysis.messages)
3055
- );
3178
+ if (item.issue) {
3179
+ lines.push(
3180
+ "",
3181
+ "Full conversation on the issue (authorized accounts and you only, oldest first):",
3182
+ "",
3183
+ formatMessages(item.issueAnalysis.messages)
3184
+ );
3185
+ }
3056
3186
  if (item.prAnalysis && item.prAnalysis.messages.length > 0) {
3057
3187
  lines.push(
3058
3188
  "",
@@ -3484,8 +3614,19 @@ function prepareBaseBranch(baseBranch) {
3484
3614
  }
3485
3615
  return { ok: true, branch: baseBranch };
3486
3616
  }
3617
+ function resetToForcePushedRemote(headRefName, previousRemoteSha) {
3618
+ const localSha = revParse(`refs/heads/${headRefName}`);
3619
+ if (previousRemoteSha === null || localSha === null) return null;
3620
+ if (!isAncestorCommit(localSha, previousRemoteSha)) return null;
3621
+ const reset = resetHardTo(`refs/remotes/origin/${headRefName}`);
3622
+ if (!reset.ok) {
3623
+ return { ok: false, reason: "pull-failed", detail: reset.stderr };
3624
+ }
3625
+ return { ok: true, branch: headRefName };
3626
+ }
3487
3627
  function preparePrBranch(headRefName) {
3488
3628
  if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
3629
+ const previousRemoteSha = revParse(`refs/remotes/origin/${headRefName}`);
3489
3630
  const fetched = fetchBranch(headRefName);
3490
3631
  if (!fetched.ok) {
3491
3632
  return { ok: false, reason: "checkout-failed", detail: fetched.stderr };
@@ -3500,7 +3641,13 @@ function preparePrBranch(headRefName) {
3500
3641
  }
3501
3642
  const pull = pullFastForwardOnly(headRefName);
3502
3643
  if (!pull.ok) {
3503
- return { ok: false, reason: "pull-failed", detail: pull.stderr };
3644
+ const reset = resetToForcePushedRemote(headRefName, previousRemoteSha);
3645
+ if (reset !== null) return reset;
3646
+ return {
3647
+ ok: false,
3648
+ reason: "pull-failed",
3649
+ detail: `${pull.stderr} \u2014 the local ${headRefName} has commits origin/${headRefName} does not, so it was not reset automatically; inspect them and \`git reset --hard origin/${headRefName}\` yourself`
3650
+ };
3504
3651
  }
3505
3652
  return { ok: true, branch: headRefName };
3506
3653
  }
@@ -3514,14 +3661,25 @@ function utcStamp(now) {
3514
3661
  function flattenBranchName(branch) {
3515
3662
  return branch.replaceAll("/", "-");
3516
3663
  }
3517
- function detectRescueTarget(baseBranch, now) {
3664
+ function agentOwnsBranch(branch, agentUser) {
3665
+ if (agentUser.length === 0) return false;
3666
+ let open;
3667
+ try {
3668
+ open = findOpenPr(branch);
3669
+ } catch {
3670
+ return false;
3671
+ }
3672
+ if (open === null) return true;
3673
+ return open.author.toLowerCase() === agentUser.toLowerCase();
3674
+ }
3675
+ function detectRescueTarget(options, now) {
3518
3676
  const current = getCurrentBranch();
3519
3677
  const detached = current === "HEAD" || current.length === 0;
3520
- if (!detached && current !== baseBranch) {
3678
+ if (!detached && current !== options.baseBranch && agentOwnsBranch(current, options.agentUser)) {
3521
3679
  return { branch: current, createdBranch: false, source: current };
3522
3680
  }
3523
- const source = detached ? "detached HEAD" : baseBranch;
3524
- const label = detached ? "detached" : flattenBranchName(baseBranch);
3681
+ const source = detached ? "detached HEAD" : current === options.baseBranch ? options.baseBranch : current;
3682
+ const label = detached ? "detached" : flattenBranchName(source);
3525
3683
  return {
3526
3684
  branch: `${RESCUE_BRANCH_PREFIX}${label}-${utcStamp(now)}`,
3527
3685
  createdBranch: true,
@@ -3544,7 +3702,7 @@ function rescueUncommittedChanges(options, now) {
3544
3702
  options.log(" rescue nothing to do; the working tree is clean\n");
3545
3703
  return { kind: "clean" };
3546
3704
  }
3547
- const target = detectRescueTarget(options.baseBranch, now);
3705
+ const target = detectRescueTarget(options, now);
3548
3706
  if (options.dryRun) {
3549
3707
  options.log(
3550
3708
  target.createdBranch ? ` rescue would create ${target.branch} off ${target.source}, commit the uncommitted changes, push it and open a draft PR
@@ -3575,8 +3733,11 @@ function rescueUncommittedChanges(options, now) {
3575
3733
  }
3576
3734
  const pushed = pushSetUpstream(target.branch);
3577
3735
  if (!pushed.ok) {
3578
- options.log(` rescue FAILED to push ${target.branch}: ${pushed.stderr}
3579
- `);
3736
+ options.log(
3737
+ ` rescue FAILED to push ${target.branch}: ${pushed.stderr}
3738
+ rescue the work is committed locally on ${target.branch}; push it by hand before it is lost
3739
+ `
3740
+ );
3580
3741
  return { kind: "failed", step: "push", detail: pushed.stderr };
3581
3742
  }
3582
3743
  let existing;
@@ -3782,17 +3943,195 @@ function runRepoHygiene(options, now = /* @__PURE__ */ new Date()) {
3782
3943
  return { rescue, base, prunes: outcomes, degraded };
3783
3944
  }
3784
3945
 
3946
+ // src/run/operationLog.ts
3947
+ import {
3948
+ accessSync,
3949
+ appendFileSync,
3950
+ constants,
3951
+ readFileSync as readFileSync4,
3952
+ renameSync as renameSync2,
3953
+ unlinkSync as unlinkSync2,
3954
+ writeFileSync as writeFileSync2
3955
+ } from "fs";
3956
+ import { randomUUID as randomUUID2 } from "crypto";
3957
+ import { dirname as dirname2, join as join3 } from "path";
3958
+ var EXECUTION_LOG_FILE = "automata-execution.log";
3959
+ var WORK_LOG_FILE = "automata-work.log";
3960
+ var MAX_EXECUTION_LINES = 1e3;
3961
+ var WORK_RECORD_MAX_AGE_DAYS = 30;
3962
+ var MAX_DETAIL_LENGTH = 200;
3963
+ var OUTCOMES = [
3964
+ "answered",
3965
+ "answered-no-reply",
3966
+ "skipped",
3967
+ "failed",
3968
+ "deferred"
3969
+ ];
3970
+ function operationLogDirectory() {
3971
+ return dirname2(process.cwd());
3972
+ }
3973
+ function repoField(repo) {
3974
+ if (repo === null) return "-";
3975
+ const flat = oneLine(repo);
3976
+ return flat.length === 0 ? "-" : flat;
3977
+ }
3978
+ function formatExecutionLine(tick) {
3979
+ const counts = {
3980
+ answered: 0,
3981
+ "answered-no-reply": 0,
3982
+ skipped: 0,
3983
+ failed: 0,
3984
+ deferred: 0
3985
+ };
3986
+ let runs = 0;
3987
+ for (const item of tick.items) {
3988
+ counts[item.outcome]++;
3989
+ if (item.ranExecutor) runs++;
3990
+ }
3991
+ const fields = [
3992
+ tick.timestamp.toISOString(),
3993
+ tick.command,
3994
+ `repo=${repoField(tick.repo)}`,
3995
+ `items=${String(tick.items.length)}`,
3996
+ ...OUTCOMES.map((outcome) => `${outcome}=${String(counts[outcome])}`),
3997
+ `runs=${String(runs)}`,
3998
+ `exit=${String(tick.exitCode)}`,
3999
+ `dur=${(tick.durationMs / 1e3).toFixed(1)}s`
4000
+ ];
4001
+ if (tick.note !== void 0 && tick.note.length > 0) fields.push(`note=${tick.note}`);
4002
+ return fields.join(" ") + "\n";
4003
+ }
4004
+ function oneLine(value) {
4005
+ return value.replace(/\s+/g, " ").trim();
4006
+ }
4007
+ function briefDetail(detail) {
4008
+ const flat = oneLine(detail);
4009
+ return flat.length <= MAX_DETAIL_LENGTH ? flat : `${flat.slice(0, MAX_DETAIL_LENGTH)}\u2026`;
4010
+ }
4011
+ function describeItemExecution(item) {
4012
+ if (item.executor === void 0) return "";
4013
+ const model = item.model === void 0 ? "" : ` model=${oneLine(item.model)}`;
4014
+ const effort = item.effort === void 0 ? "" : ` effort=${oneLine(item.effort)}`;
4015
+ return ` [${oneLine(item.executor)}${model}${effort}]`;
4016
+ }
4017
+ function formatWorkRecord(tick) {
4018
+ const ran = tick.items.filter((item) => item.ranExecutor);
4019
+ if (ran.length === 0) return null;
4020
+ const header = `=== ${tick.timestamp.toISOString()} ${repoField(tick.repo)} ===
4021
+ `;
4022
+ const lines = ran.map(
4023
+ (item) => `${oneLine(item.subject)} ${item.turn === null ? "-" : oneLine(item.turn)} ${item.outcome}${describeItemExecution(item)} \u2014 ${briefDetail(item.detail)}
4024
+ `
4025
+ );
4026
+ return header + lines.join("") + "\n";
4027
+ }
4028
+ function trimToLastLines(content, max) {
4029
+ const lines = content.split("\n");
4030
+ if (lines.at(-1) === "") lines.pop();
4031
+ if (lines.length <= max) return content;
4032
+ return lines.slice(lines.length - max).join("\n") + "\n";
4033
+ }
4034
+ var RECORD_HEADER = /^=== (\S+) /;
4035
+ function pruneOldRecords(content, now, maxAgeMs) {
4036
+ if (content.length === 0) return content;
4037
+ const cutoff = now.getTime() - maxAgeMs;
4038
+ const lines = content.split("\n");
4039
+ if (lines.at(-1) === "") lines.pop();
4040
+ const groups = [];
4041
+ let current = { header: null, lines: [] };
4042
+ for (const line of lines) {
4043
+ const header = RECORD_HEADER.exec(line);
4044
+ if (header === null) {
4045
+ current.lines.push(line);
4046
+ continue;
4047
+ }
4048
+ groups.push(current);
4049
+ current = { header: header[1], lines: [line] };
4050
+ }
4051
+ groups.push(current);
4052
+ return groups.filter((group) => {
4053
+ if (group.lines.length === 0) return false;
4054
+ if (group.header === null) return true;
4055
+ const parsed = Date.parse(group.header);
4056
+ return Number.isNaN(parsed) || parsed >= cutoff;
4057
+ }).map((group) => group.lines.join("\n") + "\n").join("");
4058
+ }
4059
+ function appendWithRetention(dir, file, content, retain) {
4060
+ const target = join3(dir, file);
4061
+ appendFileSync(target, content, "utf8");
4062
+ const existing = readFileSync4(target, "utf8");
4063
+ const retained = retain(existing);
4064
+ if (retained === existing) return;
4065
+ const temp = `${target}.${randomUUID2()}.tmp`;
4066
+ try {
4067
+ writeFileSync2(temp, retained, "utf8");
4068
+ renameSync2(temp, target);
4069
+ } finally {
4070
+ try {
4071
+ unlinkSync2(temp);
4072
+ } catch {
4073
+ }
4074
+ }
4075
+ }
4076
+ function recordTick(tick, dir = operationLogDirectory()) {
4077
+ try {
4078
+ accessSync(dir, constants.W_OK);
4079
+ } catch {
4080
+ return;
4081
+ }
4082
+ try {
4083
+ appendWithRetention(
4084
+ dir,
4085
+ EXECUTION_LOG_FILE,
4086
+ formatExecutionLine(tick),
4087
+ (existing) => trimToLastLines(existing, MAX_EXECUTION_LINES)
4088
+ );
4089
+ } catch {
4090
+ }
4091
+ try {
4092
+ const record = formatWorkRecord(tick);
4093
+ if (record === null) return;
4094
+ const maxAgeMs = WORK_RECORD_MAX_AGE_DAYS * 24 * 60 * 60 * 1e3;
4095
+ appendWithRetention(
4096
+ dir,
4097
+ WORK_LOG_FILE,
4098
+ record,
4099
+ (existing) => pruneOldRecords(existing, tick.timestamp, maxAgeMs)
4100
+ );
4101
+ } catch {
4102
+ }
4103
+ }
4104
+
3785
4105
  // src/commands/doWork.ts
3786
4106
  var inFlightMarker = null;
4107
+ function subjectLabel(issue, pr) {
4108
+ if (issue !== null) return `#${String(issue)}`;
4109
+ return pr === null ? "#?" : `PR #${String(pr)}`;
4110
+ }
4111
+ function itemLabel(item) {
4112
+ return subjectLabel(item.issue?.number ?? null, item.pr?.number ?? null);
4113
+ }
4114
+ function reportLabel(report) {
4115
+ return subjectLabel(report.issue, report.pr);
4116
+ }
4117
+ function itemTitle(item) {
4118
+ return item.issue?.title ?? item.pr?.title ?? "";
4119
+ }
3787
4120
  function out(message) {
3788
4121
  process.stdout.write(message);
3789
4122
  }
3790
4123
  function progress(message) {
3791
4124
  process.stderr.write(message);
3792
4125
  }
4126
+ var loggableInvocation = null;
3793
4127
  function fail(message) {
3794
4128
  process.stderr.write(`Error: ${message}
3795
4129
  `);
4130
+ if (loggableInvocation !== null) {
4131
+ const { startedAt } = loggableInvocation;
4132
+ loggableInvocation = null;
4133
+ logTick([], 1, startedAt, "config-error");
4134
+ }
3796
4135
  process.exit(1);
3797
4136
  }
3798
4137
  function parsePositiveInt(value, label) {
@@ -3864,11 +4203,13 @@ function resolveSettings(options) {
3864
4203
  participants: { allowedUsers, agentUser },
3865
4204
  prompts: {
3866
4205
  "issue-discuss": doWork.prompts?.issueDiscuss ?? DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT,
3867
- "pr-work": doWork.prompts?.prWork ?? DEFAULT_DO_WORK_PR_WORK_PROMPT
4206
+ "pr-work": doWork.prompts?.prWork ?? DEFAULT_DO_WORK_PR_WORK_PROMPT,
4207
+ "pr-orphan": doWork.prompts?.prOrphan ?? DEFAULT_DO_WORK_PR_ORPHAN_PROMPT
3868
4208
  },
3869
4209
  technique: config.issueDiscoveryTechnique,
3870
4210
  discoveryValue: config.issueDiscoveryValue,
3871
- onlyIssue: options.issue === void 0 ? void 0 : parsePositiveInt(options.issue, "--issue")
4211
+ onlyIssue: options.issue === void 0 ? void 0 : parsePositiveInt(options.issue, "--issue"),
4212
+ onlyPr: options.pr === void 0 ? void 0 : parsePositiveInt(options.pr, "--pr")
3872
4213
  };
3873
4214
  }
3874
4215
  function checkAuthenticatedIdentity(agentUser, allowedUsers) {
@@ -3932,12 +4273,12 @@ function planRun(item, settings, execution) {
3932
4273
  }
3933
4274
  function describePlannedRun(item, settings, run5, execution) {
3934
4275
  const rule = "\u2500".repeat(72);
3935
- const branchAction = item.turn === "pr-work" ? " and fast-forward" : " and pull";
4276
+ const branchAction = item.turn === "issue-discuss" ? " and pull" : " and fast-forward";
3936
4277
  const assignment = item.needsAssignment ? `would assign to ${settings.participants.agentUser}` : "already assigned";
3937
- const markerTarget = item.turn === "pr-work" && item.pr ? `pull request #${String(item.pr.number)}` : `issue #${String(item.issue.number)}`;
4278
+ const markerTarget = markerSurfaceLabel(item);
3938
4279
  const lines = [
3939
4280
  rule,
3940
- `Issue #${String(item.issue.number)} \u2014 ${item.issue.title}`,
4281
+ describeSubject(item),
3941
4282
  rule,
3942
4283
  ` Turn ${item.turn}`,
3943
4284
  ` Why ${item.reason}`,
@@ -3960,11 +4301,14 @@ function describePlannedRun(item, settings, run5, execution) {
3960
4301
  ];
3961
4302
  return lines.join("\n") + "\n";
3962
4303
  }
4304
+ function describeSubject(item) {
4305
+ return item.issue !== null ? `Issue #${String(item.issue.number)} \u2014 ${item.issue.title}` : `Pull request #${String(item.pr?.number ?? 0)} \u2014 ${itemTitle(item)}`;
4306
+ }
3963
4307
  function describeRefusedRun(item, refusal) {
3964
4308
  const rule = "\u2500".repeat(72);
3965
4309
  return [
3966
4310
  rule,
3967
- `Issue #${String(item.issue.number)} \u2014 ${item.issue.title}`,
4311
+ describeSubject(item),
3968
4312
  rule,
3969
4313
  ` Turn ${item.turn}`,
3970
4314
  ` Why ${item.reason}`,
@@ -4005,7 +4349,7 @@ function validateDoWorkConfig(section) {
4005
4349
  validateProtectedBranches(section["protectedBranches"]);
4006
4350
  validateSettingContainer(section["models"], "models", ["claude", "codex"]);
4007
4351
  validateSettingContainer(section["effort"], "effort", ["claude", "codex"]);
4008
- validateSettingContainer(section["prompts"], "prompts", ["issueDiscuss", "prWork"]);
4352
+ validateSettingContainer(section["prompts"], "prompts", ["issueDiscuss", "prWork", "prOrphan"]);
4009
4353
  }
4010
4354
  function validateProtectedBranches(value) {
4011
4355
  if (value === void 0 || value === null) return;
@@ -4028,7 +4372,14 @@ function validateSettingContainer(value, container, keys) {
4028
4372
  }
4029
4373
  }
4030
4374
  }
4375
+ function issuePassEnabled(settings) {
4376
+ return settings.onlyPr === void 0 || settings.onlyIssue !== void 0;
4377
+ }
4378
+ function orphanPassEnabled(settings) {
4379
+ return settings.onlyIssue === void 0 || settings.onlyPr !== void 0;
4380
+ }
4031
4381
  function discoverIssues(settings) {
4382
+ if (!issuePassEnabled(settings)) return [];
4032
4383
  const candidates = listCandidateIssues(settings.technique, settings.discoveryValue, settings.limit);
4033
4384
  if (settings.onlyIssue === void 0) {
4034
4385
  if (candidates.length === settings.limit) {
@@ -4050,17 +4401,58 @@ function discoverIssues(settings) {
4050
4401
  }
4051
4402
  return [surface.issue];
4052
4403
  }
4053
- function issueMatchesFilter(surface, settings) {
4404
+ function matchesDiscoveryFilter(subject, settings) {
4054
4405
  const value = settings.discoveryValue.toLowerCase();
4055
4406
  switch (settings.technique) {
4056
4407
  case "label":
4057
- return surface.labels.some((label) => label.toLowerCase() === value);
4408
+ return subject.labels.some((label) => label.toLowerCase() === value);
4058
4409
  case "assignee":
4059
- return surface.assignees.some((assignee) => assignee.toLowerCase() === value);
4410
+ return subject.assignees.some((assignee) => assignee.toLowerCase() === value);
4060
4411
  case "title-contains":
4061
- return surface.issue.title.toLowerCase().includes(value);
4412
+ return subject.title.toLowerCase().includes(value);
4062
4413
  }
4063
4414
  }
4415
+ function issueMatchesFilter(surface, settings) {
4416
+ return matchesDiscoveryFilter(
4417
+ { labels: surface.labels, assignees: surface.assignees, title: surface.issue.title },
4418
+ settings
4419
+ );
4420
+ }
4421
+ function prMatchesFilter(candidate, settings) {
4422
+ return matchesDiscoveryFilter(
4423
+ { labels: candidate.labels, assignees: candidate.assignees, title: candidate.pr.title },
4424
+ settings
4425
+ );
4426
+ }
4427
+ function issuesClosedBy(linkMap, prNumber) {
4428
+ return [...linkMap.byIssue.entries()].filter(([, prs]) => prs.some((pr) => pr.number === prNumber)).map(([issueNumber]) => issueNumber);
4429
+ }
4430
+ function discoverOrphanPrs(settings, linkMap) {
4431
+ if (!orphanPassEnabled(settings)) return [];
4432
+ if (settings.onlyPr === void 0) {
4433
+ return linkMap.orphans.filter((candidate) => prMatchesFilter(candidate, settings));
4434
+ }
4435
+ const target = settings.onlyPr;
4436
+ const match = linkMap.orphans.find((candidate) => candidate.pr.number === target);
4437
+ if (match === void 0) {
4438
+ const closes = issuesClosedBy(linkMap, target);
4439
+ if (closes.length > 0) {
4440
+ throw new Error(
4441
+ `Pull request #${String(target)} closes issue #${String(closes[0])} of this repository, so it is not orphaned and the issue pass owns it. Use \`--issue ${String(closes[0])}\` instead.`
4442
+ );
4443
+ }
4444
+ throw new Error(
4445
+ `#${String(target)} is not an open pull request of this repository. The orphan pass only ever works on open pull requests; use \`--issue\` for an issue.`
4446
+ );
4447
+ }
4448
+ if (!prMatchesFilter(match, settings)) {
4449
+ progress(
4450
+ `Note: pull request #${String(target)} does not match the configured discovery filter (${settings.technique} = ${settings.discoveryValue}); processing it anyway because --pr was given.
4451
+ `
4452
+ );
4453
+ }
4454
+ return [match];
4455
+ }
4064
4456
  function buildIssueState(issue, linkMap) {
4065
4457
  const issueSurface = getIssueSurface(issue.number);
4066
4458
  const linkedPrs = linkMap.byIssue.get(issue.number) ?? [];
@@ -4074,16 +4466,16 @@ function buildIssueState(issue, linkMap) {
4074
4466
  function describePlan(decisions) {
4075
4467
  const lines = decisions.map((decision) => {
4076
4468
  if (decision.kind === "skip") {
4077
- return ` #${String(decision.issue.number)} nothing to do \u2014 ${decision.detail}`;
4469
+ return ` ${subjectLabel(decision.issue?.number ?? null, decision.pr?.number ?? null)} nothing to do \u2014 ${decision.detail}`;
4078
4470
  }
4079
4471
  const item = decision.item;
4080
4472
  const claim = item.needsAssignment ? ", will assign to the agent" : "";
4081
- return ` #${String(item.issue.number)} ${item.turn} on ${item.branch} \u2014 ${item.reason}${claim}`;
4473
+ return ` ${itemLabel(item)} ${item.turn} on ${item.branch} \u2014 ${item.reason}${claim}`;
4082
4474
  });
4083
- return lines.length === 0 ? " (no issues matched the discovery filter)\n" : lines.join("\n") + "\n";
4475
+ return lines.length === 0 ? " (nothing matched the discovery filter)\n" : lines.join("\n") + "\n";
4084
4476
  }
4085
4477
  function claimIssue(item, settings) {
4086
- if (!item.needsAssignment) return;
4478
+ if (!item.needsAssignment || item.issue === null) return;
4087
4479
  try {
4088
4480
  assignIssueToAgent(item.issue.number, settings.participants.agentUser);
4089
4481
  progress(` assigned issue #${String(item.issue.number)} to ${settings.participants.agentUser}.
@@ -4094,7 +4486,8 @@ function claimIssue(item, settings) {
4094
4486
  }
4095
4487
  }
4096
4488
  function notePickupOnIssue(item, marker) {
4097
- if (item.turn !== "pr-work" || item.pr === null || !item.issueAnalysis.hasNewMessage) return null;
4489
+ if (item.turn !== "pr-work" || item.pr === null || item.issue === null) return null;
4490
+ if (!item.issueAnalysis.hasNewMessage) return null;
4098
4491
  try {
4099
4492
  return postMarker(
4100
4493
  "issue",
@@ -4114,6 +4507,7 @@ function notePickupOnIssue(item, marker) {
4114
4507
  }
4115
4508
  }
4116
4509
  function reportIssueMessagesBuriedByNote(item, note, participants) {
4510
+ if (item.issue === null) return 0;
4117
4511
  const watermark = promptWatermark([item.issueAnalysis.messages]);
4118
4512
  let buried;
4119
4513
  try {
@@ -4140,24 +4534,43 @@ function reportIssueMessagesBuriedByNote(item, note, participants) {
4140
4534
  }
4141
4535
  function refreshItem(item, settings) {
4142
4536
  const linkMap = getOpenPrLinkMap();
4143
- return decideWork(buildIssueState(item.issue, linkMap), settings.participants, {
4537
+ const policy = {
4144
4538
  baseBranch: settings.baseBranch,
4145
4539
  defaultBranch: linkMap.defaultBranch,
4146
4540
  protectedBranches: settings.protectedBranches
4147
- });
4541
+ };
4542
+ if (item.issue === null) return refreshOrphanItem(item, linkMap, settings, policy);
4543
+ return decideWork(buildIssueState(item.issue, linkMap), settings.participants, policy);
4544
+ }
4545
+ function refreshOrphanItem(item, linkMap, settings, policy) {
4546
+ const number = item.pr?.number ?? 0;
4547
+ const stillOrphan = linkMap.orphans.some((candidate) => candidate.pr.number === number);
4548
+ if (!stillOrphan) {
4549
+ const linkedTo = issuesClosedBy(linkMap, number).map((issueNumber) => `#${String(issueNumber)}`);
4550
+ return {
4551
+ kind: "skip",
4552
+ issue: null,
4553
+ pr: item.pr,
4554
+ reason: linkedTo.length > 0 ? "pr-linked" : "pr-closed",
4555
+ detail: linkedTo.length > 0 ? `pull request #${String(number)} now closes ${linkedTo.join(", ")}, so it is no longer orphaned` : `pull request #${String(number)} is no longer an open pull request of this repository`
4556
+ };
4557
+ }
4558
+ return decideOrphanPrWork({ prSurface: getPrSurface(number) }, settings.participants, policy);
4148
4559
  }
4149
4560
  function readAnsweringSurface(item) {
4150
- if (item.turn === "issue-discuss" || item.pr === null) {
4151
- return getIssueSurface(item.issue.number).messages;
4561
+ const pr = item.pr;
4562
+ if (pr !== null && item.turn !== "issue-discuss") {
4563
+ const surface = getPrSurface(pr.number);
4564
+ return [...surface.messages, ...surface.threads.flatMap((thread) => thread.comments)];
4152
4565
  }
4153
- const surface = getPrSurface(item.pr.number);
4154
- return [...surface.messages, ...surface.threads.flatMap((thread) => thread.comments)];
4155
- }
4156
- function markerSurfaceLabel(item) {
4157
- return item.turn === "pr-work" && item.pr ? `pull request #${String(item.pr.number)}` : `issue #${String(item.issue.number)}`;
4566
+ return item.issue === null ? [] : getIssueSurface(item.issue.number).messages;
4158
4567
  }
4159
4568
  function markerSurfaceTarget(item) {
4160
- return item.turn === "pr-work" && item.pr ? { surface: "pr", number: item.pr.number } : { surface: "issue", number: item.issue.number };
4569
+ return item.pr !== null && item.turn !== "issue-discuss" ? { surface: "pr", number: item.pr.number } : { surface: "issue", number: item.issue?.number ?? 0 };
4570
+ }
4571
+ function markerSurfaceLabel(item) {
4572
+ const target = markerSurfaceTarget(item);
4573
+ return target.surface === "pr" ? `pull request #${String(target.number)}` : `issue #${String(target.number)}`;
4161
4574
  }
4162
4575
  function reportOvertakenMessages(item, analysis) {
4163
4576
  if (analysis.missed.length === 0) return;
@@ -4181,7 +4594,7 @@ function reconcileMarker(item, marker, participants, watermark, runError) {
4181
4594
  analysis = analyseAnswer(readAnsweringSurface(item), participants, marker, watermark);
4182
4595
  } catch (err) {
4183
4596
  progress(
4184
- ` warning: could not re-read issue #${String(item.issue.number)} to check for an answer: ${err.message}
4597
+ ` warning: could not re-read ${markerSurfaceLabel(item)} to check for an answer: ${err.message}
4185
4598
  `
4186
4599
  );
4187
4600
  return reportUnverified(item, marker);
@@ -4230,7 +4643,7 @@ function reportNoAnswer(item, marker, runError) {
4230
4643
  updateMarker(marker, explanation);
4231
4644
  } catch (err) {
4232
4645
  progress(
4233
- ` warning: could not update the marker comment on issue #${String(item.issue.number)}: ${err.message}
4646
+ ` warning: could not update the marker comment on ${surface}: ${err.message}
4234
4647
  the humans have not been told that this run produced no answer.
4235
4648
  `
4236
4649
  );
@@ -4245,31 +4658,33 @@ async function invokeExecutor(prompt, execution, silent) {
4245
4658
  await runClaude(prompt, { model: execution.model, effort: execution.effort, printSteps: !silent });
4246
4659
  }
4247
4660
  function repairIssueLink(item, baseBranch) {
4661
+ const issue = item.issue;
4662
+ if (issue === null) return false;
4248
4663
  try {
4249
4664
  const branch = getCurrentBranch();
4250
4665
  if (branch === baseBranch) {
4251
- progress(` issue #${String(item.issue.number)} is still in discussion (no branch was created).
4666
+ progress(` issue #${String(issue.number)} is still in discussion (no branch was created).
4252
4667
  `);
4253
4668
  return false;
4254
4669
  }
4255
4670
  const pr = getCurrentBranchPr();
4256
4671
  if (!pr) {
4257
- progress(` issue #${String(item.issue.number)} is still in discussion (no pull request).
4672
+ progress(` issue #${String(issue.number)} is still in discussion (no pull request).
4258
4673
  `);
4259
4674
  return false;
4260
4675
  }
4261
- const closesRef = new RegExp(String.raw`\bcloses\s+#` + String(item.issue.number) + String.raw`\b`, "i");
4676
+ const closesRef = new RegExp(String.raw`\bcloses\s+#` + String(issue.number) + String.raw`\b`, "i");
4262
4677
  if (closesRef.test(pr.body)) {
4263
- progress(` pull request #${String(pr.number)} already closes issue #${String(item.issue.number)}.
4678
+ progress(` pull request #${String(pr.number)} already closes issue #${String(issue.number)}.
4264
4679
  `);
4265
4680
  return true;
4266
4681
  }
4267
- addClosesRefToPr(pr.number, item.issue.number);
4268
- progress(` linked pull request #${String(pr.number)} to issue #${String(item.issue.number)}.
4682
+ addClosesRefToPr(pr.number, issue.number);
4683
+ progress(` linked pull request #${String(pr.number)} to issue #${String(issue.number)}.
4269
4684
  `);
4270
4685
  return true;
4271
4686
  } catch (err) {
4272
- progress(` warning: could not link a pull request to issue #${String(item.issue.number)}: ${err.message}
4687
+ progress(` warning: could not link a pull request to issue #${String(issue.number)}: ${err.message}
4273
4688
  `);
4274
4689
  return false;
4275
4690
  }
@@ -4288,15 +4703,16 @@ function refuseBeforeRun(base, marker, detail, markerText) {
4288
4703
  }
4289
4704
  async function processItem(planned, settings, silent) {
4290
4705
  progress(`
4291
- #${String(planned.issue.number)} ${planned.turn}: ${planned.reason}
4706
+ ${itemLabel(planned)} ${planned.turn}: ${planned.reason}
4292
4707
  `);
4293
4708
  const refreshed = refreshItem(planned, settings);
4294
4709
  if (refreshed.kind === "skip") {
4295
4710
  progress(` skipped: ${refreshed.detail}
4296
4711
  `);
4297
4712
  return {
4298
- issue: planned.issue.number,
4299
- title: planned.issue.title,
4713
+ issue: planned.issue?.number ?? null,
4714
+ pr: planned.pr?.number ?? null,
4715
+ title: itemTitle(planned),
4300
4716
  turn: planned.turn,
4301
4717
  outcome: "skipped",
4302
4718
  detail: `no longer actionable: ${refreshed.detail}`
@@ -4308,8 +4724,9 @@ async function processItem(planned, settings, silent) {
4308
4724
  `);
4309
4725
  }
4310
4726
  const base = {
4311
- issue: item.issue.number,
4312
- title: item.issue.title,
4727
+ issue: item.issue?.number ?? null,
4728
+ pr: item.pr?.number ?? null,
4729
+ title: itemTitle(item),
4313
4730
  turn: item.turn
4314
4731
  };
4315
4732
  const prepared = item.turn === "issue-discuss" ? prepareBaseBranch(item.branch) : preparePrBranch(item.branch);
@@ -4320,9 +4737,9 @@ async function processItem(planned, settings, silent) {
4320
4737
  }
4321
4738
  claimIssue(item, settings);
4322
4739
  let marker;
4323
- const markerSurface = item.turn === "pr-work" && item.pr ? item.pr.number : item.issue.number;
4740
+ const markerTarget = markerSurfaceTarget(item);
4324
4741
  try {
4325
- marker = postMarker(item.turn === "pr-work" ? "pr" : "issue", markerSurface, "automata do-work: working\u2026");
4742
+ marker = postMarker(markerTarget.surface, markerTarget.number, "automata do-work: working\u2026");
4326
4743
  } catch (err) {
4327
4744
  progress(` skipped: could not post the working marker \u2014 ${err.message}
4328
4745
  `);
@@ -4336,7 +4753,7 @@ async function processItem(planned, settings, silent) {
4336
4753
  }
4337
4754
  let buriedByNote = 0;
4338
4755
  if (note !== null) {
4339
- progress(` noted on issue #${String(item.issue.number)} that the work is on pull request #${String(item.pr?.number ?? 0)}.
4756
+ progress(` noted on issue #${String(item.issue?.number ?? 0)} that the work is on pull request #${String(item.pr?.number ?? 0)}.
4340
4757
  `);
4341
4758
  buriedByNote = reportIssueMessagesBuriedByNote(item, note, settings.participants);
4342
4759
  }
@@ -4421,13 +4838,14 @@ function summarize(reports) {
4421
4838
  }
4422
4839
  for (const report of reports) {
4423
4840
  const ran = report.execution === void 0 ? "" : ` \xB7 ${describeExecution(report.execution)}`;
4424
- out(` #${String(report.issue)} ${report.turn ?? "-"} ${report.outcome} \u2014 ${report.detail}${ran}
4841
+ out(` ${reportLabel(report)} ${report.turn ?? "-"} ${report.outcome} \u2014 ${report.detail}${ran}
4425
4842
  `);
4426
4843
  }
4427
4844
  }
4428
4845
  function toItemJson(report) {
4429
4846
  return {
4430
4847
  issue: report.issue,
4848
+ pr: report.pr,
4431
4849
  title: report.title,
4432
4850
  turn: report.turn,
4433
4851
  outcome: report.outcome,
@@ -4442,23 +4860,30 @@ function toItemJson(report) {
4442
4860
  };
4443
4861
  }
4444
4862
  var doWorkCommand = new Command6("do-work").description(
4445
- "Run one tick of the autonomous loop: find the issues whose newest authorized message the agent has not answered, and answer them"
4863
+ "Run one tick of the autonomous loop: answer the issues whose newest authorized message the agent has not answered, then the open pull requests that close no issue of this repository and have one"
4446
4864
  ).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(
4447
4865
  "--effort <level>",
4448
4866
  "Reasoning effort to pass to the executor, overriding the configured default for it"
4449
- ).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(
4867
+ ).option("--issue <number>", "Restrict the tick to a single issue").option(
4868
+ "--pr <number>",
4869
+ "Restrict the orphan-pull-request pass to a single pull request (one that closes no issue of this repository)"
4870
+ ).option("--limit <n>", "Maximum number of issues to fetch", "10").option("--max-runs <n>", "Maximum number of model runs this tick").option(
4450
4871
  "--dry-run",
4451
4872
  "Print the work plan, plus a summary and the exact command that would be launched for each item, and exit without changing anything"
4452
4873
  ).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) => {
4874
+ const startedAt = Date.now();
4875
+ if (options.dryRun !== true) loggableInvocation = { startedAt };
4453
4876
  const settings = resolveSettings(options);
4454
4877
  if (options.dryRun === true) {
4455
- const exitCode2 = await runTick(settings, options);
4878
+ const { exitCode: exitCode2 } = await runTick(settings, options);
4456
4879
  if (exitCode2 !== 0) process.exit(exitCode2);
4457
4880
  return;
4458
4881
  }
4882
+ loggableInvocation = null;
4459
4883
  const lock = acquireRunLock("do-work", settings.lockStaleMinutes);
4460
4884
  if (!lock.ok) {
4461
4885
  const exitCode2 = reportLockHeld(lock, settings, options);
4886
+ logTick([], exitCode2, startedAt, "lock-held");
4462
4887
  if (exitCode2 !== 0) process.exit(exitCode2);
4463
4888
  return;
4464
4889
  }
@@ -4483,8 +4908,11 @@ var doWorkCommand = new Command6("do-work").description(
4483
4908
  process.once("SIGINT", onSignal);
4484
4909
  process.once("SIGTERM", onSignal);
4485
4910
  let exitCode;
4911
+ let reports = [];
4486
4912
  try {
4487
- exitCode = await runTick(settings, options);
4913
+ const result = await runTick(settings, options);
4914
+ exitCode = result.exitCode;
4915
+ reports = result.reports;
4488
4916
  } catch (err) {
4489
4917
  process.stderr.write(`Error: ${err.message}
4490
4918
  `);
@@ -4494,8 +4922,39 @@ var doWorkCommand = new Command6("do-work").description(
4494
4922
  process.removeListener("SIGINT", onSignal);
4495
4923
  process.removeListener("SIGTERM", onSignal);
4496
4924
  }
4925
+ logTick(reports, exitCode, startedAt);
4497
4926
  if (exitCode !== 0) process.exit(exitCode);
4498
4927
  });
4928
+ function toTickLogItem(report) {
4929
+ return {
4930
+ subject: reportLabel(report),
4931
+ turn: report.turn,
4932
+ outcome: report.outcome,
4933
+ detail: report.detail,
4934
+ ranExecutor: report.ranExecutor ?? false,
4935
+ executor: report.execution?.executor,
4936
+ model: report.execution?.model,
4937
+ effort: report.execution?.effort
4938
+ };
4939
+ }
4940
+ function logTick(reports, exitCode, startedAt, note) {
4941
+ let repo;
4942
+ try {
4943
+ const slug = getRepoSlug();
4944
+ repo = `${slug.owner}/${slug.repo}`;
4945
+ } catch {
4946
+ repo = null;
4947
+ }
4948
+ recordTick({
4949
+ command: "do-work",
4950
+ repo,
4951
+ timestamp: /* @__PURE__ */ new Date(),
4952
+ durationMs: Date.now() - startedAt,
4953
+ exitCode,
4954
+ note,
4955
+ items: reports.map(toTickLogItem)
4956
+ });
4957
+ }
4499
4958
  function reportLockHeld(lock, settings, options) {
4500
4959
  const held = lock.heldBy;
4501
4960
  const sentence = `Another automata instance is already running here (pid ${String(held.pid)} on ${held.host}, started ${held.startedAt}, command ${held.command}). Doing nothing.
@@ -4532,19 +4991,24 @@ async function runTick(settings, options) {
4532
4991
  baseBranch: settings.baseBranch,
4533
4992
  protectedBranches: settings.protectedBranches,
4534
4993
  dryRun: options.dryRun === true,
4994
+ agentUser: settings.participants.agentUser,
4535
4995
  log: progress
4536
4996
  });
4537
4997
  const issues = discoverIssues(settings);
4538
4998
  const linkMap = getOpenPrLinkMap();
4539
- const decisions = issues.map(
4540
- (issue) => decideWork(buildIssueState(issue, linkMap), settings.participants, {
4541
- baseBranch: settings.baseBranch,
4542
- defaultBranch: linkMap.defaultBranch,
4543
- protectedBranches: settings.protectedBranches
4544
- })
4545
- );
4999
+ const policy = {
5000
+ baseBranch: settings.baseBranch,
5001
+ defaultBranch: linkMap.defaultBranch,
5002
+ protectedBranches: settings.protectedBranches
5003
+ };
5004
+ const decisions = skipDuplicateHeadBranches([
5005
+ ...issues.map((issue) => decideWork(buildIssueState(issue, linkMap), settings.participants, policy)),
5006
+ ...discoverOrphanPrs(settings, linkMap).map(
5007
+ (candidate) => decideOrphanPrWork({ prSurface: getPrSurface(candidate.pr.number) }, settings.participants, policy)
5008
+ )
5009
+ ]);
4546
5010
  const items = decisions.flatMap((decision) => decision.kind === "work" ? [decision.item] : []);
4547
- const planText = `Work plan (${String(items.length)} of ${String(issues.length)} issues need an answer):
5011
+ const planText = `Work plan (${String(items.length)} of ${String(decisions.length)} candidates need an answer):
4548
5012
  ${describePlan(decisions)}`;
4549
5013
  if (options.json) {
4550
5014
  progress(planText);
@@ -4553,7 +5017,7 @@ ${describePlan(decisions)}`;
4553
5017
  }
4554
5018
  if (options.dryRun) {
4555
5019
  reportDryRun(items, decisions, settings, options, hygiene);
4556
- return 0;
5020
+ return { exitCode: 0, reports: [] };
4557
5021
  }
4558
5022
  const reports = [];
4559
5023
  const deferred = [];
@@ -4570,8 +5034,9 @@ ${describePlan(decisions)}`;
4570
5034
  progress(` failed: ${err.message}
4571
5035
  `);
4572
5036
  report = {
4573
- issue: item.issue.number,
4574
- title: item.issue.title,
5037
+ issue: item.issue?.number ?? null,
5038
+ pr: item.pr?.number ?? null,
5039
+ title: itemTitle(item),
4575
5040
  turn: item.turn,
4576
5041
  outcome: "failed",
4577
5042
  detail: err.message
@@ -4582,11 +5047,12 @@ ${describePlan(decisions)}`;
4582
5047
  }
4583
5048
  for (const item of deferred) {
4584
5049
  progress(`
4585
- #${String(item.issue.number)} deferred: --max-runs / maxRunsPerTick reached.
5050
+ ${itemLabel(item)} deferred: --max-runs / maxRunsPerTick reached.
4586
5051
  `);
4587
5052
  reports.push({
4588
- issue: item.issue.number,
4589
- title: item.issue.title,
5053
+ issue: item.issue?.number ?? null,
5054
+ pr: item.pr?.number ?? null,
5055
+ title: itemTitle(item),
4590
5056
  turn: item.turn,
4591
5057
  outcome: "deferred",
4592
5058
  detail: `run cap of ${String(settings.maxRuns)} reached`
@@ -4612,12 +5078,13 @@ ${describePlan(decisions)}`;
4612
5078
  summarizeHygiene(hygiene);
4613
5079
  summarize(reports);
4614
5080
  }
4615
- return exitCode;
5081
+ return { exitCode, reports };
4616
5082
  }
4617
5083
  function toRunJson(entry) {
4618
5084
  if (entry.kind === "refused") {
4619
5085
  return {
4620
- issue: entry.item.issue.number,
5086
+ issue: entry.item.issue?.number ?? null,
5087
+ pr: entry.item.pr?.number ?? null,
4621
5088
  turn: entry.item.turn,
4622
5089
  executor: null,
4623
5090
  model: null,
@@ -4633,7 +5100,8 @@ function toRunJson(entry) {
4633
5100
  };
4634
5101
  }
4635
5102
  return {
4636
- issue: entry.item.issue.number,
5103
+ issue: entry.item.issue?.number ?? null,
5104
+ pr: entry.item.pr?.number ?? null,
4637
5105
  turn: entry.item.turn,
4638
5106
  executor: entry.execution.executor,
4639
5107
  model: entry.execution.model ?? null,
@@ -4743,8 +5211,9 @@ function toHygieneJson(hygiene) {
4743
5211
  function toPlanJson(decision) {
4744
5212
  if (decision.kind === "skip") {
4745
5213
  return {
4746
- issue: decision.issue.number,
4747
- title: decision.issue.title,
5214
+ issue: decision.issue?.number ?? null,
5215
+ pr: decision.pr?.number ?? null,
5216
+ title: decision.issue?.title ?? decision.pr?.title ?? "",
4748
5217
  turn: null,
4749
5218
  skipReason: decision.reason,
4750
5219
  reason: decision.detail
@@ -4752,11 +5221,11 @@ function toPlanJson(decision) {
4752
5221
  }
4753
5222
  const item = decision.item;
4754
5223
  return {
4755
- issue: item.issue.number,
4756
- title: item.issue.title,
5224
+ issue: item.issue?.number ?? null,
5225
+ pr: item.pr?.number ?? null,
5226
+ title: itemTitle(item),
4757
5227
  turn: item.turn,
4758
5228
  branch: item.branch,
4759
- pr: item.pr?.number ?? null,
4760
5229
  needsAssignment: item.needsAssignment,
4761
5230
  reason: item.reason
4762
5231
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "automata-cli",
3
- "version": "0.6.0-develop.293",
3
+ "version": "0.6.0-develop.300",
4
4
  "description": "Automata CLI tool",
5
5
  "type": "module",
6
6
  "engines": {