@coreplane/switchboard 1.231.0 → 1.232.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/dist/cli.js CHANGED
@@ -9369,6 +9369,13 @@ function wakeWaitBudget(commandBudgetMs) {
9369
9369
  if (commandBudgetMs === void 0 || !Number.isFinite(commandBudgetMs)) return WAKE_WAIT_MAX_MS;
9370
9370
  return Math.max(0, Math.min(WAKE_WAIT_MAX_MS, Math.trunc(commandBudgetMs)));
9371
9371
  }
9372
+ function saysContainerGone(data) {
9373
+ if (data.reason === "runtime-replaced") return true;
9374
+ return data.needs === "attach" && typeof data.error === "string" && data.error.startsWith("worktree-missing:");
9375
+ }
9376
+ function containerGoneMessage(error) {
9377
+ return `${error.trim()}; the command was not run again`;
9378
+ }
9372
9379
  function sandboxRestartedMessage(woke) {
9373
9380
  const secs2 = Math.round(woke.waitedMs / 1e3);
9374
9381
  return `the resident container exited under a rollout and woke from its snapshot after ${secs2}s; the worktree was recreated at ${woke.ref}@${woke.sha.slice(0, 7)}, so uncommitted changes and unpushed commits from earlier in this run are gone`;
@@ -9629,10 +9636,11 @@ var init_resident = __esm({
9629
9636
  this.opts = opts;
9630
9637
  }
9631
9638
  opts;
9632
- /** Consecutive `runtime-replaced` outcomes with no successful op between
9633
- * them. One is a deploy that swapped the resident isolate under a command
9634
- * (routine, recoverable); two in a row is a flapping resident and becomes
9635
- * infra so the runner's fail-fast still has teeth. */
9639
+ /** Consecutive `runtime-replaced` answers on the idempotent routes (/read,
9640
+ * /write) with no successful op between them. One is a deploy that swapped
9641
+ * the resident isolate under a call (routine: re-attach and re-issue); two
9642
+ * in a row is a flapping resident and becomes infra. /exec never counts
9643
+ * here: its `runtime-replaced` is the typed restart (`opWithReattach`). */
9636
9644
  runtimeReplacedStreak = 0;
9637
9645
  lastBinding;
9638
9646
  /** The thread's binding as the resident answered it on the most recent
@@ -9807,15 +9815,20 @@ var init_resident = __esm({
9807
9815
  return { released: false, reason: err2 instanceof Error ? err2.message : String(err2) };
9808
9816
  }
9809
9817
  }
9810
- /** Run a route; on needs:"attach" (evicted/recycled worktree, in-body for
9818
+ /** Run a route; on needs:"attach" for an evicted worktree (in-body for
9811
9819
  * /exec, 409 for /read //write) re-attach ONCE and retry, then fail legibly.
9812
9820
  *
9813
- * A `reason:"runtime-replaced"` answer (the resident isolate was swapped by
9814
- * a deploy while the op ran) also re-attaches once — proving the new isolate
9815
- * serves this thread — but the op is re-issued only for the idempotent
9816
- * routes (/read, /write). /exec is handed back to the caller as-is: the
9817
- * command may have started, so it is never blind-retried. Two such answers
9818
- * with no success between them are infra (a flapping resident).
9821
+ * An answer saying the container under the thread is gone
9822
+ * (`saysContainerGone`: `reason:"runtime-replaced"`, the isolate was swapped
9823
+ * by a deploy while the op ran; `worktree-missing`, the container disk was
9824
+ * recycled since the last attach) is, on /exec, the typed
9825
+ * `ExecSandboxRestartedError` at once — the word the pi harness keys on
9826
+ * (harness-pi item 16) — before any recovery, so nothing the recovery
9827
+ * meets can hide it; the command may have started and is never re-issued.
9828
+ * On the idempotent routes (/read, /write) a `runtime-replaced` re-attaches
9829
+ * once — proving the new isolate serves this thread — and re-issues the op;
9830
+ * two such answers with no success between them are infra (a flapping
9831
+ * resident).
9819
9832
  *
9820
9833
  * A refusal naming a container that just exited (item 65) waits for the
9821
9834
  * wake first (`awaitWake`): the refusal came before the command started, so
@@ -9834,6 +9847,10 @@ var init_resident = __esm({
9834
9847
  ),
9835
9848
  { kind: "infra", code: "attach" }
9836
9849
  );
9850
+ const goneUnderThread = (data) => {
9851
+ if (route3 === "/exec" && saysContainerGone(data))
9852
+ throw new ExecSandboxRestartedError(containerGoneMessage(String(data.error)), 0);
9853
+ };
9837
9854
  let r = await this.call(route3, body, callTimeoutMs, signal2, span);
9838
9855
  if (isContainerRolling(r.data.error)) {
9839
9856
  const woke = await this.awaitWake(route3, String(r.data.error), { signal: signal2, budgetMs: opts.waitBudgetMs, span });
@@ -9848,19 +9865,19 @@ var init_resident = __esm({
9848
9865
  );
9849
9866
  }
9850
9867
  }
9868
+ goneUnderThread(r.data);
9851
9869
  if (r.data.needs === "attach") {
9852
9870
  await this.attach(span);
9853
9871
  r = await this.call(route3, body, callTimeoutMs, signal2, span);
9872
+ goneUnderThread(r.data);
9854
9873
  if (r.data.needs === "attach") throw stillGone(r.data);
9855
9874
  }
9856
9875
  if (r.data.reason === "runtime-replaced") {
9857
9876
  this.noteRuntimeReplaced(route3, r.data);
9858
9877
  await this.attach(span);
9859
- if (route3 === "/read" || route3 === "/write") {
9860
- r = await this.call(route3, body, callTimeoutMs, signal2, span);
9861
- if (r.data.reason === "runtime-replaced") this.noteRuntimeReplaced(route3, r.data);
9862
- if (r.data.needs === "attach") throw stillGone(r.data);
9863
- }
9878
+ r = await this.call(route3, body, callTimeoutMs, signal2, span);
9879
+ if (r.data.reason === "runtime-replaced") this.noteRuntimeReplaced(route3, r.data);
9880
+ if (r.data.needs === "attach") throw stillGone(r.data);
9864
9881
  }
9865
9882
  if (typeof r.data.error !== "string" || !r.data.error) this.runtimeReplacedStreak = 0;
9866
9883
  return r;
@@ -9944,10 +9961,6 @@ var init_resident = __esm({
9944
9961
  if (status3 === 400 && typeof data.error === "string" && data.error && data.needs === void 0) {
9945
9962
  throw new Error(`resident /exec: ${data.error}`);
9946
9963
  }
9947
- if (data.reason === "runtime-replaced") {
9948
- return `${String(data.error)}
9949
- The command may have started; re-check its effects (e.g. git status, the files it writes) before re-running it.`;
9950
- }
9951
9964
  if (typeof data.error === "string" && data.error) {
9952
9965
  throw classifyError(new ExecInfraError(`resident /exec: ${data.error}`), { kind: "infra" });
9953
9966
  }
@@ -25072,7 +25085,8 @@ async function findOpenPrByHead(repo, branch2) {
25072
25085
  return {
25073
25086
  number: first.number,
25074
25087
  htmlUrl: first.html_url,
25075
- ...first.head?.sha ? { headSha: first.head.sha } : {}
25088
+ ...first.head?.sha ? { headSha: first.head.sha } : {},
25089
+ ..."auto_merge" in first ? { autoMergeEnabled: first.auto_merge !== null } : {}
25076
25090
  };
25077
25091
  }
25078
25092
  async function findMergedPrByHead(repo, branch2) {
@@ -25182,7 +25196,6 @@ async function fetchRepoShipInfo(repo) {
25182
25196
  const data = await res.json().catch(() => null);
25183
25197
  if (!data || typeof data !== "object") return void 0;
25184
25198
  return {
25185
- ...typeof data.allow_auto_merge === "boolean" ? { allowAutoMerge: data.allow_auto_merge } : {},
25186
25199
  ...typeof data.default_branch === "string" && data.default_branch ? { defaultBranch: data.default_branch } : {}
25187
25200
  };
25188
25201
  }
@@ -25247,7 +25260,8 @@ async function fetchPullRequestFacts(pr) {
25247
25260
  sameRepoHead,
25248
25261
  ...typeof data.base?.ref === "string" && data.base.ref ? { baseRef: data.base.ref } : {},
25249
25262
  ...typeof data.html_url === "string" ? { htmlUrl: data.html_url } : {},
25250
- ...typeof data.title === "string" && data.title ? { title: data.title } : {}
25263
+ ...typeof data.title === "string" && data.title ? { title: data.title } : {},
25264
+ ..."auto_merge" in data ? { autoMergeEnabled: data.auto_merge !== null } : {}
25251
25265
  };
25252
25266
  }
25253
25267
  async function mergePullRequest(pr, opts) {
@@ -25584,7 +25598,7 @@ async function resolveRepoContext(msg, history = [], probe, residentSlugs, recor
25584
25598
  const inherited = inheritedPr(thread, records, repo);
25585
25599
  if (inherited) {
25586
25600
  const head = await openPrHeadSha(inherited);
25587
- if ("sha" in head) {
25601
+ if (!("reason" in head)) {
25588
25602
  out.pr = inherited.number;
25589
25603
  out.headSha = head.sha;
25590
25604
  if (head.base) out.baseRef = head.base;
@@ -25599,6 +25613,9 @@ async function resolveRepoContext(msg, history = [], probe, residentSlugs, recor
25599
25613
  }
25600
25614
  } else {
25601
25615
  out.prUnpostable = { number: inherited.number, reason: head.reason };
25616
+ if (inherited.source === "record" && head.reason === "closed" && head.sha !== void 0) {
25617
+ out.closedRecordPr = { number: inherited.number, headSha: head.sha, merged: head.merged };
25618
+ }
25602
25619
  }
25603
25620
  }
25604
25621
  }
@@ -25609,8 +25626,14 @@ function ownPrOf(ctx) {
25609
25626
  return { number: ctx.pr, ref: ctx.ref };
25610
25627
  }
25611
25628
  function recordPrOf(ctx) {
25612
- if (!ctx.prFromRecord || ctx.pr === void 0 || ctx.headSha === void 0) return void 0;
25613
- return { number: ctx.pr, headSha: ctx.headSha };
25629
+ if (ctx.prFromRecord && ctx.pr !== void 0 && ctx.headSha !== void 0) {
25630
+ return { number: ctx.pr, headSha: ctx.headSha, state: "open" };
25631
+ }
25632
+ if (ctx.closedRecordPr !== void 0) {
25633
+ const { number, headSha, merged } = ctx.closedRecordPr;
25634
+ return { number, headSha, state: merged ? "merged" : "closed" };
25635
+ }
25636
+ return void 0;
25614
25637
  }
25615
25638
  function inheritedPr(thread, records, repo) {
25616
25639
  const turn = thread.pr && thread.pr.repo === repo ? { repo, number: thread.pr.number, source: "turn", at: thread.prAt } : void 0;
@@ -25620,7 +25643,8 @@ function inheritedPr(thread, records, repo) {
25620
25643
  }
25621
25644
  async function openPrHeadSha(pr) {
25622
25645
  const head = await prHead(pr).catch(() => void 0);
25623
- if (head?.state === "closed") return { reason: "closed" };
25646
+ if (head?.state === "closed")
25647
+ return { reason: "closed", ...head.sha ? { sha: head.sha } : {}, merged: head.merged };
25624
25648
  if (head?.state === "open" && head.sha) {
25625
25649
  return {
25626
25650
  sha: head.sha,
@@ -25686,9 +25710,17 @@ async function prHead(pr) {
25686
25710
  const headRepo = data.head?.repo?.full_name?.toLowerCase();
25687
25711
  const ref = data.head?.ref && headRepo === pr.repo ? validRef(data.head.ref) : void 0;
25688
25712
  const state = data.state === "open" || data.state === "closed" ? data.state : void 0;
25689
- const sha = ref !== void 0 ? preferRefTip(`${pr.repo}#${pr.number}`, prSha, await headRefTipSha(pr.repo, ref, headers), ref) : prSha;
25713
+ const sha = ref !== void 0 && state !== "closed" ? preferRefTip(`${pr.repo}#${pr.number}`, prSha, await headRefTipSha(pr.repo, ref, headers), ref) : prSha;
25690
25714
  const size = sha === prSha ? prSizeOf(data) : void 0;
25691
- return { ref, sha, base, ...size ? { size } : {}, state, ...facts2 ? { facts: facts2 } : {} };
25715
+ return {
25716
+ ref,
25717
+ sha,
25718
+ base,
25719
+ ...size ? { size } : {},
25720
+ state,
25721
+ merged: data.merged === true,
25722
+ ...facts2 ? { facts: facts2 } : {}
25723
+ };
25692
25724
  }
25693
25725
  function prSizeOf(data) {
25694
25726
  const n2 = (x) => typeof x === "number" && Number.isInteger(x) && x >= 0;
@@ -32445,13 +32477,13 @@ function removeScript(dir) {
32445
32477
  return `rm -rf ${shellQuote(dir)}`;
32446
32478
  }
32447
32479
  function stdoutOf(operation, out) {
32448
- const exit = parseExitPrefix(out);
32449
- if (exit.failed || RUNTIME_WORD.test(out)) throw new PiContainerError(operation, out);
32480
+ if (RUNTIME_WORD.test(out)) throw new PiContainerRuntimeReplacedError(operation, out);
32481
+ if (parseExitPrefix(out).failed) throw new PiContainerError(operation, out);
32450
32482
  const cut = out.indexOf(STDERR_MARK);
32451
32483
  const stdout = cut >= 0 ? out.slice(0, cut) : out;
32452
32484
  return stdout === "(no output)" ? "" : stdout;
32453
32485
  }
32454
- var PiContainerError, WRITE_CHUNK_CHARS, INLINE_LINE_CHARS, LOG_READ_BYTES, MESSAGE_UPDATE_MARK, LOG_FILTER, IDENTITY_WORD, STDERR_MARK, RUNTIME_WORD, OP_TIMEOUT_MS, ExecPiContainer;
32486
+ var PiContainerError, WRITE_CHUNK_CHARS, INLINE_LINE_CHARS, LOG_READ_BYTES, MESSAGE_UPDATE_MARK, LOG_FILTER, IDENTITY_WORD, STDERR_MARK, RUNTIME_WORD, PiContainerRuntimeReplacedError, OP_TIMEOUT_MS, ExecPiContainer;
32455
32487
  var init_container = __esm({
32456
32488
  "../../src/core/harness/pi/container.ts"() {
32457
32489
  "use strict";
@@ -32473,7 +32505,13 @@ var init_container = __esm({
32473
32505
  LOG_FILTER = `grep --line-buffered -v ${shellQuote(MESSAGE_UPDATE_MARK)}`;
32474
32506
  IDENTITY_WORD = /^[A-Za-z0-9-]{1,64}$/;
32475
32507
  STDERR_MARK = "\n--- stderr ---\n";
32476
- RUNTIME_WORD = /^(?:runtime-unreachable:|runtime-replaced)/;
32508
+ RUNTIME_WORD = /\bruntime-(?:unreachable|replaced)\b/;
32509
+ PiContainerRuntimeReplacedError = class extends PiContainerError {
32510
+ constructor(operation, detail) {
32511
+ super(operation, detail);
32512
+ this.name = "PiContainerRuntimeReplacedError";
32513
+ }
32514
+ };
32477
32515
  OP_TIMEOUT_MS = 6e4;
32478
32516
  ExecPiContainer = class {
32479
32517
  constructor(executor) {
@@ -33614,10 +33652,8 @@ function settlementAnswer(s) {
33614
33652
  return { content: [{ type: "text", text: settlementText(s) }], isError: true };
33615
33653
  }
33616
33654
  function saysContainerReplaced(err2) {
33617
- if (err2 instanceof ExecSandboxRestartedError) return true;
33618
- if (!(err2 instanceof Error)) return false;
33619
- const text = err2.message.replace(/^pi container: [a-z]+ failed — /, "").replace(/^exit \d+: /, "");
33620
- return /^(?:runtime-unreachable:|runtime-replaced)/.test(text);
33655
+ if (err2 instanceof ExecSandboxRestartedError || err2 instanceof PiContainerRuntimeReplacedError) return true;
33656
+ return err2 instanceof Error && RUNTIME_WORD.test(err2.message);
33621
33657
  }
33622
33658
  function replacedCallNote(tool) {
33623
33659
  return `The container running pi was replaced while this ${tool} call was in flight; its result was lost \u2014 re-check its effects before re-running it.`;
@@ -34254,6 +34290,7 @@ var init_harness = __esm({
34254
34290
  init_runEvents();
34255
34291
  init_threadAdmission();
34256
34292
  init_bridge2();
34293
+ init_container();
34257
34294
  init_mirror();
34258
34295
  init_process();
34259
34296
  init_protocol();
@@ -34719,14 +34756,30 @@ async function runCodingPrPostStep(input) {
34719
34756
  const candidates = [target.baseRef, target.bindingRef, target.resolvedRef];
34720
34757
  const base = prDescription ? await resolveBaseRefLazy(candidates, repo, input.fetchRepoInfo) : resolveBaseRef(candidates, void 0);
34721
34758
  const pushedBranch = branch2 !== void 0 && branch2 !== base && pushed;
34722
- if (prDescription && branch2 !== void 0 && branch2 === base && target.ownPr !== void 0) {
34723
- const { number, headSha: prHead2 } = target.ownPr;
34759
+ const ownPr = target.ownPr;
34760
+ const pushedPastOwnPr = ownPr !== void 0 && pushed && headSha !== void 0 && !sameCommit2(headSha, ownPr.headSha) ? headSha : void 0;
34761
+ if (prDescription && ownPr !== void 0 && ownPr.state !== "open" && pushedPastOwnPr !== void 0 && branch2 !== void 0) {
34762
+ const url = `https://github.com/${repo}/pull/${ownPr.number}`;
34763
+ console.log(
34764
+ `[pr-post] ${logKey} skipped: ${branchLog} was pushed past the thread's ${ownPr.state} ${repo}#${ownPr.number} (its head ${ownPr.headSha.slice(0, 7)}, the workspace at ${pushedPastOwnPr.slice(0, 7)}) \u2014 a new PR is needed`
34765
+ );
34766
+ input.publish({
34767
+ type: "run_note",
34768
+ kind: "pr_not_opened",
34769
+ summary: `no PR opened: ${branch2} was pushed past the thread's ${ownPr.state} pull request #${ownPr.number}; the new commits need a new pull request`,
34770
+ at: systemClock()
34771
+ });
34772
+ return `\u26A0\uFE0F A PR description was submitted and \`${branch2}\`${branchNote} was pushed to \`${pushedPastOwnPr.slice(0, 7)}\`, but this thread's pull request ${url} is ${ownPr.state} and that branch is its head \u2014 the new commits need a new pull request, so nothing was edited: compare & open manually: ${compareUrl}`;
34773
+ }
34774
+ if (prDescription && ownPr !== void 0 && (branch2 === base || ownPr.state !== "open")) {
34775
+ const { number, headSha: prHead2, state } = ownPr;
34724
34776
  const url = `https://github.com/${repo}/pull/${number}`;
34777
+ const standing = state === "open" ? "" : ` (${state})`;
34725
34778
  try {
34726
34779
  const body = renderPrDescriptionMarkdown(prDescription, { repo, headSha: prHead2 });
34727
34780
  await input.updatePullRequest(repo, number, { title: prDescription.title, body });
34728
34781
  console.log(
34729
- `[pr-post] ${logKey} updated ${repo}#${number} \u2014 the thread's own pull request, nothing pushed (workspace on ${branchLog}; rendered at its head ${prHead2.slice(0, 7)})`
34782
+ `[pr-post] ${logKey} updated ${repo}#${number} \u2014 the thread's own pull request${standing}, nothing pushed past its head (workspace on ${branchLog}; rendered at its head ${prHead2.slice(0, 7)})`
34730
34783
  );
34731
34784
  input.publish({ type: "pr_opened", url, number, created: false, at: systemClock() });
34732
34785
  input.publish({
@@ -34734,7 +34787,7 @@ async function runCodingPrPostStep(input) {
34734
34787
  ...submittedPrDescriptionArtifact(prDescription, { repo, pr: number, headSha: prHead2, body }),
34735
34788
  at: systemClock()
34736
34789
  });
34737
- return `\u{1F500} PR updated: ${url} \u2014 body re-rendered at \`${prHead2.slice(0, 7)}\``;
34790
+ return state === "open" ? `\u{1F500} PR updated: ${url} \u2014 body re-rendered at \`${prHead2.slice(0, 7)}\`` : `\u{1F500} PR updated: ${url} \u2014 body re-rendered at \`${prHead2.slice(0, 7)}\` (the pull request is ${state}; its description was edited in place)`;
34738
34791
  } catch (err2) {
34739
34792
  const reason2 = err2 instanceof Error ? err2.message : String(err2);
34740
34793
  console.error(`[pr-post] ${logKey} update failed for ${repo}#${number}: ${reason2}`);
@@ -35677,151 +35730,6 @@ var init_runLoop = __esm({
35677
35730
  }
35678
35731
  });
35679
35732
 
35680
- // ../../src/core/ship/preflight.ts
35681
- function shipTaskText(requestText, repo) {
35682
- let t = requestText.replace(/<((?:https?):\/\/[^|>\s]+)(?:\|[^>]*)?>/gi, " $1 ");
35683
- t = t.replace(/https?:\/\/\S+/gi, " ");
35684
- const slug = repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35685
- t = t.replace(new RegExp(`\\bin\\s+${slug}\\s*:?`, "gi"), " ");
35686
- t = t.replace(new RegExp(`\\b${slug}(#\\d+)?\\b`, "gi"), " ");
35687
- t = t.replace(/\b[a-z0-9][\w.-]*\/[\w.-]+#\d+\b/gi, " ");
35688
- return t.replace(/\s+/g, " ").trim().replace(/^[:,\-—.\s]+/, "").trim();
35689
- }
35690
- async function shipPreflight(input) {
35691
- const { repoCtx } = input;
35692
- if (!input.channelId.startsWith("slack:") && !input.channelId.startsWith("cli:")) {
35693
- const base = input.runsBase?.trim();
35694
- const page2 = base ? `${base.replace(/\/+$/, "")}/runs` : "the bot's /runs page";
35695
- return refuse2(
35696
- "channel",
35697
- "not started (Slack/CLI only)",
35698
- `\u{1F6AB} \`agent:ship\` runs only from Slack or the CLI \u2014 this adapter is single-shot and cannot hold a pipeline-length run. Start it there instead, and watch pipelines on the run page (${page2}).`
35699
- );
35700
- }
35701
- const missing = ["ship", "coding", "review"].filter((a) => !input.gates.canRunAgent(a));
35702
- if (missing.length > 0) {
35703
- return refuse2(
35704
- `permission (${missing.join(", ")})`,
35705
- "not started (permissions)",
35706
- `\u{1F6AB} Running \`ship\` drives \`coding\` and \`review\` child rounds, and you're not on the allowlist for ${missing.map((a) => `\`${a}\``).join(", ")}. Ask ${input.gates.adminsHint()} for access.`
35707
- );
35708
- }
35709
- const repo = repoCtx.repo;
35710
- if (!repo) {
35711
- return refuse2(
35712
- "no repo",
35713
- "not started (no repository)",
35714
- "\u{1F6AB} `agent:ship` needs a target repository \u2014 name it in the request, e.g. `agent:ship in owner/repo: <task>`."
35715
- );
35716
- }
35717
- const info = await input.repoInfo(repo).catch(() => void 0);
35718
- if (info?.allowAutoMerge !== false) {
35719
- const reason2 = info?.allowAutoMerge === true ? `auto-merge is enabled on \`${repo}\` \u2014 ship's approving LGTM triggers the org auto-approve workflow, and with auto-merge on the PR would merge with no human. Disable auto-merge on the repo to use ship.` : `could not verify that auto-merge is disabled on \`${repo}\` (the repository lookup failed or answered without the setting) \u2014 refusing fail-closed.`;
35720
- return refuse2("auto-merge", "not started (auto-merge)", `\u{1F6AB} ${reason2}`);
35721
- }
35722
- const task = shipTaskText(input.requestText, repo);
35723
- if (repoCtx.prUnpostable?.reason === "unreachable") {
35724
- return refuse2(
35725
- "thread PR unreachable",
35726
- "not started (PR unverifiable)",
35727
- `\u{1F6AB} This thread names PR ${repo}#${repoCtx.prUnpostable.number} but it could not be fetched to run ship's entry checks \u2014 refusing fail-closed. Retry in a moment, or check the PR on GitHub.`
35728
- );
35729
- }
35730
- if (repoCtx.pr !== void 0) {
35731
- const where = `${repo}#${repoCtx.pr}`;
35732
- const facts2 = await input.prFacts({ repo, number: repoCtx.pr }).catch(() => void 0);
35733
- const fallThrough = !facts2 && task !== "" && repoCtx.prFromMessage === true;
35734
- if (!facts2 && !fallThrough) {
35735
- return refuse2(
35736
- "PR facts unavailable",
35737
- "not started (PR unverifiable)",
35738
- `\u{1F6AB} Could not fetch ${where} to run ship's entry checks (open? bot-authored? same-repo head?) \u2014 refusing fail-closed. Retry in a moment.`
35739
- );
35740
- }
35741
- if (facts2) {
35742
- const self = facts2.state === "open" ? await input.selfIdentity().catch(() => void 0) : void 0;
35743
- if (facts2.state === "open" && !self) {
35744
- return refuse2(
35745
- "own identity unavailable",
35746
- "not started (identity unverifiable)",
35747
- `\u{1F6AB} Could not resolve the GitHub identity this bot acts as, so whether ${where} is ship's to drive cannot be judged \u2014 refusing fail-closed. Retry in a moment.`
35748
- );
35749
- }
35750
- const author = facts2.author;
35751
- const shipAuthored = self !== void 0 && author?.login === self.login && author?.id === self.id;
35752
- if (facts2.state === "open" && !(task && !shipAuthored)) {
35753
- if (task) {
35754
- return refuse2(
35755
- "new task over open PR",
35756
- "not started (open PR)",
35757
- `\u{1F6AB} This thread's PR ${where} is still open \u2014 a new task over it is refused. Re-issue \`agent:ship\` with only the PR URL to resume its review loop, or finish/close ${where} and start the new task in a fresh thread.`
35758
- );
35759
- }
35760
- if (!shipAuthored) {
35761
- return refuse2(
35762
- "human-authored PR",
35763
- "not started (not ship's PR)",
35764
- `\u{1F6AB} ${where} was not authored by \`${self.login}\` (this bot) \u2014 it is not ship's to drive. Use \`agent:review\` for a one-off review, or drive the loop manually.`
35765
- );
35766
- }
35767
- if (!facts2.sameRepoHead) {
35768
- return refuse2(
35769
- "fork-head PR",
35770
- "not started (fork head)",
35771
- `\u{1F6AB} ${where}'s head branch lives on a fork, not on \`${repo}\` \u2014 ship cannot drive it.`
35772
- );
35773
- }
35774
- const branch2 = facts2.headRef ?? repoCtx.ref;
35775
- if (!branch2) {
35776
- return refuse2(
35777
- "head branch unknown",
35778
- "not started (head branch unknown)",
35779
- `\u{1F6AB} Could not determine ${where}'s head branch, so ship cannot bind the thread's worktree to it \u2014 refusing fail-closed.`
35780
- );
35781
- }
35782
- return {
35783
- ok: true,
35784
- entry: {
35785
- repo,
35786
- branch: branch2,
35787
- // The PR's OWN base wins on resume: a ship PR opened against a
35788
- // non-default base must not run its re-reviews (or re-open a closed
35789
- // PR) against the default branch. repoCtx.baseRef carries the same
35790
- // fact when the thread context resolved the PR; the default branch
35791
- // is the last resort.
35792
- base: resolveBaseRef([facts2.baseRef, repoCtx.baseRef], info.defaultBranch),
35793
- resume: {
35794
- pr: repoCtx.pr,
35795
- headSha: facts2.headSha ?? repoCtx.headSha,
35796
- ...facts2.htmlUrl !== void 0 ? { url: facts2.htmlUrl } : {}
35797
- }
35798
- }
35799
- };
35800
- }
35801
- }
35802
- }
35803
- if (!task) {
35804
- return refuse2(
35805
- "no task",
35806
- "not started (no task)",
35807
- `\u{1F6AB} Nothing to ship: give ship a task (\`agent:ship in ${repo}: <task>\`), or name an open ship PR by URL to resume its review loop.`
35808
- );
35809
- }
35810
- const ref = repoCtx.ref && !repoCtx.refFromPr && repoCtx.ref.toLowerCase() !== repo.toLowerCase() ? repoCtx.ref : void 0;
35811
- return {
35812
- ok: true,
35813
- entry: { repo, base: resolveBaseRef([ref], info.defaultBranch) }
35814
- };
35815
- }
35816
- var refuse2;
35817
- var init_preflight = __esm({
35818
- "../../src/core/ship/preflight.ts"() {
35819
- "use strict";
35820
- init_githubPulls();
35821
- refuse2 = (where, card, reply) => ({ ok: false, where, card, reply });
35822
- }
35823
- });
35824
-
35825
35733
  // ../../src/core/ship/coordinator.ts
35826
35734
  function shipInterruptedNote(prUrl2) {
35827
35735
  const stands = prUrl2 ? `Its work stands on GitHub: ${prUrl2}.` : "Whatever it pushed stands on its pipeline branch; no PR was opened yet.";
@@ -36072,6 +35980,142 @@ var init_coordinator = __esm({
36072
35980
  }
36073
35981
  });
36074
35982
 
35983
+ // ../../src/core/ship/preflight.ts
35984
+ function shipTaskText(requestText, repo) {
35985
+ let t = requestText.replace(/<((?:https?):\/\/[^|>\s]+)(?:\|[^>]*)?>/gi, " $1 ");
35986
+ t = t.replace(/https?:\/\/\S+/gi, " ");
35987
+ const slug = repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35988
+ t = t.replace(new RegExp(`\\bin\\s+${slug}\\s*:?`, "gi"), " ");
35989
+ t = t.replace(new RegExp(`\\b${slug}(#\\d+)?\\b`, "gi"), " ");
35990
+ t = t.replace(/\b[a-z0-9][\w.-]*\/[\w.-]+#\d+\b/gi, " ");
35991
+ return t.replace(/\s+/g, " ").trim().replace(/^[:,\-—.\s]+/, "").trim();
35992
+ }
35993
+ async function shipPreflight(input) {
35994
+ const { repoCtx } = input;
35995
+ if (!input.channelId.startsWith("slack:") && !input.channelId.startsWith("cli:")) {
35996
+ const base = input.runsBase?.trim();
35997
+ const page2 = base ? `${base.replace(/\/+$/, "")}/runs` : "the bot's /runs page";
35998
+ return refuse2(
35999
+ "channel",
36000
+ "not started (Slack/CLI only)",
36001
+ `\u{1F6AB} \`agent:ship\` runs only from Slack or the CLI \u2014 this adapter is single-shot and cannot hold a pipeline-length run. Start it there instead, and watch pipelines on the run page (${page2}).`
36002
+ );
36003
+ }
36004
+ const missing = ["ship", "coding", "review"].filter((a) => !input.gates.canRunAgent(a));
36005
+ if (missing.length > 0) {
36006
+ return refuse2(
36007
+ `permission (${missing.join(", ")})`,
36008
+ "not started (permissions)",
36009
+ `\u{1F6AB} Running \`ship\` drives \`coding\` and \`review\` child rounds, and you're not on the allowlist for ${missing.map((a) => `\`${a}\``).join(", ")}. Ask ${input.gates.adminsHint()} for access.`
36010
+ );
36011
+ }
36012
+ const repo = repoCtx.repo;
36013
+ if (!repo) {
36014
+ return refuse2(
36015
+ "no repo",
36016
+ "not started (no repository)",
36017
+ "\u{1F6AB} `agent:ship` needs a target repository \u2014 name it in the request, e.g. `agent:ship in owner/repo: <task>`."
36018
+ );
36019
+ }
36020
+ const info = await input.repoInfo(repo).catch(() => void 0);
36021
+ const task = shipTaskText(input.requestText, repo);
36022
+ const seeded = parseShipPlanRequest(task) !== void 0;
36023
+ if (!seeded && repoCtx.prUnpostable?.reason === "unreachable") {
36024
+ return refuse2(
36025
+ "thread PR unreachable",
36026
+ "not started (PR unverifiable)",
36027
+ `\u{1F6AB} This thread names PR ${repo}#${repoCtx.prUnpostable.number} but it could not be fetched to run ship's entry checks \u2014 refusing fail-closed. Retry in a moment, or check the PR on GitHub.`
36028
+ );
36029
+ }
36030
+ if (repoCtx.pr !== void 0 && !seeded) {
36031
+ const where = `${repo}#${repoCtx.pr}`;
36032
+ const contextCase = task !== "" && repoCtx.prFromMessage === true;
36033
+ if (!contextCase) {
36034
+ const facts2 = await input.prFacts({ repo, number: repoCtx.pr }).catch(() => void 0);
36035
+ if (!facts2) {
36036
+ return refuse2(
36037
+ "PR facts unavailable",
36038
+ "not started (PR unverifiable)",
36039
+ `\u{1F6AB} Could not fetch ${where} to run ship's entry checks (open? same-repo head?) \u2014 refusing fail-closed. Retry in a moment.`
36040
+ );
36041
+ }
36042
+ if (facts2.state === "open") {
36043
+ if (!facts2.sameRepoHead) {
36044
+ return refuse2(
36045
+ "fork-head PR",
36046
+ "not started (fork head)",
36047
+ `\u{1F6AB} ${where}'s head branch lives on a fork, not on \`${repo}\` \u2014 ship cannot drive it.`
36048
+ );
36049
+ }
36050
+ const branch2 = facts2.headRef ?? repoCtx.ref;
36051
+ if (!branch2) {
36052
+ return refuse2(
36053
+ "head branch unknown",
36054
+ "not started (head branch unknown)",
36055
+ `\u{1F6AB} Could not determine ${where}'s head branch, so ship cannot bind the thread's worktree to it \u2014 refusing fail-closed.`
36056
+ );
36057
+ }
36058
+ const base = resolveBaseRef([facts2.baseRef, repoCtx.baseRef], info?.defaultBranch);
36059
+ const autoMerge = facts2.autoMergeEnabled !== void 0 ? { autoMergeEnabled: facts2.autoMergeEnabled } : {};
36060
+ if (task) {
36061
+ return {
36062
+ ok: true,
36063
+ entry: {
36064
+ repo,
36065
+ branch: branch2,
36066
+ base,
36067
+ adopt: { pr: repoCtx.pr, ...facts2.htmlUrl !== void 0 ? { url: facts2.htmlUrl } : {} },
36068
+ ...autoMerge
36069
+ }
36070
+ };
36071
+ }
36072
+ return {
36073
+ ok: true,
36074
+ entry: {
36075
+ repo,
36076
+ branch: branch2,
36077
+ base,
36078
+ resume: {
36079
+ pr: repoCtx.pr,
36080
+ headSha: facts2.headSha ?? repoCtx.headSha,
36081
+ ...facts2.htmlUrl !== void 0 ? { url: facts2.htmlUrl } : {}
36082
+ },
36083
+ ...autoMerge
36084
+ }
36085
+ };
36086
+ }
36087
+ if (!task) {
36088
+ return refuse2(
36089
+ "closed resume target",
36090
+ "not started (PR closed)",
36091
+ `\u{1F6AB} ${where} is closed \u2014 there is no review loop to resume. Give ship a task to start fresh work.`
36092
+ );
36093
+ }
36094
+ }
36095
+ }
36096
+ if (!task) {
36097
+ return refuse2(
36098
+ "no task",
36099
+ "not started (no task)",
36100
+ `\u{1F6AB} Nothing to ship: give ship a task (\`agent:ship in ${repo}: <task>\`), or name an open ship PR by URL to resume its review loop.`
36101
+ );
36102
+ }
36103
+ const ref = repoCtx.ref && !repoCtx.refFromPr && repoCtx.ref.toLowerCase() !== repo.toLowerCase() ? repoCtx.ref : void 0;
36104
+ return {
36105
+ ok: true,
36106
+ entry: { repo, base: resolveBaseRef([ref], info?.defaultBranch) }
36107
+ };
36108
+ }
36109
+ var refuse2;
36110
+ var init_preflight = __esm({
36111
+ "../../src/core/ship/preflight.ts"() {
36112
+ "use strict";
36113
+ init_githubPulls();
36114
+ init_coordinator();
36115
+ refuse2 = (where, card, reply) => ({ ok: false, where, card, reply });
36116
+ }
36117
+ });
36118
+
36075
36119
  // ../../src/core/coordinator/handOff.ts
36076
36120
  async function plan(deps, input) {
36077
36121
  const { entry, msg } = input;
@@ -36116,7 +36160,9 @@ async function plan(deps, input) {
36116
36160
  identity: identity2,
36117
36161
  merge: "person",
36118
36162
  ...entry.resume !== void 0 ? { resume: entry.resume } : {},
36119
- ...entry.branch !== void 0 ? { entryBranch: entry.branch } : {}
36163
+ ...entry.adopt !== void 0 ? { adopt: entry.adopt } : {},
36164
+ ...entry.branch !== void 0 ? { entryBranch: entry.branch } : {},
36165
+ ...entry.autoMergeEnabled !== void 0 ? { autoMergeEnabled: entry.autoMergeEnabled } : {}
36120
36166
  }
36121
36167
  };
36122
36168
  }
@@ -36173,9 +36219,10 @@ function planWhere(p, units, mergedBefore) {
36173
36219
  if (p.path !== void 0)
36174
36220
  return `${head} Each unit runs in a thread of its own in this channel under your grants; this card follows the plan and its summary lands in this thread.`;
36175
36221
  const branch2 = units[0]?.branch ?? "";
36176
- const prUrl2 = p.resume?.url ?? (p.resume !== void 0 ? `https://github.com/${p.identity.repo}/pull/${p.resume.pr}` : void 0);
36177
- const runs = p.resume !== void 0 ? `the review loop of ${prUrl2} resumes at its next review round on \`${branch2}\` in this thread under your grants \u2014 no new coding round first` : `the unit runs on \`${branch2}\` in this thread under your grants`;
36178
- return `${head} ${runs}; this card follows it and the report lands here.`;
36222
+ const url = (of) => of?.url ?? (of !== void 0 ? `https://github.com/${p.identity.repo}/pull/${of.pr}` : void 0);
36223
+ const runs = p.resume !== void 0 ? `the review loop of ${url(p.resume)} resumes at its next review round on \`${branch2}\` in this thread under your grants \u2014 no new coding round first` : p.adopt !== void 0 ? `the unit adopts ${url(p.adopt)}: it runs on \`${branch2}\` \u2014 the pull request's own head \u2014 in this thread under your grants, no new branch` : `the unit runs on \`${branch2}\` in this thread under your grants`;
36224
+ const autoMerge = p.autoMergeEnabled ? " Auto-merge is on for this pull request: the approval merges it once checks pass." : "";
36225
+ return `${head} ${runs}; this card follows it and the report lands here.${autoMerge}`;
36179
36226
  }
36180
36227
  async function handOffToCoordinator(deps, input) {
36181
36228
  const log = deps.log ?? console.log;
@@ -36452,7 +36499,6 @@ async function runShipBranch(deps, msg, io, ctx) {
36452
36499
  gates: { canRunAgent: (a) => deps.config.canRunAgent(msg.userId, a), adminsHint: () => deps.config.adminsHint() },
36453
36500
  repoInfo: deps.fetchRepoShipInfo ?? fetchRepoShipInfo,
36454
36501
  prFacts: deps.fetchPrFacts ?? fetchPullRequestFacts,
36455
- selfIdentity: deps.fetchSelfIdentity ?? resolveGithubIdentity,
36456
36502
  runsBase: process.env.PUBLIC_BASE_URL
36457
36503
  })
36458
36504
  );
@@ -36682,7 +36728,6 @@ var init_ship = __esm({
36682
36728
  "use strict";
36683
36729
  init_trace();
36684
36730
  init_githubPulls();
36685
- init_githubApp();
36686
36731
  init_secrets2();
36687
36732
  init_handOff();
36688
36733
  init_instancesClient();
@@ -40486,7 +40531,7 @@ var init_threadTurns = __esm({
40486
40531
  "use strict";
40487
40532
  init_reply();
40488
40533
  APP_FOOTER_RE = /(?:^|\s)(?:\*Sent using\*|Sent using)\s+<@[A-Z0-9]+(?:\|[^>]*)?>(?:\s*\[[^\]\n]*\])?\s*$/;
40489
- RELAY_FOOTER_RE = /(?:^|\s)Sent by Claude in <#([CGD][A-Z0-9_]+)(?:\|[^>]*)?>\s*(?:·|•|-|—)\s*<(https?:\/\/[^|>\s]+)(?:\|[^>]*)?>\s*$/;
40534
+ RELAY_FOOTER_RE = /(?:^|\s)Sent by Claude in <#([CGD][A-Z0-9_]+)(?:\|[^>]*)?>(?:\s+on behalf of <@([A-Z0-9]+)(?:\|[^>]*)?>)?\s*(?:·|•|-|—)\s*<(https?:\/\/[^|>\s]+)(?:\|[^>]*)?>\s*$/;
40490
40535
  }
40491
40536
  });
40492
40537
 
@@ -41257,12 +41302,35 @@ var init_lookups = __esm({
41257
41302
  });
41258
41303
 
41259
41304
  // ../../src/channels/slack/requester.ts
41305
+ function textOfBlocks(blocks) {
41306
+ if (!blocks) return "";
41307
+ const out = [];
41308
+ const walk2 = (b) => {
41309
+ if (typeof b.text === "string") out.push(b.text);
41310
+ else if (b.text && typeof b.text.text === "string") out.push(b.text.text);
41311
+ for (const e of b.elements ?? []) walk2(e);
41312
+ };
41313
+ for (const b of blocks) {
41314
+ const before = out.length;
41315
+ walk2(b);
41316
+ if (out.length > before) out.push("\n");
41317
+ }
41318
+ return out.join("").replace(/\n+$/, "");
41319
+ }
41320
+ function rawTextOf(text, blocks) {
41321
+ const base = text ?? "";
41322
+ const fromBlocks = textOfBlocks(blocks);
41323
+ if (!fromBlocks) return base;
41324
+ const extra = fromBlocks.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !base.includes(l));
41325
+ return extra.length > 0 ? `${base}
41326
+ ${extra.join("\n")}` : base;
41327
+ }
41260
41328
  function parseRelayFooter(text) {
41261
41329
  const m = RELAY_FOOTER_RE.exec(text);
41262
41330
  if (!m) return void 0;
41263
41331
  let url;
41264
41332
  try {
41265
- url = new URL(m[2].replaceAll("&amp;", "&"));
41333
+ url = new URL(m[3].replaceAll("&amp;", "&"));
41266
41334
  } catch {
41267
41335
  return void 0;
41268
41336
  }
@@ -41272,7 +41340,7 @@ function parseRelayFooter(text) {
41272
41340
  if (!ts) return void 0;
41273
41341
  const threadTs = url.searchParams.get("thread_ts") ?? `${ts[1]}.${ts[2]}`;
41274
41342
  if (!THREAD_TS.test(threadTs)) return void 0;
41275
- return { channel: parts[1], threadTs };
41343
+ return { channel: parts[1], threadTs, ...m[2] ? { onBehalfOf: m[2] } : {} };
41276
41344
  }
41277
41345
  function rememberParent(key, user) {
41278
41346
  relayParentCache.set(key, user);
@@ -41302,6 +41370,7 @@ async function resolveSlackRequester(client, ev) {
41302
41370
  const poster = ev.poster ?? {};
41303
41371
  const relay = parseRelayFooter(ev.text);
41304
41372
  if (relay) {
41373
+ if (relay.onBehalfOf) return personRequester(relay.onBehalfOf, "relay-footer", poster);
41305
41374
  const user = await threadParentUser(client, relay.channel, relay.threadTs);
41306
41375
  if (user) return personRequester(user, "relay-footer", poster);
41307
41376
  }
@@ -41552,7 +41621,7 @@ function createSlackApp(deps) {
41552
41621
  channel: event.channel,
41553
41622
  user: event.user,
41554
41623
  poster: posterOf(posted),
41555
- rawText: event.text ?? "",
41624
+ rawText: rawTextOf(event.text, posted.blocks),
41556
41625
  text: stripMention(event.text ?? "", botUserId),
41557
41626
  ts: event.ts,
41558
41627
  threadTs: event.thread_ts ?? event.ts,
@@ -41579,7 +41648,7 @@ function createSlackApp(deps) {
41579
41648
  channel: m.channel,
41580
41649
  user: m.user,
41581
41650
  poster: posterOf(m),
41582
- rawText: m.text ?? "",
41651
+ rawText: rawTextOf(m.text, m.blocks),
41583
41652
  text: stripMention(m.text ?? "", botUserId),
41584
41653
  ts: m.ts,
41585
41654
  threadTs: m.thread_ts ?? m.ts,
@@ -45393,6 +45462,9 @@ async function prCheck(body, deps) {
45393
45462
  prNumber: open2.number,
45394
45463
  url: open2.htmlUrl,
45395
45464
  ...open2.headSha !== void 0 ? { headSha: open2.headSha } : {},
45465
+ // The pull request's own auto-merge fact (agent-ship item 9), so a
45466
+ // merge_ready ending can name it at the approved head.
45467
+ ...open2.autoMergeEnabled !== void 0 ? { autoMergeEnabled: open2.autoMergeEnabled } : {},
45396
45468
  at
45397
45469
  });
45398
45470
  }