@tablation/crew 0.53.3 → 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 +140 -77
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -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
  }
@@ -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,7 +12220,7 @@ function schedulerInstalled(plan, exists, crontab) {
12182
12220
  }
12183
12221
 
12184
12222
  // src/worktree-sweep.ts
12185
- import { existsSync as existsSync19, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "fs";
12223
+ import { existsSync as existsSync20, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "fs";
12186
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));
@@ -12261,16 +12299,31 @@ function orphanedWorktreeDirs(target, tickets, contract) {
12261
12299
  );
12262
12300
  return siblingWorktreeDirs(target).filter((d) => deployed.has(d.number)).map((d) => d.path);
12263
12301
  }
12264
- function warnOrphanedWorktreeDirs(paths, log) {
12302
+ function warnOrphanedWorktreeDirs(paths, log, held) {
12265
12303
  for (const p of paths) {
12266
- 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" });
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
+ }
12267
12309
  }
12268
12310
  }
12269
- async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin", base) {
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
+ };
12270
12323
  let removed = 0;
12271
12324
  let keptBranches = 0;
12272
12325
  for (const a of actions) {
12273
- if (!dryRun && base && !a.keepBranch && existsSync19(a.path) && isHandBackMerge(a.path, base)) {
12326
+ if (!dryRun && base && !a.keepBranch && existsSync20(a.path) && isHandBackMerge(a.path, base)) {
12274
12327
  if (gitOk(a.path, ["merge", "--abort"]) !== null) {
12275
12328
  log.emit(
12276
12329
  `abandoned the hand-back merge of ${base} in ${a.path}: ${a.ticket.issue_id} deployed without it`,
@@ -12278,25 +12331,19 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
12278
12331
  );
12279
12332
  }
12280
12333
  }
12281
- if (!dryRun && existsSync19(a.path)) {
12334
+ if (!dryRun && existsSync20(a.path)) {
12282
12335
  let dirty = false;
12283
12336
  try {
12284
12337
  dirty = status(a.path).length > 0;
12285
12338
  } catch {
12286
12339
  }
12287
12340
  if (dirty) {
12288
- log.warn(
12289
- `not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): it has uncommitted changes`,
12290
- { ticket: a.ticket.issue_id, step: "worktree" }
12291
- );
12341
+ refuse(a, "uncommitted changes", "it has uncommitted changes");
12292
12342
  continue;
12293
12343
  }
12294
12344
  }
12295
- if (!dryRun && a.requirePushed && a.branch && existsSync19(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
12296
- log.warn(
12297
- `not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): ${a.branch} has commits that are not on ${remote}`,
12298
- { ticket: a.ticket.issue_id, step: "worktree" }
12299
- );
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}`);
12300
12347
  continue;
12301
12348
  }
12302
12349
  const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
@@ -12320,7 +12367,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
12320
12367
  try {
12321
12368
  git(cwd, ["worktree", "remove", "--force", a.path]);
12322
12369
  } catch (e) {
12323
- log.warn(`could not remove worktree ${a.path} for ${a.ticket.issue_id}: ${e.message}`, { ticket: a.ticket.issue_id, step: "worktree" });
12370
+ refuse(a, "removal failed", `could not remove it: ${e.message}`);
12324
12371
  continue;
12325
12372
  }
12326
12373
  if (!a.keepBranch && a.branch) {
@@ -12336,7 +12383,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
12336
12383
  removed++;
12337
12384
  }
12338
12385
  if (!dryRun && actions.length) gitOk(cwd, ["worktree", "prune"]);
12339
- return { removed, keptBranches };
12386
+ return { removed, keptBranches, refused };
12340
12387
  }
12341
12388
  function isHandBackMerge(path, base) {
12342
12389
  if (gitOk(path, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]) === null) return false;
@@ -12449,12 +12496,12 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
12449
12496
  }
12450
12497
 
12451
12498
  // src/stream-sweep.ts
12452
- import { existsSync as existsSync20, readdirSync as readdirSync6, rmSync as rmSync8, statSync as statSync7 } from "fs";
12499
+ import { existsSync as existsSync21, readdirSync as readdirSync6, rmSync as rmSync8, statSync as statSync7 } from "fs";
12453
12500
  import { join as join20 } from "path";
12454
12501
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
12455
12502
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
12456
12503
  const dir = join20(stateDir, "streams");
12457
- if (!existsSync20(dir)) return [];
12504
+ if (!existsSync21(dir)) return [];
12458
12505
  const cutoff = now - retentionDays * DAY_MS2;
12459
12506
  return readdirSync6(dir).map((name) => join20(dir, name)).filter((path) => {
12460
12507
  try {
@@ -12482,7 +12529,7 @@ function applyStreamSweep(paths, dryRun, log) {
12482
12529
  return removed;
12483
12530
  }
12484
12531
  function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
12485
- if (!existsSync20(stateDir)) return [];
12532
+ if (!existsSync21(stateDir)) return [];
12486
12533
  const cutoff = now - retentionDays * DAY_MS2;
12487
12534
  return readdirSync6(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join20(stateDir, name)).filter((path) => {
12488
12535
  try {
@@ -12494,13 +12541,13 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
12494
12541
  }
12495
12542
 
12496
12543
  // src/cli.ts
12497
- import { readFileSync as readFileSync20, 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";
12498
12545
  import { createInterface } from "readline/promises";
12499
12546
  import { dirname as dirOf, resolve as resolvePath } from "path";
12500
12547
 
12501
12548
  // src/ssh-keys.ts
12502
12549
  import { execFileSync as execFileSync3 } from "child_process";
12503
- import { existsSync as existsSync21, mkdirSync as mkdirSync9, readFileSync as readFileSync15, chmodSync } from "fs";
12550
+ import { existsSync as existsSync22, mkdirSync as mkdirSync9, readFileSync as readFileSync15, chmodSync } from "fs";
12504
12551
  import { dirname as dirname12, join as join21 } from "path";
12505
12552
  var SshKeyError = class extends Error {
12506
12553
  };
@@ -12510,7 +12557,7 @@ function shipSshKeyPathFor(stateDir) {
12510
12557
  function ensureShipSshKeypair(stateDir) {
12511
12558
  const privateKeyPath = shipSshKeyPathFor(stateDir);
12512
12559
  const publicKeyPath = `${privateKeyPath}.pub`;
12513
- if (!existsSync21(privateKeyPath)) {
12560
+ if (!existsSync22(privateKeyPath)) {
12514
12561
  mkdirSync9(dirname12(privateKeyPath), { recursive: true });
12515
12562
  try {
12516
12563
  execFileSync3("ssh-keygen", [
@@ -12531,7 +12578,7 @@ function ensureShipSshKeypair(stateDir) {
12531
12578
  }
12532
12579
  chmodSync(privateKeyPath, 384);
12533
12580
  }
12534
- if (!existsSync21(publicKeyPath)) {
12581
+ if (!existsSync22(publicKeyPath)) {
12535
12582
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
12536
12583
  }
12537
12584
  const publicKey = readFileSync15(publicKeyPath, "utf8").trim();
@@ -12750,7 +12797,7 @@ function syncAllPassengerCheckouts(cfg) {
12750
12797
 
12751
12798
  // src/tunnel.ts
12752
12799
  import { spawn as spawn3 } from "child_process";
12753
- import { existsSync as existsSync22, mkdirSync as mkdirSync10, readFileSync as readFileSync16, writeFileSync as writeFileSync11, unlinkSync as unlinkSync3, readdirSync as readdirSync7 } from "fs";
12800
+ import { existsSync as existsSync23, mkdirSync as mkdirSync10, readFileSync as readFileSync16, writeFileSync as writeFileSync11, unlinkSync as unlinkSync3, readdirSync as readdirSync7 } from "fs";
12754
12801
  import { randomInt } from "crypto";
12755
12802
  import { join as join22 } from "path";
12756
12803
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -12823,7 +12870,7 @@ function tunnelStatePath(stateDir, workspaceId) {
12823
12870
  }
12824
12871
  function readPersistedTunnel(stateDir, workspaceId) {
12825
12872
  const path = tunnelStatePath(stateDir, workspaceId);
12826
- if (!existsSync22(path)) return void 0;
12873
+ if (!existsSync23(path)) return void 0;
12827
12874
  try {
12828
12875
  return JSON.parse(readFileSync16(path, "utf8"));
12829
12876
  } catch {
@@ -12931,7 +12978,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
12931
12978
  }
12932
12979
  function readAllPersistedWorkspaceIds(stateDir) {
12933
12980
  const dir = join22(stateDir, "passengers");
12934
- if (!existsSync22(dir)) return [];
12981
+ if (!existsSync23(dir)) return [];
12935
12982
  return readdirSync7(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
12936
12983
  }
12937
12984
 
@@ -13052,7 +13099,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
13052
13099
  import { createServer as createHttpServer } from "http";
13053
13100
  import { spawn as spawn4 } from "child_process";
13054
13101
  import { randomBytes } from "crypto";
13055
- import { existsSync as existsSync23, mkdirSync as mkdirSync11, readFileSync as readFileSync17, writeFileSync as writeFileSync12, unlinkSync as unlinkSync4, readdirSync as readdirSync8 } from "fs";
13102
+ import { existsSync as existsSync24, mkdirSync as mkdirSync11, readFileSync as readFileSync17, writeFileSync as writeFileSync12, unlinkSync as unlinkSync4, readdirSync as readdirSync8 } from "fs";
13056
13103
  import { join as join23 } from "path";
13057
13104
 
13058
13105
  // src/secret-compare.ts
@@ -13146,7 +13193,7 @@ function syncDaemonStatePath(stateDir, workspaceId) {
13146
13193
  }
13147
13194
  function readPersistedSyncDaemon(stateDir, workspaceId) {
13148
13195
  const path = syncDaemonStatePath(stateDir, workspaceId);
13149
- if (!existsSync23(path)) return void 0;
13196
+ if (!existsSync24(path)) return void 0;
13150
13197
  try {
13151
13198
  return JSON.parse(readFileSync17(path, "utf8"));
13152
13199
  } catch {
@@ -13177,7 +13224,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
13177
13224
  }
13178
13225
  function readAllPersistedSyncWorkspaceIds(stateDir) {
13179
13226
  const dir = join23(stateDir, "passengers");
13180
- if (!existsSync23(dir)) return [];
13227
+ if (!existsSync24(dir)) return [];
13181
13228
  const suffix = "-sync.json";
13182
13229
  return readdirSync8(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
13183
13230
  }
@@ -13234,11 +13281,11 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
13234
13281
  }
13235
13282
 
13236
13283
  // src/daemon.ts
13237
- import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync10, rmSync as rmSync9, statSync as statSync8, writeFileSync as writeFileSync13 } from "fs";
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";
13238
13285
  import { join as join25 } from "path";
13239
13286
 
13240
13287
  // src/interrupted.ts
13241
- import { existsSync as existsSync24, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
13288
+ import { existsSync as existsSync25, readdirSync as readdirSync9, readFileSync as readFileSync18 } from "fs";
13242
13289
  import { join as join24 } from "path";
13243
13290
  var INTERRUPTED_MARKER = "<!-- crew:interrupted -->";
13244
13291
  function interruptedComment(b) {
@@ -13257,7 +13304,7 @@ var pidAlive = (pid) => {
13257
13304
  };
13258
13305
  function runFinished(streamPath) {
13259
13306
  const path = `${streamPath}.meta.json`;
13260
- if (!existsSync24(path)) return false;
13307
+ if (!existsSync25(path)) return false;
13261
13308
  try {
13262
13309
  return JSON.parse(readFileSync18(path, "utf8")).running !== true;
13263
13310
  } catch {
@@ -13266,7 +13313,7 @@ function runFinished(streamPath) {
13266
13313
  }
13267
13314
  function orphanedStreams(o) {
13268
13315
  const dir = join24(o.stateDir, "streams");
13269
- if (!existsSync24(dir)) return [];
13316
+ if (!existsSync25(dir)) return [];
13270
13317
  const ownerAlive = o.isOwnerAlive ?? pidAlive;
13271
13318
  const out = [];
13272
13319
  for (const name of readdirSync9(dir)) {
@@ -13380,7 +13427,7 @@ function computeTreeSignature(paths) {
13380
13427
  }
13381
13428
  function makeStaleChecker(paths, opts = {}) {
13382
13429
  const initial = computeTreeSignature(paths);
13383
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync25(opts.reloadSentinel);
13430
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync26(opts.reloadSentinel);
13384
13431
  }
13385
13432
  function reloadSentinelFor(stateDir) {
13386
13433
  return join25(stateDir, "reload-requested");
@@ -14271,7 +14318,7 @@ if (command === "passenger-sync-daemon") {
14271
14318
  if (host === "linux" && hasSystemd) {
14272
14319
  const label = labelFor(CREW_HOME, "run");
14273
14320
  const staleTimer = join26(homedir8(), ".config", "systemd", "user", `${label}.timer`);
14274
- if (existsSync26(staleTimer)) {
14321
+ if (existsSync27(staleTimer)) {
14275
14322
  process.stdout.write(
14276
14323
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
14277
14324
  `
@@ -14297,7 +14344,7 @@ if (command === "passenger-sync-daemon") {
14297
14344
  const tracker = new Tracker(c, cfg.ship);
14298
14345
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
14299
14346
  for (const r of await resolvedRepos(c)) {
14300
- 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`);
14301
14348
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
14302
14349
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
14303
14350
  }
@@ -14315,7 +14362,7 @@ if (command === "passenger-sync-daemon") {
14315
14362
  }
14316
14363
  async function doctorFix(o) {
14317
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))));
14318
- const unitExists = (p) => existsSync26(p);
14365
+ const unitExists = (p) => existsSync27(p);
14319
14366
  const crontab = o.host === "linux" ? spawnSync6("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
14320
14367
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
14321
14368
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -14412,6 +14459,7 @@ ${changed2.map((c) => ` - ${c}
14412
14459
  }
14413
14460
  async function sweepClosedTickets(c, target, branch, tracker, dryRun2, remit, idle = false) {
14414
14461
  try {
14462
+ if (!dryRun2) reconcileHeldWorktrees(state, target.name, remit);
14415
14463
  if (idle && !sweepCandidatesExist(target.dir, branch.base)) return;
14416
14464
  const truncated = (limit) => remit.warn(`ticket lookup for the sweep hit its ${limit}-row limit; some tickets may be missed`, { step: "worktree" });
14417
14465
  const nums = /* @__PURE__ */ new Set([
@@ -14421,12 +14469,14 @@ ${changed2.map((c) => ` - ${c}
14421
14469
  ]);
14422
14470
  const scoped = ticketsByRepo(c, await tracker.ticketsByNumbers([...nums], truncated)).byRepo.get(target.name) ?? [];
14423
14471
  const actions = planWorktreeSweep(target, scoped, tracker.contract);
14424
- warnOrphanedWorktreeDirs(orphanedWorktreeDirs(target, scoped, tracker.contract), remit);
14472
+ const orphanDirs = orphanedWorktreeDirs(target, scoped, tracker.contract);
14473
+ warnOrphanedWorktreeDirs(orphanDirs, remit, dryRun2 ? void 0 : { state, repo: target.name });
14425
14474
  if (actions.length) {
14426
14475
  remit.enter("worktree");
14427
- const r = await applyWorktreeSweep(target.dir, actions, dryRun2, remit, branch.remote, branch.base);
14476
+ const r = await applyWorktreeSweep(target.dir, actions, dryRun2, remit, branch.remote, branch.base, { state, repo: target.name });
14428
14477
  remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
14429
- }
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));
14430
14480
  const orphaned = planLocalBranchCleanup(target.dir, branch.base, scoped, tracker.contract);
14431
14481
  if (orphaned.length) applyLocalBranchCleanup(target.dir, orphaned, dryRun2, remit);
14432
14482
  const stale = planRemoteBranchCleanup(target.dir, branch.remote, branch.base, scoped, tracker.contract);
@@ -15484,7 +15534,7 @@ ${changed2.map((c) => ` - ${c}
15484
15534
  `);
15485
15535
  if (!dryRun) {
15486
15536
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
15487
- const raw = existsSync26(resolvedPath) ? JSON.parse(readFileSync20(resolvedPath, "utf8")) : {};
15537
+ const raw = existsSync27(resolvedPath) ? JSON.parse(readFileSync20(resolvedPath, "utf8")) : {};
15488
15538
  raw.agentPersonas = personaResult.agentPersonas;
15489
15539
  if (skillResult) raw.agentSkills = skillResult.agentSkills;
15490
15540
  if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
@@ -15554,7 +15604,7 @@ ${changed2.map((c) => ` - ${c}
15554
15604
  `);
15555
15605
  if (!dryRun) {
15556
15606
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
15557
- const raw = existsSync26(resolvedPath) ? JSON.parse(readFileSync20(resolvedPath, "utf8")) : {};
15607
+ const raw = existsSync27(resolvedPath) ? JSON.parse(readFileSync20(resolvedPath, "utf8")) : {};
15558
15608
  raw.agentSkills = result.agentSkills;
15559
15609
  mkdirSync13(dirOf(resolvedPath), { recursive: true });
15560
15610
  writeFileSync14(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -15577,7 +15627,7 @@ ${changed2.map((c) => ` - ${c}
15577
15627
  if (sub === "list" && !routeName) usage2();
15578
15628
  if (sub === "add") {
15579
15629
  try {
15580
- 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()))));
15581
15631
  routeName = a.route;
15582
15632
  addPath = a.path;
15583
15633
  addName = a.name;
@@ -15592,7 +15642,7 @@ ${changed2.map((c) => ` - ${c}
15592
15642
  }
15593
15643
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
15594
15644
  let resolvedRemotes;
15595
- if (existsSync26(resolvedFile)) {
15645
+ if (existsSync27(resolvedFile)) {
15596
15646
  try {
15597
15647
  resolvedRemotes = JSON.parse(readFileSync20(resolvedFile, "utf8"));
15598
15648
  } catch {
@@ -15609,14 +15659,14 @@ ${changed2.map((c) => ` - ${c}
15609
15659
  for (const r of repos) {
15610
15660
  const origin2 = originOf(r.dir);
15611
15661
  const row = matchRepoName(origin2, resolvedRemotes);
15612
- 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)"}`;
15613
15663
  process.stdout.write(`${r.name} ${r.dir} ${note}
15614
15664
  `);
15615
15665
  }
15616
15666
  break;
15617
15667
  }
15618
15668
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir8()));
15619
- const top = existsSync26(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
15669
+ const top = existsSync27(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
15620
15670
  if (!top) throw new ReposError(`${target} is not a git checkout`);
15621
15671
  const origin = originOf(top);
15622
15672
  const plan = planRepoAdd({
@@ -15665,7 +15715,7 @@ ${changed2.map((c) => ` - ${c}
15665
15715
  const created = await rowTracker.createRepoRow(rowPayload);
15666
15716
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
15667
15717
  `);
15668
- if (existsSync26(resolvedFile)) {
15718
+ if (existsSync27(resolvedFile)) {
15669
15719
  const raw = JSON.parse(readFileSync20(resolvedFile, "utf8"));
15670
15720
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
15671
15721
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
@@ -15920,7 +15970,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15920
15970
  }
15921
15971
  }
15922
15972
  let previous;
15923
- const hadPreviousFile = existsSync26(resolvedPath);
15973
+ const hadPreviousFile = existsSync27(resolvedPath);
15924
15974
  if (hadPreviousFile) {
15925
15975
  try {
15926
15976
  previous = JSON.parse(readFileSync20(resolvedPath, "utf8"));
@@ -16216,15 +16266,20 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16216
16266
  await tracker.ticketsByNumbers(remoteBranchTicketNumbers(r.dir, r.config.branch.remote, r.config.branch.base), truncated)
16217
16267
  ).byRepo.get(r.name) ?? [];
16218
16268
  const actions = planWorktreeSweep(r, wtTickets, tracker.contract);
16219
- warnOrphanedWorktreeDirs(orphanedWorktreeDirs(r, wtTickets, tracker.contract), emit);
16269
+ const orphanDirs = orphanedWorktreeDirs(r, wtTickets, tracker.contract);
16270
+ warnOrphanedWorktreeDirs(orphanDirs, emit, dryRun ? void 0 : { state, repo: r.name });
16220
16271
  const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, brTickets, tracker.contract);
16221
16272
  if (stale.length) {
16222
16273
  anything = true;
16223
16274
  applyRemoteBranchCleanup(r.dir, r.config.branch.remote, stale, dryRun, emit);
16224
16275
  }
16225
- if (!actions.length) continue;
16276
+ if (!actions.length) {
16277
+ if (!dryRun) reconcileHeldWorktrees(state, r.name, emit, new Set(orphanDirs));
16278
+ continue;
16279
+ }
16226
16280
  anything = true;
16227
- const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit, r.config.branch.remote, r.config.branch.base);
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]));
16228
16283
  emit.emit(
16229
16284
  `swept ${res.removed} worktree(s) for ${r.name}, kept ${res.keptBranches} branch(es)`,
16230
16285
  { step: "worktree" }
@@ -16728,6 +16783,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16728
16783
  // Additive (CREW-1379): `unsafe` means a ship must not cut a
16729
16784
  // worktree or release from this base. `version` stays 1.
16730
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
+ })),
16731
16794
  worktreePrefix: r.config.worktrees.prefix,
16732
16795
  // A repo with no explicit `worktrees.prefix` no longer names its
16733
16796
  // worktrees `<prefix><number>` (ISSUE-969) — the checkout's own
@@ -16827,7 +16890,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
16827
16890
  );
16828
16891
  try {
16829
16892
  const evFile = eventFileFor(cfg.ship.stateDir);
16830
- const evSize = existsSync26(evFile) ? statSync9(evFile).size : 0;
16893
+ const evSize = existsSync27(evFile) ? statSync9(evFile).size : 0;
16831
16894
  process.stdout.write(
16832
16895
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
16833
16896
  ` : "events log: ok\n"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.53.3",
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",