@tablation/crew 0.53.2 → 0.53.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +286 -136
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -32,7 +32,7 @@ function doctorBaseReport(r, base, health) {
32
32
  }
33
33
 
34
34
  // src/cli.ts
35
- import { resolve as resolve4, dirname as dirname12, join as join25, basename as basename9 } from "path";
35
+ import { resolve as resolve5, dirname as dirname13, join as join26, basename as basename9 } from "path";
36
36
  import { fileURLToPath } from "url";
37
37
  import { hostname as hostname2, homedir as homedir8 } from "os";
38
38
  import { spawnSync as spawnSync6 } from "child_process";
@@ -4075,7 +4075,7 @@ function detectSystemd() {
4075
4075
  }
4076
4076
 
4077
4077
  // src/ship-attention.ts
4078
- import { statfsSync } from "fs";
4078
+ import { existsSync as existsSync11, statfsSync } from "fs";
4079
4079
 
4080
4080
  // src/git.ts
4081
4081
  import { execFileSync } from "child_process";
@@ -4765,6 +4765,44 @@ function recheckBaseStops(state, route) {
4765
4765
  }
4766
4766
  return cleared;
4767
4767
  }
4768
+ var heldWorktreeKey = (repo, path) => `worktree_held:${repo}:${path}`;
4769
+ var HELD_WORKTREE_REMINDER_MS = 24 * 60 * 60 * 1e3;
4770
+ function noteHeldWorktree(state, h, nowMs = Date.now()) {
4771
+ const store = state.shipAttention();
4772
+ const previous = store.previous();
4773
+ const key = heldWorktreeKey(h.repo, h.path);
4774
+ const existing = previous.find((i) => i.key === key)?.held;
4775
+ const nowIso = new Date(nowMs).toISOString();
4776
+ const lastWarned = existing ? Date.parse(existing.lastWarnedAt) : NaN;
4777
+ const warn = !existing ? "first" : existing.reason !== h.reason ? "changed" : !(nowMs - lastWarned < HELD_WORKTREE_REMINDER_MS) ? "reminder" : null;
4778
+ const held = {
4779
+ ...h,
4780
+ firstSeenAt: existing?.firstSeenAt ?? nowIso,
4781
+ lastWarnedAt: warn ? nowIso : existing.lastWarnedAt
4782
+ };
4783
+ const n = h.ticket.replace(/^\D+/, "");
4784
+ const item = {
4785
+ kind: "worktree_held",
4786
+ key,
4787
+ since: held.firstSeenAt,
4788
+ held,
4789
+ message: `${h.repo}: worktree ${h.path} for ${h.ticket} (${h.status}) is not swept: ${h.reason}. Fix by hand: \`crew drop ${n}\`, or commit/push or discard what is in it`
4790
+ };
4791
+ if (!existing || existing.reason !== h.reason || warn) store.persist(upsert(previous, item));
4792
+ return warn;
4793
+ }
4794
+ function reconcileHeldWorktrees(state, repo, log, stillRefused) {
4795
+ for (const i of state.shipAttention().previous()) {
4796
+ if (i.kind !== "worktree_held" || !i.held || i.held.repo !== repo) continue;
4797
+ const gone = !existsSync11(i.held.path);
4798
+ if (!gone && !(stillRefused && !stillRefused.has(i.held.path))) continue;
4799
+ clearShipAttention(state, i.key);
4800
+ log.emit(
4801
+ gone ? `worktree ${i.held.path} is gone; clearing the hold` : `worktree ${i.held.path} is no longer held; clearing the hold`,
4802
+ { ticket: i.held.ticket, step: "worktree" }
4803
+ );
4804
+ }
4805
+ }
4768
4806
  function openShipAttention(state, nowMs = Date.now()) {
4769
4807
  const stale = staleRelease(state.releaseHeartbeat().at(), nowMs);
4770
4808
  const stored = state.shipAttention().previous();
@@ -7187,7 +7225,7 @@ var alreadyReported = (ticket, g, events) => events.some((e) => e.record_id ===
7187
7225
 
7188
7226
  // src/agent.ts
7189
7227
  import { spawn as spawn2, spawnSync as spawnSync5 } from "child_process";
7190
- import { readFileSync as readFileSync12, existsSync as existsSync13, statSync as statSync5, mkdirSync as mkdirSync6, writeFileSync as writeFileSync8, createWriteStream, openSync as openSync3, closeSync as closeSync3, readSync } from "fs";
7228
+ import { readFileSync as readFileSync12, existsSync as existsSync14, statSync as statSync5, mkdirSync as mkdirSync6, writeFileSync as writeFileSync8, createWriteStream, openSync as openSync3, closeSync as closeSync3, readSync } from "fs";
7191
7229
  import { StringDecoder } from "string_decoder";
7192
7230
  import { finished } from "stream/promises";
7193
7231
  import { join as join14, dirname as dirname6 } from "path";
@@ -7605,7 +7643,7 @@ function expandSkillMarkers(text, lookup, opts = {}) {
7605
7643
  }
7606
7644
 
7607
7645
  // src/skills.ts
7608
- import { readFileSync as readFileSync11, readdirSync as readdirSync4, existsSync as existsSync12 } from "fs";
7646
+ import { readFileSync as readFileSync11, readdirSync as readdirSync4, existsSync as existsSync13 } from "fs";
7609
7647
  import { join as join13 } from "path";
7610
7648
  import { parse as parseYaml } from "yaml";
7611
7649
  import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError8, TablationApiError as TablationApiError2 } from "@tablation/client";
@@ -7622,7 +7660,7 @@ ${additions}`;
7622
7660
  }
7623
7661
 
7624
7662
  // src/agents.ts
7625
- import { readFileSync as readFileSync10, existsSync as existsSync11 } from "fs";
7663
+ import { readFileSync as readFileSync10, existsSync as existsSync12 } from "fs";
7626
7664
  import { join as join12 } from "path";
7627
7665
  import { TablationClient as TablationClient2, StaleWriteError as StaleWriteError7, TablationApiError } from "@tablation/client";
7628
7666
  var PERSONA_NAME = {
@@ -7638,10 +7676,10 @@ var AgentsSyncError = class extends Error {
7638
7676
  };
7639
7677
  function personaDefaultPrompt(promptsDir, role) {
7640
7678
  const briefPath = join12(promptsDir, "personas", `lane-${role}.md`);
7641
- if (!existsSync11(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
7679
+ if (!existsSync12(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
7642
7680
  if (SELF_CONTAINED_ROLES.has(role)) return readFileSync10(briefPath, "utf8");
7643
7681
  const commonPath = join12(promptsDir, "personas", "common.md");
7644
- if (!existsSync11(commonPath)) return readFileSync10(briefPath, "utf8");
7682
+ if (!existsSync12(commonPath)) return readFileSync10(briefPath, "utf8");
7645
7683
  return `${readFileSync10(commonPath, "utf8")}${readFileSync10(briefPath, "utf8")}`;
7646
7684
  }
7647
7685
  async function resolveAgentId(route, role, opts) {
@@ -8103,7 +8141,7 @@ function parseSkillFile(path) {
8103
8141
  }
8104
8142
  function listLocalSkills(promptsDir) {
8105
8143
  const dir = join13(promptsDir, "skills");
8106
- if (!existsSync12(dir)) return [];
8144
+ if (!existsSync13(dir)) return [];
8107
8145
  return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join13(dir, f)));
8108
8146
  }
8109
8147
  async function syncSkills(route, opts) {
@@ -8356,17 +8394,17 @@ var AgentSpawnError = class extends AgentError {
8356
8394
  };
8357
8395
  function planAgentRun(o) {
8358
8396
  const briefPath = join14(o.route.promptsDir, "personas", `lane-${o.role}.md`);
8359
- if (!existsSync13(briefPath)) {
8397
+ if (!existsSync14(briefPath)) {
8360
8398
  throw new AgentError(`no brief at ${briefPath} \u2014 refusing to run an unscoped session`);
8361
8399
  }
8362
8400
  const commonPath = join14(o.route.promptsDir, "personas", "common.md");
8363
- const usesCommon = !SELF_CONTAINED_ROLES.has(o.role) && existsSync13(commonPath);
8401
+ const usesCommon = !SELF_CONTAINED_ROLES.has(o.role) && existsSync14(commonPath);
8364
8402
  const now = o.now ?? (() => Date.now());
8365
8403
  const routeLabel = routeSlug(o.route.route);
8366
8404
  const digestPath2 = join14(o.stateDir, `digest-${routeLabel}-${o.role}.md`);
8367
8405
  let digest;
8368
8406
  let ageSeconds;
8369
- if (existsSync13(digestPath2)) {
8407
+ if (existsSync14(digestPath2)) {
8370
8408
  ageSeconds = Math.floor((now() - statSync5(digestPath2).mtimeMs) / 1e3);
8371
8409
  if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync12(digestPath2, "utf8");
8372
8410
  }
@@ -8767,7 +8805,7 @@ function spawnAgent(plan, emit) {
8767
8805
  for (const w of plan.warnings ?? []) emit.warn(w, { step: "agent", role: plan.role });
8768
8806
  const eventsSink = openSink(plan.eventsPath);
8769
8807
  const id = identityOf(plan, adapter.harness);
8770
- return new Promise((resolve5, reject) => {
8808
+ return new Promise((resolve6, reject) => {
8771
8809
  let streamFd;
8772
8810
  try {
8773
8811
  mkdirSync6(dirname6(plan.streamPath), { recursive: true });
@@ -8826,7 +8864,7 @@ function spawnAgent(plan, emit) {
8826
8864
  await endSink(eventsSink);
8827
8865
  if (spawnFailed) return;
8828
8866
  const { ms: ms2 } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey);
8829
- resolve5({ code: code ?? 1, ms: ms2 });
8867
+ resolve6({ code: code ?? 1, ms: ms2 });
8830
8868
  });
8831
8869
  child.stdin?.on("error", (err) => {
8832
8870
  emit.warn(`agent closed stdin before the prompt was delivered: ${err.message}`, { step: "agent" });
@@ -8869,12 +8907,12 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
8869
8907
  }
8870
8908
 
8871
8909
  // src/run-worktree.ts
8872
- import { existsSync as existsSync15 } from "fs";
8910
+ import { existsSync as existsSync16 } from "fs";
8873
8911
  import { dirname as dirname7, join as join16 } from "path";
8874
8912
 
8875
8913
  // src/ports.ts
8876
8914
  import { execFileSync as execFileSync2 } from "child_process";
8877
- import { existsSync as existsSync14 } from "fs";
8915
+ import { existsSync as existsSync15 } from "fs";
8878
8916
  import { join as join15 } from "path";
8879
8917
  var run = (cmd, args) => {
8880
8918
  try {
@@ -8920,7 +8958,7 @@ function ticketForPort(port, s = DEFAULT_PORTS) {
8920
8958
  }
8921
8959
  function worktreeExists(parent, prefix, n) {
8922
8960
  for (const name of [`${prefix}${n}`, `${prefix}${String(n).padStart(3, "0")}`]) {
8923
- if (existsSync14(join15(parent, name, ".git"))) return true;
8961
+ if (existsSync15(join15(parent, name, ".git"))) return true;
8924
8962
  }
8925
8963
  return false;
8926
8964
  }
@@ -8996,7 +9034,7 @@ function planRunWorktree(dir, cfg, t, role, opts = {}) {
8996
9034
  const existing = worktreeForNumber(dir, num);
8997
9035
  const located = locateBranchForTicket(dir, cfg, t, role);
8998
9036
  const baseRef = hasRemote ? `${remote}/${base}` : base;
8999
- if (existing && !existing.prunable && existsSync15(existing.path)) {
9037
+ if (existing && !existing.prunable && existsSync16(existing.path)) {
9000
9038
  const branch = existing.branch;
9001
9039
  if (status(existing.path).length > 0) {
9002
9040
  return { action: opts.resumeInterrupted ? "resume-dirty" : "leave-dirty", path: existing.path, branch };
@@ -9061,7 +9099,7 @@ function worktreeHoldsUnpushed(path, remote) {
9061
9099
  }
9062
9100
  async function finishRunWorktree(dir, cfg, branch) {
9063
9101
  const wt = worktrees(dir).find((w) => w.branch === branch && !w.prunable && w.path !== dir);
9064
- if (!wt || !existsSync15(wt.path)) return { kind: "none" };
9102
+ if (!wt || !existsSync16(wt.path)) return { kind: "none" };
9065
9103
  if (cfg.hooks.handoff) return { kind: "kept-handoff", path: wt.path };
9066
9104
  if (status(wt.path).length > 0) return { kind: "kept-dirty", path: wt.path };
9067
9105
  if (remoteConfigured(dir, cfg.branch.remote) && worktreeHoldsUnpushed(wt.path, cfg.branch.remote)) {
@@ -9073,7 +9111,7 @@ async function finishRunWorktree(dir, cfg, branch) {
9073
9111
  if (pids.length) await killGently(pids);
9074
9112
  }
9075
9113
  gitOk(dir, ["worktree", "remove", wt.path]);
9076
- return existsSync15(wt.path) ? { kind: "kept-dirty", path: wt.path } : { kind: "removed", path: wt.path };
9114
+ return existsSync16(wt.path) ? { kind: "kept-dirty", path: wt.path } : { kind: "removed", path: wt.path };
9077
9115
  }
9078
9116
 
9079
9117
  // src/handoff-guard.ts
@@ -9333,12 +9371,12 @@ async function guardRunStrays(route, ship, tracker, role, workingId, since2, log
9333
9371
 
9334
9372
  // src/connect-wizard.ts
9335
9373
  import { delimiter, dirname as dirname8, join as join17 } from "path";
9336
- import { accessSync as accessSync2, constants as constants2, existsSync as existsSync16, mkdirSync as mkdirSync7, writeFileSync as writeFileSync9 } from "fs";
9374
+ import { accessSync as accessSync2, constants as constants2, existsSync as existsSync17, mkdirSync as mkdirSync7, writeFileSync as writeFileSync9 } from "fs";
9337
9375
  import { stringify } from "yaml";
9338
9376
  var HOST_PASSENGERS_DOCS_URL = "https://crew.tablation.dev/host-passengers";
9339
9377
  function shouldRunWizard(opts) {
9340
9378
  if (!opts.isTTY) return false;
9341
- const exists = opts.configExists ?? existsSync16;
9379
+ const exists = opts.configExists ?? existsSync17;
9342
9380
  return !configSearchPath(opts.crewHome).some((p) => exists(p));
9343
9381
  }
9344
9382
  function firstRunConfigPath() {
@@ -9450,7 +9488,7 @@ Wrote ${o.configPath}.
9450
9488
  }
9451
9489
 
9452
9490
  // src/release-run.ts
9453
- import { readFileSync as readFileSync13, writeFileSync as writeFileSync10, existsSync as existsSync17, copyFileSync, mkdirSync as mkdirSync8, mkdtempSync, rmSync as rmSync7 } from "fs";
9491
+ import { readFileSync as readFileSync13, writeFileSync as writeFileSync10, existsSync as existsSync18, copyFileSync, mkdirSync as mkdirSync8, mkdtempSync, rmSync as rmSync7 } from "fs";
9454
9492
  import { dirname as dirname9, join as join18 } from "path";
9455
9493
  import { tmpdir as tmpdir2 } from "os";
9456
9494
 
@@ -10141,7 +10179,7 @@ async function bump(o, size) {
10141
10179
  }
10142
10180
  const files = o.repo.release.versionFiles.map((f) => join18(o.cwd, f));
10143
10181
  const first = files[0];
10144
- if (!existsSync17(first)) {
10182
+ if (!existsSync18(first)) {
10145
10183
  o.emit.error(`no ${o.repo.release.versionFiles[0]} to read a version from`);
10146
10184
  return null;
10147
10185
  }
@@ -10177,7 +10215,7 @@ function writeChangelog(o, version, entries) {
10177
10215
  o.emit.emit(`would prepend ${entries.length} entr(ies) to ${file}`);
10178
10216
  return;
10179
10217
  }
10180
- const existing = existsSync17(path) ? readFileSync13(path, "utf8") : "# Changelog\n";
10218
+ const existing = existsSync18(path) ? readFileSync13(path, "utf8") : "# Changelog\n";
10181
10219
  writeFileSync10(path, insertChangelogSection(existing, section));
10182
10220
  }
10183
10221
  async function confirm(o, expected) {
@@ -10283,7 +10321,7 @@ function openReleaseCheckout(o) {
10283
10321
  }
10284
10322
  for (const f of o.repo.worktrees.copy) {
10285
10323
  const src = join18(o.cwd, f);
10286
- if (!existsSync17(src)) continue;
10324
+ if (!existsSync18(src)) continue;
10287
10325
  mkdirSync8(dirname9(join18(dir, f)), { recursive: true });
10288
10326
  copyFileSync(src, join18(dir, f));
10289
10327
  }
@@ -10868,7 +10906,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
10868
10906
  }
10869
10907
 
10870
10908
  // src/watch.ts
10871
- import { watch as watchFile, existsSync as existsSync18, statSync as statSync6, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
10909
+ import { watch as watchFile, existsSync as existsSync19, statSync as statSync6, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
10872
10910
  var E = "\x1B[";
10873
10911
  var dim = (s) => `${E}2m${s}${E}0m`;
10874
10912
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -10969,7 +11007,7 @@ function renderHeaders(states, now = Date.now()) {
10969
11007
  return states.map((s) => renderHeader(s, now)).join("");
10970
11008
  }
10971
11009
  function readFrom(file, from) {
10972
- if (!existsSync18(file)) return { events: [], offset: from };
11010
+ if (!existsSync19(file)) return { events: [], offset: from };
10973
11011
  const size = statSync6(file).size;
10974
11012
  if (size < from) return readFrom(file, 0);
10975
11013
  if (size === from) return { events: [], offset: from };
@@ -12182,17 +12220,15 @@ function schedulerInstalled(plan, exists, crontab) {
12182
12220
  }
12183
12221
 
12184
12222
  // src/worktree-sweep.ts
12185
- import { existsSync as existsSync19 } from "fs";
12186
- import { basename as basename8 } from "path";
12223
+ import { existsSync as existsSync20, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "fs";
12224
+ import { basename as basename8, dirname as dirname11, join as join19, resolve as resolve4 } from "path";
12187
12225
  function planWorktreeSweep(target, tickets, contract) {
12188
12226
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
12189
12227
  const verified = contract.statuses.verified;
12190
12228
  const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
12191
12229
  const actions = [];
12192
- for (const w of worktrees(target.dir)) {
12193
- const m = /(?:^|-)(\d+)$/.exec(basename8(w.path));
12194
- if (!m) continue;
12195
- const ticket = byNumber.get(m[1]);
12230
+ for (const w of worktrees(target.dir).slice(1)) {
12231
+ const ticket = ticketForWorktree(w, byNumber, contract);
12196
12232
  if (!ticket) continue;
12197
12233
  const isVerified = ticket.status === verified;
12198
12234
  if (!isVerified && !terminal.has(ticket.status)) continue;
@@ -12206,37 +12242,108 @@ function planWorktreeSweep(target, tickets, contract) {
12206
12242
  }
12207
12243
  return actions;
12208
12244
  }
12245
+ var DIR_NUMBER = /(?:^|-)(\d+)$/;
12246
+ var DIR_NUMBER_SUFFIXED = /(?:^|-)(\d+)(?:-[a-z0-9]{1,8})?$/;
12247
+ var BRANCH_STEM = /^(?:.*\/)?([^/]+?-\d+)(?:-.*)?$/;
12248
+ function ticketForWorktree(w, byNumber, contract) {
12249
+ if (w.branch) {
12250
+ const stem = BRANCH_STEM.exec(w.branch)?.[1];
12251
+ const num = stem && /-(\d+)$/.exec(stem)?.[1];
12252
+ const t = num ? byNumber.get(num) : void 0;
12253
+ if (t && ticketStems(t).has(stem)) return t;
12254
+ }
12255
+ const dir = basename8(w.path);
12256
+ const m = DIR_NUMBER.exec(dir);
12257
+ if (m && byNumber.has(m[1])) return byNumber.get(m[1]);
12258
+ if (!w.branch) {
12259
+ const t = byNumber.get(DIR_NUMBER_SUFFIXED.exec(dir)?.[1] ?? "");
12260
+ if (t && t.status === contract.statuses.deployed) return t;
12261
+ }
12262
+ return void 0;
12263
+ }
12209
12264
  function worktreeTicketNumbers(target) {
12210
12265
  const nums = /* @__PURE__ */ new Set();
12211
- for (const w of worktrees(target.dir)) {
12212
- const m = /(?:^|-)(\d+)$/.exec(basename8(w.path));
12266
+ for (const w of worktrees(target.dir).slice(1)) {
12267
+ if (w.branch) {
12268
+ const n = /-(\d+)$/.exec(BRANCH_STEM.exec(w.branch)?.[1] ?? "")?.[1];
12269
+ if (n) nums.add(n);
12270
+ }
12271
+ const m = DIR_NUMBER_SUFFIXED.exec(basename8(w.path));
12213
12272
  if (m) nums.add(m[1]);
12214
12273
  }
12274
+ for (const d of siblingWorktreeDirs(target)) nums.add(d.number);
12215
12275
  return [...nums];
12216
12276
  }
12217
- async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin") {
12277
+ function siblingWorktreeDirs(target) {
12278
+ const parent = dirname11(target.dir.replace(/[/\\]+$/, ""));
12279
+ const stem = `${basename8(target.dir.replace(/[/\\]+$/, ""))}-`;
12280
+ const registered = new Set(worktrees(target.dir).map((w) => resolve4(w.path)));
12281
+ let names;
12282
+ try {
12283
+ names = readdirSync5(parent);
12284
+ } catch {
12285
+ return [];
12286
+ }
12287
+ const out = [];
12288
+ for (const name of names) {
12289
+ if (!name.startsWith(stem)) continue;
12290
+ const m = DIR_NUMBER_SUFFIXED.exec(name);
12291
+ const path = join19(parent, name);
12292
+ if (m && !registered.has(resolve4(path))) out.push({ path, number: m[1] });
12293
+ }
12294
+ return out;
12295
+ }
12296
+ function orphanedWorktreeDirs(target, tickets, contract) {
12297
+ const deployed = new Set(
12298
+ tickets.filter((t) => t.status === contract.statuses.deployed).map((t) => t.issue_id.replace(/^\D+/, ""))
12299
+ );
12300
+ return siblingWorktreeDirs(target).filter((d) => deployed.has(d.number)).map((d) => d.path);
12301
+ }
12302
+ function warnOrphanedWorktreeDirs(paths, log, held) {
12303
+ for (const p of paths) {
12304
+ const number = DIR_NUMBER_SUFFIXED.exec(basename8(p))?.[1];
12305
+ const when = held && number ? noteHeldWorktree(held.state, { repo: held.repo, path: p, ticket: number, status: "deployed", reason: "orphaned directory" }) : "first";
12306
+ if (when) {
12307
+ log.warn(`orphaned directory ${p}: its ticket is deployed but it is not a registered worktree; the sweep will not delete it \u2014 remove it by hand`, { step: "worktree" });
12308
+ }
12309
+ }
12310
+ }
12311
+ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin", base, held) {
12312
+ const refused = [];
12313
+ const refuse = (a, reason, detail) => {
12314
+ refused.push(a.path);
12315
+ const when = held ? noteHeldWorktree(held.state, { repo: held.repo, path: a.path, ticket: a.ticket.issue_id, status: a.ticket.status, reason }) : "first";
12316
+ if (when) {
12317
+ log.warn(
12318
+ `not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): ${detail}`,
12319
+ { ticket: a.ticket.issue_id, step: "worktree" }
12320
+ );
12321
+ }
12322
+ };
12218
12323
  let removed = 0;
12219
12324
  let keptBranches = 0;
12220
12325
  for (const a of actions) {
12221
- if (!dryRun && existsSync19(a.path)) {
12326
+ if (!dryRun && base && !a.keepBranch && existsSync20(a.path) && isHandBackMerge(a.path, base)) {
12327
+ if (gitOk(a.path, ["merge", "--abort"]) !== null) {
12328
+ log.emit(
12329
+ `abandoned the hand-back merge of ${base} in ${a.path}: ${a.ticket.issue_id} deployed without it`,
12330
+ { ticket: a.ticket.issue_id, step: "worktree" }
12331
+ );
12332
+ }
12333
+ }
12334
+ if (!dryRun && existsSync20(a.path)) {
12222
12335
  let dirty = false;
12223
12336
  try {
12224
12337
  dirty = status(a.path).length > 0;
12225
12338
  } catch {
12226
12339
  }
12227
12340
  if (dirty) {
12228
- log.warn(
12229
- `not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): it has uncommitted changes`,
12230
- { ticket: a.ticket.issue_id, step: "worktree" }
12231
- );
12341
+ refuse(a, "uncommitted changes", "it has uncommitted changes");
12232
12342
  continue;
12233
12343
  }
12234
12344
  }
12235
- if (!dryRun && a.requirePushed && a.branch && existsSync19(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
12236
- log.warn(
12237
- `not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): ${a.branch} has commits that are not on ${remote}`,
12238
- { ticket: a.ticket.issue_id, step: "worktree" }
12239
- );
12345
+ if (!dryRun && a.requirePushed && a.branch && existsSync20(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
12346
+ refuse(a, "unpushed commits", `${a.branch} has commits that are not on ${remote}`);
12240
12347
  continue;
12241
12348
  }
12242
12349
  const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
@@ -12257,7 +12364,12 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
12257
12364
  );
12258
12365
  continue;
12259
12366
  }
12260
- gitOk(cwd, ["worktree", "remove", "--force", a.path]);
12367
+ try {
12368
+ git(cwd, ["worktree", "remove", "--force", a.path]);
12369
+ } catch (e) {
12370
+ refuse(a, "removal failed", `could not remove it: ${e.message}`);
12371
+ continue;
12372
+ }
12261
12373
  if (!a.keepBranch && a.branch) {
12262
12374
  gitOk(cwd, ["branch", "-D", a.branch]);
12263
12375
  if (remoteConfigured(cwd, remote) && !deleteRemoteBranch(cwd, remote, a.branch)) {
@@ -12270,7 +12382,21 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
12270
12382
  );
12271
12383
  removed++;
12272
12384
  }
12273
- return { removed, keptBranches };
12385
+ if (!dryRun && actions.length) gitOk(cwd, ["worktree", "prune"]);
12386
+ return { removed, keptBranches, refused };
12387
+ }
12388
+ function isHandBackMerge(path, base) {
12389
+ if (gitOk(path, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]) === null) return false;
12390
+ const msgPath = gitOk(path, ["rev-parse", "--git-path", "MERGE_MSG"]);
12391
+ if (!msgPath) return false;
12392
+ let first;
12393
+ try {
12394
+ first = readFileSync14(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
12395
+ } catch {
12396
+ return false;
12397
+ }
12398
+ const escaped = base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12399
+ return new RegExp(`^Merge (?:remote-tracking )?branch '(?:[^']+/)?${escaped}'`).test(first);
12274
12400
  }
12275
12401
  function remoteBranchTicketNumbers(cwd, remote, base) {
12276
12402
  if (!remoteConfigured(cwd, remote)) return [];
@@ -12297,13 +12423,18 @@ function deployedBranchStems(tickets, contract) {
12297
12423
  const stems = /* @__PURE__ */ new Set();
12298
12424
  for (const t of tickets) {
12299
12425
  if (t.status !== contract.statuses.deployed) continue;
12300
- const num = t.issue_id.replace(/^\D+/, "");
12301
- const unpadded = String(Number.parseInt(num, 10));
12302
- const prefixes = ["issue", t.project_issue_prefix?.toLowerCase()].filter((p) => !!p);
12303
- for (const p of prefixes) {
12304
- stems.add(`${p}-${num}`);
12305
- stems.add(`${p}-${unpadded}`);
12306
- }
12426
+ for (const st of ticketStems(t)) stems.add(st);
12427
+ }
12428
+ return stems;
12429
+ }
12430
+ function ticketStems(t) {
12431
+ const stems = /* @__PURE__ */ new Set();
12432
+ const num = t.issue_id.replace(/^\D+/, "");
12433
+ const unpadded = String(Number.parseInt(num, 10));
12434
+ const prefixes = ["issue", t.project_issue_prefix?.toLowerCase()].filter((p) => !!p);
12435
+ for (const p of prefixes) {
12436
+ stems.add(`${p}-${num}`);
12437
+ stems.add(`${p}-${unpadded}`);
12307
12438
  }
12308
12439
  return stems;
12309
12440
  }
@@ -12314,7 +12445,8 @@ function isTicketBranch(name, base, stems) {
12314
12445
  }
12315
12446
  function sweepCandidatesExist(cwd, base) {
12316
12447
  const linked = worktrees(cwd).slice(1);
12317
- if (linked.some((w) => /(?:^|-)\d+$/.test(basename8(w.path)))) return true;
12448
+ if (linked.some((w) => DIR_NUMBER_SUFFIXED.test(basename8(w.path)) || w.branch && BRANCH_STEM.test(w.branch))) return true;
12449
+ if (siblingWorktreeDirs({ name: "", dir: cwd }).length) return true;
12318
12450
  return localBranches(cwd).some((b) => b !== base && /-\d+(?:-.*)?$/.test(b));
12319
12451
  }
12320
12452
  function localBranchTicketNumbers(cwd, base) {
@@ -12364,14 +12496,14 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
12364
12496
  }
12365
12497
 
12366
12498
  // src/stream-sweep.ts
12367
- import { existsSync as existsSync20, readdirSync as readdirSync5, rmSync as rmSync8, statSync as statSync7 } from "fs";
12368
- import { join as join19 } from "path";
12499
+ import { existsSync as existsSync21, readdirSync as readdirSync6, rmSync as rmSync8, statSync as statSync7 } from "fs";
12500
+ import { join as join20 } from "path";
12369
12501
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
12370
12502
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
12371
- const dir = join19(stateDir, "streams");
12372
- if (!existsSync20(dir)) return [];
12503
+ const dir = join20(stateDir, "streams");
12504
+ if (!existsSync21(dir)) return [];
12373
12505
  const cutoff = now - retentionDays * DAY_MS2;
12374
- return readdirSync5(dir).map((name) => join19(dir, name)).filter((path) => {
12506
+ return readdirSync6(dir).map((name) => join20(dir, name)).filter((path) => {
12375
12507
  try {
12376
12508
  return statSync7(path).mtimeMs < cutoff;
12377
12509
  } catch {
@@ -12397,9 +12529,9 @@ function applyStreamSweep(paths, dryRun, log) {
12397
12529
  return removed;
12398
12530
  }
12399
12531
  function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
12400
- if (!existsSync20(stateDir)) return [];
12532
+ if (!existsSync21(stateDir)) return [];
12401
12533
  const cutoff = now - retentionDays * DAY_MS2;
12402
- return readdirSync5(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join19(stateDir, name)).filter((path) => {
12534
+ return readdirSync6(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join20(stateDir, name)).filter((path) => {
12403
12535
  try {
12404
12536
  return statSync7(path).mtimeMs < cutoff;
12405
12537
  } catch {
@@ -12409,24 +12541,24 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
12409
12541
  }
12410
12542
 
12411
12543
  // src/cli.ts
12412
- import { readFileSync as readFileSync19, existsSync as existsSync26, mkdirSync as mkdirSync13, writeFileSync as writeFileSync14, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
12544
+ import { readFileSync as readFileSync20, existsSync as existsSync27, mkdirSync as mkdirSync13, writeFileSync as writeFileSync14, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
12413
12545
  import { createInterface } from "readline/promises";
12414
12546
  import { dirname as dirOf, resolve as resolvePath } from "path";
12415
12547
 
12416
12548
  // src/ssh-keys.ts
12417
12549
  import { execFileSync as execFileSync3 } from "child_process";
12418
- import { existsSync as existsSync21, mkdirSync as mkdirSync9, readFileSync as readFileSync14, chmodSync } from "fs";
12419
- import { dirname as dirname11, join as join20 } from "path";
12550
+ import { existsSync as existsSync22, mkdirSync as mkdirSync9, readFileSync as readFileSync15, chmodSync } from "fs";
12551
+ import { dirname as dirname12, join as join21 } from "path";
12420
12552
  var SshKeyError = class extends Error {
12421
12553
  };
12422
12554
  function shipSshKeyPathFor(stateDir) {
12423
- return join20(stateDir, "ssh", "id_ed25519");
12555
+ return join21(stateDir, "ssh", "id_ed25519");
12424
12556
  }
12425
12557
  function ensureShipSshKeypair(stateDir) {
12426
12558
  const privateKeyPath = shipSshKeyPathFor(stateDir);
12427
12559
  const publicKeyPath = `${privateKeyPath}.pub`;
12428
- if (!existsSync21(privateKeyPath)) {
12429
- mkdirSync9(dirname11(privateKeyPath), { recursive: true });
12560
+ if (!existsSync22(privateKeyPath)) {
12561
+ mkdirSync9(dirname12(privateKeyPath), { recursive: true });
12430
12562
  try {
12431
12563
  execFileSync3("ssh-keygen", [
12432
12564
  "-t",
@@ -12446,10 +12578,10 @@ function ensureShipSshKeypair(stateDir) {
12446
12578
  }
12447
12579
  chmodSync(privateKeyPath, 384);
12448
12580
  }
12449
- if (!existsSync21(publicKeyPath)) {
12581
+ if (!existsSync22(publicKeyPath)) {
12450
12582
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
12451
12583
  }
12452
- const publicKey = readFileSync14(publicKeyPath, "utf8").trim();
12584
+ const publicKey = readFileSync15(publicKeyPath, "utf8").trim();
12453
12585
  return { privateKeyPath, publicKeyPath, publicKey };
12454
12586
  }
12455
12587
  function sshKeygenAvailable() {
@@ -12665,9 +12797,9 @@ function syncAllPassengerCheckouts(cfg) {
12665
12797
 
12666
12798
  // src/tunnel.ts
12667
12799
  import { spawn as spawn3 } from "child_process";
12668
- import { existsSync as existsSync22, mkdirSync as mkdirSync10, readFileSync as readFileSync15, writeFileSync as writeFileSync11, unlinkSync as unlinkSync3, readdirSync as readdirSync6 } from "fs";
12800
+ import { existsSync as existsSync23, mkdirSync as mkdirSync10, readFileSync as readFileSync16, writeFileSync as writeFileSync11, unlinkSync as unlinkSync3, readdirSync as readdirSync7 } from "fs";
12669
12801
  import { randomInt } from "crypto";
12670
- import { join as join21 } from "path";
12802
+ import { join as join22 } from "path";
12671
12803
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
12672
12804
  var defaultSpawn = (cmd, args) => spawn3(cmd, args, DEFAULT_SPAWN_OPTIONS);
12673
12805
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -12734,20 +12866,20 @@ function startTunnel(opts) {
12734
12866
  };
12735
12867
  }
12736
12868
  function tunnelStatePath(stateDir, workspaceId) {
12737
- return join21(stateDir, "passengers", `${workspaceId}.json`);
12869
+ return join22(stateDir, "passengers", `${workspaceId}.json`);
12738
12870
  }
12739
12871
  function readPersistedTunnel(stateDir, workspaceId) {
12740
12872
  const path = tunnelStatePath(stateDir, workspaceId);
12741
- if (!existsSync22(path)) return void 0;
12873
+ if (!existsSync23(path)) return void 0;
12742
12874
  try {
12743
- return JSON.parse(readFileSync15(path, "utf8"));
12875
+ return JSON.parse(readFileSync16(path, "utf8"));
12744
12876
  } catch {
12745
12877
  return void 0;
12746
12878
  }
12747
12879
  }
12748
12880
  function writePersistedTunnel(stateDir, state) {
12749
12881
  const path = tunnelStatePath(stateDir, state.workspaceId);
12750
- mkdirSync10(join21(stateDir, "passengers"), { recursive: true });
12882
+ mkdirSync10(join22(stateDir, "passengers"), { recursive: true });
12751
12883
  writeFileSync11(path, `${JSON.stringify(state, null, 2)}
12752
12884
  `);
12753
12885
  }
@@ -12845,9 +12977,9 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
12845
12977
  }
12846
12978
  }
12847
12979
  function readAllPersistedWorkspaceIds(stateDir) {
12848
- const dir = join21(stateDir, "passengers");
12849
- if (!existsSync22(dir)) return [];
12850
- return readdirSync6(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
12980
+ const dir = join22(stateDir, "passengers");
12981
+ if (!existsSync23(dir)) return [];
12982
+ return readdirSync7(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
12851
12983
  }
12852
12984
 
12853
12985
  // src/mcp-endpoint-registry.ts
@@ -12967,8 +13099,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
12967
13099
  import { createServer as createHttpServer } from "http";
12968
13100
  import { spawn as spawn4 } from "child_process";
12969
13101
  import { randomBytes } from "crypto";
12970
- import { existsSync as existsSync23, mkdirSync as mkdirSync11, readFileSync as readFileSync16, writeFileSync as writeFileSync12, unlinkSync as unlinkSync4, readdirSync as readdirSync7 } from "fs";
12971
- import { join as join22 } from "path";
13102
+ import { existsSync as existsSync24, mkdirSync as mkdirSync11, readFileSync as readFileSync17, writeFileSync as writeFileSync12, unlinkSync as unlinkSync4, readdirSync as readdirSync8 } from "fs";
13103
+ import { join as join23 } from "path";
12972
13104
 
12973
13105
  // src/secret-compare.ts
12974
13106
  import { timingSafeEqual } from "crypto";
@@ -13057,20 +13189,20 @@ var defaultSpawn2 = (cmd, args, env) => {
13057
13189
  return child;
13058
13190
  };
13059
13191
  function syncDaemonStatePath(stateDir, workspaceId) {
13060
- return join22(stateDir, "passengers", `${workspaceId}-sync.json`);
13192
+ return join23(stateDir, "passengers", `${workspaceId}-sync.json`);
13061
13193
  }
13062
13194
  function readPersistedSyncDaemon(stateDir, workspaceId) {
13063
13195
  const path = syncDaemonStatePath(stateDir, workspaceId);
13064
- if (!existsSync23(path)) return void 0;
13196
+ if (!existsSync24(path)) return void 0;
13065
13197
  try {
13066
- return JSON.parse(readFileSync16(path, "utf8"));
13198
+ return JSON.parse(readFileSync17(path, "utf8"));
13067
13199
  } catch {
13068
13200
  return void 0;
13069
13201
  }
13070
13202
  }
13071
13203
  function writePersistedSyncDaemon(stateDir, state) {
13072
13204
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
13073
- mkdirSync11(join22(stateDir, "passengers"), { recursive: true });
13205
+ mkdirSync11(join23(stateDir, "passengers"), { recursive: true });
13074
13206
  writeFileSync12(path, `${JSON.stringify(state, null, 2)}
13075
13207
  `);
13076
13208
  }
@@ -13091,10 +13223,10 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
13091
13223
  }
13092
13224
  }
13093
13225
  function readAllPersistedSyncWorkspaceIds(stateDir) {
13094
- const dir = join22(stateDir, "passengers");
13095
- if (!existsSync23(dir)) return [];
13226
+ const dir = join23(stateDir, "passengers");
13227
+ if (!existsSync24(dir)) return [];
13096
13228
  const suffix = "-sync.json";
13097
- return readdirSync7(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
13229
+ return readdirSync8(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
13098
13230
  }
13099
13231
  function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
13100
13232
  const alive = deps.isPidAlive ?? isPidAlive;
@@ -13149,12 +13281,12 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
13149
13281
  }
13150
13282
 
13151
13283
  // src/daemon.ts
13152
- import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as readFileSync18, readdirSync as readdirSync9, rmSync as rmSync9, statSync as statSync8, writeFileSync as writeFileSync13 } from "fs";
13153
- import { join as join24 } from "path";
13284
+ import { existsSync as existsSync26, mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync10, rmSync as rmSync9, statSync as statSync8, writeFileSync as writeFileSync13 } from "fs";
13285
+ import { join as join25 } from "path";
13154
13286
 
13155
13287
  // src/interrupted.ts
13156
- import { existsSync as existsSync24, readdirSync as readdirSync8, readFileSync as readFileSync17 } from "fs";
13157
- import { join as join23 } from "path";
13288
+ import { existsSync as existsSync25, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
13289
+ import { join as join24 } from "path";
13158
13290
  var INTERRUPTED_MARKER = "<!-- crew:interrupted -->";
13159
13291
  function interruptedComment(b) {
13160
13292
  const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
@@ -13172,21 +13304,21 @@ var pidAlive = (pid) => {
13172
13304
  };
13173
13305
  function runFinished(streamPath) {
13174
13306
  const path = `${streamPath}.meta.json`;
13175
- if (!existsSync24(path)) return false;
13307
+ if (!existsSync25(path)) return false;
13176
13308
  try {
13177
- return JSON.parse(readFileSync17(path, "utf8")).running !== true;
13309
+ return JSON.parse(readFileSync18(path, "utf8")).running !== true;
13178
13310
  } catch {
13179
13311
  return true;
13180
13312
  }
13181
13313
  }
13182
13314
  function orphanedStreams(o) {
13183
- const dir = join23(o.stateDir, "streams");
13184
- if (!existsSync24(dir)) return [];
13315
+ const dir = join24(o.stateDir, "streams");
13316
+ if (!existsSync25(dir)) return [];
13185
13317
  const ownerAlive = o.isOwnerAlive ?? pidAlive;
13186
13318
  const out = [];
13187
- for (const name of readdirSync8(dir)) {
13319
+ for (const name of readdirSync9(dir)) {
13188
13320
  if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
13189
- const streamPath = join23(dir, name);
13321
+ const streamPath = join24(dir, name);
13190
13322
  if (runFinished(streamPath)) continue;
13191
13323
  const sidecar = readRunSidecar(streamPath);
13192
13324
  if (!sidecar || sidecar.route !== o.routeLabel) continue;
@@ -13239,7 +13371,7 @@ async function reconcileRuns(o) {
13239
13371
  // src/daemon.ts
13240
13372
  var DEFAULT_FLOOR_MS = 5e3;
13241
13373
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
13242
- var defaultSleep = (ms2) => new Promise((resolve5) => setTimeout(resolve5, ms2));
13374
+ var defaultSleep = (ms2) => new Promise((resolve6) => setTimeout(resolve6, ms2));
13243
13375
  async function runDaemonLoop(o) {
13244
13376
  const floor = o.floorMs ?? DEFAULT_FLOOR_MS;
13245
13377
  const ceiling = o.ceilingMs ?? DEFAULT_CEILING_MS;
@@ -13285,8 +13417,8 @@ function latestMtime(path) {
13285
13417
  if (stat.isFile()) return stat.mtimeMs;
13286
13418
  if (!stat.isDirectory()) return 0;
13287
13419
  let latest = stat.mtimeMs;
13288
- for (const entry of readdirSync9(path, { withFileTypes: true })) {
13289
- latest = Math.max(latest, latestMtime(join24(path, entry.name)));
13420
+ for (const entry of readdirSync10(path, { withFileTypes: true })) {
13421
+ latest = Math.max(latest, latestMtime(join25(path, entry.name)));
13290
13422
  }
13291
13423
  return latest;
13292
13424
  }
@@ -13295,13 +13427,13 @@ function computeTreeSignature(paths) {
13295
13427
  }
13296
13428
  function makeStaleChecker(paths, opts = {}) {
13297
13429
  const initial = computeTreeSignature(paths);
13298
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync25(opts.reloadSentinel);
13430
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync26(opts.reloadSentinel);
13299
13431
  }
13300
13432
  function reloadSentinelFor(stateDir) {
13301
- return join24(stateDir, "reload-requested");
13433
+ return join25(stateDir, "reload-requested");
13302
13434
  }
13303
13435
  function daemonStampFor(stateDir) {
13304
- return join24(stateDir, "daemon.started");
13436
+ return join25(stateDir, "daemon.started");
13305
13437
  }
13306
13438
  function noteDaemonStarted(stateDir, now = Date.now()) {
13307
13439
  mkdirSync12(stateDir, { recursive: true });
@@ -13311,7 +13443,7 @@ function noteDaemonStarted(stateDir, now = Date.now()) {
13311
13443
  }
13312
13444
  function readDaemonStarted(stateDir) {
13313
13445
  try {
13314
- const v = JSON.parse(readFileSync18(daemonStampFor(stateDir), "utf8")).startedAt;
13446
+ const v = JSON.parse(readFileSync19(daemonStampFor(stateDir), "utf8")).startedAt;
13315
13447
  return typeof v === "number" ? v : null;
13316
13448
  } catch {
13317
13449
  return null;
@@ -13667,7 +13799,7 @@ async function buildEnvironment(route, ship, ticket) {
13667
13799
  }
13668
13800
 
13669
13801
  // src/cli.ts
13670
- var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname12(process.execPath) : resolve4(dirname12(fileURLToPath(import.meta.url)), "..");
13802
+ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname13(process.execPath) : resolve5(dirname13(fileURLToPath(import.meta.url)), "..");
13671
13803
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
13672
13804
  var CYCLE_SECONDS2 = 120;
13673
13805
  var STALE_CYCLES = 3;
@@ -14185,8 +14317,8 @@ if (command === "passenger-sync-daemon") {
14185
14317
  }
14186
14318
  if (host === "linux" && hasSystemd) {
14187
14319
  const label = labelFor(CREW_HOME, "run");
14188
- const staleTimer = join25(homedir8(), ".config", "systemd", "user", `${label}.timer`);
14189
- if (existsSync26(staleTimer)) {
14320
+ const staleTimer = join26(homedir8(), ".config", "systemd", "user", `${label}.timer`);
14321
+ if (existsSync27(staleTimer)) {
14190
14322
  process.stdout.write(
14191
14323
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
14192
14324
  `
@@ -14212,7 +14344,7 @@ if (command === "passenger-sync-daemon") {
14212
14344
  const tracker = new Tracker(c, cfg.ship);
14213
14345
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
14214
14346
  for (const r of await resolvedRepos(c)) {
14215
- if (!existsSync26(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
14347
+ if (!existsSync27(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
14216
14348
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
14217
14349
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
14218
14350
  }
@@ -14230,7 +14362,7 @@ if (command === "passenger-sync-daemon") {
14230
14362
  }
14231
14363
  async function doctorFix(o) {
14232
14364
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
14233
- const unitExists = (p) => existsSync26(p);
14365
+ const unitExists = (p) => existsSync27(p);
14234
14366
  const crontab = o.host === "linux" ? spawnSync6("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
14235
14367
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
14236
14368
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -14247,7 +14379,7 @@ if (command === "passenger-sync-daemon") {
14247
14379
  for (const a of plan.actions) {
14248
14380
  if (a.kind === "enable") {
14249
14381
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
14250
- writeFileSync14(cfg.configFile, planEnable(readFileSync19(cfg.configFile, "utf8"), a.route));
14382
+ writeFileSync14(cfg.configFile, planEnable(readFileSync20(cfg.configFile, "utf8"), a.route));
14251
14383
  enabledSound.add(a.route);
14252
14384
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
14253
14385
  } else {
@@ -14327,6 +14459,7 @@ ${changed2.map((c) => ` - ${c}
14327
14459
  }
14328
14460
  async function sweepClosedTickets(c, target, branch, tracker, dryRun2, remit, idle = false) {
14329
14461
  try {
14462
+ if (!dryRun2) reconcileHeldWorktrees(state, target.name, remit);
14330
14463
  if (idle && !sweepCandidatesExist(target.dir, branch.base)) return;
14331
14464
  const truncated = (limit) => remit.warn(`ticket lookup for the sweep hit its ${limit}-row limit; some tickets may be missed`, { step: "worktree" });
14332
14465
  const nums = /* @__PURE__ */ new Set([
@@ -14336,11 +14469,14 @@ ${changed2.map((c) => ` - ${c}
14336
14469
  ]);
14337
14470
  const scoped = ticketsByRepo(c, await tracker.ticketsByNumbers([...nums], truncated)).byRepo.get(target.name) ?? [];
14338
14471
  const actions = planWorktreeSweep(target, scoped, tracker.contract);
14472
+ const orphanDirs = orphanedWorktreeDirs(target, scoped, tracker.contract);
14473
+ warnOrphanedWorktreeDirs(orphanDirs, remit, dryRun2 ? void 0 : { state, repo: target.name });
14339
14474
  if (actions.length) {
14340
14475
  remit.enter("worktree");
14341
- const r = await applyWorktreeSweep(target.dir, actions, dryRun2, remit, branch.remote);
14476
+ const r = await applyWorktreeSweep(target.dir, actions, dryRun2, remit, branch.remote, branch.base, { state, repo: target.name });
14342
14477
  remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
14343
- }
14478
+ if (!dryRun2) reconcileHeldWorktrees(state, target.name, remit, /* @__PURE__ */ new Set([...r.refused, ...orphanDirs]));
14479
+ } else if (!dryRun2) reconcileHeldWorktrees(state, target.name, remit, new Set(orphanDirs));
14344
14480
  const orphaned = planLocalBranchCleanup(target.dir, branch.base, scoped, tracker.contract);
14345
14481
  if (orphaned.length) applyLocalBranchCleanup(target.dir, orphaned, dryRun2, remit);
14346
14482
  const stale = planRemoteBranchCleanup(target.dir, branch.remote, branch.base, scoped, tracker.contract);
@@ -15223,7 +15359,7 @@ ${changed2.map((c) => ` - ${c}
15223
15359
  process.exit(1);
15224
15360
  }
15225
15361
  requireArmed("run the daemon loop");
15226
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join25(CREW_HOME, "src"), join25(CREW_HOME, "dist"), join25(CREW_HOME, "bin")];
15362
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join26(CREW_HOME, "src"), join26(CREW_HOME, "dist"), join26(CREW_HOME, "bin")];
15227
15363
  const configPaths = daemonConfigPaths(cfg);
15228
15364
  watchPaths.push(...configPaths);
15229
15365
  noteDaemonStarted(cfg.ship.stateDir);
@@ -15398,7 +15534,7 @@ ${changed2.map((c) => ` - ${c}
15398
15534
  `);
15399
15535
  if (!dryRun) {
15400
15536
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
15401
- const raw = existsSync26(resolvedPath) ? JSON.parse(readFileSync19(resolvedPath, "utf8")) : {};
15537
+ const raw = existsSync27(resolvedPath) ? JSON.parse(readFileSync20(resolvedPath, "utf8")) : {};
15402
15538
  raw.agentPersonas = personaResult.agentPersonas;
15403
15539
  if (skillResult) raw.agentSkills = skillResult.agentSkills;
15404
15540
  if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
@@ -15468,7 +15604,7 @@ ${changed2.map((c) => ` - ${c}
15468
15604
  `);
15469
15605
  if (!dryRun) {
15470
15606
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
15471
- const raw = existsSync26(resolvedPath) ? JSON.parse(readFileSync19(resolvedPath, "utf8")) : {};
15607
+ const raw = existsSync27(resolvedPath) ? JSON.parse(readFileSync20(resolvedPath, "utf8")) : {};
15472
15608
  raw.agentSkills = result.agentSkills;
15473
15609
  mkdirSync13(dirOf(resolvedPath), { recursive: true });
15474
15610
  writeFileSync14(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -15484,14 +15620,14 @@ ${changed2.map((c) => ` - ${c}
15484
15620
  process.exit(2);
15485
15621
  };
15486
15622
  if (sub !== "add" && sub !== "list") usage2();
15487
- const configText = readFileSync19(cfg.configFile, "utf8");
15623
+ const configText = readFileSync20(cfg.configFile, "utf8");
15488
15624
  let routeName = positional[2];
15489
15625
  let addPath = ".";
15490
15626
  let addName;
15491
15627
  if (sub === "list" && !routeName) usage2();
15492
15628
  if (sub === "add") {
15493
15629
  try {
15494
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync26(resolvePath(p.replace(/^~(?=\/|$)/, homedir8()))));
15630
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync27(resolvePath(p.replace(/^~(?=\/|$)/, homedir8()))));
15495
15631
  routeName = a.route;
15496
15632
  addPath = a.path;
15497
15633
  addName = a.name;
@@ -15506,15 +15642,15 @@ ${changed2.map((c) => ` - ${c}
15506
15642
  }
15507
15643
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
15508
15644
  let resolvedRemotes;
15509
- if (existsSync26(resolvedFile)) {
15645
+ if (existsSync27(resolvedFile)) {
15510
15646
  try {
15511
- resolvedRemotes = JSON.parse(readFileSync19(resolvedFile, "utf8"));
15647
+ resolvedRemotes = JSON.parse(readFileSync20(resolvedFile, "utf8"));
15512
15648
  } catch {
15513
15649
  }
15514
15650
  }
15515
15651
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
15516
15652
  const text = configText;
15517
- const base = dirname12(cfg.configFile);
15653
+ const base = dirname13(cfg.configFile);
15518
15654
  try {
15519
15655
  if (sub === "list") {
15520
15656
  const repos = configuredRepos(text, routeName, base);
@@ -15523,14 +15659,14 @@ ${changed2.map((c) => ` - ${c}
15523
15659
  for (const r of repos) {
15524
15660
  const origin2 = originOf(r.dir);
15525
15661
  const row = matchRepoName(origin2, resolvedRemotes);
15526
- const note = !existsSync26(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
15662
+ const note = !existsSync27(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
15527
15663
  process.stdout.write(`${r.name} ${r.dir} ${note}
15528
15664
  `);
15529
15665
  }
15530
15666
  break;
15531
15667
  }
15532
15668
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir8()));
15533
- const top = existsSync26(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
15669
+ const top = existsSync27(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
15534
15670
  if (!top) throw new ReposError(`${target} is not a git checkout`);
15535
15671
  const origin = originOf(top);
15536
15672
  const plan = planRepoAdd({
@@ -15579,8 +15715,8 @@ ${changed2.map((c) => ` - ${c}
15579
15715
  const created = await rowTracker.createRepoRow(rowPayload);
15580
15716
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
15581
15717
  `);
15582
- if (existsSync26(resolvedFile)) {
15583
- const raw = JSON.parse(readFileSync19(resolvedFile, "utf8"));
15718
+ if (existsSync27(resolvedFile)) {
15719
+ const raw = JSON.parse(readFileSync20(resolvedFile, "utf8"));
15584
15720
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
15585
15721
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
15586
15722
  writeFileSync14(resolvedFile, `${JSON.stringify(raw, null, 2)}
@@ -15834,10 +15970,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15834
15970
  }
15835
15971
  }
15836
15972
  let previous;
15837
- const hadPreviousFile = existsSync26(resolvedPath);
15973
+ const hadPreviousFile = existsSync27(resolvedPath);
15838
15974
  if (hadPreviousFile) {
15839
15975
  try {
15840
- previous = JSON.parse(readFileSync19(resolvedPath, "utf8"));
15976
+ previous = JSON.parse(readFileSync20(resolvedPath, "utf8"));
15841
15977
  } catch {
15842
15978
  }
15843
15979
  }
@@ -15900,7 +16036,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15900
16036
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
15901
16037
  `);
15902
16038
  } else {
15903
- mkdirSync13(dirname12(resolvedPath), { recursive: true });
16039
+ mkdirSync13(dirname13(resolvedPath), { recursive: true });
15904
16040
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
15905
16041
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
15906
16042
  writeFileSync14(tmpPath, `${JSON.stringify(resolved, null, 2)}
@@ -15928,7 +16064,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15928
16064
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
15929
16065
  `);
15930
16066
  } else {
15931
- mkdirSync13(dirname12(apiKeyFile), { recursive: true });
16067
+ mkdirSync13(dirname13(apiKeyFile), { recursive: true });
15932
16068
  writeFileSync14(apiKeyFile, `${apiKeyVar}=${apiKey}
15933
16069
  `, { mode: 384 });
15934
16070
  process.stderr.write(`API key written to ${apiKeyFile}
@@ -15938,7 +16074,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15938
16074
  const keyPersisted = keychainBacked || apiKeyFile !== void 0;
15939
16075
  const wizard = firstRun;
15940
16076
  const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
15941
- const routeDir = value("dir") ?? (writeFirstRun ? join25(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
16077
+ const routeDir = value("dir") ?? (writeFirstRun ? join26(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
15942
16078
  const routeBlock = renderConnection(
15943
16079
  found,
15944
16080
  route2,
@@ -16130,14 +16266,20 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16130
16266
  await tracker.ticketsByNumbers(remoteBranchTicketNumbers(r.dir, r.config.branch.remote, r.config.branch.base), truncated)
16131
16267
  ).byRepo.get(r.name) ?? [];
16132
16268
  const actions = planWorktreeSweep(r, wtTickets, tracker.contract);
16269
+ const orphanDirs = orphanedWorktreeDirs(r, wtTickets, tracker.contract);
16270
+ warnOrphanedWorktreeDirs(orphanDirs, emit, dryRun ? void 0 : { state, repo: r.name });
16133
16271
  const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, brTickets, tracker.contract);
16134
16272
  if (stale.length) {
16135
16273
  anything = true;
16136
16274
  applyRemoteBranchCleanup(r.dir, r.config.branch.remote, stale, dryRun, emit);
16137
16275
  }
16138
- if (!actions.length) continue;
16276
+ if (!actions.length) {
16277
+ if (!dryRun) reconcileHeldWorktrees(state, r.name, emit, new Set(orphanDirs));
16278
+ continue;
16279
+ }
16139
16280
  anything = true;
16140
- const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit, r.config.branch.remote);
16281
+ const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit, r.config.branch.remote, r.config.branch.base, { state, repo: r.name });
16282
+ if (!dryRun) reconcileHeldWorktrees(state, r.name, emit, /* @__PURE__ */ new Set([...res.refused, ...orphanDirs]));
16141
16283
  emit.emit(
16142
16284
  `swept ${res.removed} worktree(s) for ${r.name}, kept ${res.keptBranches} branch(es)`,
16143
16285
  { step: "worktree" }
@@ -16530,7 +16672,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16530
16672
  }
16531
16673
  case "log": {
16532
16674
  try {
16533
- const text = readFileSync19(cfg.ship.logFile, "utf8").split("\n");
16675
+ const text = readFileSync20(cfg.ship.logFile, "utf8").split("\n");
16534
16676
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
16535
16677
  `);
16536
16678
  } catch {
@@ -16641,6 +16783,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16641
16783
  // Additive (CREW-1379): `unsafe` means a ship must not cut a
16642
16784
  // worktree or release from this base. `version` stays 1.
16643
16785
  baseBranch: baseHealth(r),
16786
+ // Additive (CREW-2051): worktrees the sweep refuses to remove.
16787
+ heldWorktrees: openShipAttention(state).filter((i) => i.kind === "worktree_held" && i.held?.repo === r.name).map((i) => ({
16788
+ path: i.held.path,
16789
+ ticket: i.held.ticket,
16790
+ status: i.held.status,
16791
+ reason: i.held.reason,
16792
+ firstSeenAt: i.held.firstSeenAt
16793
+ })),
16644
16794
  worktreePrefix: r.config.worktrees.prefix,
16645
16795
  // A repo with no explicit `worktrees.prefix` no longer names its
16646
16796
  // worktrees `<prefix><number>` (ISSUE-969) — the checkout's own
@@ -16740,7 +16890,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
16740
16890
  );
16741
16891
  try {
16742
16892
  const evFile = eventFileFor(cfg.ship.stateDir);
16743
- const evSize = existsSync26(evFile) ? statSync9(evFile).size : 0;
16893
+ const evSize = existsSync27(evFile) ? statSync9(evFile).size : 0;
16744
16894
  process.stdout.write(
16745
16895
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
16746
16896
  ` : "events log: ok\n"
@@ -16751,7 +16901,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
16751
16901
  const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run");
16752
16902
  const abandons = unitAbandonsAgents(unitPlan, (p) => {
16753
16903
  try {
16754
- return readFileSync19(p, "utf8");
16904
+ return readFileSync20(p, "utf8");
16755
16905
  } catch {
16756
16906
  return null;
16757
16907
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.53.2",
3
+ "version": "0.53.4",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",