superagent-claude 0.2.0 → 0.3.0

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/lib/cli.js CHANGED
@@ -55,7 +55,7 @@ const exitSoon = () => {
55
55
  process.exit();
56
56
  }, EXIT_GRACE_MS).unref();
57
57
  };
58
- import("./main-DKxToYW7.js").then(({ main }) => main(argv, {
58
+ import("./main-CX62QQM7.js").then(({ main }) => main(argv, {
59
59
  out: (s) => {
60
60
  process.stdout.write(s);
61
61
  },
@@ -13,7 +13,7 @@ import { DatabaseSync } from "node:sqlite";
13
13
  import { checkSync, lockSync } from "proper-lockfile";
14
14
  import { fileURLToPath } from "node:url";
15
15
  // package.json
16
- var version = "0.2.0";
16
+ var version = "0.3.0";
17
17
  // src/infra/env.ts
18
18
  /**
19
19
  * Environment for subprocesses: only the inherited whitelist (PATH, HOME, LANG) plus explicitly named variables.
@@ -1438,6 +1438,7 @@ var Assignments = class extends EnginePart {
1438
1438
  if (report.state === "blocked" || report.state === "failed") ticket.undelivered = ticket.undelivered.filter((id) => !automatic(ticket.inputs.find((input) => input.id === id)));
1439
1439
  this.repo.tickets.save(ticket);
1440
1440
  if (report.limit && report.state === "blocked") this.replies.limitStopped(ticket, assignment.id, report.limit, pull);
1441
+ if (report.workspace && report.state === "blocked") this.replies.workspaceBlocked(ticket, assignment.id, summary);
1441
1442
  const ready = !ticket.follows?.length ? "completed" : this.tickets.followsDone(ticket)?.outcome;
1442
1443
  const outcome = report.state === "dropped" ? this.tickets.followsDone(ticket)?.outcome ?? "dropped" : report.state === "ready" ? ready : void 0;
1443
1444
  if (outcome) this.tickets.end(ticketId, "executor", outcome, at);
@@ -2080,6 +2081,10 @@ function refusalText(reason) {
2080
2081
  function limitText(limit, pull) {
2081
2082
  return `I stopped before finishing: the ${limit} limit for this task was reached.` + (pull ? ` Work so far: ${pull}.` : "") + " Reply here to continue.";
2082
2083
  }
2084
+ /** The note a person gets when a configured branch is missing, so the task could not start. */
2085
+ function workspaceBlockedText(reason) {
2086
+ return `I could not start this task. ${reason} Reply here once that is fixed to try again.`;
2087
+ }
2083
2088
  /**
2084
2089
  * Each message is queued under a deterministic id, so it is never sent twice. Replies to a person go where the person's
2085
2090
  * message was written, and a result where the latest person's message to the ticket was written.
@@ -2118,6 +2123,24 @@ var Replies = class extends EnginePart {
2118
2123
  ticketId: ticket.id
2119
2124
  });
2120
2125
  }
2126
+ /**
2127
+ * Queue the reason the task could not start where the latest person's message to the ticket was written, else where
2128
+ * it started; nothing when there is no conversation. Its id is `workspace-blocked:<assignmentId>:<triggerId>` for
2129
+ * the latest person's message, so a repeat or restart sends nothing more, and a person's reply that meets the same
2130
+ * block is told again.
2131
+ */
2132
+ workspaceBlocked(ticket, assignmentId, reason) {
2133
+ const channel = this.replyChannel(ticket);
2134
+ if (!channel) return;
2135
+ const latest = ticket.inputs.findLast((input) => input.kind === "trigger" && input.human === true && !input.context)?.triggerId ?? (this.repo.triggers.get(ticket.origin.triggerId)?.human ? ticket.origin.triggerId : void 0);
2136
+ this.options.outbox.send({
2137
+ id: latest ? `workspace-blocked:${assignmentId}:${latest}` : `workspace-blocked:${assignmentId}`,
2138
+ channel,
2139
+ text: workspaceBlockedText(reason),
2140
+ kind: "reply",
2141
+ ticketId: ticket.id
2142
+ });
2143
+ }
2121
2144
  withdrawAttention(attentionId) {
2122
2145
  this.options.outbox.withdraw(key("attention-reply", attentionId));
2123
2146
  }
@@ -2866,6 +2889,12 @@ function settingsPolicy(settings, sources) {
2866
2889
  }
2867
2890
  const DEFAULT_STATE_DIR = "~/.superagent-claude";
2868
2891
  const RepositorySchema = z.string().trim().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, "must be owner/name");
2892
+ const BranchNameSchema = z.string().regex(/^(?![-/])(?!.*\.\.)(?!.*\/\/)(?!.*\/$)(?!.*\.lock$)[A-Za-z0-9._/-]{1,200}$/, "must be a branch name: letters, digits and . _ - /, not starting with - or /, without .. or //, not ending with / or .lock");
2893
+ const RepositoryEntrySchema = z.union([RepositorySchema, z.object({
2894
+ repository: RepositorySchema,
2895
+ branchFrom: BranchNameSchema.optional(),
2896
+ pullRequestTo: BranchNameSchema.optional()
2897
+ }).strict()]);
2869
2898
  const AUTH_MODES = Object.keys(AUTH_ENV);
2870
2899
  /** A refused start (exit code 2); `rule` names the violated startup rule. */
2871
2900
  var StartupRefused = class extends Error {
@@ -2877,7 +2906,23 @@ var StartupRefused = class extends Error {
2877
2906
  }
2878
2907
  };
2879
2908
  const ConfigSchema = z.object({
2880
- repositories: z.array(RepositorySchema).min(1, "must name at least one repository"),
2909
+ repositories: z.array(RepositoryEntrySchema).min(1, "must name at least one repository").superRefine((items, ctx) => {
2910
+ const seen = /* @__PURE__ */ new Set();
2911
+ const reported = /* @__PURE__ */ new Set();
2912
+ for (const item of items) {
2913
+ const name = typeof item === "string" ? item : item.repository;
2914
+ const key = name.toLowerCase();
2915
+ if (seen.has(key)) {
2916
+ if (!reported.has(key)) {
2917
+ reported.add(key);
2918
+ ctx.addIssue({
2919
+ code: "custom",
2920
+ message: `lists ${name} more than once`
2921
+ });
2922
+ }
2923
+ } else seen.add(key);
2924
+ }
2925
+ }).transform((items) => items.map((item) => typeof item === "string" ? item : item.repository)),
2881
2926
  policy: z.object({
2882
2927
  allowed: z.array(LoginSchema).min(1, "must name at least one login"),
2883
2928
  acceptMode: z.enum([
@@ -2932,6 +2977,24 @@ const ConfigSchema = z.object({
2932
2977
  }),
2933
2978
  stateDir: z.string().trim().min(1).default(DEFAULT_STATE_DIR)
2934
2979
  }).strict();
2980
+ function repositoryBranches(entries) {
2981
+ const parsed = z.array(RepositoryEntrySchema).safeParse(entries);
2982
+ if (!parsed.success) return {};
2983
+ const branches = {};
2984
+ for (const entry of parsed.data) {
2985
+ if (typeof entry === "string") continue;
2986
+ const { repository, branchFrom, pullRequestTo } = entry;
2987
+ if (branchFrom === void 0 && pullRequestTo === void 0) continue;
2988
+ branches[repository.toLowerCase()] = {
2989
+ ...branchFrom !== void 0 ? { branchFrom } : {},
2990
+ ...pullRequestTo !== void 0 ? { pullRequestTo } : {}
2991
+ };
2992
+ }
2993
+ return branches;
2994
+ }
2995
+ function branchesOf(config, repository) {
2996
+ return config.branches?.[repository.toLowerCase()] ?? {};
2997
+ }
2935
2998
  var ConfigError = class extends StartupRefused {
2936
2999
  constructor(message) {
2937
3000
  super("config", message);
@@ -2960,9 +3023,11 @@ function loadConfig(path, env = process.env) {
2960
3023
  const result = ConfigSchema.safeParse(raw);
2961
3024
  if (!result.success) throw new ConfigError(`The config file ${path} is invalid:\n` + result.error.issues.map((issue) => ` ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n"));
2962
3025
  const data = result.data;
3026
+ const branches = repositoryBranches(raw.repositories);
2963
3027
  return {
2964
3028
  ...data,
2965
- stateDir: expandStateDir(data.stateDir, dirname(path), env)
3029
+ stateDir: expandStateDir(data.stateDir, dirname(path), env),
3030
+ ...Object.keys(branches).length ? { branches } : {}
2966
3031
  };
2967
3032
  }
2968
3033
  const byLogin = (a, b) => {
@@ -3257,6 +3322,13 @@ var ReadLedger = class {
3257
3322
  };
3258
3323
  const DRAIN_MS = 2e3;
3259
3324
  const workerKey = (ticketId) => `worker-${ticketId}`;
3325
+ /** A configured branch is missing on the remote; the message is the reason a person reads. */
3326
+ var WorkspaceBlocked = class extends Error {
3327
+ constructor(message) {
3328
+ super(message);
3329
+ this.name = "WorkspaceBlocked";
3330
+ }
3331
+ };
3260
3332
  /**
3261
3333
  * Mode of a ticket Input (see docs/runtime-contract.md): a person's message steers; notes, reopen markers and
3262
3334
  * a message no person wrote that waited on a ready ticket (`quiet`) are injected; everything else is a followup.
@@ -3542,6 +3614,14 @@ var WorkerExecutor = class {
3542
3614
  try {
3543
3615
  workspace = await this.deps.prepare(ticket);
3544
3616
  } catch (e) {
3617
+ if (e instanceof WorkspaceBlocked) {
3618
+ this.report(ticketId, {
3619
+ state: "blocked",
3620
+ summary: e.message,
3621
+ workspace: true
3622
+ });
3623
+ return;
3624
+ }
3545
3625
  this.report(ticketId, {
3546
3626
  state: "failed",
3547
3627
  summary: `The workspace could not be prepared: ${this.message(e)}`
@@ -6389,6 +6469,7 @@ function truncateOutput(text, maxLines, maxBytes) {
6389
6469
  * Clone and fetch get the injected access env (authentication), and errors mask its secrets.
6390
6470
  * Publishing pushes an agent branch without force, adding co-author trailers only to commits not yet pushed.
6391
6471
  * Access errors carry the HTTP status when one is known. A transient push failure is retried up to 3 times (2 s, 5 s).
6472
+ * A worktree starts from the configured remote branch, the branch choice is fixed per ticket in a host file outside the worktree, and a missing configured branch stops prepare or push with `WorkspaceBlocked`.
6392
6473
  */
6393
6474
  var RepositoryAccessError = class extends Error {
6394
6475
  repository;
@@ -6491,7 +6572,30 @@ var Workspaces = class {
6491
6572
  clonePath(repository) {
6492
6573
  return join(this.stateDir, "repos", repository.replace("/", "__"));
6493
6574
  }
6494
- async prepare(ticket, name) {
6575
+ branchesPath(ticketId) {
6576
+ return join(this.stateDir, "branches", ticketId + ".json");
6577
+ }
6578
+ fixedBranches(ticketId) {
6579
+ const path = this.branchesPath(ticketId);
6580
+ if (!existsSync(path)) return void 0;
6581
+ const record = JSON.parse(readFileSync(path, "utf8"));
6582
+ if (typeof record !== "object" || record === null || Array.isArray(record)) throw new Error(`The branch record of ticket ${ticketId} is unreadable`);
6583
+ const { from, to } = record;
6584
+ if (from !== void 0 && typeof from !== "string" || to !== void 0 && typeof to !== "string") throw new Error(`The branch record of ticket ${ticketId} is unreadable`);
6585
+ return {
6586
+ ...from !== void 0 ? { from } : {},
6587
+ ...to !== void 0 ? { to } : {}
6588
+ };
6589
+ }
6590
+ remoteHas(clone, name) {
6591
+ return this.git([
6592
+ "rev-parse",
6593
+ "--verify",
6594
+ "--quiet",
6595
+ "refs/remotes/origin/" + name
6596
+ ], clone).then(() => true, () => false);
6597
+ }
6598
+ async prepare(ticket, name, branches = {}) {
6495
6599
  if (!NAME.test(name) || name.includes("..") || name.endsWith(".lock")) throw new Error("Invalid worktree name: " + name);
6496
6600
  const repository = this.repositoryOf(ticket);
6497
6601
  if (!REPOSITORY.test(repository)) throw new RepositoryAccessError(repository, "not an owner/name repository");
@@ -6517,6 +6621,15 @@ var Workspaces = class {
6517
6621
  const base = await this.clone(repository);
6518
6622
  mkdirSync(dirname(cwd), { recursive: true });
6519
6623
  await this.git(["worktree", "prune"], base);
6624
+ const stored = this.fixedBranches(ticket.id);
6625
+ const fixed = stored ?? {
6626
+ ...branches.branchFrom ? { from: branches.branchFrom } : {},
6627
+ ...branches.pullRequestTo ? { to: branches.pullRequestTo } : {}
6628
+ };
6629
+ const names = [...new Set([fixed.from, fixed.to].filter((name) => name !== void 0))];
6630
+ const missing = [];
6631
+ for (const name of names) if (!await this.remoteHas(base, name)) missing.push(name);
6632
+ if (missing.length) throw new WorkspaceBlocked(`${repository} has no branch ${missing.join(", ")}, so no workspace was made. A person must create the branch or change branchFrom or pullRequestTo for ${repository} in the config.`);
6520
6633
  const exists = await this.git([
6521
6634
  "rev-parse",
6522
6635
  "--verify",
@@ -6535,8 +6648,16 @@ var Workspaces = class {
6535
6648
  "-b",
6536
6649
  branch,
6537
6650
  cwd,
6538
- "origin/HEAD"
6651
+ fixed.from ? "origin/" + fixed.from : "origin/HEAD"
6539
6652
  ], base);
6653
+ if (stored === void 0) {
6654
+ const path = this.branchesPath(ticket.id);
6655
+ mkdirSync(dirname(path), {
6656
+ recursive: true,
6657
+ mode: 448
6658
+ });
6659
+ writeFileSync(path, JSON.stringify(fixed) + "\n", { mode: 384 });
6660
+ }
6540
6661
  return {
6541
6662
  repository,
6542
6663
  cwd,
@@ -6580,6 +6701,7 @@ var Workspaces = class {
6580
6701
  return dir;
6581
6702
  }
6582
6703
  async remove(ticketId) {
6704
+ rmSync(this.branchesPath(ticketId), { force: true });
6583
6705
  const path = this.worktreePath(ticketId);
6584
6706
  if (!existsSync(path)) return;
6585
6707
  const common = existsSync(join(path, ".git")) ? (await this.git([
@@ -6648,6 +6770,18 @@ var Workspaces = class {
6648
6770
  "HEAD"
6649
6771
  ], cwd)).trim();
6650
6772
  }
6773
+ async reviewBase(cwd, repo) {
6774
+ const configured = this.fixedBranches(basename(resolve(cwd)))?.from;
6775
+ if (configured === void 0) return "origin/HEAD";
6776
+ return await this.git([
6777
+ ...repo,
6778
+ ...GIT_READ_OPTIONS,
6779
+ "rev-parse",
6780
+ "--verify",
6781
+ "--quiet",
6782
+ "refs/remotes/origin/" + configured
6783
+ ], cwd).then(() => true, () => false) ? "refs/remotes/origin/" + configured : "origin/HEAD";
6784
+ }
6651
6785
  async diffStat(cwd, base) {
6652
6786
  const repo = this.repository(cwd);
6653
6787
  const known = base !== void 0 && SHA.test(base) && await this.git([
@@ -6661,7 +6795,7 @@ var Workspaces = class {
6661
6795
  ...repo,
6662
6796
  ...GIT_READ_OPTIONS,
6663
6797
  "merge-base",
6664
- "origin/HEAD",
6798
+ await this.reviewBase(cwd, repo),
6665
6799
  "HEAD"
6666
6800
  ], cwd)).trim();
6667
6801
  const out = await this.git([
@@ -6742,7 +6876,13 @@ var Workspaces = class {
6742
6876
  const branch = (await this.git(["branch", "--show-current"], cwd)).trim();
6743
6877
  if (!branch.startsWith("agent/")) throw new Error("The worktree is not on its agent branch");
6744
6878
  const clone = await this.clone(repository);
6745
- const base = (await this.git([
6879
+ const fixed = this.fixedBranches(ticket.id);
6880
+ const target = fixed?.to ?? fixed?.from;
6881
+ let base;
6882
+ if (target) {
6883
+ if (!await this.remoteHas(clone, target)) throw new WorkspaceBlocked(`${repository} has no branch ${target} to open the pull request against, so nothing was pushed and no pull request was opened. A person must create the branch or change branchFrom or pullRequestTo for ${repository} in the config.`);
6884
+ base = target;
6885
+ } else base = (await this.git([
6746
6886
  "symbolic-ref",
6747
6887
  "--short",
6748
6888
  "refs/remotes/origin/HEAD"
@@ -9019,7 +9159,8 @@ function readTool(description, grammar, read) {
9019
9159
  * Policy is checked before any git or GitHub call: live ticket, configured repository, a PR this ticket bound,
9020
9160
  * and delivery.allowAutoMerge. Publish re-checks push permission first and returns `published: false`
9021
9161
  * without any write when it is missing. Then the host review decision may stop it with `review_required`, again without any write.
9022
- * Repository access errors file a failed report.
9162
+ * Repository access errors file a failed report. A configured target branch missing on the remote returns
9163
+ * `published: false` before any push.
9023
9164
  */
9024
9165
  const REVIEW_REASON = "Run the superagent-reviewer subagent before publishing, give it the original request and the human constraints, apply only bug and missing-requirement findings, then call orchestrator_publish again";
9025
9166
  /** The issue a Trigger is on: its meta names a repository and a number, and `isPr` is false. */
@@ -9081,7 +9222,7 @@ function deliveryTools(host, ticketId) {
9081
9222
  return [
9082
9223
  tool$1({
9083
9224
  name: "orchestrator_publish",
9084
- description: "Push this ticket's commits and open its pull request, or reuse the open one for the branch; never force-pushes. Binds the PR thread to this ticket. Returns published: false, pushing nothing, when the agent account has no write access to the repository. Returns published: false with review_required, pushing nothing, when the host requires a review first: run the superagent-reviewer subagent, then publish again.",
9225
+ description: "Push this ticket's commits and open its pull request, or reuse the open one for the branch; never force-pushes. Binds the PR thread to this ticket. Returns published: false, pushing nothing, when the agent account has no write access to the repository. Returns published: false with review_required, pushing nothing, when the host requires a review first: run the superagent-reviewer subagent, then publish again. Returns published: false, pushing nothing, when the configured target branch is missing on the remote.",
9085
9226
  schema: z.object({
9086
9227
  title: z.string().trim().min(1).max(256),
9087
9228
  body: z.string().trim().min(1).max(6e4),
@@ -9122,6 +9263,12 @@ function deliveryTools(host, ticketId) {
9122
9263
  try {
9123
9264
  pushed = await deliveryHost().push(ticketId, { coAuthors: args.coAuthors });
9124
9265
  } catch (error) {
9266
+ if (error instanceof WorkspaceBlocked) return {
9267
+ published: false,
9268
+ repository,
9269
+ reason: error.message,
9270
+ next: "Report blocked with the reason; a person must create the branch or fix the repository config"
9271
+ };
9125
9272
  throw error instanceof RepositoryAccessError ? accessFailure(repository, error) : error;
9126
9273
  }
9127
9274
  let pr;
@@ -10736,7 +10883,7 @@ var Runtime = class {
10736
10883
  });
10737
10884
  };
10738
10885
  const deps = {
10739
- prepare: (ticket) => this.workspaces.prepare(ticket, worktreeName(ticket)),
10886
+ prepare: (ticket) => this.workspaces.prepare(ticket, worktreeName(ticket), branchesOf(this.current, repositoryOf(ticket))),
10740
10887
  instructions: async (ticket) => this.prompts.instructions("worker", [{
10741
10888
  source: "ticket-title",
10742
10889
  text: ticket.title
@@ -11174,6 +11321,8 @@ var Runtime = class {
11174
11321
  const effective = {
11175
11322
  ...this.current,
11176
11323
  repositories: next.repositories,
11324
+ branches: next.branches,
11325
+ review: next.review,
11177
11326
  policy: next.policy,
11178
11327
  network: next.network,
11179
11328
  retention: next.retention,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superagent-claude",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A proactive agent for GitHub repositories. Powered by Claude.",
5
5
  "license": "MIT",
6
6
  "repository": {