@tablation/crew 0.25.0 → 0.26.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,7 +1,7 @@
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";
@@ -84,6 +84,27 @@ var TOP_LEVEL = /* @__PURE__ */ new Set([
84
84
  ]);
85
85
  var BRANCH_KEYS = /* @__PURE__ */ new Set(["base", "name", "push", "remote"]);
86
86
  var PLACEHOLDER = /\{(key|number|slug|role|prefix|tag)\}/g;
87
+ var WORKTREE_PLACEHOLDERS = ["dir", "issue", "number", "key", "slug", "role", "prefix", "tag"];
88
+ function worktreeNameProblem(template) {
89
+ const valid = `Valid placeholders: ${WORKTREE_PLACEHOLDERS.map((p) => `{${p}}`).join(" ")}.`;
90
+ if (template.trim() === "") return `must be a non-empty string. ${valid}`;
91
+ if (/[/\\]/.test(template)) {
92
+ return "must not contain a path separator \u2014 a worktree is cut beside the checkout, not inside a directory of its own";
93
+ }
94
+ const stripped = template.replace(/\{([^{}]*)\}/g, (_m, name) => {
95
+ if (!WORKTREE_PLACEHOLDERS.includes(name)) return "\0";
96
+ return "";
97
+ });
98
+ if (stripped.includes("\0")) {
99
+ const bad = [...template.matchAll(/\{([^{}]*)\}/g)].map((m) => m[1] ?? "").filter((n) => !WORKTREE_PLACEHOLDERS.includes(n));
100
+ return `names unknown placeholder ${bad.map((b) => `{${b}}`).join(", ")}. ${valid}`;
101
+ }
102
+ if (/[{}]/.test(stripped)) return `has an unbalanced brace. ${valid}`;
103
+ if (!/\{(issue|number|key|slug|tag)\}/.test(template)) {
104
+ return "names no per-ticket placeholder \u2014 every ticket would get the same worktree. Use at least one of {issue} {number} {key} {slug} {tag}.";
105
+ }
106
+ return null;
107
+ }
87
108
  var RELEASE_KEYS = /* @__PURE__ */ new Set([
88
109
  "mode",
89
110
  "ship",
@@ -132,7 +153,7 @@ function parseRepoConfig(text, file) {
132
153
  rejectUnknown(raw.release?.ci, CI_KEYS, "release.ci", file);
133
154
  rejectUnknown(raw.release?.verify, VERIFY_KEYS, "release.verify", file);
134
155
  rejectUnknown(raw.branch, BRANCH_KEYS, "branch", file);
135
- rejectUnknown(raw.worktrees, /* @__PURE__ */ new Set(["copy", "prefix"]), "worktrees", file);
156
+ rejectUnknown(raw.worktrees, /* @__PURE__ */ new Set(["copy", "prefix", "name"]), "worktrees", file);
136
157
  rejectUnknown(raw.docs, /* @__PURE__ */ new Set(["triagePolicy", "designGuide"]), "docs", file);
137
158
  if (raw.version === void 0) throw new RepoConfigError(`${file}: version is required`);
138
159
  if (raw.version !== SPEC_VERSION) {
@@ -167,7 +188,13 @@ function parseRepoConfig(text, file) {
167
188
  `${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
189
  );
169
190
  }
191
+ const nameRaw = raw.worktrees?.name;
192
+ if (nameRaw !== void 0) {
193
+ const problem = typeof nameRaw === "string" ? worktreeNameProblem(nameRaw) : "must be a string";
194
+ if (problem) throw new RepoConfigError(`${file}: worktrees.name ("${String(nameRaw)}") ${problem}`);
195
+ }
170
196
  const worktrees2 = {
197
+ ...nameRaw !== void 0 ? { name: String(nameRaw) } : {},
171
198
  copy: (copyRaw ?? []).map((c) => String(c)),
172
199
  // Empty means "not declared" here; the effective config fills it in, since
173
200
  // only that layer knows the ship's setting and the checkout's directory.
@@ -358,6 +385,10 @@ function renderBranchName(template, ctx) {
358
385
  }
359
386
  });
360
387
  }
388
+ function renderWorktreeName(template, ctx) {
389
+ const issue = (ctx.tag ?? ctx.key).toLowerCase();
390
+ return renderBranchName(template.replace(/\{dir\}/g, ctx.dir).replace(/\{issue\}/g, issue), ctx);
391
+ }
361
392
  function defaultWorktreePrefix(dir) {
362
393
  const name = basename(dir.replace(/[/\\]+$/, ""));
363
394
  return name ? `${name}-issue-` : "issue-";
@@ -389,6 +420,10 @@ function resolveRepoConfig(repo, ship, dir) {
389
420
  provenance[key] = "default";
390
421
  return fallback;
391
422
  };
423
+ if (ship?.worktrees?.nameOverride) {
424
+ if (repo?.worktrees.name) shadowed.push("worktrees.name");
425
+ provenance["worktrees.name"] = "ship";
426
+ } else if (repo?.worktrees.name) provenance["worktrees.name"] = "repo";
392
427
  const hooks = {};
393
428
  const labels = {};
394
429
  for (const h of HOOK_NAMES) {
@@ -424,7 +459,8 @@ function resolveRepoConfig(repo, ship, dir) {
424
459
  repo?.worktrees.prefix || void 0,
425
460
  ship?.worktrees?.prefix,
426
461
  defaultWorktreePrefix(dir)
427
- )
462
+ ),
463
+ ...ship?.worktrees?.nameOverride || repo?.worktrees.name ? { name: ship?.worktrees?.nameOverride || repo?.worktrees.name } : {}
428
464
  },
429
465
  docs: repo?.docs ?? {},
430
466
  branch: {
@@ -525,7 +561,8 @@ function effectiveBranchTemplate(cfg, prefix) {
525
561
  if (cfg.provenance["branch.name"] !== "default") return cfg.branch.name;
526
562
  return prefix ? "{prefix}-{number}" : "issue-{number}";
527
563
  }
528
- function effectiveWorktreeDirName(cfg, dir, branchName, number) {
564
+ function effectiveWorktreeDirName(cfg, dir, branchName, number, renderName) {
565
+ if (cfg.worktrees.name && renderName) return renderName(cfg.worktrees.name);
529
566
  if (cfg.provenance["worktrees.prefix"] !== "default") return `${cfg.worktrees.prefix}${number}`;
530
567
  return `${basename(dir.replace(/[/\\]+$/, ""))}-${branchName}`;
531
568
  }
@@ -623,7 +660,12 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
623
660
  "promptSet",
624
661
  "hostPassengers"
625
662
  ]);
626
- var REPO_ENTRY_KEYS = /* @__PURE__ */ new Set(["dir", "hooks", "labels", "release", "branch", "hostPassengers"]);
663
+ function parseWorktreeName(v, where, file) {
664
+ const problem = typeof v === "string" ? worktreeNameProblem(v) : "must be a string";
665
+ if (problem) throw new ConfigError(`${file}: ${where}.worktreeName ("${String(v)}") ${problem}`);
666
+ return String(v);
667
+ }
668
+ var REPO_ENTRY_KEYS = /* @__PURE__ */ new Set(["dir", "hooks", "labels", "release", "branch", "hostPassengers", "worktreeName"]);
627
669
  var BRANCH_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["base", "name", "push", "remote"]);
628
670
  var RELEASE_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
629
671
  "mode",
@@ -959,7 +1001,7 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
959
1001
  const e = entry;
960
1002
  rejectUnknownKeys(e, REPO_ENTRY_KEYS, `${where}.repos.${name}`, file);
961
1003
  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) {
1004
+ if (e.hooks || e.labels || e.release || e.branch || e.hostPassengers !== void 0 || e.worktreeName !== void 0) {
963
1005
  repoOverrides[name] = {
964
1006
  hooks: e.hooks,
965
1007
  labels: e.labels,
@@ -969,7 +1011,8 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
969
1011
  // shape as one that never had the key, and existing callers
970
1012
  // compare this literally.
971
1013
  ...e.branch ? { branch: parseBranchOverride(e.branch, `${where}.repos.${name}`, file) } : {},
972
- ...e.hostPassengers !== void 0 ? { hostPassengers: e.hostPassengers === true } : {}
1014
+ ...e.hostPassengers !== void 0 ? { hostPassengers: e.hostPassengers === true } : {},
1015
+ ...e.worktreeName !== void 0 ? { worktreeName: parseWorktreeName(e.worktreeName, `${where}.repos.${name}`, file) } : {}
973
1016
  };
974
1017
  }
975
1018
  } else {
@@ -4661,6 +4704,7 @@ ${waiting.map((t) => `| ${displayKey2(t)} | ${t.status} | ${blockers(t, i)} |`).
4661
4704
  }
4662
4705
 
4663
4706
  // src/ticket-branch.ts
4707
+ import { basename as basename4 } from "path";
4664
4708
  function branchRenderer(t, role) {
4665
4709
  const { tag, prefix } = ticketBranchContext(t);
4666
4710
  return (template) => renderBranchName(template, { key: t.issue_id, title: t.title ?? void 0, role, tag, prefix });
@@ -4669,7 +4713,15 @@ function worktreeForTicket(dir, cfg, t, role) {
4669
4713
  const { prefix } = ticketBranchContext(t);
4670
4714
  const branchName = branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
4671
4715
  const number = t.issue_id.replace(/^\D+/, "");
4672
- return effectiveWorktreeDirName(cfg, dir, branchName, number);
4716
+ const { tag } = ticketBranchContext(t);
4717
+ return effectiveWorktreeDirName(cfg, dir, branchName, number, (template) => renderWorktreeName(template, {
4718
+ dir: basename4(dir.replace(/[/\\]+$/, "")),
4719
+ key: t.issue_id,
4720
+ title: t.title ?? void 0,
4721
+ role,
4722
+ tag,
4723
+ prefix
4724
+ }));
4673
4725
  }
4674
4726
  function branchTemplates(cfg, t) {
4675
4727
  const { prefix } = ticketBranchContext(t);
@@ -4985,7 +5037,9 @@ function writeDigest(o, d, role, stateDir) {
4985
5037
  const repoFor = (dir) => {
4986
5038
  const hit = repoCache.get(dir);
4987
5039
  if (hit) return hit;
4988
- const cfg = resolveRepoConfig(loadRepoConfig(dir), void 0, dir);
5040
+ const target = reposOf(o.route).find((r) => r.dir === dir);
5041
+ const nameOverride = target ? o.route.repoOverrides[target.name]?.worktreeName : void 0;
5042
+ const cfg = resolveRepoConfig(loadRepoConfig(dir), nameOverride ? { worktrees: { nameOverride } } : void 0, dir);
4989
5043
  repoCache.set(dir, cfg);
4990
5044
  return cfg;
4991
5045
  };
@@ -5350,7 +5404,7 @@ import { join as join7, dirname as dirname5 } from "path";
5350
5404
  import { createHash as createHash2 } from "crypto";
5351
5405
 
5352
5406
  // src/environment.ts
5353
- import { basename as basename4 } from "path";
5407
+ import { basename as basename5 } from "path";
5354
5408
  var API_KEY_VAR = "CREW_API_KEY";
5355
5409
  function authorshipBullets(a, contract) {
5356
5410
  if (!a) return [];
@@ -5401,8 +5455,16 @@ function repoSection(r, key, heading) {
5401
5455
  const repo = r.config;
5402
5456
  const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
5403
5457
  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>`;
5458
+ const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
5459
+ const worktreeName = effectiveWorktreeDirName(
5460
+ repo,
5461
+ r.dir,
5462
+ branchName,
5463
+ key.replace(/^\D+/, ""),
5464
+ // The example ticket has no tag of its own, so `{issue}` is the key.
5465
+ (template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
5466
+ );
5467
+ const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
5406
5468
  const lines = [
5407
5469
  "",
5408
5470
  heading,
@@ -6375,7 +6437,8 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
6375
6437
  const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
6376
6438
  hooks: { ...route.hooks, ...o?.hooks },
6377
6439
  labels: { ...route.labels, ...o?.labels },
6378
- branch: { ...route.branch, ...o?.branch }
6440
+ branch: { ...route.branch, ...o?.branch },
6441
+ worktrees: { nameOverride: o?.worktreeName }
6379
6442
  }, target.dir);
6380
6443
  if (role === "qa") {
6381
6444
  await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
@@ -9032,7 +9095,7 @@ async function showLogEntry(route, entryId, opts = {}) {
9032
9095
 
9033
9096
  // src/repos-cmd.ts
9034
9097
  import { homedir as homedir3 } from "os";
9035
- import { basename as basename5, resolve as resolve3 } from "path";
9098
+ import { basename as basename6, resolve as resolve3 } from "path";
9036
9099
  import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
9037
9100
  var ReposError = class extends Error {
9038
9101
  };
@@ -9104,7 +9167,7 @@ function configuredRepos(text, route, base) {
9104
9167
  });
9105
9168
  }
9106
9169
  const dir = m.get("dir");
9107
- return dir ? [{ name: basename5(String(dir)), dir: expandDir(String(dir), base) }] : [];
9170
+ return dir ? [{ name: basename6(String(dir)), dir: expandDir(String(dir), base) }] : [];
9108
9171
  }
9109
9172
  function planRepoAdd(i) {
9110
9173
  const doc = parseDocument(i.text);
@@ -9116,7 +9179,7 @@ function planRepoAdd(i) {
9116
9179
  throw new ReposError(`${target} is already configured on route ${i.route}`);
9117
9180
  }
9118
9181
  const matched = matchRepoName(i.origin, i.resolved);
9119
- const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename5(target);
9182
+ const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename6(target);
9120
9183
  let warning;
9121
9184
  if (!matched) {
9122
9185
  const known = Object.values(i.resolved?.repoNames ?? {});
@@ -9280,14 +9343,14 @@ function schedulerInstalled(plan, exists, crontab) {
9280
9343
 
9281
9344
  // src/worktree-sweep.ts
9282
9345
  import { existsSync as existsSync15 } from "fs";
9283
- import { basename as basename6 } from "path";
9346
+ import { basename as basename7 } from "path";
9284
9347
  function planWorktreeSweep(target, tickets, contract) {
9285
9348
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
9286
9349
  const verified = contract.statuses.verified;
9287
9350
  const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
9288
9351
  const actions = [];
9289
9352
  for (const w of worktrees(target.dir)) {
9290
- const m = /(?:^|-)(\d+)$/.exec(basename6(w.path));
9353
+ const m = /(?:^|-)(\d+)$/.exec(basename7(w.path));
9291
9354
  if (!m) continue;
9292
9355
  const ticket = byNumber.get(m[1]);
9293
9356
  if (!ticket) continue;
@@ -10490,7 +10553,8 @@ async function buildEnvironment(route, ship, ticket) {
10490
10553
  config: resolveRepoConfig(loadRepoConfig(t.dir), {
10491
10554
  hooks: { ...route.hooks, ...o?.hooks },
10492
10555
  labels: { ...route.labels, ...o?.labels },
10493
- branch: { ...route.branch, ...o?.branch }
10556
+ branch: { ...route.branch, ...o?.branch },
10557
+ worktrees: { nameOverride: o?.worktreeName }
10494
10558
  }, t.dir)
10495
10559
  };
10496
10560
  });
@@ -11085,7 +11149,7 @@ ${changed2.map((c) => ` - ${c}
11085
11149
  // that declares its own `worktrees.prefix` shadows either, and
11086
11150
  // `doctor` reports that the way it reports every other shadowed
11087
11151
  // setting.
11088
- worktrees: { prefix: shipWorktreePrefixFor(c) ?? await areaWorktreePrefix(c, id) }
11152
+ worktrees: { prefix: shipWorktreePrefixFor(c) ?? await areaWorktreePrefix(c, id), nameOverride: o?.worktreeName }
11089
11153
  }, t.dir)
11090
11154
  };
11091
11155
  }));
@@ -11142,7 +11206,7 @@ ${changed2.map((c) => ` - ${c}
11142
11206
  labels: { ...c.labels, ...o?.labels },
11143
11207
  release: mergeRouteRelease(c.release, o?.release),
11144
11208
  branch: { ...c.branch, ...o?.branch },
11145
- worktrees: { prefix: shipWorktreePrefixFor(c) }
11209
+ worktrees: { prefix: shipWorktreePrefixFor(c), nameOverride: o?.worktreeName }
11146
11210
  }, target.dir);
11147
11211
  const problems = validateEffective(repo);
11148
11212
  for (const p of problems) remit.warn(`${scope}: ${p}`);
@@ -13195,7 +13259,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13195
13259
  // `synthesis-tabl-123`. `worktreePrefix` above is kept for
13196
13260
  // config-provenance display; this is the field that actually
13197
13261
  // 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>`,
13262
+ worktreeDirPattern: r.config.worktrees.name ?? (r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${basename8(r.dir.replace(/[/\\]+$/, ""))}-<branch>`),
13199
13263
  worktreeParent: resolvePath(r.dir, "..")
13200
13264
  })),
13201
13265
  waiting: waiting ? { ticket: waiting.ticket, since: waiting.since, streak: fairness.streak() } : null
@@ -13294,10 +13358,10 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
13294
13358
  );
13295
13359
  for (const r of await resolvedRepos(route)) {
13296
13360
  const { config } = r;
13297
- const worktreePattern = config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename7(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`;
13361
+ const worktreePattern = config.worktrees.name ?? (config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename8(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`);
13298
13362
  process.stdout.write(
13299
13363
  `${`repo ${r.name}:`.padEnd(19)}${r.dir}
13300
- worktrees at ../${worktreePattern} (${config.provenance["worktrees.prefix"] ?? "default"}), branch ${config.branch.name}
13364
+ worktrees at ../${worktreePattern} (${config.provenance["worktrees.name"] ?? config.provenance["worktrees.prefix"] ?? "default"}), branch ${config.branch.name}
13301
13365
  requires ${config.platform} \u2014 ${satisfies(host, config.platform) ? "OK" : "MISMATCH"}
13302
13366
  `
13303
13367
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.25.0",
3
+ "version": "0.26.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",