@tablation/crew 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -213,8 +213,8 @@ crew inbox [--member NAME] your tickets across every workspace
213
213
  crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
214
214
  crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml, creating its Repos row if missing (--dry-run previews)
215
215
  crew repos list ROUTE the checkouts a route has, and the tracker Repos row each matches
216
- crew install write and load this platform's scheduler unit
217
- crew uninstall unload and remove it
216
+ crew install [--replace] write and load this platform's scheduler unit (macOS: clears a crew installed from another path)
217
+ crew uninstall [--all] unload and remove it (--all: every crew launchd unit, whichever path it came from)
218
218
  ```
219
219
 
220
220
  Every command that could change something takes `--dry-run`.
package/dist/cli.js CHANGED
@@ -1930,7 +1930,7 @@ function describeRelease(o, route) {
1930
1930
  // src/install.ts
1931
1931
  import { dirname as dirname3, join as join5, extname } from "path";
1932
1932
  import { homedir as homedir2 } from "os";
1933
- import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, statSync as statSync2, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
1933
+ import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync5, statSync as statSync2, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
1934
1934
  import { spawnSync } from "child_process";
1935
1935
  import { createHash } from "crypto";
1936
1936
 
@@ -2164,7 +2164,8 @@ function planUninstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
2164
2164
  mechanism: "launchd",
2165
2165
  unitPaths: [unitPath],
2166
2166
  crontabLine: null,
2167
- unloadCommands: [["launchctl", "unload", unitPath]]
2167
+ unloadCommands: [["launchctl", "unload", unitPath]],
2168
+ removeLabel: label
2168
2169
  };
2169
2170
  }
2170
2171
  if (hostShip === "linux" && hasSystemd) {
@@ -2282,6 +2283,15 @@ async function applyUninstall(plan, crewHome, dryRun, log) {
2282
2283
  if (!ok) log.warn("unload command failed \u2014 it may not have been loaded; continuing to remove files");
2283
2284
  }
2284
2285
  }
2286
+ if (plan.removeLabel) {
2287
+ if (dryRun) {
2288
+ log.emit(`would run: launchctl remove ${plan.removeLabel}`);
2289
+ } else {
2290
+ if (spawnSync("launchctl", ["remove", plan.removeLabel], { encoding: "utf8" }).status === 0) {
2291
+ log.emit(`removed loaded job ${plan.removeLabel}`);
2292
+ }
2293
+ }
2294
+ }
2285
2295
  for (const path of plan.unitPaths) {
2286
2296
  if (!existsSync5(path)) continue;
2287
2297
  if (dryRun) {
@@ -2300,6 +2310,91 @@ async function applyUninstall(plan, crewHome, dryRun, log) {
2300
2310
  }
2301
2311
  }
2302
2312
  }
2313
+ var CREW_LAUNCHD_PREFIX = "com.tablation.crew";
2314
+ function launchAgentsDir() {
2315
+ return join5(homedir2(), "Library", "LaunchAgents");
2316
+ }
2317
+ function xmlUnescape(s) {
2318
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
2319
+ }
2320
+ function programFromPlist(text) {
2321
+ const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text);
2322
+ if (!block) return null;
2323
+ const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => xmlUnescape(m[1]));
2324
+ return args.find((a) => a.endsWith("/bin/crew")) ?? args[0] ?? null;
2325
+ }
2326
+ function listLaunchdUnits(dir = launchAgentsDir()) {
2327
+ if (!existsSync5(dir)) return [];
2328
+ return readdirSync(dir).filter((f) => f.startsWith(CREW_LAUNCHD_PREFIX) && f.endsWith(".plist")).sort().map((f) => {
2329
+ const plistPath = join5(dir, f);
2330
+ let program = null;
2331
+ try {
2332
+ program = programFromPlist(readFileSync5(plistPath, "utf8"));
2333
+ } catch {
2334
+ }
2335
+ return {
2336
+ label: f.slice(0, -".plist".length),
2337
+ plistPath,
2338
+ program,
2339
+ programExists: program !== null && existsSync5(program)
2340
+ };
2341
+ });
2342
+ }
2343
+ function foreignLaunchdUnits(crewHome, dir) {
2344
+ const own = new Set(["run", "release", "passengers"].map((job) => labelFor(crewHome, job)));
2345
+ return listLaunchdUnits(dir).filter((u) => !own.has(u.label));
2346
+ }
2347
+ function removeLaunchdUnit(unit, dryRun, log) {
2348
+ if (dryRun) {
2349
+ log.emit(`would unload and remove ${unit.label}`);
2350
+ return;
2351
+ }
2352
+ if (existsSync5(unit.plistPath)) spawnSync("launchctl", ["unload", unit.plistPath], { encoding: "utf8" });
2353
+ spawnSync("launchctl", ["remove", unit.label], { encoding: "utf8" });
2354
+ if (existsSync5(unit.plistPath)) unlinkSync(unit.plistPath);
2355
+ log.emit(`removed ${unit.label} (${unit.plistPath})`);
2356
+ }
2357
+ function clearForeignLaunchdUnits(crewHome, replace, dryRun, log, dir) {
2358
+ const foreign = foreignLaunchdUnits(crewHome, dir);
2359
+ const blocking = foreign.filter((u) => u.program === null || u.programExists);
2360
+ if (blocking.length > 0 && !replace) {
2361
+ log.warn(
2362
+ `two crew installs target this machine: ${blocking.map((u) => `${u.label} runs ${u.program ?? "an unreadable ProgramArguments"}`).join("; ")}. Run \`crew uninstall\` from the other one, or pass --replace.`
2363
+ );
2364
+ return false;
2365
+ }
2366
+ for (const unit of foreign) {
2367
+ const why = unit.program === null ? "replacing a unit with no readable ProgramArguments" : unit.programExists ? "replacing a live other install" : "removing a stale unit whose program is gone";
2368
+ log.emit(`${why}: ${unit.label}`);
2369
+ removeLaunchdUnit(unit, dryRun, log);
2370
+ }
2371
+ return true;
2372
+ }
2373
+ function removeAllLaunchdUnits(dryRun, log, dir) {
2374
+ for (const unit of listLaunchdUnits(dir)) removeLaunchdUnit(unit, dryRun, log);
2375
+ }
2376
+ function parseLaunchctlList(text) {
2377
+ const jobs = /* @__PURE__ */ new Map();
2378
+ for (const line of text.split("\n")) {
2379
+ const cols = line.split(" ");
2380
+ if (cols.length !== 3 || cols[2] === "Label") continue;
2381
+ jobs.set(cols[2], { pid: cols[0], lastExit: cols[1] });
2382
+ }
2383
+ return jobs;
2384
+ }
2385
+ function describeForeignLaunchdUnits(crewHome, dir) {
2386
+ const foreign = foreignLaunchdUnits(crewHome, dir);
2387
+ if (foreign.length === 0) return [];
2388
+ const list = spawnSync("launchctl", ["list"], { encoding: "utf8" });
2389
+ const jobs = parseLaunchctlList(list.status === 0 ? list.stdout ?? "" : "");
2390
+ return foreign.map((u) => {
2391
+ const status2 = jobs.get(u.label);
2392
+ const loaded = status2 ? `loaded, last exit ${status2.lastExit}` : "not loaded";
2393
+ const program = u.program ? `${u.program} (${u.programExists ? "present" : "GONE"})` : "no readable ProgramArguments";
2394
+ const stale = status2 && !u.programExists ? " \u2014 stale: `crew install` removes it" : "";
2395
+ return ` ${u.label}: ${loaded}; runs ${program}${stale}`;
2396
+ });
2397
+ }
2303
2398
  function readCrontab() {
2304
2399
  const res = spawnSync("crontab", ["-l"], { encoding: "utf8" });
2305
2400
  return res.status === 0 ? res.stdout ?? "" : "";
@@ -3860,6 +3955,13 @@ function fastForward(cwd, branch) {
3860
3955
  if (!s.canFastForward) return false;
3861
3956
  return gitOk(cwd, ["merge", "--ff-only", s.upstream]) !== null;
3862
3957
  }
3958
+ function baseBranchUnsafe(r) {
3959
+ return r.action === "diverged" || r.action === "ahead" || r.action === "ff-failed";
3960
+ }
3961
+ function describeUnsafeBase(r, remote, base) {
3962
+ const counts = r.action === "diverged" ? `${r.ahead} ahead, ${r.behind} behind ${remote}/${base}` : r.action === "ahead" ? `${r.ahead} ahead of ${remote}/${base}` : `${r.behind} behind ${remote}/${base}, cannot fast-forward`;
3963
+ return `${r.detail} [${counts}] \u2014 reconcile it (\`crew sync\` afterwards), or \`git reset --hard ${remote}/${base}\` if the local commits duplicate a release`;
3964
+ }
3863
3965
  function refreshBaseBranch(cwd, remote, base, dryRun = false) {
3864
3966
  if (currentBranch(cwd) !== base || status(cwd).length > 0) {
3865
3967
  return { action: "not-applicable", detail: `${cwd} is not on a clean ${base}` };
@@ -3886,6 +3988,9 @@ function refreshBaseBranch(cwd, remote, base, dryRun = false) {
3886
3988
  detail: `${base} has diverged from ${upstream} (${ahead} ahead, ${behind} behind)`
3887
3989
  };
3888
3990
  }
3991
+ if (behind === 0 && ahead > 0) {
3992
+ return { action: "ahead", ahead, detail: `${base} has ${ahead} commit(s) ${upstream} does not` };
3993
+ }
3889
3994
  if (behind === 0) return { action: "level", detail: `${base} is level with ${upstream}` };
3890
3995
  if (dryRun) {
3891
3996
  return { action: "would-fast-forward", behind, detail: `would fast-forward ${base}: ${behind} behind ${upstream}` };
@@ -4511,7 +4616,7 @@ var alreadyReported = (ticket, g, comments) => comments.some((c) => c.ticket_id
4511
4616
 
4512
4617
  // src/agent.ts
4513
4618
  import { spawn as spawn2 } from "child_process";
4514
- import { readFileSync as readFileSync5, existsSync as existsSync7, statSync as statSync4, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
4619
+ import { readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync4, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
4515
4620
  import { finished } from "stream/promises";
4516
4621
  import { join as join7, dirname as dirname5 } from "path";
4517
4622
  import { createHash as createHash2 } from "crypto";
@@ -4954,10 +5059,10 @@ function planAgentRun(o) {
4954
5059
  let ageSeconds;
4955
5060
  if (existsSync7(digestPath2)) {
4956
5061
  ageSeconds = Math.floor((now() - statSync4(digestPath2).mtimeMs) / 1e3);
4957
- if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync5(digestPath2, "utf8");
5062
+ if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync6(digestPath2, "utf8");
4958
5063
  }
4959
- const common = readFileSync5(commonPath, "utf8");
4960
- const brief = readFileSync5(briefPath, "utf8");
5064
+ const common = readFileSync6(commonPath, "utf8");
5065
+ const brief = readFileSync6(briefPath, "utf8");
4961
5066
  const effectiveCommon = o.divergedPrompt ? "" : common;
4962
5067
  const effectiveBrief = o.divergedPrompt ?? brief;
4963
5068
  const prompt = assemblePrompt({
@@ -5337,7 +5442,7 @@ Wrote ${o.configPath}.
5337
5442
  }
5338
5443
 
5339
5444
  // src/release-run.ts
5340
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "fs";
5445
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "fs";
5341
5446
  import { join as join9 } from "path";
5342
5447
 
5343
5448
  // src/release.ts
@@ -5802,7 +5907,7 @@ async function bump(o, size) {
5802
5907
  o.emit.error(`no ${o.repo.release.versionFiles[0]} to read a version from`);
5803
5908
  return null;
5804
5909
  }
5805
- const current = JSON.parse(readFileSync6(first, "utf8")).version;
5910
+ const current = JSON.parse(readFileSync7(first, "utf8")).version;
5806
5911
  if (!current) {
5807
5912
  o.emit.error(`no version field in ${o.repo.release.versionFiles[0]}`);
5808
5913
  return null;
@@ -5815,7 +5920,7 @@ async function bump(o, size) {
5815
5920
  return version;
5816
5921
  }
5817
5922
  for (const f of files) {
5818
- const src = readFileSync6(f, "utf8");
5923
+ const src = readFileSync7(f, "utf8");
5819
5924
  const out = src.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`);
5820
5925
  if (out === src) {
5821
5926
  o.emit.error(`no version field in ${f}`);
@@ -5834,7 +5939,7 @@ function writeChangelog(o, version, entries) {
5834
5939
  o.emit.emit(`would prepend ${entries.length} entr(ies) to ${file}`);
5835
5940
  return;
5836
5941
  }
5837
- const existing = existsSync9(path) ? readFileSync6(path, "utf8") : "# Changelog\n";
5942
+ const existing = existsSync9(path) ? readFileSync7(path, "utf8") : "# Changelog\n";
5838
5943
  writeFileSync6(path, insertChangelogSection(existing, section));
5839
5944
  }
5840
5945
  async function confirm(o, expected) {
@@ -5884,9 +5989,13 @@ function refreshBase(o) {
5884
5989
  // base. Fast-forwarding is impossible and merging would be this crew
5885
5990
  // inventing a resolution nobody asked for, on the branch everything
5886
5991
  // ships from.
5992
+ // AHEAD is a stop too (CREW-1379): local commits nobody asked for on the
5993
+ // base are how a second ship's release came to diverge from origin's.
5994
+ // Releasing on top of a hand commit is the failure; reconcile it first.
5995
+ case "ahead":
5887
5996
  case "diverged":
5888
5997
  case "ff-failed":
5889
- return { ok: false, why: r.detail };
5998
+ return { ok: false, why: describeUnsafeBase(r, o.repo.branch.remote, o.repo.branch.base) };
5890
5999
  }
5891
6000
  }
5892
6001
  async function reconfirmLastRelease(o, decision) {
@@ -6682,7 +6791,7 @@ function nextRoles(decision, ran) {
6682
6791
  }
6683
6792
 
6684
6793
  // src/agents.ts
6685
- import { readFileSync as readFileSync7, existsSync as existsSync12 } from "fs";
6794
+ import { readFileSync as readFileSync8, existsSync as existsSync12 } from "fs";
6686
6795
  import { join as join11 } from "path";
6687
6796
  import { TablationClient as TablationClient2, StaleWriteError as StaleWriteError6, TablationApiError } from "@tablation/client";
6688
6797
  var PERSONA_NAME = {
@@ -6699,10 +6808,10 @@ var AgentsSyncError = class extends Error {
6699
6808
  function personaDefaultPrompt(promptsDir, role) {
6700
6809
  const briefPath = join11(promptsDir, "personas", `lane-${role}.md`);
6701
6810
  if (!existsSync12(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
6702
- if (role === "pair") return readFileSync7(briefPath, "utf8");
6811
+ if (role === "pair") return readFileSync8(briefPath, "utf8");
6703
6812
  const commonPath = join11(promptsDir, "personas", "common.md");
6704
6813
  if (!existsSync12(commonPath)) throw new AgentsSyncError(`no shared policy at ${commonPath}`);
6705
- return `${readFileSync7(commonPath, "utf8")}${readFileSync7(briefPath, "utf8")}`;
6814
+ return `${readFileSync8(commonPath, "utf8")}${readFileSync8(briefPath, "utf8")}`;
6706
6815
  }
6707
6816
  async function resolveAgentId(route, role, opts) {
6708
6817
  const cached = route.resolved?.agentPersonas?.[role]?.agentId;
@@ -7323,14 +7432,14 @@ import {
7323
7432
  } from "@tablation/client";
7324
7433
 
7325
7434
  // src/skills.ts
7326
- import { readFileSync as readFileSync8, readdirSync, existsSync as existsSync13 } from "fs";
7435
+ import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as existsSync13 } from "fs";
7327
7436
  import { join as join12 } from "path";
7328
7437
  import { parse as parseYaml } from "yaml";
7329
7438
  import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError7, TablationApiError as TablationApiError2 } from "@tablation/client";
7330
7439
  var SkillSyncError = class extends Error {
7331
7440
  };
7332
7441
  function parseSkillFile(path) {
7333
- const text = readFileSync8(path, "utf8");
7442
+ const text = readFileSync9(path, "utf8");
7334
7443
  const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text);
7335
7444
  if (!match) throw new SkillSyncError(`${path}: missing YAML frontmatter (expected a leading --- block)`);
7336
7445
  const frontmatter = match[1];
@@ -7345,7 +7454,7 @@ function parseSkillFile(path) {
7345
7454
  function listLocalSkills(promptsDir) {
7346
7455
  const dir = join12(promptsDir, "skills");
7347
7456
  if (!existsSync13(dir)) return [];
7348
- return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join12(dir, f)));
7457
+ return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join12(dir, f)));
7349
7458
  }
7350
7459
  async function syncSkills(route, opts) {
7351
7460
  if (!route.resolved) {
@@ -7670,7 +7779,7 @@ function resolveRepoProject(projects, opt, routeAreaId) {
7670
7779
 
7671
7780
  // src/claude-mcp.ts
7672
7781
  import { spawnSync as spawnSync2 } from "child_process";
7673
- import { readFileSync as readFileSync9 } from "fs";
7782
+ import { readFileSync as readFileSync10 } from "fs";
7674
7783
  import { homedir as homedir4 } from "os";
7675
7784
  import { join as join13 } from "path";
7676
7785
  var MCP_SERVER_NAME = "tablation";
@@ -7685,7 +7794,7 @@ function runClaude(bin, args) {
7685
7794
  }
7686
7795
  function userScopeEntry(home) {
7687
7796
  try {
7688
- const j = JSON.parse(readFileSync9(join13(home, ".claude.json"), "utf8"));
7797
+ const j = JSON.parse(readFileSync10(join13(home, ".claude.json"), "utf8"));
7689
7798
  return j.mcpServers?.[MCP_SERVER_NAME];
7690
7799
  } catch {
7691
7800
  return void 0;
@@ -7884,14 +7993,14 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
7884
7993
  }
7885
7994
 
7886
7995
  // src/stream-sweep.ts
7887
- import { existsSync as existsSync15, readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync6 } from "fs";
7996
+ import { existsSync as existsSync15, readdirSync as readdirSync3, rmSync as rmSync2, statSync as statSync6 } from "fs";
7888
7997
  import { join as join14 } from "path";
7889
7998
  var DAY_MS = 24 * 60 * 60 * 1e3;
7890
7999
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
7891
8000
  const dir = join14(stateDir, "streams");
7892
8001
  if (!existsSync15(dir)) return [];
7893
8002
  const cutoff = now - retentionDays * DAY_MS;
7894
- return readdirSync2(dir).map((name) => join14(dir, name)).filter((path) => {
8003
+ return readdirSync3(dir).map((name) => join14(dir, name)).filter((path) => {
7895
8004
  try {
7896
8005
  return statSync6(path).mtimeMs < cutoff;
7897
8006
  } catch {
@@ -7918,13 +8027,13 @@ function applyStreamSweep(paths, dryRun, log) {
7918
8027
  }
7919
8028
 
7920
8029
  // src/cli.ts
7921
- import { readFileSync as readFileSync13, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
8030
+ import { readFileSync as readFileSync14, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
7922
8031
  import { createInterface } from "readline/promises";
7923
8032
  import { dirname as dirOf, resolve as resolvePath } from "path";
7924
8033
 
7925
8034
  // src/ssh-keys.ts
7926
8035
  import { execFileSync as execFileSync3 } from "child_process";
7927
- import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync10, chmodSync } from "fs";
8036
+ import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync11, chmodSync } from "fs";
7928
8037
  import { dirname as dirname8, join as join15 } from "path";
7929
8038
  var SshKeyError = class extends Error {
7930
8039
  };
@@ -7958,7 +8067,7 @@ function ensureShipSshKeypair(stateDir) {
7958
8067
  if (!existsSync16(publicKeyPath)) {
7959
8068
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
7960
8069
  }
7961
- const publicKey = readFileSync10(publicKeyPath, "utf8").trim();
8070
+ const publicKey = readFileSync11(publicKeyPath, "utf8").trim();
7962
8071
  return { privateKeyPath, publicKeyPath, publicKey };
7963
8072
  }
7964
8073
  function sshKeygenAvailable() {
@@ -8172,7 +8281,7 @@ function syncAllPassengerCheckouts(cfg) {
8172
8281
 
8173
8282
  // src/tunnel.ts
8174
8283
  import { spawn as spawn3 } from "child_process";
8175
- import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync11, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync3 } from "fs";
8284
+ import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync12, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync4 } from "fs";
8176
8285
  import { randomInt } from "crypto";
8177
8286
  import { join as join16 } from "path";
8178
8287
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -8247,7 +8356,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
8247
8356
  const path = tunnelStatePath(stateDir, workspaceId);
8248
8357
  if (!existsSync17(path)) return void 0;
8249
8358
  try {
8250
- return JSON.parse(readFileSync11(path, "utf8"));
8359
+ return JSON.parse(readFileSync12(path, "utf8"));
8251
8360
  } catch {
8252
8361
  return void 0;
8253
8362
  }
@@ -8354,7 +8463,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
8354
8463
  function readAllPersistedWorkspaceIds(stateDir) {
8355
8464
  const dir = join16(stateDir, "passengers");
8356
8465
  if (!existsSync17(dir)) return [];
8357
- return readdirSync3(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
8466
+ return readdirSync4(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
8358
8467
  }
8359
8468
 
8360
8469
  // src/mcp-endpoint-registry.ts
@@ -8474,7 +8583,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
8474
8583
  import { createServer as createHttpServer } from "http";
8475
8584
  import { spawn as spawn4 } from "child_process";
8476
8585
  import { randomBytes } from "crypto";
8477
- import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync4 } from "fs";
8586
+ import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync13, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync5 } from "fs";
8478
8587
  import { join as join17 } from "path";
8479
8588
 
8480
8589
  // src/secret-compare.ts
@@ -8570,7 +8679,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
8570
8679
  const path = syncDaemonStatePath(stateDir, workspaceId);
8571
8680
  if (!existsSync18(path)) return void 0;
8572
8681
  try {
8573
- return JSON.parse(readFileSync12(path, "utf8"));
8682
+ return JSON.parse(readFileSync13(path, "utf8"));
8574
8683
  } catch {
8575
8684
  return void 0;
8576
8685
  }
@@ -8601,7 +8710,7 @@ function readAllPersistedSyncWorkspaceIds(stateDir) {
8601
8710
  const dir = join17(stateDir, "passengers");
8602
8711
  if (!existsSync18(dir)) return [];
8603
8712
  const suffix = "-sync.json";
8604
- return readdirSync4(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
8713
+ return readdirSync5(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
8605
8714
  }
8606
8715
  function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
8607
8716
  const alive = deps.isPidAlive ?? isPidAlive;
@@ -8656,7 +8765,7 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
8656
8765
  }
8657
8766
 
8658
8767
  // src/daemon.ts
8659
- import { readdirSync as readdirSync5, statSync as statSync7 } from "fs";
8768
+ import { readdirSync as readdirSync6, statSync as statSync7 } from "fs";
8660
8769
  import { join as join18 } from "path";
8661
8770
  var DEFAULT_FLOOR_MS = 5e3;
8662
8771
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
@@ -8695,7 +8804,7 @@ function latestMtime(path) {
8695
8804
  if (stat.isFile()) return stat.mtimeMs;
8696
8805
  if (!stat.isDirectory()) return 0;
8697
8806
  let latest = stat.mtimeMs;
8698
- for (const entry of readdirSync5(path, { withFileTypes: true })) {
8807
+ for (const entry of readdirSync6(path, { withFileTypes: true })) {
8699
8808
  latest = Math.max(latest, latestMtime(join18(path, entry.name)));
8700
8809
  }
8701
8810
  return latest;
@@ -9175,6 +9284,11 @@ if (command === "passenger-sync-daemon") {
9175
9284
  if (!why) return false;
9176
9285
  emit.emit(`${why} \u2014 release skipped`, { step: "release" });
9177
9286
  return true;
9287
+ }, baseHealth = function(r) {
9288
+ const { remote, base } = r.config.branch;
9289
+ const out = refreshBaseBranch(r.dir, remote, base, true);
9290
+ const unsafe = baseBranchUnsafe(out);
9291
+ return { action: out.action, unsafe, detail: unsafe ? describeUnsafeBase(out, remote, base) : out.detail };
9178
9292
  }, reportUnplaceable = function(c, unplaceable, verified, emit2) {
9179
9293
  if (unplaceableReported.has(c.route)) return;
9180
9294
  unplaceableReported.add(c.route);
@@ -9183,7 +9297,7 @@ if (command === "passenger-sync-daemon") {
9183
9297
  else emit2.emit(note.message, { step: "release" });
9184
9298
  }
9185
9299
  };
9186
- requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, reportUnplaceable2 = reportUnplaceable;
9300
+ requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, baseHealth2 = baseHealth, reportUnplaceable2 = reportUnplaceable;
9187
9301
  let cfg;
9188
9302
  let firstRun;
9189
9303
  try {
@@ -9374,12 +9488,21 @@ if (command === "passenger-sync-daemon") {
9374
9488
  const trimmed = typeof raw === "string" ? raw.trim() : "";
9375
9489
  return trimmed ? `${trimmed.toLowerCase()}-` : void 0;
9376
9490
  }
9377
- async function installScheduler() {
9491
+ async function installScheduler(opts = {}) {
9378
9492
  const host = hostPlatform();
9379
9493
  if (host === "windows") {
9380
9494
  process.stderr.write("crew install: no scheduler support yet for Windows (Task Scheduler is planned, not built)\n");
9381
9495
  process.exit(2);
9382
9496
  }
9497
+ if (host === "macos") {
9498
+ const ok = clearForeignLaunchdUnits(CREW_HOME, !!opts.replace, dryRun, {
9499
+ emit: (m) => process.stdout.write(`${m}
9500
+ `),
9501
+ warn: (m) => process.stderr.write(`crew install: ${m}
9502
+ `)
9503
+ });
9504
+ if (!ok) return false;
9505
+ }
9383
9506
  const installPathProblem = cfg.routes.some((r) => r.enabled && r.hostPassengers) ? dockerPathProblem(cfg.ship) : void 0;
9384
9507
  if (installPathProblem) {
9385
9508
  process.stderr.write(`crew install: Host Passengers is on but ${installPathProblem}
@@ -9431,6 +9554,7 @@ if (command === "passenger-sync-daemon") {
9431
9554
  }
9432
9555
  }
9433
9556
  }
9557
+ return true;
9434
9558
  }
9435
9559
  async function routeProblems(c) {
9436
9560
  const host = hostPlatform();
@@ -9477,14 +9601,13 @@ if (command === "passenger-sync-daemon") {
9477
9601
  for (const a of plan.actions) {
9478
9602
  if (a.kind === "enable") {
9479
9603
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
9480
- writeFileSync9(cfg.configFile, planEnable(readFileSync13(cfg.configFile, "utf8"), a.route));
9604
+ writeFileSync9(cfg.configFile, planEnable(readFileSync14(cfg.configFile, "utf8"), a.route));
9481
9605
  enabledSound.add(a.route);
9482
9606
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
9483
9607
  } else {
9484
9608
  if (enabledSound.size === 0) continue;
9485
9609
  if (prompter && !await prompter.confirm("No scheduler is installed for this ship \u2014 run `crew install` now?", false)) continue;
9486
- await installScheduler();
9487
- changed2.push("ran `crew install` (scheduler units written and loaded)");
9610
+ if (await installScheduler()) changed2.push("ran `crew install` (scheduler units written and loaded)");
9488
9611
  }
9489
9612
  }
9490
9613
  } finally {
@@ -10376,7 +10499,7 @@ ${changed2.map((c) => ` - ${c}
10376
10499
  `);
10377
10500
  if (!dryRun) {
10378
10501
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
10379
- const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync13(resolvedPath, "utf8")) : {};
10502
+ const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
10380
10503
  raw.agentPersonas = personaResult.agentPersonas;
10381
10504
  raw.agentSkills = skillResult.agentSkills;
10382
10505
  mkdirSync10(dirOf(resolvedPath), { recursive: true });
@@ -10425,7 +10548,7 @@ ${changed2.map((c) => ` - ${c}
10425
10548
  `);
10426
10549
  if (!dryRun) {
10427
10550
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
10428
- const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync13(resolvedPath, "utf8")) : {};
10551
+ const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
10429
10552
  raw.agentSkills = result.agentSkills;
10430
10553
  mkdirSync10(dirOf(resolvedPath), { recursive: true });
10431
10554
  writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -10441,7 +10564,7 @@ ${changed2.map((c) => ` - ${c}
10441
10564
  process.exit(2);
10442
10565
  };
10443
10566
  if (sub !== "add" && sub !== "list") usage2();
10444
- const configText = readFileSync13(cfg.configFile, "utf8");
10567
+ const configText = readFileSync14(cfg.configFile, "utf8");
10445
10568
  let routeName = positional[2];
10446
10569
  let addPath = ".";
10447
10570
  let addName;
@@ -10465,7 +10588,7 @@ ${changed2.map((c) => ` - ${c}
10465
10588
  let resolvedRemotes;
10466
10589
  if (existsSync19(resolvedFile)) {
10467
10590
  try {
10468
- resolvedRemotes = JSON.parse(readFileSync13(resolvedFile, "utf8"));
10591
+ resolvedRemotes = JSON.parse(readFileSync14(resolvedFile, "utf8"));
10469
10592
  } catch {
10470
10593
  }
10471
10594
  }
@@ -10536,7 +10659,7 @@ ${changed2.map((c) => ` - ${c}
10536
10659
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
10537
10660
  `);
10538
10661
  if (existsSync19(resolvedFile)) {
10539
- const raw = JSON.parse(readFileSync13(resolvedFile, "utf8"));
10662
+ const raw = JSON.parse(readFileSync14(resolvedFile, "utf8"));
10540
10663
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
10541
10664
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
10542
10665
  writeFileSync9(resolvedFile, `${JSON.stringify(raw, null, 2)}
@@ -10771,7 +10894,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10771
10894
  const hadPreviousFile = existsSync19(resolvedPath);
10772
10895
  if (hadPreviousFile) {
10773
10896
  try {
10774
- previous = JSON.parse(readFileSync13(resolvedPath, "utf8"));
10897
+ previous = JSON.parse(readFileSync14(resolvedPath, "utf8"));
10775
10898
  } catch {
10776
10899
  }
10777
10900
  }
@@ -11214,14 +11337,16 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11214
11337
  let acted = 0;
11215
11338
  let baseChecked = 0;
11216
11339
  let baseActed = 0;
11340
+ let baseUnsafe = 0;
11217
11341
  for (const r of await resolvedRepos(route)) {
11218
11342
  const base = refreshBaseBranch(r.dir, r.config.branch.remote, r.config.branch.base, dryRun);
11219
11343
  if (base.action !== "not-applicable") {
11220
11344
  baseChecked++;
11221
11345
  if (base.action === "fetch-failed") emit.warn(base.detail);
11222
- else if (base.action === "diverged" || base.action === "ff-failed") {
11346
+ else if (baseBranchUnsafe(base)) {
11223
11347
  baseActed++;
11224
- emit.warn(base.detail);
11348
+ baseUnsafe++;
11349
+ emit.error(`${r.name}: DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${describeUnsafeBase(base, r.config.branch.remote, r.config.branch.base)}`);
11225
11350
  } else if (base.action === "would-fast-forward" || base.action === "fast-forwarded") {
11226
11351
  baseActed++;
11227
11352
  emit.emit(base.detail);
@@ -11253,6 +11378,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11253
11378
  }
11254
11379
  }
11255
11380
  }
11381
+ if (baseUnsafe > 0) process.exitCode = 1;
11256
11382
  if (baseChecked > 0 && baseActed === 0) emit.emit(`${baseChecked} base branch(es) checked, all level with the remote`);
11257
11383
  if (tracked === 0) emit.emit("no worktree tracks a remote branch \u2014 nothing to sync");
11258
11384
  else if (acted === 0) emit.emit(`${tracked} worktree(s) tracking a remote, all level with it`);
@@ -11274,7 +11400,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11274
11400
  break;
11275
11401
  }
11276
11402
  case "install": {
11277
- await installScheduler();
11403
+ if (!await installScheduler({ replace: flag("replace") })) process.exit(2);
11278
11404
  break;
11279
11405
  }
11280
11406
  case "uninstall": {
@@ -11291,6 +11417,16 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11291
11417
  emit: (m) => process.stdout.write(`${m}
11292
11418
  `),
11293
11419
  warn: (m) => process.stderr.write(`crew uninstall: ${m}
11420
+ `)
11421
+ });
11422
+ }
11423
+ if (flag("all") && host === "macos") {
11424
+ process.stdout.write(`uninstalling every crew launchd unit${dryRun ? " (dry run)" : ""}
11425
+ `);
11426
+ removeAllLaunchdUnits(dryRun, {
11427
+ emit: (m) => process.stdout.write(`${m}
11428
+ `),
11429
+ warn: (m) => process.stderr.write(`crew uninstall: ${m}
11294
11430
  `)
11295
11431
  });
11296
11432
  }
@@ -11302,7 +11438,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11302
11438
  }
11303
11439
  case "log": {
11304
11440
  try {
11305
- const text = readFileSync13(cfg.ship.logFile, "utf8").split("\n");
11441
+ const text = readFileSync14(cfg.ship.logFile, "utf8").split("\n");
11306
11442
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
11307
11443
  `);
11308
11444
  } catch {
@@ -11373,6 +11509,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11373
11509
  repos: repos.map((r) => ({
11374
11510
  name: r.name,
11375
11511
  dir: r.dir,
11512
+ // Additive (CREW-1379): `unsafe` means a ship must not cut a
11513
+ // worktree or release from this base. `version` stays 1.
11514
+ baseBranch: baseHealth(r),
11376
11515
  worktreePrefix: r.config.worktrees.prefix,
11377
11516
  // A repo with no explicit `worktrees.prefix` no longer names its
11378
11517
  // worktrees `<prefix><number>` (ISSUE-969) — the checkout's own
@@ -11413,6 +11552,11 @@ watermark: ${state.watermark()}
11413
11552
  }
11414
11553
  for (const i of openShipAttention(state)) {
11415
11554
  process.stdout.write(`attention: ${i.kind} \u2014 ${i.message}
11555
+ `);
11556
+ }
11557
+ for (const r of await resolvedRepos(route)) {
11558
+ const bh = baseHealth(r);
11559
+ if (bh.unsafe) process.stdout.write(`base: ${r.name} \u2014 UNSAFE, no worktree cuts or releases: ${bh.detail}
11416
11560
  `);
11417
11561
  }
11418
11562
  if (waiting) {
@@ -11467,6 +11611,12 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
11467
11611
  process.stdout.write(` - ${p2}
11468
11612
  `);
11469
11613
  }
11614
+ const bh = baseHealth(r);
11615
+ if (bh.action !== "not-applicable") {
11616
+ process.stdout.write(` base ${config.branch.base}: ${bh.unsafe ? "ERROR \u2014 " : ""}${bh.detail}
11617
+ `);
11618
+ if (bh.unsafe) process.exitCode = 1;
11619
+ }
11470
11620
  }
11471
11621
  const missingAuthorship = await tracker.missingAuthorshipColumns();
11472
11622
  process.stdout.write(
@@ -11557,6 +11707,14 @@ git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler P
11557
11707
  process.stdout.write(`attention: ${i.kind} \u2014 ${i.message} (since ${i.since})
11558
11708
  `);
11559
11709
  }
11710
+ if (host === "macos") {
11711
+ const foreign = describeForeignLaunchdUnits(CREW_HOME);
11712
+ if (foreign.length > 0) {
11713
+ process.stdout.write(`foreign launchd units (not this checkout's \u2014 \`crew install\` or \`crew uninstall --all\` clears them):
11714
+ ${foreign.join("\n")}
11715
+ `);
11716
+ }
11717
+ }
11560
11718
  const passengerRoutes = cfg.routes.filter((r) => r.enabled && r.hostPassengers);
11561
11719
  if (passengerRoutes.length === 0) {
11562
11720
  process.stdout.write("host passengers: off (no enabled route sets hostPassengers: true)\n");
@@ -11639,4 +11797,5 @@ var passengerApiKeys2;
11639
11797
  var endpointApiFor2;
11640
11798
  var endpointReconcileDeps2;
11641
11799
  var releaseRefused2;
11800
+ var baseHealth2;
11642
11801
  var reportUnplaceable2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
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",
@@ -346,10 +346,13 @@ if `report_type` is either of those.
346
346
  missing commits another ship or a reviewer pushed since the last sync. It
347
347
  is quiet, not an alert, about a primary checkout that isn't cleanly on the
348
348
  base branch (something else may be mid-edit there) — that's expected, not
349
- a fault, and just means this sync is a no-op. A genuinely DIVERGED base
350
- branch is reported in `crew sync`'s own output; that's for the operator to
351
- reconcile, not something to resolve yourself — carry on and cut the
352
- worktree from whatever HEAD the primary checkout actually has. Then, from
349
+ a fault, and just means this sync is a no-op. A DIVERGED base
350
+ branch (local commits the remote lacks, or one that cannot fast-forward)
351
+ makes `crew sync` print `DO NOT CUT A WORKTREE FROM THIS BASE` and exit
352
+ non-zero. That is a hard stop: do not run `git worktree add`, do not try to
353
+ reconcile it yourself. Post a comment on the ticket naming the repo and the
354
+ counts `crew sync` printed, leave the ticket as you found it, and end the
355
+ run — the operator reconciles the checkout. Otherwise, from
353
356
  the primary checkout, `git worktree add` a sibling worktree for this
354
357
  ticket, on a new branch cut from the base branch's current HEAD. The
355
358
  digest's `worktree` and `branch` columns give this ticket's own directory
@@ -327,10 +327,13 @@ if `report_type` is either of those.
327
327
  missing commits another ship or a reviewer pushed since the last sync. It
328
328
  is quiet, not an alert, about a primary checkout that isn't cleanly on the
329
329
  base branch (something else may be mid-edit there) — that's expected, not
330
- a fault, and just means this sync is a no-op. A genuinely DIVERGED base
331
- branch is reported in `crew sync`'s own output; that's for the operator to
332
- reconcile, not something to resolve yourself — carry on and cut the
333
- worktree from whatever HEAD the primary checkout actually has. Then, from
330
+ a fault, and just means this sync is a no-op. A DIVERGED base
331
+ branch (local commits the remote lacks, or one that cannot fast-forward)
332
+ makes `crew sync` print `DO NOT CUT A WORKTREE FROM THIS BASE` and exit
333
+ non-zero. That is a hard stop: do not run `git worktree add`, do not try to
334
+ reconcile it yourself. Post a comment on the ticket naming the repo and the
335
+ counts `crew sync` printed, leave the ticket as you found it, and end the
336
+ run — the operator reconciles the checkout. Otherwise, from
334
337
  the primary checkout, `git worktree add` a sibling worktree for this
335
338
  ticket, on a new branch cut from the base branch's current HEAD. The
336
339
  digest's `worktree` and `branch` columns give this ticket's own directory