@tablation/crew 0.25.0 → 0.27.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/crew.example.yaml CHANGED
@@ -76,6 +76,12 @@ routes:
76
76
 
77
77
  worktreePrefix: "myproject-issue-"
78
78
 
79
+ # A repo may also be written as an object to name its worktree directory
80
+ # for this ship only (CREW-1504) — a template over {dir} {issue} {number}
81
+ # {key} {slug} {tag} {prefix} {role}. Outranks the repo's own
82
+ # `worktrees.name`, which outranks worktreePrefix; see docs/REPO_SPEC.md.
83
+ # My Product: { dir: "/home/me/src/my-product", worktreeName: "{dir}-{issue}" }
84
+
79
85
  # Opt this route's ship into Host Passengers for this route's workspace
80
86
  # (ISSUE-644): `crew connect` syncs this onto that workspace's own Ships
81
87
  # row. Config-truth lives here, on the machine — never edit a Ships row
package/dist/cli.js CHANGED
@@ -1,16 +1,163 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { resolve as resolve4, dirname as dirname11, join as join20, basename as basename7 } from "path";
4
+ import { resolve as resolve4, dirname as dirname11, join as join20, basename as basename8 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { hostname as hostname2, homedir as homedir5 } from "os";
7
7
  import { spawnSync as spawnSync3 } from "child_process";
8
8
 
9
9
  // src/config.ts
10
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
11
- import { resolve, dirname, isAbsolute, join as join2, basename as basename2 } from "path";
10
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
11
+ import { resolve, dirname as dirname2, isAbsolute, join as join3, basename as basename2 } from "path";
12
12
  import { homedir, tmpdir } from "os";
13
13
  import { parse as parse2 } from "yaml";
14
+
15
+ // src/events.ts
16
+ import {
17
+ appendFileSync,
18
+ mkdirSync,
19
+ existsSync,
20
+ statSync,
21
+ renameSync,
22
+ openSync,
23
+ closeSync,
24
+ rmSync
25
+ } from "fs";
26
+ import { join, dirname } from "path";
27
+ function render(e) {
28
+ const scope = e.role ? `${e.step}[${e.role}]` : e.step;
29
+ const ticket = e.ticket ? ` ${e.ticket}` : "";
30
+ const level = e.level === "info" ? "" : `${e.level.toUpperCase()}: `;
31
+ return `${e.at} ${scope}:${ticket} ${level}${e.message}`;
32
+ }
33
+ var DEFAULT_EVENTS_ROTATE_BYTES = 64 * 1024 * 1024;
34
+ var ROTATED_EVENTS_RE = /^events\.\d{8}T\d{6}Z(?:-\d+)?\.jsonl$/;
35
+ var ROTATE_LOCK_STALE_MS = 6e4;
36
+ function rotateEventsFile(file, maxBytes, now = /* @__PURE__ */ new Date()) {
37
+ try {
38
+ if (!existsSync(file) || statSync(file).size <= maxBytes) return void 0;
39
+ } catch {
40
+ return void 0;
41
+ }
42
+ const lock = `${file}.rotate.lock`;
43
+ try {
44
+ try {
45
+ closeSync(openSync(lock, "wx"));
46
+ } catch {
47
+ try {
48
+ if (Date.now() - statSync(lock).mtimeMs < ROTATE_LOCK_STALE_MS) return void 0;
49
+ rmSync(lock, { force: true });
50
+ closeSync(openSync(lock, "wx"));
51
+ } catch {
52
+ return void 0;
53
+ }
54
+ }
55
+ try {
56
+ if (statSync(file).size <= maxBytes) return void 0;
57
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
58
+ let dest = join(dirname(file), `events.${stamp}.jsonl`);
59
+ for (let n = 1; existsSync(dest); n++) dest = join(dirname(file), `events.${stamp}-${n}.jsonl`);
60
+ renameSync(file, dest);
61
+ return dest;
62
+ } finally {
63
+ rmSync(lock, { force: true });
64
+ }
65
+ } catch {
66
+ return void 0;
67
+ }
68
+ }
69
+ var Emitter = class _Emitter {
70
+ cycle;
71
+ opts;
72
+ now;
73
+ step = "poll";
74
+ role;
75
+ constructor(opts) {
76
+ this.opts = opts;
77
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
78
+ this.cycle = opts.cycleId ?? this.now().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
79
+ for (const f of [opts.eventFile, opts.logFile]) {
80
+ if (!f) continue;
81
+ try {
82
+ mkdirSync(dirname(f), { recursive: true });
83
+ } catch {
84
+ }
85
+ }
86
+ }
87
+ /** Everything emitted after this belongs to `step`, until the next one. */
88
+ enter(step, role) {
89
+ this.step = step;
90
+ this.role = role;
91
+ }
92
+ emit(message, extra = {}) {
93
+ const e = {
94
+ at: this.now().toISOString(),
95
+ cycle: this.cycle,
96
+ route: this.opts.route,
97
+ step: extra.step ?? this.step,
98
+ level: extra.level ?? "info",
99
+ message,
100
+ ...extra.role ?? this.role ? { role: extra.role ?? this.role } : {},
101
+ ...extra.ticket ? { ticket: extra.ticket } : {},
102
+ ...extra.data ? { data: extra.data } : {}
103
+ };
104
+ const line = render(e);
105
+ try {
106
+ if (this.opts.eventFile && this.opts.rotateBytes) rotateEventsFile(this.opts.eventFile, this.opts.rotateBytes, this.now());
107
+ } catch {
108
+ }
109
+ try {
110
+ if (this.opts.eventFile) appendFileSync(this.opts.eventFile, `${JSON.stringify(e)}
111
+ `);
112
+ } catch {
113
+ }
114
+ try {
115
+ if (this.opts.logFile) appendFileSync(this.opts.logFile, `${line}
116
+ `);
117
+ } catch {
118
+ }
119
+ try {
120
+ (this.opts.console ?? ((l) => process.stderr.write(`${l}
121
+ `)))(line);
122
+ } catch {
123
+ }
124
+ return e;
125
+ }
126
+ warn(message, extra = {}) {
127
+ return this.emit(message, { ...extra, level: "warn" });
128
+ }
129
+ error(message, extra = {}) {
130
+ return this.emit(message, { ...extra, level: "error" });
131
+ }
132
+ /**
133
+ * A view of this emitter labelling every event with a different route,
134
+ * sharing the same sinks and cycle id. For a fleet-wide run, the top-level
135
+ * Emitter is stamped with just one route (see cli.ts) — a phase that
136
+ * actually knows which route it's working (release, per-repo poll) uses
137
+ * this instead, so its events aren't all attributed to `routes[0]`.
138
+ */
139
+ forRoute(route) {
140
+ return new _Emitter({ ...this.opts, route, cycleId: this.cycle });
141
+ }
142
+ /** Times a step and emits its outcome, so a view can show duration. */
143
+ async timed(step, message, fn, role) {
144
+ this.enter(step, role);
145
+ const started = this.now().getTime();
146
+ try {
147
+ const result = await fn();
148
+ this.emit(message, { data: { ms: this.now().getTime() - started, ok: true } });
149
+ return result;
150
+ } catch (err) {
151
+ this.error(`${message} \u2014 ${err.message}`, {
152
+ data: { ms: this.now().getTime() - started, ok: false }
153
+ });
154
+ throw err;
155
+ }
156
+ }
157
+ };
158
+ var eventFileFor = (stateDir) => join(stateDir, "events.jsonl");
159
+
160
+ // src/config.ts
14
161
  import { getSessionStore, hostFromUrl } from "@tablation/client";
15
162
 
16
163
  // src/platform.ts
@@ -50,8 +197,8 @@ function explain(ship, required) {
50
197
  }
51
198
 
52
199
  // src/repo-config.ts
53
- import { readFileSync, existsSync } from "fs";
54
- import { join, basename } from "path";
200
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
201
+ import { join as join2, basename } from "path";
55
202
  import { parse } from "yaml";
56
203
  var SPEC_VERSION = 1;
57
204
  var REPO_CONFIG_FILENAMES = [".crew.yaml", ".crew.yml", ".crew.json"];
@@ -84,6 +231,27 @@ var TOP_LEVEL = /* @__PURE__ */ new Set([
84
231
  ]);
85
232
  var BRANCH_KEYS = /* @__PURE__ */ new Set(["base", "name", "push", "remote"]);
86
233
  var PLACEHOLDER = /\{(key|number|slug|role|prefix|tag)\}/g;
234
+ var WORKTREE_PLACEHOLDERS = ["dir", "issue", "number", "key", "slug", "role", "prefix", "tag"];
235
+ function worktreeNameProblem(template) {
236
+ const valid = `Valid placeholders: ${WORKTREE_PLACEHOLDERS.map((p) => `{${p}}`).join(" ")}.`;
237
+ if (template.trim() === "") return `must be a non-empty string. ${valid}`;
238
+ if (/[/\\]/.test(template)) {
239
+ return "must not contain a path separator \u2014 a worktree is cut beside the checkout, not inside a directory of its own";
240
+ }
241
+ const stripped = template.replace(/\{([^{}]*)\}/g, (_m, name) => {
242
+ if (!WORKTREE_PLACEHOLDERS.includes(name)) return "\0";
243
+ return "";
244
+ });
245
+ if (stripped.includes("\0")) {
246
+ const bad = [...template.matchAll(/\{([^{}]*)\}/g)].map((m) => m[1] ?? "").filter((n) => !WORKTREE_PLACEHOLDERS.includes(n));
247
+ return `names unknown placeholder ${bad.map((b) => `{${b}}`).join(", ")}. ${valid}`;
248
+ }
249
+ if (/[{}]/.test(stripped)) return `has an unbalanced brace. ${valid}`;
250
+ if (!/\{(issue|number|key|slug|tag)\}/.test(template)) {
251
+ return "names no per-ticket placeholder \u2014 every ticket would get the same worktree. Use at least one of {issue} {number} {key} {slug} {tag}.";
252
+ }
253
+ return null;
254
+ }
87
255
  var RELEASE_KEYS = /* @__PURE__ */ new Set([
88
256
  "mode",
89
257
  "ship",
@@ -103,8 +271,8 @@ var MODES = ["local", "integrate", "ci_manual", "ci_auto", "external"];
103
271
  var PROVIDERS = ["github", "buildkite", "other", "none"];
104
272
  function findRepoConfig(dir) {
105
273
  for (const name of REPO_CONFIG_FILENAMES) {
106
- const p = join(dir, name);
107
- if (existsSync(p)) return p;
274
+ const p = join2(dir, name);
275
+ if (existsSync2(p)) return p;
108
276
  }
109
277
  return null;
110
278
  }
@@ -132,7 +300,7 @@ function parseRepoConfig(text, file) {
132
300
  rejectUnknown(raw.release?.ci, CI_KEYS, "release.ci", file);
133
301
  rejectUnknown(raw.release?.verify, VERIFY_KEYS, "release.verify", file);
134
302
  rejectUnknown(raw.branch, BRANCH_KEYS, "branch", file);
135
- rejectUnknown(raw.worktrees, /* @__PURE__ */ new Set(["copy", "prefix"]), "worktrees", file);
303
+ rejectUnknown(raw.worktrees, /* @__PURE__ */ new Set(["copy", "prefix", "name"]), "worktrees", file);
136
304
  rejectUnknown(raw.docs, /* @__PURE__ */ new Set(["triagePolicy", "designGuide"]), "docs", file);
137
305
  if (raw.version === void 0) throw new RepoConfigError(`${file}: version is required`);
138
306
  if (raw.version !== SPEC_VERSION) {
@@ -167,7 +335,13 @@ function parseRepoConfig(text, file) {
167
335
  `${file}: worktrees.prefix ("${prefixRaw}") must not contain a path separator \u2014 a worktree is cut beside the checkout, not inside a directory of its own`
168
336
  );
169
337
  }
338
+ const nameRaw = raw.worktrees?.name;
339
+ if (nameRaw !== void 0) {
340
+ const problem = typeof nameRaw === "string" ? worktreeNameProblem(nameRaw) : "must be a string";
341
+ if (problem) throw new RepoConfigError(`${file}: worktrees.name ("${String(nameRaw)}") ${problem}`);
342
+ }
170
343
  const worktrees2 = {
344
+ ...nameRaw !== void 0 ? { name: String(nameRaw) } : {},
171
345
  copy: (copyRaw ?? []).map((c) => String(c)),
172
346
  // Empty means "not declared" here; the effective config fills it in, since
173
347
  // only that layer knows the ship's setting and the checkout's directory.
@@ -328,7 +502,7 @@ function parseRepoConfig(text, file) {
328
502
  function loadRepoConfig(dir) {
329
503
  const file = findRepoConfig(dir);
330
504
  if (!file) return null;
331
- return parseRepoConfig(readFileSync(file, "utf8"), file);
505
+ return parseRepoConfig(readFileSync2(file, "utf8"), file);
332
506
  }
333
507
  function hookLabel(cfg, hook2) {
334
508
  return cfg.labels[hook2] ?? cfg.hooks[hook2] ?? hook2;
@@ -358,6 +532,10 @@ function renderBranchName(template, ctx) {
358
532
  }
359
533
  });
360
534
  }
535
+ function renderWorktreeName(template, ctx) {
536
+ const issue = (ctx.tag ?? ctx.key).toLowerCase();
537
+ return renderBranchName(template.replace(/\{dir\}/g, ctx.dir).replace(/\{issue\}/g, issue), ctx);
538
+ }
361
539
  function defaultWorktreePrefix(dir) {
362
540
  const name = basename(dir.replace(/[/\\]+$/, ""));
363
541
  return name ? `${name}-issue-` : "issue-";
@@ -389,6 +567,10 @@ function resolveRepoConfig(repo, ship, dir) {
389
567
  provenance[key] = "default";
390
568
  return fallback;
391
569
  };
570
+ if (ship?.worktrees?.nameOverride) {
571
+ if (repo?.worktrees.name) shadowed.push("worktrees.name");
572
+ provenance["worktrees.name"] = "ship";
573
+ } else if (repo?.worktrees.name) provenance["worktrees.name"] = "repo";
392
574
  const hooks = {};
393
575
  const labels = {};
394
576
  for (const h of HOOK_NAMES) {
@@ -424,7 +606,8 @@ function resolveRepoConfig(repo, ship, dir) {
424
606
  repo?.worktrees.prefix || void 0,
425
607
  ship?.worktrees?.prefix,
426
608
  defaultWorktreePrefix(dir)
427
- )
609
+ ),
610
+ ...ship?.worktrees?.nameOverride || repo?.worktrees.name ? { name: ship?.worktrees?.nameOverride || repo?.worktrees.name } : {}
428
611
  },
429
612
  docs: repo?.docs ?? {},
430
613
  branch: {
@@ -525,7 +708,8 @@ function effectiveBranchTemplate(cfg, prefix) {
525
708
  if (cfg.provenance["branch.name"] !== "default") return cfg.branch.name;
526
709
  return prefix ? "{prefix}-{number}" : "issue-{number}";
527
710
  }
528
- function effectiveWorktreeDirName(cfg, dir, branchName, number) {
711
+ function effectiveWorktreeDirName(cfg, dir, branchName, number, renderName) {
712
+ if (cfg.worktrees.name && renderName) return renderName(cfg.worktrees.name);
529
713
  if (cfg.provenance["worktrees.prefix"] !== "default") return `${cfg.worktrees.prefix}${number}`;
530
714
  return `${basename(dir.replace(/[/\\]+$/, ""))}-${branchName}`;
531
715
  }
@@ -594,6 +778,7 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
594
778
  "apiKey",
595
779
  "maxConcurrentAgents",
596
780
  "streamRetentionDays",
781
+ "eventsRotateBytes",
597
782
  "reposBasePath",
598
783
  "relayHost",
599
784
  "relayPort",
@@ -623,7 +808,12 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
623
808
  "promptSet",
624
809
  "hostPassengers"
625
810
  ]);
626
- var REPO_ENTRY_KEYS = /* @__PURE__ */ new Set(["dir", "hooks", "labels", "release", "branch", "hostPassengers"]);
811
+ function parseWorktreeName(v, where, file) {
812
+ const problem = typeof v === "string" ? worktreeNameProblem(v) : "must be a string";
813
+ if (problem) throw new ConfigError(`${file}: ${where}.worktreeName ("${String(v)}") ${problem}`);
814
+ return String(v);
815
+ }
816
+ var REPO_ENTRY_KEYS = /* @__PURE__ */ new Set(["dir", "hooks", "labels", "release", "branch", "hostPassengers", "worktreeName"]);
627
817
  var BRANCH_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["base", "name", "push", "remote"]);
628
818
  var RELEASE_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
629
819
  "mode",
@@ -725,11 +915,11 @@ function expand(p, base) {
725
915
  return isAbsolute(out) ? out : resolve(base, out);
726
916
  }
727
917
  function resolvePromptsDir(promptSet, crewHome, base) {
728
- if (!promptSet) return join2(crewHome, "prompts", "default");
918
+ if (!promptSet) return join3(crewHome, "prompts", "default");
729
919
  if (promptSet.includes("/") || promptSet.startsWith(".") || promptSet.startsWith("~")) {
730
920
  return expand(promptSet, base);
731
921
  }
732
- return join2(crewHome, "prompts", promptSet);
922
+ return join3(crewHome, "prompts", promptSet);
733
923
  }
734
924
  var Missing = class {
735
925
  paths = [];
@@ -753,7 +943,7 @@ var Missing = class {
753
943
  };
754
944
  function crewVersion(crewHome) {
755
945
  try {
756
- const pkg = JSON.parse(readFileSync2(join2(crewHome, "package.json"), "utf8"));
946
+ const pkg = JSON.parse(readFileSync3(join3(crewHome, "package.json"), "utf8"));
757
947
  return pkg.version ?? "0.0.0";
758
948
  } catch {
759
949
  return "0.0.0";
@@ -763,26 +953,26 @@ function configSearchPath(crewHome) {
763
953
  if (process.env.CREW_CONFIG) return [resolve(process.env.CREW_CONFIG)];
764
954
  const xdg = process.env.XDG_CONFIG_HOME;
765
955
  return [
766
- ...xdg ? [join2(xdg, "crew", "crew.yaml")] : [],
767
- join2(homedir(), ".config", "crew", "crew.yaml"),
956
+ ...xdg ? [join3(xdg, "crew", "crew.yaml")] : [],
957
+ join3(homedir(), ".config", "crew", "crew.yaml"),
768
958
  resolve(crewHome, "crew.yaml")
769
959
  ];
770
960
  }
771
961
  function findConfigFile(crewHome) {
772
962
  const candidates = configSearchPath(crewHome);
773
- return candidates.find((c) => existsSync2(c)) ?? candidates[candidates.length - 1];
963
+ return candidates.find((c) => existsSync3(c)) ?? candidates[candidates.length - 1];
774
964
  }
775
- var defaultConfigPath = () => join2(process.env.XDG_CONFIG_HOME ?? join2(homedir(), ".config"), "crew", "crew.yaml");
965
+ var defaultConfigPath = () => join3(process.env.XDG_CONFIG_HOME ?? join3(homedir(), ".config"), "crew", "crew.yaml");
776
966
  function routeSlug(route) {
777
967
  return route.replace(/\//g, "-");
778
968
  }
779
969
  function resolvedPathFor(stateDir, route) {
780
970
  const [workspace, project] = splitRoute(route);
781
- return join2(stateDir, "resolved", workspace, `${project}.json`);
971
+ return join3(stateDir, "resolved", workspace, `${project}.json`);
782
972
  }
783
973
  function apiKeyPathFor(stateDir, route) {
784
974
  const [workspace, project] = splitRoute(route);
785
- return join2(stateDir, "keys", workspace, `${project}.env`);
975
+ return join3(stateDir, "keys", workspace, `${project}.env`);
786
976
  }
787
977
  function splitRoute(route) {
788
978
  const parts = route.split("/");
@@ -793,9 +983,9 @@ function splitRoute(route) {
793
983
  }
794
984
  function readResolvedFile(stateDir, route) {
795
985
  const path = resolvedPathFor(stateDir, route);
796
- if (!existsSync2(path)) return void 0;
986
+ if (!existsSync3(path)) return void 0;
797
987
  try {
798
- return JSON.parse(readFileSync2(path, "utf8"));
988
+ return JSON.parse(readFileSync3(path, "utf8"));
799
989
  } catch (e) {
800
990
  throw new ConfigError(`${path}: not valid JSON \u2014 ${e.message}`);
801
991
  }
@@ -851,23 +1041,23 @@ function parseResolved(raw, m, where) {
851
1041
  }
852
1042
  function loadConfig(crewHome, configFile, opts = {}) {
853
1043
  const file = configFile ?? findConfigFile(crewHome);
854
- if (opts.text === void 0 && !existsSync2(file)) {
1044
+ if (opts.text === void 0 && !existsSync3(file)) {
855
1045
  throw new ConfigError(
856
1046
  `no config found. Looked in:
857
1047
  ` + configSearchPath(crewHome).map((c) => ` - ${c}`).join("\n") + `
858
1048
 
859
1049
  Create the first of those:
860
- mkdir -p ${dirname(defaultConfigPath())} && cp ${join2(crewHome, "crew.example.yaml")} ${defaultConfigPath()}`
1050
+ mkdir -p ${dirname2(defaultConfigPath())} && cp ${join3(crewHome, "crew.example.yaml")} ${defaultConfigPath()}`
861
1051
  );
862
1052
  }
863
1053
  let raw;
864
1054
  try {
865
- raw = parse2(opts.text ?? readFileSync2(file, "utf8"));
1055
+ raw = parse2(opts.text ?? readFileSync3(file, "utf8"));
866
1056
  } catch (e) {
867
1057
  throw new ConfigError(`${file}: not valid YAML \u2014 ${e.message}`);
868
1058
  }
869
1059
  if (!raw || typeof raw !== "object") throw new ConfigError(`${file} is empty or not a mapping`);
870
- const base = dirname(file);
1060
+ const base = dirname2(file);
871
1061
  const missing = new Missing();
872
1062
  rejectUnknownKeys(raw, /* @__PURE__ */ new Set(["ship", "routes"]), "top-level", file);
873
1063
  const shipRaw = raw.ship ?? {};
@@ -884,6 +1074,10 @@ Create the first of those:
884
1074
  if (!Number.isInteger(streamRetentionDays) || streamRetentionDays < 1) {
885
1075
  missing.add(`ship.streamRetentionDays must be a positive integer (got "${shipRaw.streamRetentionDays}")`);
886
1076
  }
1077
+ const eventsRotateBytes = shipRaw.eventsRotateBytes === void 0 ? DEFAULT_EVENTS_ROTATE_BYTES : Number(shipRaw.eventsRotateBytes);
1078
+ if (!Number.isInteger(eventsRotateBytes) || eventsRotateBytes < 1) {
1079
+ missing.add(`ship.eventsRotateBytes must be a positive integer (got "${shipRaw.eventsRotateBytes}")`);
1080
+ }
887
1081
  const maxThinkingTokens = shipRaw.agent?.maxThinkingTokens === void 0 ? DEFAULT_MAX_THINKING_TOKENS : Number(shipRaw.agent.maxThinkingTokens);
888
1082
  if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
889
1083
  missing.add(`ship.agent.maxThinkingTokens must be a non-negative integer (got "${shipRaw.agent?.maxThinkingTokens}")`);
@@ -926,10 +1120,11 @@ Create the first of those:
926
1120
  useNvm: shipRaw.useNvm !== false,
927
1121
  nvmSh: shipRaw.nvmSh,
928
1122
  stateDir,
929
- logFile: expand(shipRaw.logFile ?? join2(tmpdir(), "crew.log"), base),
1123
+ logFile: expand(shipRaw.logFile ?? join3(tmpdir(), "crew.log"), base),
930
1124
  userAgent: shipRaw.userAgent ?? `Mozilla/5.0 CrewAgent/${crewVersion(crewHome)}`,
931
1125
  maxConcurrentAgents,
932
1126
  streamRetentionDays,
1127
+ eventsRotateBytes,
933
1128
  relayHost: shipRaw.relayHost ? String(shipRaw.relayHost) : void 0,
934
1129
  // 2222 matches the relay's own RELAY_SSH_PORT default (crew-relay README).
935
1130
  relayPort: shipRaw.relayPort !== void 0 ? Number(shipRaw.relayPort) : 2222,
@@ -959,7 +1154,7 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
959
1154
  const e = entry;
960
1155
  rejectUnknownKeys(e, REPO_ENTRY_KEYS, `${where}.repos.${name}`, file);
961
1156
  repoDirs[name] = expand(String(missing.req(e.dir, `${where}.repos.${name}.dir`) || ""), base);
962
- if (e.hooks || e.labels || e.release || e.branch || e.hostPassengers !== void 0) {
1157
+ if (e.hooks || e.labels || e.release || e.branch || e.hostPassengers !== void 0 || e.worktreeName !== void 0) {
963
1158
  repoOverrides[name] = {
964
1159
  hooks: e.hooks,
965
1160
  labels: e.labels,
@@ -969,7 +1164,8 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
969
1164
  // shape as one that never had the key, and existing callers
970
1165
  // compare this literally.
971
1166
  ...e.branch ? { branch: parseBranchOverride(e.branch, `${where}.repos.${name}`, file) } : {},
972
- ...e.hostPassengers !== void 0 ? { hostPassengers: e.hostPassengers === true } : {}
1167
+ ...e.hostPassengers !== void 0 ? { hostPassengers: e.hostPassengers === true } : {},
1168
+ ...e.worktreeName !== void 0 ? { worktreeName: parseWorktreeName(e.worktreeName, `${where}.repos.${name}`, file) } : {}
973
1169
  };
974
1170
  }
975
1171
  } else {
@@ -1059,8 +1255,8 @@ function repoIdForName(r, name) {
1059
1255
  }
1060
1256
  function resolveApiKey(r) {
1061
1257
  if (r.apiKey) return r.apiKey;
1062
- if (r.apiKeyFile && r.apiKeyVar && existsSync2(r.apiKeyFile)) {
1063
- for (const line of readFileSync2(r.apiKeyFile, "utf8").split("\n")) {
1258
+ if (r.apiKeyFile && r.apiKeyVar && existsSync3(r.apiKeyFile)) {
1259
+ for (const line of readFileSync3(r.apiKeyFile, "utf8").split("\n")) {
1064
1260
  const m = line.match(new RegExp(`^${r.apiKeyVar}=(.*)$`));
1065
1261
  if (m) return (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
1066
1262
  }
@@ -1123,7 +1319,7 @@ function configuredMembers(rt) {
1123
1319
  return out;
1124
1320
  }
1125
1321
  function defaultRepoDir(basePath, workspace, repoName) {
1126
- return join2(basePath, workspace, repoName);
1322
+ return join3(basePath, workspace, repoName);
1127
1323
  }
1128
1324
  function reposOf(r) {
1129
1325
  const named = Object.entries(r.repos);
@@ -1147,7 +1343,7 @@ function passengerRepoTargets(r) {
1147
1343
  return reposOf(r).filter((t) => r.repoOverrides[t.name]?.hostPassengers !== false);
1148
1344
  }
1149
1345
  function passengerCheckoutDir(basePath, workspace, repoName) {
1150
- return join2(basePath, workspace, ".passenger-checkouts", repoName);
1346
+ return join3(basePath, workspace, ".passenger-checkouts", repoName);
1151
1347
  }
1152
1348
  function passengerCheckoutTargets(r) {
1153
1349
  if (!r.hostPassengers) return [];
@@ -1279,17 +1475,17 @@ function rosterMarkdown(roster, meId) {
1279
1475
  }
1280
1476
 
1281
1477
  // src/state.ts
1282
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, mkdirSync, rmSync, statSync } from "fs";
1283
- import { join as join3 } from "path";
1478
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, rmSync as rmSync2, statSync as statSync2 } from "fs";
1479
+ import { join as join4 } from "path";
1284
1480
  var EPOCH = "1970-01-01T00:00:00.000Z";
1285
1481
  var State = class _State {
1286
1482
  dir;
1287
1483
  constructor(dir) {
1288
1484
  this.dir = dir;
1289
- mkdirSync(dir, { recursive: true });
1485
+ mkdirSync2(dir, { recursive: true });
1290
1486
  }
1291
1487
  path(...parts) {
1292
- return join3(this.dir, ...parts);
1488
+ return join4(this.dir, ...parts);
1293
1489
  }
1294
1490
  /**
1295
1491
  * The timestamp every "new since last poll" check is measured against.
@@ -1300,7 +1496,7 @@ var State = class _State {
1300
1496
  */
1301
1497
  watermark() {
1302
1498
  try {
1303
- const v = readFileSync3(this.path(".poll-watermark"), "utf8").trim();
1499
+ const v = readFileSync4(this.path(".poll-watermark"), "utf8").trim();
1304
1500
  return v || EPOCH;
1305
1501
  } catch {
1306
1502
  return EPOCH;
@@ -1325,10 +1521,10 @@ var State = class _State {
1325
1521
  }
1326
1522
  /** Whole-crew pause, and the per-role sentinels one level down. */
1327
1523
  isPaused() {
1328
- return existsSync3(this.path(".crew-paused"));
1524
+ return existsSync4(this.path(".crew-paused"));
1329
1525
  }
1330
1526
  isRolePaused(role) {
1331
- return existsSync3(this.path(`.role-paused-${role}`));
1527
+ return existsSync4(this.path(`.role-paused-${role}`));
1332
1528
  }
1333
1529
  pausedRoles(roles) {
1334
1530
  return new Set(roles.filter((r) => this.isRolePaused(r)));
@@ -1337,7 +1533,7 @@ var State = class _State {
1337
1533
  writeFileSync(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
1338
1534
  }
1339
1535
  resume(role) {
1340
- rmSync(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
1536
+ rmSync2(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
1341
1537
  }
1342
1538
  /**
1343
1539
  * The release phase (merge, deploy, release) has its own sentinel, one
@@ -1345,11 +1541,11 @@ var State = class _State {
1345
1541
  * while agents keep working.
1346
1542
  */
1347
1543
  isReleasePaused() {
1348
- return existsSync3(this.path(".release-paused"));
1544
+ return existsSync4(this.path(".release-paused"));
1349
1545
  }
1350
1546
  setReleasePaused(paused) {
1351
1547
  if (paused) writeFileSync(this.path(".release-paused"), "");
1352
- else rmSync(this.path(".release-paused"), { force: true });
1548
+ else rmSync2(this.path(".release-paused"), { force: true });
1353
1549
  }
1354
1550
  /**
1355
1551
  * Why a release cycle must not run right now, or null when it may. Every
@@ -1386,7 +1582,7 @@ var State = class _State {
1386
1582
  const name = _State.safe(rawName);
1387
1583
  const file = this.path(`.${name}.lock`);
1388
1584
  try {
1389
- const existing = Number.parseInt(readFileSync3(file, "utf8").trim(), 10);
1585
+ const existing = Number.parseInt(readFileSync4(file, "utf8").trim(), 10);
1390
1586
  if (Number.isFinite(existing) && existing > 0) {
1391
1587
  try {
1392
1588
  process.kill(existing, 0);
@@ -1402,7 +1598,7 @@ var State = class _State {
1402
1598
  ok: true,
1403
1599
  release: () => {
1404
1600
  try {
1405
- rmSync(file, { force: true });
1601
+ rmSync2(file, { force: true });
1406
1602
  } catch {
1407
1603
  }
1408
1604
  }
@@ -1480,7 +1676,7 @@ var State = class _State {
1480
1676
  const heldReported = `.release-held-${suffix}`;
1481
1677
  const read = (f) => {
1482
1678
  try {
1483
- return readFileSync3(this.path(f), "utf8").trim() || null;
1679
+ return readFileSync4(this.path(f), "utf8").trim() || null;
1484
1680
  } catch {
1485
1681
  return null;
1486
1682
  }
@@ -1494,7 +1690,7 @@ var State = class _State {
1494
1690
  return n;
1495
1691
  },
1496
1692
  clearBlocked: () => {
1497
- rmSync(this.path(blocked), { force: true });
1693
+ rmSync2(this.path(blocked), { force: true });
1498
1694
  },
1499
1695
  /**
1500
1696
  * The commit a deploy last failed on.
@@ -1510,7 +1706,7 @@ var State = class _State {
1510
1706
  `);
1511
1707
  },
1512
1708
  clearDeployFailed: () => {
1513
- rmSync(this.path(failed), { force: true });
1709
+ rmSync2(this.path(failed), { force: true });
1514
1710
  },
1515
1711
  /**
1516
1712
  * Consecutive test-gate failures on the same head (CREW-1368). A failure
@@ -1525,12 +1721,12 @@ var State = class _State {
1525
1721
  return next;
1526
1722
  },
1527
1723
  clearTestGateFailed: () => {
1528
- rmSync(this.path(gateFailed), { force: true });
1724
+ rmSync2(this.path(gateFailed), { force: true });
1529
1725
  },
1530
1726
  /** Whether the board was already told about this head's red gate. */
1531
1727
  heldReported: () => {
1532
1728
  try {
1533
- return readFileSync3(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
1729
+ return readFileSync4(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
1534
1730
  } catch {
1535
1731
  return null;
1536
1732
  }
@@ -1540,7 +1736,7 @@ var State = class _State {
1540
1736
  `);
1541
1737
  },
1542
1738
  clearHeldReported: () => {
1543
- rmSync(this.path(heldReported), { force: true });
1739
+ rmSync2(this.path(heldReported), { force: true });
1544
1740
  },
1545
1741
  testGateReported: (sha) => read(gateReported) === sha,
1546
1742
  noteTestGateReported: (sha) => {
@@ -1567,13 +1763,13 @@ var State = class _State {
1567
1763
  const ticketStreakFile = `.fairness-ticket-streak-${suffix}`;
1568
1764
  const read = (f) => {
1569
1765
  try {
1570
- return readFileSync3(this.path(f), "utf8").trim() || null;
1766
+ return readFileSync4(this.path(f), "utf8").trim() || null;
1571
1767
  } catch {
1572
1768
  return null;
1573
1769
  }
1574
1770
  };
1575
1771
  const clear2 = () => {
1576
- for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync(this.path(f), { force: true });
1772
+ for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync2(this.path(f), { force: true });
1577
1773
  };
1578
1774
  return {
1579
1775
  /** Consecutive cycles this route had actionable work and did not win. */
@@ -1632,7 +1828,7 @@ var State = class _State {
1632
1828
  return {
1633
1829
  previous: () => {
1634
1830
  try {
1635
- const parsed = JSON.parse(readFileSync3(this.path(file), "utf8"));
1831
+ const parsed = JSON.parse(readFileSync4(this.path(file), "utf8"));
1636
1832
  return parsed && typeof parsed === "object" ? parsed : {};
1637
1833
  } catch {
1638
1834
  return {};
@@ -1653,7 +1849,7 @@ var State = class _State {
1653
1849
  return {
1654
1850
  previous: () => {
1655
1851
  try {
1656
- const parsed = JSON.parse(readFileSync3(this.path(file), "utf8"));
1852
+ const parsed = JSON.parse(readFileSync4(this.path(file), "utf8"));
1657
1853
  return Array.isArray(parsed) ? parsed : [];
1658
1854
  } catch {
1659
1855
  return [];
@@ -1677,7 +1873,7 @@ var State = class _State {
1677
1873
  },
1678
1874
  at: () => {
1679
1875
  try {
1680
- return statSync(this.path(file)).mtimeMs;
1876
+ return statSync2(this.path(file)).mtimeMs;
1681
1877
  } catch {
1682
1878
  return null;
1683
1879
  }
@@ -1686,109 +1882,13 @@ var State = class _State {
1686
1882
  }
1687
1883
  readInt(file) {
1688
1884
  try {
1689
- return Number.parseInt(readFileSync3(this.path(file), "utf8").trim(), 10) || 0;
1885
+ return Number.parseInt(readFileSync4(this.path(file), "utf8").trim(), 10) || 0;
1690
1886
  } catch {
1691
1887
  return 0;
1692
1888
  }
1693
1889
  }
1694
1890
  };
1695
1891
 
1696
- // src/events.ts
1697
- import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
1698
- import { join as join4, dirname as dirname2 } from "path";
1699
- function render(e) {
1700
- const scope = e.role ? `${e.step}[${e.role}]` : e.step;
1701
- const ticket = e.ticket ? ` ${e.ticket}` : "";
1702
- const level = e.level === "info" ? "" : `${e.level.toUpperCase()}: `;
1703
- return `${e.at} ${scope}:${ticket} ${level}${e.message}`;
1704
- }
1705
- var Emitter = class _Emitter {
1706
- cycle;
1707
- opts;
1708
- now;
1709
- step = "poll";
1710
- role;
1711
- constructor(opts) {
1712
- this.opts = opts;
1713
- this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
1714
- this.cycle = opts.cycleId ?? this.now().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
1715
- for (const f of [opts.eventFile, opts.logFile]) {
1716
- if (!f) continue;
1717
- try {
1718
- mkdirSync2(dirname2(f), { recursive: true });
1719
- } catch {
1720
- }
1721
- }
1722
- }
1723
- /** Everything emitted after this belongs to `step`, until the next one. */
1724
- enter(step, role) {
1725
- this.step = step;
1726
- this.role = role;
1727
- }
1728
- emit(message, extra = {}) {
1729
- const e = {
1730
- at: this.now().toISOString(),
1731
- cycle: this.cycle,
1732
- route: this.opts.route,
1733
- step: extra.step ?? this.step,
1734
- level: extra.level ?? "info",
1735
- message,
1736
- ...extra.role ?? this.role ? { role: extra.role ?? this.role } : {},
1737
- ...extra.ticket ? { ticket: extra.ticket } : {},
1738
- ...extra.data ? { data: extra.data } : {}
1739
- };
1740
- const line = render(e);
1741
- try {
1742
- if (this.opts.eventFile) appendFileSync(this.opts.eventFile, `${JSON.stringify(e)}
1743
- `);
1744
- } catch {
1745
- }
1746
- try {
1747
- if (this.opts.logFile) appendFileSync(this.opts.logFile, `${line}
1748
- `);
1749
- } catch {
1750
- }
1751
- try {
1752
- (this.opts.console ?? ((l) => process.stderr.write(`${l}
1753
- `)))(line);
1754
- } catch {
1755
- }
1756
- return e;
1757
- }
1758
- warn(message, extra = {}) {
1759
- return this.emit(message, { ...extra, level: "warn" });
1760
- }
1761
- error(message, extra = {}) {
1762
- return this.emit(message, { ...extra, level: "error" });
1763
- }
1764
- /**
1765
- * A view of this emitter labelling every event with a different route,
1766
- * sharing the same sinks and cycle id. For a fleet-wide run, the top-level
1767
- * Emitter is stamped with just one route (see cli.ts) — a phase that
1768
- * actually knows which route it's working (release, per-repo poll) uses
1769
- * this instead, so its events aren't all attributed to `routes[0]`.
1770
- */
1771
- forRoute(route) {
1772
- return new _Emitter({ ...this.opts, route, cycleId: this.cycle });
1773
- }
1774
- /** Times a step and emits its outcome, so a view can show duration. */
1775
- async timed(step, message, fn, role) {
1776
- this.enter(step, role);
1777
- const started = this.now().getTime();
1778
- try {
1779
- const result = await fn();
1780
- this.emit(message, { data: { ms: this.now().getTime() - started, ok: true } });
1781
- return result;
1782
- } catch (err) {
1783
- this.error(`${message} \u2014 ${err.message}`, {
1784
- data: { ms: this.now().getTime() - started, ok: false }
1785
- });
1786
- throw err;
1787
- }
1788
- }
1789
- };
1790
- var eventFileFor = (stateDir) => join4(stateDir, "events.jsonl");
1791
-
1792
1892
  // src/tracker.ts
1793
1893
  import { hostname } from "os";
1794
1894
  import { TablationClient } from "@tablation/client";
@@ -2088,7 +2188,7 @@ function describeRelease(o, route) {
2088
2188
  // src/install.ts
2089
2189
  import { dirname as dirname3, join as join5, extname } from "path";
2090
2190
  import { homedir as homedir2 } from "os";
2091
- import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync5, statSync as statSync2, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
2191
+ import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync5, statSync as statSync3, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
2092
2192
  import { spawnSync } from "child_process";
2093
2193
  import { createHash } from "crypto";
2094
2194
 
@@ -2142,7 +2242,7 @@ function findOnPath(bin, searchPath) {
2142
2242
  for (const candidate of candidates) {
2143
2243
  try {
2144
2244
  accessSync(candidate, constants.X_OK);
2145
- if (statSync2(candidate).isFile()) return candidate;
2245
+ if (statSync3(candidate).isFile()) return candidate;
2146
2246
  } catch {
2147
2247
  }
2148
2248
  }
@@ -2605,10 +2705,10 @@ import { execFileSync } from "child_process";
2605
2705
  import {
2606
2706
  existsSync as existsSync6,
2607
2707
  mkdirSync as mkdirSync4,
2608
- openSync,
2609
- closeSync,
2708
+ openSync as openSync2,
2709
+ closeSync as closeSync2,
2610
2710
  writeSync,
2611
- statSync as statSync3,
2711
+ statSync as statSync4,
2612
2712
  unlinkSync as unlinkSync2
2613
2713
  } from "fs";
2614
2714
  import { dirname as dirname4, basename as basename3 } from "path";
@@ -3004,10 +3104,10 @@ function acquireCheckoutLock(dir, timeoutMs = 3e4) {
3004
3104
  const deadline = Date.now() + timeoutMs;
3005
3105
  for (; ; ) {
3006
3106
  try {
3007
- const fd = openSync(lockPath, "wx");
3107
+ const fd = openSync2(lockPath, "wx");
3008
3108
  writeSync(fd, `${process.pid}
3009
3109
  `);
3010
- closeSync(fd);
3110
+ closeSync2(fd);
3011
3111
  return () => {
3012
3112
  try {
3013
3113
  unlinkSync2(lockPath);
@@ -3018,7 +3118,7 @@ function acquireCheckoutLock(dir, timeoutMs = 3e4) {
3018
3118
  const err = e;
3019
3119
  if (err.code !== "EEXIST") throw err;
3020
3120
  try {
3021
- if (Date.now() - statSync3(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
3121
+ if (Date.now() - statSync4(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
3022
3122
  try {
3023
3123
  unlinkSync2(lockPath);
3024
3124
  } catch {
@@ -4661,6 +4761,7 @@ ${waiting.map((t) => `| ${displayKey2(t)} | ${t.status} | ${blockers(t, i)} |`).
4661
4761
  }
4662
4762
 
4663
4763
  // src/ticket-branch.ts
4764
+ import { basename as basename4 } from "path";
4664
4765
  function branchRenderer(t, role) {
4665
4766
  const { tag, prefix } = ticketBranchContext(t);
4666
4767
  return (template) => renderBranchName(template, { key: t.issue_id, title: t.title ?? void 0, role, tag, prefix });
@@ -4669,7 +4770,15 @@ function worktreeForTicket(dir, cfg, t, role) {
4669
4770
  const { prefix } = ticketBranchContext(t);
4670
4771
  const branchName = branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
4671
4772
  const number = t.issue_id.replace(/^\D+/, "");
4672
- return effectiveWorktreeDirName(cfg, dir, branchName, number);
4773
+ const { tag } = ticketBranchContext(t);
4774
+ return effectiveWorktreeDirName(cfg, dir, branchName, number, (template) => renderWorktreeName(template, {
4775
+ dir: basename4(dir.replace(/[/\\]+$/, "")),
4776
+ key: t.issue_id,
4777
+ title: t.title ?? void 0,
4778
+ role,
4779
+ tag,
4780
+ prefix
4781
+ }));
4673
4782
  }
4674
4783
  function branchTemplates(cfg, t) {
4675
4784
  const { prefix } = ticketBranchContext(t);
@@ -4985,7 +5094,9 @@ function writeDigest(o, d, role, stateDir) {
4985
5094
  const repoFor = (dir) => {
4986
5095
  const hit = repoCache.get(dir);
4987
5096
  if (hit) return hit;
4988
- const cfg = resolveRepoConfig(loadRepoConfig(dir), void 0, dir);
5097
+ const target = reposOf(o.route).find((r) => r.dir === dir);
5098
+ const nameOverride = target ? o.route.repoOverrides[target.name]?.worktreeName : void 0;
5099
+ const cfg = resolveRepoConfig(loadRepoConfig(dir), nameOverride ? { worktrees: { nameOverride } } : void 0, dir);
4989
5100
  repoCache.set(dir, cfg);
4990
5101
  return cfg;
4991
5102
  };
@@ -5344,13 +5455,13 @@ var alreadyReported = (ticket, g, comments) => comments.some((c) => c.ticket_id
5344
5455
 
5345
5456
  // src/agent.ts
5346
5457
  import { spawn as spawn2 } from "child_process";
5347
- import { readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync4, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
5458
+ import { readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
5348
5459
  import { finished } from "stream/promises";
5349
5460
  import { join as join7, dirname as dirname5 } from "path";
5350
5461
  import { createHash as createHash2 } from "crypto";
5351
5462
 
5352
5463
  // src/environment.ts
5353
- import { basename as basename4 } from "path";
5464
+ import { basename as basename5 } from "path";
5354
5465
  var API_KEY_VAR = "CREW_API_KEY";
5355
5466
  function authorshipBullets(a, contract) {
5356
5467
  if (!a) return [];
@@ -5401,8 +5512,16 @@ function repoSection(r, key, heading) {
5401
5512
  const repo = r.config;
5402
5513
  const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
5403
5514
  const branchName = branchExample(repo, key);
5404
- const worktreeName = effectiveWorktreeDirName(repo, r.dir, branchName, key.replace(/^\D+/, ""));
5405
- const worktreePattern = repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${basename4(r.dir.replace(/[/\\]+$/, ""))}-<branch>`;
5515
+ const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
5516
+ const worktreeName = effectiveWorktreeDirName(
5517
+ repo,
5518
+ r.dir,
5519
+ branchName,
5520
+ key.replace(/^\D+/, ""),
5521
+ // The example ticket has no tag of its own, so `{issue}` is the key.
5522
+ (template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
5523
+ );
5524
+ const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
5406
5525
  const lines = [
5407
5526
  "",
5408
5527
  heading,
@@ -5807,7 +5926,7 @@ function planAgentRun(o) {
5807
5926
  let digest;
5808
5927
  let ageSeconds;
5809
5928
  if (existsSync7(digestPath2)) {
5810
- ageSeconds = Math.floor((now() - statSync4(digestPath2).mtimeMs) / 1e3);
5929
+ ageSeconds = Math.floor((now() - statSync5(digestPath2).mtimeMs) / 1e3);
5811
5930
  if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync6(digestPath2, "utf8");
5812
5931
  }
5813
5932
  const common = readFileSync6(commonPath, "utf8");
@@ -6375,7 +6494,8 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
6375
6494
  const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
6376
6495
  hooks: { ...route.hooks, ...o?.hooks },
6377
6496
  labels: { ...route.labels, ...o?.labels },
6378
- branch: { ...route.branch, ...o?.branch }
6497
+ branch: { ...route.branch, ...o?.branch },
6498
+ worktrees: { nameOverride: o?.worktreeName }
6379
6499
  }, target.dir);
6380
6500
  if (role === "qa") {
6381
6501
  await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
@@ -6537,7 +6657,7 @@ Wrote ${o.configPath}.
6537
6657
  }
6538
6658
 
6539
6659
  // src/release-run.ts
6540
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync2 } from "fs";
6660
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync3 } from "fs";
6541
6661
  import { dirname as dirname8, join as join11 } from "path";
6542
6662
  import { tmpdir as tmpdir2 } from "os";
6543
6663
 
@@ -7320,7 +7440,7 @@ function openReleaseCheckout(o) {
7320
7440
  const dir = join11(root, "checkout");
7321
7441
  const close = () => {
7322
7442
  gitOk(o.cwd, ["worktree", "remove", "--force", dir]);
7323
- rmSync2(root, { recursive: true, force: true });
7443
+ rmSync3(root, { recursive: true, force: true });
7324
7444
  gitOk(o.cwd, ["worktree", "prune"]);
7325
7445
  gitOk(o.cwd, ["branch", "-D", branch]);
7326
7446
  };
@@ -7803,7 +7923,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
7803
7923
  }
7804
7924
 
7805
7925
  // src/watch.ts
7806
- import { watch as watchFile, existsSync as existsSync12, statSync as statSync5, openSync as openSync2, readSync, closeSync as closeSync2 } from "fs";
7926
+ import { watch as watchFile, existsSync as existsSync12, statSync as statSync6, openSync as openSync3, readSync, closeSync as closeSync3 } from "fs";
7807
7927
  var E = "\x1B[";
7808
7928
  var dim = (s) => `${E}2m${s}${E}0m`;
7809
7929
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -7905,13 +8025,13 @@ function renderHeaders(states, now = Date.now()) {
7905
8025
  }
7906
8026
  function readFrom(file, from) {
7907
8027
  if (!existsSync12(file)) return { events: [], offset: from };
7908
- const size = statSync5(file).size;
8028
+ const size = statSync6(file).size;
7909
8029
  if (size < from) return readFrom(file, 0);
7910
8030
  if (size === from) return { events: [], offset: from };
7911
- const fd = openSync2(file, "r");
8031
+ const fd = openSync3(file, "r");
7912
8032
  const buf = Buffer.alloc(size - from);
7913
8033
  readSync(fd, buf, 0, buf.length, from);
7914
- closeSync2(fd);
8034
+ closeSync3(fd);
7915
8035
  const text = buf.toString("utf8");
7916
8036
  const lastNewline = text.lastIndexOf("\n");
7917
8037
  if (lastNewline === -1) return { events: [], offset: from };
@@ -9032,7 +9152,7 @@ async function showLogEntry(route, entryId, opts = {}) {
9032
9152
 
9033
9153
  // src/repos-cmd.ts
9034
9154
  import { homedir as homedir3 } from "os";
9035
- import { basename as basename5, resolve as resolve3 } from "path";
9155
+ import { basename as basename6, resolve as resolve3 } from "path";
9036
9156
  import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
9037
9157
  var ReposError = class extends Error {
9038
9158
  };
@@ -9104,7 +9224,7 @@ function configuredRepos(text, route, base) {
9104
9224
  });
9105
9225
  }
9106
9226
  const dir = m.get("dir");
9107
- return dir ? [{ name: basename5(String(dir)), dir: expandDir(String(dir), base) }] : [];
9227
+ return dir ? [{ name: basename6(String(dir)), dir: expandDir(String(dir), base) }] : [];
9108
9228
  }
9109
9229
  function planRepoAdd(i) {
9110
9230
  const doc = parseDocument(i.text);
@@ -9116,7 +9236,7 @@ function planRepoAdd(i) {
9116
9236
  throw new ReposError(`${target} is already configured on route ${i.route}`);
9117
9237
  }
9118
9238
  const matched = matchRepoName(i.origin, i.resolved);
9119
- const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename5(target);
9239
+ const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename6(target);
9120
9240
  let warning;
9121
9241
  if (!matched) {
9122
9242
  const known = Object.values(i.resolved?.repoNames ?? {});
@@ -9280,14 +9400,14 @@ function schedulerInstalled(plan, exists, crontab) {
9280
9400
 
9281
9401
  // src/worktree-sweep.ts
9282
9402
  import { existsSync as existsSync15 } from "fs";
9283
- import { basename as basename6 } from "path";
9403
+ import { basename as basename7 } from "path";
9284
9404
  function planWorktreeSweep(target, tickets, contract) {
9285
9405
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
9286
9406
  const verified = contract.statuses.verified;
9287
9407
  const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
9288
9408
  const actions = [];
9289
9409
  for (const w of worktrees(target.dir)) {
9290
- const m = /(?:^|-)(\d+)$/.exec(basename6(w.path));
9410
+ const m = /(?:^|-)(\d+)$/.exec(basename7(w.path));
9291
9411
  if (!m) continue;
9292
9412
  const ticket = byNumber.get(m[1]);
9293
9413
  if (!ticket) continue;
@@ -9402,7 +9522,7 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
9402
9522
  }
9403
9523
 
9404
9524
  // src/stream-sweep.ts
9405
- import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
9525
+ import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync4, statSync as statSync7 } from "fs";
9406
9526
  import { join as join15 } from "path";
9407
9527
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
9408
9528
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
@@ -9411,7 +9531,7 @@ function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
9411
9531
  const cutoff = now - retentionDays * DAY_MS2;
9412
9532
  return readdirSync3(dir).map((name) => join15(dir, name)).filter((path) => {
9413
9533
  try {
9414
- return statSync6(path).mtimeMs < cutoff;
9534
+ return statSync7(path).mtimeMs < cutoff;
9415
9535
  } catch {
9416
9536
  return false;
9417
9537
  }
@@ -9425,7 +9545,7 @@ function applyStreamSweep(paths, dryRun, log) {
9425
9545
  continue;
9426
9546
  }
9427
9547
  try {
9428
- rmSync3(path, { force: true });
9548
+ rmSync4(path, { force: true });
9429
9549
  removed++;
9430
9550
  log.emit(`removed stream artifact ${path}`, { step: "worktree" });
9431
9551
  } catch (e) {
@@ -9434,9 +9554,20 @@ function applyStreamSweep(paths, dryRun, log) {
9434
9554
  }
9435
9555
  return removed;
9436
9556
  }
9557
+ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
9558
+ if (!existsSync16(stateDir)) return [];
9559
+ const cutoff = now - retentionDays * DAY_MS2;
9560
+ return readdirSync3(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join15(stateDir, name)).filter((path) => {
9561
+ try {
9562
+ return statSync7(path).mtimeMs < cutoff;
9563
+ } catch {
9564
+ return false;
9565
+ }
9566
+ }).sort();
9567
+ }
9437
9568
 
9438
9569
  // src/cli.ts
9439
- import { readFileSync as readFileSync14, existsSync as existsSync20, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync } from "fs";
9570
+ import { readFileSync as readFileSync14, existsSync as existsSync20, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
9440
9571
  import { createInterface } from "readline/promises";
9441
9572
  import { dirname as dirOf, resolve as resolvePath } from "path";
9442
9573
 
@@ -10174,7 +10305,7 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
10174
10305
  }
10175
10306
 
10176
10307
  // src/daemon.ts
10177
- import { readdirSync as readdirSync6, statSync as statSync7 } from "fs";
10308
+ import { readdirSync as readdirSync6, statSync as statSync8 } from "fs";
10178
10309
  import { join as join19 } from "path";
10179
10310
  var DEFAULT_FLOOR_MS = 5e3;
10180
10311
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
@@ -10206,7 +10337,7 @@ async function runDaemonLoop(o) {
10206
10337
  function latestMtime(path) {
10207
10338
  let stat;
10208
10339
  try {
10209
- stat = statSync7(path);
10340
+ stat = statSync8(path);
10210
10341
  } catch {
10211
10342
  return 0;
10212
10343
  }
@@ -10490,7 +10621,8 @@ async function buildEnvironment(route, ship, ticket) {
10490
10621
  config: resolveRepoConfig(loadRepoConfig(t.dir), {
10491
10622
  hooks: { ...route.hooks, ...o?.hooks },
10492
10623
  labels: { ...route.labels, ...o?.labels },
10493
- branch: { ...route.branch, ...o?.branch }
10624
+ branch: { ...route.branch, ...o?.branch },
10625
+ worktrees: { nameOverride: o?.worktreeName }
10494
10626
  }, t.dir)
10495
10627
  };
10496
10628
  });
@@ -10802,6 +10934,7 @@ if (command === "passenger-sync-daemon") {
10802
10934
  const emit = new Emitter({
10803
10935
  route: route?.route ?? "",
10804
10936
  eventFile: dryRun ? void 0 : eventFileFor(cfg.ship.stateDir),
10937
+ rotateBytes: cfg.ship.eventsRotateBytes,
10805
10938
  logFile: dryRun ? void 0 : cfg.ship.logFile,
10806
10939
  console: (l) => process.stderr.write(`${l}
10807
10940
  `)
@@ -10904,7 +11037,10 @@ if (command === "passenger-sync-daemon") {
10904
11037
  emitReleaseSummary(emit, summaries);
10905
11038
  if (!dryRun) state.releaseHeartbeat().touch();
10906
11039
  try {
10907
- const paths = planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays);
11040
+ const paths = [
11041
+ ...planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays),
11042
+ ...planEventsSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays)
11043
+ ];
10908
11044
  if (paths.length) {
10909
11045
  emit.enter("worktree");
10910
11046
  const removed = applyStreamSweep(paths, dryRun, emit);
@@ -11085,7 +11221,7 @@ ${changed2.map((c) => ` - ${c}
11085
11221
  // that declares its own `worktrees.prefix` shadows either, and
11086
11222
  // `doctor` reports that the way it reports every other shadowed
11087
11223
  // setting.
11088
- worktrees: { prefix: shipWorktreePrefixFor(c) ?? await areaWorktreePrefix(c, id) }
11224
+ worktrees: { prefix: shipWorktreePrefixFor(c) ?? await areaWorktreePrefix(c, id), nameOverride: o?.worktreeName }
11089
11225
  }, t.dir)
11090
11226
  };
11091
11227
  }));
@@ -11142,7 +11278,7 @@ ${changed2.map((c) => ` - ${c}
11142
11278
  labels: { ...c.labels, ...o?.labels },
11143
11279
  release: mergeRouteRelease(c.release, o?.release),
11144
11280
  branch: { ...c.branch, ...o?.branch },
11145
- worktrees: { prefix: shipWorktreePrefixFor(c) }
11281
+ worktrees: { prefix: shipWorktreePrefixFor(c), nameOverride: o?.worktreeName }
11146
11282
  }, target.dir);
11147
11283
  const problems = validateEffective(repo);
11148
11284
  for (const p of problems) remit.warn(`${scope}: ${p}`);
@@ -11956,6 +12092,7 @@ ${changed2.map((c) => ` - ${c}
11956
12092
  newEmitter: () => new Emitter({
11957
12093
  route: route.route,
11958
12094
  eventFile: eventFileFor(cfg.ship.stateDir),
12095
+ rotateBytes: cfg.ship.eventsRotateBytes,
11959
12096
  logFile: cfg.ship.logFile,
11960
12097
  console: (l) => process.stderr.write(`${l}
11961
12098
  `)
@@ -12565,7 +12702,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12565
12702
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
12566
12703
  writeFileSync9(tmpPath, `${JSON.stringify(resolved, null, 2)}
12567
12704
  `);
12568
- renameSync(tmpPath, resolvedPath);
12705
+ renameSync2(tmpPath, resolvedPath);
12569
12706
  process.stderr.write(`Resolved ids written to ${resolvedPath}
12570
12707
  `);
12571
12708
  }
@@ -12782,7 +12919,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12782
12919
  { step: "worktree" }
12783
12920
  );
12784
12921
  }
12785
- const streamPaths = planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays);
12922
+ if (!dryRun) {
12923
+ const rotated = rotateEventsFile(eventFileFor(cfg.ship.stateDir), cfg.ship.eventsRotateBytes);
12924
+ if (rotated) {
12925
+ anything = true;
12926
+ emit.emit(`rotated oversized event log to ${rotated}`, { step: "worktree" });
12927
+ }
12928
+ }
12929
+ const streamPaths = [
12930
+ ...planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays),
12931
+ ...planEventsSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays)
12932
+ ];
12786
12933
  if (streamPaths.length) {
12787
12934
  anything = true;
12788
12935
  const removed = applyStreamSweep(streamPaths, dryRun, emit);
@@ -13195,7 +13342,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13195
13342
  // `synthesis-tabl-123`. `worktreePrefix` above is kept for
13196
13343
  // config-provenance display; this is the field that actually
13197
13344
  // describes where a worktree lands by default.
13198
- worktreeDirPattern: r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${basename7(r.dir.replace(/[/\\]+$/, ""))}-<branch>`,
13345
+ worktreeDirPattern: r.config.worktrees.name ?? (r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${basename8(r.dir.replace(/[/\\]+$/, ""))}-<branch>`),
13199
13346
  worktreeParent: resolvePath(r.dir, "..")
13200
13347
  })),
13201
13348
  waiting: waiting ? { ticket: waiting.ticket, since: waiting.since, streak: fairness.streak() } : null
@@ -13285,6 +13432,15 @@ watermark: ${state.watermark()}
13285
13432
  routes: ${cfg.routes.map((c) => c.route).join(", ")}
13286
13433
  `
13287
13434
  );
13435
+ try {
13436
+ const evFile = eventFileFor(cfg.ship.stateDir);
13437
+ const evSize = existsSync20(evFile) ? statSync9(evFile).size : 0;
13438
+ process.stdout.write(
13439
+ evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
13440
+ ` : "events log: ok\n"
13441
+ );
13442
+ } catch {
13443
+ }
13288
13444
  const tracker = new Tracker(route, cfg.ship);
13289
13445
  const contractProblems = validateContract(tracker.contract);
13290
13446
  process.stdout.write(
@@ -13294,10 +13450,10 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
13294
13450
  );
13295
13451
  for (const r of await resolvedRepos(route)) {
13296
13452
  const { config } = r;
13297
- const worktreePattern = config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename7(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`;
13453
+ const worktreePattern = config.worktrees.name ?? (config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename8(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`);
13298
13454
  process.stdout.write(
13299
13455
  `${`repo ${r.name}:`.padEnd(19)}${r.dir}
13300
- worktrees at ../${worktreePattern} (${config.provenance["worktrees.prefix"] ?? "default"}), branch ${config.branch.name}
13456
+ worktrees at ../${worktreePattern} (${config.provenance["worktrees.name"] ?? config.provenance["worktrees.prefix"] ?? "default"}), branch ${config.branch.name}
13301
13457
  requires ${config.platform} \u2014 ${satisfies(host, config.platform) ? "OK" : "MISMATCH"}
13302
13458
  `
13303
13459
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.25.0",
3
+ "version": "0.27.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",